Compare commits

..
Author SHA1 Message Date
Saoud RizwanandClaude Opus 5 de1d2a322c chore(desktop): release v0.0.24
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBdgHNDCfm7WKP8qbvn7cr
2026-09-08 20:03:56 -07:00
Bee 245a7d0ccb fix(desktop): show token usage and cost for every visible session-history page (#13971)
* fix(desktop): hydrate session usage for the visible page, not just the first four rows

Discovery rows carry no token or cost totals; the history hook sums them
from each session's transcript in a second read, and that read was
hard-coded to the four most recent sessions. Every other row in the
sessions view rendered "-" for tokens and cost, and paging never asked
for more.

Widen the default window to the first ten rows (one page of the sessions
view, and the sidebar's initial threads), expose requestUsage() so a view
can ask for the rows it is showing, and have the sessions view report its
visible page so moving to older pages fills them in on demand. Transcript
reads are capped at four in flight, since each one parses a whole session
file in the sidecar.

* fix(desktop): enforce the usage-read cap across hydration effect restarts

The four-read cap was a counter local to one run of the hydration effect.
A session refresh or page change restarts the effect while reads are
still pending, and the new run started from zero, so four more reads
could join the four already in flight.

Check the cap against usageLoadingRef, which counts every read in flight
across runs, and have a finishing read pump the current run's queue via
usagePumpRef so a freed slot goes to the newest queue. Track hydrated
usage in a synchronous usageByIdRef instead of threadsRef, which lags
React's commit and made a just-finished row look unhydrated and get read
again. The delete handler no longer clears the in-flight gauge for a row
whose read is still running.

The regression test restarts the effect with four reads pending and
checks that no fifth read starts, that the restarted queue still drains
as the earlier reads finish, and that no session is read twice.

* fix(desktop): re-read a session whose status changed while its usage read was pending

A restarted hydration run dropped any row that already had a read in
flight. If the row's status had changed in the meantime (a running
session finishing is the common case), the pending read's result was
already stale when it landed, and nothing read the row again until some
later refresh happened to restart the effect. The row could sit on the
totals from before its last turn indefinitely.

Record the status each in-flight read was started under in
usageLoadingRef. A restarted run skips a row whose pending read was
started under its current status and defers one whose status has moved:
the row stays queued, and when the pending read settles and records the
status it was started under, the mismatch makes the next pump read the
row again ahead of rows never read.

Also bound the on-demand set: requestUsage now replaces the requested
ids instead of accumulating them, and the sessions view releases its page
on unmount, so a running session on a page the user has left is not
re-read on every refresh. The equality guard keeps the same Set instance
when the members are unchanged, so the view re-reporting its page on
every threads change does not restart the effect.

Tests: the status-change case (four reads pending, session-3 goes
running -> completed, its stale read finishes, it is read once more
before session-4) and the release case (a running row is re-read while
requested and left alone after the request is cleared). Both fail on the
previous commit.
2026-09-08 19:52:59 -07:00
Saoud RizwanandClaude Fable 5.1 23f2197c53 fix(desktop): ask the user how to continue when the mistake limit trips instead of stopping silently (#13969)
* desktop: ask the user how to continue when the mistake limit trips

The core's loop detector stops a run after 5 identical consecutive tool
calls (and the mistake tracker after 6 consecutive failures) by asking the
client for a decision via onConsecutiveMistakeLimitReached. The desktop
never registered that callback, so the SDK default "stop" applied and the
webview rendered the result exactly like the Stop button: the composer went
idle with no message. Users on models that fall into identical-call loops
(reported with cline-pass/kimi-k3 re-sending `editor` with old_text: null)
saw Cline "randomly stop" mid-task, and a nudge died after one more call
because the identical-call counter survives across turns.

Mirror the CLI's interactive handling (apps/cli/src/runtime/interactive/
mistakes.ts): route the decision through the sidecar's existing ask-question
channel with "Try a different approach" / "Stop this run". The prompt reads
the session id lazily because fresh starts only learn it after
manager.start() resolves and the webview matches prompts by active session.

On continue, steer the guidance into the running turn via manager.send
delivery "steer". The core appends its own guidance to a transcript store
the live runtime never reads mid-run, so without this the model would
resume with no idea why it was paused and repeat the same call.

Wired into every desktop start path: start, provider-change rebuild, fork,
and checkpoint restore. No webview changes; it already renders ask-question
requests.

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

* fix(desktop): make mistake-limit recovery pause and cancel reliably

* fix(desktop): keep mistake recovery within the desktop app

* fix(desktop): wait for mistake recovery decisions before continuing

* fix(desktop): settle unfinished tool rows when a run stops

* fix(desktop): harden mistake recovery and terminal cleanup

* fix(desktop): confirm session status before settling tools

* fix(desktop): scope stopped tool recovery to mistake prompts

* fix(desktop): deliver mistake guidance only through steering

* fix(desktop): simplify stopped tools to a rendering change

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:45:24 -07:00
Saoud RizwanandClaude Fable 5.1 9df2876c89 fix(desktop): keep the observer stream stood down for the whole busy run (#13976)
* fix(desktop): keep the observer stream stood down for the whole busy run

The core-pipe liveness mark from #13968 expired after 5s of silence even
mid-turn. Long commands, slow first tokens, and unanswered tool approvals
stall both hub pipes together, so the observer's copy of the first event
after such a gap arrived ahead of the core copy and was emitted, doubling
a delta or leaving a duplicate tool row stuck at "start" until the
turn-end reconcile.

While the session is busy, treat the mark as active regardless of age;
the 5s window now only governs idle sessions. The mark is still cleared
on session end, so an observer-only session is unaffected.

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

* fix(desktop): forget the core pipe mark when the sidecar stops a session

stop() disposes the ClineCore subscription without any local `ended`
event, so the activity mark from the previous run survived. With the
mark now treated as active for the whole busy run, a later run another
client started on the same session would set busy via the observer's
run.started and every observer chunk would be dropped with no core
subscription left to serve it.

Clear the mark on every sidecar stop path (stop command, provider-change
rebuild and its rollback, reset). Abort keeps the subscription and needs
nothing.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:26:43 -07:00
John Choi b63c738c22 feat(sdk): support authenticated remote Hub connections (#13519)
* 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

* docs(sdk): clarify remote Hub connection headers

* test(sdk): tighten hub header coverage

* test(core): batch root history fixture inserts

* test(sdk): await daemon health after discovery publication
2026-09-08 16:59:57 -07:00
Saoud Rizwan bbacedd437 fix(desktop): preserve Cline Pass model selection across new chats (#13975) 2026-09-08 16:57:58 -07:00
Dominic CooneyandSaoud Rizwan 4096eca8a1 feat(desktop): custom Windows title bar (#13831)
* feat(desktop): add custom Windows title bar

* fix(desktop): keep Windows caption controls inside the compact title-bar row

The fixed caption controls stayed h-12 when the title-bar row shrinks to
its max-md:h-7 compact height, so they overlapped page content in narrow
windows. The controls now follow the same responsive height.

Also cover the resize-driven Maximize/Restore label transitions with a
test that invokes the captured onResized listener.

* fix(desktop): keep Windows caption controls above overlays

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-08 16:45:26 -07:00
Saoud RizwanandClaude Fable 5.1 2f0f78bb8e fix(tools): tell the model how to recover when editor old_text is null (#13970)
* fix(tools): tell the model how to recover when editor old_text is null

The editor schema declares old_text as nullable+optional, so the JSON
schema the model receives is anyOf[string, null]. Models that fill optional
parameters with null (observed with cline-pass/kimi-k3) then hit a terse
"old_text is required" error for existing files and re-send the identical
call until the loop detector stops the run.

Spell out in the schema description that null/omitted is only valid when
creating a file or inserting via insert_line, and make the executor error
name the file, say whether old_text was null or omitted, and state the
recovery so the next call has a reason to differ.

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

* revert schema description and test changes; keep only the executor error message

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:14:57 -07:00
Saoud RizwanandClaude Opus 5 62ba65397f fix(desktop): stop the live chat stream from doubling and dropping chunks (#13968)
* fix(desktop): emit each streamed chat chunk once

The sidecar has two pipes into `emitChunk`: the ClineCore session
subscription (`handleCoreSessionEvent`) and the hub observer client
(`handleHubLiveEvent`, gated on `attachedViaHub`). Opening a session arms
both — the hydrate's `pending_prompts` call makes `HubRuntimeHost`
subscribe to the session, and `attach` sets `attachedViaHub` — so for a
session streaming through the hub every delta was emitted twice.

Neither existing guard caught it. Both copies go through `emitChunk`, so
each gets its own increasing `index`, which is what the webview's replay
guard compares; and the webview's `endsWith` fallback is defeated by the
50ms coalescing buffer, which concatenates the duplicated deltas before
comparing them. `attachedViaHub` is cleared when the webview *sends*, so
this only showed on sessions that stream without a local send first — a
run already in flight when the task is opened, a resumed or scheduled run
— and the canonical store was always clean, so reopening the task
rendered correct text.

Arbitrate instead of guessing which pipe owns a session: the first source
to deliver a contended stream wins and the other is muted until the owner
falls silent for 5s. Ownership is per stream, so a pipe that wins one
stream cannot mute another it does not itself carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

* fix(desktop): keep rendering the live stream when the sidecar restarts

`shouldApplyStreamChunk` drops any chunk whose `index` is not above the
highest one already seen for that session. That counter lives in the
sidecar process (`ctx.streamIndices`), but the webview only cleared its
high-water mark in `hydrateSession`, never on reconnect. So when a sidecar
was replaced under a live webview — crash-respawn, hub drain-and-replace,
a stale-sidecar swap — the replacement started numbering at 1 again and
the webview silently discarded everything for that session until the new
process counted past the old run.

The guard runs ahead of any per-stream handling, so this dropped far more
than assistant text: `chat_queued_prompt_start` (the user's own message
bubbles) and the tool-call rows went with it. The transcript only looked
broken live — the turn-end reconcile and switching tasks both re-read
canonical history and repaired it, which is why it presented as rows that
vanish mid-turn and come back afterwards.

Stamp each chunk with the emitting sidecar's boot id so a counter reset is
a fact rather than an inference: a changed boot id means a new process, so
the mark is rebased instead of swallowing the stream. Replays from the
same process are still dropped exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

* refactor(desktop): let the core pipe decide, not a list of stream names

The first pass arbitrated a hardcoded set of six `chat_*` stream names —
a third copy of knowledge already encoded in the two switch statements
that emit them, and one a later stream would silently drift out of.

The two pipes are not peers, so they do not need symmetric arbitration.
ClineCore's session subscription is the primary; the hub observer's
projection exists to cover sessions ClineCore is not subscribed to. Any
event reaching `handleCoreSessionEvent` proves it is subscribed, so that
pipe records its own liveness and the observer stands down while it is
serving. Dropping an observer chunk for a stream the observer never
produces is a no-op, so the list has nothing left to do.

Liveness is marked from the pipe rather than from `emitChunk`, so chunks
the sidecar synthesizes locally never claim to be the core subscription.

Net: the stream-name list, the per-stream owner map and its type are
gone, and detection now starts at the session's first core event of any
kind instead of its first contended chunk.

Also fills in `coreStreamActivity` on the partial `as unknown as
SidecarContext` fixtures in chat-session.test.ts — they bypass the type
checker, so a missing field only surfaces as a runtime crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

* docs(desktop): record why core-pipe liveness marks on every event

Review asked why status and queue events mark the pipe active when they
carry no chat content. Marking only on content would be worse, and the
reason is not local to this function: the hub fans out to listeners in
registration order, so the observer's global subscription (registered at
sidecar boot) sees each delta before this per-session one (registered at
hydrate). Waiting for core content to establish the mark would let the
observer's copy of a turn's first delta through before the mark existed,
doubling it every turn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNoho8fGgnh71xjQF5xMqy

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:04:14 -07:00
Saoud RizwanandSaoud Rizwan 79bf1e8c48 Imported sessions: warn in the chat and summarize the foreign history on resume (#13964)
* desktop: flag sessions imported from other coding agents in the chat

Imported Claude Code / Codex / opencode transcripts keep the source tool's
own tool names and schemas, so resuming them in Cline can behave worse than
a native session. Lead the transcript with a notice naming the source tool
so the user knows why.

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

* core: summarize imported sessions on their first resumed turn

Imported transcripts keep the source agent's tool names and schemas, which a
model continuing them may try to call. When a session marked importedFrom is
resumed without a compaction sidecar, run a manual agentic compaction over
the whole foreign history before the first model request. The summary lands
in the sidecar, so it runs once and the canonical transcript stays intact;
on failure the turn falls back to the raw history.

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

* desktop: show a status row while an imported session's history is summarized

Core tags the resume-time compaction notices with the source tool; the sidecar
now forwards notice metadata and the webview turns the started/completed pair
into one in-place status row in the transcript.

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

* desktop: keep the imported-history summary row through canonical rehydration

The row is client-only, so applyCanonicalHistory re-seats it by timestamp
instead of dropping it when the persisted transcript replaces live state.

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

* desktop: name the imported-history summary in the pre-output indicator instead of a transcript row

The summary is not part of the persisted transcript, so a client-only row
had to be re-seated after every canonical rehydration. Show it where the
ephemeral state already lives: the "Thinking..." indicator reads
"Summarizing the imported <tool> history..." while it runs, and the
imported-session notice states that the model works from a summary.

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

* review cleanup: extract imported-history compaction policy, fold client helper into session-import

- core: createImportedHistoryCompactionPrepareTurn lives in compaction.ts
  next to the other prepareTurn builders; the host only decides when it
  applies. Fix a tool_result fixture missing its name (tsc, not vitest).
- desktop: readImportedHistorySummaryActivity moves into session-import.ts
  alongside readImportedFromTool, with one test file for both.
- trim comments; note the policy in sdk/ARCHITECTURE.md.

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

* docs: note the imported-session compaction policy in sdk/ARCHITECTURE.md

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

* address review: retry imported-history summary after abort, stand down on a projected sidecar, clear the activity label on hydrate

- An aborted summarizer call no longer consumes the single attempt, so the
  next turn retries instead of replaying the raw foreign transcript.
- The policy now applies to every imported resume and skips only when the
  working context already opens with a compaction summary. A stale sidecar
  that fails projection therefore gets re-summarized rather than bypassed,
  and the host no longer gates on the sidecar's mere existence.
- hydrateSession resets activityLabel like the rest of its per-turn state.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 15:21:57 -07:00
Saoud Rizwan ffd65af086 fix(core): keep root sessions in history when child rows crowd the scan window (#13887)
* fix(core): keep root sessions in history when child rows crowd the scan window

listSessionHistory over-fetched a fixed 2x window from the backend and then
dropped subagent/team-task child rows. Children always sort after the root
that spawned them, so one session with more children than the window hid
itself and every older root, and the desktop sidebar (limit 50) rendered an
empty history with no way to load more. Widen the scan until the requested
page of roots fills or the backend runs out of rows.

* fix(core): filter root sessions in persistence for history listing

Review follow-up: widening the client-side scan still hit the 2000-row cap,
so 2000+ child rows ahead of a root left history empty. Add a rootOnly option
to the persistence adapters (SQL WHERE on is_subagent / parent_session_id),
carry it through UnifiedSessionPersistenceService, RuntimeHost, both host
implementations, and the hub session.list payload, and have history listing
request it. The client-side filter and widening stay as a fallback for older
hubs that ignore the flag.

* test(core): pin rootOnly forwarding in LocalRuntimeHost and document root-only history listing
2026-09-08 14:32:00 -07:00
Saoud RizwanandSaoud Rizwan 119fa4ea03 feat(core): mark imported sessions with an import history origin and stamp it on telemetry (#13886)
* feat(core): stamp imported sessions with an import history origin

Imported sessions now carry sessionHistoryOrigin { mode: "import", trigger: <tool> }
alongside the existing importedFrom marker, so the messages file origin block and
downstream telemetry can separate transcripts that did not originate in Cline. The
top-level source stays the client surface (desktop), matching how scheduled runs
record automation/hub-schedule.

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

* fix(core): keep the stored history origin when resuming a session

The start input always carries a default user origin, which the resume path merged
over the manifest's metadata and then persisted on the first git metadata refresh,
so automation and import provenance was lost as soon as a session was continued.

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

* feat(desktop): show the source agent icon on imported sessions

Sidebar rows and the Sessions list render a Claude / OpenAI / opencode mark (Simple
Icons, CC0) next to sessions imported from that agent, and the hover card lists the
import source.

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

* Revert "feat(desktop): show the source agent icon on imported sessions"

This reverts commit c9dd674487.

* refactor(core): inline the import history origin mode

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

* fix(core): drop the stored trigger when a resume overrides the mode

An explicit start-input mode now replaces the stored history origin as a
whole instead of pairing the new mode with the previous trigger.

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

* feat(core): stamp session_origin on every telemetry event a session emits

The runtime host resolves the session's history origin before bootstrap and
the bootstrap scopes the session telemetry with session_origin (mode) and
session_origin_trigger, using the same non-owning scope the Hub already
applies for client identity. Errors from imported transcripts can now be
filtered with session_origin = import.

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

* test(core): use CORE_TELEMETRY_EVENTS constants in session origin tests

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 14:31:22 -07:00
Saoud RizwanandSaoud Rizwan 40a7e6526d fix(core): do not build a file index for the home directory or filesystem root (#13960)
* fix(core): do not build a file index for the home directory or filesystem root

Running cline from $HOME and typing an @ mention could take the TUI to
many GB of RSS and get it OOM-killed: the file index listed every file
under the home directory and the mention picker re-ranks the whole index
on each keystroke. Skip indexing entirely when the workspace root is the
home directory or filesystem root.

Refs #13930, #13905

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

* fix(core): canonicalize paths in the home/root index guard

Compare realpaths so a symlinked or differently-cased (Windows) spelling
of the home directory still hits the guard. Add a filesystem-root test.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 13:55:43 -07:00
Bee 3d5070705c feat(desktop): Enable web search by default outside YOLO mode CLINE-3214 (#13957)
* feat(core): enable web search by default outside yolo

* fix(core): fail closed when tool settings cannot load
2026-09-08 22:11:14 +02:00
Mikołaj Kondratek 5a3b870d85 fix(core): stop checkpoints from re-hashing unchanged untracked files every turn (#13199)
* fix(core): stop checkpoints from re-hashing unchanged untracked files every turn

Checkpoint creation rebuilt a throwaway GIT_INDEX_FILE on every user
turn, so git re-read and re-hashed every untracked file before each
model call — with multi-GB untracked data files this blocks every
message for seconds to minutes (~90s in the report from #13131, on a
cloud-synced Windows workspace).

Keep one snapshot index per session in a scratch dir instead: git's
stat cache then skips unchanged files, and from the second turn the
snapshot cost is roughly git process overhead. Entries that fall out
of the untracked set (file deleted or became tracked) are removed each
turn so they cannot ghost into snapshot trees; a corrupt index heals
with one rebuild-and-retry; deleteCheckpointRefs removes the scratch
dir with the refs.

Also adds two telemetry events so checkpoint cost is observable in the
field: checkpoint.snapshot (outcome + duration per snapshot attempt)
and checkpoint.restore (outcome + duration per restore). Durations and
outcomes only — never file paths.

Snapshot contents are byte-identical to before — no size caps, no
timeouts, no restore-behavior changes. Those are tracked separately
pending the team decision on checkpoint semantics.

Part of #13131

* fix(core): harden the persistent checkpoint scratch index

Self-review findings on the persistent-index change, applied together
because they share one root cause: a throwaway mkdtemp directory became
a durable, addressable one, which changed the failure model.

- Relocate the scratch dir from the world-shared OS tmpdir to
  <cline-data-dir>/checkpoint-scratch/<sha256(cwd+sessionId)>, created
  0700. The index and pathspec files enumerate workspace paths, so they
  no longer live world-readable at a guessable path; hashing removes
  sanitization collisions between distinct session ids and keys the
  cache to the workspace it was built from.
- Clear index.lock alongside index in the rebuild path: a git process
  killed mid-add leaves the lock behind, and without this every later
  turn of the session failed the add and degraded to HEAD-only
  checkpoints permanently.
- Do not rebuild on pathspec-match failures (a listed file vanishing
  before the add): that is a per-turn race, and rebuilding threw away
  the whole cross-turn stat cache for it.
- Normalize trailing slashes in the stale-entry sweep: ls-files reports
  an untracked nested repo as "sub/" while the index records the
  gitlink as "sub", so the sweep purged the gitlink the same turn it
  was added and snapshots silently lost nested repos.
- Replace the argv-batched update-index loop with one "-z --stdin"
  invocation (the no-pathspec-from-file workaround was built on a false
  premise).
- Reap scratch dirs idle for 14 days when hooks are created; explicit
  session deletion still removes them immediately. Previously only
  deleted sessions ever cleaned up, leaking one index per session.
- Emit checkpoint.restore from the hub restore handler too (the path
  most hosts use), and capture restore failures that happen during
  validation and planning — previously the most common failures
  produced no event and durations excluded the message read.

Regression tests: stale-lock recovery, nested-repo gitlink retention
across turns, and a failed-validation restore event.

* fix(core): pin scratch-index git config so change detection survives core.ignorestat

A persistent GIT_INDEX_FILE inherits whatever the repo's config makes git
write into it. With core.ignorestat=true, git marks every entry it adds
as assume-unchanged and stops stat-checking it, so a file changed after
the first turn kept its stale content in every later snapshot. The
original per-turn throwaway index never carried that bit across turns.

Pin core.ignorestat=false (and core.splitIndex=false, which would scatter
shared-index files for our private index into the user's .git) on every
command that touches the scratch index. Regression test reproduces the
before/after! case from review.
2026-09-08 20:41:46 +02:00
Mikołaj Kondratek 43bb575b6d fix(llms, cli): mark claude-code as local-auth and describe local CLIs in the provider spec (#13407)
* fix(llms): mark claude-code as local-auth so keyless entries are usable

The Claude Code provider authenticates from the local `claude` CLI's own
credential store (Pro/Max subscription login) and never reads an API key:
createClaudeCodeProviderModule builds its settings purely from
config.options, so any stored key is inert.

Without the local-auth capability, getProviderConfigFields reported
authMethod "api-key" with a single apiKey field. The CLI's onboarding gate
(isProviderConfigured -> isProviderSettingsUsable) therefore refused a
keyless claude-code entry and dropped straight to the sign-in wizard, and
configure dialogs asked for a key that does nothing. The workaround was to
store a dummy key.

Add local-auth to the spec, and derive the CLI's local-auth UX from the
capability instead of a hardcoded `openai-codex-cli` id check. The codex
descriptor generalizes into a small registry naming each provider's CLI,
probed executable, and install URL, so the readiness screen (previously
Codex-only copy) now serves both providers. Tests pin the registry against
the set of providers the SDK reports as local auth, in both directions.

Stored keys still short-circuit the readiness check, so anyone who saved a
placeholder key keeps working.

* fix(cli): ignore stale local-CLI readiness probes

The local-CLI setup screen shares one status slot across providers. Probing
spawns the CLI with a 3s timeout, so switching providers while a probe is in
flight let the previous provider's result land on the new provider's screen —
marking it ready off another CLI's success, or blocking it off another CLI's
failure.

Tag each probe and let only the newest one write the status, the checking
flag, and the error reason.

Unreachable before: codex was the only local-CLI provider, so there was no
second provider to switch to.

* refactor(cli): keep local-CLI provider ids module-private

Both id constants are only referenced by the registry literal in the same
file, so exporting them added public API with no callers.

* refactor(llms, cli): describe local CLI providers in the provider spec

The CLI kept its own table of which local CLIs back which providers, holding
the executable, install URL and display name. That duplicated facts the
provider spec already owns (name) and made a second place to update when a
provider changes.

Move the descriptor to the spec: `executable` is the CLI analogue of
`defaults.baseUrl` (the vendor-defined command that reaches the provider,
not a resolved path), and the install link reuses the existing `docsUrl`.
Both surface on the provider info the CLI already reads.

The CLI now derives everything from the capability plus the spec, so it holds
no provider list of its own and a new local-auth provider needs no change on
that side. Drops the id constants, the hardcoded registry, and the drift-guard
test that only existed to keep the two copies in sync.

* refactor(llms, core, cli): resolve local CLI facts from the provider catalog

Ports the shape from Bee's branch. The command a local-auth provider borrows
credentials from is declared as `metadata.localCliCommand` beside the existing
`docsUrl`, and `resolveProviderLocalCli` reads it, so hosts get both without a
new top-level spec field.

Splits two things this previously conflated. `isLocalAuthProvider` routes on
the capability alone, while the CLI descriptor is optional: a local-auth
provider whose credentials come from somewhere unprobeable now reaches the
local setup screen and can connect, instead of falling through to an API-key
form with no fields.

* fix(cli): declare localCli in the save callback deps

Relaxing the save gate to allow local-auth providers that name no CLI made
saveLocalCliConfig read localCli without listing it, so the callback could
decide against a stale value after switching providers.

* fix(cli): route local-auth setup on the capability and stop gating on the PATH probe

Two integration gaps between the capability and the screens it drives.

Onboarding and provider switching branched on whether a CLI descriptor was
found, so a provider that declares local-auth without naming a CLI fell
through to the API-key form, which renders no fields for it. Both call sites
now take the same resolveProviderSetupRoute decision, which reads the
capability; the descriptor is used only to decide whether there is anything
to probe.

The readiness probe only looks on PATH, while the runtime also accepts an
explicit pathToClaudeCodeExecutable and a bundled platform binary, and Codex
falls back through npx. A PATH miss therefore means 'not on PATH', not
'unusable', so the screens report it without blocking and a provider that
really cannot start says so on the first turn.
2026-09-08 20:40:57 +02:00
Saoud RizwanandSaoud Rizwan 50f5664683 docs(readme): replace Kanban with the desktop app (#13953)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-08 10:27:05 -07:00
Bee 8a520150f8 feat(desktop): attribute lifecycle telemetry across the shared Hub (#13820)
* feat(desktop): add scoped lifecycle telemetry

* fix(telemetry): clear account identity on signout
2026-09-08 09:43:23 -07:00
TheRealSpencer 7e8063857b security: collapse nested undici@5.29.0 onto 7.x (CVE-2026-1525) — residual of #13223 (#13809)
* security: collapse nested undici@5.29.0 onto 7.x via root override (CVE-2026-1525)

Adds the root override "undici": ">=7.29.0 <8" so the last undici@5.29.0 copy (dify-ai-provider -> @ai-sdk/provider-utils@3.0.33) resolves to the undici@7.29.0 already in the lock. @fastify/busboy, pulled in only by undici 5, leaves the lock. No new package version enters bun.lock.

The version-scoped key from #13223 ("undici@<6.0.0") is a silent no-op on bun 1.3.13; only a plain key resolves. Side effect: discord.js/@discordjs/rest move undici 6.28.0 -> 7.29.0 (smoke-tested).

Residual of #13223; complements #13675.

Produced by the VMP Automation, 2026-09-03 run

* chore: drop inert scoped undici override superseded by 7.x pin
2026-09-08 18:39:05 +02:00
Mikołaj Kondratek 8ff5f22cf9 fix(webview): flag attached images the selected model can't use and offer a model switch (#13943)
* fix(webview): warn when an image is pasted or dropped for a text-only model

Pasting or dropping an image into the chat box did not check whether the
selected model accepts image input. The image was attached and shown as a
thumbnail, then silently replaced by a text placeholder before the API call,
so the user never learned it was ignored.

The chat box now refuses the image and shows an inline hint, using the same
overlay pattern as the existing dimension and unsupported-file errors.
Unknown capability data fails open, matching core.

* fix(model-catalog): let declared input modalities decide supportsImages

The provider layer prefers a model's declared input modalities over its
capability list when deciding whether image parts are sent. The adapter that
produces the legacy supportsImages flag only looked at capabilities, so a
model declaring text-only input without a capabilities array was reported as
image-capable to the UI while the request formatter still stripped images.

* fix(webview): keep unsupported images attached, badge them and offer a model switch

Refusing the paste/drop was the wrong shape: it dropped user content and
could not cover images attached before switching to a text-only model.

Images are now attached regardless. While the selected model has no image
input, each image thumbnail carries a warning badge and a notice under the
composer says the images will be ignored and links to the model picker. Both
are derived from the current model, so they appear and disappear as the model
changes.

* fix(webview): make the model-picker link in the images notice a native button

An anchor with role=button and no href is focusable but ignores Enter and
Space, so keyboard users could not open the model picker from the notice.

* fix(webview): keep the link colour on the model-picker button in the images notice

A native button inherits the notice's warning colour, which made the link
blend into the sentence; use the VS Code text-link colours instead.

* refactor(webview): rename imagesUnsupported to unsupportedImagesAttached

The flag also requires images to be attached, so the old name read like a
plain negation of modelSupportsImages.
2026-09-08 21:56:14 +09:00
Mikołaj Kondratek f5af82140b fix(core): sanitize credential fields when saving provider settings (#13716)
Strip Unicode control/format characters and surrounding whitespace from
credential-bearing fields (apiKey, auth tokens, AWS credentials, GCP
fields, SAP client credentials, header values) in saveProviderSettings,
so a pasted key carrying an invisible BOM/zero-width character no longer
persists corrupted and 401s indistinguishably from a wrong key. A value
that is only whitespace and invisible characters clears the field.
2026-09-08 14:07:57 +02:00
Mikołaj Kondratek fc28a5fe33 feat(hostbridge): send the spawn token on host bridge calls (#13734)
* feat(hostbridge): send the spawn token on host bridge calls

The Host Bridge listens on loopback with insecure credentials, so the
host can pin where it listens but cannot prove who is calling: any local
process or OS user can dial the port and drive the IDE. Hosts cannot
authenticate bridge calls until the core identifies itself.

Attach the token the host already issues for this spawn
(CLINE_CORE_CONNECTION_TOKEN) as the cline-hostbridge-token header on
every outgoing bridge call, reusing that credential rather than adding a
second secret with its own lifetime.

Covers every path a core reaches the bridge through: the generated
service clients (via the host-bridge client factory the generator now
emits), the startup health check, and the core connection stream. A core
spawned without a token sends no header, so hosts that do not check it
are unaffected.

This is the core half; hosts can only warn on a missing or mismatched
token until it ships, and enforce once every core in the wild sends it.

* test(hostbridge): cover the generated clients end to end

The middleware unit tests call the auth middleware directly, so nothing
verified the wiring that actually carries the token in production: the
generator emitting createHostBridgeClient into each generated client,
and that factory putting the header on the wire. A broken generator
template would have shipped unauthenticated calls with a green suite.

Stand up a real nice-grpc server and assert, through a generated client,
that unary and streaming calls both arrive with the token and that no
header is sent when the core was spawned without one. Verified to fail
when the generated clients are reverted to a plain createClient.

* chore(hostbridge): drop a dead eslint directive from the auth test

cline lints with biome and has no eslint config, so the
eslint-disable-next-line implied tooling that does not run here.

* fix(hostbridge): keep the spawn token after bootstrap scrubs the environment

Startup captures CLINE_CORE_CONNECTION_TOKEN and deletes it from
process.env before the health check, host initialization or the core
connection run, so descendants never inherit it and it is absent when
the environment is logged. The metadata helpers read the variable at
call time, so on a normally spawned core they saw undefined and no
header was ever sent on the one path this feature exists for. Only the
in-band hello, which uses the captured value, carried the token.

Move capture and scrub into one function in the auth module that also
retains the token in process memory, and have bootstrap call it in the
same first position. Scrubbing is preserved, and capture and share can
no longer drift apart. The helpers now read the retained copy.

Route both test files through that same bootstrap function so tokens
enter the way they do in production, and assert the environment is
already scrubbed before the call the header is observed on. The
generated-client end-to-end test therefore covers startup to receiver,
which the previous env-setting tests could not.
2026-09-08 10:18:41 +02:00
Dominic CooneyandCline Agent 5746f74ee7 fix(core): complete run_commands when background children hold the stdio pipes (#13817)
* fix(core): complete run_commands when background children hold the stdio pipes

The shell executor settled commands on the child process close event,
which fires only after the stdio streams drain. A command that
backgrounds a child (cmd &, nohup, and the same from Git Bash on
Windows) leaves the inherited pipe write-ends held open, so after the
shell itself exits close never arrives: the command hangs until the
timeout kills the whole tree, even though it finished - the same result
an interactive terminal gives when the prompt returns while a
background job keeps printing.

When the process has exited and the streams stay open past a one-second
grace period, settle with the exit code and the output collected so far,
append a note that background processes are still running and their
output is no longer captured, and unref the stream handles so the host
process is not kept alive by the orphaned pipes. Normal commands are
unchanged: close follows exit within milliseconds and the grace never
fires. Kill, abort, and timeout paths still win their races.

A detached command gets the same treatment for its log: a detached
shell that exits while a descendant holds the pipes would otherwise
never receive its exit record or completion marker, leaving the log in
the active state for the startup reaper to retire as stale.

Fixes #12417

* test(core): run inherited-stdio regressions wherever Bash exists

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 16:54:18 +09:00
d56cd10a6c fix(core): resolve session import paths correctly on Windows (#13827)
* Fix Windows session import paths

* fix(core): preserve nonblank session import paths

Use trim only to detect blank environment overrides. Keep meaningful
whitespace and the supported Windows HOMEDRIVE/HOMEPATH contract.

Cover whitespace through the real adapters, blank fallback, constructor
precedence, and Windows drive-root and runtime-home fallback paths.

---------

Co-authored-by: Cline Bot <noreply@cline.bot>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 16:54:01 +09:00
Dominic CooneyandCline Agent 6abb7cee76 fix(desktop): keep desktop backend startup off the command path (#13829)
* fix(desktop): keep startup off the UI thread

* test(desktop): make queued-startup shutdown test exercise the recheck

The test spawns the startup thread and immediately signals shutdown. If
the thread has not yet passed ensure's first shutdown check it returns
there, never queues on the process lock, and the recheck under the lock
is not exercised. With the recheck deleted the test failed only 84 of
200 runs; a 50 ms settle before signalling shutdown makes it fail
200/200 while leaving the correct code at 0/200.

* test(desktop): make the queued-startup shutdown test deterministic

Split the check-and-spawn that runs under the process lock out of
ensure_desktop_backend_started_with into
ensure_desktop_backend_started_locked, which takes the MutexGuard. The
test now plays out the exact interleaving on one thread: pass the
unlocked shutdown check, mark shutdown, take the lock, call the locked
step. No sleep, no second thread, no scheduler dependence. With the
recheck under the lock removed the test fails on every run.

Restore the comment in get_desktop_backend_endpoint explaining why a
child that dies mid-poll produces an error instead of a respawn.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 09:45:52 +09:00
Dominic CooneyandCline Agent afa1b01be1 fix(vscode): report silent shell-integration successes as empty output (#13816)
* fix(vscode): report silent shell-integration successes as empty output

When a terminal command completed with exit 0 and no output, the
VscodeTerminalProcess fallback treated the empty capture as a shell
integration failure: it read a clipboard snapshot of the terminal and
reported the command as failed, even though the OSC 633 CommandExecuted
marker proved the read() stream was working. Silent commands such as
$null or git add -A on a clean tree then failed intermittently and
returned dirty snapshots.

Gate the snapshot fallback on the CommandExecuted marker: it is parsed
from the same stream as the output, so when it was seen an empty output
is a genuine silent success. Completion without any markers keeps the
existing fallback.

Also serve mocha imports from the runner interface in test-setup: a
test file that resolves a second, un-setup mocha instance crashes at
import with "Cannot read properties of undefined (reading describe)".

Fixes #13272

* test(vscode): diagnose unsupported Mocha shim exports

Keep the six runner-owned BDD functions and derive unsupported exports

from the installed package without trapping module interop probes.

Exercise compiled imports through the real extension-host setup and

clarify terminal stream/end-event ordering without changing behavior.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-09-08 09:45:03 +09:00
Dominic CooneyandCline Bot c21b17255b fix(desktop): update Windows taskbar app icon (#13823)
Co-authored-by: Cline Bot <cline-bot@users.noreply.github.com>
2026-09-07 10:36:42 +09:00
Saoud Rizwan dac3b35ba4 ui: keep section headers visible while searching in SearchCombobox (#13854)
ClinePass lists the same model in both the Subscribed and Free tiers
(e.g. cline-pass/deepseek-v4-flash and deepseek/deepseek-v4-flash), so
flattening the sections during search produced two identical-looking rows.
Section headers now stay rendered for whichever sections still have
matches.
2026-09-04 16:40:39 -07:00
Saoud Rizwan adbfbd97d3 fix(core): make apply_patch Add File refuse to overwrite existing files (#13835)
loadFiles only pre-loaded UPDATE/DELETE targets, so the parser's
"File already exists" guard never saw ADD targets and fs.writeFile
silently replaced existing files. Load existing ADD targets too so the
guard fires.

Fixes #13833
2026-09-04 16:19:50 -07:00
Saoud Rizwan 952df213ee fix(cli): make TUI toasts wrap instead of clipping after the first line (#13818)
The toast box only set maxWidth, so a message longer than the 44-column
cap was clipped to its first line rather than wrapped. Every toast in the
Hub update flow is longer than that: the keep-Hub reminder after Esc
rendered as 'The running Cline Hub stays on the' and never reached the
'cline hub upgrade' instruction it exists to deliver. Give the box an
explicit width (message length plus border and padding, capped as
before) so the text has a real edge to wrap at.
2026-09-03 16:27:09 -07:00
Saoud Rizwan d7cb79aea5 chore(desktop): release v0.0.23 2026-09-03 11:06:02 -07:00
5de79a75d0 docs: add .clineignore hook example (#13649)
* docs: add enforced .clineignore guard plugin example

Adds clineignore-read-files-guard.ts, a beforeTool hook plugin that blocks
read_files, editor, apply_patch, and run_commands calls targeting paths
matching gitignore-style patterns in a workspace .clineignore file, and
protects .clineignore itself from modification. Features it on the
.clineignore docs page as the enforced replacement for the deprecated
built-in feature.

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

* docs: use a PreToolUse file hook for the enforced .clineignore example

Replaces the plugin-based guard with a PreToolUse hook script that works in
the VS Code extension today (.clinerules/hooks/PreToolUse plus the Enable
Hooks setting) as well as the CLI (.cline/hooks/PreToolUse.sh). The script
handles both hook payload shapes, blocks read_files/editor/apply_patch/
run_commands calls matching .clineignore patterns, and protects .clineignore
itself from modification.

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

* fix: canonicalize paths in .clineignore guard hook

Lexically collapse '.', '..', and empty segments before the ignore match
and the .clineignore self-protection check, closing bypasses via
noncanonical paths like ./.clineignore, secrets/../.env, or
/root/./file (Greptile review finding on #13649).

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

* docs: note symlink limitation in .clineignore guard hook docs

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-02 19:48:49 -07:00
Saoud RizwanandSaoud Rizwan ab3acd6a8a fix(vscode): render pending Supports Images override so stale checkbox re-syncs stop reverting it (#13694) (#13792)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:33:11 -07:00
Saoud RizwanandSaoud Rizwan c322ab2bbc Show device sign-in confirmation code in desktop app while waiting for browser (#13791)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:27:20 -07:00
Saoud RizwanandSaoud Rizwan 72b714b6be fix(desktop): open voice settings for speech input provider errors (#13726)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:24:09 -07:00
Saoud RizwanandSaoud Rizwan f0de3a2c20 fix(desktop): keep the scheduled-task report visible when a finished run collapses (#13793)
* fix(desktop): keep the scheduled-task report visible when a finished run collapses

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

* test(desktop): cover collapse edge cases around submit_and_exit

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 19:19:44 -07:00
Bee 4ae53292d3 fix(hub): never prompt about a hub running the same core version (#13785)
Two artifacts of the same release cut from different commits never share
a build fingerprint or epoch: desktop-v0.0.22 and cli-v3.0.61 both bundle
core 0.0.82, yet every desktop user with the CLI installed gets the
'Cline Hub was updated' dialog on every launch and webview reconnect, and
'Update and restart' loops on 'no app update available' because nothing
newer exists to install.

checkManagedHubBuildMismatch now returns nothing when the hub's
coreVersion equals this client's own, in both directions (build_mismatch
and outdated_hub). The fingerprint keeps its role in the reuse/retire
total order, where antisymmetry matters; it no longer drives prompts on
its own. Genuinely different releases still prompt.
2026-09-02 17:54:00 -07:00
Bee 3d0531238e fix(desktop): show the newer-hub dialog only when an app update is staged, and persist Later (#13787)
* fix(desktop): show the newer-hub dialog only when an app update is staged, and persist Later

Hardens the 'Cline Hub was updated' prompt against release skew:

- The build_mismatch modal renders only when the auto-updater reports a
  staged update ('ready'), so it can never loop on 'no app update is
  available yet'. A mismatch kicks one immediate updater check (deduped
  per hub build per page lifetime) so the prompt opens actionable as soon
  as a release exists, and the shared polled status opens it reactively
  when the background cycle stages one later. unsupported_protocol and
  outdated_hub keep their unconditional dialogs.
- 'Later' now persists in localStorage per reason:hubBuildId. The sidecar
  replays a pending mismatch on every webview connection (session
  switches, reloads, relaunches), and the previous in-memory dismissal
  resurrected the modal on each one. A different hub build still prompts.

* fix(desktop): never persist Later for an unsupported-protocol hub

Review follow-up: the persisted dismissal also stuck for
unsupported_protocol, silencing a warning about a Hub the app genuinely
cannot talk to across every reconnect and relaunch. Dismissal for that
reason is session-local again (the pre-existing behavior); only the
advisory build_mismatch key persists, enforced on both write and read so
a key stored by any other path is ignored too.

* fix(desktop): reopen a dismissed protocol warning when the mismatch is redelivered

Review follow-up: an in-place transport reconnect replays the pending
mismatch to a still-mounted dialog whose in-memory dismissedKey is
unchanged, so a dismissed unsupported_protocol warning stayed closed
while the app could not talk to the Hub.

Every delivered mismatch now passes the dismissal through
retainDismissalForIncomingMismatch: a matching non-persistable dismissal
(unsupported_protocol) is cleared so the warning reopens on the replay;
the advisory build_mismatch dismissal and dismissals for unrelated keys
stand.
2026-09-02 17:31:23 -07:00
b318c84f52 docs: add deprecation notices page (#13458)
* docs: add deprecation notices page

* docs: add primary surface to deprecations

* chore: drop unrelated formatting changes from docs PR

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-02 13:12:38 -07:00
Bee 9e0af5c010 feat(desktop): manage Agent Plugins through the Hub (#13658)
* feat(desktop): manage Agent Plugins through the Hub

* fix(desktop): show Agent Plugin inventory
2026-09-02 12:37:36 -07:00
Bee 97b0700151 feat(cli): manage Agent Plugins through the Hub (#13657) 2026-09-02 12:37:24 -07:00
Bee b46cf77ed1 feat(core): Hub-managed Agent Plugins support (#13652)
* feat(sdk): add hub-managed Agent Plugins

* fix(sdk): restrict Agent Plugin auto-discovery

* test(sdk): canonicalize Windows plugin paths

* fix(sdk): await stdio MCP process shutdown

* fix(sdk): select Agent Plugin MCP clients by source

* fix(core): defer Agent Plugin data directory creation

* docs(sdk): clarify Agent Plugin discovery scope

* fix(sdk): reject individual Agent Plugin skill toggles

settings.toggle({type: "skills"}) unconditionally called
toggleSkillFrontmatter() for any resolved skill record, including ones
sourced from an Agent Plugin. That writes a `disabled` key into the
skill's SKILL.md frontmatter, but the strict Agent Skills parser used
for these skills only permits a closed field set (name, description,
license, compatibility, metadata, allowed-tools). The very next reload
then rejects the file as invalid and the skill silently disappears
until someone hand-edits the installed plugin's SKILL.md.

Guard the toggle: an agent-plugin-sourced skill record now throws a
clear error pointing at the plugin-level toggle instead, matching how
whole-plugin enable/disable already works (setDisabledAgentPlugin,
keyed by manifest name, no file mutation).

* fix(sdk): keep disposing MCP servers when one disconnect fails

InMemoryMcpManager.dispose() unregistered servers sequentially and let
the first disconnect() rejection abort the loop. Since disconnect() can
now reject when a stdio child never exits, one wedged server would leak
every remaining server's process. Catch per-server errors, disconnect
the rest, and rethrow as an AggregateError so upstream cleanup-error
reporting still sees the failure.

Also log agent plugin discovery failures in CoreSettingsService.list
instead of swallowing them silently, so a plugin missing from settings
is diagnosable.
2026-09-02 10:11:01 -07:00
Mikołaj Kondratek c85384431d fix(standalone): decode core-connection protobus requests from proto3 JSON (#13758)
The core connection delivers protobus requests as the proto3 JSON the
webview's ts-proto toJSON encoders produce: enums arrive as string names
and default-valued fields — empty repeated fields included — are omitted.
The handlers assume ts-proto message shapes (numeric enums, repeated
fields always present), so dispatching the parsed JSON directly broke
every RPC relying on those invariants on JetBrains: changing the API
provider threw 'Cannot read properties of undefined (reading length)'
in fromProtobufModelInfo, and the plan/act toggle rejected its own mode
as invalid. The old standalone gRPC server restored these defaults
during protobuf decoding; the tunnel skipped that step.

Generate a per-method request-decoder map (request type fromJSON)
alongside the service handlers and apply it in the core-connection
dispatcher before dispatch. The in-process VS Code webview path is
untouched: it posts structured-cloned ts-proto objects that never pass
through JSON.
2026-09-02 15:59:42 +02:00
Saoud Rizwan be59305d7a chore(desktop): release v0.0.22 2026-09-01 22:04:39 -07:00
Saoud Rizwan 833be95cfb chore(vscode): release v4.1.17 (#13755) 2026-09-01 21:59:50 -07:00
Saoud Rizwan 595f1dbf2e fix(core): close imported-session stores before the temp dirs are removed
The session-import tests opened a SqliteSessionStore per case and never
closed it, so afterEach's rmSync ran against a directory still holding an
open SQLite file. POSIX allows that; Windows does not, and all seven
persisting cases failed the sdk-publish Windows job with EPERM on the
cline-db-* temp dir.

Route every store through a sessionStore() helper that registers it for
close, and close them before removing the dirs.
2026-09-01 21:19:37 -07:00
Saoud Rizwan 3501d4b0e2 chore(cli): release v3.0.61 2026-09-01 21:18:16 -07:00
Saoud Rizwan 1caf264754 chore(sdk): release v0.0.82 2026-09-01 21:05:27 -07:00
BeeandSaoud Rizwan 4d28d82efa feat(cli): handle outdated hub sessions with drain and replace flow (#13727)
* feat(cli): handle outdated hub sessions with drain and replace flow

Add logic to detect when the CLI is newer than the running Hub and provide
users with options to either keep the older Hub running (to avoid
interrupting active sessions from other clients) or force-replace it.

Implement `describeOutdatedHubSessions` helper to show quantified session
activity in the dialog, and add `HubOutdatedContent` UI component with
detailed messaging for the `build_mismatch` case. The `unsupported_protocol`
case remains a modal requiring update, while the softer mismatch now uses
a toast with enter-to-replace or escape-to-keep choices.

Includes tests for draining and replacing an older busy hub when forced.

* fix(hub): gate desktop hub_upgrade behind trusted connection and make drain-first a hard guarantee

Address review: an originless local WebSocket client could invoke the
forceful hub_upgrade command, and a failed drain request still allowed a
forced retirement, so work started during the wait window could be killed.

- hub_upgrade now requires the same canApproveTools per-connection gate as
  the tool-approval commands.
- upgradeManagedHub skips the idle-wait window when the drain was not
  established (an undrained hub keeps admitting work, so waiting only
  widens the blast radius) and refuses to replace a busy hub that did not
  accept the drain, force or not. An idle hub is still replaced so
  pre-drain-endpoint hubs (404) remain upgradable.

* fix(hub): treat failed activity readings as unknown, not idle, during hub upgrade

A transient session.list failure inside the drain wait window previously
read as an idle hub, which could end the grace window early and authorize
retirement while turns were still finishing.

- Failed readings never end the wait window early, never overwrite the
  last real observation, and never authorize a non-forced retirement.
- Without force, a hub whose activity was never confirmed is handed back
  un-drained (still_busy) instead of retired; an undrained hub is now
  replaced only when positively observed idle.
- With force and an accepted drain, an unanswerable hub is still replaced:
  the user already consented to interrupting its sessions.

* fix(hub): never retire an undrained hub on an idle snapshot

An older hub that rejects the drain has no admission barrier, so a single
idle reading cannot authorize retirement: a session admitted right after
the snapshot would die in a retire the consent prompt never covered.

upgradeManagedHub now retires a hub only under an accepted drain. The
undrained-idle case is delegated to the locked ensure path, which
re-checks activity immediately before its own retire ladder and attaches
(deferring the swap) when new work arrived in the meantime; the upgrade
then reports still_busy instead of replaced, and the desktop/TUI surfaces
tell the user to retry.

* fix(hub): require an accepted drain unconditionally before any upgrade retirement

Review follow-up: the undrained-idle delegation still reached
retireDiscoveredHub, whose own drain attempt is best-effort, so a session
admitted after the idle re-check could die in the shutdown.

upgradeManagedHub now fails fast when the hub does not accept the drain -
no wait window, no idle exception, no delegation. The drain is the
admission barrier that keeps every subsequent reading true through the
retire; a hub too old or wedged to accept it is left to the automatic
ensure path, which replaces it once idle at the next client startup, and
the error says so.

* fix(hub): establish the drain barrier before the automatic idle check

Review follow-up: the automatic incompatible-hub path read session
activity first and drained only inside the retire ladder, so a session
admitted between the idle snapshot and the shutdown could be terminated.

retireIncompatibleHub now requests the drain before the busy check: with
the drain accepted, the idle reading stays true through the retire. A
deferred (busy) hub, and one whose retirement fails or is skipped by the
circuit breaker, gets the drain lifted so it never sits alive-but-refusing
work. Hubs that do not accept the drain (pre-/drain builds answer 404)
keep the historical best-effort snapshot rather than being stranded
forever.

* polish(hub): tighten the outdated-hub dialog copy

Two short sentences instead of four long ones, spell out what Quit Cline
does (closes the app, leaves the Hub running), and rename the action to
Update Now in both the desktop dialog and the TUI variant.

* fix(cli): show the keep-Hub reminder toast when the outdated-hub dialog is dismissed (#13754)

dialog.choice() resolves undefined on Esc rather than rejecting, so the
reminder toast in .catch() never ran. Move it to the falsy branch of
.then(), matching the unsupported_protocol handler.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-09-01 20:40:40 -07:00
Saoud RizwanandSaoud Rizwan b427fae9f9 feat(desktop): group scheduled runs under their schedule in the sidebar (#13752)
* feat(core): stamp schedule id, name, and run number onto scheduled sessions

Sessions started by the cron runner only carried a generic
sessionHistoryOrigin.trigger = "hub-schedule", so clients could tell a
session was scheduled but not which schedule it belonged to or which run
it was. The runner now passes schedule provenance to the runtime handlers,
which merge it into the session metadata alongside the origin trigger:

  scheduleId          the hub schedule's external id
  scheduleName        the schedule title
  scheduleExecutionId the cron run id
  scheduleRunNumber   1-based position among every run created for the spec

The run number comes from a new SqliteCronStore.getRunOrdinal, which counts
runs of every status in creation order so a later cancellation never shifts
numbers already stamped onto earlier sessions. A reclaimed run keeps its
number, so two sessions with the same number make a duplicate visible.

HubScheduleRuntimeHandlers.startSession gains an optional second argument
carrying the metadata; existing implementations that ignore it keep working.

* feat(desktop): group scheduled runs under their schedule in the sidebar

A schedule that fires daily filled the sidebar's Scheduled section with a
row per run, each titled with the same prompt text, which read as if the
task had been duplicated. Runs of one schedule now fold into a single
collapsible row named after the schedule, with the run count on the right;
expanding it lists the runs as "Run N" sub-items (newest first) with their
usual status dot, time, hover card, context menu, and delete button. The
group holding the active session expands on its own so a run opened from
the Schedules page is visible. Grouping also applies inside project groups
when sorting by project. The Scheduled header now counts schedules rather
than runs.

Threads learn the schedule identity from the metadata the runner now
stamps (scheduleId, scheduleName, scheduleRunNumber). Runs recorded before
that fall back to the schedule executions list the hook already polls,
which now yields the schedule id and name instead of a bare session id set,
and finally to grouping by shared title. Runs without a number are labelled
with their start time instead of "Run N".

* fix(desktop): reopen a collapsed schedule group when one of its runs is opened

A stored collapse used to win over the active-session default for the
sidebar's lifetime, so a run opened from the Schedules settings page
could stay hidden inside its collapsed group. Opening a session now
clears the stored choice for the group that holds it; the group can
still be collapsed afterwards.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-01 19:51:57 -07:00
Saoud Rizwan b9977a139f feat(desktop): import sessions from Claude Code, Codex, and opencode (#13744)
* feat(core): session import service for Claude Code, Codex, and opencode history

Adds a SessionImportService to @cline/core that discovers sessions in the
on-disk stores of Claude Code (~/.claude/projects JSONL), Codex
(~/.codex/sessions rollouts + session_index titles), and opencode
(opencode.db sqlite), translates each conversation into Cline's native
MessageWithMetadata format, and persists it through CoreSessionService as
a completed, listable, resumable session.

Key mechanics:
- Claude Code: parentUuid tree walk from the newest leaf picks the active
  branch (edits/retries branch the log); same-message.id assistant lines
  merge back into one turn; sidechains, meta lines, and slash-command
  wrappers are excluded; ai-title/summary lines provide titles.
- Codex: real prompts come from user_message event_msg lines (user-role
  response_items are injected AGENTS/environment context, with a fallback
  for old rollouts); function_call/output pairs map to tool_use/tool_result;
  resumed rollouts that re-embed the original session id dedupe to the
  richest file; token_count events stamp per-turn metrics.
- opencode: reads a temp snapshot of the WAL-mode db; inline tool parts
  split into tool_use + tool_result to preserve provider-valid structure;
  child (subagent) sessions and synthetic parts are skipped.
- Shared sanitizer guarantees replayability: orphaned tool_use gets a
  placeholder result, orphaned tool_results and empty text blocks drop,
  provider-session-scoped signatures/encrypted reasoning strip.
- Imported sessions pass every history-visibility gate (terminal status,
  non-empty provider/model, chat-workspace fallback cwd, no fabricated
  checkpoint metadata) and carry metadata.importedFrom for idempotent
  re-discovery (alreadyImportedSessionId).

* feat(desktop): sidecar commands for importing sessions from other tools

Adds two sidecar WebSocket commands backed by @cline/core's
SessionImportService:

- list_importable_sessions: returns { installedTools, sessions } where
  sessions are ImportableSessionSummary rows (tool, sourceId, title, cwd,
  timestamps, messageCount, preview, alreadyImportedSessionId) discovered
  in the local Claude Code / Codex / opencode stores.
- import_sessions: takes { selections: [{ tool, sourceId }] }, validates
  each selection against the known tool list, imports sequentially
  (per-session transactional), and broadcasts session_import_progress
  events ({ index, total, result }) so the UI can render live progress.
  Returns { results } with per-item ok/sessionId/title/error.

* feat(desktop): import sessions UI for Claude Code, Codex, and opencode

Adds an Import Sessions dialog to the desktop app driven by the sidecar's
list_importable_sessions / import_sessions commands:

- Scan phase discovers local history from all three tools and groups it
  per tool with select-all checkboxes, per-row title, relative time,
  message count, and workspace folder; rows already imported are disabled
  and badged (idempotent re-open).
- Text filter across title, folder, and first-prompt preview.
- Import phase streams session_import_progress events into a progress bar
  and per-item result list; the dialog cannot be dismissed mid-import via
  overlay click. Done phase summarizes successes and lists failures with
  their error messages.
- Entry points: an Import button in the Sessions view header and an
  "Import sessions" row in Settings → General.
- use-session-history subscribes to session_import_progress so history
  refreshes no matter which surface started the import.
- Wire types live in webview/lib/session-import.ts (mirrors the core
  module's types so the client bundle never imports node-only code).

* fix(desktop): import dialog crash rendering session timestamps

formatRelativeTime takes a string (parseTimestamp calls .trim() on any
truthy value), but the import dialog passed the numeric updatedAtMs,
crashing the page with 'e.trim is not a function' as soon as scanned rows
rendered. Convert to an ISO string at the call site.

Slipped through because the webview has no typechecking anywhere:
tsconfig.dev.json excludes webview/ and next.config sets
typescript.ignoreBuildErrors, and the webview's own tsconfig currently
carries 64 pre-existing errors.

* feat(desktop): offer session import during onboarding

Adds an 'import' onboarding step between connect/github and done. The
step scans for importable Claude Code / Codex / opencode history on
entry and silently advances when nothing (new) is found or the scan
fails, so only people with actual history from other tools ever see it.
When sessions are found it summarizes the count and source tools, opens
the same ImportSessionsDialog used by the Sessions page for picking, and
flips to a confirmation state once at least one session imports. Skip is
always available, including while the scan is still running.

* fix(desktop): import dialog text overflow, collapsible sections, select all

- Titles no longer clip or push the row wide: they word-wrap up to two
  lines (line-clamp-2 + break-words, with min-w-0 down the flex chain so
  long unbroken Codex prompt titles can actually shrink); the meta line
  keeps time/count fixed and truncates only the workspace name; progress
  rows get the same min-w-0 treatment.
- Each tool section header is now a collapse toggle (chevron +
  aria-expanded) so one tool with hundreds of sessions doesn't force
  scrolling past it; collapsed headers still show count and selected
  count, and filtering forces sections open so search matches can't hide
  in a collapsed group. Collapse state resets per dialog open.
- New global Select all row above the list with indeterminate state and
  an x-of-y selected counter; it operates on the currently visible
  (filtered) selectable sessions, matching the per-section checkboxes.

* fix(desktop): import dialog header and search clipped by intrinsic column width

The dialog grid used the default auto column track, so a single
unbreakable string in a session title (Codex titles often contain URLs)
set the column's min-content width wider than the fixed 620px dialog --
break-words affects layout but not intrinsic sizing -- and
overflow-hidden then clipped everything in the column, including the
description and the search field. Pin the column to minmax(0,1fr) so the
container width always wins and long words wrap at the box edge instead.

Also add sm:max-w-none (the primitive's sm:max-w-lg survives
tailwind-merge across variants and was silently capping the dialog at
512px) and shrink-0 on the search and select-all rows so a tall list can
never compress them vertically.

* fix(desktop): onboarding import step rescanned after import and looped to done screen

The import step's scan effect depended on onContinue, an inline arrow the
parent recreates every render — and importing itself re-renders the app
shell via the history refresh. Each re-render re-ran the scan, and when
the user had imported everything (select all), the re-scan found zero
remaining sessions and hit the nothing-to-import auto-advance, yanking
them past their own import confirmation onto the done screen. The scan
now runs exactly once per step entry (onContinue held in a ref for the
async auto-skip paths).

Also, after a successful import the button is now 'Start building' and
completes onboarding directly instead of routing through the separate
done screen — two consecutive confirmation screens read as a loop. The
skip and nothing-found paths still go through the done screen so those
users get the 'You're all set' confirmation.

* fix(core): consolidate imported tool_results into the message after their tool_use

The import sanitizer answered missing tool_use ids with a separate
placeholder user message while leaving real results for the same turn in
later user messages. Anthropic requires every tool_result for a turn in
the user message immediately following it, so a partially-answered turn
would still 400 on resume. Rebuild any incomplete or split span as one
consolidated results message in tool_use order (placeholders for missing
ids, duplicates dropped) followed by a message carrying whatever else the
span held, mirroring the legacy migration sanitizer.

* fix(desktop): imported sessions resume on the user's configured provider; batch adapter caches

Opening a history session adopts the row's provider/model
(use-chat-session: session.provider || prev.provider), so imported rows
stamped with the source tool's provider — openai-native for Codex,
whatever opencode reported — resumed on providers the user may never have
configured and failed on first send. The dialog now passes the app's
current model selection (lastProvider/lastModelByProvider, i.e. what a
new chat would run on) and the service stamps it on the row; both halves
must be present so a Cline provider is never paired with a foreign model
id. The source provider/model are preserved in metadata.importedFrom and
per-message modelInfo stays accurate. Codex's provider id is corrected to
Cline's openai-native, and opencode's openai/google map to
openai-native/gemini.

Adapters also gain per-batch caches released via dispose(): Codex's
convert() re-walked the sessions tree and re-read every rollout head per
imported session (O(sessions x files)); it now builds the session-id ->
richest-file index once per batch. opencode copied the whole WAL db per
imported session; it now snapshots once per batch.

* fix(core): roll back failed imports and dedupe at import time

Addresses both Greptile P1s on #13744:

- A write failing after createRootSessionWithArtifacts (messages, status,
  manifest, title) left a half-written pid-0 session in history whose
  importedFrom marker also blocked retrying the source. persistConverted
  now deletes the session on any later failure and rethrows.
- Dedup markers were read through listSessions, which caps its scan at
  2000 rows, so a prior import older than the newest 2000 sessions was
  invisible and the source could be imported again. Add
  listSessionMetadata (ids + metadata for every row, no manifest reads or
  reconciliation) and use it for markers. Also check idempotency at
  import time, not only at discovery: a request for an already-imported
  source resolves to the existing session (alreadyImported: true) instead
  of writing a copy, covering stale pickers and repeated requests.

* fix(core): create imported sessions terminal and mark them imported last

Two failure modes shared one root cause -- the import wrote its session
in stages and claimed success too early:

- The row was created running/pid-0 and flipped to completed afterwards.
  The stale-session reconciler runs in the hub daemon against the same
  SQLite DB and, in that window, marks such rows failed and stamps
  terminal_marker metadata. createRootSessionWithArtifacts now accepts
  status/endedAt/exitCode so imports are created completed with the
  source session's end time; the separate status flip and manifest
  rewrite are gone.
- The importedFrom marker was written at creation, so a session whose
  later writes failed (and whose rollback delete also failed) still
  blocked retrying its source. The marker is now the final write, so it
  means 'this import finished' and a half-written session can never
  claim the source.

listSessionMetadata is unbounded by default so dedup sees every row.

* fix(core): resolve TS2352 casts in session-import tests (#13746)

tsc rejects casting ContentBlock[] straight to Record<string, unknown>[]
(RedactedThinkingContent is not comparable), which failed the Quality
Checks typecheck. Route the five assertion-site casts through a small
blocks() helper that widens via unknown.

* fix(core): flatten Codex content-block tool outputs during import

Newer Codex rollouts write custom_tool_call_output.output as an array of
Responses-API content blocks ({type:"input_text", text}) instead of a
plain string. The importer JSON.stringified that array into the
tool_result content, and the chat UI's tool-summary parser then rendered
each non-text block as its type label, so imported exec calls showed up
as "[input_text][input_text]" with no output.

Concatenate the text of string/text-bearing blocks (they are stream
chunks, so no separator) and keep the JSON fallback for anything else.

* fix(desktop): edit-and-resend on runs without a checkpoint

Editing a message forks the session before that run, and the sidecar
always routed that through manager.restore with workspace: true. Imported
sessions carry no checkpoint history, so editing any of their prompts
failed with "No checkpoint found at or before run N" — even after the
user had continued the session in Cline, since only the new runs get
checkpoints.

When no checkpoint exists at or before the edited run there is no
workspace state to roll back, so fork the trimmed transcript onto the
current workspace (the same path a full-history fork takes) instead of
erroring. Runs that do have a checkpoint still restore the workspace.

* fix(core): roll back failed session creation and coalesce overlapping imports

Two gaps Greptile flagged on the import path:

createRootSessionWithArtifacts upserts the row before writing the messages
file and manifest, and the call sat above persistConverted's rollback try.
A file write failing there left a completed row with no transcript in
history. Creation now runs inside the rollback, and deleteSession already
tolerates a missing row or missing files.

Each import_sessions request builds its own service and snapshots the
existing-import markers once, so two overlapping requests for one source
(a second window, a double-fired command) both passed the dedupe check and
persisted two sessions. A module-level in-flight map keyed by tool:sourceId
makes the later caller wait on the first write and report its session as
already imported.

* fix(desktop): resolve the import resume target like a new chat does

An imported Claude Code session resumed on the Anthropic provider instead
of the user's Cline selection. The dialog read model-selection storage
directly and required both a remembered provider and a remembered model;
the composer only records a model from the explicit picker handlers, so
anyone running on the default model has no entry, the lookup came back
empty, and the service fell back to the source tool's provider.

Resolve the target with getInitialChatConfig() -- the same chain a new
chat uses (remembered selection, then the built-in default), which is
never empty -- and have the import_sessions handler default to the cline
provider and CLINE_DEFAULT_MODEL_ID when a caller sends nothing, matching
other server-started sessions. The source provider can no longer become
the resume target.
2026-09-01 19:39:20 -07:00
Saoud RizwanandSaoud Rizwan 6d5a9793fc Desktop marketplace: show detail panel only on item click, left-align detail content (#13747)
* Desktop marketplace: show detail panel only on click, left-align detail content

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

* Desktop marketplace: drop license cell, single Learn more link (homepage, else repo)

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

* Desktop marketplace: keep selected entry open while list is filtered

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-01 17:03:54 -07:00
2ad2a41b56 Promote ClinePass across home banner, account page, and settings (#12556)
* feat(webview): promote ClinePass across home banner, account page, and settings

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

* fix(webview): drop removed ext-cline-pass flag gating and hardcoded pricing from ClinePass promos

The ext-cline-pass feature flag no longer exists (the provider is ungated on
main), so promo surfaces are now gated only on self-hosted mode and org
remote-config provider allowlists. Promo copy describes the subscription
without a hardcoded price, matching the CLI copy cleanup in #13514.

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

* fix(webview): open the personal dashboard context from ClinePass subscription links

ClinePass always bills the personal account, but the Manage Subscription
button (and the ClinePass provider's usage link) landed org-context users
on the org dashboard. Pass personal=true like EntitlementError and the
CLI subscription links already do.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-01 16:46:32 -07:00
Bee 9b1374059e fix(desktop): enable macOS voice input (#13741) 2026-09-02 00:57:32 +02:00
8eb5f3d57f Default web search on for the desktop app (#13725)
* Default web search on for the desktop app

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

* Make desktop web search default seed best-effort

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-31 19:23:26 -07:00
Saoud RizwanandSaoud Rizwan 0852992f3b Clarify model-facing message when user rejects a tool call (#12673)
* Clarify model-facing message when user rejects a tool call

* Include the rejected tool's name in denial reasons

* Move user-rejected tool reason into @cline/shared

* Route new user-rejection approval paths through shared reason builder

Since the original PR, several new approval surfaces landed on main with
their own terse denial strings (CLI connectors, ACP permissions, Cline Hub
webview, desktop webview, example VS Code extension). Route all of them
through buildUserRejectedToolReason so the model sees a consistent,
non-error rejection message.

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

* Add buildUserRejectedToolReason to the @cline/shared integration-test stub

The VS Code integration tests run the tsc-built CJS tree and stub the
ESM-only @cline/shared package in test-setup.js; the stub was missing the
new export, so tool-approval-denial.js threw at module load in CI.

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

* Trim scope back to the minimal rejection-copy fix

Restore the connector deniedReason plumbing, ACP permission strings,
desktop webview reason, example extension reason, and hub server fallback
to their main versions. Those surfaces already attribute the denial to a
user and are outside ENG-2329. Keep the Cline Hub webview change since
that path emits its own rejection string the model sees.

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

* Move rejection guidance suffix into agent runtime per review

* Apply review suggestions: neutral fallback reason and -- separator before rejection suffix

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-31 18:39:51 -07:00
Saoud RizwanandSaoud Rizwan 4ab091b959 fix(cli): keep markdown streaming prop stable to stop settle flash (#13719)
Flipping the <markdown> streaming prop from true to false when an
assistant text segment settles makes MarkdownRenderable call
updateBlocks(true), which skips every block-reuse path and destroys and
recreates all block renderables. Until tree-sitter re-highlights them
the whole message renders blank/unhighlighted, which users see as the
text flashing at the end of each response. Keep streaming={true} for
the transcript markdown (opencode's TUI does the same); entry.streaming
still drives the spinner glyph.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-31 18:37:17 -07:00
7a6beb9f0d fix(llms): translate gateway capabilities in one place (#13584)
* fix(core): stop an empty capability list from stripping image input

`modelHasCapability` documents a missing or empty capability list as
carrying no signal, so each gate declares its own default. Two readers
bypassed it and read `capabilities` directly, where an empty list is not
nullish but `[].includes(x)` is false:

- the session runtime's `modelSupportsImages` metadata used
  `capabilities?.includes("images") ?? true`, so the intended fail-open
  never fired for an empty list and the file-read tool silently dropped
  every image from the request;
- `toProviderModel` projected an empty list onto `false`, telling pickers
  a model definitively lacks vision, attachments, and reasoning when
  nothing had been declared.

Both now route through the shared helpers, which state their unspecified
default explicitly: `modelSupportsImageInput` fails open for a capability
gate, and `declaredCapability` preserves `undefined` for `ProviderModel`'s
tri-state booleans. A populated list stays authoritative in both.

A thinking config now short-circuits `supportsReasoning` instead of being
OR-ed with the capability read, so its absence no longer collapses the
tri-state to `false`.

* fix(llms): translate gateway capabilities in one place

Three producers built gateway model definitions from catalog `ModelInfo`,
and each carried its own hand-written `switch` over the capability list.
Nothing tied them together, so they drifted:

- builtin providers always emitted a capability list, so a model whose
  catalog entry declares no capabilities became `["text"]` where the other
  producers emitted `undefined`. `modelSupportsToolCalling` fails open only
  for an absent or empty list, so that list read as an authoritative denial
  and stripped every tool definition from requests to the affected language
  models (dify, sapaicore, opencode, and the Codex CLI);
- the OpenAI-compatible path mapped an `audio` capability that
  `ModelCapabilitySchema` does not define, while the other two dropped it;
- the pass-through capabilities (`streaming`, `files`, `temperature`, ...)
  were enumerated explicitly in one, folded into `default:` in another,
  and ignored in the third.

One exported `toGatewayModelCapabilities` now serves every producer. It is
built on a `Record<ModelCapability, GatewayModelCapability | null>` rather
than a `switch`, so extending `ModelCapabilitySchema` without deciding the
new capability's mapping fails to compile instead of silently falling
through to a default.

The conformance tests walk the capability state space taken from
`ModelCapabilitySchema` itself and assert the real producers agree with the
translator, so a future producer that maps capabilities on its own fails
even when the translator's own unit tests still pass.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
Co-authored-by: Ara <arafat.da.khan@gmail.com>
2026-08-31 15:17:31 -07:00
Dominic CooneyandCline Agent f5370ad4cf fix(core): stop an empty capability list from stripping image input (#13583)
`modelHasCapability` documents a missing or empty capability list as
carrying no signal, so each gate declares its own default. Two readers
bypassed it and read `capabilities` directly, where an empty list is not
nullish but `[].includes(x)` is false:

- the session runtime's `modelSupportsImages` metadata used
  `capabilities?.includes("images") ?? true`, so the intended fail-open
  never fired for an empty list and the file-read tool silently dropped
  every image from the request;
- `toProviderModel` projected an empty list onto `false`, telling pickers
  a model definitively lacks vision, attachments, and reasoning when
  nothing had been declared.

Both now route through the shared helpers, which state their unspecified
default explicitly: `modelSupportsImageInput` fails open for a capability
gate, and `declaredCapability` preserves `undefined` for `ProviderModel`'s
tri-state booleans. A populated list stays authoritative in both.

A thinking config now short-circuits `supportsReasoning` instead of being
OR-ed with the capability read, so its absence no longer collapses the
tri-state to `false`.

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-31 14:58:38 -07:00
Saoud Rizwan a7a57509e2 chore(desktop): release v0.0.21 2026-08-31 14:23:28 -07:00
Saoud RizwanandSaoud Rizwan c4e09725f8 Fix ask-question option text not wrapping (#13718)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-31 13:40:13 -07:00
Mikołaj Kondratek 34f803fad4 fix: sanitize stored API keys and make provider credential rejections actionable (#13549)
* fix(vscode): sanitize pasted provider API keys at the settings write boundary

Clipboards smuggle control and invisible formatting characters (newlines,
zero-width spaces, BOM) into pasted API keys. The masked key field hides
the corruption and providers reject the key with a 401 indistinguishable
from a genuinely wrong key. Strip those characters and surrounding
whitespace once in the provider config store write path, so both backing
stores (legacy state secrets and providers.json) receive the clean value.
A whitespace-only value now clears the key.

* feat(llms,vscode): classify provider 401/403 as auth errors and surface actionable guidance

Add an "auth" ProviderErrorClass, assigned when the HTTP layer reports
401/403 — status-only on purpose, since provider bodies can quote words
like "unauthorized" without the request being an auth failure. The class
rides the existing errorClass plumbing (finish -> run-failed ->
AgentErrorEvent), so every host receives it with no new wiring.

In the VS Code chat surface, rewrite classified credential rejections
from BYOK providers into actionable text pointing at the API key
configuration, keeping the provider's raw body as a diagnostic tail.
Raw bodies alone are dead ends: Mistral, for example, answers an
identical {"detail":"Invalid API Key"} for a wrong, empty, or
wrong-scope key. Cline-account providers keep the JSON path so the
webview still renders their auth failures as a sign-in card.
2026-08-31 22:27:24 +02:00
John Choi bcfa7c7e4d fix(desktop): keep Stop available for running child agents (#13678)
* fix(desktop): keep Stop available for running child agents

* fix(desktop): reconcile aborted tool activity

* fix(desktop): guard abort and agent polling races

* fix(desktop): preserve authoritative abort status

* fix(desktop): track queue-verified completion

* test(desktop): trim duplicate abort coverage

* fix(desktop): settle delayed queue verification
2026-08-31 12:09:01 -07:00
John Choi c64743eb36 fix(core): propagate parent aborts to delegated subagents (#13677)
* fix(core): propagate parent aborts to delegated subagents

* docs(core): narrow delegated abort guarantees

* fix(core): release delegated sessions after execution

* fix(core): scope abort listeners to active runs

* fix(core): inherit parent runtime pid for subagents
2026-08-31 11:45:53 -07:00
c096030ace Desktop marketplace redesign: two-pane explorer with full catalog metadata (#13653)
* feat(desktop): add marketplace design exploration prototypes (storefront, explorer, registry)

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

* fix(desktop): render catalog icon tiles without percentage padding

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

* feat(desktop): drop placeholder icon tiles from explorer marketplace direction

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

* feat(desktop): make explorer the marketplace view, drop design exploration harness

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

* feat(desktop): add category tag filters to marketplace explorer

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

* feat(desktop): collapse marketplace category pills behind a more toggle

* feat(desktop): remove maturity badges and CLI install section from marketplace

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-31 11:45:33 -07:00
Mikołaj Kondratek 48d6385274 fix(vscode): thread task id into hook runner creation so execution telemetry fires (#13547)
The SDK hooks adapter created every hook runner without a task id, and
StdioHookRunner gates all captureHookExecution calls on one being set —
so the next variant emitted zero hooks.execution events while discovery
telemetry fired normally. Pass the task id (and tool name for the tool
hooks) at all five factory.create call sites, and pin the threading
with a regression test.
2026-08-29 17:04:00 +02:00
Mikołaj Kondratek cea134be06 fix(vscode): prevent hook spawn failures from crashing the core process (#13422)
* fix(vscode): prevent hook spawn failures from crashing the core process

A hook child-process spawn failure emitted "error" on HookProcess with no
listener registered, which Node's EventEmitter turns into an uncaught
exception - killing the entire cline-core process instead of failing the
one hook open. Guard the emit behind listenerCount so the rejection (which
StdioHookRunner handles) is the only propagation path.

The trigger was a workspace root that no longer exists on disk passed as
the spawn cwd: Node reports a nonexistent cwd as a misleading ENOENT on
the launcher binary ("spawn /bin/sh ENOENT"). Validate cwd existence in
HookProcess right before spawning - falling back to no explicit cwd with
a warning that names the missing directory - and when a spawn still fails
ENOENT because the directory vanished in between, name it in the error
message instead of blaming the shell.

* fix(vscode): fail hooks with a missing working directory instead of relocating them

Running a hook whose assigned cwd no longer exists from the host
process's own working directory would let its relative paths read and
write an unrelated location (e.g. the IDE install directory). Reject
before spawning, with an error naming the missing directory; the runner
reports the hook as failed and the task continues. Also carry pre-spawn
failure messages into HookExecutionError details so the cause is not
reduced to a bare "exited with code 1".
2026-08-29 09:07:30 +02:00
Bee 1986fa56de fix(llms): make Langfuse tracer detection survive minified release builds (#13680)
* fix(llms): recognize direct tracer providers

* fix(llms): make Langfuse tracer detection survive minified release builds

Release binaries are compiled with minify enabled, which renames classes,
so initializeLangfuseTelemetry's constructor-name guard never matched
"ProxyTracerProvider" and silently returned readiness=false in every
production build (hub log: "creating span processor" followed by
"initialized readiness=false" with no branch message in between). Dev runs
execute unminified source, which is why the same env vars worked there.

Replace every constructor-name comparison with checks that survive
minification: detect the proxy structurally via getDelegate, distinguish a
recording provider from the no-op fallback by its lifecycle methods, and
confirm our NodeTracerProvider registration by object identity. When a
foreign provider already owns the global slot, attach the Langfuse span
processor to it when it accepts processors, and otherwise shut down the
orphaned provider and report the rejection instead of bailing silently.

Verified by bundling the module with Bun minify:true against the real
OpenTelemetry packages: the previous code reproduces readiness=false
(provider class name mangles to "H2"), the new code initializes with
readiness=true.
2026-08-28 19:44:15 -07:00
TheRealSpencer 27350f243c Chore/bump undici mermaid (#13675)
* chore(deps): bump mermaid to 11.16.1 and raise undici floor to 7.29.0

* chore(deps): patch js-yaml and body-parser in the npm-managed subprojects
2026-08-29 02:25:25 +02:00
John Choi 60c74bc727 feat(ui): share attachment drop zone (#13672)
* feat(ui): share attachment drop zone

* fix(ui): cancel disabled attachment drops

* chore(ui): simplify drop zone surface

* chore(ui): release v0.2.0-next.8
2026-08-28 16:21:32 -07:00
Bee aa815cd41a fix(core): refresh Cline models from live catalog (#13670) 2026-08-29 01:19:26 +02:00
1fbcfab05d test: cover session search fallback on hub timeout and rejection (#13642)
* feat: add searchable session history

Rebased onto main and updated to supersede the sidebar search dialog
from #13533: the sidebar search icon now opens the indexed command bar
(Cmd/Ctrl+P) instead of a sidebar-local cmdk dialog that eagerly loaded
the entire session history via loadAllSessions(). CommandDialog gains a
shouldFilter passthrough so server-ranked FTS hits are displayed as-is.

* fix: harden session history search

* fix: evict failed restoration sessions from search

* fix: preserve deletion when search eviction fails

* fix: address session search review feedback

* fix: preserve search suppression during reconciliation

* test: cover sidecar search fallback on hub timeout and rejection

The existing search_sessions tests only exercised the index-hit and
empty-index-fallback paths with an immediately-resolved hub reply.
Add coverage for the two other realistic Hub-connection failure
modes the fallback is meant to tolerate: the hub call rejecting, and
the hub call hanging past the 750ms withSearchDeadline race.

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>
2026-08-28 19:06:47 +02:00
Bee ce71fe5eb9 chore(llms): built-in model list update 1787907289186 (#13663)
* chore(llms): built-in model list update 1787907289186

Result of `bun run build:models`.
Includes updated model list and fixed formatting issues across codebase.

* test(llms): update GLM reasoning toggle expectation
2026-08-28 02:45:05 -07:00
Bee aa4753f4ab fix(llms): use AI SDK 7 Langfuse telemetry (#13651)
* fix(llms): use AI SDK 7 Langfuse telemetry

* test(llms): cover Langfuse runtime context
2026-08-27 21:17:39 -07:00
John Choiandabeatrix 52d5e1a515 ENG-2490: Propagate session aborts to teammates (#13647)
* fix(core): propagate session abort to teammates

* fix(core): persist aborted teammate tasks as cancelled

* fix(core): settle teammate work on session abort

* fix(core): isolate replacement runs from stale aborts

* refactor(core): narrow teammate task status metadata

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2026-08-27 19:52:01 -07:00
Bee 2208d185a4 feat(sdk): add discovery boundary ahead of Agent Plugins support (#13017) 2026-08-27 18:22:48 -07:00
Saoud Rizwan 936c018689 chore(desktop): release v0.0.20 2026-08-27 18:15:36 -07:00
BeeandHarrison 957a4bf5d9 feat(desktop): render tool output images as attachments (#13643)
* fix(desktop): render tool output images as attachments

Add support for displaying media returned by tool calls (e.g. screenshots)
as rendered images with expand-to-fullscreen capability instead of raw
base64 text. Introduces an `ImageCarousel` component for navigating
multiple images, propagates the expand handler to tool message blocks,
and extracts/validates output media in tool summaries.

* test: cover multi-image and canonical media extraction in tool output (#13645)

extractOutputMedia and the desktop tool-message rendering path were only
ever exercised with exactly one distinct valid image, and
canonicalInlineMedia (MCP-style type: "media" blocks for audio/video/file)
had zero coverage. Add tests for: multiple distinct images in one tool
result (parser + desktop carousel navigation), inline audio via the
mime_type key spelling, canonical video/file media blocks, and rejection
of an invalid canonical image block.

---------

Co-authored-by: Harrison <harrison@cline.bot>
2026-08-27 17:55:29 -07:00
Saoud Rizwan b532b174ba fix(ci): stop e2e worker teardown timeouts and deflake hub daemon e2e on Windows (#13646)
* fix(e2e): stop VS Code e2e worker teardown from timing out

The ext-vscode-test-e2e job has been failing on main with 'Worker teardown
timeout of 60000ms exceeded' even though every test passes. Playwright only
reports an Electron app as closed once the process exits AND every holder of
its stdio pipes is gone (ChildProcess 'close' waits on the extra fd3/fd4
pipes Playwright creates for Electron). Any VS Code descendant that outlives
the main process (chrome_crashpad_handler, GLib's 'dconf watch' helper,
xdg-open browser handlers, VS Code 1.135's agent host CLI subprocess that
logs 'unable to kill the process') keeps those pipes open, so app.close()
never resolves and the worker teardown hangs on it until its 60s timeout
fails the job.

Harness fixes, each removing one source of that wedge:

- closeAppForTeardown now SIGKILLs the whole process group (taskkill /T on
  Windows) when app.close() times out, instead of only the main pid — and
  does so even when the main process already exited, which is exactly the
  wedged state. Playwright launches Electron detached, so pid == pgid.
- Launch VS Code with --disable-crash-reporter so no crashpad handler
  outlives the app holding the harness pipes.
- Seed the fresh user-data-dir with chat.disableAIFeatures: true so VS
  Code's own AI features (rolled out via server-side experiments, so CI
  breaks without any repo change) never start their agent host process.
- Drop the page.close() teardown: closing VS Code's last window quits the
  whole app, and ElectronApplication.close() on an already-exited app
  deadlocks; the app fixture's app.close() closes windows itself while the
  app is alive.
- Codex sign-in no longer opens a real external browser under E2E_TEST; the
  codex-oauth test drives the OAuth callback itself, and the browser was an
  orphaned process holding the harness pipes on the runner.

* fix(core): deflake hub daemon e2e tests on Windows runners

sdk-test on windows-latest fails intermittently in the hub daemon e2e
files:

- shutdown.e2e.test.ts dies with a bare 'Error: socket hang up'. That
  message is the ws handshake (http.ClientRequest) failing, not the
  /shutdown fetch (an undici failure prints 'TypeError: fetch failed'):
  a freshly spawned bun daemon on a loaded 2-core Windows runner
  occasionally drops its first accepted connection before writing the
  upgrade response. Real hub clients reconnect with backoff, and the test
  asserts shutdown behavior rather than first-connection reliability, so
  openAuthenticatedSocket now retries transient handshake failures within
  a 15s budget.
- singleton.e2e.test.ts times out waiting for daemon discovery: it still
  used the 10s hang guard that 0cfc90158 already raised to 30s in
  shutdown.e2e.test.ts for the same reason. Use the same 30s guard.
- Raise the e2e testTimeout to 60s so a test that legitimately spawns two
  daemons back to back can survive slow-runner startups instead of the
  discovery hang guard being cut off by the test timeout.
2026-08-27 17:40:21 -07:00
Tomás BarreiroandJohn Choi b78f6d16d0 Add a GitHub integration step to the onboarding (#13225)
* Add feature flags to the app

* React to account updates

* Address comments

* Add a GitHub integration step to the onboarding

* validate domain and fix errors on auth

* Hide the step behind a feature flag

* update version

---------

Co-authored-by: John Choi <john.choi@cline.bot>
2026-08-27 17:29:08 -07:00
John Choi 839074d7c1 test(vscode): prevent E2E worker teardown hangs (#13644)
* test(vscode): capture external URLs in E2E runs

* docs(test): clarify browser capture rationale
2026-08-27 17:06:47 -07:00
Saoud RizwanandSaoud Rizwan 9e7c1a3f9a Fix CLI crash when a remote MCP server is offline but enabled (#13639)
Remote (SSE/streamable HTTP) MCP connects run on the session.create
critical path, which the hub caps at 30s. Without a connect budget an
unreachable server spent the full 60s default request timeout (with the
SSE transport stuck in a reconnect loop), stalling session.create past
the hub deadline and tearing the whole session down - the interactive
TUI exited and one-shot runs failed. Stdio servers already have a
bounded initialize budget for exactly this reason; give URL clients the
same treatment with a 10s default connect budget that an explicit
timeout overrides in either direction.

Fixes #13597

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 16:13:38 -07:00
BeeandSaoud Rizwan 29530caa58 feat: add searchable session history (#13420)
* feat: add searchable session history

Rebased onto main and updated to supersede the sidebar search dialog
from #13533: the sidebar search icon now opens the indexed command bar
(Cmd/Ctrl+P) instead of a sidebar-local cmdk dialog that eagerly loaded
the entire session history via loadAllSessions(). CommandDialog gains a
shouldFilter passthrough so server-ranked FTS hits are displayed as-is.

* fix: harden session history search

* fix: evict failed restoration sessions from search

* fix: preserve deletion when search eviction fails

* fix: address session search review feedback

* fix: preserve search suppression during reconciliation

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-27 15:55:22 -07:00
Saoud RizwanandSaoud Rizwan 691fcb6b67 fix(shared): discover global rules at ~/Cline/Rules (#13614)
The VS Code Rules tab resolves the Documents folder via
'xdg-user-dir DOCUMENTS', which prints bare $HOME when no user-dirs
config exists (WSL/headless installs), so it reads and writes global
rules at ~/Cline/Rules. The SDK's rule search paths only covered
~/Documents/Cline/Rules, so those rules never reached the system prompt.
Add the missing path to the search list.

Fixes #13542

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 14:44:25 -07:00
Saoud RizwanandSaoud Rizwan c97e4af8fa Fix scheduled tasks disappearing after desktop app updates (#13627)
* Fix hub-managed schedules being wiped by cron reconciliation on hub restart

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

* Require the virtual hub/schedules path when exempting specs from removal reconciliation

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

* Treat recorded source mtime as proof a spec is file-backed, closing the hub/schedules spoof gap

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 13:59:08 -07:00
Mikołaj Kondratek 889010b0b9 fix: hide history cost estimates for subscription-billed tasks (#13562)
* fix(vscode): hide history cost estimates for subscription-billed tasks

The task-header fix for subscription providers cannot reach history:
history rows render the stored totalCost (an API-rate estimate) and do
not know which provider ran the task, so the history page printed
$X.XXXX on every row and the recent-task chips in an empty chat view
rendered a $ chip even for subscription-billed tasks.

The SDK session records already persist the provider — the CLI's
history view uses it for exactly this — but the VS Code mappers dropped
it. Map it through both transports (HistoryItem.apiProvider for the
state-pushed taskHistory, TaskItem.api_provider for getTaskHistory) and
suppress the dollar figure per row when that provider's
usageCostDisplay is not "show", via a new useUsageCostVisibility
predicate shared by both surfaces.

Rows without a recorded provider (tasks predating the field, legacy
imports) keep showing the stored value — there is nothing to key
suppression on.

* test(vscode): e2e-verify history cost suppression in real VS Code

Seeds SDK session records (one openai-codex subscription task, one
anthropic usage-billed task) into the isolated CLINE_DIR before the
webview loads, then asserts in a real VS Code instance that both the
recent-task chips and the full history page render the dollar figure
only for the usage-billed task. Covers the two boundaries the unit
tests stub: on-disk records reaching getTaskHistory with provider
populated, and the provider listings delivering the subscription mark
to the webview.
2026-08-27 22:58:22 +02:00
Saoud RizwanandSaoud Rizwan 89c2efa970 fix(core): refuse checkpoint workspace restore when HEAD moved past the checkpoint (#13626)
* fix(core): refuse checkpoint workspace restore when HEAD moved past the checkpoint

Restoring a checkpoint runs git reset --hard, which moves the current
branch pointer. If commits were made after the checkpoint (by the user
or by the agent), the reset silently knocked them off the branch,
leaving them reachable only through the reflog.

Guard the reset: if HEAD no longer matches the commit the checkpoint
was created on, throw a descriptive error (including how many commits
would be dropped) instead of destroying history. Chat-only restore is
unaffected, and users who really want to discard the commits can reset
the branch manually first.

Fixes #13550

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

* fix(core): close the guard-to-reset race with an atomic ref update

The moved-HEAD guard read HEAD, ran further git commands, then reset
unconditionally, so a commit landing in that window could still be
knocked off the branch. Replace the reset's branch move with git's
native compare-and-swap (git update-ref HEAD <new> <old>), which fails
if HEAD no longer points at the verified commit, and follow with a bare
reset --hard to sync the index and worktree to the already-moved HEAD.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 13:47:50 -07:00
Saoud RizwanandSaoud Rizwan f753a01d85 fix(desktop): don't show providers as configured without real credentials (#13608)
* fix(desktop): don't show providers as configured without real credentials

The desktop settings marked any provider with a persisted settings entry
as Configured, but legacy VS Code migration and empty saves can seed
entries (e.g. qwen-code, sapaicore) holding only a default model and no
credentials. Move the CLI's isProviderSettingsUsable readiness check into
@cline/core, expose it as a computed 'configured' flag on the provider
catalog, and use it in the desktop's isProviderConnected.

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

* fix(desktop): resync catalog after saves so Configured badge updates live

Optimistic provider mutations can't know the sidecar-computed 'configured'
flag, so after connecting a keyless provider or saving cloud credentials
(e.g. a Vertex project id) the row stayed 'Not configured' until remount.
Silently refetch the catalog after each successful save, guarded by the
existing generation counter so newer edits discard stale responses.

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

* fix(desktop): claim a generation in post-save resync so overlapping refreshes can't apply stale snapshots

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

* fix(desktop): bump catalog generation on OAuth login success

Every other optimistic provider mutation claims a new generation; the
OAuth success path didn't, so a catalog load or resync still in flight
could arrive late and overwrite the just-connected state.

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

* fix(desktop): resync catalog after OAuth login instead of bare generation bump

The resync claims a new generation (discarding any stale in-flight
response) and its own fetch covers both the new OAuth connection and any
provider saved moments earlier, matching the post-save path.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 13:45:44 -07:00
Bee 908e09815e feat(core): anchor agent-created schedules in the user's .cline schedules home (#13634)
* feat(core): anchor agent-created schedules in the user's .cline schedules home

Agent-created schedules inherited whichever workspace folder the chat
session happened to run in, scattering user-level routines across chat
and project folders. They were invisible to workspace-scoped listings
elsewhere, tied to folders that may be cleaned up, and each chat's
tasks tool saw a different set when checking for duplicates.

Anchor them in ~/.cline/schedules instead: the hub's scheduled-task
session defaults now resolve to that home (created on demand), so
agent-created schedules live and run in one stable user-level scope.
The tasks tool guidance now tells agents that scheduled sessions run in
the schedules home, so prompts must carry absolute paths to any project
they operate on.

Schedules created explicitly with a workspace (CLI --workspace, desktop
routine wizard) are unchanged, and existing rows keep their current
workspaceRoot - they stay visible through the all-workspaces listing
paths (#13613, #13633).

* test(core): restore any pre-existing CLINE_DIR after the agenda hub test

The test's cleanup deleted CLINE_DIR outright, so an environment that
had it configured would leave later tests in the same worker on the
default storage directory. Save the previous value and restore it.

* test(core): restore CLINE_DIR even when hub test setup throws early

Restoring the override in the try/finally missed failures thrown during
transport construction or start(), before the try was entered. Register
the restore with onTestFinished instead, which runs regardless of where
the test fails.
2026-08-27 13:18:57 -07:00
8eca7575b4 fix: make OpenAI Codex (ChatGPT subscription) sign-in fail loudly instead of silently dead-ending (#13537)
* fix: make OpenAI Codex sign-in fail loudly instead of silently dead-ending

When callback port 1455 is already in use (e.g. by the Codex CLI or a
previous pending sign-in), startLocalOAuthServer returns a no-op server
and loginOpenAICodex would open the browser anyway, then dead-end:
the callback could never be received, and in the VS Code extension the
user just saw nothing happen after clicking 'Sign in to OpenAI Codex'.

- loginOpenAICodex now fails fast with an actionable 'port in use'
  error before opening the browser, unless the host provides manual
  code entry (the CLI's paste fallback keeps working)
- surface OAuth redirect errors (e.g. access_denied) instead of
  collapsing them into 'Missing authorization code'
- the extension dedupes concurrent sign-in clicks: a re-click re-opens
  the auth page of the pending flow instead of spawning a second flow
  that would collide with our own callback server
- browser-open failures now show an error message with the URL to
  open manually instead of only logging
- abandoned-flow timeouts no longer surface a confusing 'Missing
  authorization code' toast

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

* refactor: drop host-side codex login dedupe, keep flow identical to CLI

The SDK owns the failure handling now (fail-fast on an unbindable
callback port), so the extension keeps the exact same simple
loginOpenAICodex call the CLI uses. A second click while a flow is
pending gets the SDK's clear port-in-use error, same as running
'cline auth openai-codex' twice would. Keep only the CLI-parallel
onOpenUrlError surfacing (the CLI prints 'open the URL above
manually'; the extension's equivalent is an error toast with the
URL).

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

* test(e2e): cover Codex sign-in callback-port failure and redirect errors

Two driven-VS Code tests for the OpenAI Codex (ChatGPT subscription)
sign-in flow:

- with port 1455 occupied on both loopback families, clicking the
  sign-in button surfaces the fail-fast port-in-use toast
- with the port free, the callback server binds and an OAuth redirect
  error (access_denied) propagates to a visible error toast

The second test opens a real browser tab to the OpenAI auth page as a
side effect of the genuine sign-in click.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com>
2026-08-27 22:10:35 +02:00
Mikołaj Kondratek 006de710d5 fix(sdk): don't log out Codex/OCA users when token refresh fails transiently (#13565)
* fix(sdk): don't log out Codex/OCA users when token refresh fails transiently

Port the cline-provider refresh semantics to openai-codex and oca:
a transient refresh failure (network error, timeout, server 5xx) with an
already-expired access token now rethrows instead of returning null.
A null return means the refresh token was REJECTED and re-auth is
required; treating an outage blip as a rejection is what turned it into
a forced 'openai-codex requires re-authentication.' task stop while the
settings UI still showed the user as signed in.

Both providers also emit user.auth_refresh_soft_failure telemetry on
transient failures (the 'prevented logout' counter the cline provider
already has) and attach status/errorCode details to the genuine
invalid_grant logout event.

* refactor: collapse duplicate soft-failure telemetry branches and test

Review feedback: compute tokenExpired once and emit the soft-failure
event once in both providers, then return current credentials or
rethrow. Fold the codex soft-failure telemetry assertions into the
existing still-usable-token test instead of a near-duplicate case.
2026-08-27 22:10:00 +02:00
Bee 62f471f233 fix(core): stop watching agenda spec dirs while the todo tool is disabled (#13629)
* fix(core): stop watching agenda spec dirs while the todo tool is disabled

Since #13530 disabled the agent todo tool, the Agenda UI, and the
automation pump, the hub still created fs.watch watchers on the global
agenda specs dir and on every workspace root recorded in the task store
(at startup and on scope access). Nothing consumes the watcher-driven
task events while the feature is off, and the task.* hub commands
already reconcile spec files on demand, so the watchers are pure
overhead - one OS watch handle per known workspace.

Wire watchFiles to AGENDA_TODO_TOOL_ENABLED the same way
automationEnabled is, preserving a host's explicit watchFiles opt-out
for when the flag is turned back on. Schedules are unaffected: the
schedule list has no file watcher and updates through hub commands and
published schedule events.

* fix(core): reconcile external spec edits inside updateTask

With the spec watchers off there is no background reconciliation, so a
task spec edited directly on disk made every same-store task.update fail
the signature check with "task spec changed outside the manager" until
an unrelated task.get or task.list happened to reconcile the scope.

Reconcile the task's scope at the start of updateTask (mirroring what
refreshAndVerifyTaskIntent already does for approve/run), skipping it
when the file reconciler itself is the caller to avoid recursing from
reconcileFileStore. An external edit now surfaces as the store's normal
stale-revision conflict, and a re-read-and-retry succeeds. This also
closes the pre-existing watcher debounce race for updates.
2026-08-27 13:00:24 -07:00
Saoud RizwanandSaoud Rizwan c017c7016e fix(desktop): make the Tauri shell work on Windows (#13632)
- Defer updater installation to the user-initiated restart on Windows:
  install() launches the NSIS installer and exits the process immediately,
  so the background cycle now downloads only and stages the bytes, and
  restart_to_apply_update installs them after stopping the sidecar.
- Spawn child processes (sidecar, git, cmd /C start) with CREATE_NO_WINDOW
  so the GUI-subsystem app doesn't pop visible console windows.
- Fall back to USERPROFILE when HOME is unset resolving the MCP settings
  path, matching the sidecar's homedir().
- Reap the sidecar after the Windows hard-kill so its exe file lock is
  released before the NSIS installer replaces it.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 12:04:53 -07:00
Saoud RizwanandSaoud Rizwan 4bfef7087f Remove box shadow from chat message actions row (#13630)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-27 11:51:07 -07:00
Saoud RizwanandSaoud Rizwan 1d5d3b0055 Add tooltips explaining Live and After recording badges on voice input models (#13610)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 23:39:15 -07:00
Saoud RizwanandSaoud Rizwan 80dd573156 Desktop: surface scheduled-task final output — auto-expand submit_and_exit and render its summary as markdown (#13612)
* desktop: auto-expand submit_and_exit and render its summary as markdown

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

* desktop: render submit summary in full foreground color

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

* desktop: label the submit row 'Scheduled task completed'

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

* desktop: label errored submit_and_exit rows as failed

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 23:38:05 -07:00
Saoud RizwanandSaoud Rizwan ce2f7a00bb Make suggested routine template prompts prescriptive about their final output (#13611)
* Make bug hunter routine template prescriptive about its final report

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

* Make remaining routine templates prescriptive about their final output

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 23:37:20 -07:00
Saoud RizwanandSaoud Rizwan ad1408636e fix(desktop): show agent-created schedules on the Schedules page (#13613)
* fix(desktop): show agent-created schedules on the Schedules page

Schedule hub commands are scoped to the workspace registered by the
connection, but the desktop app's hub client registers the app launch
directory while agent-created schedules live under each chat's own
workspace folder - so they never appeared on the Schedules page.

Grant token-authenticated hub connections (which can already bind any
workspace at registration) explicit cross-workspace schedule access via
an allWorkspaces payload flag, and have the desktop sidecar request it
for routine schedule commands. Workspace-bound clients (local browser
origins) and default CLI behavior stay scoped.

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

* refactor(core): strip allWorkspaces flag from schedule inputs and pin it in the sidecar payload

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 23:23:08 -07:00
Saoud RizwanandSaoud Rizwan 8981079a43 Build and Authenticode-sign a Windows x64 desktop installer in desktop releases (#13607)
* feat(desktop): build and Authenticode-sign a Windows x64 NSIS installer in desktop releases

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

* fix(desktop): pin OIDC-adjacent actions to commit SHAs in the Windows signing job

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

* fix(desktop): pin checkout and upload-artifact to commit SHAs in the Windows signing job

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 23:07:50 -07:00
Dominic CooneyandCline Agent b4fd4ee0cd Tunnel ProtoBus over the existing Host Bridge (#13218)
* feat(core): tunnel ProtoBus over Host Bridge

* fix(core): harden Host Bridge stream lifecycle

* fix(core): serialize concurrent chunked responses per request

Streaming handlers deliver updates fire-and-forget, so two logical
responses for one request_id can be in flight at once. Chunked payloads
made forwarding non-atomic: each chunk write is an await, so concurrent
forwards could interleave their chunk sequences and the receiver --
which reassembles purely by arrival order -- would splice two payloads
into one. Route all forwards for a request through one promise chain; a
failed write rejects every later forward so a torn payload is never
followed by more chunks.

Rename the lock manager's instanceAddress to instanceOwner: it holds an
opaque per-spawn instance ID on the token path and a listener address
only on the CLI-harness path. Delete the caller-less getInstanceByPort
query that interpreted the owner as an address.

Also: document message_json as a legal wire encoding for small
payloads, close the gRPC client when startup fails, note the
intentional discard of the cancellation confirmation, and add the
proto's trailing newline.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-27 09:24:28 +09:00
89970ea794 Sign Windows CLI binaries with Azure Trusted Signing; surface app-control launch errors (#13021)
* feat(cli): sign Windows binaries with Azure Trusted Signing and surface app-control launch errors

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

* fix(cli): use _CLI-suffixed signing profile secret, normalize endpoint, fail loud on partial config

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-26 16:13:23 -07:00
John Choi ee0982cb98 fix(desktop): keep the window title bar draggable across views (#13572)
* fix(desktop): keep window title bar persistent

* fix(desktop): reserve persistent title bar space

* fix(desktop): polish persistent title bar layout
2026-08-26 15:58:58 -07:00
Mikołaj Kondratek 7718142ef2 fix(tools): preserve a file's own CRLF line endings across apply_patch updates (#13512) 2026-08-26 21:18:32 +02:00
John Choi 70654acc3e feat(ui): share agent welcome hero (#13567)
* feat(ui): share agent welcome hero

* test(ui): cover welcome hero pointer states

* refactor(ui): keep welcome hero API minimal

* test(ui): verify welcome hero package assets

* fix(ui): inline welcome hero masks
2026-08-26 11:37:52 -07:00
Saoud Rizwan c8f1caa88c fix(vscode): stop pinning DeepSeek model count in catalog smoke test (#13600) 2026-08-26 11:05:35 -07:00
𝓜𝓲𝓼𝓼𝓪𝓻𝓲 𝓐𝓱𝓲𝓵 🌿 7673b30e4d fix(vscode): avoid render crash on malformed api_req payloads in combineApiRequests (#13560) 2026-08-26 16:19:59 +02:00
Saoud Rizwan c0c37a1587 chore(cli): release v3.0.60 2026-08-26 02:25:12 -07:00
Saoud Rizwan ebdabe65ce chore(sdk): release v0.0.81 2026-08-26 02:21:57 -07:00
Saoud Rizwan 40c3a4dbd8 chore(desktop): release v0.0.19 2026-08-26 02:14:21 -07:00
Saoud Rizwan 6859d00e51 fix(hub): stop shipping full transcripts inside broadcast hub events (#13587)
* fix(hub): stop shipping full transcripts inside broadcast hub events

Every session.updated (and session.created/detached/run.started) event
embedded the session's ENTIRE message transcript via readCoreSessionSnapshot,
even though no consumer reads snapshot.messages off an event — clients fetch
messages with the session.messages command. For a multi-megabyte transcript
this turns every status flip into megabytes per subscriber, floods the
durable event log, and (until the send-queue backpressure fix lands) lets a
slow subscriber balloon the hub process by one full transcript copy per
event — reported as a 25GB cline process on a 16GB Mac.

Strip snapshot.messages centrally in HubServerTransport.publish() so every
current and future event publisher is covered, the event log stores slim
envelopes, and cursor replay stays byte-identical with live fan-out. All
other snapshot fields (status, usage, model, workspace, checkpoint) are kept,
and command replies are untouched.

* fix(hub): never capture the transcript into event/reply snapshots

Replaces the publish-boundary strip with the real fix: don't build
message-bearing snapshots in the first place. emitSessionSnapshot no longer
re-reads the entire transcript from disk on every status flip, and
readCoreSessionSnapshot no longer reads it for any event or reply — a
snapshot is a state notification (status, usage, model, workspace,
checkpoint); the transcript is fetched via the session.messages command.
Checkpoint-restore snapshots (session-versioning-service) are untouched:
restore replies carry messages in their own dedicated field.
2026-08-26 02:07:28 -07:00
Saoud Rizwan 6ba9b9d7b4 chore(cli): release v3.0.59 2026-08-26 01:49:10 -07:00
Saoud Rizwan 4c8cd98351 chore(sdk): release v0.0.80 2026-08-26 01:30:02 -07:00
Saoud Rizwan ebee8ca912 chore(vscode): release v4.1.16 2026-08-26 01:18:34 -07:00
Saoud Rizwan c0d6301884 chore(desktop): release v0.0.18 2026-08-26 00:56:19 -07:00
Saoud RizwanandSaoud Rizwan d71f097656 fix(desktop): install marketplace plugins and MCP servers in-process instead of spawning a cline binary (#13585)
* fix: install marketplace plugins and MCP servers in-process instead of spawning a cline binary

The desktop app sidecar and cline-hub shelled out to 'cline plugin install'
and 'cline mcp install' for marketplace installs. Packaged GUI apps inherit
launchd's minimal PATH on macOS and most desktop users have no cline CLI
installed at all, so installs failed with a red
'Executable not found in $PATH: "cline"' error.

Install via @cline/core's installPlugin/installMcpServer in-process instead,
matching what the VS Code extension already does. Also fix
parseMcpInstallArgs in @cline/core to treat the marketplace catalog's '--'
separator as end-of-options; previously the separator itself became the
stdio command.

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

* refactor: drop test-injection plumbing from marketplace installers

Call @cline/core's installPlugin directly instead of threading an
installer option through the marketplace entry points; tests stub the
core module instead.

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

* revert: keep cline-hub marketplace installs CLI-backed

The hub dashboard is launched via 'cline dashboard', so a CLI is always
present and CLINE_WRAPPER_PATH resolves it; the PATH bug only affects
the desktop app, which does not ship a CLI.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-26 00:14:55 -07:00
Saoud Rizwan 6539f4deea feat(desktop): hover trash button on sidebar session rows (#13582)
Each session row shows a trash icon on the right while hovered (or
when the button itself is focused), opening the same delete
confirmation dialog the row's context menu uses. The row is a button
and buttons cannot nest, so the trash is an absolutely positioned
sibling inside a group/row wrapper, overlaid where the timestamp sits:
row hover hides the timestamp, shows the trash, and moves the row's
hover background to the wrapper group so it holds while the pointer is
on the trash itself.
2026-08-26 00:01:00 -07:00
Saoud RizwanandSaoud Rizwan 036fc75b1f fix(desktop): don't block the main thread on quit while stopping the sidecar (#13566)
Quitting the mac app beach-balled for ~5-7s. The shutdown POST was
built from the ws transport URL (appending /shutdown lands inside the
query string), so the sidecar was never told to exit, and stop() then
polled the child for up to 7s on the main thread - on macOS inside
applicationWillTerminate - before SIGKILLing it.

stop() now sends SIGTERM and returns immediately. The sidecar handles
SIGTERM with the same bounded (5s) graceful shutdown as the /shutdown
endpoint and exits itself, finishing session persistence as an orphan.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-25 23:40:07 -07:00
Saoud Rizwan 6fc40127a6 feat(desktop): scheduled sessions UX — unified details dialog, run-now handoff, hidden steering, stuck-thinking fix (#13573)
* feat(desktop): merge schedule details into one view and open run-now sessions

The schedule details dialog drops its Overview/Runs tabs: one scrollable
column with the meta grid, the configuration JSON (capped at max-h-64
with internal scroll so it cannot crowd out what follows), and a Runs
section beneath it showing the three most recent runs with a ghost
"Show all N runs" expander (collapsed again whenever a different
schedule's details open). The "Full configuration for this schedule"
subtext is gone; the dialog passes aria-describedby={undefined} so
Radix does not warn about the missing description.

Run now hands you into the session it starts. The trigger command
queues the run and returns before the runner attaches a session id, so
after the toast the handler polls the schedule overview once a second
for up to 15 seconds — which doubles as keeping the page's run status
fresh (refreshSchedules now returns the fetched overview to make that
single-stream) — until the triggered execution reports its session id,
then calls onOpenSession. Guarded so it never auto-navigates after the
user left the page.

* feat(desktop): hide runtime steering messages from transcripts

Scheduled/automation runs inject user-role steering messages each
iteration ("[SYSTEM] This run is not complete until you call
submit_and_exit...", plus a team-obligations variant). The chat view
rendered them as user bubbles, as if the person had typed them — in a
scheduled session the transcript was mostly [SYSTEM] noise.

They are machinery talking to the model, not something the person said
or needs to read, so the transcript now hides them entirely:
MessageBubble renders null for any [SYSTEM]-prefixed user message.
Grouping still treats them as working-row machinery via a single
isSystemSteeringMessage predicate — they collapse into the run's work
span, are never a turn boundary, can never be mistaken for a run's
answer, and never advance the run count even when metadata is missing —
so work-block folding and checkpoint/edit run numbering stay correct.
A finished scheduled session now reads as prompt, work summary, answer.

* fix(desktop): poll history while an attached session's event stream is dead

Opening a scheduled session while (or right after) it runs left the
view stuck on the thinking shimmer until the user switched away and
back. Root cause is in core: the hub daemon executes scheduled runs on
a private LocalRuntimeHost inside createLocalHubScheduleRuntimeHandlers,
while the hub server only projects live events from its own session
host — so session.attach succeeds but no assistant/tool/status events
ever flow. And since multiple hub daemons share cron.db, a run claimed
by a different daemon is invisible to this hub regardless. The proper
core rewiring is tracked as ENG-2474.

Client-side heal that covers every case: while an attached history
session reports a busy status and no chat_event chunk has arrived for
five seconds (and no assistant bubble is mid-stream), poll every three
seconds — re-read canonical history, merged through the same dedupe
path hydration uses, and the session record's status — so the
transcript and the thinking indicator settle in place. Locally driven
turns keep chunks flowing, so the quiet-window guard keeps the fallback
inert there.

* chore(desktop): format workspace selector components

Biome formatting drift that landed on main; picked up by a formatter
pass over components/views/chat.

* fix(desktop): keep stale-stream poll inert during locally driven turns

The fallback poll could fire between a local submit and the model's
first chunk (optimistic user bubble added, stream quiet past the
window, no assistant bubble yet). It then replaced the optimistic
bubble — raw prompt text — with its canonical history twin, which is
stored wrapped in a user_input envelope. The rekey handler that runs
when the stream starts looks for a trailing user bubble matching the
raw prompt, finds only the wrapped copy, and appends a second bubble:
duplicated messages in normal interactive chat.

The poll now stays inert while a local turn is in flight
(turnEpoch !== turnSettledEpoch, or outstanding optimistic user
messages), checked both before polling and again after the snapshot
returns. Hydration marks the turn settled — the mount defaults
(epoch 0, settled -1) otherwise read as an open turn and would keep
the fallback inert forever for the scheduled-session case it exists
for. Applying a polled snapshot also rebuilds the live tool routing
keys, same as hydration, so later tool events update canonical rows
in place instead of appending.

* fix(desktop): keep the working indicator alive for narrating scheduled runs

Watching a scheduled run live: the first tool row appeared, then the
thinking indicator vanished with nothing streaming, and the rest of
the run (final answer, submit_and_exit) only showed up seconds later
in one lump.

inferHydratedChatStatus treats a "running" session record whose
transcript ends on an assistant message as a session that died without
a status flip and reports "completed". That heuristic is right for
stale records, but scheduled/automation models narrate between tool
calls, so a polled snapshot can genuinely end on assistant text
mid-run — the completed flip hid the working indicator, folded the
run early, and disarmed the stale-stream poll (status left the busy
set), dead-ending live updates until an in-flight poll happened to
deliver the finished run.

The heuristic now only applies once the transcript has actually gone
quiet (newest message older than two minutes — comfortably past model
latency plus tool runs). A recently active transcript keeps the
record's "running" verdict, so the indicator stays up and polling
stays armed until the record itself settles.

* fix(desktop): stale-stream poll mirrors the session record instead of inferring

Replaces the previous fix for the vanishing working indicator (the
time-window guard added to inferHydratedChatStatus) with a version
that adds no inference at all: the heuristic is restored to exactly
its long-standing form, and the poll now maps the session record's
status verbatim (mapSessionRecordStatus).

The record is the right authority in the poll's context: the sessions
this fallback serves have a live host maintaining their record, and it
flips to a terminal status when the run ends. Transcript-shape
inference belongs only where it has always lived — hydrating sessions
whose records may be orphaned — and would misread a mid-run snapshot
ending on assistant narration as a finished session, hiding the
working indicator and disarming the poll.

* fix(desktop): address review findings on steering detection and run-now matching

Steering detection additionally requires the injected-message marker
(meta.userRunSpan === 0) beside the [SYSTEM] prefix, so a person's
genuine prompt that happens to start with "[SYSTEM]" stays visible
and turn-counted. The failure direction is deliberate: an unstamped
injected reminder would merely show as a user bubble, while the
content-only check could hide a real prompt.

Run-now only follows the execution id the trigger reply itself named;
the newest-execution-for-this-schedule fallback could open a previous
run's session when the trigger failed to enqueue one.

* fix(desktop): report a failed run-now instead of confirming a start

A trigger reply without an execution means no run was enqueued (the
schedule may have been disabled or deleted since the page loaded). The
handler previously toasted "Run started" regardless and then silently
skipped the session-open polling. It now shows a destructive
"Run not started" toast, refreshes the schedule list so the row
reflects reality, and skips the polling entirely.
2026-08-25 23:28:21 -07:00
Saoud Rizwan 110138b540 feat(desktop): sidebar time view, Customize/Marketplace split, and schedule page UX (#13570)
* feat(desktop): split Customize into Installed and Marketplace pages

The Customize hub previously embedded a Browse section inside every tab
that had a catalog. That inlining made each tab long and buried the
catalog. Customize is now the installed inventory only (skills, MCP,
plugins, rules, hooks, tools tabs pass marketplaceVariant="installed"
to the embedded MarketplaceView; McpServersContent grew the same prop),
with an outline Marketplace button in the header.

Browsing moved to a dedicated Marketplace settings section that renders
the previously dead "directory" variant of MarketplaceView: one list
across all catalog types with type-filter chips, wrapping tag chips,
and light rules separating the filter tiers from each other and from
the results. The Clear control now renders inline at the end of the tag
row only while a tag is active, the Updated date is gone, and the
header hosts an Installed button mirroring the one on the Customize
page. Directory subheader copy: "A curated set of plugins, MCP
servers, and skills from the Cline community."

Tag and type chips wrap to new lines instead of scrolling
horizontally.

* feat(desktop): sidebar time view with sections, sort toggle, and scheduled detection

Restores the time-sorted session list as the default sidebar view, with
collapsible Pinned / Scheduled / Tasks sections (headers appear only
once something is pinned or scheduled) and the page-fill effect that
grows the fetched history window until a Show-more click makes visible
progress. Project grouping stays as the alternate mode behind a
one-click sort toggle whose icon reflects the active mode — the old
dropdown cost an extra click for a two-option choice.

Scheduled sessions are detected two ways: the hub-schedule origin
trigger in session metadata, plus a fallback that asks the hub which
session ids belong to schedule executions (list_routine_schedules,
fetched on mount and every two minutes, merged into a rolling set).
The fallback matters because locally executed scheduled runs do not
reliably stamp the trigger into session metadata — a real scheduled
session created today carried only {mode:"user"} provenance. The
scheduled clock icon now leads the row, left of the title; pin and
timestamp stay on the right.

The initial visible page grows from 10 to 30 rows so a tall sidebar
fills instead of stranding a stub of rows over empty space (history
fetches already start at 50).

The expanded sidebar's Customize row now hosts indented Installed and
Marketplace sub-tabs while a customize section is open; the active
sub-tab carries the full selected background while the parent keeps a
subtler one so the two simultaneous highlights read differently.

Also fixes the hover-card flash on click (logo card and session-row
cards): Radix HoverCardContent sits on a DismissableLayer, so a click
on the trigger registers as a pointer-down outside the card and
dismisses it, and the trigger's focus event immediately reopens it.
onPointerDownOutside preventDefault suppresses the dismissal; cards
still close on pointer leave.

* feat(desktop): schedule page row, dialog, and details UX polish

Schedule cards are now click targets: clicking anywhere on a card
outside its controls opens the details dialog (guarded via
closest("button,...") since every inline control, including the Radix
switch, renders a button element), with Enter/Space keyboard support.
The redundant eye button is gone. The remaining edit / run / pause /
delete buttons grow from the 12px icon-sm size to 28px targets with
16px icons, sized consistently with the adjacent enable toggle — the
icons use explicit size-4 classes so the Button base svg rule cannot
shrink them back.

The new/edit dialog gains breathing room between field labels and their
inputs (space-y-2 per field wrapper).

The details dialog no longer scrolls as a whole when the schedule JSON
is long: the dialog is a flex column capped at 85vh, the JSON pre
shrinks to the remaining space (min-h-0) and scrolls internally, and
the Runs tab list scrolls inside the tab the same way.
2026-08-25 15:19:29 -07:00
3497391c5a feat(desktop): customize macOS DMG install window (#13563)
* feat(desktop): add Retina DMG background tooling

* feat(desktop): customize the macOS DMG layout

* ci(desktop): validate DMG background assets

* fix(desktop): adjust DMG Applications icon position

* ci(desktop): drop redundant DMG artwork validation from publish workflow

Tauri's beforeBuildCommand already runs dmg:background (with its own
validation) at the start of the build/sign/notarize step, and the
release/beta config overlays do not override the build section, so this
step duplicated work the publish job performs anyway. PR-time coverage
lives in desktop-test.yml.

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-08-25 14:30:59 -07:00
Mikołaj Kondratek 9154a54a0e fix: stop showing cost estimates for subscription-billed providers (#13552)
* fix(vscode): stop showing cost estimates for subscription-billed providers

Providers whose usage is covered by a flat-rate subscription (ChatGPT
Plus/Pro via openai-codex, ClinePass) are marked with
metadata.usageCostDisplay = "subscription" in the SDK, and the CLI
already suppresses dollar figures for them. The VS Code host collapsed
that value into "show" before it reached the webview, so the task
header and model pricing rows rendered API-rate cost estimates that
users read as real charges on top of their subscription.

Pass all three usageCostDisplay values ("show" | "hide" |
"subscription") through the catalog listing and render cost only when
the value is "show", matching the CLI's shouldShowCliUsageCost
policy.

* feat(llms): mark Claude Code as a subscription-billed provider

Claude Code is typically authenticated with a Claude Pro/Max
subscription, but its models reuse Anthropic API pricing metadata, so
Cline rendered per-token prices and API-rate cost estimates for usage
that is covered by the subscription. Set usageCostDisplay =
"subscription" on the provider (picked up by the CLI and the VS Code
webview) and suppress the price rows in the Claude Code settings card.

The Claude Code CLI can also run on API-key billing, where a real cost
exists; the provider cannot distinguish the two, so we prefer showing
no number over a misleading one.

* fix(vscode): suppress cost display until provider listings load

While the ListProviders request is in flight (or after it fails), the
usage-cost hook had no listing to consult and fell back to "show",
flashing the API-rate estimate at subscription users on every chat-view
mount — the exact display the previous commit removes. Return
"unknown" whenever listings are absent; consumers already render cost
only for "show", so they suppress it during that window with no
changes. Briefly hiding a real cost is harmless, briefly showing a fake
charge is not.
2026-08-25 19:17:14 +02:00
Tomás Barreiro 7d004f8dc7 Hide task costs on vscode when ClinePass is selected (#13515) 2026-08-25 18:11:41 +02:00
MaxandMax Paulus 🥪 432e00eaa6 fix(vscode): include rich workspace metadata in system prompt (#13518)
* capture richer workspace information for vs code extension

* fix(shared): redact credentials from workspace remotes

* fix(shared): avoid regex backtracking in remote redaction

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-08-25 09:02:49 -07:00
Mikołaj Kondratek 095385b985 fix(desktop): unblock sdk-test lint on the voice-input model picker (#13553)
The model picker renders a radiogroup of styled buttons with role=radio
and aria-checked; biome's useSemanticElements flags the role as an
error, which fails the sdk-test Quality Checks lint for every PR
touching sdk/ or apps/ paths. Suppress with a justification — switching
to input type=radio needs a restyle and belongs to the desktop settings
work.
2026-08-25 08:58:23 -07:00
Saoud Rizwan 491b30b806 chore(desktop): release v0.0.17 2026-08-25 01:49:58 -07:00
Saoud Rizwan 8b046d04f9 feat(desktop): Customize hub, sidebar overhaul, and settings polish (#13538)
* feat(desktop): merge customization pages into a Customize hub with inline marketplace

Replaces the Plugins page and the dedicated Marketplace page with a single
Customize hub. Tabs: Skills, MCP, Plugins, Rules, Hooks, Tools, each with
live counts. Tabs backed by a marketplace catalog render the installed
items followed by an inline browsable Browse section (CLI-hub style), so
installing from the catalog immediately reflects in Installed above.

- Installed cards restyled to mirror the browse-card anatomy: bg-card p-4
  containers, absolute top-right xs Uninstall matching Install, truncating
  semibold titles, primary-tinted icons, real Badge components instead of
  ad-hoc bordered spans, un-indented line-clamped descriptions
- Rules/Hooks/Tools rows brought into the same card language; redundant
  intro paragraphs (duplicating the page description) removed; Tools group
  headers match the Installed header style with counts
- Marketplace section header renamed to Browse; duplicate 'N results' row
  removed (the header count is the single source)
- MCP embedded view now shows the full marketplace instead of
  installed-only

* feat(desktop): overhaul sidebar sessions and navigation

Sessions list:
- Sort toggle removed; sessions are always grouped by project, with pinned
  sessions leading each group (both subsets ordered by recency). The
  Pinned/Scheduled/Tasks category sections and their time-mode paging
  machinery (page-fill effect included) are deleted
- Scheduled sessions get an inline clock icon next to the pin position;
  pin + clock render together when both apply, and the running/unread
  status dot now coexists with them
- One font size (text-sm) across the list: titles, timestamps, project
  headers, show-more buttons, empty states. sidebarText needed !text-sm
  because the default button size's text-base wins the twMerge conflict
- Gradient fade under the Sessions header once the list scrolls, so rows
  fade out instead of hard-clipping
- The session-detail hover card is controlled from the sidebar and closes
  on scroll (Radix receives no pointer events while scrolling, so it used
  to float over moving content)
- Sidebar min resize width raised 224->260px; the per-project show-more
  label truncates so its nowrap text can't force rows to overflow and clip
  timestamps at narrow widths

Navigation:
- Customize replaces the Plugins/Marketplace/Hooks/Rules/Tools sidebar
  entries; Schedules and Customize are hidden from the expanded settings
  nav (their top rows cover them) but stay reachable when collapsed
- The settings gear always opens General instead of resuming the last
  section; the Account no-op hover special case is gone
- The New row highlights (aria-current) while the fresh not-yet-started
  task page is showing and hands off to the session row once the task
  starts; hitting New also focuses the prompt input via a window-event
  signal (lib/prompt-input-focus.ts) since the sidebar and composer sit in
  distant subtrees
- Fixed the xs button size collapsing any icon-bearing button to 12x12
  (leftover has-[>svg]:size-3 from when xs was a micro button) — this was
  why Uninstall buttons rendered broken next to Install

* feat(desktop): polish settings pages and chat composer

Models page:
- The provider detail panel is always open: no X button, no empty
  no-selection state. It defaults to the first connected provider (falling
  back to the first in the catalog), which also removes the layout shift
  that happened when the page swapped between full-width and panel
  variants on selection
- Fixed the list pane becoming unscrollable while the panel was open:
  grid items default to min-size auto, so the pane grew past its track
  inside the overflow-hidden grid and its ScrollArea had nothing to
  scroll; wrapped it in a min-h-0 min-w-0 cell
- Add Provider opens a Dialog instead of swapping the page
  (AddProviderContent gained a dialog variant that renders only the form)
- Embedded inputs (provider search, model search, detail fields) share one
  EMBEDDED_INPUT_CLASS stripping the Input component's own border/dark bg
  tint/shadow/ring, which rendered as a mismatched inner box; the model
  search box uses the same h-9/px-3 frame as the provider search
- Model list flows with the page instead of a max-h capped inner scroller

Other pages:
- Account uses the shared PageFrame/PageHeader: left-aligned, text-3xl
  title, Sign Out in the header actions slot
- Desktop notifications is one General section: header row plus the
  Event/Notify/Sound matrix nested in a card, so its rows no longer read
  as top-level peers of Dark mode; 'Available in the desktop app' label
  removed
- Schedule page retitled from Schedules with a real description; Customize
  description rewritten

Chat composer:
- The voice dictation button only renders once a voice model is
  configured (Settings -> Voice); the unconfigured deep-link state is
  gone (prop type kept for an easy restore)
2026-08-25 01:43:58 -07:00
Saoud RizwanandSaoud Rizwan 8a6c6f8afe Redesign desktop Model Providers page and split voice input into its own settings page (#13531)
* Redesign desktop Model Providers page and split voice input into its own settings page

- Group providers into Connected / Popular / All with auth-kind hints and
  connection status instead of per-row enable toggles
- Show browser sign-in (not an API key field) for OAuth providers, with a
  collapsed manual-key escape hatch where supported, plus explicit
  Connect / Disconnect / Sign out actions
- Move voice input to a dedicated Settings > Voice page that only offers
  connected transcription-capable providers, preselects a default model
  (streaming preferred), and stays disabled in the sidebar until a
  provider is connected

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

* Show native tooltip on the disabled Voice settings nav item

Disabled buttons drop pointer events, so the 'connect a model provider'
hint moves to a wrapping span for the browser tooltip to render.

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

* Drop letter avatars and gray provider ids from provider rows and voice chips

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

* Drop model counts from provider list rows

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

* Rename provider Connected status to Configured and drop the green styling

A settings entry is configuration, not a live connection; neutral gray
text avoids implying an active link, since the user still picks which
configured provider to use per chat.

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

* Resync provider catalog from disk when a settings save fails

Connect/disconnect/credential edits update the list optimistically; a
failed save now reloads the catalog instead of leaving the optimistic
state (and the view's module cache) claiming a configuration that was
never persisted.

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

* Rename oauthProvider test fixture to dodge CodeQL name heuristic

CodeQL's clear-text-storage query flags any identifier matching 'oauth'
as a credential source and traced the fixture's provider id into the
favorite-models localStorage write, which stores only provider/model id
strings. Renaming the fixture removes the false-positive source.

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

* Guard catalog reloads against races and resync detail drafts on failed saves

Optimistic provider mutations now bump a generation that discards any
in-flight catalog response, so a failed-save recovery reload can't
overwrite a newer edit with an older disk snapshot. The recovery also
remounts the provider detail panel via a reset token so its local field
drafts reflect the reloaded on-disk state instead of unpersisted edits
or an optimistically cleared disconnect.

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

* Fix failed-save recovery ordering and retry superseded reloads

Remount the provider detail only after the authoritative catalog reload
lands, so its drafts re-seed from disk state rather than the optimistic
values that failed to persist. When a concurrent edit supersedes the
recovery's in-flight response, retry the reload (bounded) instead of
dropping it, since that edit performs no reload of its own.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 23:54:09 -07:00
Saoud RizwanandSaoud Rizwan 4f5f238407 Desktop app: organize sidebar sessions into Pinned, Scheduled, and Tasks sections (#13528)
* Add Pinned/Scheduled/Tasks categories to desktop app sidebar

Replace the Schedules and Favorites filter-menu options with visible
collapsible category sections in the session sidebar, and rename the
Favorite action to Pin across the sidebar and sessions view.

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

* Grow full history window when Tasks show-more outpaces loaded tasks

loadMoreSessions treats its argument as a limit on all sessions, but the
Tasks show-more count only tracks Task rows, so once pinned/scheduled
rows pushed the loaded total past the requested count the call no-oped
and clicks went dead. Grow the whole history window via
loadOlderSessions instead, and only when the loaded tasks cannot fill
the next page. Addresses Greptile review on #13528.

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

* Auto-fill the Tasks page instead of fetching once per show-more click

A single 50-session window growth can consist entirely of pinned or
scheduled sessions, leaving a show-more click with no visible Tasks
progress. Replace the one-shot fetch with a page-fill effect that keeps
growing the history window until the requested Tasks page fills or
history runs out. Addresses the follow-up Greptile review on #13528.

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

* Halt page-fill retries after a failed history fetch

A failed fetch leaves the task count and has-more state unchanged,
which are exactly the conditions the page-fill effect fires on, so one
failing request would retry and re-toast forever. Halt the effect after
a failure and let the next explicit show-more click retry. Addresses
the third Greptile review on #13528.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 22:51:00 -07:00
Saoud RizwanandSaoud Rizwan 8f69880ac4 Hide Channels and Agents sections from desktop app sidebar (#13527)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 21:54:59 -07:00
Saoud RizwanandSaoud Rizwan a0c341e93c desktop: sidebar navigation cleanup with New/Schedule/Customize rows and dialog-based search (#13533)
* desktop: clean up sidebar navigation chrome

- Give New Task its own full-width labeled row below the logo row
  instead of an ambiguous icon next to the agenda toggle
- Wire the New Task row to the home action so starting a new task
  clearly takes you home (the logo still works as a fallback)
- Swap back/forward chevrons for browser-style arrow icons and
  bump their size

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

* desktop: sidebar New/Schedule/Customize rows and always-visible search

- Stack New (plus icon), Schedule, and Customize as full-width labeled
  rows below the logo; whole row highlights on hover via sidebarItem
- New starts a fresh task (home), Schedule opens Settings > Schedules,
  Customize opens the Customizations sections (Plugins first)
- Show the session search bar permanently above the sessions list
  instead of hiding it behind a search icon toggle

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

* desktop: move session search into a dialog behind a logo-row icon

- Replace the inline sidebar search bar with a search icon in the
  logo row that opens a cmdk command dialog listing sessions
- Selecting a result opens that session and closes the dialog
- Remove the agenda/tasks toggle the icon replaces, along with the
  now-unreachable sidebar Agenda panel (the welcome screen still
  surfaces agenda tasks)

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

* desktop: load full session history when the search dialog opens

Addresses Greptile review on #13533: the dialog only searched the
currently loaded history batch, so older unloaded sessions could not
be found. Opening search now kicks off loadAllSessions() (the hook's
purpose-built global-search loader), and the empty state reads
'Searching older sessions...' while more history is streaming in.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 21:53:47 -07:00
Saoud RizwanandSaoud Rizwan f91af30401 Add Desktop App and Cloud Platform to bug report issue template (#13532)
* Add Desktop App and Cloud Platform to bug report surfaces

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

* Rename Surface Diagnostics field to Diagnostics

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 19:49:47 -07:00
Saoud RizwanandSaoud Rizwan 83b2588c9c Disable the agent todo tool and hide the Agenda UI in the desktop app (#13530)
* remove todo tool and Agenda UI, keep schedule-only tasks tool

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

* chore: biome formatting fixes

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

* restore agenda backend; disable todo kind behind a flag instead of deleting

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

* keep agenda automation pump idle while the todo tool is disabled

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

* remove todo tool and Agenda UI altogether (revert the disable-flag hybrid)

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

* restore all agenda code to main state

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

* disable agent todo tool and hide Agenda UI behind flags

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 19:35:51 -07:00
Saoud RizwanandSaoud Rizwan 6e09e81a79 Add suggested schedule templates to the desktop Schedules page (#13529)
* Add suggested schedule templates to desktop Schedules page

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

* Fix unreadable selected text in inputs caused by selection utility conflict

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

* Restyle Suggested section label as small gray uppercase

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

* Hide suggested schedule cards that match an existing schedule name

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 18:14:01 -07:00
Saoud RizwanandSaoud Rizwan dc43a57fd7 fix(tools): create new files with the platform-native line ending (#13521)
* fix(tools): use platform-native EOL for new files and preserve CRLF in apply_patch updates

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

* simplify to the minimal new-file EOL fix

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

* extract shared normalizeNewFileLineEndings helper

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 17:54:54 -07:00
Saoud RizwanandSaoud Rizwan 833cc891b5 chore(vscode): hide per-tool MCP auto-approve checkboxes behind a flag (#13522)
* chore(vscode): remove per-tool MCP auto-approve checkboxes from webview

MCP auto-approval is now governed solely by the global 'Use MCP servers'
toggle; the SDK approval path (shared with the CLI and desktop app) has no
per-tool granularity, so the per-tool and 'Auto-approve all tools'
checkboxes were no-ops that implied control that no longer exists. Remove
them from the MCP settings view and chat tool rows. The autoApprove arrays
in cline_mcp_settings.json and the toggleToolAutoApprove RPC are left
intact for the legacy extension.

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

* chore(vscode): hide per-tool MCP auto-approve checkboxes behind a flag

Keep the checkbox components, handlers, and RPC plumbing intact but gate
rendering behind SHOW_MCP_PER_TOOL_AUTO_APPROVE=false: the SDK approval
path (shared with the CLI and desktop app) is all-or-nothing via the
global 'Use MCP servers' toggle, so the per-tool checkboxes were no-ops.
Flip the flag back on if the SDK gains per-tool approval granularity.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 17:47:15 -07:00
Saoud RizwanandSaoud Rizwan a2fb1d15ca fix(core): prevent search_codebase from crashing the process on giant single-line files (#13525)
* fix(core): prevent search_codebase from crashing the process on giant single-line files

searchWithRipgrep buffered all of rg's --json stdout into one string. Each
JSON event embeds the full text of the matched line (--max-columns is
ignored in JSON mode), so searching a directory of serialized trace dumps
(single-line multi-hundred-MB JSON files) accumulated gigabytes of stdout
until string concatenation threw RangeError: Out of memory inside the
stream data handler. That throw is outside the tool's try/catch, so it
escalated to an uncaughtException and killed the CLI/hub daemon.

Parse rg's JSON events incrementally line by line, drop events larger
than 256KB, truncate matched/context lines to MAX_LINE_CHARS, and stop
reading once maxResults is reached. The fallback regex scan now skips
files larger than 10MB (reporting the skip count) and truncates its
context lines the same way.

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

* simplify search_codebase crash fix to a minimal diff

Replace the incremental JSON-event parser with three small guards: stop
buffering rg stdout past 10MB, drop the trailing partial event before
parsing, and slice fallback context lines to MAX_LINE_CHARS. Drops the
fallback file-size skip and skip-count reporting.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 17:45:59 -07:00
Saoud Rizwan 8e7a55498b chore(cli): release v3.0.58 2026-08-24 15:44:16 -07:00
Saoud Rizwan 0cfc901589 fix(hub): flush the /shutdown 202 before daemon teardown
The /shutdown handler queued teardown on a microtask, which runs before
the event loop's write phase, so the daemon could process.exit() before
the accepted 202 was handed to the socket. Unix masked it (uv_try_write
lands small loopback writes synchronously); Windows has no such fast
path and lost the race regularly — the recurring shutdown.e2e.test.ts
'socket hang up' failures on windows-latest. Start teardown from the
response's write callback instead, with an idempotent 1s fallback so a
client that vanishes mid-write cannot strand the daemon, and send
Connection: close so the client gets a FIN rather than an abort.

Since the flakiness this compensated for is fixed at the source, restore
maxWorkers: 2 for the Windows core suite (serializing it cost ~3 min of
CI per run), and raise the e2e daemon discovery hang guard 10s→30s —
it guards against hangs, not runner speed.
2026-08-24 15:43:44 -07:00
Saoud RizwanandSaoud Rizwan a5c3181b78 fix(vscode): don't steal last-used provider from ClinePass on credential refresh (#13520)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 15:18:04 -07:00
Saoud Rizwan 8cb60caf0c chore(sdk): release v0.0.79 2026-08-24 14:55:30 -07:00
Mikołaj Kondratek c03e452315 fix(sdk): carry root overrides into the Node smoke-test sandbox (#13517)
ci-node-smoke.ts installs the packed SDK tarballs with a plain npm
install in a fresh temp dir, where the repo root package.json overrides
do not apply. When @sap-cloud-sdk 4.9.0 shipped (2026-08-24) it broke
@sap-ai-sdk/ai-api 2.14.0 (via @jerome-benoit/sap-ai-provider in
@cline/llms) with ERR_PACKAGE_PATH_NOT_EXPORTED, failing the smoke step
on every PR even though the root already pins @sap-cloud-sdk/* to 4.6.0.

Copy the root overrides block into the generated sandbox package.json
so the smoke install resolves the same pinned versions as the repo and
future third-party releases cannot break it independently.
2026-08-24 23:49:52 +02:00
Saoud RizwanandSaoud Rizwan 83ff8fd2f5 fix(hub): cap hub-events db size so it can't fill the disk (#13516)
* fix(hub): cap hub-events db size so it can't fill the disk

Row/time retention alone didn't bound disk usage: envelopes carrying
full session snapshots reach hundreds of KB each, so retained rows
could total tens of GB, sweeps only ran hourly, and DELETE never
shrinks a SQLite file. Enforce a 64 MiB size budget in prune() (oldest
rows first, VACUUM to return the space), and also prune after every
16 MiB appended so bursts can't outrun the hourly timer.

Fixes #13505

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

* fix(hub): tolerate VACUUM failure on a full disk

VACUUM needs scratch space and can fail in exactly the state a
ballooned event log causes. The byte-budget deletes already bound live
data, so swallow the error and let the next sweep retry the reclaim
instead of aborting startup pruning and disabling the durable log.

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

* fix(hub): count the size budget in UTF-8 bytes, not characters

envelopeJson.length (UTF-16 units) and SQLite LENGTH() (characters)
undercount multibyte text by up to 3x, which could leave a CJK-heavy
log settled above budget and re-running VACUUM every sweep. Use
Buffer.byteLength and LENGTH(CAST(... AS BLOB)) instead.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-24 14:47:26 -07:00
Mikołaj Kondratek 397a6a3341 fix(vscode): resolve hook workspace identity from the window, not shared global state (#13352)
* fix(vscode): resolve hook workspace identity from the window, not shared global state

Hook discovery, hook cwd selection, and the workspaceRoots metadata passed
to hook scripts all read the workspaceRoots/primaryRootIndex global state
keys. Global state lives in ~/.cline and is shared by every Cline instance
(all VS Code windows, the CLI, the JetBrains plugin), and nothing writes
these keys anymore, so hooks resolved against whatever project some other
or older instance last recorded. With a second window open on another
project, a workspace's .clinerules/hooks scripts were never discovered.

Resolve workspace roots via a single guarded helper backed by
HostProvider.workspace.getWorkspacePaths() (in-process, window-scoped,
same as refreshHooks): blank paths are filtered, a host-bridge failure
degrades to no workspace roots instead of silently disabling global hooks
or skipping blocking PreToolUse guards, and one resolution is threaded
through hooks-dir discovery, cache misses, cwd selection, and hook input
metadata so they can't disagree (previously up to four host lookups per
hook execution — real gRPC round trips in the standalone host). Roots and
hooks dirs are matched on whole path segments with the longest root
winning, so prefix-sharing or nested workspace roots resolve to the right
project. The adapter creates the runner once per event and skips no-op
runners, making creation the single resolution point; the separate
hasHook/getHookInfo checks are removed. The dead workspaceRoots and
primaryRootIndex state keys are dropped, and the four hand-rolled
HostProvider.workspace test stubs are consolidated into one shared
helper.

* test(vscode): add e2e coverage for workspace-scoped hook discovery

Boots real VS Code with the packaged extension against the workspace
fixture, sends a prompt, and asserts the fixture's UserPromptSubmit hook
was discovered from the open window's workspace, executed with that
workspace root as its cwd, and received the same root in its
workspaceRoots input — the end-to-end contract the hook workspace
identity fix establishes.

* test(vscode): isolate the e2e hook fixture from the shared workspace

The UserPromptSubmit fixture hook lived in the shared e2e workspace, so
every prompt-sending spec executed it (hooksEnabled defaults to true) —
and its cold PowerShell spawn on Windows pushed chat.test.ts past the
5s expect timeout. hooks.test.ts now overrides workspaceDir to a
dedicated workspace-hooks fixture, so only the hooks spec pays the hook
spawn.
2026-08-24 23:28:11 +02:00
Saoud Rizwan c9b75155ea fix(cli): remove the $4.99 ClinePass promo copy (#13514)
The $4.99 first-month promo is ending, so the CLI's first-launch "Try ClinePass" dialog should no longer advertise it. Also drops the leftover CLI_PROMO_CODE plumbing, which has been an empty string since the promo-code flow was removed.
2026-08-24 13:09:42 -07:00
Saoud Rizwan 09ee902639 chore(vscode): release v4.1.15 2026-08-23 12:33:23 -07:00
Saoud RizwanandSaoud Rizwan 2b7b01328a fix(vscode): auto-approve all MCP tool calls when the MCP toggle is on (#13498)
* fix(vscode): honor MCP auto-approve settings for SDK tool calls

The SDK extension required both the global 'Use MCP servers' auto-approve
toggle AND each tool's per-tool autoApprove flag before silently approving
an MCP call, while the legacy extension treated them as either/or. Restore
the legacy OR semantics so toggling MCP auto-approve works again.

Also key toolPolicies by the registered SDK tool name (via
defaultMcpToolNameTransform, now exported from @cline/core) instead of raw
server__tool. Servers whose names contain sanitized characters (e.g.
marketplace names like github.com/user/repo) or exceed 64 chars produced
policy keys that never matched the registered tool, so those MCP tools ran
without any approval gate; the live auto-approve lookup now re-applies the
transform instead of string-splitting the name.

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

* Revert "fix(vscode): honor MCP auto-approve settings for SDK tool calls"

This reverts commit 86c568fbba.

* fix(vscode): auto-approve all MCP tool calls when the MCP toggle is on

The SDK extension only auto-approved an MCP call when the global 'Use MCP
servers' auto-approve toggle AND that tool's per-tool autoApprove flag were
both set, so toggling MCP auto-approve appeared to do nothing and users had
to opt in each tool individually. The toggle alone now governs all MCP
tools; the per-tool flag is no longer consulted.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-23 12:12:32 -07:00
Saoud Rizwan be8b984d10 chore(vscode): release v4.1.14 2026-08-23 02:41:04 -07:00
Saoud Rizwan c870116d1d fix(telemetry): emit task.completed from every session teardown path (#13489)
The task.completed fallback lived only inside shutdownSession, but
stopSession/dispose route interactive sessions with a terminal reported
status through releaseSessionRuntime, which never emitted. Truthful
session-status reporting (shipped in 4.1.11) re-routed a large share of
interactive stops onto that branch and silently dropped the event.

Route the emission through a single choke point,
emitTaskCompletedOnTeardown, called from both shutdownSession and
releaseSessionRuntime. The completion criterion no longer reads
session.status: interactive sessions use the recorded final-turn
outcome (lastInteractiveTurnFinishReason), non-interactive sessions
keep the existing input.status === "completed" logic. A new
taskCompletedEmitted flag (also set by the submit_and_exit observer)
enforces exactly one task.completed per session. failSession now
records the errored final turn so a stale "completed" from an earlier
turn can never leak into the teardown emission. Telemetry only; no
user-facing behavior changes.
2026-08-23 02:34:26 -07:00
Saoud Rizwan 4f836ae7d0 test(sdk): give windows-sensitive suites realistic timeouts
Four consecutive SDK publish runs failed on windows-latest, each on a
different test, all of them plain timeouts: two @cline/shared SQLite
tests at the 5s vitest default, core's bash executor at 10s, and the hub
singleton endpoint test at 10s. The 2-core Windows runner spawns forks
and takes SQLite locks slowly enough to blow those budgets under load.

These timeouts guard against hangs; they are not timing assertions (the
one suite that does assert elapsed time, shutdown.e2e, was fixed by
removing file-level parallelism instead). Raise core to 20s and give
@cline/shared an explicit 15s in place of the inherited 5s default.
2026-08-22 16:39:19 -07:00
Saoud Rizwan 6cb653a362 chore(desktop): release v0.0.16 2026-08-22 16:34:23 -07:00
Saoud Rizwan 5077fe8697 fix(core): run hub e2e files serially so daemon timing budgets survive CI contention
singleton.e2e.test.ts (added in #13468) spawns real daemons and runs for
~15s. Vitest's default file parallelism let it run alongside
shutdown.e2e.test.ts, whose assertions are wall-clock bound: discovery
within 10s, exit within 5s, and a 2s shutdown watchdog. On the 2-core
windows-latest runner that contention alone broke those budgets, failing
the shutdown test two different ways across runs — once never observing
discovery, once with the daemon forced to exit before its HTTP 202
flushed (socket hang up). The test passed on Windows before #13468 and
has failed every SDK publish run since.
2026-08-22 15:18:04 -07:00
Saoud Rizwan 2266fe8cf4 chore(cli): release v3.0.57 2026-08-22 13:25:40 -07:00
Saoud Rizwan 21cb8d2525 chore(sdk): release v0.0.78 2026-08-22 13:03:18 -07:00
Saoud Rizwan 68ad354b52 chore(vscode): prepare 4.1.13 release 2026-08-22 12:50:54 -07:00
Saoud RizwanandSaoud Rizwan e098a8ed0d fix(core): stop stored capability lists from silently revoking tool calling for custom models (#13476)
* fix(core): seed tools capability when custom model capabilities are synthesized from boolean flags

For a models.json entry with no explicit capabilities list, toStoredModelInfo
synthesized a capability array purely from boolean convenience flags (e.g.
supportsReasoning: true -> ["reasoning"]). modelSupportsToolCalling fails open
only for a missing or empty list, so the synthesized non-empty list read as an
authoritative denial and silently stripped every tool definition from requests
to custom OpenAI-compatible models (#13463).

Seed "tools" whenever the list was not explicitly authored and the boolean
projections made it non-empty, preserving the fail-open contract. Explicitly
authored capability lists remain authoritative and can still disable tools.

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

* test(core): cover stale catalog capability overrides

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

* fix: treat stored capability lists as non-authoritative for tool calling

The hasExplicitCapabilities guard still let two producers of tool-less
lists through:

- The VS Code legacy-override migration (legacyModelInfoToOverrides)
  persists explicit partial lists like ["prompt-cache"] into models.json
  for custom OpenAI-compatible models, which then read as an authoritative
  "cannot call tools" and drop every tool - same symptom as #13463.
- Any hand- or UI-authored partial list on a non-catalog model.

Stored entries and user-authored provider metadata have no way to declare
"cannot call tools" (there is no supportsTools field, and every writer
that authors a full list includes "tools"), so seed "tools" into any
non-empty list for a language model. Only generated catalog capabilities
remain authoritative - a genuine no-tools catalog model stays that way -
and non-language models (e.g. image generation) never gain a tools claim.

Also make legacyModelInfoToOverrides write "tools" into the arrays it
fabricates, matching the providers.json migration, so models.json stops
being poisoned for older readers.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-22 12:42:09 -07:00
1de61b178a feat(hub): add drain and upgrade commands with replay support (#13468)
* feat(hub): add drain and upgrade commands with replay support

* handles disconnection

* feat(hub): wire bot profiles, drain, and durable event/run-queue into the live transport

Completes the wiring the previous commits' primitives needed:
HubServerTransport gains isDraining(), hub.drain/hub.status/profile.get
command handling, and replayEventsAfter() (backed by the durable event
log), plus the sequence/sinceSequence wire types they depend on in
shared/hub.ts. run-queue-handlers.ts reads the active bot profile's
plugin roots when executing durable runs.

Also adds hub/profiles/: profile.json (identity/rules/plugins) ->
system prompt composition, --profile / CLINE_HUB_BOT_PROFILE
resolution, and the bundled cline-dad profile with its
cline_hub_support read-only diagnostics tool.

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

* Revert "feat(hub): wire bot profiles, drain, and durable event/run-queue into the live transport"

This reverts commit 6696d5d202.

* fix(hub): dedupe replayed events by eventId, not just sequence

HubEventLogStore.append() returns a new envelope stamped with a
sequence rather than mutating the input, so a pending approval
re-issued sequence-less by subscribe() (it predates any durable-log
append) and its later sequence-stamped copy from the durable log are
two different objects carrying the same eventId. The replay-then-live
buffer in browser-websocket.ts only deduped by sequence, so the
sequence-less copy's guard never tripped and it was delivered a second
time when the buffer flushed after replay.

Track delivered eventIds alongside the sequence cursor; eventId
survives the append/stamp round-trip unchanged, so this dedupes the
exact-same logical event regardless of which copy arrives first.

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

* fix(hub): wire drain, durable event log, and run queue into the live transport

CI on this branch failed bun run build:sdk: browser-websocket.ts,
client/index.ts, and hub-websocket-server.ts (already on this branch)
reference sequence/sinceSequence, HubServerTransport.isDraining(), and
the "hub.drain" command — but the commit that reverted bot profiles
out of this branch also reverted this wiring, since it shared a commit
with the profiles work. That wiring is a hub concern, not a
bot-profiles one; split it back out.

- shared/hub.ts: sequence/sinceSequence types, run.enqueue/run.list/
  hub.drain/hub.status/stream.replay capability, command, and event
  names. profile.get intentionally excluded — stays bot-profiles-only.
- context.ts: isDraining() on HubTransportContext. botProfile field
  intentionally excluded.
- hub-server-transport.ts: eventLog/runQueue fields and start/stop
  lifecycle, publish() appends to the durable log, handleCommand cases
  for run.enqueue/run.list/hub.drain/hub.status, drain-refusal check,
  replayEventsAfter()/lastEventSequence(). startBotProfile()/
  startHubSupportTool() and the profile.get case intentionally
  excluded.
- run-queue-handlers.ts: added without handleProfileGet (needs
  ctx.botProfile, which doesn't exist here).
- hub-upgrades.test.ts: added without its two bot-profile-injection
  tests (they need a resolved bot profile to assert against).

Verified bun run build:sdk exits 0 (the exact CI command) and
bunx vitest run src/hub passes (311/312; the one failure is the
same pre-existing environment-timing flake already present before
this change).

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

* fix(hub): export instance-lock, event-log, and run-queue from the hub barrel

These landed as internal modules only; hub-server-transport.ts and
hub-websocket-server.ts import them by direct path, but nothing
re-exported them from the public @cline/core/hub surface the way
sibling discovery/server modules already are.

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

* fix(hub): wire the instance lock into the daemon entry point

The singleton lock (discovery/instance-lock.ts) and its consumption in
startHubWebSocketServer/ensureHubWebSocketServer were already on this
branch, but the daemon entry point's own half was not: retrying a bind
when a retiring predecessor still holds the lock, and exiting with a
distinct code (3) instead of the generic fatal path when a live Hub
already owns the data directory. Without this, a daemon racing a
retiring predecessor could fail outright instead of waiting the lock
out, and losing the singleton race looked identical to a crash.

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

* fix(hub): address drain/upgrade review findings (#13478)

- cline hub upgrade: check idleness at least once (--wait 0 works), reject
  non-numeric --wait, and un-drain on every abort path so an aborted
  upgrade can never leave the hub refusing new work
- add cline hub drain --off and the off query param to requestHubDrain so
  POST /drain?off is reachable from shipped code
- HubEventLogStore/HubRunQueue: WAL journal mode + busy_timeout, and stamp
  sequences from lastInsertRowid instead of SELECT MAX(sequence)
- HubInstanceLock.acquire: degrade to an unheld lock when SQLite is
  unavailable instead of refusing hub startup; only BUSY/LOCKED still
  raises HubLockHeldError
- ensureHubWebSocketServer: retire an unusable discovered hub through the
  shared retireDiscoveredHub (busy hubs are attached to, drain precedes
  shutdown, discovery cleared only when the hub actually retired)
- replay adapter: advance the cursor past eventId-deduped events, cap
  replay pages, stop when the cursor stalls, and drop the dedupe set after
  the buffered flush so it cannot grow for the socket lifetime

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(hub): derive the singleton e2e challenger cwd portably

The challenger's working directory was derived by round-tripping the
discovery path through a file: URL and stripping the last pathname
segment. On Windows that yields a POSIX-style '/C:/...' path, which is
not a valid spawn cwd, so the spawn fails ENOENT before the singleton
lock is ever contested and the Windows SDK test job goes red.

The data dir is simply the discovery file's parent: use dirname().

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 21:49:21 -07:00
Saoud Rizwan e7ed29109b ci(vscode): make combined nightly manual-dispatch only
The PublishNightly environment gained required reviewers, so each cron
run parked on approval, held the workflow's concurrency group, and
silently cancelled every scheduled run queued behind it. 20 consecutive
scheduled nightlies died this way between 2026-07-31 and 2026-08-21;
the only nightlies that shipped in that window were manual dispatches.

Drop the cron rather than leave a trigger that cannot succeed unattended.
2026-08-21 17:01:56 -07:00
BeeandCursor Agent 9316de6bb5 fix: propagate Langfuse session telemetry (#13473)
* fix telemetry session propagation

* feat telemetry client version metadata

* fix(core): address Langfuse review feedback — hub client identity + delegated agent session grouping (#13475)

* fix(core): rebuild hub session client identity from request headers

Hub-backed sessions do not transport extensionContext (it is local-only),
so the daemon's runtime built traces without the clientName/clientVersion
metadata even though the hub client bakes X-CLIENT-TYPE / X-CLIENT-VERSION
into the session's provider headers. Reconstruct extensionContext.client
from those headers during local runtime bootstrap so hub-backed Langfuse
traces carry the same client identity as local runtimes, and the daemon's
header re-resolution stops clobbering the original X-CLIENT-TYPE.

* fix(core): propagate parent distinctId/sessionId to delegated agents

Delegated agents (spawned sub-agents, configured agents, teammates) were
built without distinctId and sessionId, so their Langfuse traces had no
userId or sessionId and did not group with the parent user or session.
Thread the host-resolved distinctId through RuntimeBuilderInput and the
root sessionId through the delegated-agent config provider, and copy both
onto the delegated AgentConfig.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-21 16:34:21 -07:00
Tomás Barreiro fb58e340a2 Add feature flags to the desktop app (#13289)
* Add feature flags to the app

* React to account updates

* Address comments

* use a per-app file
2026-08-22 00:25:30 +02:00
Saoud Rizwan db6d18a98a chore(vscode): prepare 4.1.12 release 2026-08-21 13:53:56 -07:00
2ea460fa46 Treat an empty preserved capability list as unspecified when seeding tools (#13465)
* Treat an empty preserved capability list as unspecified when seeding tools

toSdkModelInfo guarded the tools seeding with a strict
preservedCapabilities === undefined check, but modelHasCapability —
the runtime's own reader — treats undefined AND length === 0 as
"unspecified". A custom OpenAI-Compatible model whose stored
capabilities field is a defined-but-empty array (a config carried over
from before the field existed, or one round-tripped through a boundary
that defaults it to []) skipped the seeding; the first boolean
projection to run afterwards (e.g. supportsReasoning) then populated
the array, the runtime gate read the non-empty, tool-less list as
authoritative, and every tool definition was silently dropped from the
session (#13463).

The guard now covers the empty array too, matching the reader's
unspecified semantics.

* test: satisfy the store's isModelInfo gate so the empty-capabilities case actually reaches knownModels

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

---------

Co-authored-by: yzxcj797 <yzxcj797@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-08-21 13:42:01 -07:00
Saoud RizwanandSaoud Rizwan 7d366ce7d4 fix(vscode): remote config MCP settings (#13466)
* fix(vscode): enforce enterprise MCP controls on the Customize marketplace

The unified Customize marketplace replaced the old MCP marketplace
without carrying over enterprise remote-config enforcement: the catalog
RPC returned every MCP entry and installs were never policy-checked,
so orgs with mcpMarketplaceEnabled=false or an allowedMCPServers
allowlist saw (and could install) all marketplace MCP servers.

- Filter MCP entries out of getMarketplaceCatalog when the marketplace
  is disabled, and restrict entries to the allowlist when configured
  (matching entry id, display name, installed server name, or source
  repo URL, mirroring legacy GitHub-URL allowlist ids)
- Reject installMarketplaceEntry requests that violate the policy
- Map the published catalog's repo/homepage fields onto
  sourceUrl/homepageUrl so URL-based allowlists can match
- Update the enterprise MCP server controls docs

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

* refactor: simplify MCP marketplace policy enforcement

Fold the policy check into marketplace-helpers, drop the dedicated
test suite, and trim the docs edit to the strictly necessary line.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 12:40:01 -07:00
Saoud Rizwan fb60f9e5fd chore(desktop): release v0.0.15 2026-08-20 22:42:08 -07:00
Saoud Rizwan 9e0015b78b chore(vscode): prepare 4.1.11 release 2026-08-20 22:07:44 -07:00
596 changed files with 64929 additions and 12695 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Warn when attached images will be ignored because the selected model does not support image input: image thumbnails get a warning badge and the composer offers to switch to an image-capable model, instead of the images being silently dropped before the API call
+1 -1
View File
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
The CLI is npm-only. Do not add alternate distribution or signing steps.
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
+4 -4
View File
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
## Release contract
@@ -21,7 +21,7 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
@@ -120,7 +120,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
+7 -3
View File
@@ -15,6 +15,8 @@ body:
- VSCode Extension
- JetBrains Plugin
- CLI
- Desktop App
- Cloud Platform
default: 0
validations:
required: true
@@ -62,13 +64,15 @@ body:
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
label: Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
- Desktop App: paste the app version from the Settings view.
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
validations:
required: false
- type: textarea
+155
View File
@@ -0,0 +1,155 @@
name: Sign Windows CLI binaries
description: >
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
signatures. If the Azure Trusted Signing secrets are not configured, the
action logs a warning and exits successfully so releases keep working while
signing infrastructure is being provisioned.
inputs:
azure-client-id:
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
required: false
default: ""
azure-tenant-id:
description: Entra tenant ID.
required: false
default: ""
azure-subscription-id:
description: Azure subscription ID containing the Trusted Signing account.
required: false
default: ""
endpoint:
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
required: false
default: ""
account:
description: Trusted Signing account name.
required: false
default: ""
certificate-profile:
description: Trusted Signing certificate profile name.
required: false
default: ""
files:
description: Newline-separated list of PE files to sign.
required: true
runs:
using: composite
steps:
- name: Check signing configuration
id: check
shell: bash
env:
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
run: |
missing=()
set_count=0
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
if [ -z "${!var}" ]; then
missing+=("$var")
else
set_count=$((set_count + 1))
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
echo "enabled=true" >> "$GITHUB_OUTPUT"
elif [ "$set_count" -eq 0 ]; then
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
# Partial configuration is almost certainly a typo'd or renamed
# secret. Fail loudly instead of silently publishing unsigned.
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
exit 1
fi
- name: Azure login (OIDC)
if: steps.check.outputs.enabled == 'true'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
subscription-id: ${{ inputs.azure-subscription-id }}
- name: Sign Windows binaries
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
FILES: ${{ inputs.files }}
JSIGN_VERSION: "7.5"
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
run: |
set -euo pipefail
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
echo "::add-mask::${JSIGN_STOREPASS}"
export JSIGN_STOREPASS
# jsign expects the endpoint host, not the URL. Tolerate both the
# portal's display form (trailing slash) and the bare form.
KEYSTORE="${SIGNING_ENDPOINT#https://}"
KEYSTORE="${KEYSTORE%/}"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Signing ${file}"
java -jar "$JSIGN_JAR" \
--storetype TRUSTEDSIGNING \
--keystore "$KEYSTORE" \
--storepass env:JSIGN_STOREPASS \
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
--alg SHA-256 \
--tsaurl http://timestamp.acs.microsoft.com \
--tsmode RFC3161 \
--replace \
"$file"
done <<< "$FILES"
- name: Verify signatures
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
FILES: ${{ inputs.files }}
# Authenticode chains anchor to the Microsoft Identity Verification
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
# explicitly (pinned) for osslsigncode chain validation.
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
run: |
set -euo pipefail
if ! command -v osslsigncode >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq osslsigncode
fi
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Verifying signature on ${file}"
# Timestamp countersignature chain is checked separately by Windows;
# -ignore-timestamp only skips TSA chain validation here, not the
# Authenticode chain itself.
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
done <<< "$FILES"
+27
View File
@@ -190,6 +190,19 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with latest tag
env:
NPM_CONFIG_PROVENANCE: "true"
@@ -447,6 +460,20 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
if: steps.check_commits.outputs.skip != 'true'
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
+274 -1
View File
@@ -462,9 +462,282 @@ jobs:
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
build-windows:
name: Build Windows (x64)
needs: validate
# Same gate rationale as the macOS build job above. This job additionally
# needs id-token: write for Azure OIDC: Windows binaries are
# Authenticode-signed with Azure Trusted Signing, authenticated through the
# PublishDesktop-environment federated credential on the cline-cli-signing
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: windows-latest
timeout-minutes: 90
permissions:
contents: read
id-token: write
steps:
# All-or-nothing: an unsigned Windows desktop build is never acceptable
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
# unsigned installers), and Tauri would skip updater-artifact signing
# silently if the updater key were missing. Unlike the CLI pipeline
# there is no unsigned fallback here.
- name: Verify signing secrets are present
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing signing secrets for the Windows desktop build:"
printf ' - %s\n' "${missing[@]}"
echo
echo "The AZURE_* names are repository secrets; the TAURI_* names"
echo "live in the PublishDesktop environment. Refusing to build an"
echo "unsigned Windows desktop release."
exit 1
fi
echo "All Windows signing secrets are present."
# Every action in this job is SHA-pinned (unlike elsewhere in this
# file): they run with id-token: write and the updater signing key in
# scope, so a hijacked upstream tag must not be able to reach the
# signing identity or tamper with what gets signed and uploaded.
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
with:
# With a SHA-pinned action the toolchain no longer comes from the
# ref name, so it must be set explicitly.
toolchain: stable
# No Rust build cache, mirroring the macOS job: this job holds the
# updater signing key and an Azure signing session, and a restored cache
# archive is attacker-controlled if the Actions cache is poisoned.
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
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 }}
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
# NSIS uninstaller, and the installer itself). The overlay is generated
# here rather than committed because signCommand needs an absolute path
# to the signing script on this runner.
- name: Write signing config overlay
shell: bash
run: |
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
cat > "$SIGN_CONF" <<EOF
{
"\$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
}
}
}
EOF
cat "$SIGN_CONF"
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
- name: Build and sign desktop bundle
shell: bash
working-directory: apps/examples/desktop-app
# NSIS only: the MSI (WiX) target adds nothing for direct-download
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
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 }}
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
# Azure Trusted Signing; the token comes from the azure/login session)
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
# Updater artifact signing (minisign keypair, same key as macOS)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Same guardrail as the macOS job: assert the compiled binary embeds
# this channel's updater feed URL and not the other channel's. Checked
# on the unbundled main exe because NSIS compresses the installer
# contents, which defeats a string search on the installer itself.
- name: Verify updater feed endpoint
shell: bash
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
shell: bash
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- name: Collect artifacts
shell: bash
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
PREFIX="${PRODUCT// /-}"
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
if [ -z "$SETUP" ]; then
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
exit 1
fi
# The .sig is the updater (minisign) signature; without it the
# manifest generator cannot publish a windows-x86_64 entry.
if [ ! -f "${SETUP}.sig" ]; then
echo "updater signature missing next to $SETUP"
exit 1
fi
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
ls -lh "$OUT"
# Independent Authenticode gate on the exact artifact users download.
# The signing script already verifies each file it signs, but this step
# would still catch an installer that skipped signCommand entirely.
- name: Verify Authenticode signatures
shell: pwsh
working-directory: apps/examples/desktop-app
run: |
# The Tauri bundler signs the sidecar in place, so check it here too;
# a WDAC-locked machine blocks the app at runtime if the sidecar it
# spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
}
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
needs: [validate, build, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
+50
View File
@@ -0,0 +1,50 @@
name: desktop-test
on:
push:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
pull_request:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
dmg-background:
name: Test DMG background tooling
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/examples/desktop-app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
# The suite only uses Bun/Node built-ins and committed artwork, so it does
# not need a workspace dependency install or macOS runner.
- name: Test DMG background tooling
run: bun run test:dmg-background
@@ -14,9 +14,12 @@ name: ext-vscode-publish-nightly
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
# Manual dispatch only. The nightly cron was removed deliberately: the
# PublishNightly environment gained required reviewers, and an unattended
# cron run would just sit `waiting` on that approval, hold this workflow's
# concurrency group, and silently cancel every later scheduled run behind it
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
workflow_dispatch:
inputs:
legacy-ref:
@@ -74,8 +77,9 @@ jobs:
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
# NOTE: the || fallback is retained so this stays correct if a
# non-dispatch trigger is ever added back (inputs are empty strings
# on e.g. `schedule` events, where the declared default does not apply).
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
+1
View File
@@ -88,6 +88,7 @@ apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
+16 -2
View File
@@ -61,8 +61,9 @@ Emission ownership:
- `user.extension_activated`: emitted **once per host process** by host-specific helpers
(`captureCliExtensionActivated` for the CLI, `captureExtensionActivated` for VS Code).
- `workspace.initialized` / `workspace.init_error`: emitted by a per-process de-duplicated
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
- `workspace.initialized` / `workspace.init_error`: emitted by a de-duplicated emitter in
`prepareLocalRuntimeBootstrap`. A dedicated host emits once per workspace; a shared Hub emits
once per client surface and workspace. Hosts must NOT re-emit these.
- `workspace.path_resolved`: emitted from default tool executors **only when**
`WorkspaceManager` exposes more than one root.
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
@@ -104,6 +105,19 @@ hub-backed session, so the daemon must own its own `ITelemetryService`. It build
identifies from the cached cline account (re-resolved periodically, since the daemon often
starts before login) and flushes on every shutdown path, including startup failure.
The Hub transport forwards the serializable `ExtensionContext.client` and
`ExtensionContext.user` values with session create/restore requests. The daemon wraps its
process-owned service with `createClientScopedTelemetryService()` so lifecycle events use the
originating client's `cline_type`, platform/version, and current account/organization without
mutating the singleton shared by concurrent clients. Keep canonical task fields named
`provider` and `model`; do not reintroduce host-specific aliases such as `apiProvider` or
`modelId` for `task.created`, `task.restarted`, or `task.completed`.
`UserContext.distinctId` may be an anonymous machine ID. Set `UserContext.accountId` to the
authenticated account ID (or `null` for an explicitly signed-out client) whenever a client
forwards user context; this prevents machine IDs and stale daemon identity from becoming
`user_id` / `organization_id` on task events.
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
+127
View File
@@ -1,5 +1,132 @@
# Changelog
## [4.1.17]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- ClinePass is now surfaced across the app: a card on the account page describing what the plan covers, a hint in provider settings, and a banner on the home screen. Dismissed banners stay dismissed.
### Fixed
- Fixed the background Hub process ballooning in memory during long sessions. Session status updates broadcast a full copy of the conversation transcript to every connected client, so on a large task each status change shipped megabytes and could grow the process to tens of gigabytes. Snapshots now carry state only.
- Hook scripts that fail to spawn no longer crash the extension's core process and take the running task down with them.
- Fixed a chat render crash on malformed `api_req` payloads.
- Cost estimates no longer appear in task history for subscription-billed tasks (ClinePass, ChatGPT via Codex, and Claude Code), matching the task header.
- Pasted provider API keys are now stripped of the invisible characters clipboards smuggle in (newlines, zero-width spaces, BOM). A key corrupted that way was hidden by the masked field and rejected by the provider with a 401 indistinguishable from a genuinely wrong key. Credential rejections now say that the API key is the problem and point at its configuration, keeping the provider's raw response as a diagnostic tail.
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when callback port 1455 is occupied. Previously the button opened a browser to a flow whose callback could never arrive, and nothing else happened. OAuth redirect errors such as `access_denied` are surfaced instead of being reported as a missing authorization code.
- A transient network failure while refreshing OpenAI Codex or OpenAI-compatible-account tokens no longer signs you out. Only a genuinely rejected refresh token now requires re-authentication.
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request.
- Fixed images being dropped from file reads on models whose capability list is empty.
- Restoring a checkpoint now refuses to run when commits were made after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected.
- `apply_patch` now preserves a file's existing CRLF line endings.
- Global rules are now also read from `~/Cline/Rules`, which is where the Rules tab writes them on WSL and headless installs whose Documents folder resolves to the home directory.
- An enabled but unreachable remote (SSE or streamable HTTP) MCP server no longer stalls session startup; remote connects now have a 10 second budget.
- Aborting a task now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running.
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not ready in every published build while working in development.
- Cline provider models are now read from the live catalog, so newly published models appear without an extension update.
- Hook execution telemetry now fires; the task id was not threaded into hook runner creation, so those events were dropped.
### Changed
- Refreshed the built-in model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing throughout. This is an unusually wide refresh: the resolved default model changes for 57 providers, most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT following. If you use a provider without pinning a model, expect a different default.
- The message the model receives when you reject a tool call now names the rejected tool and reads as your decision rather than an error.
## [4.1.16]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
- New files are now created with your platform's native line endings.
- Fixed the codebase search tool crashing on files containing a single enormous line.
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
- The hub's event log can no longer grow until it fills your disk.
### Changed
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
## [4.1.15]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
## [4.1.14]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
### Fixed
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
## [4.1.13]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
## [4.1.12]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
## [4.1.11]
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
### Added
- Let models that support it generate images during a task. Generated images render inline in the conversation.
### Fixed
- Fix code actions failing with "command not found" on VS Code 1.134.
- Fix `@` file mentions breaking on paths that contain spaces.
- Show the diff edit view for multi-line edits in files with CRLF line endings.
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
- Honor the classic truncation range when migrating legacy tasks.
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
- Point provider signup links at each provider's API key page instead of a generic landing page.
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
- Stop offering image, voice, and other non-chat models in chat model pickers.
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
### Changed
- Show the billed cost for Cline gateway usage.
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
### Fixed (legacy bundle)
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
## [4.1.10]
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
+8 -12
View File
@@ -5,7 +5,7 @@
<h1 align="center">Cline</h1>
<p align="center">
The open source coding agent in your IDE and terminal.
The open source coding agent in your IDE, terminal, and desktop.
</p>
<div align="center">
@@ -57,17 +57,13 @@ npm i -g cline
</td>
<td align="center" width="50%">
### Kanban
### Desktop App
Run many agents in parallel from a
web-based task board. Each card gets its own
worktree, auto-commit, and dependency chains.
Cline as a native app for macOS and Windows.
Run agent sessions in any folder, schedule
routines, and manage models, plugins, and MCP servers.
```
npm i -g kanban
```
<a href="https://github.com/cline/kanban">Learn more</a>
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
<br><br>
</td>
@@ -108,7 +104,7 @@ the JetBrains family.
### SDK
Build your own AI agents and integrations powered by the same engine that runs the CLI, Kanban, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
Build your own AI agents and integrations powered by the same engine that runs the CLI, desktop app, VS Code extension, and JetBrains plugin. Custom tools, multi-agent teams, connectors, scheduled automations, and more.
```
npm install @cline/sdk
@@ -131,8 +127,8 @@ npm install @cline/sdk
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
## Edits Code Across Your Project
+47
View File
@@ -1,5 +1,52 @@
# Cline CLI Changelog
## 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
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not-ready in every published binary while working in dev
- Restoring a checkpoint now refuses to run when you have made commits after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected
- `apply_patch` now preserves a file's existing CRLF line endings
- Global rules are now also read from `~/Cline/Rules`, which is where the VS Code Rules tab writes them on WSL and headless installs
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
- Fixed scheduled tasks disappearing after a hub restart
- Fixed markdown flashing as it settled at the end of a streamed response
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
- Refreshed the model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
## 3.0.60
- The config screen now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugins can be enabled or disabled with Space; the Hub persists the state and their skills and MCP servers follow it when the interactive runtime is rebuilt
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing on files that contain a single enormous line
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 3.0.58
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
## 3.0.57
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
## 3.0.56
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
+3
View File
@@ -270,6 +270,9 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
### Windows
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
### Windows code signing
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
### File permissions
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
+23
View File
@@ -72,6 +72,29 @@ function run(target) {
});
if (result.error) {
console.error(result.error.message);
// Windows application control (Smart App Control, WDAC, AppLocker)
// blocks the child exe at launch, which Node surfaces only as an
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
const code = result.error.code;
if (
os.platform() === "win32" &&
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
) {
console.error(
"\nWindows refused to start the Cline binary:\n " +
target +
"\n\n" +
"This usually means an application control policy (Smart App Control,\n" +
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
"run the path above directly in a terminal and check the error Windows\n" +
"reports, or inspect its signature with:\n\n" +
' Get-AuthenticodeSignature "' +
target +
'"\n\n' +
"If it was blocked by policy, allow the file or ask your administrator\n" +
"to trust it. See https://github.com/cline/cline/issues for known issues.",
);
}
process.exit(1);
}
if (typeof result.status === "number") {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.56",
"version": "3.0.61",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+16 -1
View File
@@ -19,6 +19,7 @@ import {
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { createCliCore } from "../session/session";
import { loadInteractiveConfigData } from "../tui/interactive-config";
import type { CliOutputMode } from "../utils/types";
@@ -423,8 +424,20 @@ async function loadInteractiveConfigDataForCommand(
cwd: string,
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
const userInstructionService = createConfigUserInstructionService(cwd);
const core = await createCliCore({
backendMode: "auto",
cwd,
workspaceRoot: cwd,
});
try {
await userInstructionService.start();
const [, agentPluginSettings] = await Promise.all([
userInstructionService.start(),
core.settings.list({
cwd,
workspaceRoot: cwd,
includePluginTools: true,
}),
]);
return await loadInteractiveConfigData({
userInstructionService,
cwd,
@@ -432,9 +445,11 @@ async function loadInteractiveConfigDataForCommand(
availabilityContext: {
mode: "act",
},
agentPluginSettings,
});
} finally {
userInstructionService.stop();
await core.dispose("cli_config_command_complete");
}
}
+147
View File
@@ -3,16 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockLocalHubHasNoActiveSessions,
mockProbeHubServer,
mockReadHubDiscovery,
mockRequestHubDrain,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
mockClearHubDiscovery: vi.fn(),
mockEnsureDetachedHubServer: vi.fn(),
mockLocalHubHasNoActiveSessions: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockRequestHubDrain: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -27,8 +31,10 @@ const {
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
requestHubDrain: mockRequestHubDrain,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
@@ -95,6 +101,147 @@ describe("createHubCommand", () => {
});
});
function createCommand() {
const output: string[] = [];
const errors: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
);
return {
cmd,
output,
errors,
exitCode: () => exitCode,
};
}
it("sends an un-drain request with drain --off", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain", "--off"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain --off",
{ off: true },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: false,
url: "ws://127.0.0.1:25463/hub",
});
});
it("drains without the off flag by default", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain",
{ off: false },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
mockStopLocalHubServerGracefully.mockResolvedValue(true);
mockEnsureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(errors).toEqual([]);
expect(exitCode()).toBe(0);
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
// The drain was never lifted manually: the drained hub was replaced.
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toEqual({
upgraded: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
const { cmd, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(exitCode()).toBe(1);
expect(errors[0]).toContain("still serving sessions");
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub upgrade aborted",
{ off: true },
);
});
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
const { cmd } = createCommand();
cmd.configureOutput({ writeErr: () => {} });
for (const sub of cmd.commands) {
sub.configureOutput({ writeErr: () => {} });
}
await expect(
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
).rejects.toThrow("--wait requires a non-negative number of seconds.");
expect(mockRequestHubDrain).not.toHaveBeenCalled();
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
+125 -1
View File
@@ -1,14 +1,16 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
localHubHasNoActiveSessions,
probeHubServer,
readHubDiscovery,
requestHubDrain,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { Command, InvalidArgumentError } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
@@ -54,6 +56,16 @@ function resolveCliHubOwnerContext() {
: resolveSharedHubOwnerContext();
}
function parseWaitSeconds(value: string): number {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
throw new InvalidArgumentError(
"--wait requires a non-negative number of seconds.",
);
}
return parsed;
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -150,5 +162,117 @@ export function createHubCommand(
}),
);
hub
.command("drain")
.description("Refuse new mutating work while accepted runs finish")
.option("--reason <text>", "Why the hub is draining")
.option("--off", "Lift the drain and accept new mutating work again")
.action(
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
io.writeErr("No hub is running.");
fail();
return;
}
const draining = cmdOptions.off !== true;
const ok = await requestHubDrain(
discovery.url,
discovery.authToken,
cmdOptions.reason ??
(draining ? "cline hub drain" : "cline hub drain --off"),
{ off: !draining },
);
if (!ok) {
io.writeErr(
draining
? "Hub drain request failed."
: "Hub un-drain request failed.",
);
fail();
return;
}
io.writeln(JSON.stringify({ draining, url: discovery.url }));
}),
);
hub
.command("upgrade")
.description(
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
)
.option(
"--wait <seconds>",
"How long to wait for the hub to go idle",
parseWaitSeconds,
120,
)
.action(
action(async (cmdOptions: { wait: number }) => {
const opts = hub.opts<{
cwd: string;
host?: string;
port?: number;
pathname?: string;
}>();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url) {
const drained = await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade",
).catch(() => false);
// An aborted upgrade must hand the hub back: leaving it
// draining refuses all new mutating work until a restart.
const undrain = async (): Promise<void> => {
if (!drained) {
return;
}
await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade aborted",
{ off: true },
).catch(() => false);
};
try {
const deadline = Date.now() + cmdOptions.wait * 1_000;
let idle = false;
// Check at least once so --wait 0 still observes an idle hub.
for (;;) {
idle = await localHubHasNoActiveSessions(
discovery.url,
discovery.authToken,
).catch(() => true);
if (idle || Date.now() >= deadline) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
if (!idle) {
await undrain();
io.writeErr(
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
);
fail();
return;
}
await stopHubServer(opts.cwd);
} catch (error) {
await undrain();
throw error;
}
}
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(JSON.stringify({ upgraded: true, url }));
}),
);
return hub;
}
+2 -1
View File
@@ -2,7 +2,8 @@ import {
createDiscordAdapter,
type DiscordAdapter,
} from "@chat-adapter/discord";
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
// Note: discord.js@14 declares undici ^6.27.0, but the root package.json
// override ("undici": ">=7.29.0 <8") forces undici 7.x for CVE-2026-1525.
import type { ChatStartSessionRequest } from "@cline/core";
import {
createUserInstructionConfigService,
@@ -71,7 +71,6 @@ export function MigrationNoticeContent(
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
<text selectable>Try it now with a limited-time promo for $4.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
@@ -17,6 +17,7 @@ import {
import {
applyPluginFailures,
type InteractiveConfigItem,
isToggleableInteractiveConfigItem,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
import { createInteractiveConfigDataLoader } from "./config-data";
@@ -113,6 +114,172 @@ describe("interactive config data loader", () => {
return pluginPath;
}
it("merges the hub-owned Agent Plugin inventory into the config view", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-plugin-"));
tempRoots.push(tempRoot);
const pluginRoot = "/remote/home/.agents/plugins/portable-review";
const calls: unknown[] = [];
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
loadCoreSettings: async (input) => {
calls.push(input);
return {
workflows: [],
rules: [],
tools: [],
plugins: [
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: true,
toggleable: true,
agentPlugin: true,
},
],
skills: [
{
id: "portable-review:review",
name: "review",
path: `${pluginRoot}/skills/review/SKILL.md`,
kind: "skill",
source: "global-plugin",
enabled: true,
toggleable: false,
agentPlugin: true,
pluginName: "portable-review",
pluginPath: pluginRoot,
},
],
mcp: [
{
id: "portable-review.docs",
name: "portable-review.docs",
path: `${pluginRoot}/mcp.json`,
kind: "mcp",
source: "global-plugin",
enabled: true,
toggleable: false,
agentPlugin: true,
pluginName: "portable-review",
pluginPath: pluginRoot,
},
],
};
},
});
const data = await loader.loadConfigData({ includePluginTools: false });
expect(calls).toEqual([
expect.objectContaining({
includePluginTools: false,
}),
]);
expect(data.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "portable-review",
agentPlugin: true,
toggleable: true,
deletable: false,
}),
]),
);
const skill = data.skills.find(
(item) => item.id === "portable-review:review",
);
expect(skill).toMatchObject({
pluginName: "portable-review",
agentPlugin: true,
});
expect(skill && isToggleableInteractiveConfigItem(skill)).toBe(false);
expect(data.mcp).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "portable-review.docs" }),
]),
);
});
it("toggles Agent Plugins through the hub without mutating client settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-toggle-"));
tempRoots.push(tempRoot);
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
const pluginRoot = "/hub/home/.agents/plugins/portable-review";
const toggleCalls: unknown[] = [];
const loader = createInteractiveConfigDataLoader({
config: {
...createConfig(tempRoot),
agentPluginPaths: ["./portable-review"],
},
toggleCoreSettings: async (input) => {
toggleCalls.push(input);
return {
changedTypes: ["plugins", "skills", "mcp"],
snapshot: {
workflows: [],
rules: [],
tools: [],
skills: [],
mcp: [],
plugins: [
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: false,
toggleable: true,
agentPlugin: true,
},
],
},
};
},
});
const data = await loader.onToggleConfigItem(
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: true,
toggleable: true,
deletable: false,
agentPlugin: true,
},
{ includePluginTools: false },
);
expect(toggleCalls).toEqual([
expect.objectContaining({
type: "plugins",
id: "agent-plugin:portable-review",
path: pluginRoot,
name: "portable-review",
enabled: false,
agentPluginPaths: ["./portable-review"],
includePluginTools: false,
}),
]);
expect(data?.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "portable-review",
enabled: false,
agentPlugin: true,
}),
]),
);
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
});
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,4 +1,8 @@
import {
type CoreSettingsListInput,
type CoreSettingsMutationResult,
type CoreSettingsSnapshot,
type CoreSettingsToggleInput,
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
@@ -10,6 +14,7 @@ import {
import {
type InteractiveConfigData,
type InteractiveConfigItem,
isToggleableInteractiveConfigItem,
type LoadInteractiveConfigDataOptions,
loadInteractiveConfigData,
} from "../../tui/interactive-config";
@@ -18,6 +23,12 @@ import type { Config } from "../../utils/types";
export function createInteractiveConfigDataLoader(input: {
config: Config;
userInstructionService?: UserInstructionConfigService;
loadCoreSettings?: (
input: CoreSettingsListInput,
) => Promise<CoreSettingsSnapshot>;
toggleCoreSettings?: (
input: CoreSettingsToggleInput,
) => Promise<CoreSettingsMutationResult>;
}) {
const workspaceRoot = () =>
input.config.workspaceRoot?.trim() || input.config.cwd;
@@ -28,16 +39,36 @@ export function createInteractiveConfigDataLoader(input: {
enableSpawnAgent: input.config.enableSpawnAgent,
enableAgentTeams: input.config.enableAgentTeams,
});
const loadConfigData = async (
const buildSettingsInput = (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> =>
await loadInteractiveConfigData({
): CoreSettingsListInput => ({
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
agentPluginPaths: input.config.agentPluginPaths,
includePluginTools: options.includePluginTools,
});
const buildConfigData = async (
options: LoadInteractiveConfigDataOptions,
agentPluginSettings: CoreSettingsSnapshot | undefined,
): Promise<InteractiveConfigData> => {
return await loadInteractiveConfigData({
userInstructionService: input.userInstructionService,
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
includePluginTools: options.includePluginTools,
agentPluginSettings,
});
};
const loadConfigData = async (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> => {
const agentPluginSettings = await input
.loadCoreSettings?.(buildSettingsInput(options))
.catch(() => undefined);
return await buildConfigData(options, agentPluginSettings);
};
const refreshUserInstructionConfigs = async (): Promise<void> => {
const service = input.userInstructionService;
@@ -55,6 +86,9 @@ export function createInteractiveConfigDataLoader(input: {
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (!isToggleableInteractiveConfigItem(item)) {
return undefined;
}
const settings = createCoreSettingsService();
if (item.kind === "skill" && typeof item.enabled === "boolean") {
await settings.toggle({
@@ -72,6 +106,22 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
if (item.agentPlugin === true) {
if (!input.toggleCoreSettings) {
throw new Error(
"Agent Plugin settings require a connected Cline Hub.",
);
}
const result = await input.toggleCoreSettings({
...buildSettingsInput(options),
type: "plugins",
id: item.id,
path: item.path,
name: item.name,
enabled: !item.enabled,
});
return await buildConfigData(options, result.snapshot);
}
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
@@ -150,7 +200,11 @@ export function createInteractiveConfigDataLoader(input: {
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (item.kind !== "plugin") {
if (
item.kind !== "plugin" ||
item.agentPlugin === true ||
item.deletable === false
) {
return undefined;
}
await uninstallPlugin({
@@ -2,6 +2,10 @@ import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
type CoreSettingsListInput,
type CoreSettingsMutationResult,
type CoreSettingsSnapshot,
type CoreSettingsToggleInput,
createSessionCompactionState,
isSessionNotFoundError,
type PendingPromptMutationResult,
@@ -299,6 +303,19 @@ export function createInteractiveSessionRuntime(input: {
return await startupPromise;
};
const listCoreSettings = async (
settingsInput: CoreSettingsListInput,
): Promise<CoreSettingsSnapshot> => {
const manager = await ensureSessionManager();
return await manager.settings.list(settingsInput);
};
const toggleCoreSettings = async (
settingsInput: CoreSettingsToggleInput,
): Promise<CoreSettingsMutationResult> => {
const manager = await ensureSessionManager();
return await manager.settings.toggle(settingsInput);
};
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
const manager = sessionManager;
const sessionId = activeSessionId;
@@ -883,6 +900,8 @@ export function createInteractiveSessionRuntime(input: {
return {
ensureReady,
listCoreSettings,
toggleCoreSettings,
sendCurrentTurn,
updatePendingPrompt,
getAccumulatedUsage,
+53 -4
View File
@@ -1,8 +1,17 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildUserInputMessage } from "./prompt";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
const workspaceDirectories: string[] = [];
afterEach(() => {
for (const directory of workspaceDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("buildUserInputMessage", () => {
it("extracts image mentions into userImages", async () => {
@@ -43,3 +52,43 @@ describe("buildUserInputMessage", () => {
expect(result.userFiles).toEqual([filePath]);
});
});
describe("resolveSystemPrompt workspace metadata", () => {
it("includes git remotes and the latest commit for Cline requests", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
workspaceDirectories.push(cwd);
execFileSync("git", ["init"], { cwd });
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
writeFileSync(join(cwd, "README.md"), "test\n");
execFileSync("git", ["add", "README.md"], { cwd });
execFileSync("git", ["commit", "-m", "initial"], { cwd });
execFileSync(
"git",
["remote", "add", "origin", "https://example.com/cline/repo.git"],
{ cwd },
);
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd,
encoding: "utf8",
}).trim();
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
expect(prompt).toContain(commit);
});
it("includes parseable metadata outside a project", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
workspaceDirectories.push(cwd);
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
expect(prompt).toContain("# Workspace Configuration");
expect(prompt).toContain(JSON.stringify(cwd));
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
expect(prompt).not.toContain("associatedRemoteUrls");
expect(prompt).not.toContain("latestGitCommitHash");
});
});
+6 -4
View File
@@ -200,10 +200,6 @@ export async function runInteractive(
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
| undefined;
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService,
});
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
@@ -302,6 +298,12 @@ export async function runInteractive(
uiEvents.emit("pending-prompt-submitted", event);
},
});
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService,
loadCoreSettings: sessionRuntime.listCoreSettings,
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
});
let modeChangePromise: Promise<void> | undefined;
let modeChangeTarget: "plan" | "act" | undefined;
const modeSwitchNotice = createModeSwitchNoticeTracker();
+7 -41
View File
@@ -11,6 +11,8 @@ import {
getValidClineCredentials,
type ProviderSettings,
ProviderSettingsManager,
persistClineAccountTelemetryIdentity,
resolveClineAccountTelemetryIdentity,
saveLocalProviderOAuthCredentials,
type UserCurrentPlan,
} from "@cline/core";
@@ -158,38 +160,6 @@ export async function createClineAccountService(input: {
});
}
/**
* Persist the active organization so headless runs and the hub daemon can
* attach it to telemetry identity. Personal account clears stale org fields.
*/
function persistClineOrganizationContext(
activeOrganization: ClineAccountOrganization | null,
userId: string,
): void {
try {
const manager = new ProviderSettingsManager();
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
accountId: persisted.auth?.accountId ?? userId,
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Best-effort only.
}
}
export async function loadClineAccountSnapshot(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
@@ -213,16 +183,12 @@ export async function loadClineAccountSnapshot(input: {
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance.balance)
: balance.balance;
const accountContext = {
id: user.id,
email: user.email,
provider: "cline",
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
};
const accountContext = resolveClineAccountTelemetryIdentity(user);
identifyTelemetryAccount(accountContext, input.config.logger);
persistClineOrganizationContext(activeOrganization, user.id);
persistClineAccountTelemetryIdentity(
new ProviderSettingsManager(),
accountContext,
);
return {
user,
+8 -1
View File
@@ -657,11 +657,18 @@ export function ChatEntryView(props: {
* token identity, so settled content never re-renders.
* tableOptions preserves the bordered table style that coalesced
* mode used by default (top-level defaults to borderless columns).
*
* streaming stays true even after the entry settles: flipping the
* prop makes MarkdownRenderable rebuild every block from scratch
* (updateBlocks(true) skips all reuse paths), so the finished
* message flashes back to unhighlighted text while tree-sitter
* re-highlights. opencode's TUI keeps streaming={true} for the
* same reason. entry.streaming still drives the spinner glyph.
*/}
<markdown
content={content}
syntaxStyle={getSyntaxStyle(theme, mode)}
streaming={entry.streaming}
streaming={true}
internalBlockMode="top-level"
tableOptions={{ style: "grid" }}
fg={defaultFg}
@@ -47,7 +47,10 @@ export function shouldCloseExtDetailForKey(keyName: string): boolean {
export function shouldToggleExtDetailForKey(
keyName: string,
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
>,
): boolean {
return (
keyName === "space" &&
@@ -57,7 +60,10 @@ export function shouldToggleExtDetailForKey(
}
export function getExtDetailFooterText(
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
>,
): string {
return typeof item.enabled === "boolean" &&
isToggleableInteractiveConfigItem(item)
@@ -14,6 +14,27 @@ 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
@@ -2,12 +2,21 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useDialogPalette } from "../../hooks/use-theme";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
import {
describeOutdatedHubSessions,
resolveHubUpdateRequiredKeyAction,
} from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
hubCoreVersion?: string;
}
/**
* Shown only for `unsupported_protocol`: the running Hub speaks a protocol
* this CLI cannot, so nothing hub-backed works until the CLI updates. The
* softer `build_mismatch` case (newer Hub, compatible protocol) is a toast
* in root.tsx, not this modal.
*/
export function HubUpdateRequiredContent(
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
) {
@@ -29,13 +38,12 @@ export function HubUpdateRequiredContent(
<text fg="yellow">Cline Hub was updated</text>
<box flexDirection="column">
<text selectable>
Another Cline installation restarted the shared Cline Hub
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
matches this CLI.
Another Cline installation updated the shared Cline Hub
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""} to a version this
CLI cannot talk to.
</text>
<text selectable>
Update and restart Cline so this CLI and the Hub run the same version
again.
Update and restart Cline to reconnect to the running Hub.
</text>
</box>
<box flexDirection="row">
@@ -49,3 +57,64 @@ export function HubUpdateRequiredContent(
</box>
);
}
export interface HubOutdatedDetails {
hubCoreVersion?: string;
activeSessionCount?: number;
participantClientCount?: number;
}
/**
* Shown when this CLI is the newer build and the shared Hub was left running
* an older one because it is still serving other clients' sessions. Enter
* replaces the Hub now (interrupting that work); Esc keeps it running.
*/
export function HubOutdatedContent(
props: ChoiceContext<boolean> & HubOutdatedDetails,
) {
const {
activeSessionCount,
dialogId,
dismiss,
participantClientCount,
resolve,
} = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
const action = resolveHubUpdateRequiredKeyAction(key);
if (action === "ignore") return;
if (action === "update") {
resolve(true);
return;
}
dismiss();
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="yellow">Cline Hub update required</text>
<box flexDirection="column">
<text selectable>
This CLI needs a newer Cline Hub, but the running one is still serving{" "}
{describeOutdatedHubSessions({
activeSessionCount,
participantClientCount,
})}
.
</text>
<text selectable>
Updating stops that Hub and interrupts its sessions.
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Update Now</text>
</box>
</box>
<text fg={palette.muted}>
Press Enter to update now, Esc to keep the Hub running
</text>
</box>
);
}
@@ -3,7 +3,6 @@ import {
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
import {
type DialogDismissKey,
isAnyKeyDismiss,
@@ -77,8 +76,5 @@ export function buildClinePassSubscriptionPageUrl(
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
if (CLI_PROMO_CODE) {
url.searchParams.set("code", CLI_PROMO_CODE);
}
return url.toString();
}
@@ -1,6 +1,7 @@
import {
completeClineDeviceAuth,
getProviderConfigFields,
isLocalAuthProvider,
isOAuthProvider,
loginLocalProvider,
type ProviderConfigFieldKey,
@@ -15,11 +16,10 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
checkLocalCliInstalled,
type LocalCliStatus,
type ProviderLocalCli,
} from "../../../utils/local-cli";
import open from "../../../utils/open";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { useDialogPalette } from "../../hooks/use-theme";
@@ -33,6 +33,7 @@ import {
updateProviderConfigValue,
} from "../../utils/provider-config-values";
import { getProviderSection } from "../../utils/provider-sections";
import { canContinueLocalCliSetup } from "../../views/onboarding/model";
import {
getSearchableListRowsWindow,
type SearchableItem,
@@ -82,7 +83,7 @@ export function ProviderPickerContent(
// just a model id and base URL) still render as configured.
isConfigured: p.enabled === true,
isOAuth: isOAuthProvider(p.id),
isLocalAuth: isOpenAICodexCliProvider(p.id),
isLocalAuth: isLocalAuthProvider(p.id),
capabilities: p.capabilities,
}));
setProviders(providerItems);
@@ -654,29 +655,25 @@ export function ProviderConfigInputContent(
);
}
export function CodexCliStatusContent(
export function LocalCliStatusContent(
props: ChoiceContext<boolean> & {
cli?: ProviderLocalCli;
providerName: string;
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const { resolve, dismiss, dialogId, cli, providerName } = props;
const palette = useDialogPalette();
const [status, setStatus] = useState<CodexCliStatus | undefined>();
const [status, setStatus] = useState<LocalCliStatus | undefined>();
const [checking, setChecking] = useState(false);
const refresh = useCallback(() => {
if (!cli) return;
setStatus(undefined);
setChecking(true);
checkCodexCliInstalled()
checkLocalCliInstalled(cli)
.then(setStatus)
.catch((error: unknown) => {
setStatus({
installed: false,
reason: error instanceof Error ? error.message : String(error),
});
})
.finally(() => setChecking(false));
}, []);
}, [cli]);
useEffect(() => {
refresh();
@@ -691,7 +688,7 @@ export function CodexCliStatusContent(
refresh();
return;
}
if (key.name === "return" && status?.installed) {
if (key.name === "return" && canContinueLocalCliSetup(cli, status)) {
resolve(true);
}
}, dialogId);
@@ -702,31 +699,37 @@ export function CodexCliStatusContent(
<strong>{providerName}</strong>
</text>
{checking && <text fg="gray">Checking for Codex CLI...</text>}
{checking && <text fg="gray">Checking for {providerName}...</text>}
{status?.installed && (
<box flexDirection="column" gap={1}>
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
<text fg={palette.success}>
{"\u25cf"} {providerName} installed
</text>
<text fg="gray">{status.version}</text>
</box>
)}
{status && !status.installed && (
<box flexDirection="column" gap={1}>
<text fg="yellow">Codex CLI was not found</text>
<text fg="yellow">{providerName} was not found</text>
<text fg="gray">{status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg={palette.act} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
{cli?.docsUrl && (
<box flexDirection="column">
<text fg="gray">Install {providerName} from:</text>
<text fg={palette.act} selectable>
{cli.docsUrl}
</text>
</box>
)}
</box>
)}
<text fg="gray">
<em>
{status?.installed
{cli
? "Enter to continue, R to recheck, Esc to go back"
: "R to recheck, Esc to go back"}
: "Enter to continue, Esc to go back"}
</em>
</text>
</box>
+4 -1
View File
@@ -23,6 +23,9 @@ export function Toast(props: { toast: ToastState | null }) {
};
const availableWidth = Math.max(1, width - 4);
const maxWidth = Math.min(44, availableWidth);
// Border and horizontal padding take four columns. An explicit width (not
// maxWidth) is what makes the text wrap instead of clipping at the edge.
const boxWidth = Math.min(maxWidth, props.toast.message.length + 4);
const right = width < 32 ? 0 : 2;
const color = variantColor[props.toast.variant];
@@ -32,7 +35,7 @@ export function Toast(props: { toast: ToastState | null }) {
zIndex={100}
top={1}
right={right}
maxWidth={maxWidth}
width={boxWidth}
border
borderStyle="rounded"
borderColor={color}
+12 -4
View File
@@ -10,7 +10,7 @@ import { isClineProvider } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
import { getLocalCliInfo } from "../../utils/local-cli";
import {
getPersistedProviderApiKey,
isOAuthProvider,
@@ -20,8 +20,8 @@ import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
LocalCliStatusContent,
OAuthApiKeyInputContent,
OAuthLoginContent,
type OAuthLoginResult,
@@ -43,6 +43,7 @@ import {
type ThinkingLevel,
ThinkingLevelContent,
} from "../components/model-selector/model-selector";
import { resolveProviderSetupRoute } from "../views/onboarding/model";
export interface OpenModelSelectorOptions {
onCancel?: () => Promise<void> | void;
@@ -178,6 +179,9 @@ async function runProviderChange(
async () => await getProviderDisplayName(newProviderId),
);
const existingSettings = manager.getProviderSettings(newProviderId);
const needsLocalCliSetup =
resolveProviderSetupRoute(newProviderId) === "local_cli";
const localCliProvider = getLocalCliInfo(newProviderId);
// Manual API key entry is the escape hatch for when OAuth login isn't
// working; only the Cline providers accept a dashboard API key.
@@ -246,12 +250,16 @@ async function runProviderChange(
loginResult === "use_api_key"
? await openManualApiKeyDialog()
: loginResult;
} else if (isOpenAICodexCliProvider(newProviderId)) {
} else if (needsLocalCliSetup) {
saved = await dialog.choice<boolean>({
style: { maxHeight: termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<CodexCliStatusContent {...ctx} providerName={displayName} />
<LocalCliStatusContent
{...ctx}
cli={localCliProvider}
providerName={displayName}
/>
),
});
if (saved) {
@@ -1,5 +1,9 @@
import type { AgentMode } from "@cline/core";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RuntimeToolInteraction, TuiProps } from "../types";
@@ -36,16 +40,16 @@ function toRuntimeToolInteraction(
};
}
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
function deniedToolResult(): ToolApprovalResult {
return {
approved: false,
reason: `Tool "${request.toolName}" was denied by user`,
reason: USER_REJECTED_TOOL_REASON,
};
}
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
if (pending.kind === "tool_approval") {
pending.resolve(deniedToolResult(pending.request));
pending.resolve(deniedToolResult());
return;
}
pending.resolve("[User dismissed the question]");
@@ -111,9 +115,7 @@ export function useRuntimeDialogBridge(input: {
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
return;
}
pending.resolve(
approved ? { approved: true } : deniedToolResult(pending.request),
);
pending.resolve(approved ? { approved: true } : deniedToolResult());
const hasNext = finishActive(id);
if (!hasNext) {
refocusTextarea();
+72 -8
View File
@@ -9,6 +9,8 @@ import {
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
type CoreSettingsItem,
type CoreSettingsSnapshot,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
discoverPluginModulePaths,
getPluginDisplayName,
@@ -80,6 +82,12 @@ export interface InteractiveConfigItem {
| "global-plugin"
| "workspace-plugin";
description?: string;
/** True when the hub discovered this through agent-plugins.org. */
agentPlugin?: boolean;
/** Explicitly overrides the default toggle policy for this item. */
toggleable?: boolean;
/** Explicitly overrides the default delete policy for this item. */
deletable?: boolean;
}
export interface InteractiveConfigData {
@@ -100,8 +108,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "pluginName" | "toggleable"
>,
): boolean {
if (item.toggleable !== undefined) {
return item.toggleable;
}
if (item.kind === "mcp") {
return !item.pluginName;
}
@@ -324,12 +338,53 @@ export function applyPluginFailures(
}
}
function toAgentPluginInteractiveItem(
item: CoreSettingsItem,
): InteractiveConfigItem {
return {
id: item.id,
name: item.name,
path: item.path,
enabled: item.enabled,
kind: item.kind,
source: item.source,
description: item.description,
pluginName: item.pluginName,
pluginPath: item.pluginPath,
loadError: item.loadError,
agentPlugin: true,
toggleable: item.toggleable ?? false,
deletable: false,
...(item.kind === "plugin" ? { configKind: "plugin" as const } : {}),
};
}
function appendAgentPluginSnapshotItems(
target: InteractiveConfigItem[],
items: readonly CoreSettingsItem[],
): void {
const existing = new Set(
target.map((item) => `${item.kind}\0${item.id}\0${item.path}`),
);
for (const item of items) {
if (item.agentPlugin !== true) {
continue;
}
const key = `${item.kind}\0${item.id}\0${item.path}`;
if (!existing.has(key)) {
target.push(toAgentPluginInteractiveItem(item));
existing.add(key);
}
}
}
export async function loadInteractiveConfigData(input: {
userInstructionService?: UserInstructionConfigService;
cwd: string;
workspaceRoot: string;
availabilityContext?: BuiltinToolAvailabilityContext;
includePluginTools?: boolean;
agentPluginSettings?: CoreSettingsSnapshot;
}): Promise<InteractiveConfigData> {
const workflows: InteractiveConfigItem[] = [];
const rules: InteractiveConfigItem[] = [];
@@ -518,14 +573,23 @@ export async function loadInteractiveConfigData(input: {
}
}
if (input.agentPluginSettings) {
appendAgentPluginSnapshotItems(plugins, input.agentPluginSettings.plugins);
appendAgentPluginSnapshotItems(skills, input.agentPluginSettings.skills);
appendAgentPluginSnapshotItems(mcp, input.agentPluginSettings.mcp);
}
const existsLocallyOrComesFromHub = (item: InteractiveConfigItem) =>
item.agentPlugin === true || existsSync(item.path);
return {
workflows: toSorted(workflows.filter((item) => existsSync(item.path))),
rules: toSorted(rules.filter((item) => existsSync(item.path))),
skills: toSorted(skills.filter((item) => existsSync(item.path))),
hooks: toSorted(hooks.filter((item) => existsSync(item.path))),
agents: toSorted(agents.filter((item) => existsSync(item.path))),
plugins: toSorted(plugins.filter((item) => existsSync(item.path))),
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
workflows: toSorted(workflows.filter(existsLocallyOrComesFromHub)),
rules: toSorted(rules.filter(existsLocallyOrComesFromHub)),
skills: toSorted(skills.filter(existsLocallyOrComesFromHub)),
hooks: toSorted(hooks.filter(existsLocallyOrComesFromHub)),
agents: toSorted(agents.filter(existsLocallyOrComesFromHub)),
plugins: toSorted(plugins.filter(existsLocallyOrComesFromHub)),
mcp: toSorted(mcp.filter(existsLocallyOrComesFromHub)),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
+82 -10
View File
@@ -2,6 +2,7 @@ import {
getCurrentContextSize,
type ManagedHubBuildMismatchEvent,
summarizeUsageFromMessages,
upgradeManagedHub,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { formatDisplayUserInput } from "@cline/shared";
@@ -40,7 +41,10 @@ import {
buildCommandPaletteItems,
findCommandPaletteShortcut,
} from "./components/dialogs/command-palette-items";
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
import {
HubOutdatedContent,
HubUpdateRequiredContent,
} from "./components/dialogs/hub-update-required";
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
import {
SKILLS_MARKETPLACE_ACTION,
@@ -586,17 +590,85 @@ function App(props: TuiProps) {
setHubBuildMismatch(null);
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
if (hubBuildMismatch.reason === "outdated_hub") {
// This CLI is already the newer build. The Hub is behind only because
// retiring it would kill the sessions it is serving, and it is
// replaced on its own at the next launch. Nothing is wrong, nothing is
// asked, and nothing the user can act on differs - so say nothing, the
// same conclusion the desktop surface reached.
//
// The classification still earns its keep here: it is what stops the
// update-and-restart prompt below from firing at someone who has
// nothing to update.
// This CLI is already the newer build; the Hub is behind only because
// retiring it would kill the sessions it is serving. Left alone it
// would stay behind for as long as those sessions run, so put the
// choice to the user: replace it now (interrupting that work), or
// keep it running and update later. This session itself is safe
// either way - a CLI that could not attach to the outdated Hub is
// running on the local backend.
const details = {
hubCoreVersion,
activeSessionCount: hubBuildMismatch.activeSessionCount,
participantClientCount: hubBuildMismatch.participantClientCount,
};
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
<HubOutdatedContent {...ctx} {...details} />
),
})
.then(async (update) => {
if (!update) {
// choice() resolves undefined on Esc; it does not reject.
showToast(
"The running Cline Hub stays on the older version. Run 'cline hub upgrade' once its sessions finish.",
"info",
);
refocusTextareaRef.current();
return;
}
showToast("Updating the Cline Hub…", "info");
try {
const result = await upgradeManagedHub({
force: true,
reason: "cline TUI hub update",
});
if (result.outcome === "still_busy") {
showToast(
"The Hub picked up new sessions before it could be replaced. Try again in a moment.",
"info",
);
} else {
showToast(
result.outcome === "replaced" || result.outcome === "started"
? "Cline Hub updated."
: "Cline Hub is already up to date.",
"success",
);
}
} catch (error) {
showToast(
error instanceof Error && error.message
? error.message
: "Updating the Cline Hub failed. Run 'cline doctor fix' and try again.",
"error",
);
}
refocusTextareaRef.current();
})
.catch(() => {
refocusTextareaRef.current();
});
return;
}
if (hubBuildMismatch.reason === "build_mismatch") {
// The Hub is newer but still speaks this CLI's protocol, so the
// session keeps working and parity is advisable rather than urgent.
// A modal mid-session is too heavy for advice; a toast (once per
// observed Hub build, the watcher dedupes) says what changed and
// how to catch up without stealing focus.
showToast(
`The shared Cline Hub was updated${
hubCoreVersion ? ` (core ${hubCoreVersion})` : ""
}. Run 'cline update' and restart when convenient.`,
"info",
);
return;
}
// unsupported_protocol: this CLI cannot speak the running Hub's
// protocol at all, so nothing hub-backed can work until it updates.
// That is worth a blocking prompt.
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
@@ -1,5 +1,4 @@
import { ProviderSettingsManager } from "@cline/core";
import { isProviderSettingsUsable } from "../../utils/provider-readiness";
import { isProviderSettingsUsable, ProviderSettingsManager } from "@cline/core";
import type { TuiProps } from "../types";
export function isProviderConfigured(config: TuiProps["config"]): boolean {
+50 -1
View File
@@ -128,12 +128,61 @@ export function resolveActiveConfigItems(
}
}
export interface ConfigPluginSection {
label: string;
items: InteractiveConfigItem[];
}
export function getConfigPluginSections(
items: readonly InteractiveConfigItem[],
): ConfigPluginSection[] {
const clinePlugins = items.filter((item) => item.agentPlugin !== true);
const agentPlugins = items.filter((item) => item.agentPlugin === true);
return [
...(clinePlugins.length > 0
? [
{
label: `Cline Plugins (${clinePlugins.length})`,
items: clinePlugins,
},
]
: []),
...(agentPlugins.length > 0
? [
{
label: `Agent Plugins (${agentPlugins.length})`,
items: agentPlugins,
},
]
: []),
];
}
export function getConfigTabCountHeading(
tab: InteractiveConfigTab,
itemCount: number,
): string | undefined {
return tab === "plugins" ? undefined : `${toTabLabel(tab)} (${itemCount})`;
}
export function shouldRenderConfigItemAsEnabled(
item: InteractiveConfigItem,
enabledState: "enabled" | "disabled" | "partial",
): boolean {
return (
enabledState === "enabled" &&
(isToggleableInteractiveConfigItem(item) || item.agentPlugin === true)
);
}
export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
return isToggleableInteractiveConfigItem(item);
}
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
return item.kind === "plugin";
return (
item.deletable ?? (item.kind === "plugin" && item.agentPlugin !== true)
);
}
export function resolveConfigItemSelectAction(
@@ -5,11 +5,16 @@ import {
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigPluginSections,
getConfigTabCountHeading,
isDeletableConfigItem,
isInlineConfigAction,
isToggleableConfigItem,
resolveConfigItemDeleteAction,
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
shouldRenderConfigItemAsEnabled,
} from "./config-view-helpers";
function createItem(
@@ -71,6 +76,65 @@ describe("config view helpers", () => {
).toBe(false);
});
it("lets users toggle hub-discovered Agent Plugins without deleting them", () => {
const plugin = createItem({
kind: "plugin",
agentPlugin: true,
toggleable: true,
deletable: false,
source: "global-plugin",
});
expect(isToggleableConfigItem(plugin)).toBe(true);
expect(isDeletableConfigItem(plugin)).toBe(false);
expect(resolveConfigItemToggleAction(plugin)).toEqual({
kind: "toggle-item",
item: plugin,
});
expect(resolveConfigItemDeleteAction(plugin)).toBeUndefined();
expect(resolveConfigItemSelectAction(plugin)).toEqual({
kind: "toggle-item",
item: plugin,
});
});
it("separates Cline and Agent Plugins into labeled sections", () => {
const clinePlugin = createItem({
kind: "plugin",
name: "cline-plugin",
source: "workspace-plugin",
});
const agentPlugin = createItem({
kind: "plugin",
name: "portable-plugin",
source: "global-plugin",
agentPlugin: true,
});
expect(getConfigPluginSections([clinePlugin, agentPlugin])).toEqual([
{ label: "Cline Plugins (1)", items: [clinePlugin] },
{ label: "Agent Plugins (1)", items: [agentPlugin] },
]);
});
it("uses section counts instead of a combined Plugins heading", () => {
expect(getConfigTabCountHeading("plugins", 10)).toBeUndefined();
expect(getConfigTabCountHeading("skills", 20)).toBe("Skills (20)");
});
it("renders a loaded Agent Plugin as enabled", () => {
const agentPlugin = createItem({
kind: "plugin",
agentPlugin: true,
toggleable: true,
});
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "enabled")).toBe(true);
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "disabled")).toBe(
false,
);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+36 -19
View File
@@ -25,6 +25,8 @@ import {
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigPluginSections,
getConfigTabCountHeading,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
@@ -34,6 +36,7 @@ import {
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
shouldRenderConfigItemAsEnabled,
toTabLabel,
} from "./config-view-helpers";
@@ -298,6 +301,16 @@ function appendSkillRows(
}
}
function appendPluginRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
): void {
for (const section of getConfigPluginSections(items)) {
rows.push({ kind: "head", label: section.label });
appendExtRows(rows, section.items);
}
}
function withOptimisticToggle(
data: InteractiveConfigData,
item: InteractiveConfigItem,
@@ -477,10 +490,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
} else {
const activeItems = resolveActiveConfigItems(configData, activeTab);
r.push({
kind: "head",
label: `${toTabLabel(activeTab)} (${activeItems.length})`,
});
const countHeading = getConfigTabCountHeading(
activeTab,
activeItems.length,
);
if (countHeading) {
r.push({ kind: "head", label: countHeading });
}
if (activeItems.length === 0 && !pluginToolsLoading) {
r.push({
@@ -504,6 +520,21 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
} else if (activeTab === "skills") {
appendSkillRows(r, activeItems);
} else if (activeTab === "plugins") {
appendPluginRows(r, activeItems);
if (pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
} else {
for (const item of activeItems) {
r.push({
@@ -523,19 +554,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: getPluginLoadErrorLabel(item),
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
}
if (activeTab === "mcp") {
@@ -871,11 +889,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: "○ "
: "";
const rightLabel = row.rightLabel ?? "";
const toggleable = isToggleableConfigItem(row.item);
const prefix = " ".repeat(row.indent ?? 0);
const rowColor = row.item.loadError
? "red"
: toggleable && enabledState === "enabled"
: shouldRenderConfigItemAsEnabled(row.item, enabledState)
? palette.success
: enabledState === "partial"
? "yellow"
+54 -31
View File
@@ -17,10 +17,11 @@ import {
getIndividualPlanFeatures,
} from "../../../utils/cline-pass-errors";
import {
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
checkLocalCliInstalled,
getLocalCliInfo,
type LocalCliStatus,
type ProviderLocalCli,
} from "../../../utils/local-cli";
import open from "../../../utils/open";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
@@ -59,6 +60,7 @@ import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
canContinueLocalCliSetup,
DEFAULT_THINKING_LEVEL_INDEX,
getMainMenuOptions,
type ModelEntry,
@@ -66,6 +68,7 @@ import {
type OnboardingStep,
type ProviderEntry,
type ReasoningEffort,
resolveProviderSetupRoute,
shouldUseFeaturedClineModelPicker,
type ThinkingLevel,
toModelEntriesFromKnownModels,
@@ -103,6 +106,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [authError, setAuthError] = useState("");
const [activeProviderId, setActiveProviderId] = useState("");
const [activeProviderName, setActiveProviderName] = useState("");
const localCli = useMemo(
() => getLocalCliInfo(activeProviderId),
[activeProviderId],
);
const [byoFields, setByoFields] = useState<ProviderConfigFields["fields"]>(
{},
);
@@ -110,10 +117,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
const [byoFocusedField, setByoFocusedField] =
useState<ProviderConfigFieldKey>("apiKey");
const [codexCliStatus, setCodexCliStatus] = useState<
CodexCliStatus | undefined
const [localCliStatus, setLocalCliStatus] = useState<
LocalCliStatus | undefined
>();
const [codexCliChecking, setCodexCliChecking] = useState(false);
const [localCliChecking, setLocalCliChecking] = useState(false);
const localCliProbeRef = useRef(0);
const authAbortRef = useRef(false);
// Device code flow
@@ -486,18 +494,23 @@ export function useOnboardingController(props: OnboardingControllerProps) {
}
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
const refreshCodexCliStatus = useCallback(() => {
setCodexCliStatus(undefined);
setCodexCliChecking(true);
checkCodexCliInstalled()
.then(setCodexCliStatus)
.catch((error: unknown) => {
setCodexCliStatus({
installed: false,
reason: error instanceof Error ? error.message : String(error),
});
const refreshLocalCliStatus = useCallback((provider: ProviderLocalCli) => {
// Probing spawns the provider's CLI, so a result can land long after the
// user moved on. Two local-CLI providers share this single status, so an
// unlabelled result could mark the selected provider ready off a probe of
// the previous one (or block it off a stale failure). Only the newest
// probe may write.
const probeId = ++localCliProbeRef.current;
const isCurrentProbe = () => localCliProbeRef.current === probeId;
setLocalCliStatus(undefined);
setLocalCliChecking(true);
checkLocalCliInstalled(provider)
.then((status) => {
if (isCurrentProbe()) setLocalCliStatus(status);
})
.finally(() => setCodexCliChecking(false));
.finally(() => {
if (isCurrentProbe()) setLocalCliChecking(false);
});
}, []);
const selectProvider = useCallback(
@@ -510,12 +523,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
}
return;
}
if (provider.isLocalAuth || isOpenAICodexCliProvider(provider.id)) {
if (resolveProviderSetupRoute(provider.id) === "local_cli") {
setActiveProviderId(provider.id);
setActiveProviderName(provider.name);
setCodexCliStatus(undefined);
setStep("codex_cli_setup");
refreshCodexCliStatus();
setStep("local_cli_setup");
// Only providers that name a CLI have something to probe; the
// rest reach the screen with readiness simply unknown.
const localCliProvider = getLocalCliInfo(provider.id);
if (localCliProvider) refreshLocalCliStatus(localCliProvider);
return;
}
const config = getProviderConfigFields(provider.id);
@@ -575,11 +590,17 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setByoFocusedField(firstField ?? "apiKey");
setStep("byo_apikey");
},
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
[providers, startOAuthFlow, refreshLocalCliStatus, providerSettingsManager],
);
const saveCodexCliConfig = useCallback(() => {
if (!codexCliStatus?.installed) {
const recheckLocalCli = useCallback(() => {
if (localCli) {
refreshLocalCliStatus(localCli);
}
}, [localCli, refreshLocalCliStatus]);
const saveLocalCliConfig = useCallback(() => {
if (!canContinueLocalCliSetup(localCli, localCliStatus)) {
return;
}
saveLocalProviderSettings(providerSettingsManager, {
@@ -588,7 +609,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
transitionToModelPicker(activeProviderId);
}, [
activeProviderId,
codexCliStatus,
localCli,
localCliStatus,
providerSettingsManager,
transitionToModelPicker,
]);
@@ -798,13 +820,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
deviceAbortRef.current = true;
},
resetAuth,
refreshCodexCliStatus,
refreshLocalCliStatus: recheckLocalCli,
startOAuthFlow,
startDeviceCodeFlow,
selectProvider,
loadModelsForProvider,
saveClineModelSelection,
saveCodexCliConfig,
saveLocalCliConfig,
saveByoConfig,
saveModelSelection,
saveThinkingLevel,
@@ -820,8 +842,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
byoFields,
byoFocusedField,
byoValues,
codexCliChecking,
codexCliStatus,
localCli,
localCliChecking,
localCliStatus,
clineEntries,
clineModelSelected,
clinePassCurrentPlanName,
@@ -860,7 +883,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
providersLoading,
recommendedLoading: recommended.loading,
saveByoConfig,
saveCodexCliConfig,
saveLocalCliConfig,
saveCustomModelId,
selectedModelName,
step,
@@ -51,13 +51,13 @@ export function useOnboardingKeyboard(input: {
abortOAuth: () => void;
abortDeviceCode: () => void;
resetAuth: () => void;
refreshCodexCliStatus: () => void;
refreshLocalCliStatus: () => void;
startOAuthFlow: (providerId: OnboardingOAuthProviderId) => void;
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
selectProvider: (providerId: string) => void;
loadModelsForProvider: (providerId: string) => void;
saveClineModelSelection: (modelId: string, modelName: string) => void;
saveCodexCliConfig: () => void;
saveLocalCliConfig: () => void;
saveByoConfig: () => void;
saveModelSelection: () => void;
saveThinkingLevel: (level: ThinkingLevel) => void;
@@ -98,7 +98,7 @@ export function useOnboardingKeyboard(input: {
input.setMenuSelected(0);
return;
}
if (input.step === "codex_cli_setup") {
if (input.step === "local_cli_setup") {
input.setStep("byo_provider");
return;
}
@@ -227,13 +227,13 @@ export function useOnboardingKeyboard(input: {
return;
}
if (input.step === "codex_cli_setup") {
if (input.step === "local_cli_setup") {
if (key.name === "r") {
input.refreshCodexCliStatus();
input.refreshLocalCliStatus();
return;
}
if (key.name === "return") {
input.saveCodexCliConfig();
input.saveLocalCliConfig();
}
return;
}
@@ -1,7 +1,15 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getLocalCliInfo } from "../../../utils/local-cli";
vi.mock("../../../utils/local-cli", () => ({
getLocalCliInfo: () => undefined,
}));
import {
canContinueLocalCliSetup,
getMainMenuOptions,
getOAuthProviderLabel,
resolveProviderSetupRoute,
shouldUseFeaturedClineModelPicker,
toModelEntriesFromKnownModels,
toModelEntry,
@@ -77,6 +85,20 @@ describe("onboarding model helpers", () => {
});
});
it("marks the Claude Code provider as local auth", () => {
expect(
toProviderEntry({
id: "claude-code",
name: "Claude Code",
models: null,
}),
).toMatchObject({
id: "claude-code",
isOAuth: false,
isLocalAuth: true,
});
});
it("maps model names and reasoning support strictly", () => {
expect(
toModelEntry({
@@ -166,3 +188,31 @@ describe("onboarding model helpers", () => {
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
describe("local-auth setup routing", () => {
// A provider can declare `local-auth` without naming a CLI we can probe.
// Routing must follow the capability; the descriptor is only for probing.
// Otherwise it falls through to the API-key form, which renders no fields
// for a local-auth provider.
it("routes a local-auth provider with no CLI descriptor to local setup", () => {
expect(getLocalCliInfo("claude-code")).toBeUndefined();
expect(resolveProviderSetupRoute("claude-code")).toBe("local_cli");
});
it("routes OAuth and API-key providers unchanged", () => {
expect(resolveProviderSetupRoute("anthropic")).toBe("api_key");
});
// The probe only looks on PATH, while the runtime also accepts an explicit
// pathToClaudeCodeExecutable and a bundled platform binary. A PATH miss
// therefore means "not on PATH", not "unusable", so it must not block.
it("lets the user continue when the CLI is not found on PATH", () => {
const cli = { command: "claude", docsUrl: "https://example.invalid" };
expect(
canContinueLocalCliSetup(cli, {
installed: false,
reason: "The claude executable was not found on PATH.",
}),
).toBe(true);
});
});
+39 -4
View File
@@ -4,8 +4,14 @@ import type {
ModelOperation,
} from "@cline/shared";
import { isChatProviderModel } from "../../../utils/chat-models";
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
import { isOAuthProvider } from "../../../utils/provider-auth";
import type {
LocalCliStatus,
ProviderLocalCli,
} from "../../../utils/local-cli";
import {
isLocalAuthProvider,
isOAuthProvider,
} from "../../../utils/provider-auth";
export type OnboardingStep =
| "menu"
@@ -13,7 +19,7 @@ export type OnboardingStep =
| "device_code"
| "byo_provider"
| "byo_apikey"
| "codex_cli_setup"
| "local_cli_setup"
| "cline_pass_subscription"
| "cline_model"
| "model_picker"
@@ -85,6 +91,35 @@ export const MAIN_MENU: MenuOption[] = [
},
];
/**
* Which setup flow a provider needs. Keyed off how the provider authenticates,
* so every caller routes the same way.
*/
export type ProviderSetupRoute = "oauth" | "local_cli" | "api_key";
export function resolveProviderSetupRoute(
providerId: string,
): ProviderSetupRoute {
if (isOAuthProvider(providerId)) return "oauth";
if (isLocalAuthProvider(providerId)) return "local_cli";
return "api_key";
}
/**
* Whether the local-CLI setup screen lets the user connect.
*/
export function canContinueLocalCliSetup(
_cli: ProviderLocalCli | undefined,
_status: LocalCliStatus | undefined,
): boolean {
// The probe only looks on PATH, while the runtime also accepts an explicit
// pathToClaudeCodeExecutable and a bundled platform binary, and Codex falls
// back through `npx`. A PATH miss therefore means "not on PATH", not
// "unusable", so the screen reports it without blocking — a provider that
// really cannot start says so on the first turn, in its own words.
return true;
}
export function getMainMenuOptions(options?: {
isClinePassEnabled?: boolean;
}): MenuOption[] {
@@ -174,7 +209,7 @@ export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
id: provider.id,
name: provider.name,
isOAuth: isOAuthProvider(provider.id),
isLocalAuth: isOpenAICodexCliProvider(provider.id),
isLocalAuth: isLocalAuthProvider(provider.id),
hasAuth:
Boolean(provider.apiKey) || provider.oauthAccessTokenPresent === true,
...(provider.capabilities ? { capabilities: provider.capabilities } : {}),
+24 -15
View File
@@ -2,10 +2,10 @@ import "opentui-spinner/react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
} from "../../../utils/codex-cli";
import type {
LocalCliStatus,
ProviderLocalCli,
} from "../../../utils/local-cli";
import {
ClineModelPicker,
type ClineModelPickerEntry,
@@ -25,6 +25,7 @@ import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
canContinueLocalCliSetup,
type MenuOption,
THINKING_LEVELS,
} from "./model";
@@ -362,18 +363,20 @@ export function OnboardingProviderConfigScreen(props: {
);
}
export function OnboardingCodexCliScreen(props: {
export function OnboardingLocalCliScreen(props: {
activeProviderName: string;
checking: boolean;
cli?: ProviderLocalCli;
compact: boolean;
contentWidth: number;
mouse: MouseTrackerState;
status?: CodexCliStatus;
status?: LocalCliStatus;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
const installedStatus =
props.status?.installed === true ? props.status : undefined;
const canContinue = canContinueLocalCliSetup(props.cli, props.status);
return (
<OnboardingFrame
compact={props.compact}
@@ -386,31 +389,37 @@ export function OnboardingCodexCliScreen(props: {
{props.checking && (
<box flexDirection="row" gap={1}>
<spinner name="dots" color="gray" />
<text fg="gray">Checking for Codex CLI...</text>
<text fg="gray">Checking for {props.activeProviderName}...</text>
</box>
)}
{installedStatus && (
<box flexDirection="column" gap={1} alignItems="center">
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
<text fg={colors.success}>
{"\u25cf"} {props.activeProviderName} installed
</text>
<text fg="gray">{installedStatus.version}</text>
</box>
)}
{props.status && !props.status.installed && (
{props.cli && props.status && !props.status.installed && (
<box flexDirection="column" gap={1} width={props.contentWidth}>
<text fg="yellow">Codex CLI was not found</text>
<text fg="yellow">{props.activeProviderName} was not found</text>
<text fg="gray">{props.status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg={colors.accent} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
{props.cli.docsUrl && (
<box flexDirection="column">
<text fg="gray">Install {props.activeProviderName} from:</text>
<text fg={colors.accent} selectable>
{props.cli.docsUrl}
</text>
</box>
)}
</box>
)}
<text fg="gray">
<em>
{installedStatus
{canContinue
? "Enter to continue, R to recheck, Esc to go back, Ctrl+C to exit"
: "R to recheck, Esc to go back, Ctrl+C to exit"}
</em>
+6 -5
View File
@@ -7,10 +7,10 @@ import { getOAuthProviderLabel, type OnboardingResult } from "./model";
import {
OnboardingClineModelScreen,
OnboardingClinePassSubscriptionScreen,
OnboardingCodexCliScreen,
OnboardingCustomModelIdScreen,
OnboardingDeviceCodeScreen,
OnboardingDoneScreen,
OnboardingLocalCliScreen,
OnboardingMainMenuScreen,
OnboardingModelPickerScreen,
OnboardingOAuthPendingScreen,
@@ -83,15 +83,16 @@ export function OnboardingView(props: OnboardingViewProps) {
);
}
if (state.step === "codex_cli_setup") {
if (state.step === "local_cli_setup" && state.localCli) {
return (
<OnboardingCodexCliScreen
<OnboardingLocalCliScreen
activeProviderName={state.activeProviderName}
checking={state.codexCliChecking}
checking={state.localCliChecking}
cli={state.localCli}
compact={compact}
contentWidth={contentWidth}
mouse={mouse}
status={state.codexCliStatus}
status={state.localCliStatus}
/>
);
}
+6 -2
View File
@@ -1,5 +1,9 @@
import { createInterface } from "node:readline";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import { truncate } from "./helpers";
import { c, getActiveCliSession, write } from "./output";
@@ -91,7 +95,7 @@ async function requestTerminalToolApproval(
}
return {
approved: false,
reason: `Tool "${request.toolName}" was denied by user`,
reason: USER_REJECTED_TOOL_REASON,
};
}
+3 -12
View File
@@ -18,20 +18,11 @@ import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export const CLI_PROMO_CODE = "";
export function getCliSubscriptionUrl(): string {
if (!CLI_PROMO_CODE) {
return new URL(
`/dashboard/subscription?personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString();
}
return `${new URL(
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
return new URL(
`/dashboard/subscription?personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
).toString();
}
export function getCliNotSubscribedMessage(): string {
-55
View File
@@ -1,55 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export const OPENAI_CODEX_CLI_PROVIDER_ID = "openai-codex-cli";
export const CODEX_CLI_INSTALL_URL = "https://developers.openai.com/codex/cli";
export type CodexCliStatus =
| {
installed: true;
version: string;
}
| {
installed: false;
reason: string;
};
export function isOpenAICodexCliProvider(providerId: string): boolean {
return providerId.trim().toLowerCase() === OPENAI_CODEX_CLI_PROVIDER_ID;
}
export async function checkCodexCliInstalled(): Promise<CodexCliStatus> {
try {
const result = await execFileAsync("codex", ["--version"], {
timeout: 3000,
windowsHide: true,
});
const version = (result.stdout || result.stderr).trim();
return {
installed: true,
version: version || "codex",
};
} catch (error) {
const details =
error && typeof error === "object"
? (error as { code?: unknown; message?: unknown })
: undefined;
const code = typeof details?.code === "string" ? details.code : "";
if (code === "ENOENT") {
return {
installed: false,
reason: "The codex executable was not found on PATH.",
};
}
const message =
typeof details?.message === "string"
? details.message
: "Could not run codex --version.";
return {
installed: false,
reason: message,
};
}
}
+30
View File
@@ -0,0 +1,30 @@
import { isLocalAuthProvider } from "@cline/core";
import { describe, expect, it } from "vitest";
import { getLocalCliInfo } from "./local-cli";
describe("local CLI providers", () => {
it("reads the CLI a local-auth provider borrows credentials from", () => {
expect(getLocalCliInfo("openai-codex-cli")).toEqual({
command: "codex",
docsUrl: "https://developers.openai.com/codex/cli",
});
expect(getLocalCliInfo("claude-code")).toEqual({
command: "claude",
docsUrl: "https://code.claude.com/docs/en/setup",
});
});
it("names no CLI for providers that authenticate with an API key", () => {
expect(getLocalCliInfo("anthropic")).toBeUndefined();
expect(getLocalCliInfo("openai-codex")).toBeUndefined();
});
// Routing is keyed off the capability alone, so a local-auth provider whose
// credentials come from somewhere unprobeable still reaches the local setup
// screen instead of an empty API-key form.
it("routes on the capability, not on knowing a CLI", () => {
expect(isLocalAuthProvider("claude-code")).toBe(true);
expect(isLocalAuthProvider("openai-codex-cli")).toBe(true);
expect(isLocalAuthProvider("anthropic")).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { Llms } from "@cline/core";
const execFileAsync = promisify(execFile);
export type ProviderLocalCli = Llms.ProviderLocalCli;
export type LocalCliStatus =
| {
installed: true;
version: string;
}
| {
installed: false;
reason: string;
};
/**
* The CLI a `local-auth` provider borrows credentials from, as declared in
* the provider catalog. `undefined` for providers that name none those are
* connected without a readiness check rather than probing a guessed command.
*/
export function getLocalCliInfo(
providerId: string,
): ProviderLocalCli | undefined {
return Llms.resolveProviderLocalCli(providerId);
}
export async function checkLocalCliInstalled(
cli: ProviderLocalCli,
): Promise<LocalCliStatus> {
try {
const result = await execFileAsync(cli.command, ["--version"], {
timeout: 3000,
windowsHide: true,
});
const version = (result.stdout || result.stderr).trim();
return {
installed: true,
version: version || cli.command,
};
} catch (error) {
const details = error as NodeJS.ErrnoException | undefined;
if (details?.code === "ENOENT") {
return {
installed: false,
reason: `The ${cli.command} executable was not found on PATH.`,
};
}
return {
installed: false,
reason:
error instanceof Error
? error.message
: `Could not run ${cli.command} --version.`,
};
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import {
formatProviderOAuthApiKey,
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
isLocalAuthProvider,
isOAuthProvider,
Llms,
type ProviderOAuthCredentials,
@@ -21,7 +22,7 @@ export function normalizeAuthProviderId(providerId: string): string {
return normalizeProviderId(normalized);
}
export { isOAuthProvider };
export { isLocalAuthProvider, isOAuthProvider };
export function toProviderApiKey(
providerId: string,
@@ -264,6 +264,19 @@ export async function handleDesktopCommand(
) {
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
}
if (command === "search_sessions") {
if (!ctx.uiClient) throw new Error("Hub is not connected");
const query = String(args?.query ?? "").trim();
if (!query) return [];
return await ctx.uiClient.searchSessions({
query,
limit: typeof args?.limit === "number" ? args.limit : 50,
workspaceRoot:
typeof args?.workspaceRoot === "string"
? args.workspaceRoot
: undefined,
});
}
if (command === "read_session_hooks") {
return [];
}
+1 -1
View File
@@ -29,7 +29,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "11.16.0",
"mermaid": "11.16.1",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
+87
View File
@@ -15,6 +15,7 @@ import {
PlugIcon,
RotateCcwIcon,
RssIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
Trash2Icon,
@@ -63,6 +64,7 @@ import type {
import { PageFrame, PageHeader } from "./components/views/page-layout";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import { desktopClient } from "./lib/desktop-client";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
@@ -740,7 +742,18 @@ function SessionsView({
onRenameSession: (sessionId: string, title: string) => Promise<void> | void;
sessions: WebviewSessionSummary[];
}) {
type SearchHit = {
sessionId: string;
documentId: string;
title: string;
workspaceRoot: string;
role: string;
snippet: string;
};
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searching, setSearching] = useState(false);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [deleteSessionCandidate, setDeleteSessionCandidate] =
@@ -772,6 +785,34 @@ function SessionsView({
});
}, [sessions, sessionFilters, sortDirection]);
useEffect(() => {
const query = searchQuery.trim();
if (!query) {
setSearchHits([]);
setSearching(false);
return;
}
let cancelled = false;
setSearching(true);
const timer = setTimeout(() => {
void desktopClient
.invoke<SearchHit[]>("search_sessions", { query, limit: 50 })
.then((hits) => {
if (!cancelled) setSearchHits(hits);
})
.catch(() => {
if (!cancelled) setSearchHits([]);
})
.finally(() => {
if (!cancelled) setSearching(false);
});
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [searchQuery]);
const startRenameSession = (session: WebviewSessionSummary) => {
setEditingSessionId(session.sessionId);
setEditingTitle(session.title || shortId(session.sessionId));
@@ -894,6 +935,52 @@ function SessionsView({
</>
}
/>
<div className="relative mb-3">
<SearchIcon className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search all session history"
className="pl-9"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search messages, commands, errors, and file paths across all sessions…"
value={searchQuery}
/>
</div>
{searchQuery.trim() ? (
<section className="mb-4 overflow-hidden rounded-lg border bg-card">
{searching ? (
<p className="px-4 py-5 text-sm text-muted-foreground">
Searching
</p>
) : searchHits.length === 0 ? (
<p className="px-4 py-5 text-sm text-muted-foreground">
No matching session history.
</p>
) : (
searchHits.map((hit) => (
<button
className="block w-full border-b px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
key={hit.documentId}
onClick={() => onOpenSession(hit.sessionId)}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<span className="truncate">{hit.title}</span>
<span className="text-xs font-normal text-muted-foreground">
{hit.role}
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{hit.snippet}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{hit.workspaceRoot}
</p>
</button>
))
)}
</section>
) : null}
<section className="w-full min-w-0 overflow-x-auto">
<div className="grid w-full min-w-[56rem] grid-cols-[minmax(12rem,1.35fr)_minmax(7rem,0.85fr)_minmax(10rem,1.1fr)_5rem_5rem_4.5rem_5.5rem_2rem] gap-x-4 bg-muted/40 px-4 py-3 text-[15px] font-medium text-muted-foreground">
+5 -2
View File
@@ -1,6 +1,9 @@
"use client";
import type { GeneratedMedia } from "@cline/shared/browser";
import {
type GeneratedMedia,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared/browser";
import { GeneratedMediaContent } from "@cline/ui";
import {
CheckIcon,
@@ -1182,7 +1185,7 @@ export default function Chat({
type: "approval_response",
approvalId,
approved,
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
});
setStatus(approved ? "Approval sent." : "Rejection sent.");
};
+151
View File
@@ -1,5 +1,156 @@
# Cline Desktop Changelog
## 0.0.24
- Fixed the live chat stream doubling text and dropping messages mid-turn. The sidecar had two pipes into the same emitter — the core session subscription and the Hub observer — and a session that streams without a local send first (a run already in flight when you open the task, a resumed run, a scheduled run) got every delta emitted twice. The core pipe is now the primary and the observer stands down while it is serving, for the whole busy run rather than a 5s window, so long commands, slow first tokens, and unanswered tool approvals no longer let a duplicate through. Separately, when the sidecar was replaced under a live webview (crash-respawn, Hub drain-and-replace, stale-sidecar swap) its stream counter restarted at 1 and the webview silently discarded everything until the new process counted past the old run — this dropped your own message bubbles and tool rows, not just assistant text, which is why rows appeared to vanish mid-turn and come back afterwards
- Cline no longer stops silently mid-task when a model gets stuck repeating itself. The loop detector stops a run after 5 identical tool calls and the mistake tracker after 6 consecutive failures, but the desktop never registered a decision callback, so the run just ended and the composer went idle with no message. You are now asked how to continue — "Try a different approach" or "Stop this run" — and the guidance is steered into the running turn so the model knows why it was paused instead of repeating the same call
- The `editor` tool's error message now names the file, says whether `old_text` was null or omitted, and states how to recover. Models that fill optional parameters with null (seen with kimi-k3) hit a terse "old_text is required" and re-sent the identical call until the loop detector stopped the run
- Fixed your Cline Pass model selection being replaced when you start a new chat. Catalogs are discovery data, not validation — the bundled catalog can omit live Cline Pass models and refreshes can return partial lists, so a model missing from the catalog was treated as invalid and silently swapped for a default
- Cline Desktop now has a custom title bar on Windows, with caption controls that follow the compact title-bar height in narrow windows and stay above overlays. The Windows taskbar icon was also updated
- Token counts and costs now fill in for every session you can see. The sessions view only ever hydrated the four most recent rows, so every other row showed "-" and paging never asked for more; the visible page is now hydrated on demand, with reads capped and re-run when a session's status changes underneath them
- Sessions imported from Claude Code, Codex, and opencode now say so in the chat, and their foreign history is summarized on the first resumed turn. Imported transcripts keep the source tool's own tool names and schemas, which a model continuing them may try to call — the summary runs once, the original transcript stays intact, and the "Thinking..." indicator reads "Summarizing the imported <tool> history..." while it happens
- Fixed session history rendering empty when one session had many subagent or team-task children. Child rows always sort after the root that spawned them, so a single busy session could hide itself and every older session from the sidebar with no way to load more
- Checkpoints no longer re-hash every untracked file before each message. Checkpoint creation rebuilt a throwaway git index each turn, so multi-GB untracked data blocked every message for seconds to minutes (~90s in one report on a cloud-synced Windows workspace). One snapshot index is now kept per session, so from the second turn the cost is roughly git process overhead. Snapshot contents are byte-identical to before
- Commands that background a child process (`cmd &`, `nohup`, and the same from Git Bash) no longer hang until the timeout. The inherited stdio pipes stay open after the shell exits, so the completion event never arrived even though the command was done; these now settle with the real exit code and a note that background output is no longer captured
- Typing an `@` mention from your home directory no longer indexes your entire home folder. That could take memory into the gigabytes and get the process killed; the home directory and filesystem root are now skipped entirely
- Web search is now enabled by default outside YOLO mode, and tool settings fail closed if they cannot be loaded
- Claude Code no longer asks for an API key it never reads. It authenticates from the local `claude` CLI's own credential store, but was reported as an API-key provider, so a keyless entry was refused and the workaround was to save a dummy key
- Pasted credentials with invisible characters no longer persist corrupted. A BOM or zero-width character carried in from a copy-paste produced 401s indistinguishable from a wrong key; credential fields are now stripped of control and format characters on save
- The model picker keeps section headers visible while you search. Cline Pass lists the same model in both the Subscribed and Free tiers, so flattening the sections during search produced two identical-looking rows
- `apply_patch` "Add File" now refuses to overwrite an existing file instead of silently replacing it
- Fixed session import paths resolving incorrectly on Windows
- The desktop backend now starts off the command path, so startup no longer blocks the UI
- The SDK can now connect to authenticated remote Hubs
## 0.0.23
- Agent Plugins are now discovered and run by the shared Hub. Packages under `~/.agents/plugins` are validated from their `plugin.json`, their valid Agent Skills become available to the agent, and their stdio / Streamable HTTP / SSE MCP servers start automatically. Settings → Customize lists Agent Plugins separately from Cline Plugins, with each plugin's description, badge, and contributed tools, and enable/disable is Hub-managed per plugin. Workspace `.agents/plugins` directories are intentionally ignored
- The "Cline Hub was updated" dialog no longer appears on every launch and reconnect. The app no longer prompts about a Hub running the same core version it does — a desktop and CLI release cut from different commits bundle the same core but never share a build fingerprint, so anyone with both installed got a dialog whose "Update and restart" looped on "no app update available". The build-mismatch dialog now also waits until an app update is actually staged, and "Later" sticks across session switches, reloads, and relaunches instead of resurfacing every time. A Hub the app genuinely cannot talk to still warns every time
- Signing in now shows the device confirmation code in the app while you wait on the browser, so you can match it against the code the browser asks you to confirm — in onboarding, Account settings, and the provider list
- Voice input failures caused by provider setup — missing credentials, transcription config — now take you straight to voice settings instead of a toast you cannot act on. Genuine microphone permission failures still toast, with a clearer message
- Fixed the scheduled-task report vanishing when a finished run's step collapsed
- Fixed one wedged MCP server blocking the rest from shutting down, leaking their processes
## 0.0.22
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
- Runs of a schedule now fold into a single collapsible sidebar row named after the schedule, with its run count, instead of one row per run all carrying the same prompt title. Expanding lists them newest-first as "Run N" with the usual status dot, time, hover card, context menu, and delete; the group holding the active session opens on its own
- Voice input now works on macOS. The app shipped without a microphone usage description or entitlement, so dictation failed silently
- Web search is now on by default
- The marketplace detail panel now opens on click rather than hover, with left-aligned content, a single "Learn more" link, and the selected entry staying open while you filter the list
- When the Hub is older than the app, you are now offered a choice — replace it, with a count of the sessions that would be interrupted, or keep it running — instead of the app quietly working against stale code. Replacing drains the Hub first so in-flight turns finish
- Editing and resending a message now works on sessions with no checkpoint history, such as imported ones, instead of failing with "No checkpoint found at or before run N"
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- The message the model receives when you reject a tool call now names the tool and reads as your decision rather than an error
- Refreshed the model catalog. Adds eight providers (Bothub, OpenReason, SenseNova (China), TokenRouter, Vancine, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and changes the resolved default model for 36 providers — most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Kilo Gateway, DevPass, DigitalOcean, CrossModel, and Eden AI following. If you use a provider without pinning a model, expect a different default
## 0.0.21
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
- Stopping a session now actually stops everything it started. Stop stays available while child agents are running, and an abort propagates to delegated subagents and to teammates instead of leaving orphaned work running in the background; cancelled teammate tasks now persist as cancelled
- Fixed the ask-a-question tool's option text overflowing instead of wrapping
- You can now drop file attachments anywhere over the chat input, not just on the small attach target
- Cline provider models now refresh from the live catalog, so newly released models show up without waiting for an app update
- Provider 401/403 responses are now classified as authentication errors rather than generic request failures, so a bad or missing API key is distinguishable from a real provider outage
- Fixed Langfuse tracing never initializing in release builds — the minified bundle broke tracer detection, so telemetry worked in dev and silently did nothing in the shipped app. Also updated for AI SDK 7's telemetry API
- Refreshed the model catalog. Adds TokenGo and Volcengine Ark, and updates model lists, pricing, and the resolved default model for ~36 providers (including Hugging Face, Mistral, OpenRouter, Together, NanoGPT, Requesty, Baseten, Cloudflare Workers AI, and DigitalOcean) — if you use one of those without pinning a model, you will get a different default
## 0.0.20
- Customize now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugin switches use Hub-managed enablement, contributed skills appear in the Skills inventory, and connected desktop views refresh when Hub settings change
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
- Session search now covers your full indexed history. The sidebar search icon opens the command bar (Cmd/Ctrl+P) with server-ranked results, instead of a sidebar-local dialog that first loaded every session into memory
- Onboarding has a new GitHub integration step
- Fixed scheduled tasks disappearing after the app updated — hub-managed schedules were being wiped by cron reconciliation on restart
- Agent-created schedules now live in one user-level home (`~/.cline/schedules`) instead of being scattered across whichever chat folder created them, and they now appear on the Schedules page
- A finished scheduled session now surfaces its final answer: the completing step auto-expands, is labeled "Scheduled task completed" (or failed), and its summary renders as markdown
- Suggested routine templates now ask for a specific final report, so a scheduled run ends with something readable
- Providers no longer show as "Configured" on the strength of a leftover settings entry with no real credentials, and the badge now updates live after connecting or saving credentials instead of waiting for a remount
- Fixed OpenAI Codex (ChatGPT subscription) sign-in silently dead-ending when callback port 1455 was already in use — it now fails immediately with an actionable error, and OAuth redirect errors surface instead of a confusing "Missing authorization code"
- Codex and OCA sign-ins are no longer dropped when a token refresh hits a transient network failure or server error
- Checkpoint restore now refuses to reset your workspace when commits were made after the checkpoint, instead of silently knocking them off the branch
- Fixed an enabled-but-offline remote MCP server stalling session startup until the session was torn down
- Global rules stored at `~/Cline/Rules` are now discovered (previously only `~/Documents/Cline/Rules`), fixing rules that never reached the model on WSL and headless installs
- `apply_patch` now preserves a file's own CRLF line endings
- The window title bar stays draggable across every view
- Voice input's Live and After recording badges now have tooltips explaining them
- Removed the box shadow from the chat message actions row
- The hub no longer watches agenda spec directories while the todo tool is disabled, dropping an OS watch handle per known workspace
## 0.0.19
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 0.0.18
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
- "Run now" now hands you into the session it starts
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
- Fixed quitting the app beach-balling for several seconds
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
- Fixed hover cards flashing closed and reopening when clicked
- The macOS DMG install window now has custom Cline artwork and layout
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
## 0.0.17
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
- Added suggested schedule templates to the Schedule page
- Add Provider opens a dialog instead of swapping out the page
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
- Fixed the provider list being unscrollable while a provider detail panel was open
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
- Fixed Uninstall buttons collapsing to a broken square next to Install
- Fixed unreadable selected text inside input fields
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing the app on files containing a single enormous line
- The hub's event log can no longer grow until it fills your disk
## 0.0.16
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
- The app now honors server-side feature flags, refreshing them when your account changes
## 0.0.15
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
- Refreshed app icons and branding
- Reskinned the first-run onboarding, with an interactive welcome graphic
- Plugins, MCP servers, and Skills are now one Plugins hub with a dedicated Marketplace page
- The composer's model selector now leads with Recommended and Free tiers (Subscribed and Free on ClinePass), labeled by display name with descriptions, instead of an alphabetized list of raw model ids. Provider settings show the same badges and descriptions
- Agents can now create and manage durable todos and one-time or recurring schedules
- Fixed checkpoint restore wedging permanently. Sessions that were never prompted — and persistence-only updates — reported a bogus "running" status, so anything gated on an active turn stayed blocked forever
- Fixed "No sessions found" flashing while session history was still loading
- Fixed the work summary undercounting elapsed time when thinking before a tool call attached to the answer instead of the run
- Fixed the settings gear keeping its hover state while the Account screen is open
- Fixed ClinePass not being recognized as OAuth-managed in the chat credential gate, which asked for credentials it already had
- Fixed copying a user message bringing along its internal envelope
- Fixed multi-line code blocks collapsing onto a single line
- Image, voice, and other non-chat models are no longer offered in chat model pickers
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hook output and `cancel` control being discarded
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown
- PowerShell commands now fail fast on the first error instead of flooding output and still reporting success
- Usage now displays the billed gateway cost
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
## 0.0.14
- The app now posts native macOS notifications when a task finishes or needs your input, so you can leave Cline working in the background. Configure them under Settings → Notifications.
+32
View File
@@ -17,6 +17,38 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Customizing the macOS Install Window
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
`background.gen.tiff` Finder actually renders is a gitignored build artifact
regenerated from them on every build.
1. The current source artwork is `640x400`. Export `background.png` at 1x and
`background@2x.png` at 2x.
2. Currently the app icons are centered at `(140, 200)` and
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
and `applicationFolderPosition` to reposition the app icons.
3. Build with `bun run build:binary`. Before compiling, the build validates
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
expected 1x and 2x representations. Run `bun run dmg:background` to do just
that step, e.g. to sanity-check new artwork without a full build. The DMG
is written beneath `src-tauri/target/release/bundle/dmg/`.
Run `bun run test:dmg-background` for the cross-platform checks covering the
committed PNG dimensions and TIFF validation logic.
The configured `640x432` Finder window is intentionally 32 points taller than
the `640x400` background. That extra height matches the Finder chrome in the
currently verified packaged layout; re-check it after material macOS or Finder
changes. The project deliberately uses a multi-resolution TIFF even though
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
both the 1x and 2x representations from a single background file. Re-check the
packaged DMG after upgrading Tauri in case its background validation changes.
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.14",
"version": "0.0.24",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -15,6 +15,8 @@
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"dmg:background": "bun run scripts/dmg-background.ts",
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
@@ -33,10 +35,10 @@
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@pierre/diffs": "^1.3.0",
"@fontsource-variable/geist-mono": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^3.9.1",
"@pierre/diffs": "^1.3.0",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
@@ -80,6 +82,7 @@
"next": "16.2.11",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -15,9 +15,7 @@ async function reserveAvailablePort(): Promise<number> {
reject(new Error("Failed to reserve a sidecar port"));
return;
}
server.close((error) =>
error ? reject(error) : resolve(address.port),
);
server.close((error) => (error ? reject(error) : resolve(address.port)));
});
});
}
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test";
import path from "node:path";
import {
parseTiffInfo,
readPngDimensions,
validateTiffRepresentations,
} from "./dmg-background";
const DMG_ROOT = path.resolve(import.meta.dir, "..", "src-tauri", "dmg");
const EXPECTED_REPRESENTATIONS = [
{ width: 640, height: 400, dpiX: 72, dpiY: 72 },
{ width: 1280, height: 800, dpiX: 144, dpiY: 144 },
];
const TIFF_INFO = `Directory at 0x1
Image Width: 640 Image Length: 400
Resolution: 72, 72
Resolution Unit: pixels/inch
Directory at 0x2
Image Width: 1280 Image Length: 800
Resolution: 144, 144
Resolution Unit: pixels/inch
`;
describe("parseTiffInfo", () => {
test("reads the dimensions and DPI of every TIFF representation", () => {
expect(parseTiffInfo(TIFF_INFO)).toEqual(EXPECTED_REPRESENTATIONS);
});
test("rejects representations without pixel-per-inch resolution", () => {
expect(() =>
parseTiffInfo(TIFF_INFO.replace("pixels/inch", "pixels/cm")),
).toThrow(/could not parse TIFF representation/);
});
});
describe("DMG source artwork", () => {
test("has the expected 1x and 2x dimensions", async () => {
const [dimensions1x, dimensions2x] = await Promise.all([
readPngDimensions(path.join(DMG_ROOT, "background.png")),
readPngDimensions(path.join(DMG_ROOT, "background@2x.png")),
]);
expect(dimensions1x).toEqual({ width: 640, height: 400 });
expect(dimensions2x).toEqual({ width: 1280, height: 800 });
});
});
describe("validateTiffRepresentations", () => {
test("accepts the expected representations", () => {
expect(() =>
validateTiffRepresentations(EXPECTED_REPRESENTATIONS),
).not.toThrow();
});
test("rejects the wrong number of representations", () => {
expect(() =>
validateTiffRepresentations(EXPECTED_REPRESENTATIONS.slice(0, 1)),
).toThrow(/exactly two image representations/);
});
test("rejects incorrect representation dimensions", () => {
expect(() =>
validateTiffRepresentations([
EXPECTED_REPRESENTATIONS[0],
{ ...EXPECTED_REPRESENTATIONS[1], width: 1279 },
]),
).toThrow(/must be 1280x800/);
});
test("rejects incorrect representation DPI", () => {
expect(() =>
validateTiffRepresentations([
{ ...EXPECTED_REPRESENTATIONS[0], dpiX: 73 },
EXPECTED_REPRESENTATIONS[1],
]),
).toThrow(/must be 72x72 DPI/);
});
});
@@ -0,0 +1,175 @@
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { $ } from "bun";
type Dimensions = {
width: number;
height: number;
};
type TiffRepresentation = Dimensions & {
dpiX: number;
dpiY: number;
};
const APP_ROOT = path.resolve(import.meta.dir, "..");
const DMG_ROOT = path.join(APP_ROOT, "src-tauri", "dmg");
const BACKGROUND_1X = path.join(DMG_ROOT, "background.png");
const BACKGROUND_2X = path.join(DMG_ROOT, "background@2x.png");
// Gitignored build artifact; only the PNG sources are committed.
const BACKGROUND_TIFF = path.join(DMG_ROOT, "background.gen.tiff");
const EXPECTED_1X = { width: 640, height: 400 };
const EXPECTED_2X = { width: 1280, height: 800 };
const EXPECTED_TIFF_REPRESENTATIONS: TiffRepresentation[] = [
{ ...EXPECTED_1X, dpiX: 72, dpiY: 72 },
{ ...EXPECTED_2X, dpiX: 144, dpiY: 144 },
];
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
// PNG stores its big-endian width and height in the fixed IHDR fields at
// byte offsets 16 and 20, so dimensions can be checked without an image library.
export const readPngDimensions = async (
filePath: string,
): Promise<Dimensions> => {
const contents = await readFile(filePath);
const hasPngSignature = PNG_SIGNATURE.every(
(byte, index) => contents[index] === byte,
);
if (
contents.length < 24 ||
!hasPngSignature ||
contents.toString("ascii", 12, 16) !== "IHDR"
) {
throw new Error(`${filePath} is not a valid PNG with an IHDR header`);
}
return {
width: contents.readUInt32BE(16),
height: contents.readUInt32BE(20),
};
};
const assertDimensions = (
label: string,
actual: Dimensions,
expected: Dimensions,
): void => {
if (actual.width !== expected.width || actual.height !== expected.height) {
throw new Error(
`${label} must be ${expected.width}x${expected.height}, got ${actual.width}x${actual.height}`,
);
}
};
// tiffutil prints one "Directory at ..." block for each image representation
// embedded in the TIFF.
export const parseTiffInfo = (output: string): TiffRepresentation[] =>
output
.split(/(?=Directory at )/)
.filter((block) => block.startsWith("Directory at "))
.map((block) => {
const dimensions = block.match(
/Image Width:\s*(\d+)\s+Image Length:\s*(\d+)/,
);
const resolution = block.match(/Resolution:\s*([\d.]+),\s*([\d.]+)/);
if (
!dimensions ||
!resolution ||
!block.includes("Resolution Unit: pixels/inch")
) {
throw new Error(`could not parse TIFF representation:\n${block}`);
}
return {
width: Number(dimensions[1]),
height: Number(dimensions[2]),
dpiX: Number(resolution[1]),
dpiY: Number(resolution[2]),
};
});
export const validateTiffRepresentations = (
representations: TiffRepresentation[],
label = "TIFF",
): void => {
const sortedRepresentations = [...representations].sort(
(left, right) => left.width - right.width,
);
if (sortedRepresentations.length !== EXPECTED_TIFF_REPRESENTATIONS.length) {
throw new Error(
`${label} must contain exactly two image representations, got ${sortedRepresentations.length}`,
);
}
for (const [index, expected] of EXPECTED_TIFF_REPRESENTATIONS.entries()) {
const actual = sortedRepresentations[index];
assertDimensions(`${label} representation ${index + 1}`, actual, expected);
if (actual.dpiX !== expected.dpiX || actual.dpiY !== expected.dpiY) {
throw new Error(
`${label} representation ${index + 1} must be ${expected.dpiX}x${expected.dpiY} DPI, got ${actual.dpiX}x${actual.dpiY} DPI`,
);
}
}
};
const assertTiffRepresentations = async (filePath: string): Promise<void> => {
const representations = parseTiffInfo(
await $`tiffutil -info ${filePath}`.quiet().text(),
);
validateTiffRepresentations(representations, filePath);
};
const assertSourceDimensions = async (): Promise<void> => {
const [dimensions1x, dimensions2x] = await Promise.all([
readPngDimensions(BACKGROUND_1X),
readPngDimensions(BACKGROUND_2X),
]);
assertDimensions("background.png", dimensions1x, EXPECTED_1X);
assertDimensions("background@2x.png", dimensions2x, EXPECTED_2X);
};
const generateTiff = async (outputPath: string): Promise<void> => {
// Finder's .DS_Store references one background file. A multi-representation
// TIFF lets AppKit select the 1x or 2x bitmap without relying on it to discover
// a separate @2x companion beside that referenced file.
await $`tiffutil -cathidpicheck ${BACKGROUND_1X} ${BACKGROUND_2X} -out ${outputPath}`.quiet();
await assertTiffRepresentations(outputPath);
};
const main = async (): Promise<void> => {
if (process.argv.length > 2) {
throw new Error("usage: bun run dmg:background");
}
if (process.platform !== "darwin") {
// Runs from beforeBuildCommand on every platform, but only macOS builds
// bundle a DMG and only macOS ships tiffutil.
console.log("Skipping DMG background generation on non-macOS host.");
return;
}
await assertSourceDimensions();
// Generate and validate in scratch space so the configured build artifact is
// replaced only after tiffutil has produced a complete, verified TIFF.
const scratchRoot = await mkdtemp(
path.join(tmpdir(), "cline-dmg-background-"),
);
const generatedTiff = path.join(scratchRoot, "background.tiff");
try {
await generateTiff(generatedTiff);
await copyFile(generatedTiff, BACKGROUND_TIFF);
console.log(`Generated ${path.relative(APP_ROOT, BACKGROUND_TIFF)}.`);
} finally {
await rm(scratchRoot, { force: true, recursive: true });
}
};
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
@@ -77,6 +77,66 @@ describe("buildUpdateManifest", () => {
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("maps a Windows NSIS setup artifact to windows-x86_64", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x64-setup.exe.sig"),
"sig-windows-x64\n",
);
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(manifest.platforms["windows-x86_64"]).toEqual({
signature: "sig-windows-x64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x64-setup.exe",
});
// darwin entries from the universal artifact are unaffected.
expect(Object.keys(manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
]);
});
test("ignores non-updater exe files without a setup arch suffix", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64.exe"), "exe");
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(Object.keys(manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
]);
});
test("throws when a Windows setup artifact is missing its signature", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow();
});
test("throws when universal and per-arch artifacts claim the same platform", () => {
const dir = makePerArchArtifactDir();
writeFileSync(
@@ -24,17 +24,25 @@ export type UpdateManifest = {
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// Maps the arch token embedded in macOS artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
// A universal (fat) bundle serves both macOS architectures: each slice of the
// installed app requests its own compile-time arch key at runtime, and both
// keys point at the same artifact and signature.
const PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
const MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
aarch64: ["darwin-aarch64"],
x86_64: ["darwin-x86_64"],
universal: ["darwin-aarch64", "darwin-x86_64"],
};
// On Windows the updater artifact is the NSIS installer itself
// (createUpdaterArtifacts signs the setup exe with the updater key), named
// `<Product>_<version>_<arch>-setup.exe` by the Tauri bundler.
const WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
x64: ["windows-x86_64"],
arm64: ["windows-aarch64"],
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
@@ -45,13 +53,22 @@ const getArgValue = (args: string[], name: string): string | undefined => {
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
const platformKeysOfUpdaterArtifact = (
fileName: string,
): string[] | undefined => {
if (fileName.endsWith(".app.tar.gz")) {
const arch = Object.keys(MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
(candidate) => fileName.includes(`_${candidate}`),
);
return arch ? MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
}
return Object.keys(PLATFORM_KEYS_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
if (fileName.endsWith("-setup.exe")) {
const arch = Object.keys(WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
(candidate) => fileName.endsWith(`_${candidate}-setup.exe`),
);
return arch ? WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
}
return undefined;
};
export const buildUpdateManifest = (options: {
@@ -65,8 +82,8 @@ export const buildUpdateManifest = (options: {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
const platformKeys = platformKeysOfUpdaterArtifact(fileName);
if (!platformKeys) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
@@ -74,7 +91,7 @@ export const buildUpdateManifest = (options: {
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
for (const platformKey of PLATFORM_KEYS_BY_ARCH_SUFFIX[arch]) {
for (const platformKey of platformKeys) {
if (platforms[platformKey]) {
throw new Error(
`multiple updater artifacts claim platform ${platformKey}; found ${fileName} after ${platforms[platformKey].url}`,
@@ -89,7 +106,7 @@ export const buildUpdateManifest = (options: {
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
`no updater artifacts (*.app.tar.gz or *-setup.exe with a known arch suffix) found in ${options.dir}`,
);
}
@@ -0,0 +1,77 @@
# Authenticode-signs one PE file with Azure Trusted Signing via jsign.
#
# Invoked by the Tauri bundler through `bundle > windows > signCommand` (the
# desktop-publish workflow generates a config overlay pointing here), once per
# binary it stages: the main app exe, the code-sidecar external binary, the
# NSIS uninstaller, and the NSIS installer itself.
#
# Requirements (all provided by the desktop-publish Windows job):
# - an azure/login OIDC session (jsign's token comes from `az account get-access-token`)
# - AZURE_TRUSTED_SIGNING_ENDPOINT / _ACCOUNT_NAME / _CERTIFICATE_PROFILE env vars
# - java on PATH (preinstalled on GitHub Windows runners)
#
# Mirrors the CLI pipeline (.github/actions/sign-windows-cli): same jsign
# version and flags, same Microsoft timestamp service. Kept as a standalone
# script so the signing behavior is reviewable in the repo rather than inlined
# in a generated config string.
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $Path
)
$ErrorActionPreference = "Stop"
$jsignVersion = "7.5"
$jsignSha256 = "602A51C3545A6DC4FB99BD2EA7152B26D1345916D0C93DDFBD5936CB735AF91C"
$endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
$account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
$certProfile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
if (-not $endpoint -or -not $account -or -not $certProfile) {
throw "Azure Trusted Signing env vars are not set (AZURE_TRUSTED_SIGNING_ENDPOINT/_ACCOUNT_NAME/_CERTIFICATE_PROFILE)"
}
$resolved = (Resolve-Path $Path).Path
# jsign expects the endpoint host; tolerate the portal's trailing-slash form.
$keystore = $endpoint -replace '^https://', '' -replace '/$', ''
$jar = Join-Path $env:RUNNER_TEMP "jsign-$jsignVersion.jar"
if (-not (Test-Path $jar)) {
Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$jsignVersion/jsign-$jsignVersion.jar" -OutFile $jar
}
$actualHash = (Get-FileHash -Algorithm SHA256 $jar).Hash
if ($actualHash -ne $jsignSha256) {
Remove-Item $jar -Force
throw "jsign jar checksum mismatch: expected $jsignSha256, got $actualHash"
}
# Short-lived bearer token from the azure/login OIDC session. Fetched per
# invocation (signCommand runs once per file) so a long Rust build beforehand
# can never leave us with an expired token. Passed to jsign via env, not argv.
$env:JSIGN_STOREPASS = (az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
if (-not $env:JSIGN_STOREPASS) {
throw "failed to acquire an Azure access token; is azure/login configured on this job?"
}
Write-Host "Signing $resolved"
java -jar $jar `
--storetype TRUSTEDSIGNING `
--keystore $keystore `
--storepass env:JSIGN_STOREPASS `
--alias "$account/$certProfile" `
--alg SHA-256 `
--tsaurl http://timestamp.acs.microsoft.com `
--tsmode RFC3161 `
--replace `
$resolved
if ($LASTEXITCODE -ne 0) {
throw "jsign failed for $resolved (exit $LASTEXITCODE)"
}
$signature = Get-AuthenticodeSignature $resolved
if ($signature.Status -ne "Valid") {
throw "signature verification failed for ${resolved}: $($signature.Status) - $($signature.StatusMessage)"
}
Write-Host "Signed and verified: $resolved ($($signature.SignerCertificate.Subject))"
@@ -16,6 +16,7 @@ sidecar/
├── index.ts # Entry point: starts HTTP+WS server
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── client-context.ts # Desktop client/account identity for shared telemetry
├── commands.ts # Command router
├── chat-session.ts # Shared-Hub chat session adapter
├── session-data/ # Shared discovery, messages, artifacts, search helpers
@@ -81,6 +82,15 @@ The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
Every create, restart, fork, and restore also attaches the serializable Desktop
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
that context across the Hub transport and scopes the daemon-owned telemetry
service to the originating surface. This keeps lifecycle events centralized in
Core while reporting Desktop dimensions (`cline_type: "desktop"`, `platform:
"Cline Desktop"`, and the Desktop app version) and the current account and
organization. The shared Hub telemetry singleton is never mutated per session,
so concurrent CLI and Desktop tasks retain their own attribution.
### 2. Tool Approval — Client-Owned Promise Resolution
The shared Hub routes approval requests back to the client that created the
@@ -13,6 +13,8 @@ import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
createDesktopMistakeLimitPrompt,
createDesktopMistakeRecovery,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
@@ -22,7 +24,11 @@ import {
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import { handleCoreSessionEvent } from "./context";
import {
handleCoreSessionEvent,
requestSidecarAskQuestion,
resolveSidecarAskQuestion,
} from "./context";
import type { SidecarContext } from "./types";
describe("resolveDesktopSessionMode", () => {
@@ -195,25 +201,49 @@ describe("hasProviderChanged", () => {
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
expect(input.config).not.toHaveProperty("enableSpawnAgent");
expect(input.config).not.toHaveProperty("enableAgentTeams");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const start = vi.fn(
async (input: {
config: Record<string, unknown>;
localRuntime?: {
extensionContext?: {
client?: Record<string, unknown>;
user?: Record<string, unknown>;
};
};
}) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
expect(input.config).not.toHaveProperty("enableSpawnAgent");
expect(input.config).not.toHaveProperty("enableAgentTeams");
expect(input.localRuntime?.extensionContext?.client).toMatchObject({
name: "cline-desktop",
platform: "Cline Desktop",
});
expect(input.localRuntime?.extensionContext?.user).toEqual({
distinctId: "account-1",
accountId: "account-1",
organizationId: "org-1",
});
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
},
);
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
telemetryUser: {
distinctId: "account-1",
accountId: "account-1",
organizationId: "org-1",
},
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
@@ -308,6 +338,7 @@ describe("session forks", () => {
start,
},
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -429,6 +460,7 @@ describe("session forks", () => {
send,
},
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -462,7 +494,91 @@ describe("session forks", () => {
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("keeps a full-history fork on the current workspace without restoring", async () => {
it("forks trimmed messages without restoring when the edited run has no checkpoint", async () => {
const sourceSessionId = `source-imported-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "imported prompt" },
{ role: "assistant" as const, content: "imported response" },
{ role: "user" as const, content: "prompt to edit" },
{ role: "assistant" as const, content: "response to replace" },
];
const expectedMessages = sourceMessages.slice(0, 2);
const start = vi.fn(async () => ({ sessionId: "imported-fork" }));
const restore = vi.fn(async () => {
throw new Error("restore must not run without a checkpoint");
});
const readMessages = vi.fn(async () => expectedMessages);
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
messages: sourceMessages,
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "completed",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
importedFrom: { tool: "codex", sourceId: "cdx-1" },
},
})),
readMessages,
restore,
start,
},
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 2,
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
})) as { sessionId: string };
expect(restore).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
initialMessages: expectedMessages,
sessionMetadata: expect.objectContaining({
fork: expect.objectContaining({
forkedFromSessionId: sourceSessionId,
beforeRunCount: 2,
}),
}),
}),
);
expect(result.sessionId).toBe("imported-fork");
expect(ctx.liveSessions.has(sourceSessionId)).toBe(false);
expect(ctx.liveSessions.get("imported-fork")?.messages).toEqual(
expectedMessages,
);
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("keeps a full-history fork on the current workspace and cancels source questions", async () => {
const sourceSessionId = `source-full-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "first prompt" },
@@ -504,9 +620,21 @@ describe("session forks", () => {
start,
},
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
pendingQuestions: new Map(),
} as unknown as SidecarContext;
const pendingDecision = createDesktopMistakeLimitPrompt(
ctx,
() => sourceSessionId,
)({
iteration: 5,
consecutiveMistakes: 6,
maxConsecutiveMistakes: 6,
reason: "tool_execution_failed",
});
expect(ctx.pendingQuestions.size).toBe(1);
await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
@@ -516,6 +644,8 @@ describe("session forks", () => {
},
});
await expect(pendingDecision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(restore).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ initialMessages: sourceMessages }),
@@ -672,6 +802,7 @@ describe("session forks", () => {
]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
sessionManager: { restore },
} as unknown as SidecarContext;
@@ -795,6 +926,7 @@ describe("first-send connection updates", () => {
]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
@@ -1518,6 +1650,7 @@ Follow the desktop send workflow instructions.`,
liveSessions: new Map([[sessionId, session]]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
coreStreamActivity: new Map(),
wsClients: new Set(),
sessionManager: {
send,
@@ -1670,3 +1803,488 @@ Follow the desktop send workflow instructions.`,
);
});
});
describe("mistake-limit prompt", () => {
function createPromptContext() {
const send = vi.fn();
const steer = vi.fn(async () => undefined);
const ctx = {
wsClients: new Set([{ send }]),
streamIndices: new Map(),
coreStreamActivity: new Map(),
pendingQuestions: new Map(),
liveSessions: new Map(),
sessionManager: {
send: steer,
stop: vi.fn(async () => {}),
abort: vi.fn(async () => {}),
},
} as unknown as SidecarContext;
const readQuestionRequest = () => {
const raw = send.mock.calls
.map(
([encoded]) =>
JSON.parse(String(encoded)) as {
event: { name: string; payload: Record<string, unknown> };
},
)
.find((message) => message.event.name === "ask_question_requested");
return raw?.event.payload as
| {
requestId: string;
sessionId: string;
question: string;
options: string[];
}
| undefined;
};
return { ctx, steer, readQuestionRequest };
}
const limitContext = {
iteration: 15,
consecutiveMistakes: 6,
maxConsecutiveMistakes: 6,
reason: "tool_execution_failed" as const,
details:
"Detected 5 consecutive identical calls to `editor`; stopping to avoid a loop.",
};
it("holds tool and model hooks until Continue has queued recovery guidance", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
let finishSteering!: () => void;
steer.mockImplementationOnce(
() =>
new Promise<undefined>((resolve) => {
finishSteering = () => resolve(undefined);
}),
);
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
expect(recovery.onConsecutiveMistakeLimitReached(limitContext)).toBe(
decision,
);
let released = false;
const waiting = Promise.all([
recovery.hooks.beforeModel(),
recovery.hooks.beforeTool(),
recovery.hooks.afterTool(),
]).then((results) => {
released = true;
return results;
});
await Promise.resolve();
expect(released).toBe(false);
expect(ctx.pendingQuestions.size).toBe(1);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"Try a different approach",
);
await Promise.resolve();
expect(steer).toHaveBeenCalledTimes(1);
expect(released).toBe(false);
finishSteering();
await expect(decision).resolves.toMatchObject({ action: "continue" });
await expect(waiting).resolves.toEqual([undefined, undefined, undefined]);
expect(released).toBe(true);
await expect(recovery.hooks.beforeModel()).resolves.toBeUndefined();
});
it("leaves ordinary questions alone when cancelling a mistake prompt", async () => {
const { ctx, readQuestionRequest } = createPromptContext();
const decision = createDesktopMistakeLimitPrompt(
ctx,
() => "session-1",
)(limitContext);
const mistakeRequestId = readQuestionRequest()?.requestId;
const normalQuestion = requestSidecarAskQuestion(
ctx,
"Which file?",
["a", "b"],
{ sessionId: "session-1", agentId: "desktop", iteration: 1 },
);
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-1",
});
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(1);
const remaining = [...ctx.pendingQuestions.values()][0];
expect(remaining.item.requestId).not.toBe(mistakeRequestId);
resolveSidecarAskQuestion(ctx, remaining.item.requestId, "a");
await expect(normalQuestion).resolves.toBe("a");
});
it.each([
"answer",
"abort",
] as const)("releases waiting hooks with Stop on %s", async (action) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
const waiting = Promise.all([
recovery.hooks.beforeModel(),
recovery.hooks.beforeTool(),
recovery.hooks.afterTool(),
]);
if (action === "answer") {
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"Stop this run",
);
} else {
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-1",
});
}
await expect(decision).resolves.toMatchObject({ action: "stop" });
for (const control of await waiting)
expect(control).toMatchObject({ stop: true });
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).not.toHaveBeenCalled();
});
it("asks the active session's user instead of stopping silently", async () => {
const { ctx, readQuestionRequest } = createPromptContext();
// Session ids are only known after start() resolves; the prompt must
// read the id at prompt time, not at construction time.
let sessionId = "";
const decide = createDesktopMistakeLimitPrompt(ctx, () => sessionId);
sessionId = "session-late";
const decision = decide(limitContext);
const request = readQuestionRequest();
expect(request).toMatchObject({
sessionId: "session-late",
options: ["Try a different approach", "Stop this run"],
});
expect(request?.question).toContain("repeated mistakes or tool calls");
expect(request?.question).toContain("identical calls to `editor`");
expect(
resolveSidecarAskQuestion(ctx, request?.requestId ?? "", "Stop this run"),
).toBe(true);
await expect(decision).resolves.toEqual({
action: "stop",
reason: "stopped after mistake_limit_reached prompt",
});
});
it("delivers recovery guidance only through steering", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
const request = readQuestionRequest();
resolveSidecarAskQuestion(
ctx,
request?.requestId ?? "",
"Try a different approach",
);
const result = await decision;
expect(result).toEqual({ action: "continue" });
expect(steer).toHaveBeenCalledExactlyOnceWith({
sessionId: "session-1",
prompt: expect.stringContaining("Do not repeat the same call"),
delivery: "steer",
});
expect(steer).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining("identical calls to `editor`"),
}),
);
});
it.each([
"stop",
" STOP THIS RUN ",
"2",
"no",
])("treats the free-text answer %s as Stop, like the CLI", async (answer) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decision = createDesktopMistakeLimitPrompt(
ctx,
() => "session-1",
)(limitContext);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
answer,
);
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(steer).not.toHaveBeenCalled();
});
it.each([
"rejected",
"unavailable",
])("stops waiting hooks when steering is %s", async (failure) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
if (failure === "rejected")
steer.mockRejectedValueOnce(new Error("Disconnected"));
else ctx.sessionManager = null;
const recovery = createDesktopMistakeRecovery(ctx, () => "session-1");
const decision = recovery.onConsecutiveMistakeLimitReached(limitContext);
const waiting = Promise.all([
recovery.hooks.beforeModel(),
recovery.hooks.beforeTool(),
recovery.hooks.afterTool(),
]);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"Try a different approach",
);
await expect(decision).resolves.toMatchObject({
action: "stop",
reason: expect.stringContaining("Could not send recovery guidance"),
});
for (const result of await waiting)
expect(result).toMatchObject({ stop: true });
expect(ctx.pendingQuestions.size).toBe(0);
});
it("passes free-text answers through as user guidance", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
resolveSidecarAskQuestion(
ctx,
readQuestionRequest()?.requestId ?? "",
"read the file first, then edit",
);
await expect(decision).resolves.toEqual({ action: "continue" });
expect(steer).toHaveBeenCalledExactlyOnceWith({
sessionId: "session-1",
prompt: expect.stringContaining(
"User guidance: read the file first, then edit",
),
delivery: "steer",
});
});
it("reuses Continue for already-started iterations and asks again for new mistakes", async () => {
const { ctx, steer } = createPromptContext();
ctx.liveSessions.set("session-1", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: 0,
status: "running",
});
const startIteration = (iteration: number) =>
handleCoreSessionEvent(ctx, {
type: "agent_event",
payload: {
sessionId: "session-1",
event: { type: "iteration_start", iteration },
},
});
const answer = (value: string) => {
const pending = [...ctx.pendingQuestions.values()][0];
expect(pending).toBeDefined();
resolveSidecarAskQuestion(ctx, pending.item.requestId, value);
};
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
startIteration(15);
const first = decide(limitContext);
// The model can advance while the client decision is pending.
startIteration(20);
// Do not extend the covered iterations while waiting for the hub's
// steering acknowledgement: a newer step may already have the guidance.
steer.mockImplementationOnce(async () => {
startIteration(21);
return undefined;
});
answer("Try a different approach");
await expect(first).resolves.toMatchObject({ action: "continue" });
// A batch can have many failures in one iteration, followed by more
// failures queued before the user answered. None needs another prompt.
for (const iteration of [
...Array<number>(20).fill(15),
16,
17,
18,
19,
20,
]) {
await expect(decide({ ...limitContext, iteration })).resolves.toEqual({
action: "continue",
});
}
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).toHaveBeenCalledTimes(1);
startIteration(21);
const next = decide({ ...limitContext, iteration: 21 });
answer("Try a different approach");
await expect(next).resolves.toMatchObject({ action: "continue" });
expect(steer).toHaveBeenCalledTimes(2);
// A new user run must not inherit the previous run's decision, even
// though its iteration numbers start over.
startIteration(1);
startIteration(5);
const newRun = decide({ ...limitContext, iteration: 5 });
answer("Stop this run");
await expect(newRun).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).toHaveBeenCalledTimes(2);
});
it("falls back to stopping when no session owns the question", async () => {
const { ctx, steer } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "");
await expect(decide(limitContext)).resolves.toEqual({
action: "stop",
reason: `mistake_limit_reached: ${limitContext.details}`,
});
expect(steer).not.toHaveBeenCalled();
});
it("removes an aborted run's question and rejects late answers", async () => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
const request = readQuestionRequest();
expect(ctx.pendingQuestions.size).toBe(1);
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-1",
});
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(
resolveSidecarAskQuestion(
ctx,
request?.requestId ?? "",
"Try a different approach",
),
).toBe(false);
expect(steer).not.toHaveBeenCalled();
});
it.each([
"stop",
"abort",
"reset",
] as const)("cancels only the owning session's questions on %s", async (action) => {
const { ctx } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const other = createDesktopMistakeLimitPrompt(
ctx,
() => "session-2",
)(limitContext);
const decision = decide(limitContext);
await handleChatSessionCommand(ctx, { action, sessionId: "session-1" });
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(
[...ctx.pendingQuestions.values()].map((p) => p.item.sessionId),
).toEqual(["session-2"]);
await handleChatSessionCommand(ctx, {
action: "abort",
sessionId: "session-2",
});
await other;
expect(ctx.pendingQuestions.size).toBe(0);
});
it("times out an unanswered question and removes it from polling", async () => {
vi.useFakeTimers();
try {
const { ctx, steer } = createPromptContext();
const decide = createDesktopMistakeLimitPrompt(ctx, () => "session-1");
const decision = decide(limitContext);
await vi.advanceTimersByTimeAsync(5 * 60_000);
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(steer).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it.each([
"run",
"session",
])("cancels the question when the %s ends externally", async (kind) => {
const { ctx, steer, readQuestionRequest } = createPromptContext();
const decision = createDesktopMistakeLimitPrompt(
ctx,
() => "session-1",
)(limitContext);
const requestId = readQuestionRequest()?.requestId ?? "";
if (kind === "session")
handleCoreSessionEvent(ctx, {
type: "ended",
payload: { sessionId: "session-1", reason: "stopped", ts: Date.now() },
});
else
handleCoreSessionEvent(ctx, {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "done",
reason: "aborted",
text: "",
iterations: 5,
usage: { inputTokens: 0, outputTokens: 0 },
},
},
});
await expect(decision).resolves.toMatchObject({ action: "stop" });
expect(ctx.pendingQuestions.size).toBe(0);
expect(
resolveSidecarAskQuestion(ctx, requestId, "Try a different approach"),
).toBe(false);
expect(steer).not.toHaveBeenCalled();
});
it("is wired into freshly started sessions as a local runtime option", async () => {
const start = vi.fn(
async (input: {
config: Record<string, unknown>;
localRuntime?: Record<string, unknown>;
}) => {
expect(input.config).not.toHaveProperty(
"onConsecutiveMistakeLimitReached",
);
expect(
typeof input.localRuntime?.onConsecutiveMistakeLimitReached,
).toBe("function");
expect(input.config).not.toHaveProperty("hooks");
expect(input.localRuntime?.hooks).toMatchObject({
beforeModel: expect.any(Function),
beforeTool: expect.any(Function),
afterTool: expect.any(Function),
});
return {
sessionId: "session-limit",
manifest: { cwd: "/tmp/ws", workspace_root: "/tmp/ws" },
manifestPath: "/tmp/session-limit.json",
messagesPath: "/tmp/session-limit.messages.json",
};
},
);
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/ws",
},
});
expect(start).toHaveBeenCalledTimes(1);
});
});
+262 -19
View File
@@ -8,10 +8,12 @@ import {
type ClineCoreStartConfig,
createSessionCompactionState,
createUserInstructionConfigService,
findCheckpointForRun,
getCoreBuiltinToolCatalog,
isSkillsToolAvailable,
projectSessionCompactionState,
readGlobalSettings,
readSessionCheckpointHistory,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
@@ -20,14 +22,27 @@ import {
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
import {
buildClineSystemPrompt,
type ConsecutiveMistakeLimitContext,
type ConsecutiveMistakeLimitDecision,
formatUserCommandBlock,
} from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { createDesktopExtensionContext } from "./client-context";
import {
cancelSidecarMistakeQuestions,
emitChunk,
forgetCorePipe,
nowMs,
requestSidecarAskQuestion,
sendEvent,
} from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import { persistSessionMessages } from "./session-data/messages";
import type {
@@ -399,7 +414,177 @@ function readPositiveInteger(value: unknown): number | undefined {
return undefined;
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
type MistakeLimitDecider = (
context: ConsecutiveMistakeLimitContext,
) => Promise<ConsecutiveMistakeLimitDecision>;
const MISTAKE_LIMIT_CONTINUE_OPTION = "Try a different approach";
const MISTAKE_LIMIT_STOP_OPTION = "Stop this run";
const MISTAKE_LIMIT_DETAIL_MAX_CHARS = 600;
/**
* Desktop counterpart of the CLI's mistake-limit prompt
* (apps/cli/src/runtime/interactive/mistakes.ts).
*
* When the core's loop detector or mistake tracker trips, it asks the client
* how to proceed. Without a decision callback the default is "stop", which
* reaches the webview as a plain aborted turn: indistinguishable from the
* user pressing Stop, with no explanation. A model stuck re-issuing the same
* failing tool call therefore looked like Cline randomly gave up mid-task.
* Route the decision through the existing ask-question channel instead so
* the user sees what went wrong and can choose.
*
* `getSessionId` is read at prompt time: for fresh starts the session id is
* only known after `manager.start()` resolves, and the webview matches the
* prompt to its active session by id.
*/
export function createDesktopMistakeLimitPrompt(
ctx: SidecarContext,
getSessionId: () => string,
): MistakeLimitDecider {
return async (context) => {
const sessionId = getSessionId().trim();
const recovery = ctx.liveSessions.get(sessionId)?.mistakeRecovery;
if (
recovery?.continuedThroughIteration !== undefined &&
context.iteration <= recovery.continuedThroughIteration
) {
// The tracker serializes decisions, so old failures can arrive after
// Continue. The user has already answered for these in-flight steps.
return { action: "continue" };
}
const detail = context.details?.trim() ?? "";
const truncatedDetail =
detail.length > MISTAKE_LIMIT_DETAIL_MAX_CHARS
? `${detail.slice(0, MISTAKE_LIMIT_DETAIL_MAX_CHARS)}`
: detail;
const question = [
"Cline detected repeated mistakes or tool calls and needs your guidance.",
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
"How should Cline continue?",
]
.filter((line) => line.length > 0)
.join("\n");
let answer: string;
try {
answer = await requestSidecarAskQuestion(
ctx,
question,
[MISTAKE_LIMIT_CONTINUE_OPTION, MISTAKE_LIMIT_STOP_OPTION],
{
sessionId,
agentId: "desktop-mistake-limit",
iteration: context.iteration,
},
);
} catch (error) {
// Prompt timed out or the session was torn down: fall back to the
// core's default decision, but keep the reason so the stop is
// attributable.
ctx.logger?.log("Mistake-limit prompt unanswered; stopping run", {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
return {
action: "stop",
reason: `mistake_limit_reached: ${detail || context.reason}`,
};
}
const normalized = answer.trim().toLowerCase();
if (["2", "stop this run", "stop", "n", "no"].includes(normalized)) {
return {
action: "stop",
reason: "stopped after mistake_limit_reached prompt",
};
}
const customGuidance =
normalized.length > 0 &&
normalized !== "1" &&
normalized !== MISTAKE_LIMIT_CONTINUE_OPTION.toLowerCase()
? answer.trim()
: "";
const guidance = [
"The run reached the limit for repeated mistakes or tool calls.",
truncatedDetail ? `Latest: ${truncatedDetail}` : "",
"Do not repeat the same call. Re-check the tool's parameter requirements, fix the call, and try a different approach.",
customGuidance ? `User guidance: ${customGuidance}` : "",
]
.filter((line) => line.length > 0)
.join(" ");
// Use the existing steering queue so the running model receives the
// guidance, including any instructions entered in the desktop prompt.
const manager = ctx.sessionManager;
try {
if (!manager) throw new Error("Desktop session manager is unavailable");
const continuedThroughIteration = Math.max(
context.iteration,
recovery?.latestIteration ?? context.iteration,
);
await manager.send({ sessionId, prompt: guidance, delivery: "steer" });
if (recovery) {
recovery.continuedThroughIteration = continuedThroughIteration;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
ctx.logger?.log("Failed to steer mistake-limit guidance", {
sessionId,
error: detail,
});
// Releasing the hooks without the guidance would resume the same
// failing loop. Only Continue after the steering request succeeds.
return {
action: "stop",
reason: `Could not send recovery guidance: ${detail}`,
};
}
// Steering already delivers the guidance; do not also append it via
// the mistake tracker's recovery-notice path.
return { action: "continue" };
};
}
export function createDesktopMistakeRecovery(
ctx: SidecarContext,
getSessionId: () => string,
) {
const prompt = createDesktopMistakeLimitPrompt(ctx, getSessionId);
let pendingDecision: Promise<ConsecutiveMistakeLimitDecision> | undefined;
const waitForDecision = async () => {
const decision = await pendingDecision;
return decision?.action === "stop"
? { stop: true, reason: decision.reason }
: undefined;
};
return {
onConsecutiveMistakeLimitReached: (
context: ConsecutiveMistakeLimitContext,
) => {
if (!pendingDecision) {
pendingDecision = prompt(context).finally(() => {
pendingDecision = undefined;
});
}
return pendingDecision;
},
hooks: {
// The decision callback alone does not pause the SDK. These existing
// awaited hooks hold desktop runs at tool/model boundaries until the
// user answers. afterTool holds before the next iteration consumes
// the recovery guidance queued by the prompt's Continue action.
beforeModel: waitForDecision,
beforeTool: waitForDecision,
afterTool: waitForDecision,
},
};
}
function buildCoreSessionConfig(
config: JsonRecord,
telemetryUser?: SidecarContext["telemetryUser"],
mistakeRecovery?: ReturnType<typeof createDesktopMistakeRecovery>,
): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
@@ -442,6 +627,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
checkpoint: { enabled: true },
sessions: config.sessions,
initialMessages: config.initialMessages,
extensionContext: createDesktopExtensionContext(telemetryUser),
...mistakeRecovery,
};
}
@@ -672,8 +859,14 @@ async function handleStart(
: requestedSessionId
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
: undefined;
// Resolved once start() returns; the mistake-limit prompt reads it lazily.
let startedSessionId = requestedSessionId;
const coreConfig: JsonRecord = {
...buildCoreSessionConfig(request.config),
...buildCoreSessionConfig(
request.config,
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => startedSessionId),
),
systemPrompt,
...(initialMessages ? { initialMessages } : {}),
};
@@ -695,6 +888,7 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
startedSessionId = sessionId;
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
@@ -792,6 +986,7 @@ async function handleAttach(
async function startRebuiltSession(
manager: ClineCore,
ctx: SidecarContext,
sessionId: string,
config: JsonRecord,
systemPrompt: string,
@@ -803,11 +998,15 @@ async function startRebuiltSession(
: undefined;
const restarted = await manager.start({
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
sessionId,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
buildCoreSessionConfig(
{
...config,
sessionId,
systemPrompt,
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => sessionId),
) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -852,11 +1051,14 @@ async function rebuildSessionForProviderChange(
resolveSystemPrompt(nextConfig),
]);
cancelSidecarMistakeQuestions(ctx, sessionId, "Session provider changed");
await manager.stop(sessionId);
forgetCorePipe(ctx, sessionId);
let replacementStarted = false;
try {
await startRebuiltSession(
manager,
ctx,
sessionId,
nextConfig,
nextSystemPrompt,
@@ -875,9 +1077,11 @@ async function rebuildSessionForProviderChange(
try {
if (replacementStarted) {
await manager.stop(sessionId);
forgetCorePipe(ctx, sessionId);
}
await startRebuiltSession(
manager,
ctx,
sessionId,
previousConfig,
previousSystemPrompt,
@@ -1131,7 +1335,11 @@ async function handleStop(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
cancelSidecarMistakeQuestions(ctx, sessionId, "Session stopped");
await getSessionManager(ctx).stop(sessionId);
// stop() disposes the ClineCore subscription; the observer must be free to
// serve any later run another client starts on this session.
forgetCorePipe(ctx, sessionId);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1146,6 +1354,7 @@ async function handleAbort(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
cancelSidecarMistakeQuestions(ctx, sessionId, "Run aborted");
await getSessionManager(ctx).abort(sessionId, "user_abort");
const session = ctx.liveSessions.get(sessionId);
if (session) {
@@ -1287,20 +1496,35 @@ async function handleForkUnlocked(
},
};
const systemPrompt = await resolveSystemPrompt(forkConfig);
// Assigned below once the forked session exists; read lazily by the prompt.
let newSessionId = "";
const startInput = {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...forkConfig,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
buildCoreSessionConfig(
{
...forkConfig,
systemPrompt,
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => newSessionId),
) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
sessionMetadata: forkMetadata,
toolPolicies: resolveToolPolicies(forkConfig),
};
let newSessionId: string;
if (forkBeforeRunCount !== undefined) {
// Sessions without a checkpoint at or before the edited run (imported
// transcripts, checkpoints disabled) have no workspace state to roll back,
// so fork the trimmed messages onto the current workspace instead of
// failing the edit.
const canRestoreWorkspace =
forkBeforeRunCount !== undefined &&
findCheckpointForRun(
readSessionCheckpointHistory({ metadata: sourceMetadata }),
forkBeforeRunCount,
) !== undefined;
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
const cwd =
restoreWorkspacePath ||
(typeof forkConfig.cwd === "string" && forkConfig.cwd.trim()) ||
@@ -1342,6 +1566,11 @@ async function handleForkUnlocked(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
cancelSidecarMistakeQuestions(
ctx,
sourceSessionId,
"Session replaced by fork",
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
@@ -1366,6 +1595,7 @@ async function handleReset(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (sessionId) {
cancelSidecarMistakeQuestions(ctx, sessionId, "Session reset");
const session = ctx.liveSessions.get(sessionId);
if (
session?.busy ||
@@ -1375,6 +1605,7 @@ async function handleReset(
) {
await getSessionManager(ctx).stop(sessionId);
}
forgetCorePipe(ctx, sessionId);
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
@@ -1404,6 +1635,8 @@ async function handleRestoreCheckpoint(
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
const manager = getSessionManager(ctx);
return withWorkspaceRestoreLock(ctx, cwd, async () => {
// Updated once restore() returns; read lazily by the mistake-limit prompt.
let restoredSessionId = sourceSessionId;
const restored = await manager.restore({
sessionId: sourceSessionId,
checkpointRunCount: runCount,
@@ -1411,10 +1644,14 @@ async function handleRestoreCheckpoint(
restore: { messages: true, workspace: true },
start: {
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
systemPrompt: await resolveSystemPrompt(config),
}) as unknown as ClineCoreStartConfig,
buildCoreSessionConfig(
{
...config,
systemPrompt: await resolveSystemPrompt(config),
},
ctx.telemetryUser,
createDesktopMistakeRecovery(ctx, () => restoredSessionId),
) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1426,10 +1663,16 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
restoredSessionId = sessionId;
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
cancelSidecarMistakeQuestions(
ctx,
sourceSessionId,
"Session checkpoint restored",
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
@@ -0,0 +1,56 @@
import * as os from "node:os";
import { resolveCoreDistinctId } from "@cline/core";
import type {
ClientContext,
ExtensionContext,
TelemetryMetadata,
UserContext,
} from "@cline/shared";
import { version } from "../package.json";
/** Shared identity for request headers, Hub attribution, and telemetry. */
export const DESKTOP_CLIENT_CONTEXT = {
name: "cline-desktop",
version,
platform: "Cline Desktop",
platformVersion: version,
isMultiRoot: false,
} as const satisfies ClientContext;
export const DESKTOP_TELEMETRY_METADATA = {
extension_version: version,
cline_type: "desktop",
platform: DESKTOP_CLIENT_CONTEXT.platform,
platform_version: DESKTOP_CLIENT_CONTEXT.platformVersion,
os_type: os.platform(),
os_version: os.version(),
} satisfies TelemetryMetadata;
export function resolveDesktopTelemetryUser(input?: {
accountId?: string;
email?: string;
organizationId?: string;
}): UserContext {
const accountId = input?.accountId?.trim();
return accountId
? {
distinctId: accountId,
accountId,
email: input?.email,
organizationId: input?.organizationId,
}
: {
distinctId: resolveCoreDistinctId(),
accountId: null,
};
}
/** Serializable context attached to every Desktop session sent to the Hub. */
export function createDesktopExtensionContext(
user?: UserContext,
): ExtensionContext {
return {
client: DESKTOP_CLIENT_CONTEXT,
...(user ? { user: { ...user } } : {}),
};
}
@@ -5,6 +5,8 @@ import type { SidecarContext } from "./types";
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -20,7 +22,9 @@ vi.mock("@cline/core", async () => {
executeClineAccountAction: executeClineAccountActionMock,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
saveProviderSettings = persistProviderSettingsMock;
},
saveLocalProviderSettings: saveProviderSettingsMock,
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
@@ -29,11 +33,13 @@ vi.mock("@cline/core", async () => {
function createContext() {
const capture = vi.fn();
const setDistinctId = vi.fn();
const updateCommonProperties = vi.fn();
const ctx = {
telemetry: { capture },
telemetry: { capture, setDistinctId, updateCommonProperties },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
return { ctx, capture, setDistinctId, updateCommonProperties };
}
const FETCH_ME_ARGS = {
@@ -50,12 +56,15 @@ beforeEach(() => {
clineAccountServiceCtorMock.mockReset();
executeClineAccountActionMock.mockReset();
getProviderSettingsMock.mockReset();
saveProviderSettingsMock.mockReset();
persistProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
describe("cline_account command auth states", () => {
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
const { ctx, capture } = createContext();
it("returns a typed not-authenticated result and restores anonymous telemetry when signed out", async () => {
const { ctx, capture, setDistinctId, updateCommonProperties } =
createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
@@ -69,6 +78,14 @@ describe("cline_account command auth states", () => {
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
expect(setDistinctId).toHaveBeenCalledWith(expect.any(String));
expect(updateCommonProperties).toHaveBeenCalledWith(
expect.objectContaining({
user_id: undefined,
account_id: undefined,
organization_id: undefined,
}),
);
});
it("runs the account action unchanged when a fresh token resolves", async () => {
@@ -140,3 +157,252 @@ describe("cline_account command auth states", () => {
});
});
});
/**
* Feature-flag identity is otherwise resolved once at sidecar startup, so these
* cover the mid-session transitions that would otherwise keep evaluating flags
* against a stale account (or the device).
*/
describe("cline_account keeps feature-flag identity in sync", () => {
async function currentFlagsUserId(): Promise<string | undefined> {
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
return getDesktopFeatureFlagsContext().userId ?? undefined;
}
async function runOperation(ctx: SidecarContext, operation: string) {
const { handleCommand } = await import("./commands");
return handleCommand(ctx, "cline_account", {
action: "clineAccount",
operation,
});
}
beforeEach(async () => {
const { resetDesktopFeatureFlagsForTesting } = await import(
"./feature-flags"
);
resetDesktopFeatureFlagsForTesting();
});
it("adopts the account identity on login", async () => {
const { ctx, setDistinctId, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({
id: "acct-1",
email: "dev@example.com",
});
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
expect(setDistinctId).toHaveBeenCalledWith("acct-1");
expect(updateCommonProperties).toHaveBeenCalledWith(
expect.objectContaining({ user_id: "acct-1", account_id: "acct-1" }),
);
expect(ctx.telemetryUser).toEqual({
distinctId: "acct-1",
accountId: "acct-1",
email: "dev@example.com",
organizationId: undefined,
});
});
it("applies and persists the active organization for task telemetry", async () => {
const { ctx, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({
provider: "cline",
auth: { accountId: "acct-1", accessToken: "token" },
});
executeClineAccountActionMock.mockResolvedValue({
id: "acct-1",
email: "dev@example.com",
organizations: [
{
active: true,
memberId: "member-1",
name: "Acme",
organizationId: "org-1",
roles: ["member"],
},
],
});
await runOperation(ctx, "fetchMe");
expect(updateCommonProperties).toHaveBeenCalledWith(
expect.objectContaining({
user_id: "acct-1",
organization_id: "org-1",
}),
);
expect(persistProviderSettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
organizationId: "org-1",
memberId: "member-1",
}),
}),
{ setLastUsed: false },
);
expect(ctx.telemetryUser).toEqual({
distinctId: "acct-1",
accountId: "acct-1",
email: "dev@example.com",
organizationId: "org-1",
});
});
it("leaves the signed-in identity intact across an organization switch", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
executeClineAccountActionMock.mockResolvedValue(undefined);
getProviderSettingsMock.mockReturnValue({
auth: { accountId: "stale-acct" },
});
await runOperation(ctx, "switchAccount");
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("adopts the identity from the refetch that follows a switch", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
executeClineAccountActionMock.mockResolvedValue(undefined);
await runOperation(ctx, "switchAccount");
executeClineAccountActionMock.mockResolvedValue({ id: "acct-2" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-2");
});
it("clears the account identity on logout", async () => {
const { ctx, setDistinctId, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
// Signed out: no token resolves.
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBeUndefined();
expect(ctx.telemetryUser).toEqual(
expect.objectContaining({
accountId: null,
distinctId: expect.any(String),
}),
);
expect(setDistinctId).toHaveBeenLastCalledWith(
ctx.telemetryUser?.distinctId,
);
expect(ctx.telemetryUser?.distinctId).not.toBe("acct-1");
expect(updateCommonProperties).toHaveBeenLastCalledWith(
expect.objectContaining({
user_id: undefined,
account_id: undefined,
account_email: undefined,
organization_id: undefined,
}),
);
});
it("clears the identity when sign-out blanks the cline auth settings", async () => {
const { ctx, setDistinctId, updateCommonProperties } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
// What the Sign Out button actually sends: a settings write that blanks
// the auth block. No account command is involved.
getProviderSettingsMock.mockReturnValue({ auth: { accountId: "" } });
saveProviderSettingsMock.mockReturnValue({
providerId: "cline",
enabled: true,
settingsPath: "/tmp/settings.json",
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "cline",
api_key: "",
settings: { auth: { accessToken: "", refreshToken: "", accountId: "" } },
});
expect(await currentFlagsUserId()).toBeUndefined();
expect(ctx.telemetryUser).toEqual(
expect.objectContaining({
accountId: null,
distinctId: expect.any(String),
}),
);
expect(setDistinctId).toHaveBeenLastCalledWith(
ctx.telemetryUser?.distinctId,
);
expect(updateCommonProperties).toHaveBeenLastCalledWith(
expect.objectContaining({
user_id: undefined,
account_id: undefined,
organization_id: undefined,
}),
);
});
it("ignores settings writes for other providers", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
saveProviderSettingsMock.mockReturnValue({
providerId: "anthropic",
enabled: true,
settingsPath: "/tmp/settings.json",
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "anthropic",
api_key: "sk-test",
});
// Saving an unrelated provider must not disturb the Cline identity.
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("falls back to the device distinct ID after logout", async () => {
const { ctx } = createContext();
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
const deviceId = getDesktopFeatureFlagsContext().distinctId;
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(getDesktopFeatureFlagsContext().distinctId).toBe("acct-1");
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
await runOperation(ctx, "fetchMe");
// Not left on the previous account's ID.
expect(getDesktopFeatureFlagsContext().distinctId).toBe(deviceId);
});
});
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext, SidecarWebSocketClient } from "./types";
const upgradeManagedHubMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
upgradeManagedHub: upgradeManagedHubMock,
};
});
function createContext(): SidecarContext {
return {
workspaceRoot: "/workspace",
wsClients: new Set(),
hubBuildMismatch: {
url: "ws://127.0.0.1:25463/hub",
reason: "outdated_hub",
expectedBuildId: "current-build",
},
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
}
function connection(canApproveTools: boolean): SidecarWebSocketClient {
return { data: { canApproveTools } } as unknown as SidecarWebSocketClient;
}
beforeEach(() => {
upgradeManagedHubMock.mockReset();
});
describe("hub_upgrade command", () => {
it("rejects connections without the approval token, before touching the hub", async () => {
const { handleCommand } = await import("./commands");
const ctx = createContext();
await expect(
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(false) }),
).rejects.toThrow(/trusted desktop connection/);
await expect(handleCommand(ctx, "hub_upgrade", {}, {})).rejects.toThrow(
/trusted desktop connection/,
);
expect(upgradeManagedHubMock).not.toHaveBeenCalled();
// The pending mismatch must survive a refused request.
expect(ctx.hubBuildMismatch).not.toBeNull();
});
it("forces the upgrade for the trusted webview connection and clears the mismatch", async () => {
upgradeManagedHubMock.mockResolvedValue({
outcome: "replaced",
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
activeSessionCount: 2,
});
const { handleCommand } = await import("./commands");
const ctx = createContext();
const result = await handleCommand(
ctx,
"hub_upgrade",
{},
{ connection: connection(true) },
);
expect(upgradeManagedHubMock).toHaveBeenCalledWith({
workspaceRoot: "/workspace",
force: true,
reason: "Cline Desktop hub update",
});
expect(result).toEqual({
outcome: "replaced",
url: "ws://127.0.0.1:25463/hub",
interruptedSessionCount: 2,
});
expect(ctx.hubBuildMismatch).toBeNull();
});
it("surfaces a newer running hub as an error instead of replacing it", async () => {
upgradeManagedHubMock.mockResolvedValue({
outcome: "hub_not_older",
url: "ws://127.0.0.1:25463/hub",
});
const { handleCommand } = await import("./commands");
const ctx = createContext();
await expect(
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(true) }),
).rejects.toThrow(/newer than this app/);
expect(ctx.hubBuildMismatch).not.toBeNull();
});
});
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
import {
listClineGitHubRepositories,
listClineIntegrations,
resolveGitHubInstallUrl,
} from "./commands-integrations";
import type { SidecarContext } from "./types";
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
};
});
function createContext() {
const capture = vi.fn();
const ctx = {
telemetry: { capture },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
}
const REQUEST_OPTIONS = {
apiBaseUrl: "https://api.example.com",
appBaseUrl: "https://app.example.com",
authToken: "test-token",
} as const;
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
return {
...REQUEST_OPTIONS,
fetchImpl: fetchImpl as unknown as typeof fetch,
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
getProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("listClineIntegrations", () => {
it("lists integrations through the envelope with a bearer token", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
const result = await listClineIntegrations(requestOptions(fetchImpl));
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer test-token",
);
});
});
describe("listClineGitHubRepositories", () => {
it("lists GitHub repositories from the repositories endpoint", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
);
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
expect(result).toEqual([{ full_name: "cline/cline" }]);
expect(String(fetchImpl.mock.calls[0][0])).toBe(
"https://api.example.com/api/v1/integrations/github/repositories",
);
});
it("surfaces the API envelope error message on failures", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse(
{ success: false, error: "failed to list integrations" },
500,
),
);
await expect(
listClineIntegrations(requestOptions(fetchImpl)),
).rejects.toThrow("failed to list integrations");
});
});
describe("resolveGitHubInstallUrl", () => {
it("resolves the GitHub install URL from the redirect location", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: {
location: "https://github.com/apps/cline/installations/new?state=abc",
},
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new?state=abc",
});
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(url.origin + url.pathname).toBe(
"https://api.example.com/api/v1/integrations/github/install",
);
// The post-install browser hop must land on the Cline dashboard.
expect(url.searchParams.get("redirect")).toBe(
"https://app.example.com/dashboard/integrations",
);
// The redirect must be read, not followed: the Location URL is the result.
expect(init.redirect).toBe("manual");
});
it("resolves a relative redirect location against the request URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "//github.com/apps/cline/installations/new" },
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
// A bare relative Location would blow up later in the URL opener.
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new",
});
});
it.each([
["https://evil.example/apps/cline", "evil.example"],
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
// Subdomains are not part of the install flow, so they are not allowed
// either -- the host must be exactly github.com.
["https://gist.github.com/apps/cline", "gist.github.com"],
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(null, { status: 302, headers: { location } }),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow(`unexpected host: ${host}`);
});
it("rejects a redirect that does not use https", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://github.com/apps/cline" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("must use https");
});
it("rejects a redirect location that is not a usable URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("not a valid URL");
});
it("throws when the install endpoint does not answer with a redirect", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ error: "authentication required" }, 401),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("authentication required");
});
});
describe("cline_integrations command auth states", () => {
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
});
it("calls the Cline API with the resolved fresh token when signed in", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toContain("/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer fresh-token",
);
});
it("rejects unknown operations", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
await expect(
handleCommand(ctx, "cline_integrations", {
operation: "dropIntegrations",
}),
).rejects.toThrow("Unsupported Cline integrations operation");
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import type {
ClineGitHubRepository,
ClineIntegration,
} from "../webview/lib/cline-integrations-types";
const DEFAULT_TIMEOUT_MS = 30_000;
const GITHUB_INSTALL_HOST = "github.com";
function resolveInstallRedirect(location: string, requestUrl: URL): string {
let resolved: URL;
try {
resolved = new URL(location, requestUrl);
} catch {
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
}
if (resolved.protocol !== "https:") {
throw new Error(
`GitHub install redirect must use https, got: ${resolved.protocol}`,
);
}
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
throw new Error(
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
);
}
return resolved.toString();
}
export interface ClineIntegrationsRequestOptions {
apiBaseUrl: string;
/** Frontend origin the browser install flow returns to when it finishes. */
appBaseUrl: string;
authToken: string;
requestTimeoutMs?: number;
fetchImpl?: typeof fetch;
}
export async function listClineIntegrations(
options: ClineIntegrationsRequestOptions,
): Promise<ClineIntegration[]> {
const data = await requestClineApiJson("/api/v1/integrations", options);
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
}
export async function listClineGitHubRepositories(
options: ClineIntegrationsRequestOptions,
): Promise<ClineGitHubRepository[]> {
const data = await requestClineApiJson(
"/api/v1/integrations/github/repositories",
options,
);
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
}
export async function resolveGitHubInstallUrl(
options: ClineIntegrationsRequestOptions,
): Promise<{ url: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
const installUrl = new URL(
"/api/v1/integrations/github/install",
options.apiBaseUrl,
);
installUrl.searchParams.set(
"redirect",
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
);
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(installUrl, {
method: "GET",
headers: { Authorization: `Bearer ${options.authToken}` },
redirect: "manual",
signal: controller.signal,
});
const location = response.headers.get("location");
if (response.status >= 300 && response.status < 400 && location?.trim()) {
return { url: resolveInstallRedirect(location.trim(), installUrl) };
}
const text = await response.text().catch(() => "");
let parsed: unknown;
try {
parsed = text.trim() ? JSON.parse(text) : undefined;
} catch {
parsed = undefined;
}
throw new Error(formatRequestFailure(response.status, text, parsed));
} finally {
clearTimeout(timeout);
}
}
function getEnvelopeError(parsed: unknown): string | undefined {
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
return undefined;
}
const error = (parsed as { error?: unknown }).error;
return typeof error === "string" && error.trim() ? error : undefined;
}
function formatRequestFailure(
status: number,
bodyText: string,
parsed: unknown,
): string {
const envelopeError = getEnvelopeError(parsed);
if (envelopeError) {
return envelopeError;
}
const body = bodyText.trim();
if (body) {
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
return `Cline integrations request failed with status ${status}: ${preview}`;
}
return `Cline integrations request failed with status ${status}`;
}
async function requestClineApiJson(
endpoint: string,
options: ClineIntegrationsRequestOptions,
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
method: "GET",
headers: {
Authorization: `Bearer ${options.authToken}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown;
if (text.trim()) {
try {
parsed = JSON.parse(text);
} catch {
if (!response.ok) {
throw new Error(
formatRequestFailure(response.status, text, undefined),
);
}
throw new Error("Cline integrations response was not valid JSON");
}
}
if (!response.ok) {
throw new Error(formatRequestFailure(response.status, text, parsed));
}
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
const envelope = parsed as {
success?: unknown;
error?: unknown;
data?: unknown;
};
if (typeof envelope.success === "boolean") {
if (!envelope.success) {
throw new Error(
getEnvelopeError(parsed) || "Cline integrations request failed",
);
}
return envelope.data ?? null;
}
}
return parsed ?? null;
} finally {
clearTimeout(timeout);
}
}
+393 -5
View File
@@ -15,7 +15,9 @@ import type {
import {
addLocalProvider,
ClineAccountService,
type ClineAccountUser,
captureAuthRefreshSoftFailure,
clearAccountTelemetryIdentity,
createConfiguredStreamingTranscriptionSession,
createUserInstructionConfigService,
ensureCustomProvidersLoaded,
@@ -23,18 +25,25 @@ import {
fetchClineRecommendedModels,
getCoreBuiltinToolCatalog,
getLocalProviderModels,
identifyAccount,
listHookConfigFiles,
listLocalProviders,
normalizeOAuthProvider,
ProviderSettingsManager,
parseMcpServerRegistration,
persistClineAccountTelemetryIdentity,
probeMcpServerConnection,
RuntimeOAuthTokenManager,
readGlobalSettings,
resolveClineAccountTelemetryIdentity,
resolveLocalClineAuthToken,
resolveMcpServerRegistration,
resolveSessionBackend,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
SESSION_IMPORT_TOOLS,
type SessionImportRequest,
SessionImportService,
type SessionImportTool,
SqliteSessionStore,
saveLocalProviderSettings,
saveVoiceInputSettings,
@@ -45,10 +54,13 @@ import {
transcribeConfiguredVoiceInput,
updateLocalProvider,
updateMcpSettingsFileSync,
upgradeManagedHub,
} from "@cline/core";
import { resolveAudioTranscriptionRoute } from "@cline/llms";
import {
CLINE_DEFAULT_MODEL_ID,
formatSessionSearchPreview,
formatSessionSearchTitle,
getClineEnvironmentConfig,
isCanonicalBase64,
ONE_TIME_SCHEDULE_CRON_PATTERN,
@@ -59,6 +71,12 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
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 {
listClineGitHubRepositories,
listClineIntegrations,
resolveGitHubInstallUrl,
} from "./commands-integrations";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -70,6 +88,10 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
identifyDesktopFeatureFlagsAccount,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -294,6 +316,69 @@ function removePathIfExists(
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
function syncAccountContextFromResult(
ctx: SidecarContext,
manager: ProviderSettingsManager,
operation: string,
result: unknown,
): void {
if (operation === "fetchMe") {
const user = result as ClineAccountUser | undefined;
if (user?.id) {
const identity = resolveClineAccountTelemetryIdentity(user);
ctx.telemetryUser = resolveDesktopTelemetryUser({
accountId: identity.id,
email: identity.email,
organizationId: identity.organizationId,
});
identifyAccount(ctx.telemetry, identity);
persistClineAccountTelemetryIdentity(manager, identity);
void identifyDesktopFeatureFlagsAccount(
{ id: user.id, email: user.email },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
return;
}
}
function syncAccountContextFromSettings(
ctx: SidecarContext,
manager: ProviderSettingsManager,
): void {
const auth = manager.getProviderSettings("cline")?.auth;
const accountId = auth?.accountId?.trim();
if (!auth || !accountId) {
syncSignedOutAccountContext(ctx);
return;
}
ctx.telemetryUser = resolveDesktopTelemetryUser({
accountId,
organizationId: auth.organizationId,
});
identifyAccount(ctx.telemetry, {
id: accountId,
provider: "cline",
organizationId: auth.organizationId,
organizationName: auth.organizationName,
memberId: auth.memberId,
});
void identifyDesktopFeatureFlagsAccount(
{ id: accountId },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
function syncSignedOutAccountContext(ctx: SidecarContext): void {
const telemetryUser = resolveDesktopTelemetryUser();
ctx.telemetryUser = telemetryUser;
clearAccountTelemetryIdentity(ctx.telemetry, telemetryUser.distinctId);
void identifyDesktopFeatureFlagsAccount(
{},
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
async function resolveFreshClineAuthToken(
ctx: SidecarContext,
manager: ProviderSettingsManager,
@@ -503,6 +588,67 @@ async function listSessionsFromSidecarManager(
.slice(0, max);
}
async function withSearchDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Session search timed out")),
timeoutMs,
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function metadataSessionSearchHits(
value: unknown,
query: string,
): JsonRecord[] {
if (!Array.isArray(value)) return [];
const normalizedQuery = query.toLocaleLowerCase();
return value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const session = item as JsonRecord;
const metadata =
session.metadata && typeof session.metadata === "object"
? (session.metadata as JsonRecord)
: {};
const sessionId = String(session.sessionId ?? "").trim();
if (!sessionId) return [];
const rawTitle = String(
metadata.title ?? session.title ?? session.prompt ?? sessionId,
).trim();
const prompt = String(session.prompt ?? metadata.prompt ?? "");
const title = formatSessionSearchTitle(rawTitle) || sessionId;
const workspaceRoot = String(session.workspaceRoot ?? session.cwd ?? "");
const searchable = [rawTitle, prompt, workspaceRoot, session.model]
.join("\n")
.toLocaleLowerCase();
if (!searchable.includes(normalizedQuery)) return [];
return [
{
sessionId,
documentId: `${sessionId}:metadata`,
ordinal: -1,
role: "session",
startedAt: String(session.startedAt ?? session.createdAt ?? ""),
workspaceRoot,
title,
snippet: formatSessionSearchPreview("session", prompt || title),
score: 0,
},
];
});
}
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
@@ -585,7 +731,15 @@ async function handleRoutineScheduleCommand(
hubCommand: string,
payload?: Record<string, unknown>,
) => {
const reply = await hubClient.command(hubCommand as never, payload);
// The desktop app runs chats (and therefore agent-created schedules)
// across many workspace folders, while this hub client is registered
// against the app's own launch directory. Ask the hub for schedules
// across all workspaces so the Schedules page manages every schedule
// on this machine, not just the launch-directory scope.
const reply = await hubClient.command(hubCommand as never, {
...payload,
allWorkspaces: true,
});
if (!reply.ok) {
throw new Error(
reply.error?.message ?? `hub command failed: ${hubCommand}`,
@@ -924,6 +1078,34 @@ async function listUserInstructionConfigs(
} finally {
userInstructionService.stop();
}
const knownSkillPaths = new Set(
skills.flatMap((skill) => {
if (!skill || typeof skill !== "object") return [];
const path = (skill as JsonRecord).path;
return typeof path === "string" ? [path] : [];
}),
);
for (const skill of hubSettings.skills) {
if (
skill.agentPlugin !== true ||
skill.enabled === false ||
knownSkillPaths.has(skill.path)
) {
continue;
}
skills.push({
id: skill.id,
name: skill.name,
description: skill.description,
instructions: "",
path: skill.path,
enabled: true,
source: skill.source,
agentPlugin: true,
pluginName: skill.pluginName,
});
knownSkillPaths.add(skill.path);
}
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
// Pin spawn/teams availability so this listing matches the hub's
@@ -943,9 +1125,15 @@ async function listUserInstructionConfigs(
runtimeCommands,
agents: loadAgents(),
plugins: hubSettings.plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
path: plugin.path,
enabled: plugin.enabled !== false,
source: plugin.source,
toggleable: plugin.toggleable === true,
agentPlugin: plugin.agentPlugin === true,
description: plugin.description,
loadError: plugin.loadError,
contributions: plugin.contributions,
})),
tools: [
@@ -1276,6 +1464,45 @@ export async function handleCommand(
return "";
}
// ── Managed hub upgrade ───────────────────────────────────────────
if (command === "hub_upgrade") {
// Replacing the shared Hub interrupts other clients' sessions, so it
// carries the same per-connection gate as the tool-approval commands:
// only the webview connection dialed with the approval token may ask,
// never an arbitrary local WebSocket client.
if (!options?.connection?.data?.canApproveTools) {
throw new Error("hub upgrade requires a trusted desktop connection");
}
// Only reached after the user accepted the blocking "Hub update
// required" dialog, so force: the old Hub is replaced even though it
// is still serving other clients' sessions. Drain-first semantics
// still give in-flight turns the wait window to finish.
const result = await upgradeManagedHub({
workspaceRoot: ctx.workspaceRoot,
force: true,
reason: "Cline Desktop hub update",
});
if (result.outcome === "hub_not_older") {
throw new Error(
"The running Cline Hub is newer than this app, so it was not replaced. Update Cline instead.",
);
}
if (result.outcome === "still_busy") {
throw new Error(
"The running Cline Hub picked up new sessions before it could be replaced, so it was left running. Try again.",
);
}
// The mismatch is resolved: a null broadcast closes the dialog in
// every connected webview and stops the replay-on-connect.
ctx.hubBuildMismatch = null;
broadcastEvent(ctx, "hub_build_mismatch", null);
return {
outcome: result.outcome,
url: result.url ?? null,
interruptedSessionCount: result.activeSessionCount ?? 0,
};
}
// ── Tool approvals (in-memory) ────────────────────────────────────
if (command === "poll_tool_approvals") {
const sessionId = String(args?.sessionId ?? "").trim();
@@ -1359,11 +1586,105 @@ export async function handleCommand(
typeof args?.limit === "number" ? args.limit : 300,
);
}
if (command === "search_sessions") {
const query = String(args?.query ?? "").trim();
if (!query) return [];
const limit =
typeof args?.limit === "number" && Number.isFinite(args.limit)
? Math.max(1, Math.min(200, Math.trunc(args.limit)))
: 50;
const workspaceRoot =
typeof args?.workspaceRoot === "string"
? args.workspaceRoot.trim() || undefined
: undefined;
if (ctx.hubClient) {
try {
const reply = await withSearchDeadline(
ctx.hubClient.command("session.search", {
query,
limit,
workspaceRoot,
}),
750,
);
if (
reply.ok &&
Array.isArray(reply.payload?.hits) &&
reply.payload.hits.length > 0
) {
return reply.payload.hits.slice(0, limit).map((hit) => ({
...hit,
title: formatSessionSearchTitle(hit.title),
snippet: formatSessionSearchPreview(hit.role, hit.snippet),
}));
}
} catch {
// Fall back to metadata-only search when the index is unavailable.
}
}
const sessions = await withSearchDeadline(
listSessionsFromSidecarManager(ctx, 500),
1_000,
).catch(() => []);
return metadataSessionSearchHits(sessions, query).slice(0, limit);
}
if (command === "get_discovered_session") {
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
if (!sessionId) throw new Error("session id is required");
return (await getSessionFromSidecarManager(ctx, sessionId)) ?? null;
}
// ── Session import from other coding tools ────────────────────────
if (command === "list_importable_sessions") {
const backend = await resolveSessionBackend({ backendMode: "local" });
const importer = new SessionImportService(backend);
return {
installedTools: importer.installedTools(),
sessions: await importer.discover(),
};
}
if (command === "import_sessions") {
const rawSelections = Array.isArray(args?.selections)
? args.selections
: [];
const requests: SessionImportRequest[] = [];
for (const selection of rawSelections) {
if (!selection || typeof selection !== "object") continue;
const tool = String((selection as JsonRecord).tool ?? "").trim();
const sourceId = String((selection as JsonRecord).sourceId ?? "").trim();
if (!sourceId) continue;
if (!(SESSION_IMPORT_TOOLS as readonly string[]).includes(tool)) {
continue;
}
requests.push({ tool: tool as SessionImportTool, sourceId });
}
if (requests.length === 0) {
throw new Error("at least one { tool, sourceId } selection is required");
}
const backend = await resolveSessionBackend({ backendMode: "local" });
const importer = new SessionImportService(backend);
// Opening a history session resumes on the row's provider/model, so the
// UI passes what a new chat would run on; the source tool's own
// provider/model stay in metadata.importedFrom.
// Never let the source tool's provider become the resume target: when
// the caller sends no selection, use the app default like other
// server-started sessions do.
const provider = asTrimmedString(args?.provider) ?? "cline";
const model = asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID;
const results = await importer.importMany(
requests,
(result, index) => {
broadcastEvent(ctx, "session_import_progress", {
index,
total: requests.length,
result,
});
},
{ provider, model },
);
return { results };
}
if (command === "update_chat_session_title") {
const sessionId = String(args?.sessionId ?? "").trim();
if (!sessionId) throw new Error("session id is required");
@@ -1403,7 +1724,7 @@ export async function handleCommand(
const result = await backend.updateSession({ sessionId, metadata: merged });
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
// Annotating a session is not session activity. updateSession stamps
// updated_at, which clients sort and label rows by, so a favorite would
// updated_at, which clients sort and label rows by, so a pin would
// otherwise make an old session look like it just ran.
if (existing?.updatedAt) {
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
@@ -1548,6 +1869,11 @@ export async function handleCommand(
// would be captured as error telemetry and shown raw to the user.
const authToken = await resolveFreshClineAuthToken(ctx, manager);
if (!authToken) {
// Backstop for credentials that go away without a settings write —
// an expired or server-revoked token. Explicit sign-out is handled
// at its source in `save_provider_settings`; this catches the rest
// so a stale account never keeps serving its rollout cohort.
syncSignedOutAccountContext(ctx);
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
@@ -1556,10 +1882,43 @@ export async function handleCommand(
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
const result = await executeClineAccountAction(
args as ClineAccountActionRequest,
accountService,
);
syncAccountContextFromResult(ctx, manager, operation, result);
return result;
}
// ── Cline integrations (GitHub App) ────────────────────────────────
if (command === "cline_integrations") {
const operation = String(args?.operation ?? "").trim();
if (!operation) throw new Error("operation is required");
const manager = new ProviderSettingsManager();
const authToken = await resolveFreshClineAuthToken(ctx, manager);
if (!authToken) {
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
const environment = getClineEnvironmentConfig();
const requestOptions = {
apiBaseUrl: settings?.baseUrl?.trim() || environment.apiBaseUrl,
appBaseUrl: environment.appBaseUrl,
authToken,
};
switch (operation) {
case "list":
return await listClineIntegrations(requestOptions);
case "listGitHubRepositories":
return await listClineGitHubRepositories(requestOptions);
case "githubInstallUrl":
return await resolveGitHubInstallUrl(requestOptions);
default:
throw new Error(
`Unsupported Cline integrations operation: ${operation}`,
);
}
}
// ── Provider management ────────────────────────────────────────────
@@ -1718,13 +2077,21 @@ export async function handleCommand(
}
if (command === "save_provider_settings") {
const manager = new ProviderSettingsManager();
return saveLocalProviderSettings(manager, {
const saved = saveLocalProviderSettings(manager, {
...readProviderSettingsUpdate(args),
providerId: String(args?.provider ?? ""),
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
// 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
// rather than waiting for the next account fetch.
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
syncAccountContextFromSettings(ctx, manager);
}
return saved;
}
if (command === "add_provider") {
const manager = new ProviderSettingsManager();
@@ -1790,7 +2157,16 @@ export async function handleCommand(
);
});
},
{ owner: options?.connection },
{
owner: options?.connection,
// Push the device sign-in confirmation code so the webview can
// show it while the user confirms it in the browser.
onUserCode: (userCode) =>
broadcastEvent(ctx, "provider_oauth_user_code", {
provider: providerId,
userCode,
}),
},
);
}
if (command === "cancel_provider_oauth_login") {
@@ -1827,6 +2203,18 @@ export async function handleCommand(
return readGlobalSettings();
}
// ── Feature flags ──────────────────────────────────────────────────
// Flags are evaluated here, not in the webview: the sidecar already has
// the PostHog key inlined at build time and evaluates against the same
// distinct ID it reports telemetry with. The client just reads the
// resolved values.
if (command === "get_feature_flags") {
return await refreshDesktopFeatureFlags({
logger: ctx.logger,
telemetry: ctx.telemetry,
});
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
@@ -210,6 +210,157 @@ describe("Code sidecar runtime capabilities", () => {
expect(connectMock).toHaveBeenCalledOnce();
});
it("returns indexed search results without listing every session", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
const hits = [
{
sessionId: "session-1",
documentId: "session-1:0",
ordinal: 0,
role: "user",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
title: oversizedPrompt,
snippet: oversizedPrompt,
score: -1,
},
];
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
const list = vi.fn(async () => []);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ title: string; snippet: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:0",
}),
]);
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
expect(results[0]?.title).not.toContain("user_input");
expect(results[0]?.snippet).not.toContain("user_input");
expect(command).toHaveBeenCalledWith("session.search", {
query: "generate",
limit: 50,
workspaceRoot: undefined,
});
expect(list).not.toHaveBeenCalled();
});
it("falls back to session metadata while the index has no hits", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const command = vi.fn(async () => ({ ok: true, payload: { hits: [] } }));
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: oversizedPrompt,
metadata: { title: oversizedPrompt },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ title: string; snippet: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
expect(results[0]?.title).not.toContain("user_input");
expect(results[0]?.snippet).not.toContain("user_input");
expect(list).toHaveBeenCalledOnce();
});
it("falls back to session metadata when the hub search call rejects", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const command = vi.fn(async () => {
throw new Error("hub connection lost");
});
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: "generate an image of a puppy",
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ sessionId: string; documentId: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(command).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledOnce();
});
it("falls back to session metadata when the hub search call exceeds the deadline", async () => {
vi.useFakeTimers();
try {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
// Never resolves: exercises the withSearchDeadline race timing out
// rather than the hub call rejecting.
const command = vi.fn(() => new Promise(() => {}));
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: "generate an image of a puppy",
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const pending = handleCommand(ctx, "search_sessions", {
query: "generate",
}) as Promise<Array<{ sessionId: string; documentId: string }>>;
await vi.advanceTimersByTimeAsync(750);
const results = await pending;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(command).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("forwards raw hub tool updates to attached desktop sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
@@ -929,6 +1080,7 @@ describe("Code sidecar runtime capabilities", () => {
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
allWorkspaces: true,
scheduleId: "schedule-1",
});
});
@@ -1041,6 +1193,33 @@ describe("Code sidecar runtime capabilities", () => {
},
]);
});
it("forwards Hub settings changes so open desktop views can refresh", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
handleHubLiveEvent(ctx, {
event: "settings.changed",
payload: {
types: ["plugins", "skills", "mcp"],
},
});
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: {
name: "settings.changed",
payload: {
types: ["plugins", "skills", "mcp"],
},
},
},
]);
});
});
describe("disposeSidecarContext attachment cleanup", () => {
@@ -1092,3 +1271,276 @@ describe("disposeSidecarContext attachment cleanup", () => {
expect(ctx.liveSessions.size).toBe(0);
});
});
describe("Chat chunk pipe selection", () => {
async function createStreamingContext(sessionId: string) {
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set(sessionId, {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
return ctx;
}
function coreTextEvent(sessionId: string, text: string) {
return {
type: "agent_event",
payload: {
sessionId,
event: { type: "content_start", contentType: "text", text },
},
} as never;
}
function chunksFor(ctx: SidecarContext, stream: string): string[] {
return readEvents(ctx)
.filter(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream === stream,
)
.map((message) =>
String((message.event.payload as { chunk?: string }).chunk),
);
}
it("emits one copy when both pipes carry the same delta", async () => {
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
"./context"
);
const ctx = await createStreamingContext("session-1");
// Opening a session arms both pipes, so the hub publishes each delta to
// the ClineCore session subscription and to the observer client.
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "Pack "));
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "Pack " },
});
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "my box"));
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "my box" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["Pack ", "my box"]);
});
it("still streams sessions only the observer delivers", async () => {
const { handleHubLiveEvent } = await import("./context");
const ctx = await createStreamingContext("session-1");
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "remote " },
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "run" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["remote ", "run"]);
});
it("stands down for every stream the core pipe serves, not just text", async () => {
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
"./context"
);
const ctx = await createStreamingContext("session-1");
// One text delta on the core pipe is enough to prove it is subscribed,
// so the observer's tool rows are duplicates too.
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "working"));
handleHubLiveEvent(ctx, {
event: "tool.started",
sessionId: "session-1",
payload: { toolCallId: "call-1", toolName: "run_commands" },
});
expect(chunksFor(ctx, "chat_tool_call_start")).toEqual([]);
});
it("stays stood down through a quiet gap while the run is busy", async () => {
vi.useFakeTimers();
try {
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
"./context"
);
const ctx = await createStreamingContext("session-1");
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "local"));
// A long command or an unanswered tool approval stalls both pipes.
// The observer's copy of the first event afterwards still arrives
// ahead of the core copy, so it must stay muted for the whole run.
vi.advanceTimersByTime(60_000);
handleHubLiveEvent(ctx, {
event: "tool.started",
sessionId: "session-1",
payload: { toolCallId: "call-1", toolName: "run_commands" },
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "after gap" },
});
expect(chunksFor(ctx, "chat_tool_call_start")).toEqual([]);
expect(chunksFor(ctx, "chat_text")).toEqual(["local"]);
} finally {
vi.useRealTimers();
}
});
it("takes over after stop when another client starts the next run", async () => {
const { forgetCorePipe, handleCoreSessionEvent, handleHubLiveEvent } =
await import("./context");
const ctx = await createStreamingContext("session-1");
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "local"));
// The desktop stops the session: ClineCore disposes its subscription
// without any local `ended` event, so the stop path forgets the mark.
forgetCorePipe(ctx, "session-1");
const stopped = ctx.liveSessions.get("session-1");
if (stopped) stopped.busy = false;
// Another client (CLI, schedule) runs the session; the observer is the
// only pipe left and its run.started marks the session busy again.
handleHubLiveEvent(ctx, {
event: "run.started",
sessionId: "session-1",
payload: {},
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "remote run" },
});
expect(ctx.liveSessions.get("session-1")?.busy).toBe(true);
expect(chunksFor(ctx, "chat_text")).toEqual(["local", "remote run"]);
});
it("takes over once the run has ended and the core pipe goes silent", async () => {
vi.useFakeTimers();
try {
const { handleCoreSessionEvent, handleHubLiveEvent } = await import(
"./context"
);
const ctx = await createStreamingContext("session-1");
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "local"));
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "muted" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["local"]);
handleCoreSessionEvent(ctx, {
type: "status",
payload: { sessionId: "session-1", status: "completed" },
} as never);
vi.advanceTimersByTime(6_000);
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "takeover" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["local", "takeover"]);
} finally {
vi.useRealTimers();
}
});
it("tracks the core pipe per session", async () => {
const { createSidecarContext, handleCoreSessionEvent, handleHubLiveEvent } =
await import("./context");
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
for (const sessionId of ["session-1", "session-2"]) {
ctx.liveSessions.set(sessionId, {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
}
// ClineCore serves session-1; session-2 is still observer-only.
handleCoreSessionEvent(ctx, coreTextEvent("session-1", "one"));
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "one" },
});
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-2",
payload: { text: "two" },
});
expect(chunksFor(ctx, "chat_text")).toEqual(["one", "two"]);
});
it("never drops chunks the sidecar produces itself", async () => {
const { broadcastChunk, handleHubLiveEvent } = await import("./context");
const ctx = await createStreamingContext("session-1");
// The observer is serving this session; a locally synthesized chunk is
// not part of either relay and must always reach the webview.
handleHubLiveEvent(ctx, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "remote" },
});
broadcastChunk(ctx, "session-1", "chat_queued_prompt_start", "{}");
expect(chunksFor(ctx, "chat_queued_prompt_start")).toEqual(["{}"]);
});
it("stamps chunks with a stable per-process boot id", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const first = await createStreamingContext("session-1");
handleHubLiveEvent(first, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "a" },
});
handleHubLiveEvent(first, {
event: "assistant.delta",
sessionId: "session-1",
payload: { text: "b" },
});
const boots = readEvents(first)
.filter((message) => message.event.name === "chat_event")
.map((message) => (message.event.payload as { boot?: string }).boot);
expect(boots).toHaveLength(2);
expect(boots[0]).toBeTruthy();
expect(boots[1]).toBe(boots[0]);
// A replacement sidecar restarts `index` at 1, so it must be
// distinguishable by boot id.
const second = createSidecarContext("/workspace/project");
expect(second.bootId).not.toBe(first.bootId);
});
});
+179 -20
View File
@@ -26,8 +26,13 @@ import {
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import {
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsService,
} from "./feature-flags";
import { sessionLogPath } from "./paths";
import type {
ChunkSource,
LiveSession,
PendingAskQuestion,
PendingToolApproval,
@@ -110,23 +115,25 @@ export function syncSidecarApprovalReadiness(
ctx: SidecarContext,
): Promise<void> {
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
const update = previous.catch(() => undefined).then(async () => {
const hubClient = ctx.hubClient;
if (!hubClient) return;
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
});
const update = previous
.catch(() => undefined)
.then(async () => {
const hubClient = ctx.hubClient;
if (!hubClient) return;
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
});
approvalReadinessUpdates.set(ctx, update);
return update.finally(() => {
if (approvalReadinessUpdates.get(ctx) === update) {
@@ -165,13 +172,76 @@ function appendSessionChunk(
});
}
/**
* How long an idle session stays "served by ClineCore" after its last event
* before the observer projection may take over.
*
* This window only applies between runs. While a run is busy, silence on the
* core pipe is not evidence that its subscription died: a long command, a
* slow first token, or a tool-approval prompt the user takes a while to answer
* all stall both pipes together. Expiring the mark mid-turn let the observer's
* copy of the first post-gap event through ahead of the core copy, doubling a
* delta or orphaning a duplicate tool row every time a turn paused for longer
* than the window.
*/
const CORE_PIPE_ACTIVE_MS = 5_000;
/**
* Records that the ClineCore subscription is serving this session.
*
* The sidecar has two pipes into `emitChunk`: the ClineCore session
* subscription and the hub observer client. Opening a session arms both (the
* hydrate's `pending_prompts` call makes ClineCore subscribe to the session,
* and `attach` sets `attachedViaHub`), so for a session streaming through the
* hub each delta arrived twice and since both copies go through `emitChunk`
* each gets its own increasing `index`, which is exactly what the webview's
* replay guard compares, so it could not tell them apart.
*
* The two pipes are not peers: ClineCore's subscription is the primary, and
* the observer projection exists to cover sessions ClineCore is not subscribed
* to. Any event on the primary proves it is subscribed, so it is the primary
* that decides no list of which streams happen to overlap.
*/
function markCorePipeActive(ctx: SidecarContext, sessionId: string): void {
ctx.coreStreamActivity.set(sessionId, nowMs());
}
/**
* Forget that the ClineCore subscription served this session. Called when the
* session ends and wherever the sidecar stops a session: `stop` disposes the
* core subscription without any local `ended` event, and a stale mark would
* otherwise mute the observer for the next run another client starts on the
* same session, since that run's `run.started` marks the session busy.
*/
export function forgetCorePipe(ctx: SidecarContext, sessionId: string): void {
ctx.coreStreamActivity.delete(sessionId);
}
function isCorePipeActive(
ctx: SidecarContext,
sessionId: string,
now: number,
): boolean {
const lastEventAt = ctx.coreStreamActivity.get(sessionId);
if (lastEventAt === undefined) return false;
// The mark is cleared when the session ends, so during a busy run it means
// the core subscription served this session and is still the pipe to trust
// even when neither pipe has had anything to deliver for a while.
if (ctx.liveSessions.get(sessionId)?.busy) return true;
return now - lastEventAt <= CORE_PIPE_ACTIVE_MS;
}
function emitChunk(
ctx: SidecarContext,
sessionId: string,
stream: string,
chunk: string,
source: ChunkSource = "core",
): void {
const ts = nowMs();
if (source === "observer" && isCorePipeActive(ctx, sessionId, ts)) {
return;
}
appendSessionChunk(sessionId, stream, chunk, ts);
const nextIndex = (ctx.streamIndices.get(sessionId) ?? 0) + 1;
ctx.streamIndices.set(sessionId, nextIndex);
@@ -181,6 +251,7 @@ function emitChunk(
chunk,
ts,
index: nextIndex,
boot: ctx.bootId,
});
}
@@ -338,6 +409,7 @@ function handleAgentEvent(
message: event.message,
noticeType: event.noticeType,
reason: event.reason,
metadata: event.metadata,
}),
);
break;
@@ -361,6 +433,7 @@ function handleAgentEvent(
break;
}
case "done": {
cancelSidecarMistakeQuestions(ctx, sessionId, "Run ended");
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -395,7 +468,19 @@ function handleAgentEvent(
);
break;
}
case "iteration_start":
case "iteration_start": {
const session = ctx.liveSessions.get(sessionId);
if (session) {
// Iterations restart at one for each user run. Keep the previous
// answer only within the run in which it was supplied.
if (event.iteration === 1 || !session.mistakeRecovery) {
session.mistakeRecovery = { latestIteration: event.iteration };
} else {
session.mistakeRecovery.latestIteration = event.iteration;
}
}
break;
}
case "iteration_end":
break;
}
@@ -438,6 +523,29 @@ export function handleCoreSessionEvent(
ctx: SidecarContext,
event: CoreSessionEvent,
): void {
// Reaching here at all means ClineCore is subscribed to the session, so its
// projection is live and the observer's copy of the same hub events would
// be a duplicate. Marked from the pipe itself rather than from `emitChunk`,
// so chunks the sidecar synthesizes locally never claim to be this pipe.
//
// Deliberately every event, not just the content-bearing ones: the hub
// fans out to listeners in registration order, and the observer's global
// subscription is registered at sidecar boot while this per-session one
// arrives at hydrate — so the observer sees each delta first. Waiting for
// core content to mark the pipe would let the observer's copy of a turn's
// first delta through before the mark existed, doubling it every turn.
// The cost is the reverse case: if this pipe delivers a status or queue
// event and then stops while the observer keeps streaming, the observer is
// held off for the rest of the run (and `CORE_PIPE_ACTIVE_MS` after it).
// That needs the subscription torn down mid-turn, which only stop and
// detach do, and the turn-end reconcile restores the gap from canonical
// history — where doubling would be visible on every turn.
const eventSessionId = (event.payload as { sessionId?: string } | undefined)
?.sessionId;
if (eventSessionId) {
markCorePipeActive(ctx, eventSessionId);
}
switch (event.type) {
case "chunk": {
const { sessionId, stream, chunk } = event.payload;
@@ -514,6 +622,7 @@ export function handleCoreSessionEvent(
}
case "ended": {
const { sessionId, reason } = event.payload;
cancelSidecarMistakeQuestions(ctx, sessionId, "Session ended");
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -521,6 +630,8 @@ export function handleCoreSessionEvent(
session.status = reason || "ended";
}
discardAllTrackedAttachments(sessionId, session);
// The next run decides afresh which pipe is serving the session.
forgetCorePipe(ctx, sessionId);
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
break;
}
@@ -564,12 +675,15 @@ export function createSidecarContext(
observability: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
telemetryUser?: SidecarContext["telemetryUser"];
} = {},
): SidecarContext {
return {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
coreStreamActivity: new Map(),
bootId: randomUUID(),
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
@@ -578,6 +692,7 @@ export function createSidecarContext(
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
telemetryUser: observability.telemetryUser,
unsubscribeSessionEvents: null,
hubBuildMismatch: null,
};
@@ -627,6 +742,10 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
// Shuts down the PostHog client the feature flags service owns, flushing
// any pending $feature_flag_called events.
cleanup.push(disposeDesktopFeatureFlagsService());
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -714,6 +833,28 @@ export function resolveSidecarAskQuestion(
return true;
}
/** Remove prompts before their session is stopped or replaced in the UI. */
export function cancelSidecarMistakeQuestions(
ctx: SidecarContext,
sessionId: string,
reason: string,
): void {
for (const pending of ctx.pendingQuestions?.values() ?? []) {
if (
pending.item.sessionId !== sessionId ||
pending.item.context?.agentId !== "desktop-mistake-limit"
)
continue;
ctx.pendingQuestions.delete(pending.item.requestId);
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.reject(new Error(reason));
sendEvent(ctx, "ask_question_cancelled", {
requestId: pending.item.requestId,
reason,
});
}
}
export function createSidecarRuntimeCapabilities(
ctx: SidecarContext,
): RuntimeCapabilities {
@@ -801,6 +942,10 @@ export function handleHubLiveEvent(
});
return;
}
if (event.event === "settings.changed") {
sendEvent(ctx, event.event, event.payload ?? {});
return;
}
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
if (!sessionId) {
@@ -816,14 +961,20 @@ export function handleHubLiveEvent(
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
if (text) {
emitChunk(ctx, sessionId, "chat_text", text);
emitChunk(ctx, sessionId, "chat_text", text, "observer");
}
return;
}
case "assistant.media": {
const media = event.payload?.media;
if (isGeneratedMedia(media)) {
emitChunk(ctx, sessionId, "chat_media", JSON.stringify(media));
emitChunk(
ctx,
sessionId,
"chat_media",
JSON.stringify(media),
"observer",
);
}
return;
}
@@ -839,6 +990,7 @@ export function handleHubLiveEvent(
sessionId,
"chat_reasoning",
JSON.stringify({ text, redacted }),
"observer",
);
return;
}
@@ -858,6 +1010,7 @@ export function handleHubLiveEvent(
: "tool",
input: event.payload?.input,
}),
"observer",
);
return;
}
@@ -877,6 +1030,7 @@ export function handleHubLiveEvent(
: "tool",
update: event.payload?.update,
}),
"observer",
);
return;
}
@@ -900,6 +1054,7 @@ export function handleHubLiveEvent(
? event.payload.error
: undefined,
}),
"observer",
);
return;
}
@@ -1021,6 +1176,10 @@ export async function initializeSessionManager(
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
featureFlags: getDesktopFeatureFlagsService({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
hub: {
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
buildClinePostHogClient: vi.fn(() => ({ kind: "posthog-client" })),
PostHogFeatureFlagsProvider: vi.fn(function PostHogFeatureFlagsProvider(
this: Record<string, unknown>,
options: unknown,
) {
this.kind = "posthog";
this.options = options;
}),
NoOpFeatureFlagsProvider: vi.fn(function NoOpFeatureFlagsProvider(
this: Record<string, unknown>,
) {
this.kind = "noop";
}),
resolveCoreDistinctId: vi.fn(() => "machine-distinct-id"),
poll: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
setContext: vi.fn(),
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
// Two known flags keep the snapshot assertions meaningful even as the
// real registry changes.
FEATURE_FLAGS: ["ext-cline-pass", "ext-demo-flag"],
NoOpFeatureFlagsProvider: mocks.NoOpFeatureFlagsProvider,
resolveCoreDistinctId: mocks.resolveCoreDistinctId,
FeatureFlagsService: class {
options: Record<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
}
poll = mocks.poll;
dispose = mocks.dispose;
setContext = mocks.setContext;
getFlagPayload = mocks.getFlagPayload;
},
};
});
vi.mock("@cline/core/services/feature-flags/posthog", () => ({
buildClinePostHogClient: mocks.buildClinePostHogClient,
PostHogFeatureFlagsProvider: mocks.PostHogFeatureFlagsProvider,
}));
import {
buildFeatureFlagsSnapshot,
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsContext,
getDesktopFeatureFlagsService,
refreshDesktopFeatureFlags,
resetDesktopFeatureFlagsForTesting,
setDesktopFeatureFlagsAccountContext,
} from "./feature-flags";
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const originalIsTest = process.env.IS_TEST;
beforeEach(() => {
vi.clearAllMocks();
resetDesktopFeatureFlagsForTesting();
delete process.env.IS_TEST;
delete process.env.E2E_TEST;
});
afterEach(() => {
if (originalApiKey === undefined) {
delete process.env.TELEMETRY_SERVICE_API_KEY;
} else {
process.env.TELEMETRY_SERVICE_API_KEY = originalApiKey;
}
if (originalIsTest === undefined) {
delete process.env.IS_TEST;
} else {
process.env.IS_TEST = originalIsTest;
}
});
describe("getDesktopFeatureFlagsService", () => {
it("uses PostHog when the build-time key is inlined", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.buildClinePostHogClient).toHaveBeenCalledWith("phc_key");
expect(mocks.NoOpFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("falls back to the no-op provider when no key was inlined", () => {
delete process.env.TELEMETRY_SERVICE_API_KEY;
getDesktopFeatureFlagsService();
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("never calls PostHog under IS_TEST even with a key present", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
process.env.IS_TEST = "true";
getDesktopFeatureFlagsService();
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("returns one shared instance so the core and the webview agree", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
expect(getDesktopFeatureFlagsService()).toBe(
getDesktopFeatureFlagsService(),
);
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
});
});
describe("feature flags context", () => {
it("defaults to the machine distinct ID under the cline-code client name", () => {
const context = getDesktopFeatureFlagsContext();
expect(context.clientName).toBe("cline-code");
expect(context.distinctId).toBe("machine-distinct-id");
});
it("switches to the account ID once signed in, and pushes it to the service", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
setDesktopFeatureFlagsAccountContext({
id: "acct-1",
email: "dev@example.com",
});
const context = getDesktopFeatureFlagsContext();
expect(context.distinctId).toBe("acct-1");
expect(context.userId).toBe("acct-1");
expect(mocks.setContext).toHaveBeenCalledTimes(1);
});
it("keeps the device identity when the account ID is blank", () => {
setDesktopFeatureFlagsAccountContext({ id: " " });
expect(getDesktopFeatureFlagsContext().distinctId).toBe(
"machine-distinct-id",
);
});
it("clears the account identity on sign-out and falls back to the device", () => {
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
expect(getDesktopFeatureFlagsContext().userId).toBe("acct-1");
expect(setDesktopFeatureFlagsAccountContext({})).toBe(true);
const context = getDesktopFeatureFlagsContext();
expect(context.userId).toBeUndefined();
// Must not be left on the signed-out account's ID.
expect(context.distinctId).toBe("machine-distinct-id");
});
it("reports no change when the same account is re-confirmed", () => {
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(true);
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(false);
});
it("reports no change when signed out twice", () => {
expect(setDesktopFeatureFlagsAccountContext({})).toBe(false);
});
it("re-points at the new account when switching accounts", () => {
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-2" })).toBe(true);
const context = getDesktopFeatureFlagsContext();
expect(context.userId).toBe("acct-2");
expect(context.distinctId).toBe("acct-2");
});
});
describe("buildFeatureFlagsSnapshot", () => {
it("resolves every known flag so the client needs no defaults", () => {
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
flag === "ext-cline-pass" ? true : undefined,
);
const snapshot = buildFeatureFlagsSnapshot(
getDesktopFeatureFlagsService() as never,
);
expect(snapshot.flags).toEqual({
"ext-cline-pass": true,
// Unreturned flags resolve to false rather than being absent.
"ext-demo-flag": false,
});
});
it("passes non-boolean payloads through untouched", () => {
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
flag === "ext-cline-pass" ? { variant: "b", limit: 3 } : false,
);
const snapshot = buildFeatureFlagsSnapshot(
getDesktopFeatureFlagsService() as never,
);
expect(snapshot.flags["ext-cline-pass"]).toEqual({
variant: "b",
limit: 3,
});
});
});
describe("refreshDesktopFeatureFlags", () => {
it("polls before returning the snapshot", async () => {
mocks.getFlagPayload.mockReturnValue(true);
const snapshot = await refreshDesktopFeatureFlags();
expect(mocks.poll).toHaveBeenCalledTimes(1);
expect(snapshot.flags["ext-cline-pass"]).toBe(true);
});
it("still returns cached values when the poll fails", async () => {
mocks.poll.mockRejectedValueOnce(new Error("offline"));
mocks.getFlagPayload.mockReturnValue(false);
const logger = { error: vi.fn(), log: vi.fn(), debug: vi.fn() };
const snapshot = await refreshDesktopFeatureFlags({ logger });
expect(snapshot.flags["ext-cline-pass"]).toBe(false);
expect(logger.error).toHaveBeenCalled();
});
});
describe("disposeDesktopFeatureFlagsService", () => {
it("disposes the live service and clears it", async () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
await disposeDesktopFeatureFlagsService();
expect(mocks.dispose).toHaveBeenCalledTimes(1);
// A later call builds a fresh service rather than reusing a disposed one.
getDesktopFeatureFlagsService();
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(2);
});
it("is a no-op when nothing was created", async () => {
await expect(disposeDesktopFeatureFlagsService()).resolves.toBeUndefined();
expect(mocks.dispose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,171 @@
import { join } from "node:path";
import {
type BasicLogger,
FEATURE_FLAGS,
type FeatureFlagPayload,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
let desktopFeatureFlagsContext: FeatureFlagsContext = {
clientName: "cline-code",
};
let desktopFeatureFlagsService: FeatureFlagsService | undefined;
function resolveDesktopFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.cline-code.json");
}
function ensureDesktopDistinctId(): string {
const distinctId = desktopFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
desktopFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getDesktopFeatureFlagsContext(): FeatureFlagsContext {
ensureDesktopDistinctId();
return { ...desktopFeatureFlagsContext };
}
export function getDesktopFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!desktopFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
desktopFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getDesktopFeatureFlagsContext(),
cacheFilePath: resolveDesktopFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
}
return desktopFeatureFlagsService;
}
export async function disposeDesktopFeatureFlagsService(): Promise<void> {
if (!desktopFeatureFlagsService) {
return;
}
const current = desktopFeatureFlagsService;
desktopFeatureFlagsService = undefined;
await current.dispose();
}
export function setDesktopFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): boolean {
const accountId = account.id?.trim();
const previousUserId = desktopFeatureFlagsContext.userId ?? undefined;
if (previousUserId === (accountId || undefined)) {
return false;
}
if (accountId) {
desktopFeatureFlagsContext = {
...desktopFeatureFlagsContext,
distinctId: accountId,
userId: accountId,
};
} else {
// Drop both identifiers; ensureDesktopDistinctId re-resolves the device
// ID on the next read rather than leaving the old account's ID behind.
const {
distinctId: _distinctId,
userId: _userId,
...rest
} = desktopFeatureFlagsContext;
desktopFeatureFlagsContext = rest;
}
desktopFeatureFlagsService?.setContext(getDesktopFeatureFlagsContext());
return true;
}
export type FeatureFlagsSnapshot = {
flags: Record<string, FeatureFlagPayload>;
};
export function buildFeatureFlagsSnapshot(
service: FeatureFlagsService,
): FeatureFlagsSnapshot {
const flags: Record<string, FeatureFlagPayload> = {};
for (const flag of FEATURE_FLAGS) {
flags[flag] = service.getFlagPayload(flag) ?? false;
}
return { flags };
}
/**
* Refresh flags from PostHog, then hand back the resolved snapshot.
*
* Polling is cheap to call repeatedly.
*/
export async function refreshDesktopFeatureFlags(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): Promise<FeatureFlagsSnapshot> {
const service = getDesktopFeatureFlagsService(options);
try {
await service.poll();
} catch (error) {
options?.logger?.error?.("Error refreshing desktop feature flags", {
error,
});
}
return buildFeatureFlagsSnapshot(service);
}
export async function identifyDesktopFeatureFlagsAccount(
account: { id?: string; email?: string },
options?: { logger?: BasicLogger; telemetry?: ITelemetryService },
): Promise<void> {
if (
!setDesktopFeatureFlagsAccountContext(account) ||
!desktopFeatureFlagsService
) {
return;
}
try {
await desktopFeatureFlagsService.poll();
} catch (error) {
options?.logger?.error?.("Error polling desktop feature flags", { error });
}
}
export function resetDesktopFeatureFlagsForTesting(): void {
desktopFeatureFlagsService = undefined;
desktopFeatureFlagsContext = { clientName: "cline-code" };
}
@@ -1,7 +1,10 @@
import { homedir } from "node:os";
import {
checkManagedHubBuildMismatch,
createClineTelemetryServiceConfig,
readGlobalSettings,
setHomeDirIfUnset,
setModelToolEnabledGlobally,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
@@ -65,6 +68,20 @@ async function main() {
pid: process.pid,
});
// Web search is opt-in elsewhere in Cline, but the desktop app defaults
// it to on. Seed the shared setting only when the user has never set it,
// so an explicit off (from any Cline app) stays off. Best-effort: an
// unwritable settings file must not block startup over a default.
try {
if (readGlobalSettings().tools?.web_search === undefined) {
setModelToolEnabledGlobally("web_search", true);
}
} catch (error) {
observability.logger.error?.("Failed to seed web search default", {
error,
});
}
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
@@ -148,6 +165,34 @@ async function main() {
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
},
});
// The watcher's first check only runs after its interval, but a mismatch
// that already exists at startup - an older Hub this app attached to
// because it is still serving other clients' sessions - must prompt
// before the user starts working, not half a minute in. Session-manager
// init has already settled the hub state, so check once right away. The
// broadcast reaches webviews that are already connected; the replay in
// createWebSocketHandler covers ones that connect later. Skipped when
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
// hosts keep protocol-only compatibility and must not show update prompts.
if (!process.env.CLINE_HUB_PORT?.trim()) {
void checkManagedHubBuildMismatch()
.then((mismatch) => {
if (!mismatch || ctx.hubBuildMismatch) {
return;
}
ctx.hubBuildMismatch = mismatch;
observability.logger.log(
"Managed hub build mismatch detected at startup",
{
hubBuildId: mismatch.hubBuildId,
hubCoreVersion: mismatch.hubCoreVersion,
reason: mismatch.reason,
},
);
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
})
.catch(() => undefined);
}
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlugin } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getOfficialPluginInstallPath,
@@ -9,6 +10,15 @@ import {
} from "./marketplace";
import type { JsonRecord } from "./types";
// Marketplace plugin installs run in-process through @cline/core (spawning a
// `cline` binary fails with 'Executable not found in $PATH: "cline"' in the
// packaged app). Stub only installPlugin; everything else stays real.
vi.mock(import("@cline/core"), async (importOriginal) => ({
...(await importOriginal()),
installPlugin: vi.fn(),
}));
const installPluginMock = vi.mocked(installPlugin);
const GOAL_ENTRY = {
id: "goal",
type: "plugin",
@@ -23,6 +33,13 @@ beforeEach(async () => {
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
previousClineDir = process.env.CLINE_DIR;
process.env.CLINE_DIR = tempClineDir;
installPluginMock.mockReset().mockImplementation(async (options) => ({
source: options.source,
installPath: goalInstallDir(),
entryPaths: [],
mcpSyncFailures: [],
mcpOAuthCandidates: [],
}));
});
afterEach(async () => {
@@ -43,57 +60,49 @@ function goalInstallDir(): string {
}
describe("official plugin install detection", () => {
it("does not treat a leftover empty install directory as installed", async () => {
// Regression: a failed or interrupted install can leave the directory
// behind with nothing in it. The next install attempt then returned
// "already installed" without running the CLI, so the UI flipped the
// entry to Uninstall with no error while nothing actually worked.
await mkdir(goalInstallDir(), { recursive: true });
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout: "",
stderr: "install exploded",
}));
await expect(
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
).rejects.toThrow(/Plugin install failed/);
expect(spawnCommand).toHaveBeenCalledTimes(1);
});
it("passes --force so a retry can reclaim the leftover directory", async () => {
// Without --force the CLI refuses to replace the existing path
// ("Plugin is already installed at ... Use --force to replace it."),
// so every retry from the UI would fail against the stale directory.
await mkdir(goalInstallDir(), { recursive: true });
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
const result = await installMarketplaceEntry(
{ entry: GOAL_ENTRY },
{ spawnCommand },
);
it("installs plugins in-process through @cline/core", async () => {
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
expect(result).toMatchObject({
status: "installed",
message: "Installed Goal.",
});
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
expect(installPluginMock).toHaveBeenCalledWith({
source: "goal",
force: false,
});
});
it("does not pass --force for a clean first install", async () => {
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
it("does not treat a leftover empty install directory as installed", async () => {
// Regression: a failed or interrupted install can leave the directory
// behind with nothing in it. The next install attempt then returned
// "already installed" without running the installer, so the UI flipped
// the entry to Uninstall with no error while nothing actually worked.
await mkdir(goalInstallDir(), { recursive: true });
installPluginMock.mockRejectedValueOnce(new Error("install exploded"));
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
await expect(
installMarketplaceEntry({ entry: GOAL_ENTRY }),
).rejects.toThrow(/install exploded/);
expect(installPluginMock).toHaveBeenCalledTimes(1);
});
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
it("passes force so a retry can reclaim the leftover directory", async () => {
// Without force the installer refuses to replace the existing path
// ("Plugin is already installed at ... Use --force to replace it."),
// so every retry from the UI would fail against the stale directory.
await mkdir(goalInstallDir(), { recursive: true });
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
expect(result).toMatchObject({
status: "installed",
message: "Installed Goal.",
});
expect(installPluginMock).toHaveBeenCalledWith({
source: "goal",
force: true,
});
});
it("still short-circuits when the directory contains a plugin module", async () => {
@@ -111,22 +120,51 @@ describe("official plugin install detection", () => {
join(installDir, "package", "index.ts"),
"export default {};",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
const result = await installMarketplaceEntry(
{ entry: GOAL_ENTRY },
{ spawnCommand },
);
const result = await installMarketplaceEntry({ entry: GOAL_ENTRY });
expect(result).toMatchObject({
status: "installed",
message: "Goal is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
expect(installPluginMock).not.toHaveBeenCalled();
});
it("registers MCP servers in-process, honoring the -- args separator", async () => {
const settingsPath = join(tempClineDir, "cline_mcp_settings.json");
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
try {
const result = await installMarketplaceEntry({
entry: {
id: "aikido",
type: "mcp",
name: "Aikido",
install: {
args: ["aikido", "--", "npx", "-y", "@aikidosec/mcp@1.0.9"],
},
},
});
expect(result).toMatchObject({
status: "installed",
message: "Installed Aikido.",
});
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers: Record<string, { transport?: unknown }>;
};
expect(settings.mcpServers.aikido?.transport).toEqual({
type: "stdio",
command: "npx",
args: ["-y", "@aikidosec/mcp@1.0.9"],
});
} finally {
if (previousSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
}
}
});
it("excludes partial install directories from the installed entries list", async () => {
@@ -149,4 +187,19 @@ describe("official plugin install detection", () => {
);
expect(populated.installedKeys).toEqual(["plugin:goal"]);
});
it("does not match portable Agent Plugins to Cline marketplace entries", () => {
const result = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
plugins: [
{
id: "agent-plugin:goal",
name: "goal",
path: "/home/user/.agents/plugins/goal",
agentPlugin: true,
},
],
} as JsonRecord);
expect(result.installedKeys).toEqual([]);
});
});
@@ -18,8 +18,11 @@ import {
resolve,
} from "node:path";
import {
installPlugin as installCorePlugin,
installMcpServer,
type MarketplaceActionResult,
type MarketplaceEntryInput,
parseMcpInstallArgs,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
@@ -457,18 +460,6 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
};
}
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
return { command: wrapperPath, argsPrefix: [] };
}
const entry = process.argv[1]?.trim();
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
return { command: process.execPath, argsPrefix: [entry] };
}
return { command: "cline", argsPrefix: [] };
}
function isInsidePath(childPath: string, parentPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
@@ -723,6 +714,7 @@ function hasMatchingInventoryItem(
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
if (record.agentPlugin === true) return false;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
@@ -823,7 +815,6 @@ async function installSkill(
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
if (installArgs.length !== 1) {
@@ -840,41 +831,36 @@ async function installPlugin(
message: `${entry.name ?? entry.id} is already installed.`,
};
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"install",
installArgs[0] ?? "",
// Reclaim a leftover directory from a failed or interrupted install:
// without --force the CLI refuses to replace the existing path and
// every retry from the UI would fail the same way. This is safe
// because the state check just confirmed the directory contains no
// loadable plugin module.
...(installState === "partial" ? ["--force"] : []),
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
// Install in-process instead of shelling out to a `cline` binary: the
// packaged desktop app cannot assume a CLI install exists on the user's
// PATH (GUI apps inherit launchd's minimal PATH on macOS), which surfaced
// as 'Executable not found in $PATH: "cline"' in the marketplace UI.
//
// force reclaims a leftover directory from a failed or interrupted
// install: without it the installer refuses to replace the existing path
// and every retry from the UI would fail the same way. This is safe
// because the state check just confirmed the directory contains no
// loadable plugin module.
const result = await installCorePlugin({
source: installArgs[0] ?? "",
force: installState === "partial",
});
const warnings = result.mcpSyncFailures.map(
(failure) =>
`Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
details: {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
} as JsonRecord,
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
};
}
@@ -885,45 +871,26 @@ export async function installMarketplaceEntry(
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
// Register the server in-process; this only writes MCP settings, so
// there is no reason to depend on a `cline` binary being on PATH.
const result = installMcpServer(
parseMcpInstallArgs(entry.install.args ?? []),
);
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
details: result as unknown as JsonRecord,
output:
result.warnings.length > 0 ? result.warnings.join("\n") : undefined,
};
}
if (entry.type === "skill") {
return installSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return installPlugin(entry, spawnCommand);
return installPlugin(entry);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
@@ -14,6 +14,8 @@ function createContext(workspaceRoot: string): SidecarContext {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
coreStreamActivity: new Map(),
bootId: "test-boot",
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
@@ -1,10 +1,13 @@
import type { ProviderSettingsManager } from "@cline/core";
import {
completeClineDeviceAuth,
getProviderAuthStorageId,
loginLocalProvider,
markLocalProviderEnabled,
saveLocalProviderOAuthCredentials,
startClineDeviceAuth,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
export class OAuthLoginCancelledError extends Error {
constructor(providerId: string) {
@@ -26,13 +29,41 @@ type PendingOAuthLogin = {
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
export type OAuthLoginDependencies = {
login: typeof loginLocalProvider;
login: typeof loginProviderForDesktop;
save: typeof saveLocalProviderOAuthCredentials;
markEnabled: typeof markLocalProviderEnabled;
};
/**
* Cline account providers sign in with the WorkOS device-code grant, whose
* browser page asks the user to confirm a short code. `loginLocalProvider`
* runs that flow but discards the code, so use the split helpers instead and
* surface the code through `onUserCode` for the UI to display.
*/
async function loginProviderForDesktop(
providerId: string,
existing: Parameters<typeof loginLocalProvider>[1],
openUrl: (url: string) => void,
onUserCode?: (userCode: string) => void,
): ReturnType<typeof loginLocalProvider> {
if (providerId !== "cline" && providerId !== "cline-pass") {
return loginLocalProvider(providerId, existing, openUrl);
}
const device = await startClineDeviceAuth();
onUserCode?.(device.userCode);
openUrl(device.verificationUriComplete ?? device.verificationUri);
return completeClineDeviceAuth({
deviceCode: device.deviceCode,
expiresInSeconds: device.expiresInSeconds,
pollIntervalSeconds: device.pollIntervalSeconds,
apiBaseUrl:
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
provider: providerId,
});
}
const defaultDependencies: OAuthLoginDependencies = {
login: loginLocalProvider,
login: loginProviderForDesktop,
save: saveLocalProviderOAuthCredentials,
markEnabled: markLocalProviderEnabled,
};
@@ -47,7 +78,11 @@ export async function runCancellableProviderOAuthLogin(
manager: ProviderSettingsManager,
providerId: string,
openUrl: (url: string) => void,
options: { owner?: object } = {},
options: {
owner?: object;
/** Receives the device sign-in confirmation code, when the flow has one. */
onUserCode?: (userCode: string) => void;
} = {},
dependencies: OAuthLoginDependencies = defaultDependencies,
): Promise<{ provider: string; accessToken: string }> {
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
@@ -74,7 +109,7 @@ export async function runCancellableProviderOAuthLogin(
// after cancellation is observed and cannot become an unhandled
// rejection that kills the sidecar.
const credentials = await Promise.race([
dependencies.login(providerId, existing, openUrl),
dependencies.login(providerId, existing, openUrl, options.onUserCode),
cancellation,
]);
if (entry.cancelled) {
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { version } from "../package.json";
const mocks = vi.hoisted(() => ({
captureExtensionActivated: vi.fn(),
@@ -28,7 +29,14 @@ vi.mock("@cline/core", async () => {
identifyAccount: mocks.identifyAccount,
ProviderSettingsManager: class {
getProviderSettings() {
return { auth: { accountId: "account-1" } };
return {
auth: {
accountId: "account-1",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
},
};
}
},
setSdkLogger: mocks.setSdkLogger,
@@ -57,8 +65,10 @@ describe("desktop observability", () => {
expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({
metadata: expect.objectContaining({
extension_version: version,
cline_type: "desktop",
platform: "Cline",
platform: "Cline Desktop",
platform_version: version,
}),
});
expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith(
@@ -67,9 +77,18 @@ describe("desktop observability", () => {
expect(mocks.identifyAccount).toHaveBeenCalledWith(telemetry, {
id: "account-1",
provider: "cline",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
});
expect(mocks.captureExtensionActivated).toHaveBeenCalledWith(telemetry);
expect(mocks.setSdkLogger).toHaveBeenCalledWith(logger);
expect(observability.telemetryUser).toEqual({
distinctId: "account-1",
accountId: "account-1",
email: undefined,
organizationId: "org-1",
});
await observability.dispose();
await observability.dispose();
@@ -1,4 +1,3 @@
import * as os from "node:os";
import {
captureExtensionActivated,
createClineTelemetryServiceConfig,
@@ -8,7 +7,12 @@ import {
ProviderSettingsManager,
setSdkLogger,
} from "@cline/core";
import { version } from "../package.json";
import type { UserContext } from "@cline/shared";
import {
DESKTOP_TELEMETRY_METADATA,
resolveDesktopTelemetryUser,
} from "./client-context";
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
import {
createDesktopLoggerAdapter,
type DesktopLoggerAdapter,
@@ -17,6 +21,7 @@ import {
export interface DesktopObservability {
readonly logger: DesktopLoggerAdapter["core"];
readonly telemetry: ITelemetryService;
readonly telemetryUser?: UserContext;
dispose(): Promise<void>;
}
@@ -27,24 +32,25 @@ export function createDesktopObservability(): DesktopObservability {
const telemetryHandle = createConfiguredTelemetryHandle({
...createClineTelemetryServiceConfig({
metadata: {
extension_version: version,
cline_type: "desktop",
platform: "Cline",
platform_version: process.version,
os_type: os.platform(),
os_version: os.version(),
},
metadata: DESKTOP_TELEMETRY_METADATA,
}),
logger,
});
const telemetry = telemetryHandle.telemetry;
const auth = new ProviderSettingsManager().getProviderSettings("cline")?.auth;
const telemetryUser = resolveDesktopTelemetryUser({
accountId: auth?.accountId,
organizationId: auth?.organizationId,
});
if (auth?.accountId) {
identifyAccount(telemetry, {
id: auth.accountId,
provider: "cline",
organizationId: auth.organizationId,
organizationName: auth.organizationName,
memberId: auth.memberId,
});
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
}
captureExtensionActivated(telemetry);
@@ -52,6 +58,7 @@ export function createDesktopObservability(): DesktopObservability {
return {
logger,
telemetry,
telemetryUser,
async dispose() {
if (disposed) return;
disposed = true;
@@ -8,6 +8,7 @@ import type {
ToolApprovalResult,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type { UserContext } from "@cline/shared";
export type JsonRecord = Record<string, unknown>;
@@ -60,6 +61,11 @@ export type LiveSession = {
prompt?: string;
title?: string;
attachedViaHub?: boolean;
/** Iterations already in flight when the user supplied recovery guidance. */
mistakeRecovery?: {
latestIteration: number;
continuedThroughIteration?: number;
};
/** Materialized attachment files for prompts still waiting in the queue. */
queuedAttachmentFiles?: Map<string, string[]>;
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
@@ -111,10 +117,29 @@ export type SidecarWebSocketClient = {
close?: () => void;
};
/**
* Which pipe produced a chat chunk. Both feed `emitChunk`, and for a session
* streaming through the hub both carry the same events, so the observer's copy
* is dropped while the ClineCore subscription is serving that session.
*/
export type ChunkSource = "core" | "observer";
export type SidecarContext = {
liveSessions: Map<string, LiveSession>;
restoringWorkspacePaths: Set<string>;
streamIndices: Map<string, number>;
/**
* When the ClineCore subscription last delivered an event for a session, so
* the observer projection can stand down while it is serving and take over
* again if it stops.
*/
coreStreamActivity: Map<string, number>;
/**
* Identifies this sidecar process. `streamIndices` restarts whenever the
* sidecar does, so the webview needs to tell "index 1 of a new process"
* apart from a replay of the run it already rendered.
*/
bootId: string;
wsClients: Set<SidecarWebSocketClient>;
pendingApprovals: Map<string, PendingToolApproval>;
pendingQuestions: Map<string, PendingAskQuestion>;
@@ -123,6 +148,8 @@ export type SidecarContext = {
workspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
/** Analytics identity and explicit account state forwarded with each session. */
telemetryUser?: UserContext;
unsubscribeSessionEvents: (() => void) | null;
/**
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
@@ -4,5 +4,7 @@
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Cline uses the microphone to transcribe speech into chat input.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Cline uses speech recognition to turn your voice into chat input.</string>
</dict>
</plist>
@@ -5,6 +5,10 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-title",
"core:window:allow-start-dragging",
"notification:default"
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -9,5 +9,8 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Voice input requires audio capture access when Hardened Runtime is enabled. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+255 -134
View File
@@ -9,7 +9,8 @@ use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::Duration;
#[cfg(target_os = "macos")]
@@ -112,6 +113,12 @@ struct UpdateState {
// concurrently and the later one can overwrite a freshly staged "ready"
// with "idle"/"error" decided from its stale pre-await snapshot.
cycle: tokio::sync::Mutex<()>,
// Windows only: the downloaded-but-not-installed update. On Windows,
// Update::install launches the NSIS installer and std::process::exit(0)s
// immediately, so installation must wait for the user-initiated restart
// instead of running inside the background cycle like it does on macOS.
#[cfg(windows)]
pending_install: Mutex<Option<(tauri_plugin_updater::Update, Vec<u8>)>>,
}
impl UpdateState {
@@ -224,12 +231,30 @@ async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
return;
}
set_update_status(app, state, "downloading", Some(version.clone()), None);
// macOS: install right away — it only swaps the .app on disk and
// the running app keeps going until the user restarts. Windows:
// download only, because install() launches the NSIS installer
// and exits the process on the spot; the staged bytes are
// installed by restart_to_apply_update instead.
#[cfg(not(windows))]
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => set_update_status(app, state, "ready", Some(version), None),
Err(error) => {
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
}
}
#[cfg(windows)]
match update.download(|_, _| {}, || {}).await {
Ok(bytes) => {
if let Ok(mut pending) = state.pending_install.lock() {
*pending = Some((update, bytes));
}
set_update_status(app, state, "ready", Some(version), None);
}
Err(error) => {
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
}
}
}
Ok(None) => {
if ready_version.is_none() {
@@ -259,51 +284,35 @@ async fn run_update_loop(app: tauri::AppHandle, state: Arc<UpdateState>) {
struct DesktopBackendState {
ws_endpoint: Mutex<Option<String>>,
process: Mutex<Option<Child>>,
shutting_down: Mutex<bool>,
shutting_down: AtomicBool,
}
impl DesktopBackendState {
fn is_shutting_down(&self) -> bool {
self.shutting_down
.lock()
.map(|guard| *guard)
.unwrap_or(true)
self.shutting_down.load(AtomicOrdering::Acquire)
}
fn stop(&self) {
if let Ok(mut guard) = self.shutting_down.lock() {
*guard = true;
}
if let Ok(endpoint_guard) = self.ws_endpoint.lock() {
if let Some(endpoint) = endpoint_guard.as_ref() {
request_desktop_backend_shutdown(endpoint);
}
}
self.shutting_down.store(true, AtomicOrdering::Release);
if let Ok(mut process_guard) = self.process.lock() {
if let Some(child) = process_guard.as_mut() {
// The sidecar bounds its own graceful shutdown with
// SHUTDOWN_TIMEOUT_MS (5s in sidecar/index.ts) and then exits
// itself; wait past that window before escalating to kill so
// an active session can finish persisting.
for _ in 0..70 {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => thread::sleep(Duration::from_millis(100)),
Err(_) => break,
}
}
match child.try_wait() {
Ok(Some(_)) => {}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
}
// Quit runs this on the main thread (on macOS inside
// applicationWillTerminate:, where blocking beach-balls the
// app), so signal the sidecar and return without waiting.
// SIGTERM triggers its own bounded graceful shutdown
// (SHUTDOWN_TIMEOUT_MS in sidecar/index.ts), after which it
// exits itself, finishing session persistence as an orphan.
#[cfg(unix)]
let _ = Command::new("kill").arg(child.id().to_string()).status();
// Windows has no SIGTERM equivalent, so terminate outright.
// Reap the child too: TerminateProcess is quick, and the
// update-restart path needs the sidecar exe's file lock
// released before the NSIS installer replaces it.
#[cfg(not(unix))]
{
let _ = child.kill();
let _ = child.wait();
}
}
*process_guard = None;
@@ -332,13 +341,29 @@ struct DesktopBackendReadyLine {
mode: Option<String>,
}
/// The release binary is a GUI-subsystem app (no console), so on Windows
/// every console-subsystem child (git, cmd, the sidecar) would otherwise
/// allocate its own visible console window. Piped stdio does not prevent
/// that; only CREATE_NO_WINDOW does.
#[cfg(windows)]
fn hide_console_window(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn hide_console_window(_command: &mut Command) {}
fn resolve_workspace_root(launch_cwd: &str) -> String {
let output = Command::new("git")
let mut command = Command::new("git");
command
.arg("-C")
.arg(launch_cwd)
.arg("rev-parse")
.arg("--show-toplevel")
.output();
.arg("--show-toplevel");
hide_console_window(&mut command);
let output = command.output();
match output {
Ok(result) if result.status.success() => {
@@ -353,51 +378,6 @@ fn resolve_workspace_root(launch_cwd: &str) -> String {
}
}
fn request_desktop_backend_shutdown(endpoint: &str) {
let trimmed = endpoint.trim();
if trimmed.is_empty() {
return;
}
let base = trimmed.strip_suffix('/').unwrap_or(trimmed);
let url = format!("{base}/shutdown");
let timeout_seconds = "2";
#[cfg(target_os = "windows")]
{
let _ = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"try {{ Invoke-WebRequest -UseBasicParsing -Method Post -Uri '{}' -TimeoutSec {} | Out-Null }} catch {{ }}",
url.replace('\'', "''"),
timeout_seconds
),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(not(target_os = "windows"))]
{
let _ = Command::new("curl")
.args([
"-fsS",
"--connect-timeout",
timeout_seconds,
"--max-time",
timeout_seconds,
"-X",
"POST",
&url,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
fn resolve_desktop_backend_script_path(context: &AppContext) -> Option<PathBuf> {
let launch_cwd = PathBuf::from(&context.launch_cwd);
let candidates = [
@@ -501,7 +481,9 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stderr(Stdio::piped());
hide_console_window(&mut command);
command
.spawn()
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
}
@@ -525,10 +507,26 @@ fn ensure_desktop_backend_started_with(
// callers (setup, the health-check loop, endpoint fetches from the
// webview) serialize: the second caller blocks here, then sees the live
// child and returns instead of spawning a duplicate.
let mut process_guard = state
let process_guard = state
.process
.lock()
.map_err(|_| "failed to lock desktop backend process state")?;
ensure_desktop_backend_started_locked(state, process_guard, spawn_backend)
}
/// The check-and-spawn that runs under the process lock. Split from the lock
/// acquisition so a test can establish "shutdown began after the unlocked
/// check but before the lock was taken" deterministically.
fn ensure_desktop_backend_started_locked(
state: &Arc<DesktopBackendState>,
mut process_guard: MutexGuard<'_, Option<Child>>,
spawn_backend: impl FnOnce() -> Result<Child, String>,
) -> Result<(), String> {
// stop() marks shutdown before taking this same process lock. Recheck
// under the lock so a queued startup cannot spawn after shutdown.
if state.is_shutting_down() {
return Ok(());
}
if let Some(existing) = process_guard.as_mut() {
match existing.try_wait() {
// A live child owns startup even while its endpoint is still
@@ -624,7 +622,11 @@ fn resolve_mcp_settings_path() -> Result<PathBuf, String> {
return Ok(PathBuf::from(trimmed));
}
}
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
// USERPROFILE is the Windows equivalent of HOME (and what the sidecar's
// homedir() resolves there); HOME is usually unset on Windows.
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "neither HOME nor USERPROFILE is set".to_string())?;
Ok(PathBuf::from(home)
.join(".cline")
.join("data")
@@ -648,8 +650,10 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let path_arg = path.to_string_lossy().to_string();
let status = Command::new("cmd")
.args(["/C", "start", "", &path_arg])
let mut command = Command::new("cmd");
command.args(["/C", "start", "", &path_arg]);
hide_console_window(&mut command);
let status = command
.status()
.map_err(|e| format!("failed to open path: {e}"))?;
if status.success() {
@@ -675,17 +679,29 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
}
#[tauri::command]
fn get_desktop_backend_endpoint(
async fn get_desktop_backend_endpoint(
backend_state: State<'_, Arc<DesktopBackendState>>,
context: State<'_, AppContext>,
) -> Result<String, String> {
ensure_desktop_backend_started(backend_state.inner(), context.inner())?;
let backend_state = backend_state.inner().clone();
let context = context.inner().clone();
let state_for_start = backend_state.clone();
tauri::async_runtime::spawn_blocking(move || {
ensure_desktop_backend_started(&state_for_start, &context)
})
.await
.map_err(|error| format!("desktop backend startup task failed: {error}"))??;
// Sidecar startup includes login-shell PATH resolution (bounded at 3s,
// see sidecar/shell-path.ts) plus session-manager init, whose duration
// varies by machine. Poll well past that combined worst case; the loop
// returns as soon as the ready line arrives, so only failure waits long.
// While pending this only waits — respawning is ensure's job, and it
// refuses to start a second sidecar while the first one is still alive.
// A child that dies mid-poll makes this return an error rather than
// respawn: the next ensure call — the health-check loop within 5 seconds,
// or this command when the webview reconnects — replaces the dead child.
// Async sleeps keep Tauri's window event loop responsive while pending.
for _ in 0..150 {
if let Some(endpoint) = backend_state
.ws_endpoint
@@ -708,7 +724,7 @@ fn get_desktop_backend_endpoint(
if child_exited {
return Err("desktop backend exited before publishing its endpoint".to_string());
}
thread::sleep(Duration::from_millis(100));
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err("desktop backend endpoint not ready".to_string())
}
@@ -736,13 +752,52 @@ fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus
fn restart_to_apply_update(
app: tauri::AppHandle,
backend_state: State<'_, Arc<DesktopBackendState>>,
update_state: State<'_, Arc<UpdateState>>,
) {
// restart() never returns, so the run-loop Exit handler does not get a
// chance to stop the sidecar; shut it down explicitly first.
// Neither restart() nor install() returns, so the run-loop Exit handler
// does not get a chance to stop the sidecar; shut it down explicitly
// first. On Windows this also releases the sidecar exe's file lock,
// which the NSIS installer needs in order to replace it.
backend_state.stop();
// Windows: install the bytes staged by the background cycle. install()
// launches the NSIS installer (which relaunches the app when done) and
// exits this process, so it only returns on failure — fall through to a
// plain restart of the current version in that case.
#[cfg(windows)]
if let Some((update, bytes)) = update_state
.pending_install
.lock()
.ok()
.and_then(|mut pending| pending.take())
{
if let Err(error) = update.install(bytes) {
eprintln!("[updater] failed to launch the update installer: {error}");
}
}
#[cfg(not(windows))]
let _ = update_state;
app.restart();
}
/// Relaunch the current version of the app. Used after the sidecar replaces
/// the shared Cline Hub under the running app (the "Cline Hub update
/// required" flow): a fresh launch attaches everything to the new Hub instead
/// of trying to migrate live connections. restart() never returns, so the
/// run-loop Exit handler cannot stop the sidecar; do it explicitly first.
#[tauri::command]
fn relaunch_app(app: tauri::AppHandle, backend_state: State<'_, Arc<DesktopBackendState>>) {
backend_state.stop();
app.restart();
}
/// Quit the app. Used by the "Cline Hub update required" flow when the user
/// chooses to keep the older running Hub (and its live sessions) and update
/// later. The run-loop Exit handler stops the sidecar.
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
app.exit(0);
}
/// Run one updater check/download/stage cycle immediately instead of waiting
/// for the next background interval, and report the resulting status. Used by
/// flows that need an update staged right now (e.g. the "Cline Hub was
@@ -758,56 +813,82 @@ async fn check_for_update_now(
}
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
/// resource at icons/dock/<id>.png.
const APP_DOCK_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
/// webview/lib/app-icon.ts. Every id has a matching bundled resource at
/// icons/app/<id>.png.
const APP_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"];
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn resolve_app_icon(app: &tauri::AppHandle, icon: &str) -> Result<PathBuf, String> {
let icon_path = app
.path()
.resolve(
format!("icons/app/{icon}.png"),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| format!("failed resolving app icon resource: {e}"))?;
if !icon_path.exists() {
return Err(format!(
"app icon resource missing: {}",
icon_path.display()
));
}
Ok(icon_path)
}
#[tauri::command]
fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
if !APP_DOCK_ICONS.contains(&icon.as_str()) {
async fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result<bool, String> {
if !APP_ICONS.contains(&icon.as_str()) {
return Err(format!("unknown app icon: {icon}"));
}
#[cfg(target_os = "macos")]
{
// "classic" also ships as a dock resource, so every choice loads the
// same way; setApplicationIconImage's binding warns that passing nil
// to restore the bundled icon may not be allowed.
let icon_path = app
.path()
.resolve(
format!("icons/dock/{icon}.png"),
tauri::path::BaseDirectory::Resource,
)
.map_err(|e| format!("failed resolving dock icon resource: {e}"))?;
if !icon_path.exists() {
return Err(format!(
"dock icon resource missing: {}",
icon_path.display()
));
}
// Every choice uses a resource because AppKit does not support restoring
// the bundled icon by passing a nil application icon.
let icon_path = resolve_app_icon(&app, &icon)?;
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
use objc2::{AllocAnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let ns_app = NSApplication::sharedApplication(mtm);
let Some(image) = NSImage::initWithContentsOfFile(
NSImage::alloc(),
&NSString::from_str(&icon_path.to_string_lossy()),
) else {
eprintln!("[dock-icon] failed loading image: {}", icon_path.display());
return;
};
// SAFETY: called on the main thread with a valid, non-nil image.
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
let result = (|| {
let mtm = MainThreadMarker::new().ok_or_else(|| {
"app icon update did not run on the main thread".to_string()
})?;
let ns_app = NSApplication::sharedApplication(mtm);
let image = NSImage::initWithContentsOfFile(
NSImage::alloc(),
&NSString::from_str(&icon_path.to_string_lossy()),
)
.ok_or_else(|| {
format!("failed loading app icon image: {}", icon_path.display())
})?;
// SAFETY: called on the main thread with a valid, non-nil image.
unsafe { ns_app.setApplicationIconImage(Some(&image)) };
Ok(())
})();
let _ = result_tx.send(result);
})
.map_err(|e| format!("failed switching dock icon: {e}"))?;
.map_err(|e| format!("failed switching app icon: {e}"))?;
result_rx
.await
.map_err(|_| "app icon update ended before AppKit completed".to_string())??;
Ok(true)
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "windows")]
{
let icon_path = resolve_app_icon(&app, &icon)?;
let image = tauri::image::Image::from_path(&icon_path)
.map_err(|e| format!("failed loading app icon image: {e}"))?;
let window = app
.get_webview_window(MAIN_WINDOW_LABEL)
.ok_or_else(|| "main window is unavailable".to_string())?;
window
.set_icon(image)
.map_err(|e| format!("failed switching taskbar icon: {e}"))?;
Ok(true)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = app;
Ok(false)
@@ -1159,9 +1240,15 @@ fn main() {
setup_tray_icon(app)?;
let app_context = app.state::<AppContext>().inner().clone();
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
eprintln!("[desktop-backend] startup failed: {error}");
}
let state_for_start = backend_state.clone();
let context_for_start = app_context.clone();
tauri::async_runtime::spawn_blocking(move || {
if let Err(error) =
ensure_desktop_backend_started(&state_for_start, &context_for_start)
{
eprintln!("[desktop-backend] startup failed: {error}");
}
});
// Dev builds are not installed app bundles, so there is nothing the
// updater could meaningfully check or replace.
if !cfg!(debug_assertions) {
@@ -1200,7 +1287,9 @@ fn main() {
set_app_icon,
show_session_notification,
drain_desktop_actions,
set_tray_status
set_tray_status,
relaunch_app,
quit_app
])
.build(tauri::generate_context!())
.expect("error while building tauri app")
@@ -1225,6 +1314,16 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn macos_bundle_declares_voice_input_permissions() {
let info_plist = include_str!("../Info.plist");
assert!(info_plist.contains("<key>NSMicrophoneUsageDescription</key>"));
assert!(info_plist.contains("<key>NSSpeechRecognitionUsageDescription</key>"));
let entitlements = include_str!("../entitlements.plist");
assert!(entitlements.contains("<key>com.apple.security.device.audio-input</key>"));
}
#[test]
fn desktop_actions_are_buffered_in_order_until_drained() {
let state = DesktopActionState::default();
@@ -1400,6 +1499,28 @@ mod tests {
state.stop();
}
/// The interleaving where only the recheck under the lock stands between
/// shutdown and a fresh spawn: startup has passed its unlocked shutdown
/// check, stop() marks shutdown while startup is still waiting for the
/// process lock, and then startup acquires the lock. Played out directly
/// on one thread so the ordering is exact rather than scheduled.
#[test]
fn startup_queued_on_process_lock_does_not_spawn_after_shutdown() {
let state = Arc::new(DesktopBackendState::default());
let spawn_count = AtomicUsize::new(0);
assert!(!state.is_shutting_down(), "the unlocked check passes");
state.shutting_down.store(true, AtomicOrdering::Release);
let process_guard = state.process.lock().expect("process lock should succeed");
ensure_desktop_backend_started_locked(&state, process_guard, || {
spawn_count.fetch_add(1, Ordering::SeqCst);
spawn_pending_sidecar()
})
.expect("shutdown should make startup a no-op");
assert_eq!(spawn_count.load(Ordering::SeqCst), 0);
}
#[test]
fn exited_child_is_replaced_on_next_startup_check() {
let state = Arc::new(DesktopBackendState::default());
@@ -1,12 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.14",
"version": "0.0.24",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
"devUrl": "http://localhost:3125",
"beforeBuildCommand": "bun run build",
"beforeBuildCommand": "bun run dmg:background && bun run build",
"frontendDist": "../webview/out"
},
"plugins": {
@@ -38,7 +38,7 @@
"active": true,
"targets": "all",
"externalBin": ["bin/code-sidecar"],
"resources": ["icons/dock/*.png"],
"resources": ["icons/app/*.png"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -48,7 +48,22 @@
],
"macOS": {
"entitlements": "entitlements.plist",
"hardenedRuntime": true
"hardenedRuntime": true,
"dmg": {
"background": "dmg/background.gen.tiff",
"windowSize": {
"width": 640,
"height": 432
},
"appPosition": {
"x": 140,
"y": 200
},
"applicationFolderPosition": {
"x": 500,
"y": 200
}
}
}
}
}

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