Compare commits

..
Author SHA1 Message Date
Saoud RizwanandClaude Opus 5 66bacb655c chore(desktop): note the compaction trigger fix in the 0.0.30 changelog
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:03:09 -07:00
Saoud RizwanandClaude Opus 5 6224cc80aa chore(desktop): release v0.0.30
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:03:09 -07:00
BeeandSaoud Rizwan cb0092e343 core: trigger compaction on the provider's actual input-token count (#14195)
* core: trigger compaction on the provider's actual input-token count

Compaction's trigger compared a character-based estimate (~3 chars/token)
against the model's input budget. Dense content -- disassembly, image dumps,
minified sources -- tokenizes far denser than that, so a transcript could
reach the real context ceiling while the estimate stayed under the threshold
and compaction never fired. Affected runs then filled the window and had
their turns squeezed down to a handful of output tokens.

The runtime now records the provider-reported input-token count for each
request and threads it to the prepare-turn pipeline as
previousRequestInputTokens; the trigger uses max(estimate, actual), so real
usage crosses the threshold even when the estimate does not. The estimate is
kept as a floor so the very first oversized turn is still caught before any
usage has been reported.

Also raises the default summarizer output budget from 4096 to 8192: a model
that reasons by default can spend a tight budget on thinking and return no
summary text, which skips compaction entirely.

* core: forward previous request input tokens through the runtime bridge

SessionRuntime.createRuntimePrepareTurn() rebuilds the prepare-turn context
field by field, so previousRequestInputTokens was dropped before reaching the
compaction pipeline. Every production core session therefore fell back to the
character estimate alone and the actual-usage trigger never engaged.

Forward the field alongside overflowRecovery and cover the bridge with a
regression test.

* core: scale the compaction budget by the observed token underestimate

The actual-usage trigger only moved the trigger; maxInputTokens still drove the
retention target off the unscaled estimate, so a compaction started by real
usage could retain too much and overflow again.

Divide maxInputTokens by max(1, actual / estimate) instead. The trigger test is
algebraically identical to comparing actual usage against the unscaled trigger,
while the target, message translation and projection costs now all correspond
to the provider's real limit in consistent estimate units. The factor never
loosens the budget, engages only on direct evidence of under-counting, and is
capped at MAX_INPUT_UNDERESTIMATE_FACTOR so a small estimate cannot collapse it.

* core: move the actual-count compaction test off the trigger boundary

The provider count was set to exactly the 1.05x multiple used for the
budget, which put the scaled trigger within 0.1 token of the estimate;
the test only compacted because of ceil rounding. Use 1.5x so it asserts
the behavior rather than the rounding.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-16 18:57:36 -07:00
0905884ab5 Refresh Cline logo assets (shared, desktop, menubar) (#13965)
* feat(branding): add canonical Cline logo assets

* feat(vscode): refresh extension logo surfaces

* feat(web): update Hub and example branding

* docs: update Mintlify branding assets

* build(brand): lock asset generator tooling

* chore(branding): limit logo refresh to assets and required wiring

* fix(vscode): restore accessible logo names

* fix(branding): retain favicon contrast in dark browser tabs

* [2/3] Refresh desktop logo assets (#13966)

* chore(desktop): refresh native logo assets

* chore(menubar): refresh native logo assets (#13967)

part of logo migration

* docs: use latest adjusted navigation logo

* fix(hub): keep favicon visible in dark browser tabs

Point the Hub favicon at the dedicated favicon.svg and give it the same
prefers-color-scheme fallback as the example app. cline-logo-filled.svg
stays untouched since the sidebar renders it with dark:invert.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-16 18:39:55 -07:00
John Choi d8221325fa chore(ui): prepare next.10 parity release (#14198)
* chore(ui): prepare next.10 parity release

* chore(ui): keep existing package smoke checks unchanged

* test(ui): check packed diff declarations against both peers
2026-09-16 18:12:41 -07:00
charles 0cf47f5851 fix(providers): refresh CoreWeave branding and setup links (#14185) 2026-09-16 17:52:00 -07:00
063ce7c06d feat(telemetry): enable release traces and preserve Langfuse user/session tracking (#13982)
* feat(telemetry): enable OTLP traces exporter in publish builds

Bakes OTEL_TRACES_EXPORTER=otlp into stable/nightly extension and CLI
publish builds. With the client trace pipeline (#13974) this turns on
cline-provider AI SDK trace emission at 100% task sampling, metadata-only
(no prompt/completion content).

Kill switches without a release: remote config openTelemetryTracesExporter
(extension) and the prod otel-collector's probabilistic sampler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(telemetry): record prompt/completion content on traced requests

Sets CLINE_TRACE_RECORD_CONTENT=true alongside the traces exporter, so
cline-provider traces carry full message content (recordInputs/
recordOutputs) instead of metadata only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: assert content-capture env is inlined into the packaged extension

Stable gates pre-publish (right after vsce package); nightly packages
inside publish-nightly.mjs so its check is post-publish detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): gate nightly publication on content flag inlining

* fix(telemetry): cover combined release packaging and verify trace artifacts

* fix(ci): type trace artifact checker and assert missing bundle fixture

* fix(test): use the .exe outfile Bun compile produces on Windows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): propagate Langfuse identity attributes through OTLP

* refactor(telemetry): move Langfuse integration ownership to llms

* refactor(ci): remove brittle trace artifact checks

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: abeatrix <beatrix@cline.bot>
2026-09-16 17:50:22 -07:00
Saoud RizwanandSaoud Rizwan 173837314b fix: stop Windows resolving bare program names through the workspace cwd (#14171)
* feat(shared): add disableCurrentDirectoryExecutableSearch for Windows

libuv resolves a bare program name on Windows by searching the child's
working directory before PATH, gated on the spawning process having
NoDefaultCurrentDirectoryInExePath defined. Cline spawns rg, git and
powershell with the user's workspace as cwd, so a repo shipping an rg.exe
would get it executed at index time. Expose a one-call helper that sets
Microsoft's documented opt-out, plus a Windows-only test that plants a
zero-byte cmd.exe and asserts the real one still runs.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix: stop Windows resolving bare program names through the workspace cwd

Call disableCurrentDirectoryExecutableSearch() at startup in every
process that hosts Cline core: the CLI (and the hub daemon it boots), the
desktop sidecar, the VS Code extension host, and the JetBrains cline-core
process. One environment variable covers every spawn site (file indexer,
search, simple-git, shell executor, hooks, MCP, taskkill) and is inherited
by children, which cmd.exe, libuv and Bun 1.4+ honor as well.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix: hand user-facing children the inherited NoDefaultCurrentDirectoryInExePath state

libuv reads the opt-out from the spawning process, so Cline's own protection
does not depend on children carrying it. But cmd.exe, Go, Bun 1.4 and
libuv-based children honor it as well, so letting them inherit Cline's
setting would silently change how a user's own bare program names resolve
(npm scripts running a cwd-local .bat through cmd.exe, MCP servers named
relative to cwd, hooks that spawn helpers).

disableCurrentDirectoryExecutableSearch() now latches the value the process
inherited, and withInheritedExecutableSearch() restores that state on the
child env at the spawn sites that run user-authored programs: the shell
executor, MCP stdio servers, hook subprocesses, the plugin subprocess
sandbox, and the VS Code host's HookProcess.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Revert "fix: hand user-facing children the inherited NoDefaultCurrentDirectoryInExePath state"

This reverts commit b643517257.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-16 17:30:11 -07:00
Mikołaj Kondratek 3871851962 fix(desktop): stop sidecar holders with Restart Manager in the Windows installer (#14175)
* fix(desktop): stop sidecar holders with Restart Manager in the Windows installer

The hook added for the "Error opening file for writing" update failure
never terminated anything. Tauri builds an x86 NSIS installer, so its
nsExec launches the WOW64 32-bit powershell.exe, and from there
Get-Process returns an empty Path for every 64-bit process. The
Where-Object filter on $_.Path therefore matched no code-sidecar.exe on
any x64 machine, Stop-Process received nothing, and the installer went on
to write over the exe the detached Hub daemon still held. Updates from
0.0.25 through 0.0.28 failed exactly as 0.0.24 did.

Replace the process query with the Windows Restart Manager: register the
installed cline-app.exe and code-sidecar.exe, ask which processes hold
them, and force-shut those down. This is bitness-independent, scopes to
this install's files without matching on names or paths (a side-by-side
Cline Beta hosting the shared Hub is left alone because it holds a
different file), and covers every holder, including connector children
the Hub spawns from the same exe. Registering the main binary as well
stops a still-running desktop from respawning the sidecar mid-install.

If Restart Manager itself fails, print the error, offer Retry/Cancel
(the updater runs the installer passive, so the user who clicked
"Restart now" is watching), and abort rather than continue into a
half-replaced install. Silent installs take the Cancel default.

* refactor(desktop): flatten the installer hook's error handling with goto

Review nit: jump to end_session / failed / done labels instead of nesting
each Restart Manager step one level deeper. Same control flow and register
discipline; every exit path still ends an opened session and pops $0-$4.
2026-09-17 01:46:44 +02:00
John Choi 025baa8fb7 feat(desktop): reconcile cloud session snapshots (#14080)
* feat(desktop): isolate cloud Hub snapshot reconciliation

* fix(desktop): reconcile reflected attachment-only cloud prompts

* fix(desktop): preserve unreflected cloud tool and reasoning events

* fix(desktop): preserve unfinished aborted cloud output

* fix(desktop): retain interrupted output after saved replies

* fix(desktop): retain output completed after cloud snapshot

* fix(desktop): exclude tool output from empty prompt matches
2026-09-16 13:47:56 -07:00
John Choi d6d4566451 feat(desktop): add cloud session lifecycle (#13856)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* feat(desktop): add cloud session foundations

* fix(desktop): require boolean cloud rollout flag

* test(desktop): run sidecar suite in pull requests

* ci: test desktop changes in stacked pull requests

* fix(desktop): scope cloud model catalog rollout

* fix(sdk): clear feature flags on identity change

* test(hub): reconnect approval session owner

* feat(desktop): add cloud session REST client

* feat(desktop): add cloud transcript reconciliation

* feat(desktop): add cloud session lifecycle

* test(desktop): cover cloud session REST client

* test(desktop): cover cloud session lifecycle

* test(desktop): keep lifecycle fixture layer-local

* docs(sdk): clarify remote Hub connection headers

* docs(hub): describe approval recovery

* docs(llms): describe cloud catalog opt-in

* test(sdk): tighten hub header coverage

* test(hub): remove redundant approval setup

* chore(desktop): trim cloud foundation scaffolding

* test(desktop): trim cloud REST coverage

* test(desktop): consolidate cloud API cases

* test(desktop): remove unused lifecycle fixtures

* test(desktop): complete lifecycle context fixture

* fix(hub): preserve approval recovery for existing clients

* test(core): batch root history fixture inserts

* test(desktop): align cloud fixtures with stream context

* test(sdk): await daemon health after discovery publication

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* test(hub): reconnect approval session owner

* docs(hub): describe approval recovery

* test(hub): remove redundant approval setup

* fix(hub): preserve approval recovery for existing clients

* refactor(hub): drop cloud-only pending approval API

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* refactor(desktop): defer cloud interaction helpers to their owning layers

* fix(desktop): preserve cached flags and share account auth

* fix(desktop): return cloud session identity before provisioning completes

* test(desktop): simplify cloud create recovery fixtures

* fix(desktop): reject invalid cloud history snapshots

* fix(desktop): refresh expired cloud sessions without a Hub connection

* refactor(desktop): rely on backend GitHub token retries

* refactor(desktop): require the cloud status API

* fix(desktop): reconcile failed cloud session status

* fix(desktop): use conversation activity for failed cloud history
2026-09-16 12:05:14 -07:00
Bee 964a0fc36d fix(desktop): keep streamed tool output and stop the spinner when a tool ends without a final output (#14188)
Fixed issue: when a tool streams its output through chat_tool_call_update and the runtime's chat_tool_call_end event carries no output field (the tool already streamed everything), the desktop chat hook rebuilt the tool message payload with result: null. buildToolPresentation() reads a null result with no error as "still running", so a finished tool went back to a running/spinning presentation for the rest of the turn.
2026-09-16 10:27:41 -07:00
Saoud RizwanandClaude Opus 5 fb1d838085 chore(desktop): release v0.0.29
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:01:03 -07:00
Saoud RizwanandSaoud Rizwan 99db7582b0 desktop: show target mode icon on sidebar sort toggle (#14189)
The sidebar time/project toggle showed the icon for the mode that was
already active. Swap it so the icon reflects the mode a click switches to:
folder icon while sorted by time, clock icon while grouped by project.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-16 09:56:03 -07:00
BeeandSaoud Rizwan 15f001ad0b fix(desktop): steer queued prompts with Enter CLINE-3285 (#13958)
* feat(cli): add Enter key steering for empty input in queue

Allow empty input with Enter to promote the first queued prompt, enhancing navigation efficiency. Update hint text and add tests for key routing behavior.

* fix(desktop): bound queue steering waits and reject stale replies

* fix: steer the current queue head atomically in core

* ui update

* fix(core): type session ID in hub UI event test mock

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-16 00:45:20 -07:00
Mikołaj Kondratek 186ac22e29 telemetry: fire ui.panel_opened from initializeWebview on every host (#14101)
* telemetry: fire ui.panel_opened from initializeWebview on every host

ui.panel_opened was only captured from VscodeWebviewProvider, so the
JetBrains plugin never emitted it: the activation funnel there jumps from
user.extension_activated (core spawned on project open) straight to task
events, with no signal that the Cline UI was ever rendered.

The webview calls the initializeWebview RPC once on mount on every host,
so capture the same event there with source "webview_initialized".
Existing dashboards keyed on ui.panel_opened pick up JetBrains for free;
VS Code gains one extra source value on an event it already emits.

* telemetry: type the ui.panel_opened source

A PanelOpenedSource union on capturePanelOpened keeps the documented
source list and the call sites from drifting apart. Also note that on
JetBrains webview_initialized fires on every webview reload, so a
crash-restart loop emits one per restart.
2026-09-16 09:25:15 +02:00
fc68af9660 fix(desktop): add standard macOS margin to the Dock icon (#14155)
* fix(desktop): add standard macOS margin to the Dock icon

The bundled icon.icns and the selectable runtime Dock icons were
edge-to-edge, so Cline rendered larger than neighbouring apps in the
Dock. Regenerate icon.icns from the source artwork scaled to 824/1024
of the canvas, and add padded copies of the runtime icons under
icons/app/macos/ that set_app_icon uses on macOS. Windows keeps the
existing edge-to-edge .ico and icons/app PNGs.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): refresh icon artwork and platform exports

* fix(desktop): limit artwork updates to macOS icons

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Haley Park <haleypark.design@gmail.com>
2026-09-16 00:17:46 -07:00
Mikołaj Kondratek b675eb6189 telemetry: report host-provided core spawn ordinal and reason as metadata (#14104)
* telemetry: report host-provided core spawn ordinal and reason as metadata

Out-of-process hosts spawn cline-core and sometimes respawn it: after a
crash, on a rollout fallback or demotion, or when the user restarts the
agent. From core's side every spawn is just another
user.extension_activated, so a core that crash-restarted three times and a
user with three project windows open are indistinguishable in telemetry.

Read CLINE_CORE_SPAWN_ORDINAL and CLINE_CORE_SPAWN_REASON from the spawn
env, the same contract as IS_DEV and CLINE_ROLLOUT_VARIANT, and attach
them as core_spawn_ordinal / core_spawn_reason metadata on both telemetry
pipelines (classic TelemetryService and the SDK handle), so they land on
every event. Malformed values are dropped field by field; in-process hosts
set nothing and get no fields, like host_plugin_version on the CLI.

The JetBrains plugin sets the env in a companion change.

* telemetry: type core_spawn_reason as the shared CoreSpawnReason union

Define CORE_SPAWN_REASONS / CoreSpawnReason once in @cline/shared next to
TelemetryMetadata and use the union in every metadata contract (shared,
classic TelemetryService, vitest stub) and in the env parser, so a
programmatic setMetadata/updateMetadata cannot introduce a reason the
parser's allowlist would have rejected.

* telemetry: derive the classic metadata's spawn fields from CoreSpawnTelemetryMetadata

Index into the parser's type instead of redeclaring both fields and their
doc comment, mirroring how extension_variant defers to
RolloutTelemetryMetadata. Also fix import order flagged by biome check.
2026-09-16 15:09:57 +09:00
Bee 82b8e1fa04 chore: Refine provider model catalog filtering (#13368)
* refactor model catalog filtering

* fix hub model compatibility filtering

* filter routine fallback models

* docs: correct image-output filter comments to match mergeKnownModels behavior (#13388)

* chore: revert unrelated merge-hook formatting
2026-09-15 11:57:30 -07:00
Saoud Rizwan 6e8bea1cba docs(changelog): drop the packaging note from the 4.1.18 entry
Whether 4.1.18 ships as the combined package or the standalone SDK build is
a release-mechanics detail that may change again; keep the entry to what
users actually get.
2026-09-15 02:01:52 -07:00
Saoud Rizwan 8739d4953c docs(changelog): describe 4.1.18 as a combined-package release
The 4.1.18 entry claimed the package was now SDK-only and smaller. The
standalone publish path is timing out against the Marketplace, so this
version ships through the combined A/B workflow instead; state that
accurately, since the entry is pasted verbatim into the release and Slack.
2026-09-15 02:00:14 -07:00
Saoud Rizwan c17c82b79a chore(desktop): release v0.0.28 2026-09-15 00:34:19 -07:00
Saoud Rizwan cb0b20f791 docs(skills): rewrite publish-extension for the completed SDK cutover
The ext-sdk-bundle-rollout flag reached 100%, so stable releases now ship a
standalone build of main through ext-vscode-publish-stable.yml rather than the
combined legacy+next A/B VSIX. Rewrite the skill around that path and record
what is left to retire.

Also corrects two things the old text got wrong: nightly is manual-dispatch
only (the cron was removed because PublishNightly gained required reviewers,
so unattended runs sat waiting and starved their successors), and the stable
workflow tags before it builds, so a tag-push failure fails early rather than
leaving a published-but-untagged release.
2026-09-14 23:56:07 -07:00
Saoud Rizwan 2198414245 chore(vscode): release v4.1.18 2026-09-14 23:56:01 -07:00
Saoud Rizwan d718dd16f8 fix(ui): derive diff options from the FileDiff component, not FileDiffProps
@pierre/diffs 1.4 added a second required type parameter to FileDiffProps
while keeping defaults on the FileDiff component itself, so FileDiffProps<undefined>
fails with TS2314 under any 1.4.x. The package is declared as ^1.3.0, and the
release pipeline deletes bun.lock and re-resolves, so CI built against 1.4.2
while the committed lockfile pinned 1.3.6 locally.

Deriving the options type from ComponentProps<typeof FileDiff> compiles against
both 1.3.x and 1.4.x, and stops the emitted .d.ts from re-exporting a peer
dependency type whose arity changes between minors.
2026-09-14 22:37:16 -07:00
Saoud Rizwan 128ec277d5 test(llms): decouple Bedrock geo-profile cases from the generated catalog
deepseek.r1-v1:0 was retired from the Bedrock catalog upstream, so the two
cases asserting a us. prefix for it started failing once the release regen
picked up the new catalog. The resolver is correct: it only prefixes a geo
profile when the catalog confirms that variant exists. Inject hasCatalogModel
in both cases so they state their catalog premise explicitly, matching the
isolation added for the fallback cases in #14017.
2026-09-14 22:12:18 -07:00
Saoud Rizwan 18477898e0 chore(cli): release v3.0.62 2026-09-14 21:36:49 -07:00
Saoud Rizwan 9f19f047d2 chore(sdk): release v0.0.83 2026-09-14 21:36:41 -07:00
Saoud RizwanandSaoud Rizwan e21b5903c2 fix(llms): default Cline Pass to a subscribed-tier model (#14141)
firstGeneratedModelId took the first entry of the cline-pass catalog, which
is release-date ordered and mixes cline-pass/*, cline-free/* and :free
models, so the default drifted to whichever free model shipped most
recently. Restrict the default to cline-pass/* ids, falling back to the
previous behavior when the catalog has none, and add a regression test
that asserts the tier rather than a specific model id.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 21:04:06 -07:00
Bee 17529c8cb1 feat(core): add SSH remote environments (#14116)
* feat(core): add SSH remote environments

* fix(core): harden SSH lifecycle and helper package exports

* fix(core): use current SSH identity for pending cleanup

* docs(core): clarify SSH destination invariants during cleanup

* fix(core): recover SSH cleanup after remote Hub crashes

* test(core): make SSH regression coverage portable on Windows

* fix(core): leave account connectors untouched by SSH Hubs

* fix(core): restore missing SSH helpers for pending cleanup
2026-09-14 19:58:02 -07:00
Dominic Cooney 36905f11cc fix(core): refine PowerShell shell guidance (#14055) 2026-09-15 11:11:29 +09:00
BeeandClaude Fable 5.1 722b640c28 feat(agents): retry transient provider errors and verify execution in yolo mode (#14125)
* refactor(core): improve yolo mode

update submit_and_exit tool and yolo mode prompt

* feat(agents): retry transient provider errors before failing a run

A model turn that fails with a transient, provider-returned error is now
re-issued up to 3 times with exponential backoff (1s/2s/4s, capped at 15s)
before the error ends the run. Previously a single OpenRouter
"Provider returned error" (typically a forwarded 429) aborted the whole run
with exit 1, which cost the agent most of its runs against rate-limited
models.

Retryability is decided from the AI SDK's own typed signal rather than
message matching:
- isRetryableProviderError prefers APICallError.isRetryable, unwraps
  RetryError, and walks AISDKError.cause; for non-typed errors it falls
  back to the HTTP status, and finally to the single documented
  "Provider returned error" provider quirk.
- Because the agent loop only sees a flattened error string, the flag is
  computed at captureStreamError (where the structured error is still in
  hand) and threaded through a new errorRetryable field on the finish
  event, mirroring the existing errorClass path.

Non-retryable failures (auth, context-window overflow, other 4xx) and turns
that already produced tool calls are never retried, so a turn that would
otherwise succeed is unchanged. The backoff is abort-safe.

* llms: increase max retries limit to 5

Pass maxRetries=5 to AI SDK model calls (the SDK default is 2). The SDK
retries the initial request on 429/5xx/network failures with exponential
backoff that honors retry-after headers. Errors a provider emits mid-stream
(OpenRouter's "Provider returned error" after a 200) never reach this layer,
so the agent loop keeps its own turn-level retry; the two are complementary.

* fix(llms): judge RetryError retryability by its final attempt only

When the final error inside an AI SDK RetryError was not a typed instance,
isRetryableProviderError fell back to a structural walk over the whole
wrapper, including the earlier attempts the SDK had already retried away.
An earlier 429 could therefore make a final plain 400, or a statusless
transport failure, look retryable.

The structural fallback is now a standalone helper and, for a RetryError, runs
on the final attempt alone. The outer fallback for non-wrapped errors is
unchanged.

* fix(agents): only retry provider errors when the attempt left nothing behind

Tighten the transient-provider-error retry so a re-issued request can never
duplicate or repeat what the failed attempt already did:

- Do not retry once the attempt streamed any content (text, reasoning, media,
  or local tool calls). Those deltas were already emitted and there is no
  event to retract them, so a second stream would show the output twice.
  Previously only local tool calls blocked the retry.
- Do not retry once the attempt recorded provider-executed tool activity.
  That activity lives in message metadata rather than content, so the old
  content-only check missed it and a retry could run the side effects again.
- Reset lastError, lastErrorClass, lastErrorRetryable, and lastErrorReported
  at the start of every turn and before every provider-error retry. The
  AgentModel contract allows a finish event with reason "error" and no error
  payload; such an event previously inherited the class and retryability of
  an earlier attempt. Overflow recovery's own inner request is left alone,
  since its "nothing to compact" error reports the first attempt's message.

* fix(llms): keep request-start retries in one layer

The turn-level retry unwrapped the AI SDK's RetryError and, when its final
attempt looked transient, re-ran the turn. The SDK had already spent its
request-start retries with retry-after-aware backoff, so the two counts
multiplied: up to 6 SDK attempts times 4 turn attempts for one persistent 429.

Decide turn-level retryability with a dedicated helper that treats a
RetryError as terminal and otherwise defers to isRetryableProviderError.
Each failure class now has exactly one retrying layer: request-start failures
belong to the SDK's maxRetries; pre-output socket deaths and empty responses
to withEmptyResponseRetry, whose first doStream runs outside its retry loop
so it never re-runs request-start rejections; and mid-stream provider errors,
which the SDK never retries, to the turn-level retry alone. Document that
ownership next to MODEL_REQUEST_MAX_RETRIES.

* fix(llms): guard the RetryError check in isRetryableBeyondSdkRetries

`RetryError.isInstance` throws when the "ai" module is only partially
available, which is the case in test files that mock it with a subset of
exports. Wrap the check like the other typed checks in this file so the
classifier falls through instead of crashing the stream error handler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(llms): expect errorRetryable on error finish events

Error finish events now carry `errorRetryable` alongside `errorClass`.
Update the exact-shape assertions in gateway.test.ts to include it; every
covered case is a non-retryable failure, so the expected value is false.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:36:37 +02:00
Saoud Rizwan 067b865f97 fix(cli): improve desktop migration notice readability 2026-09-14 17:31:20 -07:00
Saoud RizwanandSaoud Rizwan c925a9c94c fix(readme): point desktop app download at cline.bot/desktop (#14133)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 17:20:54 -07:00
Saoud RizwanandClaude Opus 5 899e8f3da4 chore(cli): drop "(beta)" from the Cline Desktop notice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uun2bwaGxA8mPL17b6jG3u
2026-09-14 16:14:06 -07:00
Saoud RizwanandSaoud Rizwan e10183f2a8 Add Cline Desktop launch notice to the CLI (#14123)
* Add Cline Desktop launch CTA to extension home banner and CLI startup notice

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Show Cline Desktop CTA on all platforms

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Drop hardcoded extension desktop banner in favor of remote banner

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 16:11:36 -07:00
Saoud RizwanandSaoud Rizwan 00daef8f89 desktop: fix model name flashing when the composer picker opens (#14118)
* desktop: only refresh the live model list when the picker opens

Opening the composer model picker re-ran the whole load effect, which
first replaced providerModels/modelDetails with the bundled catalog and
then restored the live list once loadProviderModels resolved. For a
frame in between the trigger resolved against the bundled catalog,
flashing the raw model id (or a stale name) before snapping back.

Refresh only the active provider's live list on open, via a shared
applyProviderModels helper that the load effect and the
subscribeToProviderModels listener already duplicated.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* desktop: mark reasoning capability as catalog-backed after a picker refresh

The load effect flips reasoningCapabilitySource to "catalog" once live
models land; the picker-open refresh path should too, so a session that
started offline (source stuck at "fallback") trusts the live reasoning
data once a later refresh succeeds.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 15:56:14 -07:00
Saoud RizwanandSaoud Rizwan 94980446c9 fix(hub): stop forwarding per-chunk stream events to remote onEvent hooks (#14120)
The hub proxies every AgentRuntimeEvent to a client-contributed onEvent
hook as a capability round trip carrying the full session snapshot, and
the agent loop awaits it. For a streaming model that meant one ~200-300 KB
serialization, a persisted capability.requested row, four hub log lines,
and a blocking IPC hop per token (#14091).

Skip assistant-text-delta, assistant-reasoning-delta, and tool-updated in
the hook proxy; no client consumes them through a remote hook. Every other
event still reaches the hook unchanged.

Also set synchronous=NORMAL on the hub event log so the remaining
per-chunk delta rows stop costing an fsync each; WAL still syncs at
checkpoints and the log survives a process crash for reconnect replay.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 13:25:18 -07:00
Saoud RizwanandSaoud Rizwan c2ac5feecb fix(desktop): keep retrying the backend connection when the sidecar is slow to start (#14119)
* fix(desktop): keep retrying the backend connection when the sidecar is slow to start

The webview asked the Tauri shell for the sidecar endpoint exactly once. When
the sidecar took longer than the shell's 15s poll (hub startup lock plus hub
daemon boot can exceed that on a slow Windows machine), the command returned
"desktop backend endpoint not ready" and the UI parked on "Desktop backend
unavailable" until the app was relaunched, where the same race repeated.

Schedule a reconnect with backoff after any failed connect, and drop the
cached endpoint on each attempt so a sidecar the shell respawned (which
issues a new approval token) is dialed with its current endpoint.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): keep the existing flat reconnect delay

Drop the consecutive-attempt backoff so the reconnect-after-drop path stays
identical to before apart from re-resolving the endpoint.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 13:24:51 -07:00
Saoud RizwanandSaoud Rizwan 97afeaccf0 fix(desktop): don't submit composer on IME composition Enter (#14114)
On macOS with a Chinese/Japanese IME, the Enter that commits the current
composition also reached the composer's Enter-to-submit handler and sent
the message. Skip keydown handling while a composition is in progress
(isComposing, or WebKit's post-compositionend Enter with keyCode 229) so
Enter and arrow keys are left to the IME.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 12:15:15 -07:00
Saoud RizwanandSaoud Rizwan b4b980d9a1 desktop: refresh the composer model list when the picker opens (#14111)
The composer's ModelSelector loaded a provider's model list once, on
mount and on provider change, so the Recommended/Free tiers stamped by
the SDK feed stayed frozen until an app restart. The CLI and extension
refresh on picker open; do the same here via a new SearchCombobox
onOpen hook. The sidecar caches the feed and catalog, so repeat opens
are cheap.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 11:41:52 -07:00
Saoud RizwanandSaoud Rizwan 9de01af92d desktop: ClinePass and free model value prop in onboarding (#14107)
Sign in with Cline card now lists the three benefits (free model
promotions, ClinePass for generous usage across open weights models, no
API key needed). After a Cline sign-in the done step shows the current
free models from list_cline_recommended_models and a Get ClinePass link.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 10:35:14 -07:00
John Choi d896a0ac84 feat(desktop): add cloud session REST client (#13855)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* feat(desktop): add cloud session foundations

* fix(desktop): require boolean cloud rollout flag

* test(desktop): run sidecar suite in pull requests

* ci: test desktop changes in stacked pull requests

* fix(desktop): scope cloud model catalog rollout

* fix(sdk): clear feature flags on identity change

* test(hub): reconnect approval session owner

* feat(desktop): add cloud session REST client

* test(desktop): cover cloud session REST client

* docs(sdk): clarify remote Hub connection headers

* docs(hub): describe approval recovery

* docs(llms): describe cloud catalog opt-in

* test(sdk): tighten hub header coverage

* test(hub): remove redundant approval setup

* chore(desktop): trim cloud foundation scaffolding

* test(desktop): trim cloud REST coverage

* test(desktop): consolidate cloud API cases

* fix(hub): preserve approval recovery for existing clients

* test(core): batch root history fixture inserts

* test(sdk): await daemon health after discovery publication

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* test(hub): reconnect approval session owner

* docs(hub): describe approval recovery

* test(hub): remove redundant approval setup

* fix(hub): preserve approval recovery for existing clients

* refactor(hub): drop cloud-only pending approval API

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* fix(desktop): preserve cached flags and share account auth

* fix(desktop): return cloud session identity before provisioning completes

* test(desktop): simplify cloud create recovery fixtures

* fix(desktop): reject invalid cloud history snapshots
2026-09-14 09:45:53 -07:00
Etisha GargandRenee Huang f80dd1eeff Cline desktop page (#14102)
* docs: cline desktop page

* docs: move cline desktop page to usage section

- Relocate page from getting-started/ to usage/ and move nav entry to top of Usage group
- Remove Plan/Act mode bullet (not supported in Cline Desktop)

---------

Co-authored-by: Renee Huang <renee@cline.bot>
2026-09-14 08:02:57 -07:00
Saoud Rizwan 19ddebb3b9 Update terminology from MCP servers to MCPs 2026-09-13 20:44:53 -07:00
Saoud RizwanandClaude Opus 5 c1c0b55ca0 chore(desktop): release v0.0.27
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HicVJBCVgTR79A4Lnu2XoQ
2026-09-13 15:03:36 -07:00
Saoud RizwanandSaoud Rizwan ccb62e6b39 desktop: one actionable error for Cline auth failures, and a working Account page for dead tokens (#14089)
* desktop: show one actionable bubble for a credential failure

When a turn fails before the runtime takes the prompt (e.g. the Cline OAuth
refresh throws), the hub's run.failed reaches the webview as a detail-less
chat_done, then the send RPC resolves with the actual error. The second
report was deduped as a bubble but still overwrote the hook's error state,
so the chat rendered the detail-less bubble plus a banner with the detailed
copy underneath it.

appendTurnFailureMessage now tracks the bubble shown for the current turn:
a later, detailed report upgrades it in place and the error state follows
the bubble, so the failure renders exactly once.

Credential failures also carry a fix action in the bubble: Cline goes to
Settings -> Account (Settings -> Models keeps reporting a stale token as
signed in), other providers open Settings -> Models, and local-auth
providers keep pointing at their CLI.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* desktop: treat a rejected Cline refresh token as signed out

resolveFreshClineAuthToken fell back to the persisted access token even when
the refresh failed with OAuthReauthRequiredError. That token is dead too, so
the account request failed with a 401 and the Account page showed an error
card whose Retry failed the same way, instead of the sign-in prompt.

Return no token for a rejected refresh so cline_account reports the typed
not-authenticated result. Transient refresh failures still fall back.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* desktop: give a session that fails to start over credentials the same guidance

A fresh session applies the OAuth credentials in start, so a rejected Cline
refresh surfaced there as the raw runtime message with no hint or action.
Route start failures inside sendPrompt through the shared credential check
so they get the hint and the Sign in to Cline action too, and shorten the
Cline hint so it does not repeat the runtime's own wording.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* desktop: key the shown failure bubble on user bubbles, not the turn epoch

A retry submitted while the failed send is still settling is forced onto
the queue path, which bumps the turn epoch without adding a user bubble.
The late RPC report then missed the bubble already on screen and appended
a second copy. Track user bubbles appended to the live transcript instead:
a failure bubble stays the trailing one for its turn until the next user
bubble lands, which is what the old trailing-error check measured.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-13 14:37:26 -07:00
Saoud RizwanandSaoud Rizwan 81abe4978c Rename settings sidebar "Models" to "API Providers" and swap its icon (#14088)
* Rename settings sidebar Models section to API Providers and swap its icon

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Update code comments that still referenced Settings → Models

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-13 14:10:06 -07:00
Saoud RizwanandSaoud Rizwan ea7c7f11a9 fix(desktop): make Cline and Cline Pass sign-out stick (#14090)
* fix(desktop): make Cline and Cline Pass sign-out stick

Signing out of the Cline provider from the Providers page removes its
providers.json entry, but the legacy import re-adds it from the classic
extension's secrets.json (cline:clineAccountId / clineApiKey) on the next
sidecar command, so the user appears signed back in. Same root cause as
the ChatGPT/Codex sign-out fix (#14040), which only cleared Codex secrets.

Signing out of Cline Pass did nothing at all: its credentials are stored
under the "cline" provider (storageProviderId), so deleting only the
cline-pass entry left the account signed in.

Generalize the legacy-secret clearing helper to a per-provider key map
and, when a provider with a different storage provider is disabled, also
remove that storage provider's entry.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* test(desktop): cover Cline Pass sign-out cascading to the shared cline entry

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-13 14:09:26 -07:00
Emmanuel Acheampong cfe9cadab9 feat: add Crusoe as an OpenAI-compatible provider (#12267) 2026-09-11 16:47:11 -07:00
Haley Park e7d224dec6 feat(ui): share native Switch across desktop settings (#14073)
* feat(ui): add shared native Switch

* refactor(desktop): adopt shared Switch

* ci(ui): run desktop settings integration tests
2026-09-11 16:18:15 -07:00
632fce5071 feat(llms): sampled cline-provider AI SDK tracing via the host OTLP exporter (#13974)
* fix(llms): limit Langfuse telemetry to Cline provider

Refs ENG-2505

* fix(llms): include ClinePass in Langfuse telemetry

Refs ENG-2505

* feat(llms): sampled cline-provider AI SDK tracing via the host OTLP exporter

Enables the collector-relay tracing path: when the host telemetry service
has registered an OTLP tracer provider, cline-provider streams can emit
AI SDK spans without Langfuse credentials in the process.

- CLINE_TRACE_SAMPLE_PERCENT (default 0 = off) gates emission; sampling
  is a deterministic FNV-1a hash of the task/session id, so whole tasks
  sample together and retries decide identically.
- Metadata-only by default (recordInputs/recordOutputs false): models,
  tokens, timings, errors — no prompt or completion content. Content
  requires CLINE_TRACE_RECORD_CONTENT=true explicitly.
- The direct Langfuse path (env credentials, hub/internal) is unchanged:
  full content, every request, provider-gated as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(telemetry): client OTLP trace pipeline for cline-provider AI SDK spans

Extension side of the Langfuse collector relay:

- OpenTelemetryClientProvider gains a TracerProvider: when tracesExporter
  is configured, spans batch-export over OTLP (grpc / http/json /
  http/protobuf) to the same collector endpoint as logs/metrics.
  register() also installs the async context manager span parenting needs.
- tracesExporter plumbed through all three config sources: build constants
  (OTEL_TRACES_EXPORTER), runtime env (CLINE_OTEL_TRACES_EXPORTER), and
  remote config (openTelemetryTracesExporter) — the last is the no-release
  kill switch.
- SDK: a host that registers a traces exporter now defaults to 100% task
  sampling; CLINE_TRACE_SAMPLE_PERCENT reduces it (0 disables). Env reads
  are literal so build-time inlining works. Metadata-only stays the
  default; content still requires CLINE_TRACE_RECORD_CONTENT=true.
- CLI build inlines the new envs alongside the existing OTEL set.

No publish workflow sets OTEL_TRACES_EXPORTER yet, so shipped builds keep
tracing dark — activation is a separate one-line-per-workflow PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(llms): honor the global telemetry opt-out on the trace relay path

AI SDK spans bypass the ITelemetryService wrapper that enforces the
user's opt-out for events and metrics, so an opted-out user's prompts
would still have traced once a host registered an OTLP exporter.
resolveAiSdkTelemetry now re-checks the shared global settings file
(telemetryOptOut) per stream — covering startup state and mid-session
opt-outs across extension and CLI. The credentialed direct Langfuse
path is unchanged: it only activates on explicit operator config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: biome format

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(llms): one trace export path per host — direct Langfuse declines when an OTLP tracer is registered

Enablement is a per-stream boolean but export fan-out is processor-level:
attaching the direct LangfuseSpanProcessor to a host-registered tracer
provider ships every span twice (direct + collector relay). The direct
path now declines instead of cooperating when a recording provider
already owns the global slot; it only exports when it registers its own
provider. A host therefore configures exactly one of LANGFUSE_*
credentials or an OTLP traces exporter, and misconfiguring both is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(hub): daemon-distinct trace identity for Langfuse hub coverage (#14034)

The detached hub daemon already has everything needed to emit AI SDK
traces: it ships in the same compiled binary as the CLI (so the publish
build's OTEL_TRACES_EXPORTER / CLINE_TRACE_RECORD_CONTENT inlining
reaches it), its telemetry handle flows into the hub server and the
scheduled-run handlers, core's OpenTelemetryProvider registers a global
tracer when the exporter is configured, and shutdown already flushes
with a hard deadline.

What was missing is identity: spans carry only the OTel resource, not
per-event metadata, so daemon-emitted traces were indistinguishable
from CLI-local ones (both service.name=cline). The daemon now declares
serviceName=cline-hub-daemon (+ serviceVersion), via new optional
serviceName/serviceVersion fields on the shared OpenTelemetryClientConfig.

Opt-out holds for the daemon unchanged: it is a same-machine, same-user
process, so the shared global settings file the per-stream gate reads is
the requesting user's own (shared/cloud topologies tracked in ENG-2525).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(llms): fail closed when the telemetry opt-out cannot be verified

The relay's opt-out guard treated every settings read failure as 'no
opt-out recorded', so a corrupted or unreadable settings file (including
a torn read of the non-atomic writer) silently re-enabled tracing for a
user who had opted out. Only a genuinely absent file (ENOENT, first run)
now reads as opted in; malformed JSON and every other read failure
disable the relay — consent that cannot be verified is not consent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(llms): identify the OTLP relay explicitly instead of inferring it from any recording tracer

'Some recording tracer exists' misclassified console-only tracing as the
collector relay (enabling the relay decision) and — for mutable
providers — newly suppressed the direct Langfuse attach that worked
before the one-path guard. The hosts that build tracer providers now
stamp a relay marker only when a real OTLP span processor was created
(console-only providers stay unmarked), and the trace decisions key off
that marker:

- relay path enables only behind a marked provider;
- the one-path decline fires only for marked providers — unmarked
  mutable providers keep the original cooperative attach behavior;
- a relay decline is no longer cached, so direct Langfuse gets to retry
  once a relay is disposed.

The marker lives on the provider instance (reached through the OTel API
global), which survives bundled module duplication where a module-level
registry would not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: organize imports

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): own and dispose remote trace clients across config updates

* fix(llms): wire per-call Langfuse telemetry and isolate direct export

* refactor(telemetry): reject unsupported providers before initialization

* test(desktop): raise sidecar vitest timeout above the module-graph import cost

The first test in each sidecar file pays the @cline/core → @cline/llms
import cost, which lands within ~100ms of the 5s default on CI runners
(commands-account failed at 5009ms; context.test.ts passed at 4923ms).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: abeatrix <beatrix@cline.bot>
2026-09-11 15:13:54 -07:00
John ChoiandSaoud Rizwan e15bb0a7e5 feat(desktop): add cloud session foundations (#13558)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* feat(desktop): add cloud session foundations

* fix(desktop): require boolean cloud rollout flag

* test(desktop): run sidecar suite in pull requests

* ci: test desktop changes in stacked pull requests

* fix(desktop): scope cloud model catalog rollout

* fix(sdk): clear feature flags on identity change

* test(hub): reconnect approval session owner

* docs(sdk): clarify remote Hub connection headers

* docs(hub): describe approval recovery

* docs(llms): describe cloud catalog opt-in

* test(sdk): tighten hub header coverage

* test(hub): remove redundant approval setup

* chore(desktop): trim cloud foundation scaffolding

* fix(hub): preserve approval recovery for existing clients

* test(core): batch root history fixture inserts

* test(sdk): await daemon health after discovery publication

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* test(hub): reconnect approval session owner

* docs(hub): describe approval recovery

* test(hub): remove redundant approval setup

* fix(hub): preserve approval recovery for existing clients

* refactor(hub): drop cloud-only pending approval API

* chore(desktop): trim redundant cloud session comments

* fix(desktop): preserve cached flags and share account auth

* fix(llms): retain cloud model names after catalog merge

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-11 13:49:57 -07:00
Bee efa9a17caf fix(desktop): show voice input errors without opening settings (#14071) 2026-09-11 13:20:13 -07:00
Bee ac082be7c5 fix(llms): route OpenCode Go models and send session headers (#14048) 2026-09-11 12:21:20 -07:00
Haley Park 7e20bbc0e0 fix(ui): align welcome hero eye color in dark mode (#14043)
* fix(ui): align welcome hero eye color in dark mode

* fix(ui): avoid layout updates during welcome eye animation
2026-09-11 10:26:30 -07:00
Saoud RizwanandClaude Opus 5 a7c1dfc298 chore(desktop): release v0.0.26
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qj6N74NgMeXSWn8qg7S4tw
2026-09-11 00:27:15 -07:00
Saoud RizwanandClaude Fable 5.1 23924da84f feat(desktop): list Cline Pass with Cline and add a set-up row to the composer provider picker (#14058)
* revert(desktop): drop the configured indicator from the composer provider picker

This reverts commit 3033c394c7.

The picker's rows come from `enabledProviderIds`, which the sidecar
derives from `Boolean(directSettings)` — any persisted providers.json
entry — so for a user who configured their providers in the app, every
row also satisfies `isProviderConnected` and gets the same green check.
A badge on every row is decoration, not information.

The two sets only diverge on entries with no usable credentials: ones
seeded by the legacy VS Code migration, empty saves, and sign-outs that
leave the entry behind. That is worth surfacing, but Settings already
does it per provider, with the affordance to fix it — which the composer
does not have.

Reverts the `indicator` prop on SearchCombobox too; the picker was its
only consumer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qj6N74NgMeXSWn8qg7S4tw

* feat(desktop): list Cline Pass with Cline and add a set-up row to the composer provider picker

The composer's provider picker only lists providers with a saved
providers.json entry. Desktop onboarding signs in as "cline" and never
writes a "cline-pass" entry, so a new Cline Pass user saw exactly one
row — Cline — even though the one sign-in configures both (ClinePass
stores its credentials under "cline"). `listLocalProviders` now marks a
provider enabled whenever the provider it stores credentials under is,
so Cline and Cline Pass surface together. The CLI's provider picker
inherits this; it treats `enabled` as configured, which is now also
true for Cline Pass.

Since the picker is by construction "what you have set up", it is also
the natural place to reach the rest of the catalog: a trailing
"Set up another provider" row opens Settings → Models instead of
selecting. It rides the existing `onOpenModelSettings` callback the
welcome notice already uses, so it is an action rather than a
selection — the trigger keeps showing the current provider.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qj6N74NgMeXSWn8qg7S4tw

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:23:35 -07:00
Saoud Rizwan dd50b97192 fix(desktop): stop rendering a deleted queued prompt in the chat (#14053)
The sidecar inferred a queued prompt had started running whenever a
pending_prompts snapshot arrived with fewer items and a different head,
and emitted chat_queued_prompt_start for the old head. Deleting the first
queued prompt (or discarding the queue) produces the same snapshot shape,
so the removed prompt appeared in the transcript as a user message that
never executed.

The runtime already announces every real start via
pending_prompt_submitted (drain and steer), so drop the heuristic and
rely on that event alone.

Fixes #14038
2026-09-10 23:16:46 -07:00
Saoud Rizwan 5142921a54 fix(core): persist seeded sessions with their live idle status (#14052)
Sessions seeded with history (forks, checkpoint restores) materialize
their row at start, but ensureSessionPersisted never passed the session
status, so the persistence service defaulted the row and manifest to
"running" while the live session was idle. A checkpoint restore that
reuses the session id resumes from that manifest, so the desktop app
showed a forked session stuck on "Thinking..." after restoring a
checkpoint and switching sessions.

Fixes #14037
2026-09-10 23:16:36 -07:00
Dominic CooneyandSaoud Rizwan e4f1df2c5a fix(core): identify shell editions and discourage redundant wrappers (#14025)
* fix(core): identify shell editions and discourage redundant wrappers

* fix(shared): keep PowerShell edition export Node-only

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 18:01:20 -07:00
Bee 3f74808a43 fix(schedules): prevent polling stalls and default to local timezone CLINE-3224 (#14004)
* fix(schedules): keep polling active and preserve local timezone

* fix(schedules): preserve unset timezones and instrument runs

* fix(telemetry): use camelCase scheduler properties

* fix(schedules): do not count capacity waits as run attempts

* test(cli): isolate dispatch tests from SDK runtime imports

* fix(schedules): fence execution lifecycle and claim capacity atomically
2026-09-10 17:56:53 -07:00
Saoud Rizwan 1636215231 fix(desktop): require a text prompt when sending attachments (#14050)
* fix(hub): accept image/file-only session input without a prompt

The hub's run.start / session.send_input and run.enqueue handlers rejected
any payload with an empty prompt before looking at attachments, so the
desktop app failed with "session input requires a prompt string" when a
user submitted an image with no text. The runtime host already supports
prompt-less turns with images/files, so only gate on the absence of both.

Fixes #14036

* Revert "fix(hub): accept image/file-only session input without a prompt"

This reverts commit a586c8f213.

* fix(desktop): require a text prompt when sending attachments

Submitting an image with no text let the request through to the hub, which
rejected it with "session input requires a prompt string". Block the send
in the composer and show a toast asking for a message instead.

Fixes #14036
2026-09-10 17:53:40 -07:00
Saoud Rizwan a0a8c060fb fix(desktop): clear legacy Codex credentials on ChatGPT sign-out (#14040)
* fix(core): import each legacy provider only once so sign-out is not undone

Every ProviderSettingsManager construction re-ran the legacy
globalState.json/secrets.json import and re-added any provider missing
from providers.json. Removing a provider (e.g. signing out of the
ChatGPT/Codex provider in the desktop app) deleted its entry, so the
next sidecar command immediately re-imported the credentials from
secrets.json and the user appeared signed in again.

Track imported provider ids in providers.json (migratedLegacyProviders)
and skip them on later runs. Providers that already had an entry are
recorded too, so removing them later also sticks. New legacy providers
that appear afterwards are still imported, so the classic-extension
handoff keeps working.

* Revert "fix(core): import each legacy provider only once so sign-out is not undone"

This reverts commit db013c8352.

* fix(desktop): clear legacy Codex credentials on ChatGPT sign-out

Signing out of the ChatGPT (openai-codex) provider removes its
providers.json entry, but ProviderSettingsManager re-imports missing
providers from the legacy extension's secrets.json on every
construction, so the next sidecar command signed the user back in.

Remove openai-codex-oauth-credentials from secrets.json when the
desktop app signs out of that provider. Temporary until the legacy
import is retired.

* fix(desktop): surface failed legacy secrets.json write on ChatGPT sign-out

A failed write now throws so the webview reports the sign-out as failed
and resyncs, instead of reporting success and being signed back in by
the next legacy import. Missing or unparseable files stay a no-op.
2026-09-10 17:06:42 -07:00
BeeandSaoud Rizwan 31b0dd9900 fix(desktop): refresh live model catalogs for all shared providers CLINE-3239 (#14003)
* fix(desktop): refresh live model catalogs for all shared providers

* fix: exclude private catalogs from shared refresh and measure model loads

* fix: redact registered provider IDs in model telemetry

* refactor: drop provider.models_loaded telemetry from live catalog fix

Keep this PR scoped to the model refresh bug. The event counted cache hits
and bundled fallbacks as 'returned', so it could not tell whether a live
refresh actually succeeded; split it out for a dedicated follow-up if
model-load observability is still wanted.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 14:26:19 -07:00
BeeandSaoud Rizwan ea368a1fc5 fix(automation): make event acceptance atomic and retryable (#14039)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 14:24:04 -07:00
alex-lum f6359842a5 fix(llms): limit Langfuse telemetry to Cline backend providers (#13851)
* fix(llms): limit Langfuse telemetry to Cline provider

Refs ENG-2505

* fix(llms): include ClinePass in Langfuse telemetry

Refs ENG-2505
2026-09-10 14:08:06 -07:00
Saoud Rizwan a35f9c7e35 fix(desktop): point local-auth credential failures at the provider CLI (#14035)
* fix(desktop): point local-auth credential failures at the provider CLI

When a Claude Code / Codex CLI / OpenCode turn fails with an auth error
(e.g. Claude Code's 'Failed to authenticate: OAuth session expired and
could not be refreshed'), the failure hint sent users to Settings ->
Models, where there is nothing to fix: the login lives in the CLI on the
machine. Resolve the provider's local CLI from the catalog and tell the
user to sign in again there instead. Also classify 'authenticate' and
'session expired' as credential failures so that exact error gets a hint
at all.

* fix(desktop): treat 'not logged in' / '/login' turn failures as credential errors

Claude Code reports a signed-out CLI as 'Not logged in · Please run
/login', which the credential classifier missed, so the hint never showed.
2026-09-10 13:55:08 -07:00
BeeandSaoud Rizwan 05dbe644d1 fix(llms): report zero cost for Cline Pass and free models (#14012)
* fix(llms): report zero cost for Cline Pass and free models

* fix(llms): refresh included pricing and track cost corrections

* fix(telemetry): catalog included cost corrections with typed capture

* refactor(llms): drop included-cost correction telemetry

The sdk.cline_included_cost_corrected event fired on essentially every
Cline Pass / free response (the API reports upstream cost on those), so
it carried no signal, and computing the unadjusted cost for it ran
calculateUsageCostFromPricing on every usage event for every provider.
Keep the zero-cost fix in normalizeUsage and remove the shared helper,
core event registration, and telemetry plumbing.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 13:27:20 -07:00
Bee 7877d0e0a3 Unify Tools, Skills, and Rules tab styling in desktop Customize view (#13837)
* feat(desktop): unify Tools, Skills, and Rules tab styling in Customize

Brings the Tools, Skills, and Rules tabs in the desktop app's Customize
view in line with the pattern Plugins already used, so every tab now
reads as one consistent list style instead of three different ones:

- Tools: section titles now read "BuiltIn Tools <count>" / "Plugin
  Tools <count>" with the count in muted text, add a search bar that
  filters both sections, and add an Enable all/Disable all checkbox
  per section that only touches the currently filtered/visible tools.
- Skills: replace the standalone destructive Uninstall button with the
  same dots menu (Copy path / Uninstall) Plugins use, and add a toggle
  switch to enable/disable a skill in place. This required backend
  wiring since skills previously had no enabled/disabled concept in
  the desktop app: a new set_skill_disabled sidecar command calls the
  existing hub settings.toggle({type: "skills"}) path (the same one
  the CLI's TUI config panel already uses), and disabled skills are no
  longer filtered out of the listing so they can be found and
  re-enabled.
- Workflows: show the same toggle switch as skills for visual
  consistency, but disabled with a tooltip, since core's settings
  toggle doesn't have a "workflows" branch yet.
- Rules: add the same dots menu (Copy path only, since rule deletion
  isn't wired up in uninstallLocalPrimitive) and a disabled toggle
  switch, and drop the raw path text now that it's reachable via Copy
  path, matching how Skills/Plugins cards are laid out.

Also included: reorder the Customize tabs to Tools, Plugins, Skills,
Rules, MCP, Hooks and default to the Tools tab; add a "muted" badge
variant used by the Plugin Tools badge; rename the sidebar "New Task"
button to "New Session".

* fix(desktop): stop bulk tool toggle fallback from mis-flipping grouped tools

The legacy toggle_disabled_plugin_tool fallback (used only when a
sidecar predates set_tool_disabled) flips one name with no explicit
target state. That only reliably lands on the desired state when a
tool maps to a single underlying name — a tool with several headless
names in a mixed disabled state can end up unchanged or wrong, since
blindly flipping every name can move it further from the goal instead
of closer. Skip those tools in the fallback instead of silently
leaving them wrong, apply whatever did succeed, and surface a clear
error naming what couldn't be changed. Same fix applied to both the
single-tool and bulk enable/disable paths.

* fix(desktop): let the legacy tool-toggle fallback flip uniform groups

The previous fix blocked every multi-name tool from the legacy
toggle_disabled_plugin_tool fallback, even ones where the underlying
names shared the same prior state and could be safely flipped as a
group. Instead, attempt the group flip and verify the tool's resulting
state against the target: if it matches (a uniform group), keep it; if
it doesn't (a mixed group the legacy command can't reliably resolve),
flip everyone back to restore the original state and report that tool
as needing a newer build, rather than leaving it in some other
unintended combination.

* fix(desktop): make the legacy tool-toggle fallback direction-aware

The previous fix's verification used the tool's aggregate enabled flag
to confirm a grouped flip landed on target, but that flag is true only
when every underlying name is enabled — it can't tell "now fully
disabled" apart from "still mixed," since both report false. So the
disable direction could report success while some underlying actions
stayed enabled.

Extract the group-flip logic into toggleLegacyToolGroup, shared by the
single-tool and bulk paths, and make it direction-aware: enabling
verifies with one flip (the aggregate flag is an exact test for that
direction), disabling verifies with a second probe flip whose result
is the exact test for "did the first flip fully disable it," redoing
the first flip if so. Either direction, on failure, flips back to the
exact original state rather than leaving some other combination.

* fix(desktop): use explicit tool states without legacy fallbacks
2026-09-10 13:17:50 -07:00
Bee 4063e1731e fix(desktop): reject unsupported image attachments CLINE-3234 (#14000)
* fix(desktop): reject unsupported image attachments

* fix(desktop): validate draft images and track blocked attachments

* fix(desktop): reject unsupported formats and catalog image telemetry

* fix(desktop): disable unsupported attachments and validate dropped images

* fix(desktop): separate image attachments from file picker

* refactor(desktop): simplify image attachment validation
2026-09-10 12:11:28 -07:00
BeeandSaoud Rizwan 75ac98a3c8 feat(desktop): add PR opening and merge/CI status indicators CLINE-3226 (#13997)
* feat(desktop): add pull request links and merge/CI status

* fix(desktop): align PR status colors with merge readiness

* feat(desktop): track pull request feature interactions

* fix(desktop): use camelCase PR telemetry properties

* fix(desktop): hide unavailable PR integration and throttle retries

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 10:48:55 -07:00
BeeandSaoud Rizwan 258227dbe0 fix(desktop): prevent history hover metadata overlap (#14005)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 10:07:24 -07:00
Bee fc3273bbe0 refactor(hub): share describeOutdatedHubSessions between CLI and desktop (#13772)
* refactor(hub): share describeOutdatedHubSessions between CLI and desktop

Post-merge review follow-up from #13727: the helper existed word for word
in the CLI TUI and the desktop webview, with tests only on the desktop
copy. Both surfaces must read identically, so the copy now lives once in
@cline/shared (exported on both the node and browser surfaces, which the
webview already imports) with the test suite moved alongside it.

* fix(desktop): use browser shared entry for hub update dialog
2026-09-10 16:10:19 +09:00
avianion ae0c86f11f docs: add Avian as OpenAI-compatible provider guide (#9677)
* docs: move Avian to consolidated providers page

* Fix alphabetical ordering of provider entries
2026-09-10 15:35:36 +09:00
46ba1d3228 fix(shared): preserve nested PowerShell scripts across editions (#13815)
* fix(shared): unwrap nested PowerShell -Command before the wrapper parses it

run_commands feeds commands to PowerShell through a stdin bootstrap that
executes the text as outer PowerShell source. A nested
`powershell -Command "... $_ ..."` therefore had its double-quoted
argument parsed by the outer parser: $_ was interpolated away before the
nested shell ever saw it, so pipelines like
`... | Where-Object { $_.Name ... }` errored once per enumerated item -
an error flood over large trees that looked like a hang - while the
nested child still exited 0.

Detect redundant nested invocations in getShellInvocation and run the
decoded script directly. Only semantics-preserving rewritings are done:
same PowerShell edition as the configured outer shell, -NoProfile plus
only wrapper flags the bootstrap already applies (a nested shell that
would load the user's profile keeps its own process), and an entirely
double-quoted -Command tail. Escapes decode per PowerShell rules,
including the PowerShell 7-only `u{...} and `e. Everything else passes
through byte-identical.

Fixes #13284

* fix(shared): preserve nested PowerShell statement boundaries

* fix(shared): preserve requested PowerShell across nested editions

* test(core): compare canonical PowerShell fixture paths

* fix(shared): keep nested PowerShell helper internal

* fix(shared): unwrap single-quoted PowerShell scripts

* fix(shared): require call operator for quoted PowerShell paths

* refactor(shared): drop the deprecated unwrapNestedPowerShellCommand helper

It had no production callers and existed only so tests could import it.
Move its explanation onto parseNestedPowerShellCommand, which is what
getShellInvocation actually runs, and assert on getShellInvocation's
executable and input in the tests instead.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-10 13:44:00 +09:00
451 changed files with 30686 additions and 5864 deletions
+131 -136
View File
@@ -1,34 +1,33 @@
---
name: publish-extension
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
description: Use when releasing the Cline VS Code extension — stable (standalone SDK build of main via ext-vscode-publish-stable), nightly (ext-vscode-publish-nightly, manual dispatch), or an emergency legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, workflow dispatch, environment approvals, tagging, post-publish verification, and the remaining retirement of the finished A/B rollout machinery.
---
# VS Code Extension Release
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or an emergency legacy hotfix — or to retire the leftover A/B rollout machinery.
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
## The current era: combined A/B rollout
## The current era: standalone SDK extension from `main`
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
**The legacy → SDK migration is complete.** The PostHog flag `ext-sdk-bundle-rollout` reached 100% (verified empirically 2026-09-15: 200/200 `/decide` probes returned `true`), so every user on the combined VSIX runs the `next` (SDK, bun) bundle and the `legacy/` half is dead weight. Stable releases now ship a **plain build of `main`** through `ext-vscode-publish-stable.yml`. The combined A/B path (`ext-vscode-ab-package.yml`) is no longer used for releases and is pending deletion — see "Retiring the A/B machinery" at the bottom for what is still left to clean up and the one caveat (leave the flag at 100%).
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
History, for context only: the A/B era ran `4.1.0``4.1.17` (JulSep 2026). Design docs remain at `apps/vscode-rollout/README.md` and PR #12253 until that directory is removed.
### The listings and the workflows
| Channel | Marketplace ID | Workflow | Trigger | Version |
|---|---|---|---|---|
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
| **Stable** | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch, from `main` | `apps/vscode/package.json` on `main`; tag `v<version>` must match |
| Nightly | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | **manual dispatch only** (cron deliberately removed) | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
| Legacy hotfix (emergency only) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | `apps/vscode/package.json` on `legacy-extension` |
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish``Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
Stable runs the reusable bun suite (`ext-vscode-test.yml`, tests `main`) before an environment-gated publish job (`publish` environment required reviewers approve in the Actions UI). Nightly uses `PublishNightly` (branch policy only). Nightly **still builds the combined loader VSIX** (loader + `next/` + `legacy/`) until it is converted back to a plain build — that conversion is on the retirement list below.
## Golden rules (read before any release)
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
1. **One listing, one version line.** `claude-dev` has been published from multiple workflows and branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
```bash
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
@@ -37,13 +36,123 @@ All three publish paths gate on tests before publishing: nightly and ab-package
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
```
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
**`ext-vscode-publish-stable` has no marketplace-monotonicity preflight** (that check only ever lived in the retired ab-package workflow), so this query is the only guard. The `legacy-extension` branch sits at `4.0.12`, so it cannot collide with the `4.1.x` line, but a legacy hotfix would have to be numbered above the live stable version (see the emergency section).
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
2. **Tag == package version, enforced.** The workflow reads `apps/vscode/package.json`, requires the `tag` input to equal `v<that version>`, and hard-fails otherwise. Bump the version on `main` first.
3. **Changelog lives at the repo ROOT** (`CHANGELOG.md`) — not `apps/vscode/CHANGELOG.md` (doesn't exist). The workflow hard-fails unless the first `## [` heading is exactly `## [<version>]`. The section body becomes the GitHub release notes and the Slack post (Slack copy is trimmed to 3000 chars with a link out; the release body stays whole).
4. **Ask before pushing** commits or tags. **Never approve the `publish` environment gate yourself via `gh api`** — hand the maintainer the run URL to click "Review deployments".
5. **Concurrency**: the workflow groups on the tag with `cancel-in-progress: false`. A publish run left `waiting` on approval blocks every later dispatch of the same tag until cancelled (`gh run cancel <id>`).
## Stable release — the current path
### Pre-flight
```bash
# 1. What's live (rule 1) → pick <VERSION> strictly above it (normally patch bump).
# 2. Confirm main's package.json is at the *previous* published version, i.e. the repo
# reflects the live line and nothing unreleased is already bumped:
node -p "require('./apps/vscode/package.json').version"
# 3. What's in the release:
git fetch origin main --tags
git log v<PREV>..origin/main --oneline --no-merges -- apps/vscode sdk/packages
```
The CLI/SDK notes are the best starting point for the extension notes — the extension bundles `@cline/*` from source, so an SDK release in the same window ships here too. Read `sdk/CHANGELOG.md` for the matching SDK version and translate what's extension-visible; skip CLI-only and desktop-only items.
### Release prep on `main` (PR, not direct push)
- Bump `apps/vscode/package.json` → `<VERSION>`.
- Prepend `## [<VERSION>]` to root `CHANGELOG.md` with the approved notes.
- Side effect of the bump: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
Optional local rehearsal of the exact packaging step (the workflow has no dry-run input, and the build runs inside the gated publish job, so this is the only pre-approval check):
```bash
bun install --frozen-lockfile && bun run build:sdk
cd apps/vscode && npx @vscode/vsce package --no-dependencies --allow-package-secrets sendgrid --out /tmp/rehearsal.vsix
# vsce runs vscode:prepublish → `bun run package` (check-types + build:webview + lint + esbuild --production),
# i.e. the same build the workflow performs. Telemetry env is NOT set locally, so expect
# telemetry to be dark in this artifact — that is fine for a build rehearsal, not for shipping.
```
### Dispatch
```bash
gh workflow run ext-vscode-publish-stable.yml --ref main \
-f release-type=release \
-f auto_create_tag_from_main=true \
-f tag=v<VERSION>
gh run list --workflow=ext-vscode-publish-stable.yml --limit 1 --json databaseId,url,status
```
What the run does, in order: test gate on `main` → publish job checks out `main`, **creates and pushes `v<VERSION>` at the tested SHA** (auto-create mode; refuses if the tag already exists elsewhere) → `bun install --frozen-lockfile` → `bun run build:sdk` → asserts the `better-sqlite3` native binary → verifies tag/version/changelog/PATs → `vsce package` → `bun run publish:marketplace` (vsce publish **and** `npx ovsx publish`, both `--no-dependencies`) → GitHub release with the `.vsix` attached → Slack post. The publish job waits for `publish` environment approval before any of that. Check what a run is waiting on:
```bash
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
```
`release-type=pre-release` publishes to the pre-release channel of the same listing; it is a real publish, not a rehearsal.
### Post-publish
1. Verify both registries serve the new version (expect minutes-to-an-hour of Marketplace validation lag after "Published" appears in the logs):
```bash
# Marketplace: query from rule 1
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
```
2. Verify the bookkeeping landed: `git fetch --tags && git tag --list 'v<VERSION>'`, `gh release view v<VERSION>`, Slack post in the release channel. Tag creation happens **before** the build in this workflow, so a tag-push failure fails the run early (no publish) rather than leaving a published-but-untagged release. Known cause: the built commit touches `.github/workflows/**` (default token cannot create such refs). Workaround: create and push the tag yourself at `origin/main` HEAD (ask first), then re-dispatch with `auto_create_tag_from_main=false` from `main` — the workflow requires the existing tag to point at the exact SHA it tested.
3. Artifact check (`gh run download <run-id>` or the release asset): `package.json` inside is `saoudrizwan.claude-dev@<VERSION>`; `grep -c 'process.env.TELEMETRY_SERVICE_API_KEY' extension/dist/extension.js` must be **0** (a leftover literal means the build ran without its env and telemetry is silently dead). `CLINE_ROLLOUT_VARIANT` is defined to `""` for ordinary builds by `apps/vscode/esbuild.mjs`, so no literal is expected and telemetry carries no `extension_variant` — correct for a standalone build.
4. Monitor errors on `extension_version = '<VERSION>'` in `otel.otel_logs` (stable cohort is cleanly separable — nightly versions are timestamps). Metabase dashboards 17 (task error rate) and 19 (error deep dive).
## Nightly release
**Manual dispatch only.** The cron was removed on purpose: the `PublishNightly` environment made scheduled runs sit `waiting`, hold the concurrency group, and silently cancel every later scheduled run behind them. A stale nightly listing is therefore expected, not a bug.
```bash
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
```
No changelog/version prep — the version is computed. Verify with the Marketplace query against `saoudrizwan.cline-nightly`. Until converted, nightly still ships the combined loader VSIX with a `legacy/` bundle built from `legacy-extension`.
**Red run ≠ failed publish** on this path: its tag-push step runs *after* publishing and fails whenever main's HEAD touches `.github/workflows/**`. If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
## Emergency rollback
Preferred: **ship a fixed build of `main` at a higher version** through the stable workflow above. It is the same path, fully gated, and the only rollback that keeps users on the SDK extension.
Last resort — the legacy hotfix path — still exists but is degraded: `legacy-extension` is the pre-SDK npm codebase, last touched 2026-08-18 at `4.0.12`, and a publish from it would move every user back onto code that is weeks behind. If it is ever needed:
```bash
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
# live stable version (rule 1 — e.g. 4.1.18 live -> hotfix is 4.1.19, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main -f release-type=release
# (the branch is hardcoded to legacy-extension in the workflow)
```
The npm suite runs ungated, the publish job waits on the `publish` environment, the workflow tags and creates the GitHub release itself, and it publishes to Marketplace **and** Open VSX. On that branch use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
## Retiring the A/B machinery (still to do)
The rollout is done but the scaffolding is still in the repo. Retire it in this order, each as its own PR:
1. **Nightly → plain build of `main`**: drop the loader/`legacy-src` stitching from `ext-vscode-publish-nightly.yml` so nightly matches stable. Preserve the `|| 'default'` fallbacks for `inputs.*` while editing.
2. Delete `ext-vscode-ab-package.yml` and, once the emergency path above is judged unnecessary, `ext-vscode-publish-legacy.yml`; keep the `legacy-extension` branch for history.
3. Remove `apps/vscode-rollout/` and the rollout-only code paths in `apps/vscode/src/services/telemetry/rollout-metadata.ts` (the `extension_variant` metadata and `extension.rollout.bundle_activated` event).
4. Optionally port the marketplace-monotonicity preflight from the old ab-package workflow into `ext-vscode-publish-stable.yml` — the only automated guard for rule 1 was retired with it.
5. **Archive the PostHog flag last, and not yet.** Machines still on a combined VSIX (`≤ 4.1.17`) consult `ext-sdk-bundle-rollout` on every window load and treat a *deleted* flag as `legacy`. Leave it at 100% until `extension.rollout.bundle_activated` for combined versions flatlines, then archive. Re-verify the percentage empirically before touching it (no PostHog admin needed — the key is inlined in any shipped combined loader; download the 4.1.17 VSIX from the Marketplace `vspackage` URL, `gunzip`, `unzip`, `grep -o 'phc_[A-Za-z0-9]*' extension/extension.js`):
```bash
node -e '
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
const KEY = process.argv[1];
(async () => {
let t = 0, n = 200;
for (let i = 0; i < n; i += 20) {
@@ -58,129 +167,15 @@ All three publish paths gate on tests before publishing: nightly and ab-package
})()' "$KEY"
```
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
## Stable release (combined A/B VSIX) — the current stable path
### Pre-flight
```bash
# 1. What's live, and what version comes next (must exceed it — rule 1)
# 2. Flag percentage (rule 2) — decide where it should be for this release
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
git fetch origin main legacy-extension
git log --oneline -3 origin/legacy-extension
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
# hard-fails if views/viewsContainers/configuration diverged between branches.
git show origin/main:apps/vscode/package.json > /tmp/next.json
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
```
Release prep on `main` (PR, not direct push):
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
### Dispatch
```bash
gh workflow run ext-vscode-ab-package.yml --ref main \
-f version=<VERSION> -f next-ref=main -f publish=true
# (the legacy bundle always builds from the protected legacy-extension branch;
# it is deliberately not an input)
# publish=false builds an installable .vsix artifact without publishing and
# needs NO environment approval — the ungated build job uploads the artifact
# and the run completes.
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
```
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
```bash
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
```
### Post-publish
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
```bash
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
```
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
```bash
git tag v<VERSION> <main-sha-built> # ask before pushing
git push origin v<VERSION>
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
```
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
### Known caveats of this path
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
- A red run can still mean a successful publish on paths that tag (see Gotchas).
## Nightly release
Happens automatically (cron 12:00 UTC). Manual cut:
```bash
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
gh run watch <run-id> --exit-status --interval 60
```
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
## Legacy hotfix release (and emergency full rollback)
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
```bash
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
# highest version ever published to the listing (rule 1 — including combined
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main \
-f release-type=release
# (the branch is hardcoded to legacy-extension in the workflow; it is
# deliberately not an input)
```
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
## Cutover: retiring the A/B machinery (the endgame)
When the next bundle has held at 100% long enough to trust:
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX.
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
6. Update this skill: delete the combined-era sections and keep the standalone flow.
6. Update this skill: delete this section and the combined-loader notes under Nightly.
## Gotchas index
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (the workflows handle this).
- Every ext workflow pins `bun-version: 1.3.14` while the root `packageManager` is `bun@1.3.13`. This is consistent across all of them and has shipped fine — don't "fix" it in one workflow alone.
- The publish job pins **Node 22** on purpose: Node 24 / npm 11 can make vsce's `npm list` detection fail with `ELSPROBLEMS` during packaging. `setup-bun` provides no Node runtime, and the publish scripts and `npx ovsx` need one.
- `gh run watch --exit-status` has returned exit 0 on a failed run. Always confirm with `gh run view <id> --json status,conclusion` before acting on a result.
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group.
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; no publish workflow has an untrusted trigger surface.
- Environment-approval runs left waiting don't time out quickly — they sit for days and block their tag's concurrency group.
- Open VSX has held a first-time publish in moderation before (logs say "Published", API 404s for hours). Verify with the API query rather than the log line.
+4
View File
@@ -154,6 +154,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -423,6 +425,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -284,6 +284,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -161,6 +161,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -272,6 +272,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
+10 -2
View File
@@ -6,13 +6,13 @@ on:
- main
paths:
- "sdk/**"
- "apps/examples/desktop-app/**"
- ".github/workflows/sdk-test.yml"
workflow_dispatch:
pull_request:
branches:
- main
paths:
- "sdk/**"
- "apps/examples/desktop-app/**"
- ".github/workflows/sdk-test.yml"
workflow_call:
@@ -98,6 +98,14 @@ jobs:
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F './sdk/packages/**' test
- name: Run Desktop Sidecar Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'ubuntu-latest' }}
run: bun -F @cline/code test:sidecar
- name: Run Desktop Settings UI Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
run: bun -F @cline/code test:settings-ui
- name: Smoke test SQLite under Node
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
timeout-minutes: 10
+35
View File
@@ -1,5 +1,40 @@
# Changelog
## [4.1.18]
### Added
- Images attached to a model that cannot read them are now flagged instead of silently discarded. Thumbnails get a warning badge and the composer explains that the images will be ignored, with a button to switch to an image-capable model. Previously the thumbnail looked normal and the image was replaced with a text placeholder just before the request, so there was no way to tell it had been dropped. Model info and the attachment picker also report image support accurately for models that declare text-only input without listing capabilities.
### Fixed
- On Windows, opening a repository that contains a file named `rg.exe`, `git.exe`, or `powershell.exe` no longer runs that file in place of the real program. Bare program names were resolved through the workspace directory before PATH, so a planted executable ran with your privileges as soon as the workspace was indexed. Cline now sets Windows' `NoDefaultCurrentDirectoryInExePath` opt-out at startup, in both the VS Code extension and the JetBrains core. Processes Cline launches inherit it, so inside a Command Prompt shell a program in the current directory now needs `.\` as it already does in PowerShell.
- A model turn that fails mid-stream with a transient provider error is now retried up to three times with backoff instead of ending the task. A single rate-limit response forwarded by a gateway previously surfaced as a failed task. A turn that has already streamed output is never retried, so nothing is duplicated.
- Terminal commands that succeed without printing anything (`git add -A` on a clean tree, for example) are now reported as empty output. They were treated as a shell-integration failure, which fed the model a snapshot of unrelated terminal scrollback prefixed with a warning that the output could not be captured, so silent commands intermittently looked like failures.
- Checkpoints no longer re-hash every untracked file on each message. In workspaces holding large untracked directories this delayed every message by seconds to minutes; a persistent per-task index now lets git skip files it has already seen.
- `run_commands` no longer hangs until its timeout after a command that backgrounds a child process. The command had finished, but the backgrounded process held the output pipes open.
- `apply_patch` no longer silently overwrites an existing file when the model uses "Add File" on a path that already exists. The file's contents were replaced with no error and no record of what was lost.
- PowerShell commands the model wrapped in another `powershell -Command "..."` are no longer parsed twice. The outer shell consumed `$_` before the inner command ran, so pipelines using it emitted an error for every item processed while still reporting success.
- Opening your home directory as a workspace no longer drives the extension host to exhaustion. Typing an `@` mention indexed every file beneath it and re-ranked the whole index on each keystroke.
- The "Supports Images" checkbox in OpenAI Compatible model settings now stays where you put it. The checkbox rendered the last committed value, and the re-sync that arrived during the save round-trip was re-emitted as a change event that wrote the old value back over your edit.
- The error shown to the model when an `editor` call omits the text to replace now names the file and explains how to recover. The previous message was terse enough that some models re-sent the identical call until the task stopped.
- Credentials are now stripped of invisible characters when saved, not only when pasted, and the cleanup covers AWS, GCP, and SAP fields and custom header values in addition to API keys.
- Cline Pass now defaults to a model from your subscription rather than a free one. Its model list contains both tiers and the default was whichever model was published most recently, so a subscriber who never picked a model could be left on the free tier.
- Cline Pass and free models now show no cost rather than the underlying market price, which is not what you are billed for.
- Model pickers now fall back to the full Recommended, Free, and Subscribed lists when the models endpoint cannot be reached. The offline fallback was a short hardcoded list with no subscription tier at all.
- Model lists for providers sharing the built-in catalog now refresh from the live catalog, so newly published models appear without an extension update, with timeouts so an unresponsive provider endpoint cannot stall the list.
- Claude Code and OpenCode no longer ask for an API key they never read. Both authenticate from their own local CLI's credentials, but they were treated as key-based providers; the workaround was storing a dummy key. A missing CLI on `PATH` now warns rather than blocking, since a configured path or bundled binary also works.
- The OpenAI Codex (ChatGPT subscription) model list no longer offers models the backend rejects, and Codex context limits are applied to every Codex model instead of being inherited from the OpenAI API catalog, which inflated both the context budget and the usage figures derived from it.
- Models served by OpenCode Go are now sent over the wire protocol each one actually speaks. Every model was sent over the OpenAI chat-completions adapter, so models on that endpoint speaking other protocols failed or misbehaved.
- Task history no longer goes blank when a task spawns many subagents. Subagent rows crowded out the tasks that created them, hiding the parent task and everything older.
- Langfuse tracing, when configured, is now limited to the Cline and Cline Pass providers. The provider was ignored, so prompts and responses sent to third-party and bring-your-own-key providers were exported too.
### Changed
- Web search is now enabled by default on models that support it, outside YOLO mode. It can still be turned off in settings, and a settings file that cannot be read leaves it disabled rather than silently on.
- The `run_commands` tool description now names the PowerShell edition in use — `Windows PowerShell (powershell.exe)` versus `PowerShell (pwsh.exe)` — quotes its guidance against that executable, and tells the model to run commands directly rather than wrapping them in another shell invocation. It also no longer describes the environment as Windows when `pwsh` is the configured shell on macOS or Linux.
- Refreshed the built-in model catalog. Adds four providers (Infer by Flow7, Melious, NaN, and Wallaby) and takes the catalog from 5,788 to 6,079 models. This is a wide refresh: the resolved default model changes for 44 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Nebius, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go. Gemini and Vertex now resolve to Gemini 3.8 Flash, GitHub Copilot and Vivgrid to GPT-6 Astra, and NVIDIA to GLM 5.3 Flash. If you use a provider without pinning a model, expect a different default.
## [4.1.17]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
+2 -2
View File
@@ -63,7 +63,7 @@ Cline as a native app for macOS and Windows.
Run agent sessions in any folder, schedule
routines, and manage models, plugins, and MCP servers.
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
<a href="https://cline.bot/desktop">Download for macOS and Windows</a>
<br><br>
</td>
@@ -182,7 +182,7 @@ const deployTool = createTool({
const agent = new Agent({ tools: [deployTool], /* ... */ })
```
...or use [MCP servers](https://github.com/modelcontextprotocol) to connect to databases, query APIs, manage cloud infrastructure, and interact with external systems. Use [community-built servers](https://github.com/modelcontextprotocol/servers) or ask Cline to create custom tools on the fly. In the CLI, manage servers with `cline mcp`.
...or use [MCPs](https://github.com/modelcontextprotocol) to connect to databases, query APIs, manage cloud infrastructure, and interact with external systems. Use [community-built servers](https://github.com/modelcontextprotocol/servers) or ask Cline to create custom tools on the fly. In the CLI, manage servers with `cline mcp`.
## Multi-Agent Teams
+28
View File
@@ -1,5 +1,33 @@
# Cline CLI Changelog
## 3.0.62
- Introducing Cline Desktop: a native app for working with open-weight models, with task import from Claude Code and Codex, scheduled runs, web search and voice input, and a marketplace for plugins, MCP servers, and skills. The CLI now shows a one-time startup notice pointing at cline.bot/desktop. At most one notice appears per launch, and `CLINE_DISABLE_CLINE_PASS_NOTICE=1` still suppresses all of them
- Agent Plugins are now managed through the Hub. Packages under `~/.agents/plugins/*` are discovered and validated, their skills are exposed through the skills tool as `plugin-name:skill-name`, and their MCP servers start without touching `cline_mcp_settings.json`. The config screen lists them separately from Cline Plugins and Space toggles them. Workspace `.agents/plugins` directories are deliberately not scanned, so opening a repo cannot implicitly start repo-controlled MCP servers
- A model turn that dies mid-stream with a transient provider error is now retried up to 3 times with backoff instead of failing the run — a single forwarded 429 from OpenRouter previously ended the run with exit 1. A turn that already streamed output is never retried, so nothing is duplicated
- Streaming output is no longer throttled by the Hub. Every streamed token was proxied to client hooks as a round trip carrying a full copy of the session, with the agent loop waiting on it
- Checkpoints no longer stall every message in workspaces with large untracked directories. Each turn re-hashed every untracked file before the model call, which on multi-GB workspaces blocked messages for seconds to minutes; a persistent per-session snapshot index now lets git skip unchanged files
- Fixed `run_commands` hanging until timeout after a command that backgrounds a child process. The command had finished, but the backgrounded child held the output pipes open
- Fixed `cline` being OOM-killed when run from your home directory. Typing an `@` mention indexed every file under `$HOME` and re-ranked it on each keystroke
- Fixed `apply_patch` silently overwriting an existing file when the model used "Add File" on a path that already exists
- Fixed PowerShell commands that the model wrapped in another `powershell -Command "..."` being parsed twice, which stripped `$_` out of pipelines and produced an error per enumerated item while still reporting success. The tool description now also names which PowerShell edition is in use and tells the model not to wrap commands
- API keys pasted with an invisible character (BOM, zero-width space) are now cleaned before being saved. They were stored corrupted and the provider's 401 was indistinguishable from a wrong key
- Signing in with Claude Code no longer demands an API key it never reads. It authenticates from the `claude` CLI's own credential store, but onboarding treated it as an API-key provider and dropped you into the sign-in wizard; the workaround was storing a dummy key. A missing `claude` on PATH now warns instead of blocking, since a configured path, bundled binary, or npx also works. OpenCode gets the same local-CLI treatment
- Web search is now on by default in non-yolo sessions on models that support it
- A running Hub on the same core version as your CLI no longer prompts you to update it. Anyone with both the desktop app and the CLI installed saw a "Cline Hub was updated" dialog on every launch that could never resolve
- Scheduled runs no longer stall behind each other — one long turn blocked dispatch of every other schedule. Parallelism limits are now enforced at claim time, work resumed after system sleep is no longer started twice, and a schedule created without a timezone uses your local one instead of an implicit default
- Session history no longer goes blank when a session spawns many subagents. Child rows crowded out the roots, hiding the parent session and everything older
- Fixed TUI toasts being clipped to their first line. The Hub messages that use them are all longer than that, so the keep-Hub reminder never showed the `cline hub upgrade` command it exists to deliver
- Cline Pass now defaults to a subscribed model instead of a free one. Its model list mixes both tiers and the default was whichever model shipped most recently, so subscribers who never picked a model were put on the free tier
- Cline Pass and free models now show zero cost instead of the upstream market price for requests you are not billed per token for
- Model pickers now fall back to the full Recommended, Free, and Subscribed tiers when the models endpoint is unreachable. Previously the offline fallback was six hardcoded models with no subscribed tier at all
- The OpenAI Codex (ChatGPT subscription) model list no longer includes models the backend rejects, and Codex context limits are applied to every Codex model instead of being inherited from the OpenAI API catalog, which was inflating the context budget and the usage math
- Model lists for all shared-catalog providers now refresh from the live catalog, so newly published models appear without a CLI update, with timeouts so a hung provider endpoint cannot stall the list
- Cline Pass now shows as a configured provider after sign-in — it stores credentials under Cline, so one sign-in configured both but only Cline appeared
- Fixed OpenCode Go serving several wire protocols behind one URL while every model was sent over the OpenAI chat-completions adapter
- Collapsed a nested `undici@5.29.0` (CVE-2026-1525) onto 7.x. The earlier remediation's version-scoped override key was silently ignored by Bun, so a vulnerable copy survived
- Refreshed the model catalog. Adds four providers (Infer by Flow7, Melious, NaN, and Wallaby) and takes the bundled catalog from 5,788 to 6,079 models. This is a wide refresh: the resolved default model changes for 44 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Nebius, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go. Gemini and Vertex now resolve to Gemini 3.8 Flash, GitHub Copilot and Vivgrid to GPT-6 Astra, and NVIDIA to GLM 5.3 Flash. If you use any provider without pinning a model, expect a different default
## 3.0.61
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
+6
View File
@@ -109,6 +109,12 @@ const result = await Bun.build({
"OTEL_METRICS_EXPORTER",
),
"process.env.OTEL_LOGS_EXPORTER": defineProcessEnv("OTEL_LOGS_EXPORTER"),
"process.env.OTEL_TRACES_EXPORTER": defineProcessEnv(
"OTEL_TRACES_EXPORTER",
),
"process.env.CLINE_TRACE_RECORD_CONTENT": defineProcessEnv(
"CLINE_TRACE_RECORD_CONTENT",
),
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": defineProcessEnv(
"OTEL_EXPORTER_OTLP_PROTOCOL",
),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.61",
"version": "3.0.62",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+3
View File
@@ -32,6 +32,9 @@ const BUILD_TIME_INLINED_ENV_VARS = [
"OTEL_TELEMETRY_ENABLED",
"OTEL_LOGS_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_TRACES_EXPORTER",
"CLINE_TRACE_SAMPLE_PERCENT",
"CLINE_TRACE_RECORD_CONTENT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
+4
View File
@@ -4,6 +4,7 @@ import { isMainThread } from "node:worker_threads";
import {
claimHubDaemonProcess,
claimSupervisedConnectorProcess,
disableCurrentDirectoryExecutableSearch,
disposeAll,
initVcr,
setConnectorCliLaunchSpec,
@@ -21,6 +22,9 @@ import { writeErr } from "./utils/output";
// Set CLINE_VCR=record|playback and CLINE_VCR_CASSETTE=<path> to enable.
initVcr(process.env.CLINE_VCR);
// Before any personality below can spawn a child with the workspace as cwd.
disableCurrentDirectoryExecutableSearch();
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (claimHubDaemonProcess()) {
+16 -18
View File
@@ -1,18 +1,17 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { useDialogPalette } from "../tui/hooks/use-theme";
import {
type DialogDismissKey,
isAnyKeyDismiss,
} from "../tui/utils/dialog-keys";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
import open from "../utils/open";
import type { CliMigrationNotice } from "./notice";
/**
* Enter opens the subscription page; any other (unmodified) key dismisses the
* Enter opens the notice's page; any other (unmodified) key dismisses the
* dialog; modifier-held keys are ignored.
*
* The dialog used to be dismissible only with Esc, but Esc is the least
@@ -20,7 +19,7 @@ import type { CliMigrationNotice } from "./notice";
* timeout disambiguation, and Windows console input layers are known to
* swallow it), which left users stuck behind the promo with no way out.
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
* subscription link never dismisses the dialog mid-click.
* link never dismisses the dialog mid-click.
*/
export function resolveMigrationNoticeKeyAction(
key: DialogDismissKey,
@@ -36,27 +35,26 @@ export function MigrationNoticeContent(
) {
const { dialogId, notice, resolve } = props;
const palette = useDialogPalette();
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
const openSubscriptionPage = useCallback(() => {
setStatus("Opening ClinePass in your browser...");
void open(subscriptionUrl, { wait: false })
const openNoticePage = useCallback(() => {
setStatus("Opening in your browser...");
void open(notice.url, { wait: false })
.then(() => {
setStatus("Opened ClinePass in your browser.");
setStatus("Opened in your browser.");
})
.catch(() => {
setStatus(
"Could not open the browser automatically. Use the URL below.",
);
});
}, [subscriptionUrl]);
}, [notice.url]);
useDialogKeyboard((key) => {
const action = resolveMigrationNoticeKeyAction(key);
if (action === "ignore") return;
if (action === "open") {
openSubscriptionPage();
openNoticePage();
return;
}
resolve(true);
@@ -66,20 +64,20 @@ export function MigrationNoticeContent(
<box flexDirection="column" paddingX={1} gap={1}>
<text fg={palette.act}>{notice.title}</text>
<box flexDirection="column">
<text selectable>
ClinePass is a $9.99/month subscription plan to get access to the
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
{notice.body.split("\n").map((line) => (
<text key={line} selectable>
{line}
</text>
))}
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
<a href={notice.url}>{notice.url}</a>
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Open ClinePass</text>
<text fg={palette.textOnSelection}>{notice.openLabel}</text>
</box>
</box>
{status && <text fg={palette.muted}>{status}</text>}
+38 -5
View File
@@ -55,11 +55,42 @@ describe("migration notice", () => {
);
});
it("does not show after the notice is marked as shown", () => {
it("does not show after every notice is marked as shown", () => {
const dataDir = createTempDataDir();
markClineCliMigrationNoticeShown(dataDir);
markClineCliMigrationNoticeShown(dataDir, "cline-cli-desktop-launch");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
it("shows the desktop launch notice once the ClinePass intro was shown", () => {
const dataDir = createTempDataDir();
markClineCliMigrationNoticeShown(dataDir);
const notice = getClineCliMigrationNotice(dataDir);
expect(notice?.id).toBe("cline-cli-desktop-launch");
expect(notice?.url).toBe("https://cline.bot/desktop");
});
it("shows only one notice per launch", () => {
const dataDir = createTempDataDir();
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
"cline-cli-cline-pass-intro",
);
});
it("marks the desktop launch notice as shown by id", () => {
const dataDir = createTempDataDir();
markClineCliMigrationNoticeShown(dataDir);
markClineCliMigrationNoticeShown(dataDir, "cline-cli-desktop-launch");
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain('"cline-cli-cline-pass-intro": true');
expect(rawState).toContain('"cline-cli-desktop-launch": true');
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
@@ -85,7 +116,7 @@ describe("migration notice", () => {
).toBeUndefined();
});
it("does not show when ClinePass is already the active provider", () => {
it("does not show the ClinePass intro when ClinePass is already the active provider", () => {
const dataDir = createTempDataDir();
expect(
@@ -93,8 +124,8 @@ describe("migration notice", () => {
dataDir,
{},
{ activeProviderId: "cline-pass" },
),
).toBeUndefined();
)?.id,
).toBe("cline-cli-desktop-launch");
});
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
@@ -141,6 +172,8 @@ describe("migration notice", () => {
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain("cline-cli-cline-pass-intro");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
expect(getClineCliMigrationNotice(dataDir)?.id).not.toBe(
"cline-cli-cline-pass-intro",
);
});
});
+46 -11
View File
@@ -1,20 +1,54 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
const NOTICE_ID = "cline-cli-cline-pass-intro";
export const CLINE_PASS_NOTICE_ID = "cline-cli-cline-pass-intro";
const DESKTOP_NOTICE_ID = "cline-cli-desktop-launch";
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
// Historically named for the ClinePass promo; disables every startup notice.
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
const DESKTOP_APP_URL = "https://cline.bot/desktop";
export interface CliMigrationNotice {
id: string;
title: string;
body: string;
url: string;
openLabel: string;
}
export interface CliMigrationNoticeOptions {
activeProviderId?: string;
}
function getClinePassNotice(): CliMigrationNotice {
return {
id: CLINE_PASS_NOTICE_ID,
title: "Try ClinePass",
body: "ClinePass is a $9.99/month subscription plan to get access to the latest open-weight coding models with enough quota for day-to-day work, at a much lower cost than paying API costs directly.",
url: getCliSubscriptionUrl(),
openLabel: "Open ClinePass",
};
}
function getDesktopNotice(): CliMigrationNotice {
return {
id: DESKTOP_NOTICE_ID,
title: "Introducing Cline Desktop",
body: [
"A native app for working with open weights models. Use it with ClinePass and our free models, or BYOK.",
"- Import tasks from Claude Code and Codex",
"- Run Cline on a regular schedule",
"- Use web search tool and voice input",
"- Browse Marketplace for plugins, MCPs, and skills",
"Available for macOS and Windows.",
].join("\n"),
url: DESKTOP_APP_URL,
openLabel: "Get Cline Desktop",
};
}
interface CliNoticeState {
shown: Record<string, boolean>;
}
@@ -84,32 +118,33 @@ export function getClineCliMigrationNotice(
if (disableNotice && !forceNotice) {
return undefined;
}
// At most one notice per launch, oldest first, so a user who has already
// dismissed the ClinePass intro sees the desktop launch on their next start.
if (
shouldSuppressClineCliMigrationNoticeForActiveProvider(
!shouldSuppressClineCliMigrationNoticeForActiveProvider(
options.activeProviderId,
env,
)
) &&
(forceNotice || !noticeState.shown[CLINE_PASS_NOTICE_ID])
) {
return undefined;
return getClinePassNotice();
}
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
return undefined;
if (!noticeState.shown[DESKTOP_NOTICE_ID]) {
return getDesktopNotice();
}
return {
id: NOTICE_ID,
title: "Try ClinePass",
};
return undefined;
}
export function markClineCliMigrationNoticeShown(
dataDir = resolveClineDataDir(),
noticeId = CLINE_PASS_NOTICE_ID,
): void {
const noticePath = resolveCliNoticeStatePath(dataDir);
const noticeState = readNoticeState(noticePath);
const nextState: CliNoticeState = {
shown: {
...noticeState.shown,
[NOTICE_ID]: true,
[noticeId]: true,
},
};
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
+14 -2
View File
@@ -165,8 +165,14 @@ vi.mock("./runtime/run-interactive", () => {
vi.mock("./utils/session", () => sessionMocks);
vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", async () => {
// Keep dispatch tests independent of the full SDK runtime import graph.
// Only persisted-settings behavior needs its real implementation here.
const { readGlobalSettings } = await vi.importActual<
typeof import("../../../sdk/packages/core/src/services/global-settings")
>("../../../sdk/packages/core/src/services/global-settings");
return {
...(await vi.importActual("@cline/core")),
readGlobalSettings,
setSdkLogger: vi.fn(),
resolveProviderConfig: llmMocks.resolveProviderConfig,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
@@ -787,6 +793,9 @@ describe("runCli lightweight command dispatch", () => {
const notice = {
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
body: "ClinePass body",
url: "https://app.cline.bot/dashboard/subscription?personal=true",
openLabel: "Open ClinePass",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
process.argv = ["bun", "src/index.ts"];
@@ -810,7 +819,7 @@ describe("runCli lightweight command dispatch", () => {
await options?.onInitialNoticeShown?.(notice);
expect(
migrationNoticeMocks.markClineCliMigrationNoticeShown,
).toHaveBeenCalledTimes(1);
).toHaveBeenCalledWith(undefined, notice.id);
});
it("passes the active ClinePass provider into the migration notice gate", async () => {
@@ -1171,6 +1180,9 @@ describe("runCli lightweight command dispatch", () => {
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
body: "ClinePass body",
url: "https://app.cline.bot/dashboard/subscription?personal=true",
openLabel: "Open ClinePass",
});
process.argv = ["bun", "src/index.ts", "history"];
+2 -2
View File
@@ -1198,8 +1198,8 @@ export async function runCli(): Promise<void> {
activeProviderId: provider,
});
if (initialNotice) {
markInitialNoticeShown = () => {
markClineCliMigrationNoticeShown();
markInitialNoticeShown = (notice) => {
markClineCliMigrationNoticeShown(undefined, notice.id);
};
}
}
@@ -14,27 +14,6 @@ export function resolveHubUpdateRequiredKeyAction(
return "ignore";
}
/**
* Human phrase for the live work an outdated Hub is serving, used by the
* "Hub update required" dialog. Falls back to an unquantified phrase when the
* Hub could not answer the activity query.
*/
export function describeOutdatedHubSessions(counts: {
activeSessionCount?: number;
participantClientCount?: number;
}): string {
const sessions = counts.activeSessionCount;
if (typeof sessions !== "number" || sessions <= 0) {
return "active sessions from other Cline clients";
}
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
const clients = counts.participantClientCount;
if (typeof clients !== "number" || clients <= 0) {
return sessionsPhrase;
}
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
}
/**
* Yolo and sandbox sessions force the local backend and never attach to the
* shared managed Hub (see the forceLocalBackend condition in the interactive
@@ -1,11 +1,9 @@
// @jsxImportSource @opentui/react
import { describeOutdatedHubSessions } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useDialogPalette } from "../../hooks/use-theme";
import {
describeOutdatedHubSessions,
resolveHubUpdateRequiredKeyAction,
} from "./hub-update-required-helpers";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
hubCoreVersion?: string;
@@ -36,7 +36,7 @@ export function QueuedPrompts(props: {
? "Waiting. ↑/↓ navigate, Tab edit, Esc cancels turn"
: `Steered next. ↑/↓ navigate, Tab edit, ${escapeHint}`
: `↑/↓ navigate, Enter steer, Tab edit, ${escapeHint}`
: "↑ steer or edit messages";
: "Enter with empty input to steer first · ↑ select or edit";
return (
<box
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import type { KeyEvent } from "@opentui/core";
import { describe, expect, it, vi } from "vitest";
import { useRootKeyboard } from "./use-root-keyboard";
import { shouldHandleInputHistory } from "./root-keyboard-routing";
describe("root keyboard input history routing", () => {
@@ -29,3 +31,58 @@ describe("root keyboard input history routing", () => {
).toBe(false);
});
});
const keyboard = vi.hoisted(() => ({ handle: (_key: KeyEvent) => {} }));
vi.mock("@opentui/react", () => ({
useKeyboard: (handle: (key: KeyEvent) => void) => {
keyboard.handle = handle;
},
}));
vi.mock("react", async (importOriginal) => ({
...(await importOriginal<typeof import("react")>()),
useRef: (current: unknown) => ({ current }),
}));
vi.mock("../contexts/session-context", () => ({
useSession: () => ({ isRunning: true, isExitRequested: false }),
}));
describe("empty-input queue steering", () => {
it.each([
{ text: "", shift: false, promote: true },
{ text: " ", shift: false, promote: true },
{ text: "new draft", shift: false, promote: false },
{ text: "", shift: true, promote: false },
])("routes Enter with $text and shift=$shift", ({ text, shift, promote }) => {
const promotePrompt = vi.fn();
useRootKeyboard({
isDialogOpen: false,
appView: "chat",
autocomplete: { mode: false },
transcriptScrollRef: { current: null },
getCurrentInputText: () => text,
queuedPromptSelection: {
items: [
{ id: "first", prompt: "first", steer: false, attachmentCount: 0 },
{ id: "second", prompt: "second", steer: false, attachmentCount: 0 },
],
selectedId: null,
editingId: null,
promote: promotePrompt,
},
} as unknown as Parameters<typeof useRootKeyboard>[0]);
const preventDefault = vi.fn();
keyboard.handle({
name: "return",
ctrl: false,
meta: false,
shift,
repeated: false,
preventDefault,
} as unknown as KeyEvent);
expect(promotePrompt).toHaveBeenCalledTimes(promote ? 1 : 0);
if (promote) {
expect(promotePrompt).toHaveBeenCalledWith("first");
expect(preventDefault).toHaveBeenCalledOnce();
}
});
});
@@ -209,6 +209,22 @@ export function useRootKeyboard(input: {
return;
}
if (
!hasInputText &&
hasQueuedPrompts &&
!key.shift &&
!key.ctrl &&
!key.meta &&
(key.name === "enter" || key.name === "return")
) {
key.preventDefault();
const firstPrompt = queuedSelection.items[0];
if (firstPrompt && !firstPrompt.steer && !key.repeated) {
queuedSelection.promote(firstPrompt.id);
}
return;
}
if (key.name === "up" && canHandleInputHistory) {
if (
input.inputHistory.navigateHistory("up", input.inputValueRef.current)
+5 -1
View File
@@ -15,7 +15,10 @@ import {
useDialogState,
} from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
import {
CLINE_PASS_NOTICE_ID,
shouldSuppressClineCliMigrationNoticeForActiveProvider,
} from "../kanban-migration/notice";
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
import {
isSameRepoStatus,
@@ -552,6 +555,7 @@ function App(props: TuiProps) {
if (initialNoticeShownRef.current) return;
if (appView !== "home") return;
if (
notice.id === CLINE_PASS_NOTICE_ID &&
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
) {
initialNoticeShownRef.current = true;
+1 -1
View File
@@ -106,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
<title>Cline Hub</title>
</head>
<body>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cline Hub</title>
</head>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
</svg>
<svg width="113" height="113" viewBox="0 0 113 113" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M56.4998 8.99805L56.4999 5.29805H56.4998V8.99805ZM68.4998 20.708L72.1998 20.7081V20.708H68.4998ZM67.9929 25L64.3662 24.2671L63.4704 28.7H67.9929V25ZM84.4998 25L84.4998 21.3H84.4998V25ZM95.7 49.7998H92V51.2066L92.9346 52.258L95.7 49.7998ZM108.027 63.667L110.793 61.2088L110.792 61.2088L108.027 63.667ZM108.027 64.7305L110.792 67.1887L110.793 67.1883L108.027 64.7305ZM95.7 78.5977L92.9346 76.1394L92 77.1909V78.5977H95.7ZM95.7 92.2002L99.3999 92.2002V92.2002H95.7ZM84.4998 103.4V107.1H84.4998L84.4998 103.4ZM17.2996 92.2002H13.5996V92.2002L17.2996 92.2002ZM17.2996 78.5986H20.9996V77.1918L20.0649 76.1404L17.2996 78.5986ZM4.97144 64.7305L2.20602 67.1886L2.20611 67.1887L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L7.73685 66.1251L4.97144 63.667ZM17.2996 49.7979L20.065 52.256L20.9996 51.2046V49.7979H17.2996ZM45.0066 25V28.7H49.5292L48.6333 24.267L45.0066 25ZM44.4998 20.708H40.7998V20.7081L44.4998 20.708ZM56.4998 8.99805L56.4996 12.698C61.1812 12.6982 64.7998 16.3247 64.7998 20.708H68.4998H72.1998C72.1998 12.0452 65.0728 5.29829 56.4999 5.29805L56.4998 8.99805ZM68.4998 20.708L64.7998 20.7079C64.7997 21.6154 64.6614 22.8066 64.3662 24.2671L67.9929 25L71.6196 25.7329C71.969 24.004 72.1997 22.2864 72.1998 20.7081L68.4998 20.708ZM67.9929 25V28.7H84.4998V25V21.3H67.9929V25ZM84.4998 25L84.4997 28.7C88.6419 28.7001 92 32.0582 92 36.2002H95.7H99.3999C99.3999 27.9712 92.7287 21.3001 84.4998 21.3L84.4998 25ZM95.7 36.2002H92V49.7998H95.7H99.3999V36.2002H95.7ZM95.7 49.7998L92.9346 52.258L105.262 66.1252L108.027 63.667L110.792 61.2088L98.4653 47.3416L95.7 49.7998ZM108.027 63.667L105.262 66.1251C104.285 65.026 104.285 63.371 105.261 62.2727L108.027 64.7305L110.793 67.1883C112.308 65.4837 112.308 62.9141 110.793 61.2088L108.027 63.667ZM108.027 64.7305L105.262 62.2722L92.9346 76.1394L95.7 78.5977L98.4653 81.0559L110.792 67.1887L108.027 64.7305ZM95.7 78.5977H92V92.2002H95.7H99.3999V78.5977H95.7ZM95.7 92.2002L92 92.2002C91.9999 96.3422 88.6418 99.7003 84.4997 99.7004L84.4998 103.4L84.4998 107.1C92.7287 107.1 99.3999 100.429 99.3999 92.2002L95.7 92.2002ZM84.4998 103.4V99.7004H28.4998V103.4V107.1H84.4998V103.4ZM28.4998 103.4V99.7004C24.3577 99.7004 20.9996 96.3423 20.9996 92.2002L17.2996 92.2002L13.5996 92.2002C13.5996 100.429 20.2707 107.1 28.4998 107.1V103.4ZM17.2996 92.2002H20.9996V78.5986H17.2996H13.5996V92.2002H17.2996ZM17.2996 78.5986L20.0649 76.1404L7.73676 62.2722L4.97144 64.7305L2.20611 67.1887L14.5342 81.0569L17.2996 78.5986ZM4.97144 64.7305L7.73685 62.2723C8.71353 63.3711 8.71353 65.0264 7.73685 66.1251L4.97144 63.667L2.20602 61.2088C0.690476 62.9138 0.690476 65.4836 2.20602 67.1886L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L20.065 52.256L17.2996 49.7979L14.5341 47.3397L2.20602 61.2088L4.97144 63.667ZM17.2996 49.7979H20.9996V36.2002H17.2996H13.5996V49.7979H17.2996ZM17.2996 36.2002H20.9996C20.9996 32.0581 24.3576 28.7 28.4998 28.7V25V21.3C20.2707 21.3 13.5996 27.9712 13.5996 36.2002H17.2996ZM28.4998 25V28.7H45.0066V25V21.3H28.4998V25ZM45.0066 25L48.6333 24.267C48.3381 22.8066 48.1998 21.6154 48.1998 20.7079L44.4998 20.708L40.7998 20.7081C40.7998 22.2864 41.0305 24.0039 41.3799 25.733L45.0066 25ZM44.4998 20.708H48.1998C48.1998 16.3244 51.8182 12.698 56.4998 12.698V8.99805V5.29805C47.9264 5.29805 40.7998 12.0451 40.7998 20.708H44.4998Z" fill="#1C1C24"/>
<rect x="66.1001" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
<rect x="37.3" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
</svg>

Before

Width:  |  Height:  |  Size: 957 B

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+5 -16
View File
@@ -1,16 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="92px" height="96px" viewBox="0 0 92 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Group Copy 2</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="icon-copy" transform="translate(-34, -40)" fill="#24292F">
<g id="Group-Copy-2" transform="translate(34, 40.5)">
<g id="Group-3-Copy-4" transform="translate(0, 0)">
<path d="M65.4492701,16.3 C76.3374701,16.3 85.1635558,25.16479 85.1635558,36.1 L85.1635558,42.7 L90.9027661,54.1647464 C91.4694141,55.2966923 91.4668177,56.6300535 90.8957658,57.7597839 L85.1635558,69.1 L85.1635558,75.7 C85.1635558,86.63554 76.3374701,95.5 65.4492701,95.5 L26.0206986,95.5 C15.1328272,95.5 6.30641291,86.63554 6.30641291,75.7 L6.30641291,69.1 L0.448507752,57.7954874 C-0.14693501,56.6464093 -0.149634367,55.2802504 0.441262896,54.1288283 L6.30641291,42.7 L6.30641291,36.1 C6.30641291,25.16479 15.1328272,16.3 26.0206986,16.3 L65.4492701,16.3 Z M62.9301895,22 L29.189529,22 C19.8723267,22 12.3191987,29.5552188 12.3191987,38.875 L12.3191987,44.5 L7.44288578,53.9634655 C6.84794449,55.1180686 6.85066096,56.4896598 7.45017099,57.6418974 L12.3191987,67 L12.3191987,72.625 C12.3191987,81.9450625 19.8723267,89.5 29.189529,89.5 L62.9301895,89.5 C72.2476729,89.5 79.8005198,81.9450625 79.8005198,72.625 L79.8005198,67 L84.5682187,57.6061395 C85.1432011,56.473244 85.1458141,55.1345713 84.5752587,53.9994398 L79.8005198,44.5 L79.8005198,38.875 C79.8005198,29.5552188 72.2476729,22 62.9301895,22 Z" id="Combined-Shape" fill-rule="nonzero"></path>
<circle id="Oval" cx="45.7349843" cy="11" r="11"></circle>
</g>
<rect id="Rectangle-Copy" stroke="#24292F" stroke-width="8" x="31" y="44.5" width="5" height="22" rx="2.5"></rect>
<rect id="Rectangle-Copy-2" stroke="#24292F" stroke-width="8" x="55" y="44.5" width="5" height="22" rx="2.5"></rect>
</g>
</g>
</g>
</svg>
<svg width="113" height="113" viewBox="0 0 113 113" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M56.4998 8.99805L56.4999 5.29805H56.4998V8.99805ZM68.4998 20.708L72.1998 20.7081V20.708H68.4998ZM67.9929 25L64.3662 24.2671L63.4704 28.7H67.9929V25ZM84.4998 25L84.4998 21.3H84.4998V25ZM95.7 49.7998H92V51.2066L92.9346 52.258L95.7 49.7998ZM108.027 63.667L110.793 61.2088L110.792 61.2088L108.027 63.667ZM108.027 64.7305L110.792 67.1887L110.793 67.1883L108.027 64.7305ZM95.7 78.5977L92.9346 76.1394L92 77.1909V78.5977H95.7ZM95.7 92.2002L99.3999 92.2002V92.2002H95.7ZM84.4998 103.4V107.1H84.4998L84.4998 103.4ZM17.2996 92.2002H13.5996V92.2002L17.2996 92.2002ZM17.2996 78.5986H20.9996V77.1918L20.0649 76.1404L17.2996 78.5986ZM4.97144 64.7305L2.20602 67.1886L2.20611 67.1887L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L7.73685 66.1251L4.97144 63.667ZM17.2996 49.7979L20.065 52.256L20.9996 51.2046V49.7979H17.2996ZM45.0066 25V28.7H49.5292L48.6333 24.267L45.0066 25ZM44.4998 20.708H40.7998V20.7081L44.4998 20.708ZM56.4998 8.99805L56.4996 12.698C61.1812 12.6982 64.7998 16.3247 64.7998 20.708H68.4998H72.1998C72.1998 12.0452 65.0728 5.29829 56.4999 5.29805L56.4998 8.99805ZM68.4998 20.708L64.7998 20.7079C64.7997 21.6154 64.6614 22.8066 64.3662 24.2671L67.9929 25L71.6196 25.7329C71.969 24.004 72.1997 22.2864 72.1998 20.7081L68.4998 20.708ZM67.9929 25V28.7H84.4998V25V21.3H67.9929V25ZM84.4998 25L84.4997 28.7C88.6419 28.7001 92 32.0582 92 36.2002H95.7H99.3999C99.3999 27.9712 92.7287 21.3001 84.4998 21.3L84.4998 25ZM95.7 36.2002H92V49.7998H95.7H99.3999V36.2002H95.7ZM95.7 49.7998L92.9346 52.258L105.262 66.1252L108.027 63.667L110.792 61.2088L98.4653 47.3416L95.7 49.7998ZM108.027 63.667L105.262 66.1251C104.285 65.026 104.285 63.371 105.261 62.2727L108.027 64.7305L110.793 67.1883C112.308 65.4837 112.308 62.9141 110.793 61.2088L108.027 63.667ZM108.027 64.7305L105.262 62.2722L92.9346 76.1394L95.7 78.5977L98.4653 81.0559L110.792 67.1887L108.027 64.7305ZM95.7 78.5977H92V92.2002H95.7H99.3999V78.5977H95.7ZM95.7 92.2002L92 92.2002C91.9999 96.3422 88.6418 99.7003 84.4997 99.7004L84.4998 103.4L84.4998 107.1C92.7287 107.1 99.3999 100.429 99.3999 92.2002L95.7 92.2002ZM84.4998 103.4V99.7004H28.4998V103.4V107.1H84.4998V103.4ZM28.4998 103.4V99.7004C24.3577 99.7004 20.9996 96.3423 20.9996 92.2002L17.2996 92.2002L13.5996 92.2002C13.5996 100.429 20.2707 107.1 28.4998 107.1V103.4ZM17.2996 92.2002H20.9996V78.5986H17.2996H13.5996V92.2002H17.2996ZM17.2996 78.5986L20.0649 76.1404L7.73676 62.2722L4.97144 64.7305L2.20611 67.1887L14.5342 81.0569L17.2996 78.5986ZM4.97144 64.7305L7.73685 62.2723C8.71353 63.3711 8.71353 65.0264 7.73685 66.1251L4.97144 63.667L2.20602 61.2088C0.690476 62.9138 0.690476 65.4836 2.20602 67.1886L4.97144 64.7305ZM4.97144 63.667L7.73685 66.1251L20.065 52.256L17.2996 49.7979L14.5341 47.3397L2.20602 61.2088L4.97144 63.667ZM17.2996 49.7979H20.9996V36.2002H17.2996H13.5996V49.7979H17.2996ZM17.2996 36.2002H20.9996C20.9996 32.0581 24.3576 28.7 28.4998 28.7V25V21.3C20.2707 21.3 13.5996 27.9712 13.5996 36.2002H17.2996ZM28.4998 25V28.7H45.0066V25V21.3H28.4998V25ZM45.0066 25L48.6333 24.267C48.3381 22.8066 48.1998 21.6154 48.1998 20.7079L44.4998 20.708L40.7998 20.7081C40.7998 22.2864 41.0305 24.0039 41.3799 25.733L45.0066 25ZM44.4998 20.708H48.1998C48.1998 16.3244 51.8182 12.698 56.4998 12.698V8.99805V5.29805C47.9264 5.29805 40.7998 12.0451 40.7998 20.708H44.4998Z" fill="#1C1C24"/>
<rect x="66.1001" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
<rect x="37.3" y="49.8008" width="9.6" height="28.8" rx="4.8" fill="#1C1C24"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -60,6 +60,7 @@ import { desktopClient } from "@/lib/desktop-client";
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
import { normalizeProviderId } from "@/lib/provider-id";
import {
filterChatModels,
loadProviderModelCatalog,
loadProviderModels,
} from "@/lib/provider-model-catalog";
@@ -492,7 +493,9 @@ export function RoutineSchedulesContent() {
async function loadModelsForProvider() {
try {
const models = await loadProviderModels(normalizedProvider);
const models = filterChatModels(
await loadProviderModels(normalizedProvider),
);
if (cancelled || models.length === 0) {
return;
}
@@ -16,57 +16,48 @@ export type ProviderModelCatalog = {
providerReasoningModels: Record<string, string[]>;
};
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
)
.map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter(
(model) =>
model.supportsReasoning &&
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
)
.map((model) => model.id);
export function filterChatModels(
models: ProviderModel[] | undefined,
): ProviderModel[] {
return (models ?? []).filter((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
);
}
export function buildProviderModelCatalog(
providers: Provider[],
): ProviderModelCatalog {
const providerEntries = providers.map((provider) => {
const chatModels = filterChatModels(provider.modelList);
return {
provider,
modelIds: chatModels.map((model) => model.id),
reasoningModelIds: chatModels
.filter((model) => model.supportsReasoning)
.map((model) => model.id),
};
});
return {
providers,
enabledProviderIds: providers
enabledProviderIds: providerEntries
.filter(
(provider) =>
provider.enabled && toModelIds(provider.modelList).length > 0,
({ provider, modelIds }) => provider.enabled && modelIds.length > 0,
)
.map((provider) => provider.id),
.map(({ provider }) => provider.id),
providerModels: Object.fromEntries(
providers.map((provider) => [
provider.id,
toModelIds(provider.modelList),
]),
providerEntries.map(({ provider, modelIds }) => [provider.id, modelIds]),
),
providerReasoningModels: Object.fromEntries(
providers.map((provider) => [
providerEntries.map(({ provider, reasoningModelIds }) => [
provider.id,
toReasoningModelIds(provider.modelList),
reasoningModelIds,
]),
),
};
+60
View File
@@ -1,5 +1,65 @@
# Cline Desktop Changelog
## 0.0.30
- Windows updates no longer fail with "Error opening file for writing". The installer hook meant to stop the running backend never terminated anything: Tauri ships a 32-bit NSIS installer, so its PowerShell ran under WOW64, where `Get-Process` reports an empty path for every 64-bit process — the filter matched no sidecar on any x64 machine. The installer then wrote over an executable the detached Hub daemon still held open, which is why updating from 0.0.25 through 0.0.28 failed exactly as 0.0.24 did. The installer now asks the Windows Restart Manager which processes hold this install's files and shuts those down; that is bitness-independent, covers the connector processes the Hub spawns, and leaves a side-by-side Cline Beta alone. If Restart Manager itself fails you get a Retry/Cancel prompt rather than a half-replaced install
- On Windows, Cline no longer runs a program out of your workspace when it means to run the system one. Windows resolves a bare program name through the child process's working directory before it searches PATH, and Cline runs `rg`, `git`, and `powershell` with your workspace as that directory — so a repository that happened to contain its own `rg.exe` would have that copy executed as soon as the workspace was indexed. Cline now opts out of current-directory executable search at startup, and every process it spawns inherits the setting
- Long sessions now compact when they actually need to rather than running out of room. The trigger compared a character-based estimate of the transcript (~3 characters per token) against the model's context limit, so content that tokenizes far denser than that — disassembly, image dumps, minified sources — could fill the real context window while the estimate stayed under the threshold and compaction never fired; the turn was then squeezed down to a handful of output tokens. The trigger now also uses the token count the provider itself reports, and how much history is kept is scaled by how far off the estimate turned out to be. The summarizer's output budget also doubled, so a model that reasons before answering can no longer spend the whole budget thinking and return no summary at all
- A tool that streams its output no longer keeps spinning after it has finished. When a tool streamed everything as it went, its completion event carried no final output, which chat read as "still running" — so the tool kept its running presentation, spinner included, for the rest of the turn
- Refreshed Cline's logo and app icons throughout the app, the Dock and taskbar, and the installer
- The CoreWeave provider's branding and setup links now match CoreWeave's current documentation, and Weights & Biases gained a direct link to its API key page from provider setup
## 0.0.29
- Queued messages can now be sent into the running turn with Enter. While the agent is working, pressing Enter with an empty composer steers the first message in the queue, so it is picked up at the next turn boundary instead of waiting for the whole task to finish; the composer placeholder says so whenever the queue is non-empty. The queue head is claimed atomically, so a fast second Enter can't steer a message that has already left the queue, and a steer that fails now surfaces as a toast rather than silently doing nothing
- The macOS Dock icon now has the standard margin other apps use. The bundled icon and the selectable runtime icons (Classic, Chip, Hologram, Midnight) were edge-to-edge, so Cline rendered visibly larger than its neighbours in the Dock. Windows keeps its existing artwork
- The session sidebar's sort toggle now shows the mode a click switches to, not the one already active — a folder icon while sorted by time, a clock while grouped by project
## 0.0.28
- The app no longer gets stuck on "Desktop backend unavailable" when the backend is slow to start. The window asked for the backend's address exactly once and the shell stops waiting after about 15 seconds, so on a slower machine — where starting the hub pushed past that — you were left on the error screen until you relaunched, and the relaunch could lose the same race. Connection attempts now keep retrying and re-resolve the address each time, so a backend that has since restarted is reached at its current one
- Confirming text with a Chinese or Japanese IME no longer sends the message. The Enter that commits an in-progress composition also reached the composer, so choosing a candidate fired off a half-typed message; Enter and the arrow keys now belong to the IME while you are composing
- The model picker refreshes its list every time you open it, instead of staying frozen until you restart the app. The Recommended and Free groupings come from a live feed, so a newly promoted free model would otherwise not show up for the rest of the session
- Signing in with Cline now explains what the plan includes. The sign-in card lists the regular free model promotions, ClinePass for generous usage across open-weight models like DeepSeek, Kimi, and GLM, and that no API key is needed; the last onboarding step then shows the current free models alongside a ClinePass summary. This only appears when you sign in with Cline, not when you bring your own API key
- Cline Pass now starts you on a model from your subscription. Its model list carries both subscription and free models and the default was simply the most recently published entry, so a subscriber who never picked a model was left on a free model instead of the tier they pay for
- A turn that fails partway through with a temporary provider error is now retried up to three times instead of failing the task. A single rate-limit response passed along by a provider gateway previously ended the turn outright. A turn that has already produced output is never retried, so nothing is duplicated
- Long responses stream faster. Every chunk of a streaming reply was being handed to client-side hooks as a round trip carrying a full copy of the session, with the agent waiting on each one before continuing
- The command-running tool now tells the model which PowerShell edition it is talking to — `Windows PowerShell (powershell.exe)` versus `PowerShell (pwsh.exe)` — and to write commands directly rather than wrapping them in a second shell invocation, which was corrupting pipelines that use `$_`
- Refreshed the model catalog. Adds three providers (Infer by Flow7, Melious, and Wallaby) and takes the catalog from 5,923 to 6,079 models. The resolved default model changes for 33 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go — while NVIDIA moves to GLM 5.3 Flash and NanoGPT to GPT Astra. If you use a provider without pinning a model, expect a different default
## 0.0.27
- An expired Cline sign-in now produces one actionable error instead of two dead ends. A turn that fails before the runtime takes the prompt was reported twice — the hub's detail-less `run.failed` appended a bubble, then the send RPC resolved with the real error and overwrote the error banner underneath it — so you got a vague bubble stacked on a detailed banner. The failure now renders exactly once, upgrading in place when the detailed report arrives. Credential failures also carry a fix button: **Sign in to Cline** for the Cline provider (Settings → API Providers reads a stale token as "Signed in via browser", so there is nothing to fix there), **Open model settings** for others, and local-CLI providers keep pointing at their CLI. Sessions that fail to start over credentials get the same hint and action
- The Account page now offers a sign-in prompt when your Cline refresh token is rejected, instead of an error card whose **Retry** failed the same way. A rejected refresh was falling back to the persisted access token, which is dead too, so the account request 401'd; a rejected refresh now reports as signed out. Transient refresh failures still fall back
- Signing out of Cline and Cline Pass now sticks. Sign-out removes the provider entry, but the runtime re-imports missing providers from the classic extension's stored credentials on every command, so you appeared signed straight back in — the same root cause as the ChatGPT/Codex fix in 0.0.26, which only cleared Codex secrets. Cline Pass sign-out did nothing at all, because its credentials live under the Cline provider entry rather than its own
- The settings sidebar's **Models** section is now **API Providers**, with an icon to match — it is where you configure providers and their credentials, not where you pick a model
- A failed voice input now shows a "Speech input failed" toast in chat with the actual error, instead of dropping you into Settings → Voice. Any error that wasn't a browser exception was treated as a configuration problem, even though the microphone is already gated on a configured transcription model — so a connection or transcription failure interrupted the chat and hid the real reason. Your draft and the microphone button stay put so you can retry; microphone-permission guidance is unchanged
- OpenCode Go models work again. Requests were missing the `x-opencode-session` header Go requires to route a conversation ("Request is missing x-opencode-session and cannot be routed efficiently"), and every model was sent to `/chat/completions` because catalog normalization discarded the per-model adapter declaration — so Muse Spark, GPT, and Grok models that expect `/responses`, and MiniMax/Qwen models that expect `/messages`, failed with internal server errors. Model-level protocol routing is now preserved end to end, retries reuse the same session identity, and Go Qwen entries that omit the declaration fall back to Messages
- Added Crusoe as an OpenAI-compatible provider
- Settings switches are lighter in light mode and are now a shared native control — real keyboard, label, and form behavior, a guaranteed 24px hit area, and support for reduced-motion and forced-colors
- The welcome screen's eye color matches the dark theme, and its animation no longer triggers layout work each frame
## 0.0.26
- The composer now shows the current branch's GitHub pull request — PR number, merge status, changed-line totals, and CI checks. Click through to open it in your browser, or expand CI to inspect individual checks and their logs; status refreshes every 30 seconds while visible, on window focus, and on demand. If the branch has no PR, **Create PR** opens GitHub's comparison form. Requires the GitHub CLI (`gh`) installed and signed in, plus a GitHub.com `origin` remote; the row hides itself on the default branch, detached HEAD, and unsupported repositories. Cline does not push commits or submit the PR for you
- The Customize view's Tools, Skills, and Rules tabs now read as one consistent list instead of three different ones, matching the pattern Plugins already used. Tools gets a search bar that filters both sections and per-section Enable all/Disable all that only touches what's visible; Skills gets an in-place enable/disable toggle — previously the desktop app had no concept of a disabled skill, so disabled ones were hidden and unreachable — and the same Copy path / Uninstall menu Plugins use. Tabs are reordered to Tools, Plugins, Skills, Rules, MCP, Hooks and open on Tools, and the sidebar's "New Task" button is now "New Session"
- Deleting a queued prompt no longer leaves it in the transcript as a message that never ran. The sidecar inferred a queued prompt had started whenever the pending list shrank and its head changed — which is exactly what deleting the first queued prompt (or discarding the queue) looks like. It now relies only on the runtime's real start event
- A session forked from a checkpoint restore no longer comes back stuck on "Thinking...". Sessions that materialize with seeded history never passed their status when persisting, so the row and manifest were written as "running" while the live session was idle; resuming from that manifest then showed a session that was never going to finish
- Sending an image with no text no longer fails with "session input requires a prompt string" — the composer asks for a message instead of letting the request through to a backend that rejects it
- Image attachments in formats the model pipeline can't read (HEIC, TIFF, SVG, BMP, ICO, and friends) are now rejected at attach time — by picker, paste, or drag-and-drop — instead of failing later in the turn. Files whose type has to be inferred from the extension are classified the same way
- Signing out of ChatGPT (Codex) now sticks. Sign-out removes the provider entry, but the runtime re-imports any missing provider from the classic extension's stored credentials on every command, so the next action signed you straight back in. The legacy Codex credentials are now cleared too, and a failed clear reports the sign-out as failed instead of quietly succeeding
- Auth failures on providers that log in through a local CLI — Claude Code, Codex CLI, OpenCode — now point you at that CLI instead of Settings → Models, where there is nothing to fix. "OAuth session expired", "Not logged in · Please run /login", and similar messages are also recognized as credential failures now, so they get a hint at all
- Model lists now refresh from the live catalog for every provider that uses the shared catalog, not just Cline and Cline Pass. Providers with their own endpoint-owned model lists are unaffected, and the first providers listing is still network-free
- Cline Pass and free models now report zero cost instead of the upstream provider's price
- Session history rows no longer overlap their own hover metadata when a label is wider than the fixed label column
- Scheduled sessions no longer stall out. The poller could stop advancing, and capacity waits were counted as run attempts, so a schedule could burn through its retries without ever running. Execution lifecycle and capacity claims are now fenced atomically. New recurring schedules also default to your local timezone instead of UTC; existing schedules without a timezone keep it that way when edited
- Automation event acceptance is now atomic and retryable, so an event can't be half-accepted and lost if delivery fails mid-way
- Nested PowerShell commands no longer flood errors and look like a hang. Commands run through an outer PowerShell bootstrap, so a nested `powershell -Command "... $_ ..."` had `$_` interpolated away by the outer parser before the inner shell saw it — a `Where-Object { $_.Name ... }` pipeline then errored once per item over a large tree while still exiting 0. Redundant nested invocations are now unwrapped and run directly, only when that provably preserves semantics (same PowerShell edition, no profile loading, fully quoted command). Cline is also told which edition it's actually on — Windows PowerShell vs. Microsoft PowerShell — and to stop wrapping commands in a shell it's already running in
- Prompt telemetry to Cline's tracing backend is now limited to Cline and Cline Pass requests. Turns run against your own provider keys are not traced
- Cline Pass now appears in the composer's provider picker alongside Cline. One Cline sign-in configures both — Cline Pass stores its credentials under the Cline account rather than having its own — but the picker only listed providers with their own saved settings entry, and onboarding writes just the Cline one. A new Cline Pass user therefore saw a single row
- The composer's provider picker has a **Set up another provider** row at the bottom that opens Settings → Models. The picker only lists what you have already configured, so there was no way to reach the rest of the catalog from the composer
- Dropped the "Configured" checkmark from the composer's provider picker (added in 0.0.25). Every row in that picker has a saved settings entry, so for anyone who set their providers up in the app every row carried the same green check and it read as decoration. Settings is still where provider readiness is shown, and it distinguishes a real credential from an entry a legacy migration left behind
## 0.0.25
- ChatGPT Subscription (Codex) now lists only the models your plan can actually use. Two separate paths filled the picker from the shared OpenAI catalog, so GPT-4o, GPT-4.1, and `chatgpt-image-latest` showed up alongside the Codex models, and the runtime lost the Codex context caps. The model rules also match what the backend now accepts: `gpt-5.4` and `gpt-5.4-mini` were retired for ChatGPT accounts on 2026-08-31 and are gone, the default moves to `gpt-5.6-terra`, and every Codex model is capped at the real 400K/272K/128K backend budget instead of inheriting the API's 1.05M limits
+64 -1
View File
@@ -10,13 +10,76 @@ From `apps/examples/desktop-app/`:
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
- `bun run dev` - Tauri desktop dev
- `bun run build` - build web assets
- `bun run build:web` - build production web assets only (includes the shared UI build)
- `bun run build` - build web assets and the sidecar binary
- `bun run build:sidecar` - build the Bun sidecar bundle
- `bun run build:sidecar:bin` - compile the Bun sidecar into a local binary
- `bun run build:binary` - build desktop binary
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
### Checking webview changes
Run `bun run build:web` from this directory when changing webview imports or shared browser APIs. Type checking and Vitest do not check the production browser bundle: a valid TypeScript import can still pull Node-only modules into a client chunk. Use `@cline/shared/browser` for runtime imports in the webview; the bare `@cline/shared` source alias points to the Node entry point.
## Pull Requests
The composer shows the current branch's GitHub pull request, merge status,
changed-line totals, and CI checks. Click the PR number to open it in your
browser, or expand CI to inspect individual checks and their logs. Status
refreshes every 30 seconds while visible, when the app regains focus, and
when you click refresh.
This requires GitHub CLI (`gh`) installed and authenticated with `gh auth login`,
and a GitHub.com `origin` remote (HTTPS or SSH). The row is hidden for the
default branch, detached HEAD, and unsupported repositories. If the branch
has no PR, **Create PR** opens GitHub's comparison form; push your commits
before submitting the form. The app does not push commits or submit PRs itself.
Missing or unauthenticated GitHub CLI also hides the row. Availability checks
are shared across workspaces and cached for five minutes, so unavailable CLI
installs do not spawn a failing process on every poll or window focus. After
installing or signing into `gh`, the feature becomes available on the first
refresh after the cache expires (or after restarting the desktop backend).
Initial lookup failures stay hidden. Errors after a successful status load
can be dismissed and remain dismissed through retries until a load succeeds.
### Pull request telemetry
These events use the desktop telemetry service and respect telemetry opt-out:
| Event | Trigger |
| --- | --- |
| `desktop.pull_request.shown` | First visible PR/create row per mounted workspace and branch |
| `desktop.pull_request.open_clicked` | Click the PR link |
| `desktop.pull_request.create_clicked` | Click Create PR (intent only, not PR submission) |
| `desktop.pull_request.checks_expanded` | Open the CI popover |
| `desktop.pull_request.check_clicked` | Click a check's details link |
| `desktop.pull_request.refresh_clicked` | Click manual refresh |
Each event contains only `prState`, `ciState`, and `mergeTone` categories.
The sidecar validates these values and strips extra fields. Repository/branch
names, paths, PR numbers/titles, check names, and URLs are not included.
Automatic polling does not emit additional impressions. Telemetry delivery
does not block interactions, and failures do not interrupt the feature.
## App Icons
`src-tauri/app-icon.png` (1024x1024, edge-to-edge) is the source for
`bun tauri icon`, which generates the Windows `.ico` and Linux PNGs in
`src-tauri/icons/`. macOS is the exception: Dock icons are expected to have a
transparent margin, with the artwork filling 824 of the 1024 canvas, so the
committed `icons/icon.icns` is built from a padded copy of the source, and the
selectable runtime icons in `icons/app/macos/` are padded copies of the
Windows ones in `icons/app/`. To regenerate the macOS icon after changing the
artwork:
```bash
cd src-tauri
magick app-icon.png -resize 824x824 -background none -gravity center -extent 1024x1024 /tmp/app-icon-macos.png
bun tauri icon /tmp/app-icon-macos.png -o /tmp/icons-macos && cp /tmp/icons-macos/icon.icns icons/icon.icns
```
## Customizing the macOS Install Window
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.25",
"version": "0.0.30",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -10,6 +10,8 @@
"predev:headless": "bun run build:ui",
"dev:headless": "bun run scripts/dev-headless.ts",
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
"prebuild:web": "bun run build:ui",
"build:web": "next build webview",
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
@@ -27,6 +29,9 @@
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages --config vitest.config.ts",
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -60,7 +65,6 @@
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-toggle": "1.1.10",
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
});
it("inlines the cloud-agents toggle only when set at build time", () => {
const withoutToggle = defineMap(telemetryDefineArgs({}));
expect(withoutToggle).not.toHaveProperty(
"process.env.CLINE_CODE_CLOUD_AGENTS",
);
const withToggle = defineMap(
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
);
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
});
it("JSON-escapes values so headers with quotes survive the define", () => {
const defines = defineMap(
telemetryDefineArgs({
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
"ERROR_SERVICE_API_KEY",
] as const;
/** Optional build-time overrides for packaged dogfood builds. */
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
const define = (name: string, value: string) => {
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
};
for (const name of OPTIONAL_SECRET_ENV_VARS) {
for (const name of [
...OPTIONAL_SECRET_ENV_VARS,
...OPTIONAL_FEATURE_ENV_VARS,
]) {
const value = env[name];
if (value) {
define(name, value);
@@ -144,6 +144,17 @@ The frontend `desktop-client.ts` connects directly to the sidecar WebSocket:
## Command Map
The model picker first uses `list_provider_catalog`, which reads the bundled and
registered models without network access. It then calls `list_provider_models`
for the active provider, both on mount and when the provider changes. All built-in
providers backed by the shared catalog refresh from the live feed (including
OpenCode); concurrent requests share one fetch and reuse its ten-minute cache.
Endpoint-owned lists such as Baseten, Hicap, Poolside, LiteLLM, Ollama, and LM Studio use their existing
discovery endpoints instead. Catalog and public endpoint requests time out after
five seconds, and the initial picker remains usable while a refresh is pending.
The sidecar omits bundled `knownModels` from the discovery config so they cannot
override live metadata; explicitly registered model overrides retain precedence.
Supported commands:
| Command | Implementation |
@@ -2280,3 +2280,35 @@ describe("mistake-limit prompt", () => {
expect(start).toHaveBeenCalledTimes(1);
});
});
describe("queue steering routing", () => {
it.each([
undefined,
"selected-prompt",
])("routes steering with prompt ID %s", async (promptId) => {
const result = { sessionId: "session", prompts: [], updated: false };
const steerFirst = vi.fn(async () => result);
const update = vi.fn(async () => result);
const ctx = {
liveSessions: new Map(),
wsClients: new Set(),
sessionManager: { pendingPrompts: { steerFirst, update } },
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "steer_prompt",
sessionId: "session",
promptId,
});
if (promptId === undefined) {
expect(steerFirst).toHaveBeenCalledWith({ sessionId: "session" });
expect(update).not.toHaveBeenCalled();
} else {
expect(update).toHaveBeenCalledWith({
sessionId: "session",
promptId,
delivery: "steer",
});
expect(steerFirst).not.toHaveBeenCalled();
}
});
});
@@ -1711,14 +1711,17 @@ async function handleSteerPrompt(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
if (!sessionId) throw new Error("sessionId is required");
if (request.promptId !== undefined && !promptId)
throw new Error("promptId cannot be empty");
const manager = getSessionManager(ctx);
const result = await manager.pendingPrompts.update({
sessionId,
promptId,
delivery: "steer",
});
const result = promptId
? await manager.pendingPrompts.update({
sessionId,
promptId,
delivery: "steer",
})
: await manager.pendingPrompts.steerFirst({ sessionId });
return {
sessionId,
updated: result.updated === true,
@@ -0,0 +1,51 @@
import {
captureAuthRefreshSoftFailure,
getProviderAuthHandler,
OAuthReauthRequiredError,
type ProviderSettingsManager,
RuntimeOAuthTokenManager,
} from "@cline/core";
import type { SidecarContext } from "./types";
// Share the refresh-aware manager so single-use refresh tokens stay single-flight.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
export async function resolveFreshClineAuthToken(
manager: ProviderSettingsManager,
ctx?: SidecarContext,
): Promise<string | undefined> {
let refreshError: Error | undefined;
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch (error) {
// A persisted token may still let the account request surface the failure.
refreshError = error instanceof Error ? error : new Error(String(error));
}
// Apply canonical OAuth-token formatting while preserving raw API keys.
const persisted = getProviderAuthHandler("cline")?.getApiKey(
manager.getProviderSettings("cline"),
);
if (!persisted && refreshError && ctx) {
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
error: refreshError,
});
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
errorName: refreshError.name,
errorCode: "desktop_refresh_failed_no_fallback_token",
});
}
// A rejected refresh token means the persisted access token is dead too.
// Handing it out would turn the signed-out state into an opaque request
// failure (an error card whose Retry fails the same way) instead of the
// sign-in prompt. Transient refresh failures still fall back to it.
if (refreshError instanceof OAuthReauthRequiredError) {
return undefined;
}
return persisted;
}
@@ -0,0 +1,505 @@
import type { HubEventEnvelope } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { reconcileBufferedCloudEvents } from "./cloud-session-snapshots";
describe("reconcileBufferedCloudEvents", () => {
const event = (
name: HubEventEnvelope["event"],
id: string,
payload: Record<string, unknown> = {},
): HubEventEnvelope => ({
version: "v1",
event: name,
eventId: id,
timestamp: Date.now(),
sessionId: "inner-1",
payload,
});
it.each([
"Continue",
"",
])("preserves submitted lifecycle while marking only newly reflected %j prompts", (prompt) => {
const submitted = (id: string) =>
event("session.pending_prompt_submitted", id, {
prompt: {
id,
prompt,
delivery: "queue",
...(prompt
? {}
: {
userImages: ["data:image/png;base64,AA=="],
attachmentCount: 1,
}),
},
});
const first = submitted("q-1");
const later = submitted("q-2");
const baseline = [
{
role: "user",
content: prompt || [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "AA==" },
},
],
},
];
// An older identical user message must not consume a new submission.
expect(
reconcileBufferedCloudEvents([first], baseline, {
baselineMessages: baseline,
}),
).toEqual([first]);
const toolResult = {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call-1", content: "done" },
],
};
expect(
reconcileBufferedCloudEvents([first], [...baseline, toolResult], {
baselineMessages: baseline,
}),
).toEqual([first]);
const snapshot = [
...baseline,
toolResult,
{
...baseline[0],
...(prompt
? {
content: [
...toolResult.content,
{ type: "text", text: `<user_input>${prompt}</user_input>` },
],
}
: {}),
},
];
const completed = event("run.completed", "done");
const running = event("run.started", "next");
// Keep the next turn's start between lifecycle events; only its bubble is reflected.
expect(
reconcileBufferedCloudEvents(
[completed, first, running, later],
snapshot,
{
baselineMessages: baseline,
},
),
).toEqual([
completed,
{ ...first, payload: { ...first.payload, transcriptReflected: true } },
running,
later,
]);
// A submission received after the transcript reply cannot be in it.
expect(
reconcileBufferedCloudEvents([later], snapshot, {
baselineMessages: baseline,
messagesSnapshotEventCutoff: 0,
}),
).toEqual([later]);
});
it("replays content the snapshot does NOT contain", () => {
const buffered = [
event("assistant.delta", "a-1", { text: "unpersisted reply" }),
event("run.completed", "done-1"),
];
expect(
reconcileBufferedCloudEvents(buffered, [
{ role: "assistant", content: "a completely different answer" },
]).map((item) => item.event),
).toEqual(["assistant.delta", "run.completed"]);
});
it("does not let an interior substring claim another run's snapshot", () => {
const buffered = [
event("assistant.finished", "a-1", { text: "foo" }),
event("run.completed", "done-1"),
event("assistant.finished", "a-2", { text: "The answer is foobar" }),
event("run.completed", "done-2"),
];
expect(
reconcileBufferedCloudEvents(buffered, [
{ role: "assistant", content: "The answer is foobar" },
]).map((item) => item.eventId),
).toEqual(["a-1", "done-1", "done-2"]);
});
it.each([
["bar", "foobar", false],
["bar", "foobar", true],
["Done", "Done", false],
] as const)("preserves aborted %j beside saved %j (reverse: %s)", (partial, saved, reverse) => {
const aborted = [
event("assistant.delta", "partial", { text: partial }),
event("run.aborted", "aborted"),
];
const completed = [
event("assistant.delta", "saved-delta", { text: saved }),
event("assistant.finished", "saved-finished", { text: saved }),
event("run.completed", "completed"),
];
expect(
reconcileBufferedCloudEvents(
reverse ? [...completed, ...aborted] : [...aborted, ...completed],
[{ role: "assistant", content: saved }],
).map((item) => item.eventId),
).toEqual(
reverse
? ["completed", "partial", "aborted"]
: ["partial", "aborted", "completed"],
);
});
it.each([
"assistant",
"reasoning",
] as const)("does not let finished %s hide the other aborted content", (finished) => {
const partial = finished === "assistant" ? "reasoning" : "assistant";
const buffered = [
event(`${finished}.finished`, "saved", {
text: "foobar",
reasoning: "foobar",
}),
event(`${partial}.delta`, "partial", { text: "bar" }),
event("run.aborted", "aborted"),
];
expect(
reconcileBufferedCloudEvents(buffered, [
{
role: "assistant",
content: [
{ type: "text", text: "foobar" },
{ type: "thinking", thinking: "foobar" },
],
},
]).map((item) => item.eventId),
).toEqual(["partial", "aborted"]);
});
it.each([
["assistant", false],
["reasoning", false],
["reasoning", true],
] as const)("preserves unfinished %s after saved output (redacted: %s)", (kind, redacted) => {
const saved = [
event(`${kind}.delta`, "saved-delta", {
text: redacted ? "" : "saved",
redacted,
}),
event(`${kind}.finished`, "saved-finished", {
[kind === "assistant" ? "text" : "reasoning"]: redacted
? undefined
: "saved",
}),
];
const partial = event(`${kind}.delta`, "partial", { text: "unfinished" });
const snapshot = [
{
role: "assistant",
content: [
redacted
? { type: "redacted_thinking", data: "opaque" }
: kind === "assistant"
? { type: "text", text: "saved" }
: { type: "thinking", thinking: "saved" },
],
},
];
for (const terminal of ["run.aborted", "run.failed"] as const) {
const end = event(terminal, "end");
const buffered = [...saved, partial, end];
expect(reconcileBufferedCloudEvents(buffered, snapshot)).toEqual([
partial,
end,
]);
const earlier = [
{
role: "assistant",
content: [
kind === "assistant"
? { type: "text", text: "earlier" }
: { type: "thinking", thinking: "earlier" },
],
},
];
expect(
reconcileBufferedCloudEvents(
[
event(`${kind}.finished`, "earlier", {
[kind === "assistant" ? "text" : "reasoning"]: "earlier",
}),
...buffered,
],
[...earlier, ...snapshot],
{ baselineMessages: earlier },
),
).toEqual([partial, end]);
expect(reconcileBufferedCloudEvents(buffered, [])).toEqual(buffered);
expect(
reconcileBufferedCloudEvents(buffered, snapshot, {
baselineMessages: snapshot,
}),
).toEqual(buffered);
expect(
reconcileBufferedCloudEvents(buffered, snapshot, {
messagesSnapshotEventCutoff: 1,
}),
).toEqual(
terminal === "run.aborted" ? buffered : [saved[1], partial, end],
);
}
});
it.each([
"assistant",
"reasoning",
] as const)("preserves %s output completed after the snapshot", (kind) => {
const key = kind === "assistant" ? "text" : "reasoning";
const saved = event(`${kind}.finished`, "saved", { [key]: "saved" });
const partial = event(`${kind}.delta`, "partial", { text: "new reply" });
const finished = event(`${kind}.finished`, "finished", {
[key]: "new reply",
});
const end = event("run.completed", "end");
expect(
reconcileBufferedCloudEvents(
[saved, partial, finished, end],
[
{
role: "assistant",
content: [
{
type: kind === "assistant" ? "text" : "thinking",
[kind === "assistant" ? "text" : "thinking"]: "saved",
},
],
},
],
{ messagesSnapshotEventCutoff: 2 },
),
).toEqual([partial, finished, end]);
});
it.each([
"run.completed",
"run.aborted",
"run.failed",
] as const)("ignores empty finishes after saved content in %s", (terminal) => {
const end = event(terminal, "end");
expect(
reconcileBufferedCloudEvents(
[
event("assistant.finished", "saved", { text: "saved" }),
event("assistant.finished", "empty", { text: "" }),
event("assistant.finished", "missing"),
end,
],
[{ role: "assistant", content: "saved" }],
),
).toEqual([end]);
});
it("supersedes despite trailing whitespace in the streamed text", () => {
const buffered = [
event("assistant.finished", "f-1", { text: "the answer \n" }),
event("run.completed", "done-1"),
];
expect(
reconcileBufferedCloudEvents(buffered, [
{ role: "assistant", content: "prefix the answer" },
]).map((item) => item.event),
).toEqual(["run.completed"]);
});
it("drops buffered queue snapshots when a fresh queue snapshot was applied", () => {
const buffered = [
event("session.pending_prompts", "q-1", { prompts: [] }),
event("assistant.delta", "a-1", { text: "live tail" }),
];
expect(
reconcileBufferedCloudEvents(buffered, []).map((item) => item.event),
).toEqual(["assistant.delta"]);
});
it("replays the newest buffered queue snapshot when the queue fetch failed", () => {
const buffered = [
event("session.pending_prompts", "q-1", { prompts: [] }),
event("session.pending_prompts", "q-2", {
prompts: [{ id: "p-1", prompt: "queued work" }],
}),
event("assistant.delta", "a-1", { text: "live tail" }),
];
expect(
reconcileBufferedCloudEvents(buffered, [], {
queueSnapshotApplied: false,
}).map((item) => item.eventId),
).toEqual(["q-2", "a-1"]);
});
it("replays a queue snapshot received after the queue fetch", () => {
const buffered = [
event("session.pending_prompts", "q-old", { prompts: [] }),
event("assistant.delta", "a-1", { text: "live tail" }),
event("session.pending_prompts", "q-new", {
prompts: [{ id: "p-1", prompt: "queued work" }],
}),
];
expect(
reconcileBufferedCloudEvents(buffered, [], {
queueSnapshotEventCutoff: 1,
}).map((item) => item.eventId),
).toEqual(["a-1", "q-new"]);
});
it("supersedes content independently across two terminal run segments", () => {
const buffered = [
event("assistant.delta", "a-1", { text: "first tail" }),
event("run.completed", "done-1"),
event("assistant.delta", "a-2", { text: "second tail" }),
event("run.completed", "done-2"),
];
expect(
reconcileBufferedCloudEvents(buffered, [
{ role: "assistant", content: "prefix first tail" },
{ role: "assistant", content: "prefix second tail" },
]).map((item) => item.event),
).toEqual(["run.completed", "run.completed"]);
});
it("does not let an older identical reply supersede a new buffered turn", () => {
const buffered = [
event("assistant.delta", "a-2", { text: "Done" }),
event("run.completed", "done-2"),
];
const baseline = [{ role: "assistant", content: "Done" }];
expect(
reconcileBufferedCloudEvents(buffered, baseline, {
baselineMessages: baseline,
}).map((item) => item.event),
).toEqual(["assistant.delta", "run.completed"]);
expect(
reconcileBufferedCloudEvents(
buffered,
[...baseline, { role: "assistant", content: "Done" }],
{ baselineMessages: baseline },
).map((item) => item.event),
).toEqual(["run.completed"]);
});
it("keeps run.failed while suppressing reflected content and dedupes tools by id", () => {
const buffered = [
event("assistant.delta", "a-1", { text: "partial failure" }),
event("tool.started", "tool-1", { toolCallId: "call-1" }),
event("run.failed", "failed-1", { error: "boom" }),
];
expect(
reconcileBufferedCloudEvents(buffered, [
{ role: "assistant", content: "saved partial failure" },
{
role: "assistant",
content: [{ type: "tool_use", id: "call-1", name: "read_file" }],
},
]).map((item) => item.event),
).toEqual(["run.failed"]);
});
it("only suppresses tool phases present in the transcript before its cutoff", () => {
const started = event("tool.started", "start", { toolCallId: "call-1" });
const finished = event("tool.finished", "finish", {
toolCallId: "call-1",
output: "done",
});
const snapshot = [
{
role: "assistant",
content: [{ type: "tool_use", id: "call-1", name: "read_file" }],
},
];
expect(reconcileBufferedCloudEvents([started, finished], snapshot)).toEqual(
[finished],
);
const completedSnapshot = [
...snapshot,
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call-1", content: "done" },
],
},
];
expect(
reconcileBufferedCloudEvents([started, finished], completedSnapshot),
).toEqual([]);
expect(
reconcileBufferedCloudEvents([started, finished], completedSnapshot, {
messagesSnapshotEventCutoff: 1,
}),
).toEqual([finished]);
});
it.each([
["run.failed", false],
["run.aborted", false],
["run.aborted", true],
] as const)("does not replay persisted thinking for %s (redacted: %s)", (terminal, redacted) => {
const buffered = [
event("reasoning.delta", "thinking", {
text: redacted ? "" : "Checking",
redacted,
}),
event("reasoning.finished", "thought", {
reasoning: redacted ? undefined : "Checking",
}),
event(terminal, "end"),
];
const snapshot = [
{
role: "assistant",
content: [
redacted
? { type: "redacted_thinking", data: "opaque" }
: { type: "thinking", thinking: "Checking" },
],
},
];
expect(reconcileBufferedCloudEvents(buffered, snapshot)).toEqual([
buffered[2],
]);
expect(reconcileBufferedCloudEvents(buffered, [])).toEqual(buffered);
expect(
reconcileBufferedCloudEvents(buffered, snapshot, {
baselineMessages: snapshot,
}),
).toEqual(buffered);
expect(
reconcileBufferedCloudEvents(buffered, snapshot, {
messagesSnapshotEventCutoff: 0,
}),
).toEqual(buffered);
});
it("does not treat persisted assistant text as proof that thinking was saved", () => {
const thinking = event("reasoning.delta", "thinking", { text: "Checking" });
const done = event("run.completed", "done");
expect(
reconcileBufferedCloudEvents(
[
thinking,
event("assistant.finished", "answer", { text: "Done" }),
done,
],
[{ role: "assistant", content: "Done" }],
),
).toEqual([thinking, done]);
});
});
@@ -0,0 +1,362 @@
import { isUserRunMessage } from "@cline/core";
import type { HubEventEnvelope } from "@cline/shared";
import type { JsonRecord, PromptInQueue } from "./types";
export function readSessionRows(
payload: Record<string, unknown> | undefined,
): JsonRecord[] {
return Array.isArray(payload?.sessions)
? payload.sessions.filter(
(item): item is JsonRecord =>
Boolean(item) && typeof item === "object" && !Array.isArray(item),
)
: [];
}
export function updatedAt(record: JsonRecord): number {
const value = record.updatedAt;
return typeof value === "number"
? value
: Date.parse(String(value ?? "")) || 0;
}
export function sessionRowModelId(record: JsonRecord | undefined): string {
const metadata =
record?.metadata && typeof record.metadata === "object"
? (record.metadata as JsonRecord)
: undefined;
return String(metadata?.model ?? record?.model ?? "").trim();
}
export function isRootSessionRow(record: JsonRecord): boolean {
const metadata =
record.metadata && typeof record.metadata === "object"
? (record.metadata as JsonRecord)
: undefined;
return !String(
metadata?.parentSessionId ?? record.parentSessionId ?? "",
).trim();
}
function messageText(
message: unknown,
kind: "text" | "thinking" = "text",
): string {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return "";
}
const content = (message as JsonRecord).content;
if (typeof content === "string") {
return kind === "text" ? content.trim() : "";
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) =>
part && typeof part === "object" && !Array.isArray(part)
? kind === "thinking" &&
(part as JsonRecord).type === "redacted_thinking"
? "[redacted]"
: String((part as JsonRecord)[kind] ?? "")
: "",
)
.join("")
.trim();
}
function normalizeUserPrompt(text: string): string {
const trimmed = text.trim();
const match = trimmed.match(/^<user_input\b[^>]*>([\s\S]*)<\/user_input>$/);
return (match ? match[1] : trimmed).trim();
}
export function countPromptOccurrences(
messages: unknown[],
prompts: PromptInQueue[],
prompt: string,
): number {
const expected = normalizeUserPrompt(prompt);
return (
messages.filter(
(message) =>
Boolean(message) &&
typeof message === "object" &&
!Array.isArray(message) &&
String((message as JsonRecord).role ?? "").toLowerCase() === "user" &&
(expected !== "" || isUserRunMessage(message as JsonRecord)) &&
normalizeUserPrompt(messageText(message)) === expected,
).length +
prompts.filter((item) => normalizeUserPrompt(item.prompt) === expected)
.length
);
}
export function submittedPromptsFromEvents(
events: HubEventEnvelope[],
): PromptInQueue[] {
return events.flatMap((event) => {
if (event.event !== "session.pending_prompt_submitted") return [];
const prompt =
event.payload?.prompt &&
typeof event.payload.prompt === "object" &&
!Array.isArray(event.payload.prompt)
? (event.payload.prompt as JsonRecord)
: undefined;
const id = String(prompt?.id ?? "").trim();
if (!id) return [];
return [
{
id,
prompt: String(prompt?.prompt ?? ""),
steer: prompt?.delivery === "steer",
attachmentCount:
typeof prompt?.attachmentCount === "number"
? prompt.attachmentCount
: 0,
userImages: Array.isArray(prompt?.userImages)
? prompt.userImages.filter(
(image): image is string => typeof image === "string",
)
: undefined,
},
];
});
}
const TERMINAL_RUN_EVENTS = new Set([
"run.completed",
"run.aborted",
"run.failed",
]);
const SUPERSEDABLE_CONTENT_EVENTS = new Set([
"assistant.delta",
"assistant.finished",
"reasoning.delta",
"reasoning.finished",
]);
function assistantTexts(
messages: unknown[],
kind: "text" | "thinking",
): string[] {
return messages
.filter(
(message): message is JsonRecord =>
Boolean(message) &&
typeof message === "object" &&
!Array.isArray(message) &&
String((message as JsonRecord).role ?? "").toLowerCase() ===
"assistant",
)
.map((message) => messageText(message, kind))
.filter(Boolean);
}
function newlyPersistedAssistantTexts(
snapshotMessages: unknown[],
baselineMessages: unknown[],
kind: "text" | "thinking" = "text",
): string[] {
const baselineCounts = new Map<string, number>();
for (const text of assistantTexts(baselineMessages, kind)) {
baselineCounts.set(text, (baselineCounts.get(text) ?? 0) + 1);
}
return assistantTexts(snapshotMessages, kind).filter((text) => {
const count = baselineCounts.get(text) ?? 0;
if (count === 0) return true;
if (count === 1) baselineCounts.delete(text);
else baselineCounts.set(text, count - 1);
return false;
});
}
function collectToolCallIds(
value: unknown,
phase: "tool_use" | "tool_result",
result = new Set<string>(),
): Set<string> {
if (!value || typeof value !== "object") return result;
if (Array.isArray(value)) {
for (const item of value) collectToolCallIds(item, phase, result);
return result;
}
const record = value as JsonRecord;
const id = phase === "tool_use" ? record.id : record.tool_use_id;
if (record.type === phase && typeof id === "string") {
result.add(id);
}
for (const child of Object.values(record))
collectToolCallIds(child, phase, result);
return result;
}
function streamedAssistantText(
events: HubEventEnvelope[],
kind: "assistant" | "reasoning" = "assistant",
): string {
let finishedText = "";
let deltas = "";
for (const event of events) {
if (event.event === `${kind}.delta`) {
deltas +=
kind === "reasoning" && event.payload?.redacted && !event.payload?.text
? "[redacted]"
: typeof event.payload?.text === "string"
? event.payload.text
: "";
} else if (event.event === `${kind}.finished`) {
const text = event.payload?.[kind === "assistant" ? "text" : "reasoning"];
const completed = typeof text === "string" && text ? text : deltas;
if (completed) finishedText = completed;
deltas = "";
}
}
return (finishedText || deltas).trim();
}
/** Reconciles each completed run separately; tools dedupe by stable call id. */
export function reconcileBufferedCloudEvents(
events: HubEventEnvelope[],
snapshotMessages: unknown[],
options: {
/**
* Whether a fresh queue snapshot was fetched and applied during
* rehydration. When it was, queue events received before its reply are
* stale; later events still win. When the fetch failed, the newest
* buffered queue event is the best state available.
*/
queueSnapshotApplied?: boolean;
queueSnapshotEventCutoff?: number;
/** Events received after the transcript reply cannot be reflected in it. */
messagesSnapshotEventCutoff?: number;
baselineMessages?: unknown[];
} = {},
): HubEventEnvelope[] {
const queueSnapshotApplied = options.queueSnapshotApplied !== false;
const unclaimedAssistantTexts = newlyPersistedAssistantTexts(
snapshotMessages,
options.baselineMessages ?? [],
);
const unclaimedThinking = newlyPersistedAssistantTexts(
snapshotMessages,
options.baselineMessages ?? [],
"thinking",
);
const snapshotToolCallIds = collectToolCallIds(snapshotMessages, "tool_use");
const snapshotToolResultIds = collectToolCallIds(
snapshotMessages,
"tool_result",
);
const beforeTranscript = new Set(
events.slice(0, options.messagesSnapshotEventCutoff ?? events.length),
);
const reflectedSubmissions = new Set<HubEventEnvelope>();
const unclaimedUserCounts = new Map<string, number>();
for (const event of events.slice(
0,
options.messagesSnapshotEventCutoff ?? events.length,
)) {
const submitted = submittedPromptsFromEvents([event])[0];
if (!submitted) continue;
const prompt = normalizeUserPrompt(submitted.prompt);
const count =
unclaimedUserCounts.get(prompt) ??
Math.max(
0,
countPromptOccurrences(snapshotMessages, [], prompt) -
countPromptOccurrences(options.baselineMessages ?? [], [], prompt),
);
unclaimedUserCounts.set(prompt, Math.max(0, count - 1));
if (count > 0) reflectedSubmissions.add(event);
}
// Queue events are full snapshots, so only the newest one matters.
const queueEvents = queueSnapshotApplied
? events.slice(options.queueSnapshotEventCutoff ?? events.length)
: events;
const lastQueueEvent = queueEvents.findLast(
(event) => event.event === "session.pending_prompts",
);
const reconciled: HubEventEnvelope[] = [];
let segment: HubEventEnvelope[] = [];
const flush = (terminal: boolean) => {
if (segment.length === 0) return;
const snapshotSegment = segment.filter((event) =>
beforeTranscript.has(event),
);
// A buffered run can contain saved replies followed by unsaved output.
const contentEnd = (kind: "assistant" | "reasoning") => {
if (!terminal) return -1;
const finished = snapshotSegment.findLastIndex(
(event) => event.event === `${kind}.finished`,
);
return finished >= 0 || segment.at(-1)?.event === "run.aborted"
? finished
: snapshotSegment.length - 1;
};
const assistantEnd = contentEnd("assistant");
const reasoningEnd = contentEnd("reasoning");
const streamed = streamedAssistantText(
snapshotSegment.slice(0, assistantEnd + 1),
);
const persistedIndex = streamed
? unclaimedAssistantTexts.findIndex((text) => text.endsWith(streamed))
: -1;
const contentPersisted = persistedIndex >= 0;
if (contentPersisted) unclaimedAssistantTexts.splice(persistedIndex, 1);
const thinking = streamedAssistantText(
snapshotSegment.slice(0, reasoningEnd + 1),
"reasoning",
);
const thinkingIndex = thinking
? unclaimedThinking.findIndex((text) => text.endsWith(thinking))
: -1;
if (thinkingIndex >= 0) unclaimedThinking.splice(thinkingIndex, 1);
for (const [index, event] of segment.entries()) {
// Preserve the turn-start lifecycle; the UI must only skip its user bubble.
if (reflectedSubmissions.has(event)) {
reconciled.push({
...event,
payload: { ...event.payload, transcriptReflected: true },
});
continue;
}
if (
beforeTranscript.has(event) &&
SUPERSEDABLE_CONTENT_EVENTS.has(event.event) &&
(event.event.startsWith("reasoning.")
? thinkingIndex >= 0 && index <= reasoningEnd
: contentPersisted && index <= assistantEnd)
) {
continue;
}
if (
event.event === "session.pending_prompts" &&
event !== lastQueueEvent
) {
continue;
}
// Keep terminal events: run.failed may carry the only error detail.
if (beforeTranscript.has(event) && event.event.startsWith("tool.")) {
const toolCallId = String(event.payload?.toolCallId ?? "").trim();
if (
snapshotToolResultIds.has(toolCallId) ||
(event.event === "tool.started" &&
snapshotToolCallIds.has(toolCallId))
)
continue;
}
reconciled.push(event);
}
segment = [];
};
for (const event of events) {
segment.push(event);
if (TERMINAL_RUN_EVENTS.has(event.event)) flush(true);
}
// Never supersede an unterminated tail.
flush(false);
return reconciled;
}
@@ -0,0 +1,820 @@
import { describe, expect, it, vi } from "vitest";
import {
CloudSessionApi,
CloudSessionError,
type CloudSessionRecord,
} from "./cloud-sessions";
const REMOTE_SESSION: CloudSessionRecord = {
id: "ses-outer",
status: "ready",
sandboxUrl: "https://pod.example/hub",
repoContext: { repoUrl: "https://github.com/cline/test" },
metadata: { modelId: "anthropic/claude-sonnet-5" },
createdAt: "2026-08-05T10:00:00.000Z",
updatedAt: "2026-08-05T10:01:00.000Z",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function jwtFor(subject: string, nonce: string): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return (
"workos:" +
encode({ alg: "none" }) +
"." +
encode({ sub: subject, nonce }) +
".sig"
);
}
describe("CloudSessionApi", () => {
it("resolves a fresh bearer token for every REST request", async () => {
const tokens = ["workos:first", "workos:second"];
const authorizations: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example/",
appBaseUrl: "https://app.example/",
getAuthToken: async () => tokens.shift(),
fetch: async (_input, init) => {
authorizations.push(
new Headers(init?.headers).get("Authorization") ?? "",
);
return jsonResponse({ success: true, data: [] });
},
});
await api.list();
await api.list();
expect(authorizations).toEqual([
"Bearer workos:first",
"Bearer workos:second",
]);
});
it("uses the dashboard create body and includes branch only when requested", async () => {
const bodies: Array<Record<string, unknown>> = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) => {
bodies.push(JSON.parse(String(init?.body)));
return jsonResponse(
{ success: true, data: { sessionId: "ses-1", sandboxUrl: "pod" } },
201,
);
},
});
await api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
branch: "feature/login-fix",
});
await api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect(bodies[0]).toMatchObject({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
branch: "feature/login-fix",
title: expect.stringMatching(/^__cline_create_request__:/),
});
expect(bodies[1]).toMatchObject({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
title: expect.stringMatching(/^__cline_create_request__:/),
});
expect(bodies[1]).not.toHaveProperty("branch");
});
it("treats a missing history snapshot (404) as null, not an empty archive", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async () => new Response("not found", { status: 404 }),
});
expect(await api.history("ses-1")).toBeNull();
});
it("accepts v1 history and rejects malformed snapshots instead of returning empty history", async () => {
const messages = [{ role: "user", content: "Hello" }];
let snapshot: unknown = { version: 1, messages };
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async () => jsonResponse(snapshot),
});
expect(await api.history("ses-1")).toEqual(messages);
snapshot = { version: 1, messages: [] };
expect(await api.history("ses-1")).toEqual([]);
for (const invalid of [
null,
{ version: 1 },
{ version: 2, messages: [] },
]) {
snapshot = invalid;
await expect(api.history("ses-1")).rejects.toMatchObject({
code: "request_failed",
detail: "Invalid archived session history",
});
}
});
it("returns the real id before polling readiness and reports provisioning phases", async () => {
vi.useFakeTimers();
const tokens = ["workos:create", "workos:create", "workos:new-account"];
const authorizations: string[] = [];
let statusCalls = 0;
const phases: Array<string | undefined> = [];
try {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => tokens.shift(),
fetch: async (input, init) => {
authorizations.push(
new Headers(init?.headers).get("Authorization") ?? "",
);
const url = new URL(String(input));
if (init?.method === "POST") {
return jsonResponse(
{
success: true,
data: { sessionId: "ses-1", status: "provisioning" },
},
201,
);
}
expect(url.pathname).toBe("/api/v1/session/ses-1/status");
statusCalls += 1;
return jsonResponse({
success: true,
data: {
sessionId: "ses-1",
status: statusCalls === 1 ? "provisioning" : "ready",
phase: statusCalls === 1 ? "cloning_repo" : "ready",
},
});
},
});
const created = await api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect(created).toMatchObject({
sessionId: "ses-1",
status: "provisioning",
});
expect(statusCalls).toBe(0);
const ready = api.waitUntilReady(
created.sessionId,
new AbortController().signal,
({ phase }) => phases.push(phase),
);
await vi.waitFor(() => expect(statusCalls).toBe(1));
await vi.advanceTimersByTimeAsync(3_000);
await expect(ready).resolves.toBeUndefined();
expect(statusCalls).toBe(2);
expect(phases).toEqual(["cloning_repo", "ready"]);
expect(authorizations).toEqual([
"Bearer workos:create",
"Bearer workos:create",
"Bearer workos:create",
]);
expect(tokens).toEqual(["workos:new-account"]);
} finally {
vi.useRealTimers();
}
});
it("refreshes an expired provisioning token without switching accounts", async () => {
const original = jwtFor("user-1", "original");
const refreshed = jwtFor("user-1", "refreshed");
const tokens = [original, refreshed];
const authorizations: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => tokens.shift(),
fetch: async (_input, init) => {
const authorization =
new Headers(init?.headers).get("Authorization") ?? "";
authorizations.push(authorization);
if (authorization === `Bearer ${original}`) {
return jsonResponse(
{ success: false, error: "authentication required" },
401,
);
}
return jsonResponse({
success: true,
data: { sessionId: "ses-1", status: "ready" },
});
},
});
await expect(
api.waitUntilReady("ses-1", new AbortController().signal),
).resolves.toBeUndefined();
expect(authorizations).toEqual([
`Bearer ${original}`,
`Bearer ${refreshed}`,
]);
});
it("does not switch accounts while refreshing provisioning auth", async () => {
const original = jwtFor("user-1", "original");
const otherAccount = jwtFor("user-2", "refreshed");
const tokens = [original, otherAccount];
let statusCalls = 0;
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => tokens.shift(),
fetch: async () => {
statusCalls += 1;
return jsonResponse(
{ success: false, error: "authentication required" },
401,
);
},
});
await expect(
api.waitUntilReady("ses-1", new AbortController().signal),
).rejects.toMatchObject({ code: "authentication_required" });
expect(statusCalls).toBe(1);
});
it("returns a recovered real id without waiting for provisioning", async () => {
const requests: string[] = [];
let recoveryTitle = "";
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:fresh",
fetch: async (input, init) => {
requests.push(
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
);
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
return jsonResponse({ success: false, error: "gateway" }, 500);
}
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-recovered",
title: recoveryTitle,
status: "provisioning",
sandboxUrl: "",
},
],
});
},
});
await expect(
api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
}),
).resolves.toMatchObject({
sessionId: "ses-recovered",
status: "provisioning",
});
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
});
it("recovers the real id after the create request times out", async () => {
vi.useFakeTimers();
const requests: string[] = [];
let recoveryTitle = "";
try {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
createTimeoutMs: 100,
getAuthToken: async () => "workos:fresh",
fetch: async (input, init) => {
requests.push(
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
);
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
return await new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener(
"abort",
() => reject(init.signal?.reason),
{ once: true },
);
});
}
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-recovered",
title: recoveryTitle,
status: "provisioning",
sandboxUrl: "",
},
],
});
},
});
const creating = api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
await vi.advanceTimersByTimeAsync(100);
await expect(creating).resolves.toMatchObject({
sessionId: "ses-recovered",
});
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
} finally {
vi.useRealTimers();
}
});
it("returns a failed recovered session without hiding its real id", async () => {
let recoveryTitle = "";
const requests: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:fresh",
fetch: async (input, init) => {
requests.push(
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
);
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
return jsonResponse({ success: false, error: "gateway" }, 500);
}
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
title: recoveryTitle,
status: "failed",
},
],
});
},
});
await expect(
api.create({
modelId: REMOTE_SESSION.metadata.modelId ?? "",
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
}),
).resolves.toMatchObject({ sessionId: "ses-outer", status: "failed" });
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
});
it("recovers a create accepted before a raw network failure", async () => {
let recoveryTitle = "";
let listCalls = 0;
const now = new Date().toISOString();
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) => {
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
throw new TypeError("fetch failed");
}
listCalls += 1;
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-recovered",
title: recoveryTitle,
createdAt: now,
updatedAt: now,
},
],
});
},
});
await expect(
api.create({
requestId: "request-a",
modelId: REMOTE_SESSION.metadata.modelId ?? "",
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
}),
).resolves.toMatchObject({ sessionId: "ses-recovered" });
expect(listCalls).toBe(1);
});
it("does not recover another process's identical session", async () => {
const now = new Date().toISOString();
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) =>
init?.method === "POST"
? jsonResponse({ success: false, error: "gateway timeout" }, 500)
: jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-other-process",
title: "__cline_create_request__:other-request",
createdAt: now,
updatedAt: now,
},
],
}),
});
await expect(
api.create({
requestId: "this-request",
modelId: REMOTE_SESSION.metadata.modelId ?? "",
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
}),
).rejects.toMatchObject({ code: "request_failed" });
});
it("hides temporary create request titles from session lists", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async () =>
jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
title: "__cline_create_request__:request-a",
},
],
}),
});
await expect(api.list()).resolves.toEqual([
expect.objectContaining({
id: "ses-outer",
title: undefined,
metadata: expect.objectContaining({
createRequestTitle: "__cline_create_request__:request-a",
}),
}),
]);
});
it("returns a stable, environment-aware GitHub connection error", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://staging-app.example/",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "GitHub is not connected" }, 412),
});
const error = await api
.create({ modelId: "model", repoUrl: "https://github.com/cline/test" })
.catch((caught) => caught);
expect(error).toBeInstanceOf(CloudSessionError);
expect(error.code).toBe("github_not_connected");
expect(error.message).toBe(
'CLOUD_SESSION_ERROR:{"code":"github_not_connected","message":"GitHub is not connected","connectUrl":"https://staging-app.example/dashboard/integrations"}',
);
});
it("routes organization GitHub setup to organization integrations", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://staging-app.example/",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "GitHub is not connected" }, 412),
});
const error = await api
.create({
modelId: "model",
repoUrl: "https://github.com/cline/test",
organizationId: "org-cline-bot",
})
.catch((caught) => caught);
expect(error).toBeInstanceOf(CloudSessionError);
expect(error.connectUrl).toBe(
"https://staging-app.example/dashboard/organization/integrations",
);
});
it("lists connected GitHub repositories and their branches", async () => {
const requestedPaths: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async (input) => {
const path = new URL(String(input)).pathname;
requestedPaths.push(path);
if (path.endsWith("/branches")) {
return jsonResponse({
success: true,
data: [{ name: "main" }, { name: "feature/cloud" }],
});
}
return jsonResponse({
success: true,
data: [
{
id: 42,
name: "cline",
full_name: "cline/cline",
html_url: "https://github.com/cline/cline",
clone_url: "https://github.com/cline/cline.git",
default_branch: "main",
},
],
});
},
});
expect(await api.listRepositories()).toEqual({
connected: true,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [
{
id: 42,
name: "cline",
fullName: "cline/cline",
url: "https://github.com/cline/cline",
defaultBranch: "main",
},
],
});
expect(await api.listBranches(42)).toEqual({
available: true,
branches: ["main", "feature/cloud"],
nextToken: "",
});
expect(requestedPaths).toEqual([
"/api/v1/integrations/github/repositories",
"/api/v1/integrations/github/repositories/42/branches",
]);
});
it("reads paginated branch responses and forwards search cursors", async () => {
let requestedUrl = "";
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async (input) => {
requestedUrl = String(input);
return jsonResponse({
success: true,
data: {
items: [{ name: "feature/cloud" }],
nextToken: "next/page",
},
});
},
});
expect(
await api.listBranches(42, undefined, {
cursor: "search cursor",
query: "feature/cloud",
}),
).toEqual({
available: true,
branches: ["feature/cloud"],
nextToken: "next/page",
});
const url = new URL(requestedUrl);
expect(url.pathname).toBe(
"/api/v1/integrations/github/repositories/42/branches",
);
expect(url.searchParams.get("query")).toBe("feature/cloud");
expect(url.searchParams.get("cursor")).toBe("search cursor");
});
it("filters legacy branch responses while backends roll out", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({
success: true,
data: [{ name: "main" }, { name: "feature/cloud" }],
}),
});
expect(await api.listBranches(42, undefined, { query: "FEATURE" })).toEqual(
{
available: true,
branches: ["feature/cloud"],
nextToken: "",
},
);
});
it("falls back to the repository default when the branch API is unavailable", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "route not found" }, 404),
});
expect(await api.listBranches(42)).toEqual({
available: false,
branches: [],
});
});
it("uses organization-scoped repository and branch endpoints", async () => {
const requestedPaths: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async (input) => {
const path = new URL(String(input)).pathname;
requestedPaths.push(path);
return jsonResponse({ success: true, data: [] });
},
});
expect(await api.listRepositories("org-cline-bot")).toMatchObject({
connected: true,
connectUrl: "https://app.example/dashboard/organization/integrations",
});
await api.listBranches(42, "org-cline-bot");
expect(requestedPaths).toEqual([
"/api/v1/organizations/org-cline-bot/integrations/github/repositories",
"/api/v1/organizations/org-cline-bot/integrations/github/repositories/42/branches",
]);
});
it("refuses ambiguous recovery for overlapping identical create requests", async () => {
const now = new Date().toISOString();
const record = (id: string, createdAt: string) => ({
id,
title: "__cline_create_request__:same-request",
status: "running",
sandboxUrl: `pod-${id}`,
repoContext: { repoUrl: "https://github.com/cline/test" },
metadata: { modelId: "anthropic/claude-sonnet-5" },
createdAt,
updatedAt: createdAt,
});
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) =>
init?.method === "POST"
? jsonResponse({ success: false, error: "gateway timeout" }, 500)
: jsonResponse({
success: true,
data: [
record("ses-newer", now),
record("ses-older", new Date(Date.now() - 1_000).toISOString()),
],
}),
});
const input = {
requestId: "same-request",
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
};
const error = await api.create(input).catch((caught) => caught);
expect(error).toMatchObject({ code: "request_failed" });
expect(String(error)).toContain("ambiguous result");
});
it("reports provisioning failure without deleting the known session", async () => {
const authorizations: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:create",
fetch: async (input, init) => {
authorizations.push(
new Headers(init?.headers).get("Authorization") ?? "",
);
expect(new URL(String(input)).pathname).toBe(
"/api/v1/session/ses-failed/status",
);
return jsonResponse({
success: true,
data: {
sessionId: "ses-failed",
status: "failed",
statusReason: "clone failed",
},
});
},
});
await expect(
api.waitUntilReady("ses-failed", new AbortController().signal),
).rejects.toMatchObject({ code: "session_failed", detail: "clone failed" });
expect(authorizations).toEqual(["Bearer workos:create"]);
});
it("turns a generic forbidden response into actionable account guidance", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "forbidden" }, 403),
});
const error = await api
.create({ modelId: "model", repoUrl: "https://github.com/cline/test" })
.catch((caught) => caught);
expect(error).toBeInstanceOf(CloudSessionError);
expect(error.code).toBe("request_failed");
expect(error.status).toBe(403);
expect(error.message).toContain(
"Switch to Personal or another organization in Settings → Account",
);
});
it("does not run list recovery after a fast client-side rejection", async () => {
let listRequests = 0;
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) => {
if (init?.method === "POST") {
return jsonResponse({ success: false, error: "invalid branch" }, 422);
}
listRequests += 1;
return jsonResponse({ success: true, data: [] });
},
});
await expect(
api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
}),
).rejects.toThrow(/invalid branch/);
expect(listRequests).toBe(0);
});
it("returns the GitHub connection action when no integration exists", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example/",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "not connected" }, 404),
});
expect(await api.listRepositories()).toEqual({
connected: false,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [],
});
});
});
@@ -0,0 +1,431 @@
import { describe, expect, it, vi } from "vitest";
import {
type CloudSessionApi,
CloudSessionError,
CloudSessionManager,
type CloudSessionRecord,
cloudSessionToDiscoveryRecord,
} from "./cloud-sessions";
import type { SidecarContext } from "./types";
const REMOTE_SESSION: CloudSessionRecord = {
id: "ses-outer",
status: "ready",
sandboxUrl: "https://pod.example/hub",
repoContext: { repoUrl: "https://github.com/cline/test" },
metadata: { modelId: "anthropic/claude-sonnet-5" },
createdAt: "2026-08-05T10:00:00.000Z",
updatedAt: "2026-08-05T10:01:00.000Z",
};
function createContext(): { ctx: SidecarContext } {
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
coreStreamActivity: new Map(),
bootId: "cloud-test-boot",
wsClients: new Set([
{
data: { canApproveTools: true },
send() {},
},
]),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
cloudSessionManager: null,
hubClient: null,
workspaceRoot: "/local/workspace",
unsubscribeSessionEvents: null,
hubBuildMismatch: null,
} as SidecarContext;
return { ctx };
}
describe("CloudSessionManager lifecycle", () => {
it.each([
"upstream request failed",
"couldn't authenticate with GitHub; try reconnecting the integration",
])("surfaces create failure without retrying: %s", async (message) => {
const { ctx } = createContext();
const create = vi.fn(async () => {
throw new CloudSessionError("request_failed", message, undefined, 502);
});
const manager = new CloudSessionManager(ctx, {
api: { create } as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
});
await expect(
manager.create({
modelId: "model",
repoUrl: "https://github.com/cline/test",
}),
).rejects.toThrow(message);
expect(create).toHaveBeenCalledOnce();
});
it("projects the outer remote-session id as the desktop session id", () => {
expect(
cloudSessionToDiscoveryRecord({
...REMOTE_SESSION,
repoContext: {
...REMOTE_SESSION.repoContext,
branch: "feature/cloud",
},
}),
).toMatchObject({
sessionId: "ses-outer",
origin: "cloud",
executionTarget: "cloud",
repoUrl: "https://github.com/cline/test",
workspaceRoot: "/workspace",
branch: "feature/cloud",
metadata: {
git: {
url: "https://github.com/cline/test",
branch: "feature/cloud",
},
},
});
});
it("treats a live session's future expiredAt as a TTL, not an end time", () => {
const alive = cloudSessionToDiscoveryRecord({
...REMOTE_SESSION,
expiredAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
expect(alive.endedAt).toBeUndefined();
const expired = cloudSessionToDiscoveryRecord({
...REMOTE_SESSION,
expiredAt: "2026-08-01T00:00:00.000Z",
});
expect(expired.endedAt).toBe("2026-08-01T00:00:00.000Z");
});
it("overlays live status and prompt-derived title on refreshed REST rows", async () => {
const { ctx } = createContext();
ctx.liveSessions.set("ses-outer", {
config: { executionTarget: "cloud" },
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
prompt: "Fix reconnect behavior\nwith a regression test",
attachedViaHub: true,
});
const manager = new CloudSessionManager(ctx, {
api: { list: async () => [REMOTE_SESSION] } as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
});
const [session] = await manager.listForDiscovery();
expect(session).toMatchObject({
sessionId: "ses-outer",
origin: "cloud",
status: "running",
prompt: "Fix reconnect behavior\nwith a regression test",
repoUrl: "https://github.com/cline/test",
metadata: {
title: "Fix reconnect behavior",
origin: "cloud",
},
});
});
it.each([
"expired",
"failed",
] as const)("reconciles a %s session without a Hub connection", async (status) => {
const { ctx } = createContext();
const remote: CloudSessionRecord = { ...REMOTE_SESSION };
const manager = new CloudSessionManager(ctx, {
api: {
create: async () => ({
sessionId: remote.id,
status: "provisioning",
sandboxUrl: "",
}),
list: async () => [remote],
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
});
await manager.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect((await manager.listForDiscovery())[0].status).toBe("ready");
const endedAt = new Date(Date.now() - 1_000).toISOString();
if (status === "expired") {
remote.expiredAt = endedAt;
} else {
remote.status = "failed";
remote.lastActivityAt = endedAt;
}
expect((await manager.listForDiscovery())[0]).toMatchObject({
status,
endedAt,
});
expect(ctx.liveSessions.get(remote.id)).toMatchObject({
status,
busy: false,
endedAt: Date.parse(endedAt),
});
if (status === "failed") {
remote.title = "Renamed after failure";
remote.updatedAt = new Date().toISOString();
expect((await manager.listForDiscovery())[0].endedAt).toBe(endedAt);
const live = ctx.liveSessions.get(remote.id)!;
ctx.liveSessions.clear();
expect((await manager.listForDiscovery())[0].endedAt).toBe(endedAt);
const hubEndedAt = Date.parse(endedAt) + 500;
live.endedAt = hubEndedAt;
ctx.liveSessions.set(remote.id, live);
expect((await manager.listForDiscovery())[0].endedAt).toBe(
new Date(hubEndedAt).toISOString(),
);
ctx.liveSessions.clear();
delete remote.lastActivityAt;
expect((await manager.listForDiscovery())[0].endedAt).toBe(
remote.createdAt,
);
}
});
it("single-flights repeated starts for the same client request", async () => {
const { ctx } = createContext();
let createCalls = 0;
let finishCreate:
| ((value: {
sessionId: string;
status: string;
sandboxUrl: string;
}) => void)
| undefined;
const manager = new CloudSessionManager(ctx, {
api: {
list: async () => [],
create: () => {
createCalls += 1;
return new Promise((resolve) => {
finishCreate = resolve;
});
},
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
});
const input = {
requestId: "client-start-1",
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
};
const first = manager.create(input);
const second = manager.create(input);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(createCalls).toBe(1);
expect(await manager.listForDiscovery()).toEqual([]);
finishCreate?.({
sessionId: "ses-created",
status: "provisioning",
sandboxUrl: "",
});
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ sessionId: "ses-created" }),
expect.objectContaining({ sessionId: "ses-created" }),
]);
});
it("keeps identical starts from separate chats independent", async () => {
const { ctx } = createContext();
let createCalls = 0;
const manager = new CloudSessionManager(ctx, {
api: {
list: async () => [],
create: async () => {
createCalls += 1;
return {
sessionId: `ses-created-${createCalls}`,
status: "provisioning",
sandboxUrl: "pod",
};
},
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
});
const input = {
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
};
const [first, second] = await Promise.all([
manager.create({ ...input, requestId: "chat-a" }),
manager.create({ ...input, requestId: "chat-b" }),
]);
expect(createCalls).toBe(2);
expect(first.sessionId).not.toBe(second.sessionId);
});
it("returns cached cloud discovery promptly while a refresh is slow", async () => {
const { ctx } = createContext();
let listCalls = 0;
let finishRefresh: ((value: CloudSessionRecord[]) => void) | undefined;
const manager = new CloudSessionManager(ctx, {
api: {
list: async () => {
listCalls += 1;
if (listCalls === 1) return [REMOTE_SESSION];
return await new Promise((resolve) => {
finishRefresh = resolve;
});
},
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
});
await manager.listForDiscovery();
const cached = await manager.listForDiscovery({ timeoutMs: 1 });
expect(cached).toEqual([
expect.objectContaining({ sessionId: "ses-outer", origin: "cloud" }),
]);
finishRefresh?.([]);
await new Promise((resolve) => setTimeout(resolve, 0));
});
it("uses only the active organization for billing and session listing", async () => {
const { ctx } = createContext();
const listCalls: Array<string | undefined> = [];
const repositoryScopes: Array<string | undefined> = [];
const branchScopes: Array<string | undefined> = [];
let createInput: Record<string, unknown> | undefined;
const orgSession = { ...REMOTE_SESSION, id: "ses-org", title: undefined };
const manager = new CloudSessionManager(ctx, {
api: {
list: async (organizationId?: string) => {
listCalls.push(organizationId);
return organizationId
? [orgSession]
: [{ ...REMOTE_SESSION, title: undefined }];
},
create: async (input: Record<string, unknown>) => {
createInput = input;
return {
sessionId: "ses-created",
status: "provisioning",
sandboxUrl: "",
};
},
listRepositories: async (organizationId?: string) => {
repositoryScopes.push(organizationId);
return { connected: true, connectUrl: "", repositories: [] };
},
listBranches: async (
_repositoryId: number,
organizationId?: string,
) => {
branchScopes.push(organizationId);
return { available: true, branches: [] };
},
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
getActiveOrganizationId: async () => "org-cline-bot",
});
const scoped = await manager.list();
expect(listCalls).toEqual(["org-cline-bot"]);
expect(scoped.map((session) => session.id)).toEqual(["ses-org"]);
await manager.listRepositories();
await manager.listBranches(42);
expect(repositoryScopes).toEqual(["org-cline-bot"]);
expect(branchScopes).toEqual(["org-cline-bot"]);
await manager.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect(createInput).toMatchObject({ organizationId: "org-cline-bot" });
});
it("refreshes the active organization before creating a session", async () => {
const { ctx } = createContext();
let serverScope = "org-a";
let cachedScope = serverScope;
const lookupOptions: Array<{ fresh?: boolean } | undefined> = [];
let createInput: Record<string, unknown> | undefined;
const manager = new CloudSessionManager(ctx, {
api: {
list: async () => [],
create: async (input: Record<string, unknown>) => {
createInput = input;
return {
sessionId: "ses-created",
status: "provisioning",
sandboxUrl: "",
};
},
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
getActiveOrganizationId: async (options) => {
lookupOptions.push(options);
if (options?.fresh) cachedScope = serverScope;
return cachedScope;
},
});
await manager.list();
serverScope = "org-b";
await manager.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect(lookupOptions).toEqual([undefined, { fresh: true }]);
expect(createInput).toMatchObject({ organizationId: "org-b" });
});
it("does not silently bill personal credits when account scope lookup fails", async () => {
const { ctx } = createContext();
let createInput: Record<string, unknown> | undefined;
const manager = new CloudSessionManager(ctx, {
api: {
list: async () => [],
create: async (input: Record<string, unknown>) => {
createInput = input;
return {
sessionId: "ses-created",
status: "provisioning",
sandboxUrl: "",
};
},
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "workos:fresh",
getActiveOrganizationId: async () => {
throw new Error("account endpoint down");
},
});
await expect(
manager.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
}),
).rejects.toThrow("account endpoint down");
expect(createInput?.organizationId).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,11 @@ const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
const clearLegacyProviderCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./legacy-provider-credentials", () => ({
clearLegacyProviderCredentials: clearLegacyProviderCredentialsMock,
}));
vi.mock("@cline/core", async () => {
const actual =
@@ -59,6 +64,7 @@ beforeEach(() => {
saveProviderSettingsMock.mockReset();
persistProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
clearLegacyProviderCredentialsMock.mockReset();
});
describe("cline_account command auth states", () => {
@@ -128,11 +134,30 @@ describe("cline_account command auth states", () => {
getAuthToken: () => Promise<string | undefined>;
};
await expect(serviceOptions.getAuthToken()).resolves.toBe(
"persisted-token",
"workos:persisted-token",
);
expect(capture).not.toHaveBeenCalled();
});
it("reports signed out when the refresh token is rejected even though a stale access token is persisted", async () => {
// The stale token would only fail the account request with a 401,
// which rendered an error card whose Retry failed the same way.
const { ctx } = createContext();
const { OAuthReauthRequiredError } =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
resolveProviderApiKeyMock.mockRejectedValue(
new OAuthReauthRequiredError("cline"),
);
getProviderSettingsMock.mockReturnValue({
auth: { accessToken: "persisted-token" },
});
const result = await runClineAccountCommand(ctx);
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
});
it("reports one auth refresh soft-failure event when the refresh fails and no fallback token exists", async () => {
const { ctx, capture } = createContext();
const refreshError = new Error(
@@ -365,6 +390,31 @@ describe("cline_account keeps feature-flag identity in sync", () => {
);
});
it("signs out of the shared cline entry and legacy secrets when cline-pass is disabled", async () => {
const { ctx } = createContext();
getProviderSettingsMock.mockReturnValue(undefined);
saveProviderSettingsMock.mockImplementation(
(_manager: unknown, request: { providerId: string }) => ({
providerId: request.providerId,
enabled: false,
settingsPath: "/tmp/settings.json",
}),
);
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "cline-pass",
enabled: false,
});
// Cline Pass stores its credentials under "cline", so both entries go,
// and the legacy secrets are cleared for the storage provider.
expect(saveProviderSettingsMock.mock.calls.map(([, r]) => r)).toEqual([
expect.objectContaining({ providerId: "cline-pass", enabled: false }),
{ providerId: "cline", enabled: false },
]);
expect(clearLegacyProviderCredentialsMock).toHaveBeenCalledWith("cline");
});
it("ignores settings writes for other providers", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
+65 -55
View File
@@ -16,7 +16,6 @@ import {
addLocalProvider,
ClineAccountService,
type ClineAccountUser,
captureAuthRefreshSoftFailure,
clearAccountTelemetryIdentity,
createConfiguredStreamingTranscriptionSession,
createUserInstructionConfigService,
@@ -25,6 +24,7 @@ import {
fetchClineRecommendedModels,
getCoreBuiltinToolCatalog,
getLocalProviderModels,
getProviderAuthHandler,
identifyAccount,
listHookConfigFiles,
listLocalProviders,
@@ -33,10 +33,8 @@ import {
parseMcpServerRegistration,
persistClineAccountTelemetryIdentity,
probeMcpServerConnection,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveClineAccountTelemetryIdentity,
resolveLocalClineAuthToken,
resolveMcpServerRegistration,
resolveSessionBackend,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
@@ -72,6 +70,7 @@ import packageJson from "../package.json";
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
import { resolveDesktopTelemetryUser } from "./client-context";
import { resolveFreshClineAuthToken } from "./cline-auth";
import {
listClineGitHubRepositories,
listClineIntegrations,
@@ -92,6 +91,7 @@ import {
identifyDesktopFeatureFlagsAccount,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -121,6 +121,8 @@ import {
sessionLogPath,
sharedSessionDataDir,
} from "./paths";
import { getPullRequestStatus } from "./pull-request";
import { capturePullRequestEvent } from "./pull-request-telemetry";
import { listSessionAgents } from "./session-data/agents";
import { readSessionHooks } from "./session-data/artifacts";
import { normalizeSessionTitle } from "./session-data/common";
@@ -309,13 +311,6 @@ function removePathIfExists(
return true;
}
// Cline access tokens expire between app launches, so account requests must
// resolve through the refresh-aware OAuth manager instead of reading the
// persisted token directly. A single shared instance keeps concurrent account
// requests single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
function syncAccountContextFromResult(
ctx: SidecarContext,
manager: ProviderSettingsManager,
@@ -379,43 +374,6 @@ function syncSignedOutAccountContext(ctx: SidecarContext): void {
);
}
async function resolveFreshClineAuthToken(
ctx: SidecarContext,
manager: ProviderSettingsManager,
): Promise<string | undefined> {
let refreshError: Error | undefined;
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch (error) {
// Fall back to the persisted token; when one exists the account request
// surfaces the auth failure to the caller.
refreshError = error instanceof Error ? error : new Error(String(error));
}
const persisted = resolveLocalClineAuthToken(
manager.getProviderSettings("cline"),
);
// Never-signed-in resolves to undefined without a refresh attempt and is
// silent. A refresh failure with no persisted fallback means credentials
// existed but yielded nothing — that is the signal a real auth regression
// would show up as, so report exactly one event for it.
if (!persisted && refreshError) {
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
error: refreshError,
});
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
errorName: refreshError.name,
errorCode: "desktop_refresh_failed_no_fallback_token",
});
}
return persisted;
}
function mergePersistedSessionRecord(
store: SqliteSessionStore,
sessionId: string,
@@ -690,9 +648,13 @@ function toPositiveInt(value: unknown): number | undefined {
return rounded > 0 ? rounded : undefined;
}
function routineScheduleTiming(
args?: Record<string, unknown>,
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
function routineScheduleTiming(args?: Record<string, unknown>):
| {
cronPattern: string;
timezone?: string;
metadata?: Record<string, number>;
}
| undefined {
if (args?.schedule_type === "once") {
const runAt =
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
@@ -704,7 +666,9 @@ function routineScheduleTiming(
: undefined;
}
const cronPattern = asTrimmedString(args?.cron_pattern);
return cronPattern ? { cronPattern } : undefined;
return cronPattern
? { cronPattern, timezone: asTrimmedString(args?.timezone) }
: undefined;
}
function asTrimmedString(value: unknown): string | undefined {
@@ -962,7 +926,7 @@ async function listHubSettings(
async function toggleHubSetting(
ctx: SidecarContext,
input: {
type: "plugins" | "tools";
type: "plugins" | "tools" | "skills";
path?: string;
name?: string;
enabled?: boolean;
@@ -1001,13 +965,18 @@ async function listUserInstructionConfigs(
const items: unknown[] = [];
for (const record of userInstructionService.listRecords(type)) {
const item = record.item as unknown as JsonRecord;
if (item.disabled === true) continue;
const disabled = item.disabled === true;
// Rules and workflows have no toggle UI, so keep hiding disabled
// ones; skills need to stay visible (disabled) so they can be
// re-enabled from the Skills tab.
if (disabled && type !== "skill") continue;
items.push({
id: record.id,
name: item.name ?? record.id,
description: item.description,
instructions: item.instructions,
path: record.filePath,
...(type === "skill" ? { enabled: !disabled } : {}),
});
}
return items;
@@ -1867,7 +1836,7 @@ export async function handleCommand(
// token up front and return a typed result the webview can act on
// instead of letting the account service throw a generic error that
// would be captured as error telemetry and shown raw to the user.
const authToken = await resolveFreshClineAuthToken(ctx, manager);
const authToken = await resolveFreshClineAuthToken(manager, ctx);
if (!authToken) {
// Backstop for credentials that go away without a settings write —
// an expired or server-revoked token. Explicit sign-out is handled
@@ -1896,7 +1865,7 @@ export async function handleCommand(
if (!operation) throw new Error("operation is required");
const manager = new ProviderSettingsManager();
const authToken = await resolveFreshClineAuthToken(ctx, manager);
const authToken = await resolveFreshClineAuthToken(manager, ctx);
if (!authToken) {
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
@@ -2088,6 +2057,24 @@ export async function handleCommand(
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
if (!saved.enabled) {
// Cline Pass keeps its credentials under "cline", so removing only
// its own entry would leave the account signed in.
const storageProviderId =
getProviderAuthHandler(saved.providerId)?.storageProviderId ??
saved.providerId;
if (storageProviderId !== saved.providerId) {
saveLocalProviderSettings(manager, {
providerId: storageProviderId,
enabled: false,
});
}
// Removing a providers.json entry lets the legacy import restore it
// from the extension's secrets.json on the next command unless those
// credentials go too. A failed write throws so the webview reports
// the sign-out as failed and resyncs.
clearLegacyProviderCredentials(storageProviderId);
}
// Sign-out is a `save_provider_settings` that blanks the cline auth block
// (see signOut in webview settings/account-view.tsx), so this is the
// authoritative signal — it fires the moment credentials are cleared
@@ -2431,6 +2418,17 @@ export async function handleCommand(
}
// ── Git operations ─────────────────────────────────────────────────
if (command === "capture_pull_request_event") {
capturePullRequestEvent(ctx.telemetry, args);
return null;
}
if (command === "get_pull_request_status") {
return await getPullRequestStatus(
typeof args?.cwd === "string" && args.cwd.trim()
? args.cwd.trim()
: ctx.workspaceRoot,
);
}
if (command === "get_git_branch") {
const cwd =
typeof args?.cwd === "string" && args.cwd.trim()
@@ -2550,6 +2548,18 @@ export async function handleCommand(
});
return await listUserInstructionConfigs(ctx, snapshot);
}
if (command === "set_skill_disabled") {
const skillPath = String(args?.path ?? "").trim();
if (!skillPath) {
throw new Error("skill path is required");
}
const snapshot = await toggleHubSetting(ctx, {
type: "skills",
path: skillPath,
enabled: args?.disabled !== true,
});
return await listUserInstructionConfigs(ctx, snapshot);
}
// ── Native OS commands ────────────────────────────────────────────
if (command === "validate_workspace_directory") {
@@ -531,6 +531,65 @@ describe("Code sidecar runtime capabilities", () => {
).toHaveLength(2);
});
it("does not announce a queued prompt start when the head is deleted from the queue", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
let onEvent: ((event: unknown) => void) | undefined;
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
subscribe: vi.fn((handler: (event: unknown) => void) => {
onEvent = handler;
return () => {};
}),
dispose: vi.fn(),
});
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
ctx.liveSessions.set("session-1", {
config: {},
messages: [],
promptsInQueue: [
{ id: "prompt-1", prompt: "first", steer: false, attachmentCount: 0 },
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
],
busy: true,
startedAt: Date.now(),
status: "running",
});
// Removing the head only produces a shrunken snapshot — no
// pending_prompt_submitted — so nothing must reach the transcript.
onEvent?.({
type: "pending_prompts",
payload: {
sessionId: "session-1",
prompts: [{ id: "prompt-2", prompt: "second", delivery: "queue" }],
},
});
const events = readEvents(ctx);
expect(
events.filter(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_queued_prompt_start",
),
).toHaveLength(0);
expect(
events.find((message) => message.event.name === "prompts_in_queue_state")
?.event.payload,
).toEqual({
sessionId: "session-1",
items: [
{ id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 },
],
});
});
it("relays generated media for attach-only Hub sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
+5 -17
View File
@@ -426,10 +426,8 @@ function handleAgentEvent(
// CoreSessionEvent routing
// ---------------------------------------------------------------------------
// The runtime's queue drain emits a pending_prompts snapshot (head removed)
// and a pending_prompt_submitted event for the same prompt back-to-back, and
// both are translated here into chat_queued_prompt_start — dedupe by prompt
// id or the UI renders the user message twice.
// Dedupe by prompt id so a repeated pending_prompt_submitted for the same
// prompt cannot render the user message twice.
function emitQueuedPromptStart(
ctx: SidecarContext,
sessionId: string,
@@ -490,20 +488,10 @@ export function handleCoreSessionEvent(
session,
mapped.map((item) => item.id),
);
const previous = session.promptsInQueue;
// A shrinking snapshot is not evidence that the head started
// running: the user may have deleted it or the queue may have been
// discarded. Only pending_prompt_submitted announces a start.
session.promptsInQueue = mapped;
if (
previous.length > mapped.length &&
previous[0] &&
previous[0].id !== mapped[0]?.id
) {
emitQueuedPromptStart(ctx, sessionId, session, {
promptId: previous[0].id,
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
});
}
}
sendPromptsInQueueSnapshot(ctx, sessionId);
break;
@@ -0,0 +1,62 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
readDesktopSettings,
resolveDesktopSettingsPath,
setCloudSessionsEnabled,
} from "./desktop-settings";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings", () => {
it("defaults cloud sessions to off when no settings file exists", () => {
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("persists the cloud sessions opt-in and reads it back", () => {
expect(setCloudSessionsEnabled(true)).toEqual({
cloudSessionsEnabled: true,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
true,
);
expect(
JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")),
).toMatchObject({ cloudSessionsEnabled: true });
expect(setCloudSessionsEnabled(false)).toEqual({
cloudSessionsEnabled: false,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("treats malformed files and non-boolean values as off", () => {
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
writeFileSync(
resolveDesktopSettingsPath(),
JSON.stringify({ cloudSessionsEnabled: "yes" }),
"utf8",
);
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
});
@@ -0,0 +1,49 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/** Desktop-only preferences kept separate from strict shared global settings. */
export type DesktopSettings = {
/** Opt-in gate for cloud sessions while the feature is in preview. */
cloudSessionsEnabled: boolean;
};
const DEFAULT_SETTINGS: DesktopSettings = {
cloudSessionsEnabled: false,
};
export function resolveDesktopSettingsPath(): string {
return join(resolveClineDataDir(), "settings", "code-settings.json");
}
export function readDesktopSettings(): DesktopSettings {
let raw: string;
try {
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
} catch {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function writeDesktopSettings(settings: DesktopSettings): void {
const filePath = resolveDesktopSettingsPath();
mkdirSync(dirname(filePath), { recursive: true });
// Avoid leaving torn settings if the process exits mid-write.
const tempPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
renameSync(tempPath, filePath);
}
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
writeDesktopSettings(next);
return next;
}
@@ -1,3 +1,6 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
poll: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
setContext: vi.fn(),
getBooleanFlagEnabled: vi.fn((_flag: unknown): boolean => false),
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
}));
@@ -39,6 +43,7 @@ vi.mock("@cline/core", async () => {
poll = mocks.poll;
dispose = mocks.dispose;
setContext = mocks.setContext;
getBooleanFlagEnabled = mocks.getBooleanFlagEnabled;
getFlagPayload = mocks.getFlagPayload;
},
};
@@ -61,15 +66,26 @@ import {
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const originalIsTest = process.env.IS_TEST;
const originalDataDir = process.env.CLINE_DATA_DIR;
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-feature-flags-"));
process.env.CLINE_DATA_DIR = dataDir;
vi.clearAllMocks();
mocks.getBooleanFlagEnabled.mockReset().mockReturnValue(false);
resetDesktopFeatureFlagsForTesting();
delete process.env.IS_TEST;
delete process.env.E2E_TEST;
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalApiKey === undefined) {
delete process.env.TELEMETRY_SERVICE_API_KEY;
} else {
@@ -239,3 +255,53 @@ describe("disposeDesktopFeatureFlagsService", () => {
expect(mocks.dispose).not.toHaveBeenCalled();
});
});
describe("cloud agents gate", () => {
beforeEach(() => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
});
it("is unavailable and disabled while the rollout flag is off", async () => {
const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import(
"./feature-flags"
);
expect(isCloudAgentsAvailable()).toBe(false);
expect(isCloudAgentsEnabled()).toBe(false);
});
it("does not enable a boolean rollout from a truthy variant payload", async () => {
const { isCloudAgentsAvailable } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue("control");
expect(isCloudAgentsAvailable()).toBe(false);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith(
"code-cloud-agents",
);
});
it("needs both the rollout flag and the user's opt-in to enable", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { setCloudSessionsEnabled } = await import("./desktop-settings");
mocks.getBooleanFlagEnabled.mockImplementation(
(flag: unknown) => flag === "code-cloud-agents",
);
expect(isCloudAgentsAvailable()).toBe(true);
setCloudSessionsEnabled(false);
expect(isCloudAgentsEnabled()).toBe(false);
setCloudSessionsEnabled(true);
expect(isCloudAgentsEnabled()).toBe(true);
});
it("lets the env override force the gate in both directions", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
mocks.getFlagPayload.mockReturnValue(undefined);
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
expect(isCloudAgentsAvailable()).toBe(true);
expect(isCloudAgentsEnabled()).toBe(true);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
expect(isCloudAgentsEnabled()).toBe(false);
});
});
@@ -13,7 +13,11 @@ import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { FeatureFlag as SharedFeatureFlag } from "@cline/shared";
import { resolveClineDataDir } from "@cline/shared/storage";
import { readDesktopSettings } from "./desktop-settings";
const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS;
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -169,3 +173,33 @@ export function resetDesktopFeatureFlagsForTesting(): void {
desktopFeatureFlagsService = undefined;
desktopFeatureFlagsContext = { clientName: "cline-code" };
}
export function readCloudAgentsEnvOverride(): boolean | undefined {
const override = process.env.CLINE_CODE_CLOUD_AGENTS?.trim().toLowerCase();
if (override === "1" || override === "true") return true;
if (override === "0" || override === "false") return false;
return undefined;
}
/** Whether the rollout makes cloud sessions available to this install. */
export function isCloudAgentsAvailable(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
const override = readCloudAgentsEnvOverride();
if (override !== undefined) return override;
return getDesktopFeatureFlagsService(options).getBooleanFlagEnabled(
FEATURE_FLAG_CODE_CLOUD_AGENTS,
);
}
/** Whether cloud sessions are both available and enabled by the user. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
const override = readCloudAgentsEnvOverride();
if (override !== undefined) return override;
if (!isCloudAgentsAvailable(options)) return false;
return readDesktopSettings().cloudSessionsEnabled;
}
+6 -1
View File
@@ -7,7 +7,11 @@ import {
setModelToolEnabledGlobally,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
import {
captureSdkError,
claimHubDaemonProcess,
disableCurrentDirectoryExecutableSearch,
} from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { configureConnectorCliLaunch } from "./connectors";
import {
@@ -232,6 +236,7 @@ async function runEntrypoint(): Promise<void> {
runTelemetrySelfcheck();
return;
}
disableCurrentDirectoryExecutableSearch();
// Claim rather than read: consuming the sentinel keeps daemon-hosted sessions
// from handing it to every process they spawn.
if (claimHubDaemonProcess()) {
@@ -0,0 +1,72 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
describe("clearLegacyProviderCredentials", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("removes only the Codex credentials from the legacy secrets file", () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
tempDirs.push(dataDir);
const secretsPath = path.join(dataDir, "secrets.json");
writeFileSync(
secretsPath,
JSON.stringify({
"openai-codex-oauth-credentials": JSON.stringify({
access_token: "a",
refresh_token: "r",
}),
openRouterApiKey: "sk-or-keep",
}),
);
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(true);
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
openRouterApiKey: "sk-or-keep",
});
});
it("removes both Cline account secrets from the legacy secrets file", () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
tempDirs.push(dataDir);
const secretsPath = path.join(dataDir, "secrets.json");
writeFileSync(
secretsPath,
JSON.stringify({
"cline:clineAccountId": JSON.stringify({ idToken: "t" }),
clineApiKey: "cline-key",
openRouterApiKey: "sk-or-keep",
}),
);
expect(clearLegacyProviderCredentials("cline", dataDir)).toBe(true);
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
openRouterApiKey: "sk-or-keep",
});
});
it("is a no-op when the file is missing, has no matching credentials, or the provider is unknown", () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
tempDirs.push(dataDir);
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(false);
const secretsPath = path.join(dataDir, "secrets.json");
writeFileSync(secretsPath, JSON.stringify({ apiKey: "keep" }));
expect(clearLegacyProviderCredentials("cline", dataDir)).toBe(false);
expect(clearLegacyProviderCredentials("anthropic", dataDir)).toBe(false);
expect(readFileSync(secretsPath, "utf8")).toBe(
JSON.stringify({ apiKey: "keep" }),
);
writeFileSync(secretsPath, "{not json");
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(false);
});
});
@@ -0,0 +1,56 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/**
* Legacy VS Code extension secrets.json keys that the legacy import in
* ProviderSettingsManager turns back into a providers.json entry.
*/
const LEGACY_SECRET_KEYS_BY_PROVIDER: Record<string, string[]> = {
"openai-codex": ["openai-codex-oauth-credentials"],
cline: ["cline:clineAccountId", "clineApiKey"],
};
/**
* Removes a provider's credentials from the legacy VS Code extension's
* secrets.json. The legacy import in ProviderSettingsManager runs on
* construction and re-adds any provider missing from providers.json, so
* leaving these credentials on disk would sign the user straight back in
* after they sign out in the desktop app. Temporary until the legacy import
* is retired.
*
* A missing or unparseable file is a no-op (the import ignores those too).
* A failed write throws so the sign-out is reported as failed instead of
* succeeding and then being undone by the next import.
*/
export function clearLegacyProviderCredentials(
providerId: string,
dataDir: string = resolveClineDataDir(),
): boolean {
const keys = LEGACY_SECRET_KEYS_BY_PROVIDER[providerId];
const secretsPath = join(dataDir, "secrets.json");
if (!keys || !existsSync(secretsPath)) {
return false;
}
let secrets: unknown;
try {
secrets = JSON.parse(readFileSync(secretsPath, "utf8"));
} catch {
return false;
}
if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) {
return false;
}
const present = keys.filter((key) => key in secrets);
if (present.length === 0) {
return false;
}
for (const key of present) {
delete (secrets as Record<string, unknown>)[key];
}
writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return true;
}
@@ -0,0 +1,68 @@
import type { ITelemetryService } from "@cline/shared";
import { expect, it, vi } from "vitest";
import { capturePullRequestEvent } from "./pull-request-telemetry";
const event = {
action: "open_clicked",
prState: "open",
ciState: "success",
mergeTone: "success",
};
function service(enabled = true) {
const capture = vi.fn();
return {
capture,
telemetry: {
capture,
isEnabled: () => enabled,
} as unknown as ITelemetryService,
};
}
it("captures only allowlisted status categories and strips identifiers", () => {
const { capture, telemetry } = service();
capturePullRequestEvent(telemetry, {
...event,
repository: "private/repo",
cwd: "/private/path",
url: "https://github.com/private/repo",
title: "Secret",
number: 42,
});
expect(capture).toHaveBeenCalledExactlyOnceWith({
event: "desktop.pull_request.open_clicked",
properties: {
prState: "open",
ciState: "success",
mergeTone: "success",
},
});
});
it.each([
{ ...event, action: "arbitrary.event" },
{ ...event, prState: "private/repo" },
{ ...event, ciState: "test name" },
{ ...event, mergeTone: "secret" },
{},
null,
])("drops invalid payloads", (input) => {
const { capture, telemetry } = service();
capturePullRequestEvent(telemetry, input);
expect(capture).not.toHaveBeenCalled();
});
it("respects telemetry opt-out", () => {
const { capture, telemetry } = service(false);
capturePullRequestEvent(telemetry, event);
expect(capture).not.toHaveBeenCalled();
expect(() => capturePullRequestEvent(undefined, event)).not.toThrow();
});
it("does not fail the command when the provider throws", () => {
const { capture, telemetry } = service();
capture.mockImplementation(() => {
throw new Error("Telemetry unavailable");
});
expect(() => capturePullRequestEvent(telemetry, event)).not.toThrow();
});
@@ -0,0 +1,17 @@
import type { ITelemetryService } from "@cline/shared";
import { pullRequestTelemetrySchema } from "../webview/lib/pull-request-telemetry-schema";
export function capturePullRequestEvent(
telemetry: ITelemetryService | undefined,
input: unknown,
): void {
const parsed = pullRequestTelemetrySchema.safeParse(input);
if (!parsed.success) return;
try {
if (!telemetry?.isEnabled()) return;
const { action, ...properties } = parsed.data;
telemetry.capture({ event: `desktop.pull_request.${action}`, properties });
} catch {
// Product interactions must continue if the telemetry provider fails.
}
}
@@ -0,0 +1,252 @@
import { describe, expect, it, vi } from "vitest";
import { getMergeStatus, summarizeChecks } from "../webview/lib/pull-request";
import {
createPullRequestStatusReader,
GITHUB_AVAILABILITY_CACHE_MS,
githubRepository,
normalizeCheck,
} from "./pull-request";
const pr = {
number: 42,
title: "Feature",
url: "https://github.com/cline/cline/pull/42",
state: "OPEN",
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
additions: 12,
deletions: 3,
headRepositoryOwner: { login: "cline" },
headRepository: { name: "cline" },
statusCheckRollup: [
{
__typename: "CheckRun",
name: "Test",
status: "COMPLETED",
conclusion: "SUCCESS",
},
],
};
function runner(prs: unknown[] = [pr], branch = "feature/pr-ui") {
return vi.fn(async (file: string, args: string[], _cwd: string) => {
if (file === "git")
return args[0] === "branch" ? branch : "git@github.com:cline/cline.git";
if (args[0] === "pr" && args[1] === "view")
return JSON.stringify(
prs.find((item) => (item as typeof pr).number === Number(args[2])),
);
return JSON.stringify(
args[0] === "repo" ? { defaultBranchRef: { name: "main" } } : prs,
);
});
}
describe("pull request status", () => {
it("reads the active workspace, filters same-named fork branches and prefers an open PR", async () => {
const run = runner([
{ ...pr, number: 99, headRepositoryOwner: { login: "someone" } },
{ ...pr, number: 41, state: "MERGED" },
pr,
]);
const result = await createPullRequestStatusReader({ run })("/worktree");
expect(result?.pullRequest?.number).toBe(42);
expect(result?.pullRequest?.checks[0].state).toBe("success");
expect(result?.createUrl).toBe(
"https://github.com/cline/cline/compare/main...feature%2Fpr-ui?expand=1",
);
expect(run.mock.calls.every((call) => call[2] === "/worktree")).toBe(true);
expect(run.mock.calls.find((call) => call[1][0] === "pr")?.[1]).toContain(
"feature/pr-ui",
);
});
it("offers creation for a feature branch and hides it for the default branch", async () => {
expect(
(await createPullRequestStatusReader({ run: runner([]) })("/repo"))
?.pullRequest,
).toBeNull();
expect(
await createPullRequestStatusReader({ run: runner([], "main") })("/repo"),
).toBeNull();
});
it("keeps merged and closed PR states", async () => {
for (const state of ["MERGED", "CLOSED"] as const) {
const result = await createPullRequestStatusReader({
run: runner([{ ...pr, state }]),
})("/repo");
expect(result?.pullRequest?.state).toBe(state);
}
});
it("does not invoke GitHub for detached HEAD or unsupported remotes", async () => {
const detached = runner([], "");
expect(
await createPullRequestStatusReader({ run: detached })("/repo"),
).toBeNull();
expect(detached).toHaveBeenCalledTimes(1);
const local = vi.fn(async () => "local");
expect(
await createPullRequestStatusReader({ run: local })("/repo"),
).toBeNull();
expect(local).toHaveBeenCalledTimes(2);
});
it.each([
"ENOENT",
1,
4,
])("hides unavailable GitHub CLI (%s), shares the cooldown, and recovers after login", async (code) => {
let time = 0;
let authenticated = false;
const base = runner();
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
if (file === "gh" && args[0] === "auth" && !authenticated)
throw Object.assign(new Error("private stderr"), { code });
return base(file, args, cwd);
});
const read = createPullRequestStatusReader({ run, now: () => time });
expect(await read("/repo")).toBeNull();
const attempts = run.mock.calls.length;
authenticated = true;
time = GITHUB_AVAILABILITY_CACHE_MS - 1;
expect(await read("/other-workspace")).toBeNull();
expect(run).toHaveBeenCalledTimes(attempts);
time++;
expect((await read("/repo"))?.pullRequest?.number).toBe(42);
expect(run.mock.calls.filter((call) => call[1][0] === "auth")).toHaveLength(
2,
);
});
it("shares an in-flight availability check across concurrent workspaces", async () => {
let finish!: () => void;
const waiting = new Promise<void>((resolve) => {
finish = resolve;
});
const base = runner();
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
if (args[0] === "auth") await waiting;
return base(file, args, cwd);
});
const read = createPullRequestStatusReader({ run });
const first = read("/first");
const second = read("/second");
await vi.waitFor(() =>
expect(
run.mock.calls.filter((call) => call[1][0] === "auth"),
).toHaveLength(1),
);
finish();
await Promise.all([first, second]);
expect(run.mock.calls.filter((call) => call[1][0] === "auth")).toHaveLength(
1,
);
});
it("hides default-branch authentication failures before any repository query", async () => {
const base = runner([], "main");
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
if (file === "gh")
throw Object.assign(new Error("Not logged in"), { code: 1 });
return base(file, args, cwd);
});
expect(await createPullRequestStatusReader({ run })("/repo")).toBeNull();
expect(
run.mock.calls.filter((call) => call[0] === "gh").map((call) => call[1]),
).toEqual([["auth", "status", "--active", "--hostname", "github.com"]]);
});
it.each([
{ code: 4 },
{ code: 1, stderr: "HTTP 401: Bad credentials" },
])("invalidates cached availability if authentication expires during a lookup", async (failure) => {
let expired = false;
const base = runner();
const run = vi.fn(async (file: string, args: string[], cwd: string) => {
if (args[0] === "repo" && expired)
throw Object.assign(new Error("Failed"), failure);
return base(file, args, cwd);
});
const read = createPullRequestStatusReader({ run });
expect((await read("/repo"))?.pullRequest?.number).toBe(42);
expired = true;
expect(await read("/repo")).toBeNull();
const attempts = run.mock.calls.length;
expect(await read("/other")).toBeNull();
expect(run).toHaveBeenCalledTimes(attempts);
});
it("preserves transient lookup errors after successful authentication", async () => {
const base = runner();
const run = async (file: string, args: string[], cwd: string) => {
if (args[0] === "repo")
throw Object.assign(new Error("private stderr"), {
code: 1,
stderr: "error connecting to api.github.com",
});
return base(file, args, cwd);
};
await expect(
createPullRequestStatusReader({ run })("/repo"),
).rejects.toThrow(
"Could not load pull request status. Check your connection and try again.",
);
});
it("accepts GitHub SSH/HTTPS remotes only", () => {
for (const remote of [
"git@github.com:cline/cline.git",
"https://github.com/cline/cline.git",
"ssh://git@github.com/cline/cline",
])
expect(githubRepository(remote)).toBe("cline/cline");
expect(
githubRepository("https://github.com.evil.test/cline/cline"),
).toBeNull();
});
});
describe("check and merge states", () => {
it("handles check runs, legacy statuses, skipped checks and unsafe links", () => {
const pending = normalizeCheck({
__typename: "CheckRun",
status: "IN_PROGRESS",
conclusion: "SUCCESS",
});
const failed = normalizeCheck({
__typename: "StatusContext",
state: "ERROR",
context: "Build",
targetUrl: "javascript:alert(1)",
});
const skipped = normalizeCheck({
__typename: "CheckRun",
status: "COMPLETED",
conclusion: "SKIPPED",
});
expect(pending.state).toBe("pending");
expect(failed).toEqual({ name: "Build", state: "failure", url: undefined });
expect(summarizeChecks([pending, failed])).toBe("failure");
expect(summarizeChecks([skipped])).toBe("skipped");
expect(summarizeChecks([])).toBe("none");
});
it("never calls an unknown, draft or blocked PR ready to merge", async () => {
const result = await createPullRequestStatusReader({ run: runner() })(
"/repo",
);
const value = result!.pullRequest!;
expect(
getMergeStatus({ ...value, mergeStateStatus: "BLOCKED" }).label,
).toBe("Blocked");
expect(getMergeStatus({ ...value, isDraft: true }).label).toBe("Draft");
expect(
getMergeStatus({
...value,
mergeable: "UNKNOWN",
mergeStateStatus: "UNKNOWN",
}).label,
).toBe("Merge status pending");
expect(getMergeStatus({ ...value, mergeable: "CONFLICTING" }).label).toBe(
"Conflicts",
);
});
});
@@ -0,0 +1,269 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
PullRequestCheck,
PullRequestStatus,
} from "../webview/lib/pull-request";
const execFileAsync = promisify(execFile);
type RunCommand = (
file: string,
args: string[],
cwd: string,
) => Promise<string>;
const runCommand: RunCommand = async (file, args, cwd) => {
const { stdout } = await execFileAsync(file, args, {
cwd,
encoding: "utf8",
timeout: 15_000,
maxBuffer: 2 * 1024 * 1024,
env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" },
});
return stdout.trim();
};
type GitHubCheck = {
__typename: string;
name?: string;
context?: string;
status?: string;
conclusion?: string;
state?: string;
detailsUrl?: string;
targetUrl?: string;
};
export function normalizeCheck(check: GitHubCheck): PullRequestCheck {
const result =
check.__typename === "CheckRun"
? check.status === "COMPLETED"
? check.conclusion
: "PENDING"
: check.state;
return {
name: check.name || check.context || "Check",
state:
result === "SUCCESS"
? "success"
: result === "NEUTRAL" || result === "SKIPPED"
? "skipped"
: [
"FAILURE",
"ERROR",
"CANCELLED",
"TIMED_OUT",
"ACTION_REQUIRED",
"STALE",
"STARTUP_FAILURE",
].includes(result ?? "")
? "failure"
: "pending",
url: safeHttpUrl(check.detailsUrl || check.targetUrl),
};
}
function safeHttpUrl(value?: string): string | undefined {
if (!value) return undefined;
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.href : undefined;
} catch {
return undefined;
}
}
export function githubRepository(remote: string): string | null {
const match = remote.match(
/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+?)\/?$/,
);
return match ? `${match[1]}/${match[2].replace(/\.git$/, "")}` : null;
}
type GitHubPullRequest = Omit<
NonNullable<PullRequestStatus["pullRequest"]>,
"checks"
> & {
headRepositoryOwner: { login: string } | null;
headRepository: { name: string } | null;
statusCheckRollup: GitHubCheck[] | null;
};
// Shared across workspaces: missing credentials are a machine-level capability,
// not a repository failure. Retry after installation/login without polling gh.
export const GITHUB_AVAILABILITY_CACHE_MS = 5 * 60_000;
function commandErrorCode(error: unknown): unknown {
return error && typeof error === "object" && "code" in error
? error.code
: undefined;
}
function isAuthenticationFailure(error: unknown): boolean {
if (commandErrorCode(error) === 4) return true;
const stderr =
error && typeof error === "object" && "stderr" in error
? String(error.stderr)
: "";
return /HTTP 401|Bad credentials/i.test(stderr);
}
export function createPullRequestStatusReader({
run = runCommand,
now = Date.now,
}: {
run?: RunCommand;
now?: () => number;
} = {}) {
let available: boolean | undefined;
let expiresAt = 0;
let probe: Promise<boolean> | undefined;
function markUnavailable() {
available = false;
expiresAt = now() + GITHUB_AVAILABILITY_CACHE_MS;
}
async function isAvailable(cwd: string): Promise<boolean> {
if (available !== undefined && now() < expiresAt) return available;
if (probe) return probe;
probe = (async () => {
try {
await run(
"gh",
["auth", "status", "--active", "--hostname", "github.com"],
cwd,
);
available = true;
expiresAt = now() + GITHUB_AVAILABILITY_CACHE_MS;
return true;
} catch (error) {
// gh auth status documents exit 1 for missing/invalid authentication.
const code = commandErrorCode(error);
if (code === "ENOENT" || code === 1 || code === 4) {
markUnavailable();
return false;
}
throw error;
}
})();
try {
return await probe;
} finally {
probe = undefined;
}
}
/** Read-only: creation is reviewed and submitted in GitHub's compare form. */
return async function readPullRequestStatus(
cwd: string,
): Promise<PullRequestStatus | null> {
if (available === false && now() < expiresAt) return null;
const branch = await run("git", ["branch", "--show-current"], cwd).catch(
() => "",
);
if (!branch) return null;
const remote = await run("git", ["remote", "get-url", "origin"], cwd).catch(
() => "",
);
const repository = githubRepository(remote);
if (!repository) return null;
try {
if (!(await isAvailable(cwd))) return null;
const repo = JSON.parse(
await run(
"gh",
["repo", "view", repository, "--json", "defaultBranchRef"],
cwd,
),
) as { defaultBranchRef: { name: string } | null };
const base = repo.defaultBranchRef?.name;
// The default branch can have years-old PRs from earlier branch workflows.
// Those do not describe the current work, and it is not a PR source branch.
if (branch === base) return null;
const prsJson = await run(
"gh",
[
"pr",
"list",
"--repo",
repository,
"--head",
branch,
"--state",
"all",
"--limit",
"100",
"--json",
"number,state,headRepositoryOwner,headRepository",
],
cwd,
);
const [owner, name] = repository.split("/");
const candidates = (
JSON.parse(prsJson) as Pick<
GitHubPullRequest,
"number" | "state" | "headRepositoryOwner" | "headRepository"
>[]
).filter(
(pr) =>
pr.headRepositoryOwner?.login.toLowerCase() === owner.toLowerCase() &&
pr.headRepository?.name.toLowerCase() === name.toLowerCase(),
);
const candidate =
candidates.find((pr) => pr.state === "OPEN") ?? candidates[0];
const pr = candidate
? (JSON.parse(
await run(
"gh",
[
"pr",
"view",
String(candidate.number),
"--repo",
repository,
"--json",
"number,title,url,state,isDraft,mergeable,mergeStateStatus,additions,deletions,statusCheckRollup",
],
cwd,
),
) as GitHubPullRequest)
: null;
return {
repository,
branch,
createUrl:
base && branch !== base
? `https://github.com/${repository}/compare/${encodeURIComponent(base)}...${encodeURIComponent(branch)}?expand=1`
: null,
pullRequest: pr
? {
number: pr.number,
title: pr.title,
url: pr.url,
state: pr.state,
isDraft: pr.isDraft,
mergeable: pr.mergeable,
mergeStateStatus: pr.mergeStateStatus,
additions: pr.additions,
deletions: pr.deletions,
checks: (pr.statusCheckRollup ?? []).map(normalizeCheck),
}
: null,
};
} catch (error) {
// Authentication can expire while a positive availability result is cached.
if (
commandErrorCode(error) === "ENOENT" ||
isAuthenticationFailure(error)
) {
markUnavailable();
return null;
}
throw new Error(
"Could not load pull request status. Check your connection and try again.",
);
}
};
}
export const getPullRequestStatus = createPullRequestStatusReader();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More