Compare commits

...
Author SHA1 Message Date
Saoud Rizwan b43fb7777c Merge PR #13069 head (cloud models, prompt dedupe, org scope fixes) into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/sidecar/chat-session.ts
2026-08-17 18:27:06 -07:00
John Choi 8ea71e3278 fix(desktop): align cloud header status 2026-08-17 18:23:38 -07:00
John Choi f4abb7e14b feat(desktop): load Cline Cloud agent models 2026-08-17 18:16:56 -07:00
John Choi 1e08d8f277 fix(desktop): refresh cloud organization scope 2026-08-17 18:16:49 -07:00
John Choi 8bebcd1f37 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes
# Conflicts:
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
2026-08-17 18:07:11 -07:00
Saoud RizwanandSaoud Rizwan d4b415f8ab fix(desktop): trim the persisted transcript on checkpoint restore (#13259)
A restore that reuses the source session id rolled the workspace back but
left the persisted transcript describing the discarded turns, so the chat
kept showing turns whose file changes had just been reverted.

Before #13075 the restore reply carried the trimmed messages and the
webview rendered them directly. Now the webview always re-reads through
read_session_messages, which prefers the persisted file over the live
session, so the trimmed history the sidecar puts on the live session is
never read. Persist it as well.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-17 18:03:52 -07:00
John Choi 14f9def7ed fix(desktop): reconcile terminal cloud replies 2026-08-17 17:56:42 -07:00
John Choi 2e6c25785e fix(desktop): dedupe live cloud prompts 2026-08-17 17:41:06 -07:00
Saoud Rizwan b565e5b9ca Merge PR #13069 head (cloud provisioning/handoff fixes) into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-17 17:36:14 -07:00
Bee 6b3f034bce fix(desktop): align usage table columns CLINE-2996 (#13325)
* fix(desktop): align usage table columns

* fix(desktop): show usage link for empty history
2026-08-17 17:30:08 -07:00
Saoud Rizwan 0313c34b1a chore(desktop): release v0.0.14-beta.1 2026-08-17 17:29:42 -07:00
Saoud Rizwan d0f49330a3 Merge remote-tracking branch 'origin/main' into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
2026-08-17 17:27:25 -07:00
Saoud Rizwan 05a6974ef8 feat(desktop): surface beta channel identity in-app (#13322)
Beta builds (prerelease versions from desktop-experimental, shipped as
'Cline Code Beta') now identify themselves everywhere users look: a Beta
pill in the sidebar footer, the product name in the sidebar hover card,
an About row in Settings > General with version + channel, the runtime
window title, and the tray menu/tooltip (via package_info, which carries
the overlay's productName).

Channel detection is a pure version-string check (-beta suffix) in the
new webview/lib/app-channel.ts — the version is baked into package.json
at build time and reported by the sidecar's get_process_context, so it
works in both the Tauri shell and web dev mode with no new plumbing.
Stable builds render no channel UI at all.
2026-08-17 17:24:22 -07:00
Saoud Rizwan 5ad2dd5fc8 feat(desktop): beta release channel from desktop-experimental branch (#13321)
* feat(desktop): add beta release channel from desktop-experimental branch

Adds a 'channel' input (stable|beta) to desktop-publish.yml. Beta releases
are tagged desktop-vX.Y.Z-beta.N on the desktop-experimental branch, built
with the tauri.beta.conf.json overlay (Cline Code Beta / bot.cline.app.beta,
side-by-side install with stable), published as prerelease GitHub releases,
and served by a separate rolling desktop-beta update feed. Both channels
dispatch from main so the PublishDesktop signing gates are unchanged.

Guards: stable channel now rejects prerelease tags (previously a beta tag
could clobber desktop-latest and auto-update every stable install onto it),
feed selection is fail-closed and cross-checked in the release job, and the
build asserts the compiled binary embeds exactly its own channel's feed URL.
Changelog extraction is exact-version now that stable and beta sections
interleave across branch merges.

Process doc in apps/examples/desktop-app/EXPERIMENTAL.md; publish-desktop
skill now asks stable-or-beta.

* docs(desktop): warn against renaming the desktop-latest feed

* docs(desktop): document the code-trust model for publish approvals

The beta dispatch-from-main invariant protects the workflow definition, not
the checked-out tag's build scripts, which run with signing secrets in scope
for stable and beta alike. Make explicit that the PublishDesktop reviewer
approval is the trust gate for that code, and that desktop-experimental
therefore needs main-grade merge controls.
2026-08-17 17:24:01 -07:00
John Choi db31380268 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes 2026-08-17 17:18:21 -07:00
Bee 87be867599 fix(desktop): make routine selectors clickable (#13324) 2026-08-17 17:05:33 -07:00
Saoud Rizwan b123f2eb54 Merge remote-tracking branch 'origin/main' into desktop-experimental 2026-08-17 16:21:56 -07:00
Bee 26cb0ec9eb test(llms): use Google language operation (#13318) 2026-08-17 15:35:56 -07:00
John Choi 303722a559 fix(desktop): show prompt during cloud provisioning 2026-08-17 14:34:43 -07:00
John Choi 5fff9cbb4c fix(desktop): preserve prompt during cloud handoff 2026-08-17 12:30:16 -07:00
John Choi 70e0a3731d fix(desktop): harden cloud provisioning recovery 2026-08-17 12:18:18 -07:00
Haley Park eed78103a4 feat(ui): AskQuestion component redesign (#13236)
* feat(ui): support explicit follow-up question submission

* Fix Enter handling for question options

* Strengthen question keyboard regression test
2026-08-17 09:32:03 -07:00
Haley Park 456f86bb3a style(desktop): session hover cards styling (#13256)
* style(desktop): simplify session hover cards

* docs: add hover card screenshots

* chore: remove PR screenshot assets

* style(desktop): address hover card review
2026-08-17 09:31:56 -07:00
Haley Park e86d988234 feat(ui): add animated reasoning and tool disclosures (#13254)
* feat(ui): add animated disclosure presentation

* docs: add disclosure screenshots

* docs: remove PR screenshots

* fix(ui): support inert across React versions
2026-08-17 09:31:41 -07:00
Fnine59 041afb718b fix(vscode): restore Gemini custom base URL (#13247) 2026-08-17 13:16:43 +02:00
John Choi d52af3112d Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/webview/app/page.tsx
2026-08-14 19:21:03 -07:00
John Choi eccb0db669 fix(desktop): recover cloud provisioning handoff 2026-08-14 19:17:50 -07:00
John Choi a9af97a83f fix(desktop): preserve cloud session routing with SSH environments 2026-08-14 19:16:41 -07:00
John Choi 53388f5f9c Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/sidecar/cloud-sessions.test.ts
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-14 19:00:05 -07:00
John Choi 093e9dd3d5 fix(desktop): harden cloud session creation 2026-08-14 18:54:31 -07:00
John Choi d35f4a043b fix: reconcile experimental desktop feature stack 2026-08-14 18:34:07 -07:00
John Choi bd9ee23f6c Merge origin/main into desktop cloud sessions 2026-08-14 18:32:55 -07:00
John Choi 10ff456e93 Merge remote-tracking branch 'origin/pr/13225' into feat/experimental-desktop-pr-bundle 2026-08-14 17:58:48 -07:00
John Choi cb34ba5cf9 Merge remote-tracking branch 'origin/pr/13031' into feat/experimental-desktop-pr-bundle 2026-08-14 17:58:44 -07:00
John Choi 2ed4b811be Merge remote-tracking branch 'origin/pr/12890' into feat/experimental-desktop-pr-bundle 2026-08-14 17:53:04 -07:00
John Choi 7bf542ddab Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/sidecar/session-data/messages.test.ts
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-14 17:43:00 -07:00
BeeandClaude Fable 5 8bbdde2a5c feat(llms): add model-driven image generation (#13025)
* feat: add image generation support

* fix(llms): preserve mixed image model behavior

* fix(llms): validate generated image models

* fix(llms): preserve mixed image response streaming

* fix(llms): preserve runtime tool ownership

* fix(llms): address image generation review feedback

* fix(desktop): relay images for attached hub sessions

* chore(llms): regenerate provider and model catalog

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

* fix(vscode): preserve SDK model capabilities across the catalog boundary

The new modelSupportsToolCalling gate treats a populated capability list
without "tools" as authoritative. But the VS Code host round-trips model
metadata through the legacy ModelInfo shape, and toSdkModelInfo
reconstructed capability arrays from the legacy booleans alone — which
have no "tools" projection. Every model with any capability flag set
came back as "cannot call tools", so sessions registered zero tools and
the file-edit e2e failed on all platforms (the editor tool call resolved
to "Unknown tool" and the edit never reached disk).

Fix, following the modalities-passthrough pattern so stacked capability
PRs can reuse it:

- Preserve the SDK capability list verbatim on legacy ModelInfo at the
  catalog boundary (adaptSdkModelInfo); union user overrides into it
  without ever fabricating a list from overrides alone.
- Seed toSdkModelInfo from the preserved list, and when none survived,
  emit an explicit "tools" signal (honoring legacy supportsTools=false)
  so reconstructed arrays can never silently disable tool calling.
- Add a shared modelHasCapability(model, capability,
  {assumeWhenUnspecified}) helper: missing or empty capability lists
  carry no signal and each check declares its own default. Future
  capability gates should route through it instead of reading
  model.capabilities directly.

Verified: file-edit e2e (Single Root + Multi-Roots) passes locally;
shared/core/llms/model-catalog/session-factory suites and typechecks
pass.

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

* chore(llms): refresh generated model catalog

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:58:53 -07:00
Bee da05eeb02d feat(desktop): add microphone transcription input (#13023)
* feat(desktop): add voice input

* fix(desktop): harden voice transcription input

* fix(llms): scope voice transcription models

* fix(desktop): guard batch voice transcripts

* refactor(voice): defer chat model filtering

* fix(desktop): invalidate stale streaming transcripts

* fix(desktop): preserve batch transcription lifecycle

* chore(llms): refresh voice model catalog

* Mic Icon

* Auto

* test(desktop): align speech input icon assertions
2026-08-14 10:57:29 -07:00
Saoud Rizwan 2be49cf91b chore(desktop): release v0.0.13 2026-08-14 10:18:33 -07:00
Haley Park b851cd86d1 docs(ui): expand agent component stories (#13235) 2026-08-14 09:49:22 -07:00
Saoud Rizwan 3e0aac53a2 chore(vscode): prepare 4.1.10 release 2026-08-14 01:44:30 -07:00
Saoud Rizwan ad442cbb6a chore(cli): release v3.0.55 2026-08-14 00:39:01 -07:00
Saoud Rizwan 225f65cc0e chore(sdk): release v0.0.75 2026-08-14 00:16:53 -07:00
Saoud Rizwan 8a619a9ea6 test(llms): decouple Vertex web-search coverage from the catalog default
The Vertex case asserted that a bare providerId resolves to a model
without web search, which only held because the generated catalog's
Vertex default happened to be a Claude route. models.dev has since moved
that default to gemini-3.7-flash, which does support native web search,
so the assertion failed on the next catalog regen while the behavior it
guarded was unchanged.

Drop the catalog-dependent case and cover the default-model fallback
against a synthetic manifest instead, where the excluded route is stated
by the test rather than inherited from upstream data.
2026-08-14 00:16:48 -07:00
Saoud Rizwan 2e46676952 style: apply biome formatting to files that drifted on main 2026-08-14 00:16:43 -07:00
BeeandSaoud Rizwan 102e08f5f4 fix(core): reclaim idle plugin sandbox processes (#13227)
* fix(core): reclaim idle plugin sandboxes

* fix(core): centralize sandbox idle shutdown

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-13 23:40:07 -07:00
Saoud Rizwan 942f76e8e7 feat: add web search settings toggle to VS Code extension and desktop app (#13245) 2026-08-13 23:18:18 -07:00
Saoud Rizwan 57eb181bab fix(cli): say nothing when the Hub is only finishing an update (#13249)
The outdated_hub notice reports a state the user cannot act on: this CLI
is already the newer build, the Hub is behind only because retiring it
would kill the sessions it is serving, and the swap happens on its own at
the next launch. A toast that interrupts to say "no action needed" is
still an interruption, and the desktop surface already concluded the same
thing by rendering nothing for this reason.

It also could not deliver the message it existed for. Toast caps at
maxWidth = Math.min(44, width - 4), and the 61-character string did not
wrap, so what actually rendered was "Update finishes the next time Cline"
- a sentence cut off before the reassuring half. Identical at 120 and 200
columns, so widening the terminal did not help.

The classification stays in core and still earns its keep at this call
site: outdated_hub is what stops the update-and-restart prompt from
firing at someone who has nothing to update. Only the rendering goes.
The build_mismatch direction, where the user does have something to do,
is untouched.
2026-08-13 23:01:34 -07:00
Saoud RizwanandSaoud Rizwan 63e9c99031 fix(cli): stop streaming markdown from flashing raw text on every chunk (#13248)
Render assistant markdown with internalBlockMode="top-level" so each
top-level markdown block gets its own renderable. The default coalesced
mode merged the entire message into one block that was rebuilt and
re-highlighted on every streamed chunk, flashing settled headings and
links back to raw uncolored markdown (visible ###, unconcealed syntax)
until the async tree-sitter highlight landed, and re-wrapping rows so
the transcript jumped vertically.

Top-level blocks are reused by token identity, so settled content never
re-renders; only the trailing unstable block updates per chunk. Pass
tableOptions style=grid to keep the bordered table rendering coalesced
mode used by default.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-13 22:57:35 -07:00
afab68f3a3 fix(core): defer replacing a Hub that is serving live sessions (#13231)
* fix(core): stop concurrent Hub installs from retiring each other

Two Cline installations on different builds would shut each other's Hub
daemon down in a loop, and every session died with an abnormal socket
close (code=1006) as its daemon was killed mid-handshake.

The retire decision was a one-sided predicate: each client independently
asked "may I reuse this Hub?", and two clients on differing builds both
answered no. #13177 added build-epoch ordering to break the tie, but left
every unordered case - missing epoch, missing build id - retiring as
before, so any pair involving a build from before epochs were embedded
still looped.

Derive the decision from a total order instead. compareHubBuilds orders
two builds by embedded epoch, then core release version, then build id,
and is antisymmetric by construction, so at most one side of a pair can
ever decide to retire. A Hub that is newer or cannot be ordered is
attached over the compatible wire protocol and left to the build-mismatch
watcher to prompt about. Genuine protocol incompatibility still replaces.

Identity is now read from the same fields on both sides. Filling in a
coreVersion locally that the wire record omits made a build's identity
depend on which role it was playing, and the two directions of a pair were
then decided by different tiers with both concluding they were newer -
a second, independent way to produce the loop.

Also:
- Scope the development Hub owner by build id, so differing dev builds run
  their own daemon side by side instead of contending for one record.
  Production keeps its singleton.
- Break the circuit after repeated retirements of the same URL, bounding
  any future ordering bug to a stale-build prompt rather than an
  unusable Hub.
- Report `cline doctor fix` honestly: separate processes that survived a
  kill from ones that appeared while the fix ran, name the live parent
  respawning a daemon, and mark a startup lock held by a running process
  as held rather than leaked.

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

* fix(cli): only blame a live parent for processes seen during doctor fix

The advice printed under "started during fix" asserted that every such
process was respawned by a live parent, and told the user to go stop it. A
process can also start on its own mid-repair - someone opening a new
session - and then the instruction points at an unrelated process, or at
none at all.

Derive the wording from whether a live parent actually exists: name it
when every process has one, state the facts when none do, and split the
list when it is mixed.

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

* test(core): order the builds in the stale-discovery hub server case

The case stubbed two build ids and expected the second to replace the
first, but supplied nothing that says which came first: no epochs, and both
servers report the same core version. Ordering therefore fell to the
build-id tiebreak, where "new-build" sorts before "old-build" and the
replacement was judged the older of the two.

Give the case the epochs its name implies, and add the missing sibling for
an unorderable pair, which is attached to rather than retired - the
behavior that keeps two installations from shutting each other down.

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

* fix(core): defer replacing a Hub that is serving live sessions

Retiring a Hub kills its established WebSockets, so replacing one under a
running session ends that turn with an abnormal close (code=1006). The
replacement is correct - the newer build should own the Hub - but the
timing is not the user's to absorb mid-turn.

Defer instead while the Hub reports live sessions: the newer client
attaches to the older Hub over the compatible wire protocol, and the swap
happens once those sessions end. Attaching rather than spawning matters -
a second daemon would race the busy one for the port.

Deferring silently would be worse than the interruption it avoids, because
a long-lived session pins the Hub to old code indefinitely with nothing to
show for it. The build-mismatch watcher only ever prompted in the
direction where updating the client resolves the mismatch; its own comment
notes that older Hubs "are retired and replaced automatically, so
prompting would only flash a stale dialog", which stops being true once
replacement can be deferred.

Add the missing direction as `outdated_hub`, reported only when a mismatch
survives consecutive checks - an idle older Hub is replaced within moments
of being seen, so a single sighting would flash exactly the stale dialog
the original comment warns about. The CLI and desktop dialogs render it as
information rather than an update prompt: nothing to install, the Hub
swaps itself when the sessions end.

The direction is decided by compareHubBuilds rather than reusability,
because a Hub that is newer and one that carries too little metadata to
order are both "reusable" but need opposite advice.

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

* fix(core): key the outdated-Hub check by daemon instance, not build

The consecutive-sighting check that keeps a routine replacement from
flashing an informational dialog was keyed by build id. Two daemons from
the same build share one, so an outdated Hub replaced by another daemon of
the same older build satisfied the check and reported exactly the churn the
check exists to hide.

Carry a hubInstanceId on the mismatch event - the Hub's own id, falling
back to pid and start time - and key the pending sighting by it. A
replacement instance now restarts the count instead of confirming it.

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

* fix(core): source Hub instance identity from the discovery record

The instance id added in the previous commit was read from the probe
response, but the watcher probes without an auth token and `/health`
deliberately reports only build and address fields - no hubId, pid, or
startedAt. So the id was always undefined in production and the check it
guards still conflated two daemons of the same build. The test missed it by
injecting a hubId into a mocked probe, a shape `/health` never returns.

Take identity from the discovery record instead, which every daemon version
writes with all three fields and which a replacement daemon rewrites as its
own. The probe is still preferred when it does carry an id, since that is
the process just spoken to.

The tests now use the real `/health` payload shape and vary identity through
the discovery record, including the pid-and-start-time fallback for records
written before Hubs carried an id.

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

* fix(core): confirm the Hub record still describes the daemon just probed

Instance identity is read from discovery before the probe and build data
comes back after it, so a daemon replaced between those two steps was
described with its predecessor's identity - the replacement then satisfied
the prior daemon's pending sighting and emitted the notification the
consecutive-instance check exists to suppress.

Re-read discovery after the probe and report nothing when the record no
longer describes the same daemon. A Hub mid-swap is churn; the next check
sees whatever it settles into.

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

* revert(core): drop the watcher instance-identity hardening

Reverts the three follow-up commits that keyed the outdated-hub
consecutive-sighting check by daemon instance (42a83beae, 931431371,
9d634f7b3). They guarded one scenario - a different daemon of the same
outdated build swapping in between two watcher ticks - where the only
consequence is an informational dialog showing one interval early or
late. The unauthenticated probe carries no instance fields in
production, which is why the first attempt needed two more patches; the
original reason+buildId consecutive-sighting suppression from this PR's
base commit already covers the case that matters (not flashing a dialog
for a hub that is mid-replacement).

* fix(core): only count sessions that stopping the hub would actually harm

hasActiveHubSessions treated every non-terminal status as busy. But a
session's hub-side runtime outlives its client: a TUI that is killed or
crashes never stops its session, which then sits in the hub with no
participants and a status that never reaches a terminal state. Under the
defer-while-busy rule that pinned the displaced hub as "serving
sessions" forever - it was never retired, every new CLI kept attaching
to the old build, and the outdated-hub dialog recurred with a promise
("replaced once those sessions end") that could never come true.
Verified empirically: a cleanly detached+disposed client leaves its
session status "running" indefinitely.

Busy now means: someone is attached (participants), or a turn may be
executing hub-side (running/pending, which covers headless and scheduled
runs). An idle session with a confirmed-empty participant list is
resumable persisted state, not live work. Hubs from core < 0.0.75 omit
the participants field entirely, so idle stays conservative (busy)
there - an attached client cannot be ruled out.

updatedAt-freshness was considered and rejected as the discriminator:
the sessions row only updates on status transitions, so a single long
agentic turn looks stale while genuinely executing.

* fix(core): gate hub busyness on attached participants only

Simplifies the busy-check to the one signal that cannot go stale:
participants are live socket subscriptions the hub drops the moment a
client's connection closes, so a crashed client can never leave a ghost
that counts as busy. Session status is deliberately not consulted - a
client killed mid-turn strands its session in a non-terminal status
forever, and QA reproduced that pinning an outdated hub as "serving
sessions" until reboot. This replaces the earlier status+participants
heuristic (and drops the aging bound it was growing) with the rule the
deferred-update design stated from the start: the hub is busy while a
client is connected to a session, and replaceable otherwise.

The accepted cost: a participant-less background run executing at the
exact moment of a hub swap dies with the old hub. Rare, and its next
scheduled tick runs normally on the replacement.

* fix(cli): tell the truth about when the outdated Hub is replaced

The outdated-hub dialog and toast said the Hub is replaced "once those
sessions end". It is not: nothing retires a hub except a fresh launch
running the ensure path, so a user who quits the busy session and
watches sees the old hub stay put and concludes something is stuck
(observed in hands-on QA). Say what actually happens - the newer build
takes over the next time Cline starts after those sessions end.

* fix(cli): speak to users, not architecture, in the pending-update notice

"Cline Hub is running an older build" assumes the reader knows what the
Hub is and why builds differ. The user-relevant facts are only: your
update is not fully active yet, your work is safe, and it finishes by
itself. Say exactly that, in both the TUI and desktop dialogs and the
toast, with the version tucked in parentheses for bug reports.

* fix(cli): drop the outdated-hub dialog for a single quiet toast

The dialog interrupted the user to say that nothing is wrong and no
action is needed - the ideal number of modals for that message is zero.
The TUI now shows one info toast ("Update finishes the next time Cline
starts. No action needed.") and the desktop app shows nothing for the
outdated_hub reason; both dialog components return to their shipped
update-and-restart form, which still appears for the build_mismatch
direction where the user genuinely has something to do. The watcher
keeps reporting outdated_hub - surfaces decide, core informs.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-13 19:46:55 -07:00
Saoud Rizwan d3d3bd8749 fix(cli): defer auto-update install until no CLI is attached to the hub (#13233)
* fix(core): bridge protections for updates landing under pre-3.0.55 clients

Three pieces, each proven against real released artifacts:

- postinstall shield: CLI versions <= 3.0.54 restart the hub daemon after a
  background auto-update even while it serves live sessions, and their
  fingerprint check then rejects every replacement hub, bricking the running
  TUI. That code is on users' machines and cannot be patched — but it runs
  only after the install completes, and it bails out harmlessly when no hub
  discovery record exists. The newly installed package's postinstall sets
  the record aside so the old updater never fires.
- superseded-record fallback: the set-aside record is also the only source
  of the auth token and pid the next new-build launch needs to retire the
  displaced hub (a port probe carries neither); ensure reads it back.
- bind retry: a hub retired on the fixed port can hold it ~2s after acking
  shutdown (watchdog force-exit); the replacement daemon retries EADDRINUSE
  for up to 5s instead of dying and leaving no hub at all.

* fix(cli): defer auto-update install until no CLI is attached to the hub

Installing while cline processes run swaps the npm package under them:
their respawn paths break on the new build fingerprint, and the updater
then restarted the hub daemon out from under live sessions (the 'Hub
connection closed (code=1006)' incident). Guarding the restart treats the
symptom; the fix is to never install under a running process.

The startup check now only records that an update is available. The
install runs at process exit, and only when the hub confirms no other
cli* client is attached — desktop sidecars and connectors ship their own
binaries, so only cli* clients make the swap unsafe. With nothing old
running at install time, no hub restart is needed at all: the next launch
retires the stale hub through the existing ensure path. Deletes
restartHubServerIfRunning, ensureCliHubServerAfterUpdate, and their
support code; manual 'cline update' still installs immediately and now
just notes that the update applies on next start.

* fix(cli): apply deferred update from the entrypoint exit sequence

The CLI entrypoint always terminates with an explicit process.exit(),
which never emits beforeExit — the hook the deferred installer waited on,
so it would never have run (caught by review). Invoke applyDeferredUpdate
directly from the entrypoint's exit sequence after disposeAll(), where
every normal termination passes; crash paths deliberately skip it. Also
clear the pending update once an install spawns so the apply is
idempotent.

* test(cli): isolate unit tests from the real ~/.cline

A full vitest run could leave a real hub daemon running against the
developer's actual ~/.cline discovery record (observed while validating
this PR: a daemon spawned from the globally installed cline binary,
attached to the real data dir). Point CLINE_DIR, CLINE_DATA_DIR, and
CLINE_HUB_DISCOVERY_PATH at a per-worker temp dir and disable auto-update
before any test file loads; subprocesses inherit the isolation via env.

* fix(core): discard the superseded discovery record once consumed

The set-aside record is one-shot recovery metadata, but nothing deleted
it, and it feeds a pid into retireDiscoveredHub's SIGTERM. Weeks later a
launch that finds no live record (routine after any retirement) could
read the stale file and signal whatever process the OS recycled that pid
onto (review finding by @abeatrix). Unlink it at every ensure resolution
that ends with a live, verified hub; failure paths keep it for the next
attempt.

* fix(cli): harden the exit-time update gate

Three review findings on the deferred-apply path:

- A wedged hub could stall an otherwise-finished CLI for tens of seconds
  via the hub client's default timeouts; the whole exit-time query is now
  bounded to 3s, with timeout counting as attached (never install unless
  the hub positively confirms).
- Sub-second commands exited before the startup version check resolved
  and silently dropped the update every time for one-shot-only usage;
  exit now grants the in-flight check a 250ms grace.
- client.list can lose a TUI's registration during transport churn while
  its session connection survives, so an empty client list is not proof
  of safety; cross-check sessions with participants. Participants rather
  than session status: finished sessions linger idle forever and must
  not pin updates, and participant-less scheduled runs live in the hub
  process, which the binary swap does not touch. Verified live: a
  session-holding client invisible to client.list defers the install,
  and the gate opens once it disconnects.

* docs(cli): fix stale beforeExit reference in the exit-gate comment

* style(cli): apply biome formatting to update deferral code

* fix(cli): let doctor see a hub whose record the update shield set aside

During the shielded update window the discovery record is renamed to
.superseded so pre-3.0.55 updaters cannot restart a busy hub. Doctor
read only the primary record, so in that window it reported the live
daemon - the one serving the user's still-open old session - as a stale
hub daemon and advised 'cline doctor fix', which kills it and reproduces
the exact 1006 incident the shield exists to prevent (found by QA).
Doctor now falls back to the set-aside record the same way the ensure
path does, and doctor fix clears the set-aside file along with the
primary record so a deliberate reset does not leave stale retirement
metadata pointing at a recyclable pid.

* fix(core): keep shielded sessions on one Hub authority (#13244)

* fix(core): recover shielded busy hub discovery

* chore(core): instrument shielded hub recovery

* fix(core): recover shielded hubs with attached clients

* fix(cli): recognize shielded hubs in doctor

* refactor(core): keep shield recovery minimal

* fix(core): retain shared Hub idle helper semantics

* chore(core): align busyness helper with the #13231 wording

The participants-only hasActiveHubSessions here duplicates the change on
bee/hub-lifecycle (this branch needs its semantics for the participant
gate). Matching that version byte for byte lets the two merges resolve
cleanly instead of conflicting. Also restores the module-registry reset
comment this branch dropped - it documents a real local-vs-CI gotcha.
2026-08-13 19:39:44 -07:00
BeeandClaude Opus 5 afab86dcbd fix(core): stop concurrent Hub installs from retiring each other (#13230)
* fix(core): stop concurrent Hub installs from retiring each other

Two Cline installations on different builds would shut each other's Hub
daemon down in a loop, and every session died with an abnormal socket
close (code=1006) as its daemon was killed mid-handshake.

The retire decision was a one-sided predicate: each client independently
asked "may I reuse this Hub?", and two clients on differing builds both
answered no. #13177 added build-epoch ordering to break the tie, but left
every unordered case - missing epoch, missing build id - retiring as
before, so any pair involving a build from before epochs were embedded
still looped.

Derive the decision from a total order instead. compareHubBuilds orders
two builds by embedded epoch, then core release version, then build id,
and is antisymmetric by construction, so at most one side of a pair can
ever decide to retire. A Hub that is newer or cannot be ordered is
attached over the compatible wire protocol and left to the build-mismatch
watcher to prompt about. Genuine protocol incompatibility still replaces.

Identity is now read from the same fields on both sides. Filling in a
coreVersion locally that the wire record omits made a build's identity
depend on which role it was playing, and the two directions of a pair were
then decided by different tiers with both concluding they were newer -
a second, independent way to produce the loop.

Also:
- Scope the development Hub owner by build id, so differing dev builds run
  their own daemon side by side instead of contending for one record.
  Production keeps its singleton.
- Break the circuit after repeated retirements of the same URL, bounding
  any future ordering bug to a stale-build prompt rather than an
  unusable Hub.
- Report `cline doctor fix` honestly: separate processes that survived a
  kill from ones that appeared while the fix ran, name the live parent
  respawning a daemon, and mark a startup lock held by a running process
  as held rather than leaked.

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

* fix(cli): only blame a live parent for processes seen during doctor fix

The advice printed under "started during fix" asserted that every such
process was respawned by a live parent, and told the user to go stop it. A
process can also start on its own mid-repair - someone opening a new
session - and then the instruction points at an unrelated process, or at
none at all.

Derive the wording from whether a live parent actually exists: name it
when every process has one, state the facts when none do, and split the
list when it is mixed.

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

* test(core): order the builds in the stale-discovery hub server case

The case stubbed two build ids and expected the second to replace the
first, but supplied nothing that says which came first: no epochs, and both
servers report the same core version. Ordering therefore fell to the
build-id tiebreak, where "new-build" sorts before "old-build" and the
replacement was judged the older of the two.

Give the case the epochs its name implies, and add the missing sibling for
an unorderable pair, which is attached to rather than retired - the
behavior that keeps two installations from shutting each other down.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:39:18 -07:00
BeeandSaoud Rizwan 6d7e745fb7 feat(core, llms): add provider-aware web search tools (#13075)
* feat(core, llms): Cline custom provider & web search

* fix(llms): preserve reasoning model token parameter

* fix(llms): keep ClinePass provider options on the wire in the shared Cline provider

The shared Cline provider hardcoded the AI SDK provider name to "cline",
but the openai-compatible model reads request-body passthrough options from
providerOptions[<name>]. Option routing emits ClinePass options under the
"cline-pass"/"clinePass" buckets, so gateway reasoning (extended thinking
budgets) silently stopped reaching the wire for cline-pass after it moved
off the generic openai-compatible module.

Thread the gateway provider id through as the provider name, and restore
strictJsonSchema: false for the new "cline" provider-options target so the
wire format matches the previous openai-compatible behavior. Add cline-pass
coverage at both the option-routing and request-body levels.

* feat(sdk): persist provider-executed tool activity (#13077)

* feat(core, llms): Cline custom provider & web search

* fix(llms): preserve reasoning model token parameter

* feat(sdk): persist provider-executed tool activity

* fix(vscode): restore state proto and settings section reverted by merge

The merge of origin/bee/websearch into this branch resolved conflicts by
keeping this branch's pre-#13126 copies of apps/vscode files, which
deleted the auto_approve_all_toggled = 174 proto field (without reserving
the number) and dropped a formatting line in FeatureSettingsSection.tsx.
Neither file is in scope for this PR. Restore both to main's content so
the proto source matches the checked-in generated code again.

* chore(vscode): match main byte-for-byte in FeatureSettingsSection.tsx

The pre-commit biome hook strips a blank line that exists on main, which
kept this out-of-scope file in the PR diff. Commit the exact main content
with --no-verify so the PR no longer touches apps/vscode at all.

---------

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

* refactor(llms): key ClinePass provider options to the shared cline bucket

Both Cline gateway ids (cline and cline-pass) are served by the same
shared "cline" AI SDK provider and hit the same Cline API, so threading
the gateway provider id through as the AI SDK provider name (78dc6f3e7)
was unnecessary indirection. Revert the name threading and instead
normalize option-routing bucket keys: buildProviderAndAliasPatch now
keys both Cline gateway ids to the shared "cline" providerOptions
bucket, which is the only bucket the openai-compatible model reads for
request-body passthrough.

Also tighten the regression coverage that motivated the original fix:
the previous effort-based test rows were vacuously satisfied through the
portable-reasoning early return (effort reasoning never reaches provider
option buckets by design). The rows now use explicit reasoning budgets,
which do flow through the gateway bucket path, and the wire-level test
composes real provider options end to end instead of hand-feeding
buckets.

* revert(llms): drop the cline strictJsonSchema special case in generic-compatible

Restores buildCompatibleProviderOptions to its pre-78dc6f3e7 state. The
strictJsonSchema passthrough is verified inert for the gateway (nothing
in @cline/llms sets a response format), so keeping a hardcoded provider
target in the generic helper bought nothing. If structured outputs are
ever added, strictness for the cline target can be decided deliberately
then.

* fix(llms): claim native web search for openai-native, not the openai alias

supportsModelTool listed "openai", but that id aliases to
openai-compatible (PROVIDER_ID_ALIASES), whose module has no native web
search. The actual native OpenAI builtin id is "openai-native", which is
served by the OpenAI Responses module that does implement
buildModelTools with provider.tools.webSearch(). Without this, the
web_search tool was never offered to native OpenAI users, and was
wrongly offered for the compatible alias.

* refactor(llms): declare model tools in provider manifests

* feat(sdk): project provider tool activity in session history

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-13 18:12:10 -07:00
abeatrix 1cb61ab925 Merge remote-tracking branch 'origin/main' into bee/agent-voice
# Conflicts:
#	apps/examples/desktop-app/package.json
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/app/globals.css
#	apps/examples/desktop-app/webview/app/page.tsx
#	apps/examples/desktop-app/webview/components/agent-header.tsx
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
#	apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx
#	apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
#	apps/examples/desktop-app/webview/lib/desktop-tray.test.ts
#	apps/examples/desktop-app/webview/lib/desktop-tray.ts
#	apps/examples/desktop-app/webview/lib/session-history.ts
#	bun.lock
#	sdk/packages/llms/package.json
#	sdk/packages/llms/src/catalog/catalog.generated.ts
#	sdk/packages/llms/src/providers/providers.generated.ts
#	sdk/packages/shared/src/llms/ai-sdk-format.ts
#	sdk/packages/ui/package.json
2026-08-13 15:54:16 -07:00
John Choi 7bf495878e fix(desktop): harden cloud session lifecycle 2026-08-13 15:52:04 -07:00
Tomás Barreiro 677a5cd915 Merge branch 'main' into add-integrations-onboarding-step 2026-08-13 19:05:59 -03:00
Bee fcd5a9e0fb feat(desktop): add app font size bootstrap script (#13232)
* feat: add app font size bootstrap script

- Import and inject APP_FONT_SIZE_BOOTSTRAP_SCRIPT in root layout
- Call syncAppFontSize on app initialization
- Add aria attributes (describedby, label, labelledby, valuetext) to Slider component
- Replace thumb key generation with useId hook for better stability
- Add settings view tests for font size functionality

* feat(desktop): add native zoom menu shortcuts
2026-08-13 14:08:09 -07:00
Tomás Barreiro 4524e884a7 Merge branch 'main' into add-integrations-onboarding-step 2026-08-13 15:42:32 -03:00
BarreiroT 75d1d6654b validate domain and fix errors on auth 2026-08-13 14:56:45 -03:00
BarreiroT 45027ee4e3 Add a GitHub integration step to the onboarding 2026-08-13 14:33:14 -03:00
John Choi 7f8fd0b62d fix(desktop): harden cloud session synchronization 2026-08-13 08:16:53 -07:00
John Choi 6df53ddf64 Merge origin/main into desktop cloud sessions 2026-08-13 08:15:59 -07:00
Saoud Rizwan c8afb44368 chore(vscode): prepare 4.1.9 release 2026-08-13 00:08:09 -07:00
Saoud Rizwan d30cce4cf1 chore(cli): release v3.0.54 2026-08-12 23:13:29 -07:00
Saoud Rizwan 3f39c46026 chore(sdk): release v0.0.74 2026-08-12 22:45:26 -07:00
Saoud Rizwan 274ad3bdb2 chore(desktop): release v0.0.12 2026-08-12 22:37:06 -07:00
Saoud Rizwan e6b1c3fbc8 chore(ui): bump @cline/ui to 0.2.0-next.4
Picks up the shared button primitives (#13164), the one-row-per-tool-call
chat rendering (#13186), the refined session chat layout (#13205), and the
@pierre/diffs hunk renderer (#13201) that landed since 0.2.0-next.3.
2026-08-12 20:40:03 -07:00
Bee 5b22490d41 fix(desktop): filter scheduled Core sessions (#13213) 2026-08-12 18:18:09 -07:00
Saoud RizwanandSaoud Rizwan d03e88e50b Render desktop diff view hunks with the shared @pierre/diffs renderer (#13201)
* Render desktop diff view hunks with shared @pierre/diffs renderer

Replace DiffView's hand-rolled DiffHunk +/- line rows with ToolFileDiff
from @cline/ui (backed by @pierre/diffs), matching the chat tool rows.
Hunks carrying complete new contents (created files) render with real
line numbers; fragment hunks hide them, mirroring ToolCallRow. All of
DiffView's chrome (collapse, copy, open-in-editor, counts) is unchanged.

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

* Make ToolFileDiff syntax palette follow the app theme, not browser preference

@pierre/diffs declares 'color-scheme: light dark' on its shadow :host, so
its light-dark() token colors resolve from the browser's preferred scheme.
Apps themed by the .dark class (desktop app) got the light palette's
near-black text on dark surfaces. Inline colorScheme: inherit on the host
wins over the :host rule and follows the app's color-scheme, which the
@cline/ui theme already flips with .dark. Skipped when a caller pins an
explicit themeType.

Also key diff-view hunks by index so repeated same-shaped hunks (a file
created twice with identical contents) don't collide.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-12 18:06:44 -07:00
Sufiyan Khan 02a1bfd0a8 fix(webview): use editor foreground for diff block text colors (#13200) 2026-08-13 09:41:52 +09:00
Saoud RizwanandSaoud Rizwan 4559f12c66 feat(desktop): temporarily disable welcome prompt suggestions and center input (#13170)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-12 14:59:03 -07:00
Saoud RizwanandSaoud Rizwan 6d6f79f948 Fix Claude Code provider: anchor session on workspace, load user settings, allow edits (#13152)
The Claude Code provider was unusable for agentic work (#13146):
- The claude-code manifest lacked the provider-tools capability, so the
  gateway sent Cline's tool definitions (which the provider drops as
  unbridgeable) while the CLI's native tools stayed enabled with no
  approval plumbing - every write was refused and no prompt appeared.
- ai-sdk-provider-claude-code defaults settingSources to [], so the
  spawned session read neither ~/.claude/settings.json nor project
  settings, silently ignoring user-configured permission rules.
- No cwd was passed, so the session inherited the extension host's
  cwd (/ on macOS) and refused writes outside it.

Changes:
- Mark claude-code with provider-tools (same treatment as the Codex
  CLI provider): stop sending unbridgeable external tools and let the
  CLI execute its own, tagged executionMode=provider for the runtime.
- Forward the session workspace cwd from @cline/core into the
  claude-code gateway provider options and lift it into the agent
  session settings.
- Default settingSources to [user, project] and permissionMode to
  acceptEdits (file edits under cwd auto-approved; command execution
  stays gated by the user's own Claude settings), all overridable via
  explicit defaultSettings.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-12 14:51:37 -07:00
Saoud RizwanandCursor Agent cde49170db fix(desktop): first turn of a fresh session no longer wedges the composer (#13122)
* fix(desktop): don't let a stale queued send response wedge the composer

A fresh session is still busy while its interactive loop starts, so the
sidecar coerces the first send onto the pending-prompt queue and replies
{queued:true} with a queue snapshot taken at enqueue time. The turn itself
runs via the runtime's queue drain and completes through stream events
(chat_queued_prompt_start -> deltas -> chat_done). On cold/slow sidecars the
RPC response lands only after those events; the webview then applied the
stale snapshot and unconditionally set status back to "running", leaving
the composer on "Agent is working..." forever and resurrecting a phantom
queue entry.

Webview: capture the turn epoch at send dispatch; chat_queued_prompt_start
bumps it, so a mismatch when the queued response arrives means the stream
already advanced the turn lifecycle and the response is ignored. Aborts now
resolve the queued branch to "cancelled" like the direct path.

Sidecar: the queued send response no longer routes its enqueue-time snapshot
through applyPendingPrompts, which overwrote the event-maintained
session.promptsInQueue and rebroadcast the stale list to every webview.

Includes deterministic regression tests for the stale-response orderings
plus temporary [P0DBG] debug instrumentation (region-marked, to be removed
after runtime verification).

* fix(desktop): ignore stale hub 'running' status after turn settles

The sidecar core is hub-attached, so chat_session_status events are
asynchronous projections of the hub's session record. A stale 'running'
can trail the stream's chat_done and flip a settled turn back to busy,
wedging the composer on 'Agent is working…' with nothing left to
reconcile. Track the epoch at which the turn settled and drop 'running'
status events until a new turn bumps the epoch.

* chore: remove stray QA screenshot artifacts from repo root

* chore(desktop): remove P0 debug instrumentation and fault injection

Strips all [P0DBG] logging, the /p0dbg sidecar route, the webview log
mirror + heartbeat, and the P0DBG_STARTUP_BUSY_MS /
P0DBG_DELAY_QUEUED_RESPONSE_MS fault-injection paths used to reproduce
the stuck-composer P0. The two real fixes (stale queued-response epoch
guard + stale-running-after-settle guard in the webview, and the
sidecar's non-clobbering queued-send snapshot) and the regression tests
remain.

* refactor(desktop): replace turn-epoch guards with an explicit turn lifecycle

The stuck-composer fixes left the hook with two hand-rolled epoch refs
(turnEpochRef / turnSettledEpochRef) mutated and compared inline across
eight call sites. Extract the rules into a pure TurnLifecycle module that
is now the only writer of the session status:

- a settled turn cannot be reopened: stale hub 'running' projections and
  stale queued-send acknowledgements are dropped by the lifecycle instead
  of by inline epoch comparisons
- async work (send RPC responses, queue reconciliation) captures an opaque
  token and the lifecycle decides whether the world moved on, instead of
  handlers comparing counters
- every status write goes through a named operation (begin, turnStarted,
  settle, projectStatus, apply, reset), so the state machine is explicit
  and unit-testable in isolation

No behavior change: the 5 wedge regression tests and the full hook suite
pass unchanged, plus 10 new unit tests for the lifecycle module itself.

* Revert "refactor(desktop): replace turn-epoch guards with an explicit turn lifecycle"

This reverts commit c480aaabe8.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-12 14:50:39 -07:00
Haley Park e703f7af2a style(desktop): refine session chat layout (#13205)
* style(ui): refine chat message surfaces and actions

* feat(desktop): refine session transcript layout

* style(desktop): refine conversation composer

* style(ui): format chat theme changes
2026-08-12 14:38:50 -07:00
Saoud Rizwan 105d357b12 fix(vscode): stop legacy-task migration backlog telemetry spam (#13185)
* fix(vscode): stop legacy-migration backlog telemetry spam, emit real migration outcomes

* refactor(vscode): slim migration telemetry fix to minimal surface

* fix(vscode): emit legacy migration outcome only after seeded session start settles

The completed event fired at in-memory conversion time, before the
seeded session start persisted the migration, so a start/persistence
failure was misreported as a successful migration and never produced an
error outcome. Conversion now records a pending migration; the followup
and compaction coordinators settle it after the session start resolves
(completed) or rejects (error/session_start_failed).

* fix(vscode): surface seeded-persistence failures in migration outcomes

LocalRuntimeHost.startSession deliberately swallows seeded-message
persistence failures (the in-memory session still works), so a resolved
start was not proof the legacy conversion became durable. The start
result now reports seededMessagesPersistence, and the resume/compaction
coordinators settle the migration from that result: completed only when
the seed write succeeded, error/seed_persistence_failed when the start
resolved but the write failed, error/session_start_failed when the
start rejected. durationMs now spans conversion through settlement.

Adds the core boundary test forcing persistSessionMessages to fail and
asserting the start still resolves with the failure visible on the
result, plus coordinator tests for both failure modes.

* refactor(vscode): drop per-task migration outcome events, keep volume fixes only

Scope the PR down to the zero-behavioral-risk telemetry fixes, per
review: keep the backlog event transition gating and the one-line
migratedSdkTaskCount fix (counting resumed legacy sessions via their
legacyTask metadata), and revert the per-task terminal outcome
plumbing (pending-migration settlement, coordinator hooks, and the
core StartSessionResult.seededMessagesPersistence field) along with
the success->completed outcome rename on the now-uncalled
captureLegacyTaskMigration. The per-task outcome events can land
separately on the observable persistence boundary.
2026-08-12 14:22:28 -07:00
Sufiyan KhanandMikołaj Kondratek 274e0c574f Fix silently repaired truncated tool-call JSON (#13015)
* fix(llms): reject truncated tool-call JSON with unterminated strings

* fix(llms): scope truncation guard to jsonrepair only and handle single quotes

* fix(shared): keep jsonrepair ahead of bare-object repair for typed literals

Restores main's precedence for inputs both strategies can handle:
{"flag": True} must repair to a typed true, not the string "True".
The truncation guard now gates only the jsonrepair step, which is the
only strategy that can invent a string terminator.

---------

Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com>
2026-08-12 22:32:03 +02:00
Saoud Rizwan 98d883bf6e fix(llms): replace all-empty-text messages with placeholder content (#13204)
A message whose content array held only empty text parts slipped past the
existing empty-content guards in formatMessagesForAiSdk (which cover
content: "" and content: []). The AI SDK then strips empty text parts,
producing {"role":"user","content":[]} on the wire, which strict
providers reject — seen in prod as Vercel 400s for kimi-k3:
"user message must have content".
2026-08-12 13:15:44 -07:00
Saoud Rizwan 6ff6a1ecbb fix(telemetry): emit per-request deltas in task.tokens on SDK surfaces (#13188)
* fix(telemetry): emit disjoint per-request token buckets in task.tokens

SDK usage events follow the AI SDK convention where inputTokens is the
full request input including cache reads/writes. task.tokens forwarded
that value as tokensIn while also reporting cacheReadTokens and
cacheWriteTokens, so every event re-counted the whole (mostly cached)
conversation context and per-task token sums inflated ~5x on
cache-heavy sessions relative to the legacy contract (tokensIn =
uncached input only, disjoint buckets).

task.tokens now subtracts the cache buckets from tokensIn at the
capture site (mirroring the webview's normalizeUsageEvent), defaults
the cache buckets to 0 instead of undefined, and stamps the provider
attribute for parity with the legacy event schema. Event and attribute
names are unchanged.

* fix(core): normalize registered ApiHandler usage to cache-inclusive inputTokens

Review follow-up: two producer contracts shared AgentUsage.inputTokens.
Native AI SDK usage reports the full cache-inclusive prompt size, but the
ApiHandler adapter forwarded classic disjoint chunk.inputTokens unchanged,
so the task.tokens cache subtraction would zero out real uncached input
for a cache-reporting registered handler.

Normalize at the adapter boundary (inputTokens + cacheReadTokens +
cacheWriteTokens) so every producer entering AgentUsage satisfies the
same cache-inclusive invariant, document that invariant on
AgentTokenUsage.inputTokens, and reframe the telemetry clamp as a
defensive guard rather than a supported producer shape. Adds an adapter
normalization test and a boundary test from an ApiStreamUsageChunk
through task.tokens asserting the disjoint buckets round-trip.

* Revert "fix(core): normalize registered ApiHandler usage to cache-inclusive inputTokens"

This reverts commit 9a9aff374a.
2026-08-12 12:53:50 -07:00
Saoud Rizwan 0f8d715dc7 fix(telemetry): report involuntary Cline logouts from the SDK auth service (#13183)
* fix(telemetry): report involuntary Cline logouts from the SDK auth service

The SDK auth service cleared credentials silently when a refresh token was
rejected (invalid grant), both mid-session and during startup restore, so
user.auth_logged_out never captured involuntary logouts on the next bundle.
Emit token_invalid at both credential-clearing sites and restore_error when
startup restore throws, matching the reason vocabulary the legacy bundle now
uses so the same warehouse query measures involuntary logouts across rollout
variants. Startup with no stored session still emits nothing.

* refactor(telemetry): trim logout-reason parity change to the minimum

* fix(telemetry): report Cline invalid-grant logouts as token_invalid in the SDK resolver

getValidClineCredentials is the single owner of the involuntary-logout
event for the Cline provider; normalize its reason to the legacy
extension's LogoutReason vocabulary (token_invalid) so warehouse queries
cover both bundles. The raw OAuth code stays in errorCode. Codex/OCA
paths keep emitting invalid_grant and are unaffected.

* fix(telemetry): let the SDK resolver own token_invalid; keep restore_error for real restore failures

Address review on the SDK-adapter half of the logout-reason split:
- drop both adapter-side token_invalid emissions - the SDK resolver
  already emits user.auth_logged_out on the same telemetry instance, so
  the adapter was double-counting the exact signal being measured
- transient failures refreshing the stored session on startup (resolver
  throws: network/timeout/5xx) no longer book as restore_error; stored
  credentials are kept and the SDK books auth_refresh_soft_failure, so
  an offline startup is not a logout
- single-source LogoutReason in services/auth/types.ts and re-export it
  from the SDK auth service instead of maintaining two parallel enums
- boundary test runs the real getValidClineCredentials and asserts
  exactly one auth_logged_out (reason=token_invalid) total, so a
  reintroduced adapter emission fails the suite
2026-08-12 12:52:01 -07:00
Saoud Rizwan 3d460d7add feat(hub): directional Hub upgrades with update-and-restart prompts in CLI and desktop (#13177)
* feat(hub): prompt update and restart when another install replaces the shared Hub

* feat(hub): make managed Hub build-watch interval configurable via CLINE_HUB_BUILD_WATCH_INTERVAL_MS

* feat(hub): reuse newer managed Hub builds instead of retiring them

Embed a build epoch alongside the deterministic runtime fingerprint so
managed-Hub compatibility can order builds in time. When fingerprints
differ, a Hub produced after the client's own build is attached over the
compatible wire protocol (and the build-mismatch watcher prompts the user
to update) instead of being retired, so concurrent installations converge
on the newest build rather than replacing each other's daemons. Older,
unordered, or metadata-less Hubs are retired and replaced as before.

* refactor(hub): simplify mismatch status derivation and dedupe sidecar event encoding

* fix(cli): only watch for managed Hub build mismatches in hub-attached sessions

Yolo and sandbox sessions force the local backend and never attach to the
shared managed Hub, so a newer Hub owned by another installation must not
interrupt them with the blocking update dialog.

* fix(desktop): stage an app update before hub-mismatch restart

'Update and restart' previously invoked restart_to_apply_update directly,
which only relaunches the current bundle. With no update staged by the
background 2h updater loop, the app came back on the same version, hit the
same newer Hub, and re-prompted immediately.

Add a check_for_update_now Tauri command that runs one updater
check/download/stage cycle on demand and reports the resulting status. The
dialog now stages the update first and restarts only when the updater
reports 'ready'; otherwise it stays open and explains that no update is
downloadable yet (or that the check failed) instead of restarting into the
same version. Addresses the outstanding Greptile P1 on the dialog.

* fix(desktop): reset the no-update hint when a new hub mismatch arrives

Without this, a dialog for a fresh mismatch reopened pre-set to 'Try again'
with the previous prompt's stale hint.

* fix(desktop): serialize updater cycles so overlapping checks cannot clobber a staged update

The periodic update loop and the on-demand check_for_update_now command
run the same check/download/stage cycle against shared state. Without
exclusion, two overlapping cycles could download the same bundle
concurrently, and the later one could overwrite a freshly staged "ready"
status with "idle" or "error" decided from its stale pre-await
ready_version snapshot - making the update dialog deny that a staged
update exists. A tokio::sync::Mutex now serializes whole cycles; the
ready_version snapshot is read under the lock, so it stays authoritative
for the cycle that took it.
2026-08-12 12:33:27 -07:00
Bee 24f66a4673 fix(core): harden Cline Hub daemon lifecycle (#13168)
* fix(core): harden Hub daemon lifecycle

* fix(core): wait for Hub listener before replacement

* fix(core): recover after Hub cleanup errors

* fix(core): assign the close memo handle before socket termination re-enters beginClose

On every shutdown with a connected client, the daemon logged
'unhandledRejection: AggregateError: hub server close failed' and exited
with code 1 instead of 0. Root cause: beginClose() terminated the
tracked WebSockets before assigning closeHandle. terminate() fires close
events whose microtask continuations advance the daemon coordinator's
deferred cleanup into server.beginClose() while the first invocation is
still mid-body, so the memo guard passes twice and a second set of
wss.close()/server.close() calls runs against the already-closing server,
rejecting with 'Server is not running' and spuriously failing the close
aggregate. The rejection then rode the daemon's unhandledRejection
fatal path and escalated the exit code.

Construct the close promises and assign the memo handle first, and only
then terminate sockets and run detach handlers; a re-entrant call now
hits the memo guard. Also observe the /shutdown handler's
fire-and-forget closeServer() so a genuine close failure is reported
solely by the owner's own await on the same memoized promise instead of
the unhandledRejection path.

Verified with a real daemon: shutdown with zero clients stays graceful
(exit 0, ~26ms); shutdown with a held-open authenticated WebSocket now
exits 0 with no unhandled rejection, still bounded by the 2s coordinator
deadline for the genuine Bun listener-close stall, with the discovery
record cleaned. Hub suites (228) and the shutdown e2e (5) pass.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-12 12:33:27 -07:00
Saoud Rizwan 354c80df7b fix(llms): update AI SDK deps so streamed tool calls with non-zero indexes don't crash (hasFinished) (#13123)
* fix(llms): update AI SDK deps to fix streamed tool calls with non-zero indexes

LiteLLM's Anthropic passthrough emits chat-completions tool_call deltas
whose index mirrors the Anthropic content-block index (1 when a text
block precedes the tool call; see BerriAI/litellm#11580).
@ai-sdk/provider-utils 5.0.18 stored streamed tool calls in a sparse
array keyed by that index and crashed at stream flush with
"Cannot read properties of undefined (reading 'hasFinished')",
aborting the agent turn. Upstream fixed this in provider-utils 5.0.21
("Fix streamed tool calls with non-zero, non-contiguous, reused, or
missing indexes.").

Update the ai / @ai-sdk packages so every chat-completions streaming
path resolves @ai-sdk/provider-utils 5.0.25, and drop the root
">=4.0.0" override on @ai-sdk/provider-utils: with intersect semantics
it pinned the workspace to the already-locked 5.0.18 even after parents
began requiring 5.0.25, and it force-upgraded dify-ai-provider two
majors past its declared ^3 range. Each package now resolves the
version line it declares.

Fixes #13119

* test(llms): pin non-zero streamed tool_call index regression (#13119)

Wire-level regression test: an openai-compatible SSE stream whose only
tool_call delta carries index 1 (Anthropic content-block numbering via
LiteLLM) must complete and emit the tool-call part instead of throwing
at flush.

* fix: address review findings from merge-conflict resolution

- Restore apps/vscode/proto/cline/state.proto to main's version: the
  merge commit's pre-commit hook regenerated it with a stale generator,
  deleting auto_approve_all_toggled = 174 and moving a reserved line,
  creating drift against the checked-in descriptor. The deletion was
  never intended.
- Restore FeatureSettingsSection.tsx to main's version (the same hook
  reformatted main's file during the merge).
- Regenerate bun.lock narrowly from main's lockfile without --force so
  the diff contains only the @ai-sdk family and its direct transitives;
  drop the spurious webview-ui-scoped @radix-ui duplicate entries the
  previous install introduced (hoisted resolutions still satisfy
  webview-ui's unchanged ranges; verified with --frozen-lockfile).
- Align @ai-sdk/provider to ^4.0.7 in @cline/llms to match the rest of
  the AI SDK family and avoid parallel provider resolutions.

Revalidated: wire repro streams to finishReason=tool-calls, @cline/llms
suite passes incl. the index-1 regression test, all workspaces
typecheck, SDK builds clean.

* fix: restore FeatureSettingsSection.tsx to main's formatting

The branch's pre-commit biome hook (--semicolons=as-needed, --write
--staged) strips a blank line from this file whenever it is staged,
which is how the unintended diff appeared in the merge commit. Commit
with --no-verify to keep the file byte-identical to main; this PR does
not touch the VS Code webview.
2026-08-12 11:30:42 -07:00
John Choi 8437a01906 fix(hub): correlate run start acknowledgments (#13054) 2026-08-12 11:26:16 -07:00
Haley Park 986f0b010b feat(ui): add shared button primitives (#13164)
* feat(ui): add shared button primitives

* fix(ui): disable composed button links

* fix(ui): block disabled composed capture handlers

* fix(ui): protect composed disabled semantics
2026-08-12 10:57:12 -07:00
Haley Park 1be2d20349 refactor(desktop): extract chat message components to messages/ (#13155)
* refactor(desktop): extract chat transcript logic to messages/

Verbatim moves out of chat-messages.tsx (2,415 -> ~1,300 lines), with no behavior changes.

- Extract shared constants, grouping and reasoning helpers, tool summaries, and tool icons into messages/.
- Add unit tests for the extracted pure logic.
- Update test:chat-ui to include tests under messages/.

chat-messages.test.tsx remains unchanged and continues to pass.

* refactor(desktop): extract chat message components to messages/

Moves MessageBubble, ReasoningBlock, ToolMessageBlock, ToolApprovalPanel
(+ formatApprovalTimestamp and the ToolApprovalRequestItem type), and the
image lightbox out of chat-messages.tsx into their own modules under
messages/. Memo wrappers, comparators, and prop contracts are unchanged;
chat-messages.tsx keeps only the ChatMessages orchestration (~780 lines).
chat-messages.test.tsx is untouched and still passes.

* refactor(desktop): extract chat transcript logic to messages/

Verbatim moves out of chat-messages.tsx (2,415 -> ~1,300 lines), with no behavior changes.

- Extract shared constants, grouping and reasoning helpers, tool summaries, and tool icons into messages/.
- Add unit tests for the extracted pure logic.
- Update test:chat-ui to include tests under messages/.

chat-messages.test.tsx remains unchanged and continues to pass.

* refactor(desktop): extract chat message components to messages/

Moves MessageBubble, ReasoningBlock, ToolMessageBlock, ToolApprovalPanel
(+ formatApprovalTimestamp and the ToolApprovalRequestItem type), and the
image lightbox out of chat-messages.tsx into their own modules under
messages/. Memo wrappers, comparators, and prop contracts are unchanged;
chat-messages.tsx keeps only the ChatMessages orchestration (~780 lines).
chat-messages.test.tsx is untouched and still passes.

* refactor(desktop): extract chat message components to messages/

Moves MessageBubble, ReasoningBlock, ToolMessageBlock, ToolApprovalPanel
(+ formatApprovalTimestamp and the ToolApprovalRequestItem type), and the
image lightbox out of chat-messages.tsx into their own modules under
messages/. Memo wrappers, comparators, and prop contracts are unchanged;
chat-messages.tsx keeps only the ChatMessages orchestration (~780 lines).
chat-messages.test.tsx is untouched and still passes.
2026-08-12 10:39:17 -07:00
John Choi 243b6fe9d9 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes
# Conflicts:
#	apps/examples/desktop-app/webview/app/page.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/lib/workspace-paths.ts
2026-08-12 09:11:57 -07:00
Saoud Rizwan a56af4efaf fix(telemetry): stop mirroring per-token stream deltas into telemetry (#13180)
* fix(telemetry): stop mirroring per-token stream deltas into telemetry

Gate assistant-text-delta, assistant-reasoning-delta, and tool-updated
runtime events out of the unconditional telemetry.capture mirror in
AgentRuntime.emit. These fire once per streamed token or tool progress
chunk and accounted for ~97% of all agent.* telemetry volume in the
field with no analytical value. Listeners, hooks.onEvent, and the
run-failed sdk.error reporting are unchanged; the gate is a static
Set lookup so no per-event allocation is added.

* refactor(telemetry): inline stream-delta telemetry gate as a switch

Replace the module-level Set constant with case labels directly at the
capture site; same behavior, less indirection.
2026-08-12 18:20:47 +09:00
Saoud Rizwan 10e5176ed8 refactor(ui): one row per tool call, terminal-style commands, and review fixes (#13186)
* feat(ui): styled label parts, terminal-style commands, and patch fidelity fixes

Label segments: ToolSummary gains labelParts ({text, code?}[]) so
consumers can render code-ish segments (file names, commands, queries,
URLs) in a monospace face. Single commands now read like a terminal
prompt — '$ bun test' — and an untruncated single command no longer
duplicates itself as a detail line.

Review fixes folded in:
- apply_patch preserves hunk boundaries: per-hunk oldText/newText on
  ApplyPatchFile and file items (re-diffing concatenated hunks let a
  deletion in one hunk pair with an addition in another), plus action
  metadata — Delete File labels as 'Deleted x' with no phantom diff,
  '*** Move to:' renames display as 'old → new'.
- run_commands accepts every RunCommandsInputUnionSchema shape (single
  entry, bare arrays, top-level {command,args}, {cmd}).
- makeUnifiedDiff treats empty text as zero lines, so creating an empty
  file or deleting all content no longer reports a phantom +1.
- parseWebFetchInput drops non-string urls instead of stringifying
  objects into labels.
- hoisted a double normalizeValue in the unknown-tool fallback.

* refactor(desktop): render each tool call as its own chat row

Drops the consecutive-call grouping ('Read 3 files · Ran 2 commands')
in favor of one row per tool call — each with its own icon, status,
disclosure, and treatment per kind:

- commands read like a terminal: '$ bun run test' in monospace, with
  the captured output in a capped scrollable mono block on expand and
  '$ '-prefixed detail lines for multi-command calls
- edit rows carry mono filenames, the +/- badge, and their pierre
  diffs pre-expanded (one diff per hunk for multi-hunk patches),
  keeping the user-toggle override from the grouped implementation
- reads/searches/fetches keep inline specifics with mono code segments
  via the shared labelParts

Also fixes the test:chat-ui exit-1 regression flagged in review:
@pierre/diffs' custom element calls CSSStyleSheet.replaceSync, which
jsdom lacks — a prototype polyfill in the suite keeps the real
component in the test tree (and the pre-expand assertions meaningful)
while letting the run exit 0. This suite gates ui-publish.yml.

* fix(desktop): keep the thinking indicator up during quiet turn stretches

The indicator only covered the gap right after a user message, so the
turn looked frozen while the model composed its next step — most
noticeably while streaming tool-call arguments, when neither text nor
a tool row is on screen. It now shows whenever the turn is running and
nothing else is visibly active (no streaming text, no in-progress tool
row, no pending approval/question).

* feat(ui): action-first tool labels

Every row leads with the plain action phrase — 'Ran command',
'Read file', 'Edited file', 'Created file', 'Deleted file' — with the
specifics (command, file name, line range) following as a monospace
segment. The mono segment renders at full size; the previous 0.92em
downscale made it look smaller than the surrounding prose.

* fix(desktop): chat polish — indicator alignment, action spacing, no expanded fade

- The Thinking indicator now mirrors the tool-row trigger metrics
  (min-h-7, py-1, gap-2, 16px icon, font-medium, 8px rhythm) so the
  text no longer shifts when the indicator swaps with an arriving
  tool row.
- The copy/fork/timestamp action row sat 4px up into the message text
  above it (-translate-y-1); it now rests 2px below the message block.
- Expanded reasoning/tool panels rendered at 70% opacity with
  hover-to-unfade; expanded content is what the user is reading, so it
  now renders at full opacity.

* feat(ui): violet active rows, gray finished rows, no green hover

Tool-row colors follow activity: running/pending rows (and the row
spinner) carry the brand violet, finished rows settle into
muted-foreground gray, and hover brightens toward the foreground
instead of hue-shifting to the success green. Errors stay red.

Also: maxInlineChars default raised 60 → 200 so real commands stop
getting truncated (the cap is now only a guard against pathological
payloads; layout handles overflow), and expanded editor rows lead with
the fuller file path above the diff, matching read rows.

* refactor(ui): let layout own label overflow instead of char caps

maxInlineChars now defaults to unlimited — labels carry the full
command/task/question text (whitespace collapsed to one line) and
.cline-chat-tool-label ellipsizes at the container edge via CSS
(nowrap + text-overflow) instead of wrapping. The cap remains as an
opt-in for width-constrained surfaces like TUIs. Since the label can
now be visually cut by layout, single-command rows always carry the
full command in their expanded details.

* fix(ui): drop stale green base color on tool triggers

The redesign moved finished tool rows to muted gray and running rows to
brand violet, but a leftover .cline-chat-tool-trigger { color:
var(--success-text) } rule later in the sheet overrode the gray base, so
every settled row still rendered green.

* fix(desktop): give message actions clear separation from message text

2px below the text read as touching; 6px (translate-y-1.5) gives the
copy/fork/timestamp row visible breathing room.

* feat(ui): spinner replaces the tool icon while a call is in flight

The progress ring used to append to the right of the label, so running
rows sprouted chrome instead of reading as one glyph + label. It now
takes the icon slot and fills the same 1rem box, so the label never
shifts when the icon swaps back in on completion.

* fix(desktop): align thinking indicator with the tool row that replaces it

The indicator sits outside the message column, so it already inherits the
conversation gap; its own mt-2 stacked on top and rendered it 8px lower
than the tool row that swaps in.

* style(desktop): message actions match chat text scale in a lighter gray

Copy/edit/restore/fork icons go from 12-14px to the 16px the rest of the
chat chrome uses, the timestamp moves from 11px to text-sm, and the whole
row renders at 70% muted-foreground so it reads as secondary chrome;
hover still brightens to full foreground.

* style(ui): running tool rows share the thinking indicator's gray

Violet-on-running read as a different system than the muted thinking
state it replaces; the spinner alone now signals activity. The progress
ring draws in currentColor so it stays gray on normal rows and red on
error rows without extra rules.

* style(desktop): nudge message actions down 2px and scale them down a step

Actions row moves from 6px to 8px below the message text; icons go
16px -> 14px and the timestamp text-sm -> text-xs after the previous
bump overshot.

* fix(ui): don't unstick conversation follow when content grows

Stick-to-bottom flipped off whenever a scroll event landed between a
content-height jump (tall diff rows mounting) and the resize observer's
re-pin: the handler read the new distance-from-bottom as the user having
left the bottom. Sticking is now released only by an actual upward
scroll and always restored on reaching the bottom, so the transcript
keeps following while rows stream in.

* feat(ui): user message bubbles on a filled brand-violet surface

The card-colored bubble sat too close to the app background to read at
a glance. New brand-violet-surface tokens (deep enough for near-white
text in both themes) fill the user bubble.

* style(desktop): give the conversation bottom padding above the composer

The last message (and its hover actions hanging below) butted against
the composer border.

* fix(desktop): composer keeps its two-line height when unfocused

Collapsing to one row on blur made the input and the conversation above
it jump on every focus change; the focus-tracking state existed only to
drive that resize.

* refactor(ui): simplify AgentAskQuestion and move it to the brand accent

The 'Follow-up question' heading, intro sentence, and box-in-box nesting
made a one-question prompt read like a form. The question now leads the
card directly (icon + text + option buttons) and the accent shifts from
blue to brand violet, with the section still labelled for assistive
tech.

* fix(desktop): pending questions and approvals render at the end of the transcript

They rendered above the whole conversation like a banner, so a follow-up
question appeared at the top of the chat instead of where the
conversation actually is.

* fix(desktop): keep message actions reachable and make hover/focus feedback instant

The 8px offset under a message was a translated gap — dead space that
dropped the parent's :hover midway to the buttons, hiding them before
they could be clicked. The offset is now padding on the actions element
so the hover chain stays unbroken. Also removes the opacity fade on the
actions row and the composer's focus border transition: both read as lag
rather than polish.
2026-08-11 21:00:04 -07:00
Saoud Rizwan a5611e8f6d feat(ui): shared tool-summary module for consistent tool-call chat rows (#13151)
* feat(ui): add shared tool-summary presentation module

Pure, framework-free tool-call presentation logic under
@cline/ui/components/agent-chat/tool-summary: buildToolSummary and
buildGroupedToolLabel turn raw {toolName, input, result} payloads into
rich row labels (file names with line ranges, inline commands, search
queries, URLs), per-item details, +/- diff counts, per-file unified
diffs (editor old/new text and apply_patch envelopes), team_* labels,
and MCP-aware output text extraction. Merges the desktop app's
buildToolSummary layer with the CLI's tool-parsing/diff utilities so
every @cline/ui consumer renders tool rows consistently.

Exports the new subpath from package.json, extends the packed-tarball
smoke test to cover it, documents the boundary change in ADOPTION.md,
and bumps the package to 0.2.0-next.3.

* refactor(desktop): adopt shared tool-summary for chat tool rows

Replaces ~950 lines of app-local tool extraction (buildToolSummary,
teamSummary, parsers, grouped-label logic) in chat-messages.tsx with
the @cline/ui tool-summary module. Desktop tool rows gain single-call
specifics inline (Read app.tsx (10-80), Ran bun test, Edited util.ts
with +/- badge), line ranges on reads, shortened paths with directory
context in expanded details, per-file unified diffs in the expanded
panel, and grouped labels joined with a middot. Detail keys switch to
index-based to fix duplicate-line key collisions. Icons re-key on the
shared ToolKind classification.

* fix(ui): stop fabricating line positions in fragment tool diffs

Editor str_replace payloads carry old_text/new_text as fragments of the
file, but makeUnifiedDiff treated them as whole files and emitted hunk
headers anchored at line 1, mislocating the change in expanded edit
rows (Greptile P1 on #13151). Fragment diffs now use a neutral
'@@ … @@' separator; only whole-file content (editor create,
apply_patch Add File sections) keeps real hunk positions.

File items also expose the raw oldText/newText (reconstructed from
hunks for apply_patch) plus a fragment flag, so rich diff renderers
can consume the texts directly instead of re-parsing unified output.

* feat(ui): render tool-row edit diffs with @pierre/diffs

Adds @cline/ui/components/agent-chat/tool-diff exporting ToolFileDiff,
a thin wrapper over @pierre/diffs (optional peer dependency) that
renders a tool-summary file item as a syntax-highlighted, theme-aware
unified diff. Fragment diffs hide line numbers instead of showing
misleading ones. The desktop chat renders edit diffs through it, and
tool groups containing an edit diff now open pre-expanded so the diff
is immediately visible.

ADOPTION.md reframes the shared-module story: extracting presentation
logic products would otherwise duplicate is the direction @cline/ui is
headed, with tool-summary and tool-diff as the first two modules. The
packed-tarball smoke test covers the new subpath in both consumers.

* fix(ui): blend tool diffs into the app surface

Two polish fixes to ToolFileDiff from design review:

- Normalize trailing newlines on both sides before diffing so tool
  payload fragments (which rarely end in a newline) don't litter every
  diff with 'No newline at end of file' markers.
- Map @pierre/diffs' background hooks (--diffs-light-bg/--diffs-dark-bg)
  to the host app's --background token (stock white/black fallback),
  so the diff surface and all its color-mixed tints (context lines,
  gutters, separators) derive from the app background instead of
  pierre's pure white/black. Overridable via a new background prop.

Storybook's ToolSummaries story now renders file items through
ToolFileDiff pre-expanded (matching the apps) and adds a multi-file
apply_patch fixture.

* fix(desktop): pre-expand tool groups when edit diffs arrive mid-stream

defaultOpen only applies at mount, but a streaming tool group mounts
with its first (often read) call and gains the edit later, so live runs
never saw the promised pre-expanded diff. Drive the disclosure with
controlled state that opens when a file diff first appears, unless the
user has toggled the row themselves. Covers the streaming path with a
rerender test.
2026-08-11 17:54:52 -07:00
BeeandSaoud Rizwan 66883f584c fix(desktop): top-align welcome chat input (#13174)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-11 14:41:41 -07:00
Haley ParkandSaoud Rizwan e370732545 refactor(ui): migrate fonts to Inter and Geist Mono (#13142)
* chore(ui): replace font dependencies

* refactor(ui): migrate shared typography tokens

* refactor(ui): adopt Inter and Geist Mono in apps

* fix(hub): preserve variable font weight tokens

* feat(ui): tune font weights for dark mode

* docs(ui): add font migration screenshots

* (chore)ui: misc typography adjustments

* fix(hub): make dark-mode font-weight overrides take effect

Tailwind's @theme inline bakes literal values into utilities, so the
.dark --font-weight-* overrides were dead code and dark mode rendered
the heavier light-mode weights. Declare the weights in :root instead so
font-* utilities keep their var() references, matching the @cline/ui
tokens approach. Also rewrap --font-mono to satisfy biome format.

* fix(ui): restore light-mode semibold to 640 and pin weight scales in test

The PR intent is a 480/560/640/640 light scale with 400/500/600/600
dark overrides, and the Hub already uses 640; tokens.css had drifted to
600 for light semibold. Regenerate scoped-tokens.css and assert both
the light and dark weight scales in the theme contract test.

* chore(desktop): remove stray double space in provider header class

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-11 14:39:57 -07:00
Haley Park ee4adda2da refactor(desktop): extract chat transcript logic to messages/ (#13153)
Verbatim moves out of chat-messages.tsx (2,415 -> ~1,300 lines), with no behavior changes.

- Extract shared constants, grouping and reasoning helpers, tool summaries, and tool icons into messages/.
- Add unit tests for the extracted pure logic.
- Update test:chat-ui to include tests under messages/.

chat-messages.test.tsx remains unchanged and continues to pass.
2026-08-11 14:33:27 -07:00
Saoud Rizwan 651829b24a chore(cli): release v3.0.53 2026-08-11 11:32:41 -07:00
Saoud Rizwan 00ba2ff7da chore(sdk): release v0.0.73 2026-08-11 11:00:44 -07:00
Mikołaj Kondratek 3f03159737 fix(vscode): don't discard a successfully refreshed Cline token after expiry (#13139)
getAuthToken captured expiresAt before refreshing, then validated the new
token against that stale value. When the old token was already past expiry
(not just inside the 5-minute buffer), a successful refresh was thrown
away and null returned, so the first call after long idle failed despite
valid credentials. Re-read the expiry from the refreshed auth info.
2026-08-11 18:23:12 +02:00
Mikołaj Kondratek 3087bd3d32 fix(hub): recoverable agent errors must not end the turn in the dashboard (#12962)
* fix(hub): don't forward recoverable agent errors to dashboard peers

Recoverable error events are in-run notices, not turn outcomes: the
MistakeTracker emits one for every recorded mistake (e.g. a plan-mode
guard-blocked run_commands call) while the run continues. The hub
dashboard forwarded every error event to peers, so the webview dropped
out of the sending state and appended an error row mid-turn — the same
host bug fixed for VS Code and the CLI in #12953.

Gate the forward on recoverable, matching those hosts: the tool failure
is already shown inline via the failed tool_event, and the turn's
outcome stays decided by how it actually ends (turn_done or a
non-recoverable error). Recoverable errors are logged server-side.

* fix(hub): forward recoverable flag to peers instead of filtering server-side

Per review: the server is a translation layer between agent events and
the webview protocol, so it should not embed display policy or console
logging. Forward every agent error with its recoverable flag on the
peer message and let each peer decide — the webview keeps recoverable
errors out of the transcript and keeps the turn state, matching how the
CLI gates display on the same flag while the information stays
available to any peer that wants it.
2026-08-11 18:11:40 +02:00
BeeandSaoud Rizwan 9b59090967 fix(desktop): reconnecting to stale managed Hub daemons (#13145)
* fix(core): replace stale managed hub daemons

* fix(core): fingerprint hub runtime builds

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-11 01:34:00 -07:00
Saoud Rizwan 7e31fb9e0d chore(vscode): prepare 4.1.8 release 2026-08-10 20:03:53 -07:00
John SimoneandSaoud Rizwan e0eb0167da feat(vscode): add Fable 5 + custom model IDs to Vertex; drop global-region picker filter (#12461)
* add custom model selection to the vertex provider

* fix race conditions from PR review

* fix linter warnings

* fix test failures

* refactor(vscode): drop Vertex global-endpoint picker filtering

The SDK catalog is live (models.dev), so a static host allowlist of
global-endpoint-capable models lags every model launch and silently hides
new models from users on vertexRegion=global. Remove the allowlist, the
host override that injected supportsGlobalEndpoint, and the picker filter;
show the full catalog for every region.

An unsupported pick now fails loudly at request time: map Vertex's
'model not available in region: global' (and Google's Publisher Model
locations/global not-found body) to recovery guidance in the error row.

Also drop Anthropic's universal pricing from the Vertex Fable 5 overlay —
Vertex bills region-dependently, so the copied price understated recorded
cost; the record now carries no pricing instead of a wrong one.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-10 19:58:20 -07:00
John Choi edd6e2c29b fix(desktop): close cloud session interaction gaps
Forward image attachments, allow session-scoped model changes, recover queued streaming after abort, and surface relayed run failures.
2026-08-10 16:49:03 -07:00
Mikołaj Kondratek d5748b2939 fix: remove stale Double-Check Completion feature tip (#13147)
* fix: remove stale Double-Check Completion feature tip

The rotating feature tips still told users to enable "Double-Check
Completion" in settings, but that toggle was removed in the new UI —
the Features section now offers Auto Compact, Feature Tips, Background
Edit, Checkpoints, Worktrees and Hooks. Following the tip sent users
searching the settings panel for something that isn't there.

Drop the tip. The remaining ten were checked against the current UI and
all still hold, including the "Settings → Features → Feature Tips" path.

* chore: remove dead CLI settings e2e page object and orphaned test

`page-objects/settings.ts` asserted the CLI settings Features tab shows
"Double-check completion" — the same removed setting behind the stale
feature tip. Nothing in the live tui-test suite (apps/cli/src/tests)
imported it; only chat.ts and auth.ts page objects are in use.

Its one importer, apps/vscode/tests/e2e/cli/interactive.test.ts, is a
leftover from the pre-2026-06-02 SDK migration squash: all three of its
imports resolve to files that don't exist, there's no tui-test config in
that tree, and no npm script runs it. It cannot execute.
2026-08-11 00:54:41 +02:00
Mikołaj Kondratek ffd6a6b1db fix: respect user max output tokens in compaction summarizer requests (#13137)
* fix: respect user max output tokens in compaction summarizer requests

The compaction summarizer hardcoded max_tokens to 1024 and the VSCode host
never mirrored the user's Max Output Tokens onto providerConfig, so summary
requests were always capped at 1024 tokens. Reasoning models can spend that
entire budget thinking; the reasoning stream is discarded, so no summary
text arrives and compaction is skipped on every attempt.

- Mirror maxTokensPerTurn onto providerConfig.maxOutputTokens in the VSCode
  session factory so consumers that build handlers straight from it (the
  compaction summarizer) honor the user's setting, matching the CLI.
- Resolve the summarizer output budget from explicit config, then model
  info, then knownModels, before the default; raise the default to 4096.
- Log a diagnostic warning (reasoning chars, incompleteReason, likely
  cause) when the summarizer returns no summary text instead of silently
  skipping.

* fix: clamp summarizer default output budget by model metadata instead of adopting it

Model maxTokens is reported capability, not a product default: without an
explicit configuration the summarizer now requests the 4096 default, lowered
by model metadata when the model reports less, never raised by it. Explicit
values still win as-is.
2026-08-11 00:20:45 +02:00
cline-cloud[bot]andCline 51784a3bf1 docs: add Qwen3.8 Max to ClinePass model list and reference pricing (#13144)
Co-authored-by: Cline <cline@users.noreply.github.com>
2026-08-10 14:52:43 -07:00
Saoud RizwanandSaoud Rizwan 149abb0ddb feat(vscode): remove YOLO mode setting, migrate old users to auto-approve all (#13126)
* feat(vscode): remove YOLO mode setting, migrate old users to auto-approve all

The SDK extension's YOLO toggle was cosmetic: nothing in the approval
path read it, so runs were silently governed by the per-action
auto-approval settings underneath (cline/cline#13114). Instead of
keeping a parallel override system, remove the setting entirely and
make the auto-approve menu the single source of truth:

- drop yoloModeToggled (and the equally dead autoApproveAllToggled)
  from state keys, settings handlers, state posts, telemetry, the
  remote-config yoloModeAllowed transform, and the settings protos
  (field numbers reserved)
- remove the Yolo Mode toggle from Settings -> Features (the whole
  Experimental section, it was the only entry) and the
  "Auto-approve: YOLO" AutoApproveBar takeover
- add a v3 storage migration that folds a previously-enabled YOLO /
  auto-approve-all toggle into autoApprovalSettings by enabling every
  action, so previously-unattended setups keep running unattended;
  the dead keys are cleared from the file store

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

* refactor(vscode): keep dead yolo keys in place instead of clearing them

Current builds never read the removed keys (the state loader only visits
known keys), so deleting them buys nothing - and the file store is shared
with older builds that still know them, so clearing would flip YOLO off
for a user who downgrades. Same downgrade-safety rule as the v1 export.

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

* chore(vscode): rename wasUnattended to shouldEnableAllActions in yolo migration

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

* chore(vscode): drop dead toggleActModeForYoloMode and stale yoloModeAllowed comment

The method was a legacy-controller carryover nothing called, and it set
the mode without rebuilding the session, which is wrong for the SDK
architecture. The comment cited yoloModeAllowed as a live remote-config
example; it no longer maps to anything.

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

* chore(vscode): refresh checked-in proto descriptor_set.pb

The tracked descriptor set had not been regenerated since the repo
move and still advertised long-changed schemas (including the removed
yolo_mode_toggled fields) to gRPC reflection clients. Sync it with the
output of bun run protos.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-09 17:15:37 -07:00
Saoud Rizwan b3cee3f973 chore(desktop): release v0.0.11 2026-08-08 20:53:18 -07:00
Saoud Rizwan c68f553856 chore(vscode): prepare 4.1.7 release 2026-08-08 20:38:09 -07:00
Saoud Rizwan fd794ce3be chore(cli): release v3.0.52 2026-08-08 19:34:11 -07:00
Saoud Rizwan f5f1071af2 chore(sdk): release v0.0.72 2026-08-08 19:00:03 -07:00
Saoud Rizwan 4540390096 desktop: hide git jargon for non-git folders (CLIENTS-100) (#13059) 2026-08-08 15:57:00 -07:00
Saoud RizwanandSaoud Rizwan b590e14b91 desktop: paste clipboard images into the composer (CLIENTS-78) (#13057)
* desktop: paste clipboard images into the composer as attachments (CLIENTS-78)

Pasting a screenshot into the composer did nothing: only drag-and-drop
and the paperclip file picker fed the attachment pipeline. Add an
onPaste handler on the composer textarea that extracts image files from
the clipboard, renames them to timestamped pasted-image-*.png files, and
routes them through the existing onAttachFiles flow. Text pastes are
untouched.

* desktop: only extract clipboard images in formats message serialization supports

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 15:56:27 -07:00
Saoud RizwanandSaoud Rizwan 54cc156089 desktop: context-aware welcome suggestions for non-code folders (CLIENTS-98) (#13060)
* desktop: context-aware welcome suggestions for non-code folders (CLIENTS-98)

* desktop: treat pending branch discovery as its own state for welcome cards

The welcome-card classifier read the "no-git" sentinel as a confirmed
non-repo, but page.tsx also used that value for the initial state and
while a workspace switch was awaiting branch discovery, so a git repo
could briefly show the plain-folder cards. Branch state is now null
while discovery is pending: the welcome screen shows no cards until the
folder is classified, and chat-mode cards (which never depend on git
state) still show immediately. Other branch consumers keep the string
contract via a "no-git" fallback.

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

* desktop: carry nullable branch state to all consumers

Propagate the pending-discovery null through ChatInputBar,
WorkspaceSelector, and the welcome workspace controls instead of
coercing to "no-git" at the page boundary, so only display leaves
fall back and the welcome classifier is the single consumer that
distinguishes pending from confirmed non-repo.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 15:45:29 -07:00
Saoud RizwanandSaoud Rizwan 6c599d18c3 desktop: surface folder picker failures and add manual path fallback (CLIENTS-73) (#13056)
* desktop: never let 'Add project…' fail silently; add manual folder path entry (CLIENTS-73)

- sidecar picker tries zenity then kdialog on Linux and throws a descriptive
  error when neither exists, instead of returning null (indistinguishable
  from user cancel); picked paths are trimmed of trailing separators
- picker failures now surface as visible error messages in both workspace
  selectors, with a manual path-entry fallback (typed absolute or ~ paths
  in the search box offer an 'Open folder' action)
- failed workspace switches (invalid/nonexistent paths) show an inline
  error instead of silently doing nothing
- validate_workspace_directory expands ~ and returns the resolved path

* desktop: keep workspace menu search/error state through catalog refreshes

The welcome-screen workspace picker reset its search text and error
message whenever onRefreshWorkspaces changed identity, which happens on
every session-history poll. Typing a path or reading an inline error
raced against the timer: the menu would silently wipe mid-interaction.
Hold the refresh callback in a ref so the reset only runs when the menu
actually opens.

* desktop: format welcome-workspace-controls test

* desktop: distinguish picker launch failures from user cancellation

A zenity/kdialog rejection with a non-ENOENT spawn error (EACCES, EMFILE,
ENOMEM) or a crash signal was classified as a user cancel, which skipped
the kdialog fallback and suppressed the inline error - recreating the
silent no-op this branch is meant to eliminate. Only a clean exit code 1
from a dialog that actually opened now counts as cancellation; broken
backends fall through to the next candidate and surface a descriptive
error otherwise. Picker logic moved to sidecar/workspace-picker.ts with
an injectable exec so the classification is unit-tested.

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

* Classify picker launch failures separately from user cancellation

zenity/kdialog failures like EACCES, EMFILE, or ENOMEM were treated as
user cancellation, suppressing the kdialog fallback and the inline
manual-entry error. Only a clean exit code 1 now counts as a cancel;
any other failure falls through to the next backend or throws the
picker-unavailable error. Picker logic moved to sidecar/folder-picker.ts
with an injectable exec so the classification is unit tested.

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

* Revert "Classify picker launch failures separately from user cancellation"

This reverts commit 24d27a004d.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 15:43:52 -07:00
Saoud Rizwan 513aacc0e6 fix(core): full-stop semantics and abort-window queue edits for surviving queues (#13100)
* fix(core): preserve queued prompts across user-initiated aborts

Pressing stop while prompts were queued silently destroyed them:
abort() called clearAborted(), which emptied the pending prompt queue
with no way to recover the typed input. The prompts vanished from the
UI, were never sent, and left no trace in session artifacts.

Aborting now only stops the in-flight turn. Queued prompts stay in the
queue and drain once the abort settles, matching the drain behavior
that already existed for self-aborted turns (loop detector / mistake
limit). The thrown-abort path (completeAbortedInteractiveTurn) now
schedules the same drain that runTurn schedules for turns resolving
with an aborted finish.

* fix(core): full-stop semantics and abort-window queue edits for surviving queues

Follow-up to the queued-prompt survival change: aborting a user turn keeps
the queue and auto-runs it, but two gaps remained.

1. No full stop: aborting a queue-initiated turn also kept draining, so
   every Escape consumed one queued prompt and started a fresh provider
   call - a session with queued messages could never be brought to rest.
   Aborting a drained turn now discards the remaining queue: the first
   Escape skips to your queued follow-ups, a second Escape stops the
   queued work too.

2. Queue operations were still rejected while an abort settled: a prompt
   typed right after Escape was silently dropped, and queued prompts were
   briefly uneditable and undeletable even though they were about to
   auto-run. enqueue/update/delete now work during the abort window;
   scheduleDrain/drain still wait for the abort to settle.
2026-08-08 12:37:18 -07:00
Saoud RizwanandSaoud Rizwan ff2f860941 test(core): pin abort + hub-restart durability, and title seeded sessions (#13097)
* test(core): cover abort + host restart + seeded recovery durability

Adds an e2e regression guard for the reported "cancel a turn, lose the
conversation" failure: a cancelled turn, a daemon restart, a
client-side recovery seeded from disk, and a second restart before that
replacement ever runs a turn. Reverting the eager seeded-history
persistence makes the final read come back empty.

Materializing a seeded session at start also left its history row with
no prompt and no title, since there is no first prompt to derive one
from. Seed the title from the inherited transcript using the same
inference listSessionHistory hydration applies, so forks and recoveries
stay identifiable in unhydrated surfaces too.

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

* fix(core): retitle seeded sessions from their first user prompt

Eagerly-materialized seeded sessions kept the interim transcript-
inferred title forever, a behavior change from pre-eager persistence
where a fork's history row was titled by the first post-fork prompt.

The interim title now only covers the window where no turn has run
(previously those rows were simply absent), and the first user prompt
after the seed backfills the row's prompt and retitles it — unless the
user renamed the session in the meantime, in which case only the prompt
column is backfilled. The resident manifest and session metadata are
updated in step so the end-of-turn usage-metadata merge cannot clobber
the title back through a stale in-memory fallback.

The e2e mock's updateSession now mirrors the real persistence-service
contract (row + manifest file), and the durability e2e covers both the
retitle and the rename guard; removing the retitle call fails the
'now add tests' assertion.

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

* refactor(core): collapse seeded-session titling to the old mechanism

The interim transcript-derived title, retitle flags, rename comparison,
and resident-manifest syncing existed only to title forks that never
run a turn - a new nicety, not parity. Dropping it collapses the whole
design back to what rows did before eager persistence: the persistence
service derives the title from the prompt when a row gains one, so the
host only needs to backfill the promptless row with the first user
prompt via updateSession. Renames win automatically because the service
preserves an existing title when no explicit title is passed.

Net production change vs main is a single 20-line backfill block in
executeTurn. The e2e mock's updateSession now models the service's
title semantics (explicit title wins, existing title preserved,
untitled rows derive from prompt), and the durability e2e asserts the
raw row stays untitled until first prompt while history hydration
infers a display title from the transcript.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:30:30 -07:00
Saoud Rizwan 62a6b5a0b2 fix(core): preserve queued prompts across user-initiated aborts (#13090)
Pressing stop while prompts were queued silently destroyed them:
abort() called clearAborted(), which emptied the pending prompt queue
with no way to recover the typed input. The prompts vanished from the
UI, were never sent, and left no trace in session artifacts.

Aborting now only stops the in-flight turn. Queued prompts stay in the
queue and drain once the abort settles, matching the drain behavior
that already existed for self-aborted turns (loop detector / mistake
limit). The thrown-abort path (completeAbortedInteractiveTurn) now
schedules the same drain that runTurn schedules for turns resolving
with an aborted finish.
2026-08-08 12:20:08 -07:00
Saoud Rizwan 0caf617b50 fix(core): keep a hung MCP server from taking down session creation (#13086)
* fix(core): keep a hung MCP server from taking down session creation

A stdio MCP server that never finishes initializing used to hold its
connect open for the full DEFAULT_MCP_CONNECT_TIMEOUT_MS (doubled across
the newline/framed attempts). MCP tool discovery runs on the
session.create critical path, so that wait blew past the 30s hub command
timeout and the CLI tore the whole interactive session down instead of
just skipping the bad server.

- Bound MCP tool loading during session build with a startup budget that
  is safely under the hub command timeout. Servers that connect in time
  contribute their tools; slower/hung servers are skipped for the session
  (their error still surfaces via the MCP manager) instead of failing
  session creation. Budget is overridable via CLINE_MCP_STARTUP_BUDGET_MS
  for tests.
- Add StdioMcpClient.close() (and optional McpServerClient.close) that
  marks the client disposed so an in-flight connect() aborts its retry
  loop instead of respawning the framed fallback.
- Dispose the manager by closing clients up front, outside the per-server
  operation locks, so a server hung in initialize can no longer stall
  teardown for the full connect budget.

Adds regression tests covering both the non-blocking build and prompt
disposal while a client is hung in connect().

* refactor(core): simplify hung-MCP-server fix to a startup budget

Replace the bespoke per-server race/tracking in loadConfiguredMcpTools
with a small withStartupBudget() wrapper around the existing
Promise.allSettled: a server that exceeds the budget becomes a normal
rejection that the existing loop already logs and skips. The connect
budget, MCP settings display (initialize timeout 30s), and the rest of
the loader are left untouched.

The client close()/manager.dispose() cleanup is kept minimal: it is what
lets teardown abort a still-in-flight connect instead of blocking on the
per-server lock (and clears the pending request timer).

* fix(mcp): cap the default initialize budget at 3s to protect session creation

Supersedes the startup-budget approach on this branch with the simple
constant fix.

MCP initialize runs on the session.create critical path, which the hub
caps at 30s, and connect() can spend the budget twice (newline then
Content-Length framing). The 30s default from #13067 meant a server that
never initializes held session.create for up to 60s, so the hub RPC
timed out and the CLI tore the whole session down and exited.

Return to the pre-#13067 shape with a bigger probe: 3s instead of 1.5s.
That still covers the ~2s starters the old probe killed (#13035) and
keeps the worst case at ~6s per server, far under the hub deadline.
Genuinely slow starters (JVM-based servers like Oracle SQLcl) now need
an explicit timeout in cline_mcp_settings.json, which continues to
override the default in either direction.

Tests: update the slow-start regression tests to the new policy (2s
connects by default, 4s connects with a configured timeout), refresh the
displayed initialize-timeout assertions, and add an invariant test that
keeps the doubled default well under HUB_DEFAULT_COMMAND_TIMEOUT_MS so
the budget cannot silently creep past the session deadline again.
2026-08-08 12:18:11 -07:00
Saoud RizwanandSaoud Rizwan 930575991d fix(desktop): stop opening a session from replacing the remembered model (#13091)
* fix(desktop): stop opening a session from replacing the remembered model

The composer's ModelSelector mirrored every provider/model prop change
into the remembered last selection (localStorage), which seeds new
sessions via getInitialChatConfig(). Opening an existing session drives
those props to that session's config, so merely viewing an old session
silently replaced the user's explicitly picked default model.

The remembered selection is now written only from the explicit picker
handlers (provider select and model select). Passive prop changes, such
as opening a session, no longer touch it.

* fix(desktop): re-seed remembered provider/model on chat reset

reset() kept the previous config's provider/model and only cleared the
session ID, so a chat pane that had hydrated a historical session could
carry that session's model into the next chat. Re-seed provider/model
(and apiKey when the provider changes) from the remembered defaults --
the same source a freshly mounted thread uses -- so reset and remount
behave identically.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:10:39 -07:00
Saoud Rizwan e40d7d44ae fix(desktop): stop treating leftover plugin install dirs as installed (#13095)
* fix(desktop): stop treating leftover plugin install dirs as installed

isOfficialPluginInstalled() only checked that the marketplace install
directory existed. A failed or interrupted install can leave that
directory behind with no plugin inside, and the next install attempt
then short-circuited with a fake 'already installed' success: the
marketplace button flipped to Uninstall with no error while nothing
actually worked, and the installed-entries listing kept reporting the
broken entry as installed.

The check now requires a loadable plugin module inside the directory
(via discoverPluginModulePaths) before reporting the entry as
installed, so partial directories fall through to a real install
attempt whose outcome is surfaced to the UI.

* fix(desktop): reclaim leftover partial plugin install dirs with --force
2026-08-08 12:06:35 -07:00
Saoud RizwanandSaoud Rizwan e973ce4f33 fix(cli): make queued message text readable on light theme TUI (#13098)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:00:50 -07:00
Saoud Rizwan 1eb28e553e test(desktop): make the pagination-recovery test deterministic
Waiting on rendered text could pass in a stale window between the search
clearing and the base reload effect running, letting the scroll fire a
stale observer whose captured request key mismatched. Drop the captured
observer callback before clearing and wait for the effect to recreate it,
which only happens after the post-clear base list applied.
2026-08-08 18:58:56 +00:00
Saoud Rizwan 42225f9219 refactor(desktop): consolidate cloud-session helpers before review
No behavior changes; review-readiness cleanup of the cloud sessions diff:

- one cloudRepositoryLabel helper in webview/lib/cloud-repositories.ts
  replaces four copies of the owner/repo label parser (sidecar placeholder
  title, repository picker, composer context label, provisioning phases)
- the cloud-provisioning- placeholder id prefix moves behind
  isCloudProvisioningSessionId, shared by the sidecar that mints the ids
  and the webview affordance gates that check them
- the two identical cloud status mappers in use-chat-session collapse into
  mapCloudRuntimeStatus in chat-session/helpers.ts
- attach() and attachExpired() share one attachResultPayload builder
  instead of duplicating the reply literal
- settings-view drops the commented-out PostHog lookup block in favor of a
  short pointer comment
- welcome-chat derives its fallback connect URL from the environment
  config instead of hardcoding production
- refresh the stale claim-set comment in create-recovery to describe the
  post-fix wait-all semantics
2026-08-08 18:58:55 +00:00
Saoud RizwanandSaoud Rizwan 4f25692d70 fix(desktop): canonicalize diff panel paths against the session cwd (#13092)
* fix(desktop): canonicalize diff panel paths against the session cwd

Tool calls address the same file inconsistently across a session: one
edit uses a workspace-relative path (journal.txt), a later one the
absolute path (/tmp/ws/journal.txt). mergeToolDiffs keyed entries by the
raw string, so the same file was listed twice in the diff panel with
split +/- counts and inconsistent naming, most visibly after git was
initialized mid-session and the model switched to absolute paths.

Diff paths are now canonicalized against the session cwd before
merging: entries for the same file collapse into one, files inside the
cwd display as workspace-relative paths, and files outside it display
their resolved path. Without a cwd the previous raw-key behavior is
kept.

* style: collapse editorReplaceEvent signature per biome format

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

* fix(desktop): collapse dot segments and keep root cwd in diff path keys

* fix(desktop): compare Windows diff path keys case-insensitively

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 11:55:58 -07:00
Saoud Rizwan b0bba2e5d6 fix(vscode): hide View Changes on completion rows until there are changes to show (#13096)
* fix(vscode): hide View Changes on completion rows until there are changes to show

The button previously always rendered on the latest completion row, faded
and disabled when the count check came back 0 - which covers both 'nothing
changed since your last message' and 'no checkpoint to compare against'
(non-git workspace, repo with no commits, comparison failure). A dead
button with a misleading tooltip in the non-git case is worse than no
button: now the row renders nothing until the host confirms there are
actual changes, and the button is always enabled when shown.

* fix(vscode): reset View Changes state when showViewChanges toggles

Greptile review: a stale positive hasChanges from a previous evaluation
could flash the button before the host confirms the new comparison when
showViewChanges flips false and back true on the same row. Reset to
'still checking' whenever the effect re-runs.
2026-08-08 11:41:41 -07:00
Saoud Rizwan a6e2c0c431 fix(core): never run a foreign compiled plugin-sandbox bootstrap for a source host (#13094)
* fix(core): never run a foreign compiled plugin-sandbox bootstrap for a source host

When @cline/core runs from source (e.g. the desktop hub daemon in dev)
with CLINE_WRAPPER_PATH set, resolveBootstrap() picked the compiled
plugin-sandbox-bootstrap.js from a separately installed CLI platform
package (such as a published version sitting in the package-manager
cache) before falling back to the source bootstrap. That bootstrap
resolves modules against the other installation's layout, so every
plugin failed to load with "Cannot find module '@cline/core'" - and
the settings pipeline swallowed the failure, leaving Settings > Tools
showing "No plugin tools found" and plugins showing no contributions
even though the same plugins loaded fine in chat sessions.

Bootstrap selection now prefers, in order: a compiled bootstrap next to
this module (always matches the host build), the source bootstrap when
the host runs from source, and only then wrapper/executable-derived
bootstraps - which remain the path for compiled binaries where
import.meta points inside the bunfs bundle.

* chore(core): restore untouched settings-service formatting
2026-08-08 11:23:25 -07:00
Saoud Rizwan da759853ea test(desktop): wait out the post-clear branch reload before paginating again 2026-08-08 05:37:23 +00:00
Saoud Rizwan 6fed0f0d38 style(desktop): format new test fixtures 2026-08-08 05:34:04 +00:00
Saoud Rizwan b456ce73dd fix(desktop): webview polish for cloud sessions from deep review
- humanize cloud error envelopes in the sync-failed banner and the
  rehydration fetch fallback (and scope the fallback to the active session)
- align the recovered-send status mapper with the rehydrated handler:
  cover cancelled, and leave unknown statuses alone instead of flipping a
  running turn to completed
- disable Delete on provisioning placeholders in the sidebar and sessions
  view; the sidecar always rejects it until the create settles
- surface the CLINE_CODE_CLOUD_AGENTS override in Settings when it makes
  the toggle diverge from effective behavior, and load the settings
  sections concurrently
- gate the slash-command menu to local sessions like @-mentions; the
  sandbox cannot resolve local skills/workflows
- stop the model selector from silently 'correcting' a locked cloud
  session's model when its id is missing from the local catalog
2026-08-08 05:32:53 +00:00
Saoud Rizwan 2b9d1c3e6a fix(desktop): harden cloud session lifecycle edges
- reap connections whose session vanished from a successful list (deleted
  remotely or re-scoped): they otherwise redial the dead proxy every ~5s
  forever, with a REST list per attempt, until app restart
- clear desktop-visible state synchronously at the start of dispose() so
  a manager rebuilt mid-dispose (account/credential change) cannot have
  its fresh liveSessions/pendingApprovals entries deleted from under it
- report rehydrated failed runs with the same chat_session_ended reason
  (error) the live run.failed path uses
2026-08-08 05:32:53 +00:00
Saoud Rizwan 82277128aa fix(desktop): only honor Connect GitHub URLs from Cline app origins
The cloud error envelope travels in Error.message and is authenticated by
string prefix only, so error strings a session pod controls (hub command
replies pass through verbatim) could spoof a github_not_connected envelope
whose connectUrl pointed anywhere. The webview rendered that as a trusted
looking Connect GitHub button and open_external_url validates protocol,
not origin. Drop connectUrls whose origin is not a known Cline app base
URL before they reach the action button.
2026-08-08 05:26:59 +00:00
Saoud Rizwan ee4edfeaf6 fix(desktop): keep the cloud setup snapshot fresh across org switches
The stale-selection guard compared repoUrl against a repositoryUrls
snapshot refreshed only on mount, account-id change, focus, or the
onboarding poll (which stops in ready status). An in-app org switch
refreshed none of those, so picking a repository from the new scope's
correctly filtered picker got immediately wiped against the old scope's
list. Route the picker's own loads through the same request-id-guarded
snapshot application, and re-check setup on the sidecar's
cloud_sessions_changed broadcast.
2026-08-08 05:25:00 +00:00
Saoud Rizwan 17b0ad7a76 fix(desktop): close both directions of the create-recovery claim race
Recovery previously waited only for earlier identical peers, so an
earlier create failing fast (any request_failed, including an instant
5xx) could adopt a later in-flight POST's listed session and hand two
composers the same sandbox. Branchless and branch-specific creates also
hashed to different claim keys while the branchless recovery filter
ignores branch, allowing cross-key adoption with no ordering at all.

- key in-flight peers by repo/model/org (branch excluded) so
  branchless recoveries see branch-specific peers
- settle each create's peer entry when its POST settles (never after
  recovery), then make recovery wait for every other in-flight peer in
  both directions, re-snapshotting until stable; waits cannot cycle
- gate recovery on timeout/5xx/no-status failures: a fast 4xx never
  provisioned anything, and recovering on one risks adopting an
  identical-config session created by another device on the account
2026-08-08 05:21:55 +00:00
Saoud Rizwan be8bb302a8 fix(desktop): re-key the first cloud prompt's bubble to the server session id
A cloud create returns a server-assigned session id, but the optimistic
user bubble kept the client-planned id. mergeCloudSnapshotWithLive drops
other-session messages before consulting the optimistic map, so the first
prompt's bubble silently lost its retention semantics: a lagging snapshot
could merge to a transcript with no user prompt, and a failed first send
lost its bubble on the next rehydration.

Also pins the previously untested merge behaviors: the reflected-prompt
budget (zero-budget retention and one-consumption-per-new-copy) and
error-bubble preservation on the unmatched-live drop path.
2026-08-08 05:18:45 +00:00
Saoud Rizwan f4f573c395 fix(desktop): release the branch picker's loading flag when a page fetch goes stale
loadMore reset loadingMore only when the request key still matched. Typing
a search character while a page fetch was in flight changed the key, so
the stale fetch never released the flag and pagination was dead for the
rest of the welcome screen's life (the observer effect and loadMore both
short-circuit on loadingMore). Only one page fetch can be in flight, so
the reset can be unconditional.
2026-08-08 05:16:29 +00:00
Saoud Rizwan e03b4577b4 fix(sdk): close the hub client zombie-connection race around registration
The post-registration continuation was the one mutation window not guarded
by the connect generation: a close() landing after the register reply
resolved but before the continuation ran would mark a closed client
registered. That stale flag then made a later failed registration skip
closing its socket, leaving a permanently unregistered zombie connection
that isConnected() reported healthy.

- generation-guard the continuation so a superseded attempt closes its
  socket and rejects instead of touching shared state
- drop the registered-flag condition from the connect() catch guard; the
  socket identity check alone decides ownership and cannot be poisoned
- keep a stale attempt's late timeout/error/close handlers from clobbering
  lastCloseError and sawSocketClose for a newer attempt
- stop close() from wiping the real connect failure cause when no socket
  was ever opened
2026-08-08 05:15:03 +00:00
Saoud Rizwan 16d36e53f2 Merge branch 'main' into saoudrizwan/desktop-cloud-sessions-9a9b 2026-08-07 21:50:08 -07:00
Saoud RizwanandSaoud Rizwan d011d049a1 fix(core): keep session context durable across aborts and hub restarts (#13078)
* fix(core): keep session context durable across aborts and hub restarts

Users on slow self-hosted endpoints reported sessions losing their entire
conversation after cancelling a long-running request: the TUI still showed
the transcript, but the next turn greeted them like a brand-new session.

Root cause is a stack of two failures:
1. The hub daemon exits on any unhandled rejection that is not an
   AgentRuntimeAbortError, so a floating abort-family rejection from a
   cancelled provider stream kills every resident session.
2. When the CLI recovers the missing session it rebuilds from the persisted
   messages file - but aborting a turn never flushed the transcript, and
   lazy session persistence (SDK 0.0.70) kept seeded history (mode-switch
   restarts, forks, previous recoveries) memory-only until the first
   completed turn. Recovery then seeds an empty session: silent context wipe.

Fixes:
- completeAbortedInteractiveTurn now flushes the transcript to disk, so an
  aborted exchange survives a hub restart.
- Sessions started with initialMessages persist them (and any compaction
  sidecar) immediately; brand-new empty sessions stay lazy, so closing an
  unused runtime still leaves no empty history entry.
- The hub daemon ignores abort-family unhandled rejections (DOMException
  AbortError, Node ABORT_ERR) the same way it already ignores
  AgentRuntimeAbortError, instead of exiting with every session resident.

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

* fix(core): write seeded history atomically with session materialization

Greptile review flagged a residual crash window in the seeded-session
persistence: ensureSessionPersisted created the session row (with an empty
messages file) and only then called persistSessionMessages, so a crash
between the two left a discoverable session whose seeded history was gone.

Close the window by threading initialMessages/systemPrompt through
createRootSessionWithArtifacts: the messages artifact is now written with
the seeded transcript before the session row is committed, so every crash
point leaves either nothing discoverable or complete data. The follow-up
persistSessionMessages call at session start is gone; the seed travels
inside session materialization.

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

* Revert "fix(core): write seeded history atomically with session materialization"

This reverts commit 5a7e0b37f1.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 21:44:40 -07:00
Saoud Rizwan e09afced69 fix(core/hub): report queued-turn failures as run.failed (#13074)
Turns drained from the pending-prompt queue resolve their errored
AgentResult inside PendingPromptsController.drain(), which discards it,
and the legacy 'error' agent event had no projection in the hub's
session-event projector — so a failed queued turn never produced any
terminal hub event. Interactive clients (e.g. the desktop app) hung on
'Thinking...' with no error shown.

The projector now publishes run.failed (with the error text and a core
session snapshot) for non-recoverable lead-agent error events, but only
when no RPC-driven turn is awaiting sessionHost.runTurn for that
session — the awaiting run.start handler already publishes the
authoritative terminal event, so this avoids double-reporting a turn
that resolves through both paths.
2026-08-07 20:52:01 -07:00
Saoud Rizwan 74a1910505 Merge branch 'main' into saoudrizwan/desktop-cloud-sessions-9a9b 2026-08-07 20:50:44 -07:00
Saoud Rizwan f3db7a0a98 fix(desktop): order concurrent identical creates so recovery cannot steal an in-flight session
A session is listed the moment the server starts provisioning it, minutes
before its successful POST returns. Timeout recovery now waits for every
earlier identical in-flight create to record its claim before adopting a
listed candidate; later peers wait on earlier ones only, so waits cannot
cycle. Regression test covers the slow-success/fast-failure overlap.
2026-08-08 03:21:07 +00:00
Saoud Rizwan 3f83be51a2 feat(vscode): fade View Changes button until changes since last message are confirmed (#13076) 2026-08-07 20:16:15 -07:00
Saoud Rizwan 2b62f7b447 fix(desktop): webview review fixes for cloud sessions
- Emit cloud_session_provisioning_failed from the sidecar and render a
  terminal error pane in an open placeholder thread instead of an
  infinite provisioning spinner.
- Cloud-aware delete confirmations in the sidebar and sessions view (the
  action destroys the remote workspace, not just local history).
- Humanize cloud rename failures instead of showing the raw envelope.
- Preserve UI error bubbles through cloud rehydration merges; ignore
  unknown snapshot statuses instead of flipping a running turn to done.
- Clear a stale repository selection when the account can no longer
  access it so the send gate re-engages.
- Migrate cloud optimistic bookkeeping across queued-prompt re-keys,
  clear cloud refs on reset, session-scope the cloud merge, fix the
  impure provisioning-phase updater, gate rename on provisioning
  placeholder rows, and stop advertising local-only mentions/commands in
  cloud composer placeholders.
2026-08-08 03:03:15 +00:00
Saoud Rizwan 6b8b5726c3 fix(desktop): cloud session lifecycle hardening from deep review
- Reap connections whose sandbox expired (attach, sidebar poll, and
  reconnect-failure paths) so dead sessions stop reconnect-looping and
  spamming sync-failure events; sync failures now notify on transition
  only.
- Tombstone sessions mid-delete so a concurrent attach/send cannot dial a
  fresh connection that outlives the delete; treat remotely-gone sessions
  (404/410) as deletable locally.
- Guard disposed connections against resurrection by late reconnect timers
  and approval responses; purge approvals stored during failed connection
  setup.
- Leave cloud approvals pending on app shutdown instead of denying tool
  calls on pods that outlive the app.
- Use a fresh auth token (with fallback) for create-timeout recovery;
  normalize list rows so one malformed record cannot crash discovery;
  widen the recovery clock-skew window now that claims prevent
  double-adoption.
- Drop the queue-shrink 'prompt started' inference on the hub path (the
  hub emits explicit submitted events; a shrink can also mean removal).
- Reset the transcript baseline on reconnect; answer pendingPrompts with
  [] for sessions with no inner session instead of throwing.
2026-08-08 02:55:37 +00:00
Saoud Rizwan f6d8e54089 fix(desktop): cross-cutting review fixes for cloud sessions
- Use core's canonical getProviderAuthHandler("cline") for the persisted
  token fallback instead of a hand-rolled prefix heuristic that could
  corrupt unprefixed API keys; drop the dead test-only reset export.
- Reset the cloud session manager and broadcast cloud_sessions_changed
  after a cline OAuth login, and broadcast on the save_provider_settings
  (sign-out) reset, so the sidebar re-scopes immediately.
- Log cloud discovery failures instead of silently emptying the sidebar.
- Atomic write-then-rename for the desktop settings file.
- Share the repository/branch wire types between sidecar and webview.
- Add command-layer tests for the settings/flag commands; refresh the
  stale sidecar ARCHITECTURE.md; delete an orphaned comment.
2026-08-08 02:47:00 +00:00
Saoud Rizwan ef09caa3e6 fix(sdk,desktop): harden hub header resolution and onboarding poll
Review findings: bound resolveConnectionHeaders with the connect timeout so
a hung token refresh cannot pin connect() and every deduped caller forever;
record resolver failures in lastCloseError so getConnectionError() reports
the real cause; add a connect-generation token so close() during header
resolution cannot leave a doomed attempt satisfying the next connect();
stop header-auth clients from inheriting registry tokens for loopback URLs;
use the shared extractSessionId in approval.list_pending. Desktop: make the
onboarding poll read status from a ref instead of running side effects in a
state updater, and re-check GitHub connectivity when the account changes.
2026-08-08 02:42:20 +00:00
Saoud RizwanandSaoud Rizwan 1efe5577e1 fix(core/cli): drain queued prompts after self-aborted turns and surface the stop (#13061)
* fix(core/cli): drain queued prompts after self-aborted turns and surface the stop

When a run ends with finishReason "aborted" without a user abort request
(loop detector hard escalation or the consecutive-mistake safety stop),
runTurn skipped the pending-prompt drain, stranding user-queued messages
forever, and the CLI rendered nothing - the task appeared to silently
stop with queued messages never consumed (#13030).

- core: schedule the drain after every completed turn, including
  aborted/error finishes. User-initiated aborts are unaffected because
  abortSession() already clears the queue, and drain() stops after one
  failed send so an erroring provider cannot spin the queue.
- cli: when a turn comes back aborted without the user having requested
  an abort, append a "Task stopped before completion." status entry
  instead of ending the turn silently.

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

* fix(core): hold queued prompts on error finishes instead of consuming them

Addresses the Greptile P1 review on #13061: a drained prompt whose turn
resolved with finishReason "error" returned normally, so the
exception-only requeue path treated the send as successful - the failed
prompt was consumed and draining continued firing the remaining queue
into a failing provider.

- drain() now stops the chain when a drained send resolves with an
  error finish. The errored entry itself is not requeued (its turn ran:
  the prompt is in the conversation and the error is surfaced), but the
  rest of the queue is held.
- runTurn() no longer schedules a drain after "error" finishes (the
  skip is removed only for "aborted", which is the #13030 fix).
  Held prompts still drain via the existing enqueue/update/delete
  triggers or the next successful turn.
- Two new unit tests cover both layers.

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

* revert(cli): drop the 'Task stopped before completion' status line

Keep the change scoped to the queue-drain fix in @cline/core. The CLI
no longer prints a notice for non-user-initiated aborted finishes;
apps/cli is back to parity with main. When messages are queued, the
drain itself makes the stop visible (the queued message runs); richer
stop-reason surfacing can be a follow-up.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 19:27:47 -07:00
Saoud Rizwan bdb5f30f5e refactor(desktop,sdk): remove the PostHog flag plumbing cloud sessions no longer use
Reverts the SDK feature-flags service/provider changes and barrel exports to
main, and strips the desktop sidecar's PostHog-backed flag service (context
targeting, cache file, refresh/dispose lifecycle). isCloudAgentsEnabled() is
now just the env override plus the Settings toggle, and get_feature_flags
answers synchronously.
2026-08-08 01:50:57 +00:00
Saoud Rizwan 2fa0be8940 refactor(desktop,sdk): drop the code-cloud-agents flag from the SDK catalog
Cloud sessions are gated by the explicit Settings toggle now, so the SDK no
longer registers the unused PostHog flag. The Settings row is wrapped in a
visibility gate that is hard-wired on, with the future flag lookup left
commented out until the flag actually exists in PostHog.
2026-08-08 01:21:40 +00:00
Saoud RizwanandSaoud Rizwan 9cbc24d6e5 Restore "View Changes" on completion rows using SDK checkpoints (#13072)
* fix(core): read untracked-at-snapshot files from stash third parent in checkpoint diff

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

* feat(vscode): restore View Changes button on completion rows via SDK checkpoint diff

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

* refactor(vscode): integrate View Changes as a footer inside the completion card

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

* style(vscode): place the View Changes button inside the completion card

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 18:11:25 -07:00
Saoud RizwanandSaoud Rizwan 551286d33b fix(cli): preserve binary MCP payloads in expanded TUI tool output (#13071)
* fix(cli): preserve binary MCP payloads in expanded TUI tool output

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

* simplify to minimal payload-preserving fix

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 18:10:28 -07:00
Saoud Rizwan 2e5a565090 fix(desktop): harden queue snapshot validation and recovery claiming
- applyQueueSnapshot now rejects replies without a prompts array instead of
  publishing an authoritative empty queue from the pending/update/remove
  command paths.
- Timeout-recovery candidate selection and claiming now happen in one
  synchronous helper so the claim can never be separated from the check,
  and the regression test exercises truly concurrent create requests.
2026-08-08 00:24:42 +00:00
Saoud Rizwan 361ac90978 fix(llms): route LiteLLM through Chat Completions instead of the Responses API (#13053)
The litellm builtin spec pinned protocol: "openai-responses", so every
request went to POST {baseUrl}/responses. Self-hosted LiteLLM proxies
commonly implement only /chat/completions, so all prompts failed with
404 Not Found on the SDK path (CLI, and now the Next extension bundle).

Drop the override so litellm inherits the openai-compatible family
default (openai-chat -> /chat/completions), matching every sibling
openai-compatible builtin and the Legacy extension behavior.

Fixes #13003, fixes #10781
2026-08-08 02:06:46 +02:00
Saoud Rizwan eb95908737 fix(desktop): only treat valid pending-prompts replies as authoritative queue snapshots
An unsuccessful or malformed session.pending_prompts reply during
rehydration no longer publishes an empty queue or discards buffered queue
events; the newest buffered queue snapshot is replayed instead.
2026-08-08 00:05:44 +00:00
Saoud RizwanandSaoud Rizwan 4eb7402334 fix(cli): render MCP tool result text instead of escaped JSON in TUI (#13066)
* fix(cli): render MCP tool result text instead of escaped JSON in TUI

MCP tools return {content: [{type: "text", text}]} which
extractFullOutputText JSON-stringified, escaping newlines into one giant
line that word-wrapped across the whole terminal and never triggered the
line-based collapse. Extract the text parts with real newlines so the
existing collapse works.

Fixes #13038

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

* fix(cli): keep placeholders for non-text blocks in mixed MCP results

Addresses Greptile review on #13066: text-only filtering silently
dropped image/resource/audio blocks from mixed MCP content. Render them
as [type] placeholders instead.

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

* fix(cli): surface non-text MCP block metadata in TUI output

Extract embedded resource text, and include resource/resource_link URIs
and image/audio mime types in placeholders so expanded mixed MCP
results keep identifying metadata.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 17:05:22 -07:00
Saoud RizwanandSaoud Rizwan ffb61a865f fix(mcp): give unconfigured stdio servers a 30s initialize budget (#13067)
The stdio MCP client gave servers without a configured `timeout` only
1.5 seconds to answer initialize before killing the process, so
slow-starting servers (e.g. Oracle SQLcl's JVM-based `sql -mcp`) could
never load and were silently skipped at session start.

Raise the default connect budget to 30s, in line with the startup
budget other MCP clients allow. A configured `timeout` still overrides
it in either direction, dead commands still fail fast through the spawn
error/exit path, and the newline -> Content-Length framing fallback is
unchanged.

Fixes #13035

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:49:28 -07:00
d3616b96ae feat(desktop): route /team prompts through core runtime (#12372)
* feat(desktop): route /team prompts through core runtime

Rewrite desktop `/team` commands as structured user command blocks before sending them to the core runtime. Validate task input and respect the globally disabled Teams tool setting.

Remove legacy agent spawn and team enablement flags from session configuration, and add coverage for prompt rewriting and disabled-tool behavior.

* fix(desktop): preserve team tool defaults

* fix(desktop): display queued /team prompts as their slash form

Queued prompts are stored in their runtime form, so a queued /team
command showed its raw <user_command> envelope in the prompt queue chip
and edit textarea. Fold queue items through formatDisplayUserInput for
display; saving an edit re-resolves the slash form through the sidecar,
so the round trip is lossless.

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

* chore(hub): align builtin tool catalog flags with the desktop sidecar

The desktop sidecar pins enableSpawnAgent/enableAgentTeams when listing
the builtin tool catalog; the hub's parallel listing did not, so the two
would drift if the preset defaults ever change. Pin the same flags in
the hub and cross-reference the two call sites.

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

* chore(desktop): drop inert enableSpawn/enableTeams config leftovers

buildCoreSessionConfig no longer reads these keys, so remove the dead
schema fields, default-config initializers, and chat-test payload
entries. The chat-session regression test still sends them on purpose
to prove legacy flags cannot override the runtime's tool presets.

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

* fix(desktop): reject /team when the mode's tool preset disables teams

The /team guard only checked the global disabled-tools setting, but the
runtime resolves tool availability from the mode's preset, so a preset
without team tools (yolo) would still send the model a spawn-a-team
instruction it cannot act on. Resolve the teams catalog entry for the
session's mode and reject /team when it is unavailable, mirroring the
runtime's own availability logic.

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-07 16:45:49 -07:00
Saoud Rizwan b091c72e40 fix(desktop): address cloud-session review findings
- Keep the newest buffered queue snapshot when rehydration's queue fetch
  fails instead of silently dropping queued/steered prompts (Greptile P1).
- Claim recovered/created session ids per process so overlapping identical
  create requests cannot adopt the same record and orphan a sandbox
  (Greptile P1).
- Use crypto.randomUUID() for provisioning placeholder ids (CodeQL
  insecure-randomness alerts).
2026-08-07 23:44:05 +00:00
98e5458b09 feat(hub): centralize plugin settings and contributions (#12942)
* fix(desktop): plugin package names

* feat(hub): centralize plugin settings and contributions

* fix(hub): address plugin settings review feedback

* fix(settings): make plugin snapshots host-aware

* fix(core): make host plugin toggles atomic

---------

Co-authored-by: cline-cloud[bot] <cline-cloud[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 16:44:02 -07:00
Saoud Rizwan 031b8d94e1 fix(core): surface OAuth authorization for SSE MCP servers on 401 (#13050)
* fix(core): surface OAuth authorization for SSE MCP servers on 401

A 401 from an SSE MCP server never persisted authorizationRequired: the
fetch-boundary UnauthorizedError was consumed by EventSource and re-thrown
as a status-less SseError, so the instanceof check routed it to
markConnectionError and hosts never offered the OAuth connect action.

Give the SSE stream request a raw fetch so a 401 fails the connection with
the SDK's typed SseError(401), and recognize 401s across transports with a
single isMcpUnauthorizedError predicate at every detection site.

* style(core): apply biome formatting to MCP oauth changes
2026-08-07 16:42:43 -07:00
Saoud RizwanandSaoud Rizwan e5bcba8ef9 fix(vscode): settle the turn phase when a mode switch aborts a running turn (#13063)
Toggling Plan/Act while a turn was streaming or waiting on a tool approval
aborted the turn but left the TurnStateTracker on its last live phase: the
aborted session's done event is fenced off as stale once the rebuild
unsubscribes it, so nothing ever settled the phase. The webview then kept
rendering that phase forever - an eternal Thinking spinner with the input
disabled (aborted while streaming), or dead Approve/Run Command buttons wired
to an approval that clearPending had already denied (aborted while awaiting
approval). Users experienced this as 'switched to act mode and nothing
happened / it never wrote the files'.

Mirror cancelTask: after aborting the turn for the mode change, append a
resume_task ask row and set the phase to resumable, so the footer offers
Resume Task with the input enabled in the new mode.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:41:13 -07:00
Saoud Rizwan ebf5b52e9b Merge origin/main into saoudrizwan/desktop-cloud-sessions-9a9b
Resolves conflicts with #13028 (native-feel polish and render-path
performance): keep dynamic view imports and the memoized headerDiff from
main while preserving the cloud-session behaviors from this branch (Cloud
icon import, Connect GitHub error-action button in the chat error banner,
and hiding the diff header for cloud sessions).
2026-08-07 23:36:29 +00:00
Saoud Rizwan 83182c0d96 fix(llms): retry mid-stream network interruptions before any model output (#13052)
* fix(llms): retry mid-stream network interruptions before any model output

* fix(llms): scale network retry backoff by network retry count, not shared attempt number
2026-08-07 16:36:27 -07:00
Saoud Rizwan 40ebd09dfa desktop: native-feel polish, render-path performance, and transition fixes (#13028)
* desktop: native-feel polish and render-path performance fixes

- Suppress the WebView browser context menu on app chrome (keep it for
  editable fields and active text selections)
- Make UI chrome unselectable app-wide; opt chat messages, markdown,
  code, diffs, and error banners back into text selection
- Contain overscroll so inner scrollers don't rubber-band the window
- Lazy-load Settings/Sessions/Onboarding/Diff views out of the entry chunk
- Memoize ChatInputBar and AgentHeader; stabilize their props in the chat
  pane so stream flushes only re-render the affected message bubble
- Stop refocusing the composer textarea on every keystroke (caret flicker)
- Cache slash commands across menu opens (stale-while-revalidate)
- Avoid rebuilding reversed message arrays and ask-question JSX per render
- Drop core info/debug console logging on the streaming hot path behind a
  cline:debug-logs opt-in; remove leftover [webview:delete] debug logs
- SearchCombobox (provider/model picker): Escape closes and restores focus
- Remove unused @vercel/analytics, recharts, embla-carousel deps and the
  unused chart/carousel UI components

* desktop: surface failed-turn errors instead of leaving the chat blank

On a failed run the runtime reports its error string in result.text.
The webview rendered that as an assistant bubble, which the canonical
history rehydration then wiped (the failed turn is never persisted),
so provider errors like a retired model id left the user staring at a
silently empty chat. Route failed-turn text to a persistent error-role
message added after rehydration instead.

* desktop: fade the welcome/conversation swap instead of hard-cutting

Sending the first message replaced the hero layout with the message
grid in a single commit, which read as a white flash. A 180ms enter
animation now plays when either side becomes visible; disabled under
prefers-reduced-motion.

* desktop: render new-chat panes instantly from the last catalog load

Clicking + remounts ChatThreadPane, which refused to render until the
provider catalog (a large fetch) and workspace list resolved again —
about a second of blank pane plus boot spinner on every new chat.
Seed remounts from a module-level snapshot of the last successful
load; the mount effect still refreshes both in the background.

* desktop: invalidate the provider-catalog snapshot with the cache

Seeding remounted chat panes from the last catalog load left a window
where a pane created right after a credential change could act on the
old keys. The snapshot now lives in the catalog module and is dropped
by invalidateProviderCatalogCache(), so credential edits force the
next remount to wait for fresh data.
2026-08-07 16:18:47 -07:00
Saoud RizwanandSaoud Rizwan 7bc18f7e15 Bring back a copy button on turn-final response rows with a subtle header (#13051)
* Add subtle response header with copy button to completion and plan rows

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

* Add changeset for response header copy button

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

* Rename turn-final headers to Completed and Plan

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:12:43 -07:00
BeeandSaoud Rizwan fad8006730 feat(hub): add execution context for scheduled run reports (#12718)
* feat(hub): add execution context for scheduled run reports

Add human-readable headers, schedule metadata, durations, and lifecycle error context to cron run reports. Resolve file-based definitions to real paths while clearly identifying Hub-managed schedules stored in cron.db.

* fix cron report formatting edge cases

* Escape schedule titles in reports

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 15:57:33 -07:00
Saoud RizwanandSaoud Rizwan 28f35a2ec8 fix(cli): harden tool input/output formatters against malformed payloads (#13048)
* fix(cli): harden tool input/output formatters against malformed payloads

Tool inputs cross the model/tool boundary and may not match their
TypeScript annotations (e.g. run_commands with { command: null }).
truncate() called str.replace() on such values, crashing the TUI with
'.replace is not a function' and making persisted sessions containing
the payload non-resumable, since hydration replays the same input
through formatToolInput().

Normalize untrusted values at the formatting boundary: truncate() now
accepts unknown and safely stringifies null/undefined/objects (including
circular structures and throwing toJSON), formatStructuredCommand no
longer returns non-string commands verbatim, and fetch_web_content
request summaries tolerate malformed entries.

Fixes #13036

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

* fix(cli): keep valid empty-string args in structured command summaries

Greptile review: filtering normalized args by truthiness also dropped
genuine empty-string argv entries, so summaries could show a different
argument list than the one executed. Filter only nullish entries before
normalization instead.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 15:53:01 -07:00
Saoud Rizwan 13d801b578 fix(desktop): allow renaming cloud sessions from the sessions view
Rename was already supported by the sidecar and offered in the sidebar and
chat header; the sessions view context menu was the odd one out. Includes
biome format fixes picked up in touched files.
2026-08-07 22:52:44 +00:00
Saoud Rizwan 642c2f643b feat(desktop): cloud sessions onboarding panel for GitHub connect flow
When the cloud composer cannot start a session yet (signed out, GitHub not
connected, or the GitHub App has no repository access) replace the composer
with an onboarding panel that explains cloud sessions, walks through the
dashboard hand-off with visual steps, and auto-detects completion via polling
and window-focus refetches. Adds a teaching hint under the ready composer.
2026-08-07 22:52:44 +00:00
Saoud Rizwan 528c6b8ff2 feat(desktop): gate cloud sessions behind an explicit settings toggle
Cloud sessions are in preview, so replace the remote rollout flag with an
opt-in toggle in Settings -> General, persisted in a desktop-owned settings
file (kept out of global-settings.json so older CLI writers cannot strip it).
The CLINE_CODE_CLOUD_AGENTS env override still wins for development. Toggling
broadcasts feature_flags_changed so open composers react without a restart.
2026-08-07 22:52:36 +00:00
Saoud Rizwan 1e24807d1a fix(vscode): fall back to session cwd or Desktop for @-mention file search in empty windows (#12982)
* fix(vscode): fall back to session cwd/Desktop for @-mention search in empty windows

* fix(vscode): use the shared chat workspace as the no-folder fallback root
2026-08-07 15:38:43 -07:00
Saoud Rizwan 2f1c94505a Merge origin/main into desktop cloud sessions branch 2026-08-07 22:32:38 +00:00
Saoud Rizwan a5f90e0d53 fix(core): pick up checkpoints when git is initialized mid-session (#13026)
ensureGitRepository cached a negative probe for the lifetime of the hook
instance, so a session started in a non-git folder never got checkpoints
even after the user ran git init. Cache only the positive answer and
re-probe otherwise; the probe runs at most once per user turn.
2026-08-07 15:25:11 -07:00
Saoud RizwanandSaoud Rizwan adabfc6bd5 fix(desktop): treat signed-out state as a typed result instead of a command error (#12976)
* fix(desktop): treat signed-out state as a typed result instead of a command error

* fix(desktop): sign out when the organization balance fetch reports the typed signed-out result

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

* chore: retrigger checks after runner outage

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 15:06:45 -07:00
Saoud Rizwan 397d6f344f fix(desktop): resolve startup script-load SyntaxError and attribute webview errors to their source URL (#12974)
* fix(desktop): remove Vercel Analytics injection that breaks packaged webview startup

* fix(desktop): attribute webview uncaught errors to their source URL

* chore: drop unrelated claude-dev version bump from lockfile

* chore: retrigger checks after runner outage
2026-08-07 15:02:36 -07:00
Bee 8ca3068b73 refactor(desktop): update team tools component (#13047)
* refactor(desktop): update team tools component

* fix(desktop): address team tool review feedback
2026-08-07 22:58:06 +02:00
John Choi 6e6befdb65 fix(ui): remove nested tool output scrolling (#13043)
* fix(ui): remove nested tool output scrolling

* fix(desktop): avoid nested tool output scrolling

* chore(desktop): remove obsolete scroll utility

* fix(desktop): preserve multiline tool details
2026-08-07 13:44:23 -07:00
Haley ParkandSaoud Rizwan 71536e55aa refactor(ui): introduce Cline-owned semantic color system (#12941)
* refactor(ui): introduce Cline-owned semantic color system

* refactor(desktop): adopt shared semantic theme roles

* refactor(ui): set 15px root and recalibrate xs/sm type scale

Scale rem steps so xs/sm stay 12/13px visually, and slightly lift dark-mode neutral-4.

* refactor(ui): align SearchCombobox with package type and hover tokens

Use host-safe cline-ui utilities and keep option font inheritance from CSS.

* fix(ui): use standard stroke-2 utility on approval spinner

* refactor(desktop): modernize shared UI primitives for Tailwind v4

Replace legacy arbitrary/has selectors with current utility syntax.

* refactor(desktop): bump chat chrome typography to text-sm

Keep composer controls and pickers on the shared sm type step.

* refactor(desktop): use max-w-344 for page frame content width

* chore(desktop): disable Next.js dev indicators

* chore: ignore desktop-app Cursor settings

* docs(pr): add before/after screenshots for #12941

* chore: retrigger checks

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 10:15:18 -07:00
John Choi 5b7005b003 fix(desktop): paginate cloud branch picker 2026-08-07 08:51:45 -07:00
abeatrix e48a7c9c91 feat(code): add SSH remote environments PoC 2026-08-07 00:20:13 -07:00
Saoud Rizwan 7348ba1847 chore(desktop): release v0.0.10 2026-08-06 23:45:56 -07:00
John Choi 71e1c59ab8 feat(desktop): switch models in cloud sessions 2026-08-06 20:38:53 -07:00
BeeandSaoud Rizwan 3e96fc6112 feat(cli): add mcp uninstall command (#12985)
* feat(cli): add mcp uninstall command

* unit test wiring

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-06 19:40:45 -07:00
Saoud RizwanandSaoud Rizwan d84d09c543 desktop: fix silent turn failures, message duplication, and stuck composer; add first-run setup guidance (#12984)
* desktop: fix silent turn failures, message duplication, and stuck composer; add first-run setup guidance

Findings from two full computer-use UX audits of the desktop app:

- Surface failed turns in the transcript: queued turns (incl. the first
  prompt of a fresh session) only signal errors via chat_done, which the
  UI previously ignored - sending a message with no credentials failed
  in complete silence. Failed turns now show an error message enriched
  with the latest core error log and a pointer to Settings -> Models.
- Fix duplicated user messages: a live send's optimistic user message
  was materialized a second time by the runtime's queued-prompt-start
  event.
- Fix composer stuck on 'Agent is working...': drop prompts from the
  local queue snapshot when they start, emit a fresh queue snapshot from
  the sidecar on pending_prompt_submitted, and double-check the server
  queue on turn completion.
- Add a 'Connect a model' notice on the welcome screen when no provider
  has credentials, with actions to reopen onboarding at the connect step
  or jump to model settings; it reacts live to credential changes.
- Add 'Get an API key' links for popular providers in onboarding and
  Settings -> Models (the catalog docUrl is never populated), and link
  the Cline dashboard from the Cline API key form.
- Explain what Cline is on the onboarding welcome step.
- Make the stop button visible (was 8px with no padding) and support
  Esc to stop; add Cmd/Ctrl+N (new session) and Cmd/Ctrl+, (settings).
- Remove leftover [webview:delete] console.error debug logging that
  surfaced an error badge after deleting a session.

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

* desktop: remove remaining delete debug logging in session history hook

The sidebar right-click delete path had the same leftover [webview:delete]
console.error instrumentation, which made the Next dev-mode issues badge
appear after every deletion.

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

* desktop: fix Biome a11y error in WelcomeSetupNotice

biome's lint/a11y/useSemanticElements errors on role="status" divs;
use the semantic <output> element (implicit status role) instead. This
was failing the repo's 'bun run lint'.

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

* desktop: count structured-config and keyless providers as connected

The welcome setup notice previously only recognized apiKey/OAuth
credentials, so users running Bedrock/Vertex (structured configValues)
or a deliberately enabled keyless local endpoint (e.g. Ollama) were
nagged to connect a model they already use. isProviderConnected now
also counts an enabled provider whose required config fields are all
filled, or an enabled provider that has no API-key field at all.

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

* desktop: keep re-key eligible when chat_done lands in the same batch as its prompt start

When a turn fails fast, chat_queued_prompt_start and chat_done can be
dispatched in one React batch. Clearing the outstanding-optimistic-
bubble registry synchronously in the chat_done handler ran before the
re-key updater enqueued by the prompt-start event, so the optimistic
bubble was appended a second time instead of re-keyed. Clear the
registry inside a state updater so it executes in event order after
the re-key. Caught by the queued-turn-failure regression test.

* desktop: make the queued-prompt re-key updater idempotent under StrictMode

React StrictMode double-invokes state updaters in dev. The
chat_queued_prompt_start re-key updater consumed the optimistic
bubble's id from outstandingOptimisticUserIdsRef on its first run, so
the second run against the same prev found no eligible candidate and
appended the same user message a second time (and, without a promptId,
makeId() minted a different id per invocation). Hoist the message id
out of the updater and remember which optimistic bubble each queued
message id re-keyed so a re-run reaches the identical result. The memo
resets alongside the outstanding set (error state, reset, hydration).

Root-caused with runtime instrumentation: the duplicate only appeared
on turns that exercised the queue-drain re-key path, and hydration
later collapsed it to one message because the duplicate never existed
in persisted state.

* desktop: preserve failure messages across post-send canonical hydration

Persisted history never contains UI-only error bubbles, so the two
post-send read_session_messages replacements in sendPrompt wiped the
failure explanation appended from chat_done ~40ms after it rendered
(confirmed with runtime instrumentation). Re-append the active
session's error messages after the canonical history. Includes a
regression test reproducing the chat_done-error-then-RPC-resolution
race.

* desktop: don't let an optional API-key field veto a connected provider

Greptile P1 follow-up: Bedrock's catalog entry carries an optional
apiKey field ('Optional Bedrock bearer token') alongside IAM/profile
authentication, and keyless local endpoints can also surface one — so
treating the mere presence of an apiKey field as proof of disconnection
kept nagging configured users. An enabled provider (the user
deliberately persisted settings for it) now counts as connected unless
a required config field is unmet; auth may legitimately live outside
the catalog (IAM, env vars, local endpoints). Brand-new users have no
enabled providers, so the first-run notice still shows for them.

* desktop: tighten credential-error guidance and stop re-pinning stale failure bubbles

* desktop: invalidate the shared provider catalog after settings OAuth login

Greptile P1 follow-up: runOAuthProviderLogin only updated the settings
view's local provider state, so the shared catalog cache and its
invalidation subscribers (the composer selector and the welcome
screen's 'Connect a model' notice) kept reporting the provider as
disconnected until an unrelated invalidation or a pane remount. Notify
the shared cache on successful OAuth login, like the account view and
the API-key save path already do.

* desktop: clear the remembered core error on turn end, reset, and hydration

Greptile flagged that turn-start events are the only thing clearing
lastCoreErrorBySessionRef, and websocket events are not replayed: a
transport interruption that drops a turn's start event lets a later
detail-less failure resurrect an earlier turn's error. The remembered
error belongs to exactly one turn, so clear it whenever a turn ends
(chat_done, any reason) as well as on reset() and history hydration.
Regression test covers the dropped-start-event sequence.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-06 19:25:42 -07:00
BeeandSaoud Rizwan 6f7f817d63 feat(core): support pre-registered OAuth clients for remote MCP (#12983)
* feat(core): support pre-registered OAuth clients for remote MCP

* fix(core): invalidate tokens when OAuth client changes

* fix(core): preserve compatible MCP OAuth sessions

* fix tests

* feat(desktop): wire mcp oauth in desktop

* UI update

* fix(core): reject stale mcp oauth callbacks

* fix(mcp): handle invalid settings and preserve state

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-06 18:58:52 -07:00
abeatrix d7b61de54f Merge remote-tracking branch 'origin/main' into bee/agent-voice
# Conflicts:
#	apps/examples/desktop-app/sidecar/context.test.ts
#	sdk/packages/llms/src/catalog/catalog.generated.ts
#	sdk/packages/llms/src/providers/ai-sdk.ts
#	sdk/packages/llms/src/providers/builtins.ts
#	sdk/packages/llms/src/providers/provider-ids.generated.ts
#	sdk/packages/llms/src/providers/providers.generated.ts
#	sdk/packages/llms/src/providers/routing/provider-option-rules.ts
#	sdk/packages/llms/src/providers/routing/provider-options.test.ts
2026-08-06 18:15:57 -07:00
BeeandCline Agent c18d9478ba feat(cli): use saved provider settings for schedules (#10667)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-06 17:34:40 -07:00
Ara 574b8eb45e fix(llms): use configured fetch for Vertex ADC refreshes (#12981) (#12991) 2026-08-06 10:07:06 -07:00
John Choi 7350294f7d fix(desktop): harden cloud session lifecycle and flags 2026-08-06 10:01:27 -07:00
John Choi fe90cbfdf1 Merge remote-tracking branch 'origin/main' into codex/desktop-cloud-agents 2026-08-06 09:49:19 -07:00
John Choi 44cf50cee7 fix(desktop): cloud sessions follow the active account scope
The sidebar now shows exactly the active scope's cloud sessions (personal
or the active organization — matching the dashboard), instead of merging
both. On account/organization switch the sidecar broadcasts
cloud_sessions_changed so the sidebar re-scopes immediately rather than
on the next poll; the cloud manager reset already discards the org cache
and connections.

The session registry is now upsert-only: a session opened under another
scope stays routable (send/abort keep working) when the server-side
active org drifts mid-run, even though it leaves the visible list.
Display truth stays lastListedSessions (active scope only).

Known behavior: after a full account/org SWITCH (manager reset), stale
threads from the previous scope report session-not-found on cold reopen —
the list no longer shows them, so this is reachable only via stale
webview state.
2026-08-06 09:43:04 -07:00
John Choi b39c7e0319 feat(desktop,sdk): cloud transcript synchronization + approval recovery
Implements the agreed convergence design (mirrors experiment/mobile-app):
subscribe → buffer → attach → snapshot (messages/status/queue) → install →
replay unreflected events → live. Single-flight with one queued rerun;
failed snapshots never become an authoritative empty transcript;
segment-scoped substring supersession in the sidecar; multiset count-delta
optimistic reconciliation with first-hydrate gating in the webview.

Review-round fixes on top of the sync implementation:
- Recovery baseline advances on delivered sends — a lost duplicate prompt
  can no longer be falsely confirmed by an earlier identical delivery.
- Prompt occurrence matching normalizes the pod's <user_input> wrapper
  (real transcripts never matched raw prompts; tests used unwrapped
  fixtures, so recovery was inert in production).
- Streamed-text trim symmetry so whitespace cannot defeat supersession
  and duplicate an entire already-persisted reply on reconnect.
- Buffered queue snapshots are dropped during replay (always older than
  the synced queue; replaying could regress it and double-bubble).
- approval.list_pending advertised in HUB_CAPABILITIES (capability-gated
  clients could never discover it) and the sidecar's approvals refresh
  never wipes observed state unless the reply provably carries the list.
- Safety tests: replay-when-not-contained, whitespace supersession,
  queue-snapshot drop, wrapped-prompt recovery, baseline advance.

Tracked follow-ups (not in this change): approval.respond and event
delivery remain unscoped hub-wide (scoping naively would break the
desktop's second approvals client); sessionId is mandatory here vs
optional on the mobile branch.
2026-08-06 09:34:16 -07:00
Saoud Rizwan 81cce3d70e chore(vscode): prepare 4.1.6 release 2026-08-06 00:47:54 -07:00
Saoud Rizwan e1352fa709 chore(cli): release v3.0.51 2026-08-06 00:28:37 -07:00
Saoud Rizwan 394fb04518 chore(sdk): release v0.0.71 2026-08-06 00:14:21 -07:00
Saoud Rizwan f1aebbfd5a feat(llms): surface meta/muse-spark-1.2-contributor for the Cline provider (#12995)
* chore(llms): regenerate model catalog from models.dev

* feat(llms): surface meta/muse-spark-1.2-contributor for the Cline provider

* test(llms): guard Vercel-only Cline model allowlist
2026-08-06 00:04:16 -07:00
abeatrix 378fdf147b feat: avatar overlay 2026-08-05 22:34:31 -07:00
John Choi 9c62406da9 fix(desktop,sdk): pre-PR review round — connection lifecycle and UX hardening
From a three-lens adversarial review of the branch:

Sidecar (cloud-sessions)
- BLOCKER: remove the connections-map entry when inner-session creation
  fails — the poisoned entry returned a disposed client whose event
  subscription was gone, silently streaming nothing for every later send.
- Single-flight inner-session creation: concurrent sends could fork two
  inner sessions on the pod, permanently dropping one run's events.
- Cache the active-organization lookup (60s, successes only) — the
  sidebar poll was making two authed REST calls per tick.
- delete() now drains an in-flight connect (zombie-connection race).
- Cold-cache expiry surfaces the clean session_expired envelope on
  send/read paths, not a raw WS upgrade failure.
- A provisioned-but-connect-failed create no longer reports failure for
  a live, billed sandbox; connect happens on demand instead.
- Guard against empty 2xx create responses (raw TypeError before).

SDK (hub client)
- Socket-identity guards in every connect-attempt cleanup path: a stale
  attempt's late timeout/error/close can no longer clobber a newer
  in-flight attempt's socket or dedupe state.

Webview
- Placeholder→real swap keeps the placeholder thread when opening the
  real session fails (was: deleted it and dumped the user on a blank
  fallback thread).
- Reset executionTarget to local when the cloud flag flips off on a
  fresh thread (was: permanently stranded cloud-gated composer).
- inferStatusFromMessages preserves 'provisioning' (the hydration pass
  was clobbering the sidebar state to idle within a second).
- Humanize cloud error envelopes in the delete toast and rename failure
  (rename previously had no catch at all).

Regression tests: poisoned-connection recovery, inner-session
single-flight.
2026-08-05 22:05:22 -07:00
John Choi a988d6a6ee fix(desktop): keep cloud loading continuous through the provisioning swap
The placeholder → real-session swap mounts a fresh thread whose hydration
briefly showed the skeleton between the provisioning row and the
conversation. An empty cloud session mid-hydration now shows the same
compact row ("Opening session...") so the loading treatment never
changes shape.
2026-08-05 21:36:52 -07:00
John Choi f1ec8c897c fix(desktop): cloud session dogfood round — billing, UX, and reliability
From live dogfooding of cloud agent sessions:

Billing & sessions
- Bill the user's ACTIVE organization (server-side active flag, cached
  resolver; personal fallback) instead of always personal credits, and
  list both personal and org-scoped sessions.
- Auto-title sessions from the first prompt; support rename via REST.
- Optional branch passthrough (picker + create body + recovery match).
- Forward autoApproveTools into cloud session creation.

Provisioning experience
- Sidebar placeholder while the synchronous create provisions (REST list
  cannot see the session yet), pulsing status dot, instant list nudge.
- Unified compact loading row (shared cycling phase line) for both the
  originating thread and the placeholder pane; phases advance once and
  hold rather than looping.
- cloud_session_provisioned event swaps placeholder threads to the real
  session when the sandbox is ready.
- Opening a placeholder is benign (loading state), reads return empty,
  only mutating actions error.

Correctness
- Surface run.failed error payloads in the chat (silent-failure fix; the
  raw CLOUD_SESSION_ERROR envelope can no longer reach the screen).
- Emit chat_session_status only on real status changes (pods stream
  periodic snapshots — every visited session was marked unread forever).
- expiredAt is a TTL deadline, not an end time; display uses createdAt
  (backend bumps updatedAt on every WS connect).
- provisioning is a first-class SessionHistoryStatus (the normalizer was
  collapsing it to idle).
- The new-prompt hero requires a thread WITHOUT a history session —
  fixes every flash-of-intro-screen path for existing sessions.
- Archived-history fallback only replaces a live failure when a snapshot
  actually exists (404 = null, not empty).

Plus GitHub repository/branch pickers, org-scoped integration URLs,
thinking-effort passthrough, and feature-flag targeting by account id.
2026-08-05 21:32:51 -07:00
John Choi 0b1f083531 fix(desktop): evaluate cloud-agents flag inside ChatThreadPane
The flag sync referenced ChatThreadPane state from Home, which only
surfaced at next-build prerender (tsconfig.dev does not cover webview).
2026-08-05 18:01:13 -07:00
John Choi d79ad49de3 Merge remote-tracking branch 'origin/main' into codex/desktop-cloud-agents 2026-08-05 17:58:02 -07:00
John Choi ce78c667cf feat(desktop): cloud agent sessions behind code-cloud-agents flag
Cloud sessions via core-platform remote-session API + Hub protocol v1:
sidecar CloudSessionManager (REST lifecycle, per-session NodeHubClient with
Bearer header auth, event translation, approvals, pending-prompt bridge,
expired-session archived history), webview Local/Cloud selector with repo +
optional branch picker, and a default-off PostHog feature flag gating
creation and the selector (existing sessions always remain accessible).
SDK: NodeHubClient resolveConnectionHeaders option; close unregistered
socket on registration failure; export FeatureFlag const from @cline/shared.
2026-08-05 17:57:57 -07:00
Saoud Rizwan 543dd0d818 fix(telemetry): attribute agent.run sdk.error events to the active model (#12972)
* fix(telemetry): attribute agent.run sdk.error events to the active model

* fix(telemetry): strip undefined values from sdk.error properties
2026-08-05 17:51:06 -07:00
abeatrix 2140996303 feat(desktop): avatar pet
- Add native Tauri avatar-window lifecycle, positioning, tray integration, and persisted preferences.
- Add the `/avatar-overlay` webview route and v2 spritesheet animation runtime.
- Play the standard wave animation when the avatar first appears.
- Add bundled Cline Bot and Mom spritesheets.
- Add avatar discovery, selection, visibility, and manifest-precedence support.
- Add Settings controls for enabling and selecting desktop avatars.
- Add an animated voice orb driven by microphone intensity.
- Improve realtime voice panel visibility, mute/stop controls, playback coordination, and tests.
- Reorganize chat input controls so speech, realtime voice, stop, and send actions do not compete.
- Document avatar package structure and storage.
2026-08-05 14:19:28 -07:00
Saoud Rizwan 1f2cbbeb9f chore(vscode): prepare 4.1.5 release 2026-08-05 14:04:04 -07:00
Saoud Rizwan b1a89156d6 feat(vscode): explain when a free model promotion ends (#12970)
* feat(vscode): explain when a free model promotion ends

Once a free promotion ends, the cline-free/ model is removed from the
catalog and the backend answers 'model not found' to requests against it.
The CLI has shown a dedicated 'Free model promotion ended' banner for this
since #12593; the extension instead rewrote the answer into generic
model-not-found guidance with no model-picker offramp.

Detect the case in the host where the active model id is known
(reshapeErrorForWebview, fed by a new MessageTranslatorState model-id
source), stamp the payload with a cline_free_promotion_ended code, and
render a dedicated card in the webview with a button into the model
picker. Classification is gated on the cline-free/ prefix so ordinary
model-not-found errors keep their generic path, and it runs before the
auth branch since the 404 status falls inside the generic auth range.

* fix(vscode): prefer the live task model over session-start metadata

A mid-task model-only switch updates the running session's model in place
(updateActiveSessionModel) and refreshes the task API shim, but never
touches the session's startConfig/manifest. Preferring the session-start
snapshot could therefore misclassify after such a switch: a genuine
retired-model 404 would miss the promotion-ended card, and the reverse
switch could show it for the wrong model. Provider switches restart the
session, so both sources agree there; the shim starts as "unknown"
(filtered out), so fresh sessions still resolve through start metadata.
2026-08-05 14:00:09 -07:00
BeeandSaoud Rizwan 1d7d9ce5e2 feat(llms): add portable reasoning resolution for AI SDK providers (#12946)
* feat(llms): add portable reasoning resolution for AI SDK providers

Introduce resolvePortableReasoning to map gateway reasoning requests
(effort levels, enabled/disabled flags) to the AI SDK's top-level
reasoning setting, applying it in buildAiSdkStreamConfig for supported
providers including Ollama.

- Defer exact token budgets to provider-specific options
- Omit reasoning when the caller expresses no explicit intent
- Replace manual provider-specific thinking overrides (e.g. Anthropic
  budget clamping, Moonshot/OpenAI-compatible toggles) with the
  portable reasoning path where applicable
- Add tests covering effort mapping, budget passthrough, and provider
  stream config integration

* fix(llms): prioritize explicit reasoning disable

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-05 12:17:49 -07:00
abeatrix 0d1a72f009 ui update 2026-08-05 12:13:53 -07:00
abeatrix a6c0cfd5c3 move audio-player to shared ui package 2026-08-05 11:19:05 -07:00
abeatrix 17e8c0cbc9 feat(desktop): add media player 2026-08-05 11:14:57 -07:00
Mikołaj Kondratek 78b7c3d8ac fix(desktop): stop rendering the first chat message twice (#12779)
* fix(desktop): dedupe chat_queued_prompt_start emitted for the same prompt

PendingPromptService.drain() emits a pending_prompts snapshot (head
removed) and a pending_prompt_submitted event back-to-back for the same
prompt. The sidecar translated both into chat_queued_prompt_start, so
the webview rendered the user's message twice until the chat was
re-hydrated from history. Track the last announced prompt id per live
session and emit the start chunk once.

* fix(desktop): re-key optimistic user bubble when the runtime queues the prompt

The send path renders an optimistic user bubble for prompts dispatched
while the session is idle, keyed by a random id. When the runtime
routes that prompt through its pending queue (e.g. during session
startup), the queued-prompt-start event appended a second bubble under
queued_user_<promptId> — the same message rendered twice until the
chat was re-hydrated from history. Re-key the trailing optimistic
bubble to the event's id instead of appending.

* fix(desktop): re-key only outstanding optimistic bubbles on queued prompt start

Review follow-up: matching by content alone could swallow a new queued
prompt that repeats the text of a message left at the transcript tail
by an earlier cancelled/failed turn. Track in-flight optimistic bubble
ids explicitly (registered on optimistic append; cleared on re-key,
turn end, error, and history hydration) and only re-key those.
2026-08-05 14:49:05 +02:00
Saoud Rizwan d626cfb0b5 chore(vscode): prepare 4.1.4 release 2026-08-05 03:03:51 -07:00
Saoud Rizwan e14f354c59 chore(desktop): release v0.0.9 2026-08-05 02:29:46 -07:00
Saoud Rizwan 41ba332f0a chore(cli): release v3.0.50 2026-08-05 02:16:56 -07:00
abeatrix e2b97792b1 feat(desktop): play generated audio artifacts 2026-08-04 22:19:09 -07:00
abeatrix e4a7c19432 feat(desktop): add generated video support 2026-08-04 22:15:50 -07:00
abeatrix 589f32d13e feat(desktop): improve realtime voice sessions 2026-08-04 22:15:21 -07:00
abeatrix 879c085fdb feat(desktop): add realtime voice and UI refinements 2026-08-04 22:15:21 -07:00
725 changed files with 105564 additions and 14289 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
+38 -18
View File
@@ -1,28 +1,34 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
> 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.
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**.
## Release contract
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code".
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (universal DMG + updater artifact + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
- 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 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.
## Workflow
0. Ask which channel this release is for — **stable or beta** — if the user has not said. Everything below branches on it; never guess.
1. Gather context.
```sh
@@ -35,10 +41,15 @@ node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').versio
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
For a **beta** release, work on `desktop-experimental` (check out `origin/desktop-experimental`; merge `origin/main` into it first if it is behind — see EXPERIMENTAL.md for the conflict policy) and read the version files from that branch. The last-tag baseline is the newest `desktop-v*` tag of either channel that is an ancestor of the branch.
2. Collect release commits.
```sh
# stable (on main):
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
# beta (on desktop-experimental):
git log <last-desktop-tag>..origin/desktop-experimental --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
@@ -49,13 +60,15 @@ Flat bullet list, user-facing language. Present the draft and wait for approval
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
Stable: ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
Beta: apply the versioning rule — base = next stable version, increment `N` (`0.0.14-beta.1``0.0.14-beta.2`; after stable `0.0.14` ships, next is `0.0.15-beta.1`). Confirm the computed version with the user.
5. Update release files (on `main` for stable, on `desktop-experimental` for beta).
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
- Prepend `## X.Y.Z` (no date; `## X.Y.Z-beta.N` for beta) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
@@ -77,16 +90,20 @@ Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z" # beta: desktop-vX.Y.Z-beta.N / "Desktop vX.Y.Z-beta.N"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
The release commit must be on the channel's branch (`main` for stable, `desktop-experimental` for beta) and the tag pushed first. Dispatch from `main` for **both** channels (see the release contract for why).
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
# stable:
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z -f channel=stable -f confirm_publish=publish
# beta:
gh workflow run desktop-publish.yml --ref main -f git_tag=desktop-vX.Y.Z-beta.N -f channel=beta -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
@@ -103,21 +120,24 @@ 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`), verifies every Mach-O in the bundle carries both slices, 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, refreshes `desktop-latest/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, 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.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30 # stable
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 `desktop-vX.Y.Z` universal `.app.tar.gz` asset (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps — including older per-arch installs — pick the update up on next launch or within 2 hours.
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.
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.)
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
Report: channel, version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Publish secrets (one-time setup)
+200 -27
View File
@@ -11,6 +11,14 @@ on:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
channel:
description: "Release channel"
required: true
type: choice
options:
- stable
- beta
default: stable
permissions:
contents: read
@@ -30,6 +38,9 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
channel: ${{ steps.version.outputs.channel }}
feed: ${{ steps.version.outputs.feed }}
product: ${{ steps.version.outputs.product }}
steps:
# Companion to the presence check in `build`, and the half that actually
# establishes scope. This job declares no environment, so a signing secret
@@ -81,11 +92,41 @@ jobs:
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
# inputs.* (not github.event.inputs.*) so the declared default
# applies when an API dispatch omits the channel input entirely.
CHANNEL: ${{ inputs.channel }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
# Fail-closed channel mapping: every channel defines its tag shape,
# its ancestry source, its feed, and its product name, and an unknown
# channel dies here. The feed assignment is the load-bearing one —
# the updater comparator is a plain semver "newer than", so a beta
# manifest landing on desktop-latest would auto-update every stable
# install onto the beta. The stable regex rejects prerelease
# suffixes for the same reason.
case "$CHANNEL" in
stable)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "stable git_tag must look like desktop-vX.Y.Z with no suffix, got: ${TAG}"
exit 1
fi
ANCESTOR_REF=main
FEED=desktop-latest
PRODUCT="Cline Code"
;;
beta)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
echo "beta git_tag must look like desktop-vX.Y.Z-beta.N, got: ${TAG}"
exit 1
fi
ANCESTOR_REF=desktop-experimental
FEED=desktop-beta
PRODUCT="Cline Code Beta"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
@@ -108,14 +149,17 @@ jobs:
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
git fetch origin "+${ANCESTOR_REF}:refs/remotes/origin/${ANCESTOR_REF}"
if ! git merge-base --is-ancestor "$HEAD_COMMIT" "origin/${ANCESTOR_REF}"; then
echo "${TAG} is not reachable from origin/${ANCESTOR_REF}"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
echo "feed=${FEED}" >> "$GITHUB_OUTPUT"
echo "product=${PRODUCT}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (universal)
@@ -126,6 +170,25 @@ jobs:
# run. Defense in depth: this `if` is advisory because a dispatched branch
# runs its own copy of this file; the enforced gate is the PublishDesktop
# environment's deployment-branch policy, which must also allow only main.
#
# Beta releases do not weaken this: a beta publish is ALSO dispatched from
# main (so this gate, the branch policy, and the workflow file executed all
# stay main's) — only the checked-out tag points into desktop-experimental,
# which validate pins via the ancestry check. A workflow copy edited on
# desktop-experimental can therefore never reach the signing secrets.
#
# What dispatch-from-main does NOT protect: the checked-out tag's own
# build scripts (bun install hooks, build:sdk, Tauri's beforeBuildCommand,
# build.rs) run inside this job with the signing secrets in scope, for
# stable and beta alike. The control for that is this environment's
# required-reviewer approval — the approver is vouching for the code the
# tag points at, not just for "a release happening". Two consequences:
# desktop-experimental must keep main-grade merge controls (branch
# protection, maintainer-only pushes), and an approval should only follow
# a look at what the tag actually contains. Building betas without these
# secrets is not an option: unsigned bundles fail Gatekeeper and updater
# artifacts must be signed with the same key or beta installs cannot
# verify their updates.
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: macos-latest
@@ -223,8 +286,13 @@ jobs:
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json
# Tauri merges repeated --config flags in order, so the beta overlay
# (product name, bundle identifier, beta update feed) layers on top of
# the release overlay without duplicating it. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
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 config for the sidecar binary. Tauri's beforeBuildCommand
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
# this step and inlines these values into the binary via `--define`
@@ -258,8 +326,10 @@ jobs:
# sidecar would otherwise ship fine and only crash on the other arch.
- name: Verify bundle is a universal binary
working-directory: apps/examples/desktop-app
env:
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Cline Code.app"
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
if [ ! -d "$APP" ]; then
echo "app bundle not found at $APP"
exit 1
@@ -276,6 +346,57 @@ jobs:
esac
done
# Guardrail: the updater endpoint is compiled into the main binary as a
# string literal (tauri-build embeds the merged config via codegen), so
# assert the bundle carries this channel's feed URL and not the other
# channel's, before anything gets signed into a release. This catches a
# --config overlay that silently failed to apply: a beta bundle polling
# desktop-latest would pull its users onto stable builds, and a stable
# bundle polling desktop-beta would push betas to every stable install.
- name: Verify updater feed endpoint
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/${PRODUCT}.app"
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
# Plain grep >/dev/null rather than grep -q: -q exits at the first
# match, SIGPIPEs strings, and would read as a failed pipeline under
# pipefail.
found=0
for bin in "$APP/Contents/MacOS/"*; do
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if strings -a "$bin" | grep "$WANT" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No binary in ${APP}/Contents/MacOS 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}"
# Guardrail: assert the telemetry config actually made it into the
# compiled sidecar. Missing env on the build step (or a regression in
# the --define inlining) would otherwise ship a release with telemetry
@@ -308,25 +429,29 @@ jobs:
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta
PREFIX="${PRODUCT// /-}"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_universal.dmg"
cp "$DMG" "$OUT/${PREFIX}_${VERSION}_universal.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz.sig"
cp "$TARBALL" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/${PREFIX}_${VERSION}_universal.app.tar.gz.sig"
ls -lh "$OUT"
@@ -364,9 +489,18 @@ jobs:
- name: Get Changelog Entry
id: changelog
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
# Grab content between this release's "## <version>" header and the
# next one. Exact match, not "first section": once main and
# desktop-experimental cross-merge, stable and beta sections
# interleave and the top section may belong to the other channel.
CONTENT=$(awk -v ver="$VERSION" '$0 == "## " ver {found=1; next} /^## [0-9]/ {if (found) exit} found {print}' apps/examples/desktop-app/CHANGELOG.md)
if [ -z "$CONTENT" ]; then
echo "No '## ${VERSION}' section found in apps/examples/desktop-app/CHANGELOG.md"
exit 1
fi
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
@@ -390,8 +524,15 @@ jobs:
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
# Stable compare links skip beta tags so they read stable -> stable;
# beta compares against whatever shipped last on either channel.
if [ "$CHANNEL" = "stable" ]; then
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-beta*' "$CURRENT_TAG^" 2>/dev/null || echo "")
else
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
fi
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
@@ -402,6 +543,7 @@ jobs:
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
prerelease: ${{ needs.validate.outputs.channel == 'beta' }}
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
@@ -410,27 +552,58 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
- name: Update auto-update feed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHANNEL: ${{ needs.validate.outputs.channel }}
FEED: ${{ needs.validate.outputs.feed }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
# Belt and braces: recompute the feed from the channel and require it
# to agree with validate's output, so no single threading bug can
# point a publish at the other channel's feed. Stable installs poll
# desktop-latest and beta installs poll desktop-beta; crossing the
# streams either pushes betas to every stable user or strands beta
# users on stale builds.
case "$CHANNEL" in
stable) EXPECTED_FEED=desktop-latest ;;
beta) EXPECTED_FEED=desktop-beta ;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
if [ "$FEED" != "$EXPECTED_FEED" ]; then
echo "feed mismatch: validate says '${FEED}' but channel '${CHANNEL}' expects '${EXPECTED_FEED}'"
exit 1
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
if ! gh release view "$FEED" >/dev/null 2>&1; then
if [ "$CHANNEL" = "beta" ]; then
gh release create "$FEED" \
--title "Cline Code desktop beta (auto-update feed)" \
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
--latest=false \
--prerelease \
--target "$(git rev-parse HEAD)"
else
gh release create "$FEED" \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
fi
gh release upload "$FEED" dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
FEED: ${{ needs.validate.outputs.feed }}
run: |
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
@@ -439,12 +612,12 @@ jobs:
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
- type: "section"
text:
type: "mrkdwn"
@@ -452,4 +625,4 @@ jobs:
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — ${{ needs.validate.outputs.channel == 'beta' && 'beta channel: installs side by side with the stable app and only beta installs auto-update; stable users are unaffected' || 'installed apps auto-update on next launch' }}${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
+1
View File
@@ -92,3 +92,4 @@ apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
apps/examples/desktop-app/.cursor/settings.json
+137
View File
@@ -1,5 +1,142 @@
# Changelog
## [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.
### Added
- Let models that support it search the web during a task, with a toggle in Feature Settings to turn it on. Search calls and their results appear in the conversation and persist across reloads.
### Fixed
- Stop two Cline installations on different builds from shutting each other's Hub daemon down in a loop, which killed live sessions with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can decide to retire the other.
- Leave a Hub that is still serving sessions in place instead of replacing it mid-handshake; the swap happens once it goes idle.
- Reclaim idle plugin sandbox processes instead of leaving them running for the life of the session.
### Changed
- Refresh the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board.
## [4.1.9]
### Changed
- Use the editor's foreground color for diff block text, so diffs stay legible in themes where the previous hardcoded color washed them out.
- Switch the interface to Inter and Geist Mono.
### Fixed
- Don't discard a successfully refreshed Cline token when the old one was already past expiry, which made the first request after a long idle period fail despite valid credentials.
- Stop the legacy-task migration backlog from spamming telemetry, and record a migration outcome only once the seeded session actually persists, so a failed migration is no longer reported as a success.
- Report involuntary Cline logouts (a rejected refresh token) instead of clearing credentials silently.
### Fixed (SDK bundle only)
These land through SDK v0.0.74 and therefore apply to windows running the SDK bundle, not the legacy one.
- Fix the Claude Code provider being unusable for agentic work: it now runs its own native tools instead of receiving tool definitions it cannot bridge, anchors the session on your workspace directory, and loads `~/.claude` plus project settings so your permission rules apply.
- Reject truncated tool-call JSON instead of silently "repairing" it into wrong arguments.
- Fix strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts.
- Fix a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough.
- Report disjoint per-request token buckets instead of re-counting the whole cached conversation on every request, which inflated per-task totals roughly 5x on cache-heavy sessions.
## [4.1.8]
### Added
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
- Support Fable 5 on Vertex.
### Changed
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
### Fixed
- Respect your configured max output tokens when the compaction summarizer requests a summary.
- Remove the stale "Double-Check Completion" feature tip.
## [4.1.7]
### Added
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
- Bring back a copy button on turn-final response rows.
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
### Changed
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
### Fixed
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
- Settle the turn phase when a mode switch aborts a running turn.
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
## [4.1.6]
### Added
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
### Fixed
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
## [4.1.5]
### Added
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
### Changed
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
## [4.1.4]
### Added
- Recognize Chutes as a provider.
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
### Changed
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
### Fixed
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
- Show installed plugins under their real package names instead of all appearing as "index".
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
## [4.1.3]
### Fixed
+72
View File
@@ -1,5 +1,77 @@
# Cline CLI Changelog
## 3.0.55
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
- Added protections for an update landing under CLI 3.0.54 and earlier, whose updater restarts the Hub mid-session and then rejects every replacement, bricking a running session. The newly installed package defuses that path during install instead of leaving it to fire
- Fixed two Cline installations on different builds shutting each other's Hub daemon down in a loop, which killed every live session with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can ever decide to retire the other (from SDK v0.0.75)
- A newer build no longer replaces a Hub that is still serving sessions — it attaches to it and the swap happens on a later launch, instead of the sessions dying mid-handshake (from SDK v0.0.75)
- Removed the "outdated Hub" notice. It reported a state you cannot act on, and the toast was capped narrower than the message, so it rendered cut off before the reassuring half of the sentence at every terminal width. The prompt for a genuine build mismatch, where there is something to do, is unchanged
- Streaming assistant markdown no longer flashes back to raw text. Settled headings, links, and code stay rendered as new chunks arrive instead of the whole message being rebuilt and re-highlighted on every chunk, which also stops the transcript from jumping vertically mid-stream
- Web search calls and their results from models that run search natively now render in the transcript (from SDK v0.0.75)
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session (from SDK v0.0.75)
- `cline doctor fix` now reports honestly: processes that survived a kill are separated from ones that appeared while the fix ran, a live parent respawning a daemon is named, and a startup lock held by a running process is reported as held rather than leaked (from SDK v0.0.75)
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board (from SDK v0.0.75)
## 3.0.54
- Fixed the Claude Code provider being unusable for agentic work: the provider now runs its own native tools instead of receiving tool definitions it cannot bridge, the session is anchored on your workspace directory instead of inheriting the host's cwd, and `~/.claude` plus project settings are loaded so your permission rules apply. File edits under the workspace are auto-approved; command execution stays gated by your own Claude settings (from SDK v0.0.74)
- Fixed truncated tool-call JSON being silently "repaired" into wrong arguments — a payload with an unterminated string is now rejected rather than getting an invented terminator (from SDK v0.0.74)
- Fixed strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts (from SDK v0.0.74)
- Fixed a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough (from SDK v0.0.74)
- Managed Hub daemons now upgrade directionally: when another Cline install ships a newer Hub build, the CLI attaches to the newer daemon and prompts you to update and restart instead of the two installs repeatedly retiring each other's daemons. Yolo and sandbox sessions, which never attach to the shared Hub, are not interrupted by that prompt (from SDK v0.0.74)
- Fixed the Hub daemon logging an unhandled `hub server close failed` error and exiting non-zero whenever a client was still connected at shutdown (from SDK v0.0.74)
- Fixed per-task token totals being inflated roughly 5x on cache-heavy sessions — token telemetry now reports disjoint uncached-input, cache-read, and cache-write buckets instead of re-counting the whole cached conversation on every request (from SDK v0.0.74)
- Upgrading the CLI now retires an already-running Hub daemon and respawns it on the new code, instead of the upgraded CLI continuing to talk to a daemon executing the previous release
## 3.0.53
- Fixed the CLI reconnecting to a stale Hub daemon after an upgrade. Hub daemons now carry a runtime build fingerprint, so an upgraded CLI retires and respawns a daemon still running older code instead of attaching to it (from SDK v0.0.73)
- Fixed compaction being silently skipped on reasoning models. The summarizer no longer hardcodes a 1024-token output cap — it honors your max output tokens setting, defaults to 4096 (lowered when the model reports less), and logs a diagnostic when a summary comes back empty (from SDK v0.0.73)
- Added Fable 5 (`claude-fable-5`) to the Vertex model catalog. Pricing is intentionally omitted because Vertex bills region-dependently, so cost shows as unknown rather than wrong (from SDK v0.0.73)
- Custom Vertex model IDs are now passed through unchanged, routing Claude-style IDs to the Anthropic-on-Vertex path (from SDK v0.0.73)
## 3.0.52
- Added `cline mcp uninstall` for removing an installed MCP server
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
## 3.0.51
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
## 3.0.50
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
- Auto-approval settings are now honored over ACP
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
## 3.0.49
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
+4 -2
View File
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
Schedule agents on cron-like intervals or external events.
If `--provider` and `--model` are omitted, schedules use the last configured
provider and model. If only `--provider` is given, the schedule uses that
provider's saved model.
```sh
cline schedule create "Daily code review" \
--cron "0 9 * * MON-FRI" \
--prompt "Review PRs opened yesterday and summarize issues." \
--workspace /path/to/repo \
--provider cline \
--model openai/gpt-5.3-codex \
--timeout 3600 \
--tags automation,review
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.49",
"version": "3.0.55",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+37
View File
@@ -17,6 +17,35 @@ import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// CLI versions <= 3.0.54 restart the hub daemon after a background
// auto-update even while it is serving live sessions, killing those sessions
// mid-turn — and their build-fingerprint check then rejects every replacement
// hub, bricking the running TUI. That restart code is the *old* version's, so
// it cannot be patched here; but it bails out harmlessly when no hub
// discovery record exists, and it runs only after this install (and this
// script) completes. Setting the record aside protects any attached clients:
// a running hub keeps serving its established connections, clients that share
// its build fingerprint rebuild the record from a port probe, and the next
// fresh launch retires stale hubs regardless of the record.
function shieldRunningHubDiscovery() {
const explicitPath = process.env.CLINE_HUB_DISCOVERY_PATH?.trim();
const dataDir =
process.env.CLINE_DATA_DIR?.trim() ||
path.join(
process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline"),
"data",
);
const recordPath =
explicitPath || path.join(dataDir, "locks", "hub", "production.json");
if (!fs.existsSync(recordPath)) {
return;
}
const asidePath = `${recordPath}.superseded`;
fs.rmSync(asidePath, { force: true });
fs.renameSync(recordPath, asidePath);
console.log("Set aside hub discovery record for the updated CLI");
}
function main() {
if (os.platform() === "win32") {
// On Windows, npm creates .cmd shims from the bin field.
@@ -79,6 +108,14 @@ function main() {
console.log(`Cached cline binary at ${target}`);
}
try {
shieldRunningHubDiscovery();
} catch (error) {
// Best-effort: without the shield the worst case is the pre-3.0.55
// restart-while-busy behavior, never a broken install.
console.error(`postinstall: hub discovery shield skipped: ${error.message}`);
}
try {
main();
} catch (error) {
+5 -5
View File
@@ -30,7 +30,7 @@ import {
ProviderSettingsManager,
SessionSource,
} from "@cline/core";
import { isLikelyAuthError, type Message } from "@cline/shared";
import { isLikelyAuthError, type MessageWithMetadata } from "@cline/shared";
import { getPersistedProviderApiKey } from "../commands/auth";
import { resolveSystemPrompt } from "../runtime/prompt";
import { subscribeToAgentEvents } from "../runtime/session-events";
@@ -100,7 +100,7 @@ interface SessionState {
*/
fatalError?: Error;
/** Messages to inject into the next session manager for conversation continuity. */
pendingInitialMessages?: Message[];
pendingInitialMessages?: MessageWithMetadata[];
}
export class AcpAgent implements Agent {
@@ -240,7 +240,7 @@ export class AcpAgent implements Agent {
this.isSessionReady();
let session = this.sessions.get(params.sessionId);
let messages: Message[];
let messages: MessageWithMetadata[];
if (session?.sessionManager && session.activeSessionId) {
// The session is still live in this connection — replay its current
@@ -676,7 +676,7 @@ export class AcpAgent implements Agent {
session: SessionState,
acpSessionId: string,
options?: { resume?: boolean },
): Promise<Message[] | undefined> {
): Promise<MessageWithMetadata[] | undefined> {
if (session.sessionManager) {
return undefined;
}
@@ -695,7 +695,7 @@ export class AcpAgent implements Agent {
workspaceRoot: config.workspaceRoot,
});
let initialMessages: Message[] | undefined;
let initialMessages: MessageWithMetadata[] | undefined;
if (options?.resume) {
initialMessages = await sessionManager
.readMessages(acpSessionId)
+70
View File
@@ -225,6 +225,76 @@ describe("translateHistoricalMessage", () => {
},
]);
});
it("replays provider model tools with the ordinary ACP tool updates", () => {
expect(
translateHistoricalMessage({
role: "assistant",
content: "Found it",
metadata: {
modelToolActivities: [
{
toolCallId: "search-1",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun release" },
output: "Bun 1.3.14",
},
],
},
} as Parameters<typeof translateHistoricalMessage>[0]),
).toEqual([
{
sessionUpdate: "tool_call",
toolCallId: "search-1",
title: expect.any(String),
kind: "search",
status: "pending",
rawInput: { query: "latest Bun release" },
},
{
sessionUpdate: "tool_call_update",
toolCallId: "search-1",
status: "completed",
rawOutput: "Bun 1.3.14",
},
{
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Found it" },
},
]);
});
it("preserves structured native web-search results", () => {
const nativeResult = {
type: "web_search_result",
url: "https://bun.sh/blog/bun-v1.3.14",
title: "Bun v1.3.14",
pageAge: "2026-08-12",
encryptedContent: "encrypted",
};
const updates = translateHistoricalMessage({
role: "assistant",
content: "Found it",
metadata: {
modelToolActivities: [
{
toolCallId: "search-native",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun" },
output: [nativeResult],
},
],
},
} as Parameters<typeof translateHistoricalMessage>[0]);
expect(updates[1]).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "search-native",
rawOutput: JSON.stringify(nativeResult),
});
});
});
describe("replaySessionHistory", () => {
+46 -4
View File
@@ -2,10 +2,11 @@ import type {
AgentSideConnection,
SessionUpdate,
} from "@agentclientprotocol/sdk";
import { projectSessionMessagesForDisplay } from "@cline/core";
import {
type ContentBlock,
formatDisplayUserInput,
type Message,
type MessageWithMetadata,
type ToolResultContent,
} from "@cline/shared";
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
@@ -29,7 +30,7 @@ function isSyntheticUserText(text: string): boolean {
export async function replaySessionHistory(
conn: AgentSideConnection,
sessionId: string,
messages: Message[],
messages: MessageWithMetadata[],
): Promise<void> {
for (const message of messages) {
for (const update of translateHistoricalMessage(message)) {
@@ -38,7 +39,17 @@ export async function replaySessionHistory(
}
}
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
export function translateHistoricalMessage(
message: MessageWithMetadata,
): SessionUpdate[] {
return projectSessionMessagesForDisplay([message]).flatMap(({ message }) =>
translateProjectedHistoricalMessage(message),
);
}
function translateProjectedHistoricalMessage(
message: MessageWithMetadata,
): SessionUpdate[] {
const blocks: ContentBlock[] =
typeof message.content === "string"
? [{ type: "text", text: message.content }]
@@ -92,6 +103,31 @@ export function translateHistoricalMessage(message: Message): SessionUpdate[] {
);
break;
}
case "media": {
const media = block.media;
if (media.modality === "image" && media.source.type === "base64") {
updates.push({
sessionUpdate:
message.role === "user"
? "user_message_chunk"
: "agent_message_chunk",
content: {
type: "image",
data: media.source.data,
mimeType: media.mediaType,
},
});
} else {
updates.push({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `[Generated ${media.modality}: ${media.mediaType}]`,
},
});
}
break;
}
case "tool_use": {
updates.push({
sessionUpdate: "tool_call",
@@ -133,8 +169,14 @@ function flattenToolResultContent(
return part.text;
case "file":
return part.content;
default:
case "image":
return "[image]";
default:
try {
return JSON.stringify(part);
} catch {
return String(part);
}
}
})
.join("\n");
+34
View File
@@ -0,0 +1,34 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { forwardAgentEvent } from "./session-updates";
describe("forwardAgentEvent", () => {
it("forwards generated images as ACP agent message chunks", () => {
const sessionUpdate = vi.fn().mockResolvedValue(undefined);
const connection = { sessionUpdate } as unknown as AgentSideConnection;
forwardAgentEvent(connection, "session-1", {
type: "content_end",
contentType: "media",
media: {
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: { type: "base64", data: "aGVsbG8=" },
},
} as AgentEvent);
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "session-1",
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "image",
data: "aGVsbG8=",
mimeType: "image/png",
},
},
});
});
});
+25
View File
@@ -4,6 +4,7 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import type { GeneratedMedia } from "@cline/shared";
import { getErrorMessage } from "@cline/shared";
import { buildToolTitle, mapToolKind } from "./tool-utils";
@@ -100,6 +101,7 @@ function translateContentEnd(
output?: unknown;
error?: string;
durationMs?: number;
media?: GeneratedMedia;
};
switch (e.contentType) {
@@ -109,6 +111,29 @@ function translateContentEnd(
case "reasoning":
// Reasoning was already streamed via content_start chunks; don't re-send.
return [];
case "media":
if (!e.media) return [];
if (e.media.modality !== "image" || e.media.source.type !== "base64") {
return [
{
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `[Generated ${e.media.modality}: ${e.media.mediaType}]`,
},
},
];
}
return [
{
sessionUpdate: "agent_message_chunk",
content: {
type: "image",
data: e.media.source.data,
mimeType: e.media.mediaType,
},
},
];
case "tool": {
const toolCallId = e.toolCallId ?? "unknown";
const failed = !!e.error;
+1
View File
@@ -17,6 +17,7 @@ const TOOL_KIND_MAP: Record<string, ToolKind> = {
WebFetch: "fetch",
fetch_web_content: "fetch",
WebSearch: "search",
web_search: "search",
Agent: "think",
spawn_agent: "think",
NotebookEdit: "edit",
+65
View File
@@ -767,6 +767,71 @@ Break work into clear steps.`,
);
});
it("routes mcp uninstall and its rm alias", () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
tempDirs.push(tempRoot);
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
const writeSettings = () => {
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: { transport: { type: "stdio", command: "node" } },
remote: {
transport: {
type: "streamableHttp",
url: "https://mcp.example.com",
},
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
};
const readServers = () =>
(
JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, unknown>;
}
).mcpServers ?? {};
writeSettings();
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(uninstallResult.status).toBe(0);
expect(asText(uninstallResult.stdout)).toContain(
"Uninstalled MCP server docs.",
);
expect(Object.keys(readServers())).toEqual(["remote"]);
writeSettings();
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(aliasResult.status).toBe(0);
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
name: "remote",
status: "uninstalled",
});
expect(Object.keys(readServers())).toEqual(["docs"]);
writeSettings();
const missingResult = runCli(["mcp", "remove", "missing"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(missingResult.status).toBe(1);
expect(asText(missingResult.stderr)).toContain(
'MCP server "missing" is not installed.',
);
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
+101
View File
@@ -18,6 +18,7 @@ const {
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockReadSupersededHubDiscovery,
mockProbeHubServer,
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
@@ -48,6 +49,7 @@ const {
),
})),
mockReadHubDiscovery: vi.fn(),
mockReadSupersededHubDiscovery: vi.fn(() => undefined as unknown),
mockProbeHubServer: vi.fn(),
mockClearHubDiscovery: vi.fn(),
mockStopLocalHubServerGracefully: vi.fn(async () => false),
@@ -73,6 +75,7 @@ vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
readSupersededHubDiscovery: mockReadSupersededHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
@@ -186,6 +189,65 @@ describe("runDoctorCommand", () => {
);
});
it("sees the hub through the set-aside record during the shielded update window", async () => {
const cwd = "/workspace";
// The npm postinstall shield renamed the discovery record aside; the
// hub is alive and serving an old client's sessions.
mockReadHubDiscovery.mockResolvedValue(undefined);
mockReadSupersededHubDiscovery.mockReturnValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "shielded-token",
pid: 50174,
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
});
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
if (command === "lsof") {
return { status: 0, stdout: "50174\n" };
}
if (
command === "pgrep" &&
Array.isArray(args) &&
args[2] === "--cline-hub-daemon"
) {
return {
status: 0,
stdout: "50174 /usr/local/bin/cline --cline-hub-daemon\n",
};
}
return { status: 1, stdout: "" };
});
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(mockProbeHubServer).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
{
authToken: "shielded-token",
},
);
// Without the fallback the live daemon reads as stale and doctor's
// advice (\"run doctor fix\") would kill the sessions the shield exists
// to protect.
expect(JSON.parse(output[0] || "")).toMatchObject({
hubHealthy: true,
staleHubPids: [],
});
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
@@ -624,3 +686,42 @@ describe("doctor supervision reporting", () => {
);
});
});
describe("describeProcessesStartedDuringFix", () => {
const { describeProcessesStartedDuringFix } = __test__;
const liveParents = new Map([
[100, 10],
[200, 20],
]);
const resolveLiveParent = (pid: number) => liveParents.get(pid);
it("says nothing when no process started during the fix", () => {
expect(
describeProcessesStartedDuringFix([], resolveLiveParent),
).toBeUndefined();
});
it("blames the parent only when every process has a live one", () => {
expect(
describeProcessesStartedDuringFix([100, 200], resolveLiveParent),
).toBe(
"\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.",
);
});
// A process can start on its own mid-repair - a user opening a new session,
// say - and telling them to go kill an unrelated parent would be wrong.
it("states the facts when no process has a live parent", () => {
expect(describeProcessesStartedDuringFix([777], resolveLiveParent)).toBe(
"\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.",
);
});
it("separates respawns from independent starts in a mixed batch", () => {
expect(
describeProcessesStartedDuringFix([100, 777], resolveLiveParent),
).toBe(
"\nSome of these were respawned by a live parent (100); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.",
);
});
});
+149 -14
View File
@@ -7,6 +7,7 @@ import {
listActiveConnectors,
probeHubServer,
readHubDiscovery,
readSupersededHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
@@ -384,6 +385,12 @@ async function clearHubStartupArtifacts(
await clearHubDiscovery(owner.discoveryPath);
clearedDiscovery = 1;
}
if (options?.clearDiscovery) {
// The set-aside copy the npm postinstall shield leaves behind. Once
// doctor has deliberately stopped everything, keeping it risks a much
// later launch SIGTERMing whatever process has recycled its pid.
clearPathIfExists(`${owner.discoveryPath}.superseded`);
}
return {
startupLocks: clearedStartupLocks,
discovery: clearedDiscovery,
@@ -411,7 +418,25 @@ function resolveCliHubOwnerContext() {
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
// The npm postinstall shield sets the discovery record aside (see
// readSupersededHubDiscovery) while an older hub finishes serving its
// sessions. Without the fallback, doctor cannot see that hub, classifies
// the live daemon as stale, and its "run doctor fix" advice kills the
// sessions the shield exists to protect.
const recorded = await readHubDiscovery(owner.discoveryPath);
// The set-aside record carries only url/token/pid; widen so the two
// sources read uniformly below.
const discovery:
| {
url?: string;
authToken?: string;
pid?: number;
port?: number;
coreVersion?: string;
}
| undefined = recorded?.url
? recorded
: readSupersededHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
: undefined;
@@ -449,6 +474,80 @@ function formatPidList(label: string, pids: number[]): string {
return `${label} ${c.dim}${pids.join(", ")}${c.reset}`;
}
function readParentPid(pid: number): number | undefined {
try {
const output = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
encoding: "utf8",
});
const parsed = Number(output.stdout?.trim());
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
} catch {
return undefined;
}
}
function liveParentPid(pid: number): number | undefined {
const parent = readParentPid(pid);
return parent && isProcessRunning(parent) ? parent : undefined;
}
/**
* A daemon whose parent is still running was almost certainly just spawned by
* that parent, and killing it only invites the parent to spawn another. Naming
* the parent points at the process the user actually has to stop.
*/
function formatDaemonPidList(label: string, pids: number[]): string {
if (pids.length === 0) {
return `${label} ${c.dim}0${c.reset}`;
}
const described = pids.map((pid) => {
const parent = liveParentPid(pid);
return parent ? `${pid} (spawned by ${parent})` : String(pid);
});
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
}
/**
* Advice for processes first seen during the fix. Only a process with a live
* parent is known to have been respawned by it; anything else may have been
* started independently (a user opening a new session mid-repair), so it gets
* a statement of fact rather than an instruction to go kill something.
*/
export function describeProcessesStartedDuringFix(
pids: number[],
resolveLiveParent: (pid: number) => number | undefined,
): string | undefined {
if (pids.length === 0) {
return undefined;
}
const respawned = pids.filter((pid) => resolveLiveParent(pid) !== undefined);
if (respawned.length === 0) {
return "\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.";
}
if (respawned.length === pids.length) {
return "\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.";
}
return `\nSome of these were respawned by a live parent (${respawned.join(", ")}); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.`;
}
function formatStartupLockList(
label: string,
locks: StartupArtifact[],
): string {
const described = locks
.map((lock) => {
if (lock.pid === undefined) {
return "unreadable";
}
return lock.stale ? `${lock.pid} (stale)` : `${lock.pid} (held, live)`;
})
.filter((entry) => entry.length > 0);
if (described.length === 0) {
return `${label} ${c.dim}0${c.reset}`;
}
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
}
function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
const pieces = [
record.timestamp ?? "unknown-time",
@@ -531,6 +630,7 @@ function killPids(pids: number[]): number {
export const __test__ = {
decideForeignContainer,
CONTAINER_CGROUP_PATTERN,
describeProcessesStartedDuringFix,
formatSupervisedConnector,
};
@@ -556,13 +656,8 @@ export async function runDoctorCommand(
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
),
);
writeln(formatDaemonPidList("stale hub daemons", before.staleHubPids));
writeln(formatStartupLockList("hub startup locks", before.hubStartupLocks));
writeln(formatPidList("cli processes", before.staleCliPids));
writeln(formatPidList("sidecar processes", before.staleSidecarPids));
if (before.activeConnectors.length === 0) {
@@ -673,16 +768,56 @@ export async function runDoctorCommand(
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
// "Remaining" means a process this run tried to kill and failed to. A
// re-scan alone cannot tell that apart from a process that appeared while
// the fix was running, and reporting the two together reads as a failure
// to kill something that was never targeted.
const survived = (targets: number[], remaining: number[]) =>
remaining.filter((pid) => targets.includes(pid));
const appeared = (targets: number[], remaining: number[]) =>
remaining.filter((pid) => !targets.includes(pid));
writeln(
formatPidList(
"remaining hub startup locks",
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
"remaining hub listeners",
survived(refreshedAfterGracefulStop.listeningPids, after.listeningPids),
),
);
writeln(formatPidList("remaining cli processes", after.staleCliPids));
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
writeln(
formatDaemonPidList(
"remaining stale hub daemons",
survived(staleHubTargets, after.staleHubPids),
),
);
writeln(
formatStartupLockList("remaining hub startup locks", after.hubStartupLocks),
);
writeln(
formatPidList(
"remaining cli processes",
survived(staleCliTargets, after.staleCliPids),
),
);
writeln(
formatPidList(
"remaining sidecar processes",
survived(staleSidecarTargets, after.staleSidecarPids),
),
);
const spawnedDuringFix = [
...appeared(staleHubTargets, after.staleHubPids),
...appeared(staleCliTargets, after.staleCliPids),
...appeared(staleSidecarTargets, after.staleSidecarPids),
];
if (spawnedDuringFix.length > 0) {
writeln(formatDaemonPidList("started during fix", spawnedDuringFix));
const advice = describeProcessesStartedDuringFix(
spawnedDuringFix,
liveParentPid,
);
if (advice) {
io.writeln(advice);
}
}
return 0;
}
+146 -1
View File
@@ -1,9 +1,13 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
runMcpUninstallCommand,
} from "./mcp";
vi.mock("@cline/core", async (importOriginal) => {
@@ -56,6 +60,19 @@ describe("mcp install command", () => {
});
});
it("shows mcp-remote marketplace entries as native remote servers", () => {
expect(
buildMcpInstallDefaults({
name: "linear",
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
}),
).toEqual({
name: "linear",
type: "streamableHttp",
url: "https://mcp.linear.app/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
@@ -269,3 +286,131 @@ describe("mcp install command", () => {
});
});
});
describe("mcp uninstall command", () => {
let root = "";
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeSettings(): string {
const settingsPath = join(root, "cline_mcp_settings.json");
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
},
},
keep: {
transport: { type: "stdio", command: "node" },
disabled: true,
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
return settingsPath;
}
function readSettings(settingsPath: string): Record<string, unknown> & {
mcpServers?: Record<string, unknown>;
} {
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
string,
unknown
> & { mcpServers?: Record<string, unknown> };
}
it("uninstalls the requested server and reports success", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
const written = readSettings(settingsPath);
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
expect(written.mcpServers?.keep).toEqual({
transport: { type: "stdio", command: "node" },
disabled: true,
});
expect(written.customTopLevelKey).toBe(true);
});
it("prints uninstall JSON with --json", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
name: "docs",
status: "uninstalled",
});
expect(writeln).toHaveBeenCalledTimes(1);
});
it("reports an error and leaves settings intact for an unknown server", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "missing",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
'MCP server "missing" is not installed.',
);
expect(writeln).not.toHaveBeenCalled();
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
it("rejects a blank name without rewriting settings", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: " ",
settingsPath,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
});
+52 -58
View File
@@ -1,12 +1,16 @@
import {
buildMcpInstallTransport as buildCoreMcpInstallTransport,
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
type McpUninstallOptions as CoreMcpUninstallOptions,
type McpUninstallResult as CoreMcpUninstallResult,
uninstallMcpServer,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
export interface McpCommandIo {
writeln?: (text: string) => void;
@@ -28,39 +32,6 @@ export interface McpInstallDirectResult {
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpServerTransportConfig["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertValidUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid MCP server URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid MCP server URL: ${url} (only http and https are supported)`,
);
}
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
@@ -73,36 +44,20 @@ export function buildMcpInstallDefaults(options: {
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const targetArgs = options.targetArgs ?? [];
if (type === "stdio") {
if (targetArgs.length === 0) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
const { name, transport } = buildCoreMcpInstallTransport(options);
if (transport.type === "stdio") {
return {
name,
type,
command: targetArgs.map(quoteCommandArg).join(" "),
type: transport.type,
command: [transport.command, ...(transport.args ?? [])]
.map(quoteCommandArg)
.join(" "),
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
type,
url,
type: transport.type,
url: transport.url,
};
}
@@ -158,3 +113,42 @@ export async function runMcpInstallCommand(
return 1;
}
}
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
io?: McpCommandIo;
json?: boolean;
}
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
export function uninstallMcpServerDirect(
options: McpUninstallOptions,
): McpUninstallDirectResult {
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
return {
name: result.name,
status: result.status,
};
}
export async function runMcpUninstallCommand(
options: McpUninstallOptions,
): Promise<number> {
try {
const name = options.name?.trim() ?? "";
if (!name) {
throw new Error("MCP server name is required");
}
const result = uninstallMcpServerDirect({ ...options, name });
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
}
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
+177 -4
View File
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", () => ({
sendHubCommand: mockSendHubCommand,
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
providers: {} as Record<string, { provider?: string; model?: string }>,
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
sendHubCommand: mockSendHubCommand,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
}
getProviderSettings(providerId: string) {
return mockProviderSettings.providers[providerId];
}
},
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
parseHubEndpointOverride: (rawAddress: string | undefined) => {
@@ -47,6 +65,8 @@ async function runScheduleCommand(
describe("runScheduleCommand list output", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it('prints "No schedules found." for empty non-json list output', async () => {
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
});
});
describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand create", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("uses the last used provider and model when both flags are omitted", async () => {
mockProviderSettings.lastUsed = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("uses an explicit provider with that provider's configured model", async () => {
mockProviderSettings.lastUsed = {
provider: "cline",
model: "openai/gpt-5.3-codex",
};
mockProviderSettings.providers.anthropic = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("fails when an explicit provider has no configured model and no model flag", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(1);
expect(errors).toEqual([
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
]);
expect(mockSendHubCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("preserves exported modelSelection providerId/modelId values", async () => {
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
describe("runScheduleCommand export", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("writes JSON content to the --to file path", async () => {
+9 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -19,6 +18,7 @@ import {
registerScheduleImportCommand,
registerScheduleUpdateCommand,
} from "./import-export";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
export function registerScheduleCommands(
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--model <model>", "Model to use")
.option("--provider <id>", "Provider ID")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
.option("--timeout <seconds>", "Timeout in seconds");
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
parseJsonObjectFlag(opts.metadataJson),
opts,
);
const modelSelection = resolveScheduleModelSelection({
provider: opts.provider,
model: opts.model,
});
const created = await client.createSchedule({
name,
cronPattern: opts.cron,
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
provider: modelSelection.provider,
model: modelSelection.model,
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
+15 -14
View File
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -18,8 +17,13 @@ import {
resolveAddress,
toPositiveInt,
} from "./common";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
provider: string;
model: string;
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
!Array.isArray(parsed.modelSelection)
? (parsed.modelSelection as Record<string, unknown>)
: undefined;
const provider = String(
modelSelection?.providerId ??
parsed.providerId ??
parsed.provider ??
"cline",
).trim();
const model = String(
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
return resolveScheduleModelSelection({
provider:
stringValue(modelSelection?.providerId) ??
stringValue(parsed.providerId) ??
stringValue(parsed.provider),
model:
stringValue(modelSelection?.modelId) ??
stringValue(parsed.modelId) ??
stringValue(parsed.model),
});
}
export function registerScheduleExportCommand(
@@ -0,0 +1,52 @@
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
interface ProviderSettingsReader {
getLastUsedProviderSettings(): ProviderSettings | undefined;
getProviderSettings(providerId: string): ProviderSettings | undefined;
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveScheduleModelSelection(
options: {
provider?: string;
model?: string;
},
providerSettingsManager?: ProviderSettingsReader,
): { provider: string; model: string } {
const explicitProvider = trimToUndefined(options.provider);
const explicitModel = trimToUndefined(options.model);
if (explicitProvider && explicitModel) {
return { provider: explicitProvider, model: explicitModel };
}
const manager = providerSettingsManager ?? new ProviderSettingsManager();
const lastUsedSettings = manager.getLastUsedProviderSettings();
const provider =
explicitProvider ??
trimToUndefined(lastUsedSettings?.provider) ??
DEFAULT_SCHEDULE_PROVIDER;
const selectedProviderSettings = explicitProvider
? manager.getProviderSettings(provider)
: lastUsedSettings;
const model =
explicitModel ??
trimToUndefined(selectedProviderSettings?.model) ??
(provider === DEFAULT_SCHEDULE_PROVIDER
? CLINE_DEFAULT_MODEL_ID
: undefined);
if (!model) {
throw new Error(
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
);
}
return { provider, model };
}
+86 -68
View File
@@ -1,12 +1,10 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
mockEnsureCliHubServer: vi.fn(),
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
@@ -18,14 +16,10 @@ vi.mock("node:child_process", async (importOriginal) => {
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
}));
import {
applyDeferredUpdate,
autoUpdateOnStartup,
checkForUpdates,
ensureCliHubServerAfterUpdate,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
@@ -42,14 +36,6 @@ const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createChildProcessThatCloses(exitCode: number): ChildProcess {
const child = new EventEmitter();
queueMicrotask(() => {
child.emit("close", exitCode);
});
return child as ChildProcess;
}
function createFile(path: string): string {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "");
@@ -265,68 +251,100 @@ describe("hub restart owner selection", () => {
});
});
describe("post-update hub launch", () => {
describe("deferred auto update", () => {
afterEach(() => {
mockEnsureCliHubServer.mockReset();
mockSpawn.mockReset();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("uses the freshly installed wrapper instead of the current executable", async () => {
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
const env = {
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
CLINE_NO_AUTO_UPDATE: "0",
};
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
expect(mockSpawn).toHaveBeenCalledWith(
"/opt/cline/lib/node_modules/cline/bin/cline",
["hub", "ensure"],
{
cwd: "/workspace/project",
env: {
...env,
CLINE_NO_AUTO_UPDATE: "1",
},
stdio: "ignore",
windowsHide: true,
},
);
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
});
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
await ensureCliHubServerAfterUpdate(
"C:\\workspace\\project",
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
"win32",
);
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
"C:\\workspace\\project",
);
it("does nothing when no update was recorded", async () => {
expect(await applyDeferredUpdate(undefined)).toBe("none");
expect(mockSpawn).not.toHaveBeenCalled();
});
it("surfaces a failure from the freshly installed CLI", async () => {
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
it("starts the detached install when no hub is discoverable", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
tempDirs.push(root);
process.env.CLINE_BUILD_ENV = "production";
process.env.CLINE_HUB_DISCOVERY_PATH = join(root, "production.json");
const unref = vi.fn();
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
await expect(
ensureCliHubServerAfterUpdate(
"/workspace/project",
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
"linux",
),
).rejects.toThrow(
"freshly installed Cline failed to start the hub (exit code 1)",
const outcome = await applyDeferredUpdate({
command: "npm update -g cline --tag latest --min-release-age=0",
});
expect(outcome).toBe("started");
expect(mockSpawn).toHaveBeenCalledWith(
"npm update -g cline --tag latest --min-release-age=0",
expect.objectContaining({
detached: true,
shell: true,
stdio: "ignore",
}),
);
expect(unref).toHaveBeenCalled();
});
it("defers while another cli client is attached to the hub", async () => {
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
tempDirs.push(root);
const discoveryPath = join(root, "production.json");
process.env.CLINE_BUILD_ENV = "production";
process.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
const {
createLocalHubScheduleRuntimeHandlers,
NodeHubClient,
startHubWebSocketServer,
} = await import("@cline/core");
const server = await startHubWebSocketServer({
host: "127.0.0.1",
port: 0,
owner: { ownerId: "update-test", discoveryPath },
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const cliClient = new NodeHubClient({
url: server.url,
authToken: server.authToken,
clientType: "cli",
displayName: "fake attached cli",
});
try {
await cliClient.command("client.list", {});
expect(await applyDeferredUpdate({ command: "echo update" })).toBe(
"deferred",
);
expect(mockSpawn).not.toHaveBeenCalled();
await cliClient.dispose();
const unref = vi.fn();
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
// The hub unregisters the client when its socket closes; poll
// briefly rather than assuming the close is processed instantly.
let outcome = "deferred";
const deadline = Date.now() + 3_000;
while (outcome === "deferred" && Date.now() < deadline) {
outcome = await applyDeferredUpdate({ command: "echo update" });
}
expect(outcome).toBe("started");
} finally {
await cliClient.dispose().catch(() => undefined);
await server.close();
}
}, 15_000);
});
describe("withMinimumReleaseAgeBypass", () => {
+137 -134
View File
@@ -1,17 +1,14 @@
import { type ChildProcess, spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import {
clearHubDiscovery,
isAutoUpdateEnabledGlobally,
probeHubServer,
NodeHubClient,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
import {
getInstalledKanbanVersion,
@@ -237,50 +234,6 @@ async function runKanbanUpdate(
return waitForProcessExit(updateProcess);
}
/**
* Start the hub through the freshly installed CLI after a self-update.
*
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
* remove that cached executable while replacing the package and then block the
* postinstall script that recreates it. The current process keeps running from
* the unlinked executable, but process.execPath is no longer spawnable. Going
* back through the wrapper makes it resolve the newly installed platform
* binary instead.
*
* Windows does not create the bin/.cline cache, and development builds do not
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
* ensure path.
*/
export async function ensureCliHubServerAfterUpdate(
workspaceRoot: string,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<void> {
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
if (!wrapperPath || platform === "win32") {
await ensureCliHubServer(workspaceRoot);
return;
}
const child = spawn(wrapperPath, ["hub", "ensure"], {
cwd: workspaceRoot,
env: {
...env,
// The fresh CLI only exists to start the hub. Do not let it launch
// another background update check while this update is finishing.
CLINE_NO_AUTO_UPDATE: "1",
},
stdio: "ignore",
windowsHide: true,
});
const exitCode = await waitForProcessExit(child);
if (exitCode !== 0) {
throw new Error(
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
);
}
}
function formatUpdateSummaryTargets(targets: string[]): string {
if (targets.length === 0) {
return "";
@@ -318,86 +271,40 @@ export function getPreferredKanbanInstaller(
);
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function waitForHubToStop(
url: string,
authToken: string | undefined,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const check = await probeHubServer(url, { authToken }).catch(
() => undefined,
);
if (!check?.url) return true;
await sleep(100);
}
return false;
}
let pendingAutoUpdate: ManualUpdateCommand | undefined;
let pendingAutoUpdateCheck: Promise<void> | undefined;
/**
* Restart the hub server if one is currently running.
* Gracefully asks the running hub process to stop, falls back to process signals,
* clears stale discovery, then re-ensures a fresh instance is spawned.
*/
async function restartHubServerIfRunning(): Promise<void> {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const health = discovery?.url
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
}).catch(() => undefined)
: undefined;
if (!discovery || !health?.url) return;
// How long the exit sequence will wait for a still-in-flight startup version
// check before giving up on it. Long enough for a typical registry response,
// short enough that one-shot commands do not feel it.
const UPDATE_CHECK_EXIT_GRACE_MS = 250;
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
} catch {
// best-effort
}
}
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
if (!stopped && pid) {
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
}
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
// Re-ensure a fresh hub instance is spawned.
try {
await ensureCliHubServerAfterUpdate(process.cwd());
writeln(`${c.green}${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
} catch (err) {
writeErr(
`[hub] failed to restart server: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// Hard cap on the exit-time hub query. The hub client's default connect and
// command timeouts add up to tens of seconds against a wedged hub, and this
// runs while the user is waiting for their shell prompt back.
const CLIENT_COUNT_EXIT_TIMEOUT_MS = 3_000;
/**
* Non-blocking auto-update check for CLI startup.
* Spawns a detached install process if a newer version is available.
*
* Deliberately does NOT install right away: replacing the npm package while
* cline processes are running swaps the binary under them their respawn
* paths break on the new build fingerprint and historically also restarted
* the hub daemon out from under live sessions. The check only records that an
* update is available; the CLI entrypoint calls applyDeferredUpdate() from
* its exit sequence (an explicit process.exit() follows, so a beforeExit hook
* would never fire), and the install runs only when no other CLI is attached
* to the hub at that point nothing is running that the swap could hurt.
* The next launch picks up the new binary and a fresh hub.
*
* Skipped for npx, dev, unknown installs. Disable with CLINE_NO_AUTO_UPDATE=1.
*/
export function autoUpdateOnStartup(): void {
@@ -409,35 +316,129 @@ export function autoUpdateOnStartup(): void {
getInstallationInfo(version);
if (!updateCommand) return;
void (async () => {
pendingAutoUpdateCheck = (async () => {
try {
const latest = await getLatestVersion(packageName, version);
if (!latest || compareVersions(version, latest) >= 0) return;
const autoUpdateCommand = withMinimumReleaseAgeBypass(
pendingAutoUpdate = withMinimumReleaseAgeBypass(
updateCommand,
packageManager,
);
const child = spawn(autoUpdateCommand.command, {
shell: true,
detached: true,
stdio: "ignore",
env: autoUpdateCommand.env
? { ...process.env, ...autoUpdateCommand.env }
: process.env,
// Prevent a console window from flashing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
const exitCode = await waitForProcessExit(child);
if (exitCode === 0) {
await restartHubServerIfRunning();
}
} catch {
// Best-effort, silently ignore
}
})();
}
/**
* True when a hub is reachable and another cli* client is attached to it.
* Only cli* clients run the npm-installed binary desktop sidecars and
* connectors ship their own so only they make the swap unsafe. This runs
* after the entrypoint's disposeAll(), so this process's own registrations
* are closed and any cli client still listed belongs to another process. Errors count as attached:
* never install unless the hub positively confirms nothing would be hurt.
*/
async function otherCliClientsAttached(): Promise<boolean> {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
if (!discovery?.url) {
return false;
}
const client = new NodeHubClient({
url: discovery.url,
authToken: discovery.authToken,
clientType: "cli-update-check",
displayName: "cline update check",
});
try {
const reply = await client.command("client.list", {}, undefined, {
timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS,
});
const clients =
(reply.payload as { clients?: Array<{ clientType?: unknown }> })
.clients ?? [];
if (
clients.some(
(entry) =>
typeof entry?.clientType === "string" &&
entry.clientType.startsWith("cli") &&
entry.clientType !== "cli-update-check",
)
) {
return true;
}
// A TUI's registration can be lost in transport churn while its session
// connection survives (observed in review), so an empty client list is
// not proof of safety. Cross-check for sessions somebody is attached to.
// Participants, not session status: finished sessions can linger idle
// forever and must not pin updates, and participant-less scheduled runs
// live in the hub process, which a binary swap does not touch.
const sessions = await client.command(
"session.list",
{ limit: 500 },
undefined,
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
);
const sessionRecords =
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
.sessions ?? [];
return sessionRecords.some(
(session) =>
Array.isArray(session?.participants) && session.participants.length > 0,
);
} finally {
await client.dispose().catch(() => undefined);
}
}
/**
* Spawns the recorded update install, detached, if no other CLI would be
* affected by the package swap. Fire-and-forget: the install outlives this
* process and its postinstall never blocks an exit.
*/
export async function applyDeferredUpdate(
pending?: ManualUpdateCommand,
): Promise<"none" | "deferred" | "started"> {
if (!pending) {
// Short-lived commands can reach exit before the startup version check
// resolves; give it a brief grace so one-shot-only usage still updates.
if (pendingAutoUpdateCheck) {
await Promise.race([
pendingAutoUpdateCheck,
sleep(UPDATE_CHECK_EXIT_GRACE_MS),
]);
}
pending = pendingAutoUpdate;
}
if (!pending) {
return "none";
}
// The whole query is bounded: the user is waiting on their prompt, and a
// wedged hub must not turn a finished command into a hung one. A timeout
// counts as "attached" — never install unless the hub positively confirms.
const attached = await Promise.race([
otherCliClientsAttached(),
sleep(CLIENT_COUNT_EXIT_TIMEOUT_MS).then(() => true),
]).catch(() => true);
if (attached) {
return "deferred";
}
pendingAutoUpdate = undefined;
const child = spawn(pending.command, {
shell: true,
detached: true,
stdio: "ignore",
env: pending.env ? { ...process.env, ...pending.env } : process.env,
// Prevent a console window from flashing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
child.unref();
return "started";
}
export interface CheckForUpdatesOptions {
verbose?: boolean;
includeKanban?: boolean;
@@ -554,7 +555,9 @@ export async function checkForUpdates(
const exitCode = await runCliUpdate(manualUpdateCommand);
if (exitCode === 0) {
installedUpdates.push(`${packageName}@${latestVersion}`);
await restartHubServerIfRunning();
writeln(
`${c.dim}The update takes effect the next time cline starts.${c.reset}`,
);
} else {
writeErr(
`Cline update failed (exit code ${exitCode}). Try running: ${manualUpdateCommand.command}`,
+72
View File
@@ -7,6 +7,7 @@ import type {
UserInstructionConfigService,
} from "@cline/core";
import { isUnusableSessionError } from "@cline/core";
import type { GeneratedMedia } from "@cline/shared";
import type { SentMessage, Thread } from "chat";
import type { CliLoggerAdapter } from "../logging/adapter";
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
@@ -125,6 +126,7 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
stream: AsyncIterable<string>,
postFinalReply?: (text: string) => Promise<void>,
resolveFallbackText?: () => Promise<string | undefined>,
hasNonTextReply?: () => boolean,
): Promise<void> {
if (transport !== "telegram" && !postFinalReply && !resolveFallbackText) {
await thread.post(stream);
@@ -135,6 +137,9 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
for await (const chunk of stream) {
text += chunk;
}
if (!text.trim() && hasNonTextReply?.()) {
return;
}
if (!text.trim()) {
text = (await resolveFallbackText?.())?.trim() || "";
}
@@ -151,6 +156,67 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
await postConnectorText(thread, transport, text);
}
const CONNECTOR_MEDIA_EXTENSIONS: Readonly<Record<string, string>> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"video/mp4": "mp4",
"video/webm": "webm",
};
async function postConnectorGeneratedMedia<TState extends ConnectorThreadState>(
thread: Thread<TState>,
mediaItems: readonly GeneratedMedia[],
): Promise<void> {
if (mediaItems.length === 0) {
return;
}
const files: Array<{ data: Buffer; filename: string; mimeType: string }> = [];
const references: string[] = [];
for (const [index, media] of mediaItems.entries()) {
const label = media.name?.trim() || `Generated ${media.modality}`;
switch (media.source.type) {
case "base64": {
const data = Buffer.from(media.source.data, "base64");
if (data.byteLength === 0) {
references.push(
`${label} (${media.mediaType}) could not be attached.`,
);
break;
}
const extension =
CONNECTOR_MEDIA_EXTENSIONS[media.mediaType.toLowerCase()] ?? "bin";
const suppliedName = media.name ? basename(media.name) : "";
files.push({
data,
filename: suppliedName || `generated-${index + 1}.${extension}`,
mimeType: media.mediaType,
});
break;
}
case "url":
references.push(`[${label}](${media.source.url})`);
break;
case "artifact":
references.push(`${label}: artifact ${media.source.artifactId}`);
break;
}
}
if (files.length === 0 && references.length === 0) {
return;
}
await thread.post({
markdown: references.length > 0 ? references.join("\n") : "Generated media",
...(files.length > 0 ? { files } : {}),
});
}
/**
* Clears a thread's stale session mapping after the hub reported the mapped
* session no longer exists, so the next turn starts a fresh session instead of
@@ -1151,6 +1217,7 @@ async function runConnectorRuntimeTurn<
client: input.client,
sessionId,
});
const generatedMedia: GeneratedMedia[] = [];
const activeTurn: ActiveConnectorTurn = {
sessionId,
@@ -1200,6 +1267,9 @@ async function runConnectorRuntimeTurn<
formatConnectorApprovalPrompt(approval),
);
},
onMedia: (media) => {
generatedMedia.push(media);
},
onCompleted: async (result) => {
await input.onReplyCompleted?.({
sessionId,
@@ -1219,7 +1289,9 @@ async function runConnectorRuntimeTurn<
}),
postFinalReply,
resolveFallbackText,
() => generatedMedia.length > 0,
);
await postConnectorGeneratedMedia(input.thread, generatedMedia);
} finally {
input.pendingApprovals.delete(input.thread.id);
if (input.activeTurns?.get(turnKey) === activeTurn) {
@@ -11,6 +11,49 @@ type StreamHandlers = {
};
describe("createConnectorRuntimeTurnStream", () => {
it("forwards generated media without adding binary data to the text stream", async () => {
let handlers: StreamHandlers | undefined;
const media = {
id: "generated-1",
modality: "image" as const,
mediaType: "image/png",
source: { type: "base64" as const, data: "aGVsbG8=" },
};
const client = {
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
handlers = callbacks;
return () => {};
},
sendRuntimeSession: async () => {
handlers?.onEvent({
eventType: "runtime.chat.media",
payload: { media },
});
return { result: { text: "", finishReason: "stop", iterations: 1 } };
},
};
const receivedMedia: unknown[] = [];
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "make an image" },
clientId: "client-1",
logger: { core: {} } as unknown as CliLoggerAdapter,
transport: "slack",
conversationId: "thread-1",
onMedia: (item) => {
receivedMedia.push(item);
},
})) {
chunks.push(chunk);
}
expect(chunks).toEqual([]);
expect(receivedMedia).toEqual([media]);
});
it("delivers tool status via callbacks instead of appending it to streamed text", async () => {
let handlers: StreamHandlers | undefined;
+9
View File
@@ -1,4 +1,5 @@
import type { ChatRunTurnRequest, HubSessionClient } from "@cline/core";
import { type GeneratedMedia, isGeneratedMedia } from "@cline/shared";
import type { CliLoggerAdapter } from "../logging/adapter";
export type PendingConnectorApproval = {
@@ -141,6 +142,7 @@ export function createConnectorRuntimeTurnStream(input: {
conversationId: string;
onToolStatus?: (message: string) => Promise<void>;
onApprovalRequested?: (approval: PendingConnectorApproval) => Promise<void>;
onMedia?: (media: GeneratedMedia) => Promise<void> | void;
onCompleted?: (result: {
text: string;
finishReason?: string;
@@ -189,6 +191,13 @@ export function createConnectorRuntimeTurnStream(input: {
},
{
onEvent: (event) => {
if (event.eventType === "runtime.chat.media") {
const media = event.payload.media;
if (isGeneratedMedia(media)) {
void input.onMedia?.(media);
}
return;
}
if (event.eventType === "approval.requested") {
const approvalId =
typeof event.payload.approvalId === "string"
@@ -165,7 +165,6 @@ describe("buildConnectorStartRequest", () => {
});
});
describe("isReusableConnectorSession", () => {
it("rejects missing and terminal sessions", () => {
expect(isReusableConnectorSession(undefined)).toBe(false);
+1 -3
View File
@@ -235,9 +235,7 @@ export async function getOrCreateSessionId<
transport: input.transport,
threadId: input.thread.id,
sessionId: existing,
...(existingSession?.status
? { status: existingSession.status }
: {}),
...(existingSession?.status ? { status: existingSession.status } : {}),
},
);
}
+9
View File
@@ -97,6 +97,15 @@ if (!isMainThread) {
} finally {
await disposeAll();
}
// The explicit process.exit below means beforeExit never fires, so a
// startup-recorded auto-update must be applied here, after all runtime
// teardown. It spawns detached and only when no other CLI is attached.
try {
const { applyDeferredUpdate } = await import("./commands/update");
await applyDeferredUpdate();
} catch {
// Best-effort; never block exit on the updater.
}
process.exit(exitCode || (process.exitCode as number) || 0);
})();
}
+18
View File
@@ -505,6 +505,24 @@ export async function runCli(): Promise<void> {
io,
});
});
const mcpUninstallCmd = mcpCmd
.command("uninstall")
.alias("remove")
.alias("rm")
.description("Uninstall an MCP server by name")
.argument("<name>", "MCP server name")
.option("--json", "Output as JSON")
.action(async (name: string) => {
const opts = mcpUninstallCmd.opts<{
json?: boolean;
}>();
const { runMcpUninstallCommand } = await import("./commands/mcp");
ctx.exitCode = await runMcpUninstallCommand({
name,
json: opts.json === true || program.opts().json === true,
io,
});
});
const createDoctorRuntimeCommand = async () => {
const { createDoctorCommand } = await import("./commands/doctor");
@@ -1012,7 +1012,9 @@ Review with the bundled skill.`,
const linear = data.mcp.find((item) => item.name === "linear");
const docs = data.mcp.find((item) => item.name === "docs");
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
expect(linear?.description).toBe(
"streamableHttp, oauth error, timeout 60s",
);
expect(linear?.loadError).toBe("OAuth authorization failed");
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
expect(docs?.loadError).toBeUndefined();
@@ -15,7 +15,7 @@ import {
type ToolApprovalResult,
type UserInstructionConfigService,
} from "@cline/core";
import type { Message } from "@cline/shared";
import type { MessageWithMetadata } from "@cline/shared";
import { createCliCore } from "../../session/session";
import { submitAndExitInTerminal } from "../../utils/approval";
import type {
@@ -56,11 +56,11 @@ type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type CurrentMessagesRead =
| { messages: Message[]; status: "read" }
| { messages: Message[]; status: "recovered" }
| { messages: Message[]; status: "stale" };
| { messages: MessageWithMetadata[]; status: "read" }
| { messages: MessageWithMetadata[]; status: "recovered" }
| { messages: MessageWithMetadata[]; status: "stale" };
type MissingSessionRecovery = {
messages: Message[];
messages: MessageWithMetadata[];
};
type ToolPolicyResolver = (
toolName: string,
@@ -210,7 +210,7 @@ export function createInteractiveSessionRuntime(input: {
};
const startFreshSession = async (
initial: Message[] = [],
initial: MessageWithMetadata[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
// Restarting an old session associate with this ID,
@@ -243,7 +243,7 @@ export function createInteractiveSessionRuntime(input: {
const startResumedSession = async (
resumeId: string,
initial: Message[] | undefined,
initial: MessageWithMetadata[] | undefined,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
@@ -421,7 +421,7 @@ export function createInteractiveSessionRuntime(input: {
};
const restartWithMessages = async (
messages: Message[],
messages: MessageWithMetadata[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
options?: { preserveSessionId?: boolean },
@@ -659,7 +659,9 @@ export function createInteractiveSessionRuntime(input: {
};
};
const resumeSession = async (sessionId: string): Promise<Message[]> => {
const resumeSession = async (
sessionId: string,
): Promise<MessageWithMetadata[]> => {
const manager = await ensureSessionManager();
const sessionRecord = await manager.get(sessionId);
if (!sessionRecord) {
@@ -754,7 +756,7 @@ export function createInteractiveSessionRuntime(input: {
const getCheckpointData = async (): Promise<
| {
messages: Message[];
messages: MessageWithMetadata[];
checkpointHistory: CheckpointEntry[];
}
| undefined
@@ -777,7 +779,9 @@ export function createInteractiveSessionRuntime(input: {
const restoreCheckpoint = async (
runCount: number,
restoreWorkspace: boolean,
): Promise<{ newSessionId: string; messages: Message[] } | undefined> => {
): Promise<
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
> => {
const manager = sessionManager;
if (!manager || !activeSessionId) {
return undefined;
@@ -80,7 +80,9 @@ describe("applyInteractiveModelChange", () => {
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
modes: {},
providers: {},
modes: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
+20
View File
@@ -490,6 +490,7 @@ export async function runInteractive(
tuiApp?.destroy();
});
let startupErrorReported = false;
let updateCliAfterExit = false;
const loadDeferredInitialMessages = resumeSessionId?.trim()
? async () => {
try {
@@ -749,6 +750,10 @@ export async function runInteractive(
onExit: () => {
tuiApp?.destroy();
},
onHubUpdateRestart: () => {
updateCliAfterExit = true;
tuiApp?.destroy();
},
onRunningChange: (running) => {
isRunning = running;
if (!running) {
@@ -877,4 +882,19 @@ export async function runInteractive(
prepareTerminalForPostTuiOutput();
writeln(formatInteractiveExitSummary(exitSummary));
}
if (updateCliAfterExit) {
if (!exitSummary) {
prepareTerminalForPostTuiOutput();
}
writeln(
"The shared Cline Hub was updated by another Cline installation. Updating this CLI…",
);
const { checkForUpdates } = await import("../commands/update");
const exitCode = await checkForUpdates({ includeKanban: false });
writeln(
exitCode === 0
? "Start cline again to reconnect to the updated Hub."
: "Update did not complete. Run 'cline update' manually, then start cline again.",
);
}
}
+7
View File
@@ -2,6 +2,7 @@ import {
type BuiltinToolAvailabilityContext,
getCoreBuiltinToolCatalog,
resolveDisabledToolNames,
resolveModelToolSettings,
type ToolCatalogEntry,
} from "@cline/core";
@@ -10,8 +11,14 @@ export type { ToolCatalogEntry } from "@cline/core";
export function getToolCatalog(
availabilityContext?: BuiltinToolAvailabilityContext,
): ToolCatalogEntry[] {
const modelToolSettings = resolveModelToolSettings();
return getCoreBuiltinToolCatalog({
disabledToolIds: resolveDisabledToolNames(),
enabledModelToolIds: new Set(
Object.entries(modelToolSettings)
.filter(([, setting]) => setting?.enabled === true)
.map(([name]) => name),
),
...availabilityContext,
});
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { generateConversationHTML } from "./export";
describe("generateConversationHTML", () => {
it("renders provider model activity with the ordinary tool HTML", () => {
const html = generateConversationHTML(
{
version: 1,
updated_at: "2026-08-13T00:00:00.000Z",
messages: [
{
id: "assistant-search",
role: "assistant",
content: "Bun 1.3.14 is current.",
metadata: {
modelToolActivities: [
{
toolCallId: "search-1",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun release" },
output: "Bun 1.3.14",
},
],
},
},
],
},
"session",
);
expect(html).toContain("web_search");
expect(html).toContain("latest Bun release");
expect(html).toContain('<span class="success">Success</span>');
expect(html).toContain("Bun 1.3.14 is current.");
});
});
+45 -2
View File
@@ -1,3 +1,4 @@
import { projectSessionMessagesForDisplay } from "@cline/core";
import {
type ContentBlock,
formatDisplayUserInput,
@@ -30,9 +31,12 @@ export function generateConversationHTML(
data: ConversationHistory,
fileName: string,
): string {
const displayMessages = projectSessionMessagesForDisplay(data.messages).map(
({ message }) => message,
);
// Build tool results map
const toolResultsMap = new Map<string, ToolResultContent>();
data.messages.forEach((msg) => {
displayMessages.forEach((msg) => {
if (!isStringContent(msg.content)) {
msg.content.forEach((block) => {
if (block.type === "tool_result") {
@@ -43,7 +47,7 @@ export function generateConversationHTML(
});
// Filter messages (same logic as viewer)
const filteredMessages = data.messages.filter((msg) => {
const filteredMessages = displayMessages.filter((msg) => {
if (msg.role === "assistant") return true;
if (isStringContent(msg.content)) {
return msg.content.trim().length > 0;
@@ -696,6 +700,14 @@ function renderContentHTML(
return renderToolUseHTML(block, toolResultsMap.get(block.id));
case "tool_result":
return ""; // Tool results are rendered with their corresponding tool_use
case "image":
return renderGeneratedMediaHTML({
modality: "image",
mediaType: block.mediaType,
source: { type: "base64", data: block.data },
});
case "media":
return renderGeneratedMediaHTML(block.media);
default:
return "";
}
@@ -703,6 +715,37 @@ function renderContentHTML(
.join("\n");
}
function renderGeneratedMediaHTML(media: {
modality: "image" | "audio" | "video" | "file";
mediaType: string;
source:
| { type: "base64"; data: string }
| { type: "url"; url: string }
| { type: "artifact"; artifactId: string };
}): string {
const source =
media.source.type === "base64"
? `data:${media.mediaType};base64,${media.source.data}`
: media.source.type === "url"
? media.source.url
: undefined;
if (!source) {
return `<p class="generated-media">Generated ${escapeHtml(media.modality)} (${escapeHtml(media.mediaType)})</p>`;
}
const escapedSource = escapeHtml(source);
const escapedType = escapeHtml(media.mediaType);
switch (media.modality) {
case "image":
return `<img class="generated-media" src="${escapedSource}" alt="Generated image" />`;
case "audio":
return `<audio class="generated-media" controls src="${escapedSource}" type="${escapedType}"></audio>`;
case "video":
return `<video class="generated-media" controls src="${escapedSource}" type="${escapedType}"></video>`;
case "file":
return `<a class="generated-media" href="${escapedSource}" download>Generated file (${escapedType})</a>`;
}
}
function renderTextHTML(text: string): string {
// Simple markdown-like rendering
let html = escapeHtml(text);
@@ -1,89 +0,0 @@
// ---------------------------------------------------------------------------
// Page-object helpers for the /settings view.
// ---------------------------------------------------------------------------
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { expectVisible } from "../terminal.js";
const TAB_ORDER = [
"API",
"Auto-approve",
"Features",
"Account",
"Other",
] as const;
export type SettingsTab = (typeof TAB_ORDER)[number];
/**
* Navigate to a specific settings tab by pressing Right from the API tab (index 0).
* Waits for each tab's content to appear before pressing the next key, making
* navigation deterministic regardless of machine speed.
*/
export async function goToSettingsTab(
terminal: Terminal,
tab: SettingsTab,
): Promise<void> {
const targetIndex = TAB_ORDER.indexOf(tab);
for (let i = 0; i < targetIndex; i++) {
terminal.keyRight();
// Wait for the next tab's content to appear before pressing again
await assertTabContent(terminal, TAB_ORDER[i + 1]);
}
}
/** Assert the API tab content is visible */
export async function assertApiTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, ["Provider:", "Model ID:"]);
}
/** Assert the Auto-approve tab content is visible */
export async function assertAutoApproveTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, [
"Read project files",
"Execute safe commands",
"Edit project files",
]);
}
/** Assert the Features tab content is visible */
export async function assertFeaturesTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, [
"Subagents",
"Web tools",
"Double-check completion",
]);
}
/** Assert the Account tab content is visible */
export async function assertAccountTab(terminal: Terminal): Promise<void> {
// The account tab shows sign-in options when not authenticated to Cline
await expectVisible(terminal, /sign in|sign out/i);
}
/** Assert the Other tab content is visible */
export async function assertOtherTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, ["Preferred language:", "Cline v"]);
}
/**
* Assert the content for a given tab is visible.
* Used internally by goToSettingsTab to confirm navigation landed correctly.
*/
export async function assertTabContent(
terminal: Terminal,
tab: SettingsTab,
): Promise<void> {
switch (tab) {
case "API":
return assertApiTab(terminal);
case "Auto-approve":
return assertAutoApproveTab(terminal);
case "Features":
return assertFeaturesTab(terminal);
case "Account":
return assertAccountTab(terminal);
case "Other":
return assertOtherTab(terminal);
}
}
+2 -2
View File
@@ -2,10 +2,9 @@ import {
type ClineAccountBalance,
type ClineAccountOrganization,
type ClineAccountOrganizationBalance,
type ClineSubscriptionPlan,
type UserCurrentPlan,
ClineAccountService,
type ClineAccountUser,
type ClineSubscriptionPlan,
formatProviderOAuthApiKey,
getPersistedProviderApiKey,
getProviderOAuthCredentialsFromSettings,
@@ -13,6 +12,7 @@ import {
type ProviderSettings,
ProviderSettingsManager,
saveLocalProviderOAuthCredentials,
type UserCurrentPlan,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
@@ -45,6 +45,13 @@ function trimLeading(text: string): string {
return text.replace(/^\n+/, "");
}
function formatMediaSize(byteLength: number): string {
if (byteLength <= 0) return "unknown size";
if (byteLength < 1024) return `${byteLength} B`;
if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KiB`;
return `${(byteLength / (1024 * 1024)).toFixed(1)} MiB`;
}
function ReasoningBlock(props: { text: string; streaming: boolean }) {
const [expanded, setExpanded] = useState(false);
const { width } = useTerminalDimensions();
@@ -640,10 +647,23 @@ export function ChatEntryView(props: {
)}
</box>
<box flexGrow={1}>
{/*
* internalBlockMode="top-level" keeps each markdown block as its
* own renderable. The default coalesced mode merges the whole
* message into one block that is torn down and re-highlighted on
* every streamed chunk, which flashes already-rendered headings
* and links back to raw uncolored markdown while tree-sitter
* re-highlights asynchronously. Top-level blocks are reused by
* 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).
*/}
<markdown
content={content}
syntaxStyle={getSyntaxStyle(theme, mode)}
streaming={entry.streaming}
internalBlockMode="top-level"
tableOptions={{ style: "grid" }}
fg={defaultFg}
/>
</box>
@@ -651,6 +671,20 @@ export function ChatEntryView(props: {
);
}
case "assistant_media":
return (
<box flexDirection="row">
<box width={2}>
<text fg={accent}>*</text>
</box>
<text fg={defaultFg} selectable>
{entry.location
? `Generated ${entry.modality} (${entry.mediaType}, ${formatMediaSize(entry.byteLength)}): ${entry.location}`
: `Generated ${entry.modality} (${entry.mediaType}) could not be saved`}
</text>
</box>
);
case "reasoning":
return <ReasoningBlock text={entry.text} streaming={entry.streaming} />;
@@ -0,0 +1,27 @@
import type { Config } from "../../../utils/types";
import type { DialogDismissKey } from "../../utils/dialog-keys";
/**
* Enter starts the update-and-restart flow; Esc dismisses (the mismatch toast
* reminds the user to update manually). Other keys are ignored so the dialog
* is not lost to a stray keystroke mid-task.
*/
export function resolveHubUpdateRequiredKeyAction(
key: DialogDismissKey,
): "update" | "dismiss" | "ignore" {
if (key.name === "return" || key.name === "enter") return "update";
if (key.name === "escape") return "dismiss";
return "ignore";
}
/**
* Yolo and sandbox sessions force the local backend and never attach to the
* shared managed Hub (see the forceLocalBackend condition in the interactive
* session runtime), so a build mismatch on that Hub is another installation's
* concern and must not interrupt these sessions with an update dialog.
*/
export function shouldWatchManagedHubBuild(
config: Pick<Config, "mode" | "sandbox">,
): boolean {
return config.mode !== "yolo" && config.sandbox !== true;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
resolveHubUpdateRequiredKeyAction,
shouldWatchManagedHubBuild,
} from "./hub-update-required-helpers";
describe("hub update required dialog", () => {
it("updates on Enter", () => {
expect(resolveHubUpdateRequiredKeyAction({ name: "return" })).toBe(
"update",
);
expect(resolveHubUpdateRequiredKeyAction({ name: "enter" })).toBe("update");
});
it("dismisses only on Esc", () => {
expect(resolveHubUpdateRequiredKeyAction({ name: "escape" })).toBe(
"dismiss",
);
});
it("ignores stray keystrokes so a mid-task keypress cannot lose the prompt", () => {
expect(resolveHubUpdateRequiredKeyAction({ name: "a" })).toBe("ignore");
expect(resolveHubUpdateRequiredKeyAction({ name: "space" })).toBe("ignore");
expect(resolveHubUpdateRequiredKeyAction({ name: "c", ctrl: true })).toBe(
"ignore",
);
});
});
describe("shouldWatchManagedHubBuild", () => {
it("watches for hub-attached modes", () => {
expect(shouldWatchManagedHubBuild({ mode: "act", sandbox: false })).toBe(
true,
);
expect(shouldWatchManagedHubBuild({ mode: "plan", sandbox: false })).toBe(
true,
);
});
it("skips yolo and sandbox sessions, which force the local backend and never attach to the managed Hub", () => {
expect(shouldWatchManagedHubBuild({ mode: "yolo", sandbox: false })).toBe(
false,
);
expect(shouldWatchManagedHubBuild({ mode: "act", sandbox: true })).toBe(
false,
);
});
});
@@ -0,0 +1,50 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { palette } from "../../palette";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
hubCoreVersion?: string;
}
export function HubUpdateRequiredContent(
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
) {
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
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 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.
</text>
<text selectable>
Update and restart Cline so this CLI and the Hub run the same version
again.
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Update and restart</text>
</box>
</box>
<text fg={palette.muted}>
Press Enter to update and restart, Esc to dismiss
</text>
</box>
);
}
@@ -117,7 +117,10 @@ function QueuedPromptRow(props: {
flexGrow={1}
/>
) : (
<text fg={selected ? theme.textOnSelection : undefined} flexGrow={1}>
<text
fg={selected ? theme.textOnSelection : theme.defaultForeground}
flexGrow={1}
>
{truncatePrompt(item.prompt)}
</text>
)}
@@ -55,6 +55,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
if (!expanded) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands optional output.
<box
flexDirection="column"
paddingLeft={2}
@@ -75,6 +76,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
<box
flexDirection="column"
paddingLeft={2}
@@ -160,6 +162,7 @@ function EditOutput(props: {
const diffPalette = props.theme.diff;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
<box
flexDirection="column"
paddingLeft={2}
@@ -216,6 +219,7 @@ function ApplyPatchOutput(props: {
const diffPalette = props.theme.diff;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
<box
flexDirection="column"
paddingLeft={2}
@@ -263,6 +267,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
: displayText;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands long output.
<box
flexDirection="column"
paddingLeft={2}
@@ -278,6 +283,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
<box
flexDirection="column"
paddingLeft={2}
@@ -304,6 +310,7 @@ export function ToolOutput(props: ToolOutputProps) {
const showDetail =
errorExpanded && presentation.detail !== presentation.summary.trim();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles error details.
<box
flexDirection="column"
paddingLeft={2}
@@ -7,6 +7,7 @@ import type {
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveNonCompactionStatusLabel } from "../../utils/events";
import { materializeGeneratedMedia } from "../../utils/generated-media";
import {
formatToolInput,
formatToolOutput,
@@ -205,6 +206,26 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
closeToolEntry(event);
break;
}
case "media": {
closeInlineStream();
const media = event.media;
if (!media) break;
const saved = materializeGeneratedMedia(media);
appendEntry({
kind: "assistant_media",
modality: media.modality,
mediaType: media.mediaType,
byteLength: saved?.byteLength ?? media.sizeBytes ?? 0,
location:
saved?.path ??
(media.source.type === "url"
? media.source.url
: media.source.type === "artifact"
? `artifact:${media.source.artifactId}`
: undefined),
});
break;
}
}
break;
}
+3 -3
View File
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
import { getMcpDescription } from "./interactive-config";
describe("getMcpDescription", () => {
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
it("discloses the default initialize timeout for unconfigured stdio servers", () => {
expect(
getMcpDescription({
name: "local",
transport: { type: "stdio", command: "node" },
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
});
it("shows one configured timeout when it also applies to initialize", () => {
@@ -40,6 +40,6 @@ describe("getMcpDescription", () => {
transport: { type: "stdio", command: "node" },
timeoutSeconds: Number.NaN,
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
discoverPluginModulePaths,
getPluginDisplayName,
hasMcpSettingsFile,
@@ -183,7 +184,7 @@ export function getMcpDescription(registration: McpServerRegistration): string {
const timeoutDescription =
registration.transport.type === "stdio" &&
!isMcpTimeoutConfigured(registration.timeoutSeconds)
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
? `request timeout ${timeoutSeconds}s, initialize timeout ${DEFAULT_MCP_CONNECT_TIMEOUT_MS / 1000}s`
: `timeout ${timeoutSeconds}s`;
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
}
+57 -1
View File
@@ -1,4 +1,9 @@
import { getCurrentContextSize, summarizeUsageFromMessages } from "@cline/core";
import {
getCurrentContextSize,
type ManagedHubBuildMismatchEvent,
summarizeUsageFromMessages,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { formatDisplayUserInput } from "@cline/shared";
import type { KeyEvent } from "@opentui/core";
import { useRenderer, useTerminalDimensions } from "@opentui/react";
@@ -34,6 +39,8 @@ import {
buildCommandPaletteItems,
findCommandPaletteShortcut,
} from "./components/dialogs/command-palette-items";
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
import {
SKILLS_MARKETPLACE_ACTION,
SKILLS_MARKETPLACE_URL,
@@ -562,6 +569,55 @@ function App(props: TuiProps) {
return () => clearTimeout(timeout);
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
const [hubBuildMismatch, setHubBuildMismatch] =
useState<ManagedHubBuildMismatchEvent | null>(null);
const hubBuildWatchEnabled = shouldWatchManagedHubBuild(props.config);
useEffect(() => {
if (!hubBuildWatchEnabled) return;
return watchManagedHubBuildMismatch({
onMismatch: (mismatch) => setHubBuildMismatch(mismatch),
});
}, [hubBuildWatchEnabled]);
const onHubUpdateRestart = props.onHubUpdateRestart;
useEffect(() => {
if (!hubBuildMismatch) return;
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.
return;
}
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
<HubUpdateRequiredContent {...ctx} hubCoreVersion={hubCoreVersion} />
),
})
.then((update) => {
if (update) {
(onHubUpdateRestart ?? exitCline)();
return;
}
showToast(
"Hub still differs from this CLI. Run 'cline update' and restart when convenient.",
"info",
);
refocusTextareaRef.current();
})
.catch(() => {
refocusTextareaRef.current();
});
}, [dialog, exitCline, hubBuildMismatch, onHubUpdateRestart, showToast]);
const {
appendEntry: appendSessionEntry,
replaceEntries: replaceSessionEntries,
+23 -5
View File
@@ -6,7 +6,7 @@ import type {
TeamEvent,
} from "@cline/core";
import type {
Message,
MessageWithMetadata,
ToolApprovalRequest,
ToolApprovalResult,
} from "@cline/shared";
@@ -29,6 +29,13 @@ import type { InteractiveSlashCommand } from "./interactive-welcome";
export type ChatEntry = (
| { kind: "user"; text: string }
| { kind: "assistant_text"; text: string; streaming: boolean }
| {
kind: "assistant_media";
modality: "image" | "audio" | "video" | "file";
mediaType: string;
byteLength: number;
location?: string;
}
| { kind: "reasoning"; text: string; streaming: boolean }
| {
kind: "tool_call";
@@ -92,7 +99,7 @@ export interface InteractiveTurnResult {
}
export interface ResumedSessionResult {
messages: Message[];
messages: MessageWithMetadata[];
totalCost?: number;
currentContextSize?: number;
}
@@ -145,7 +152,7 @@ export interface TuiProps {
initialPrompt?: string;
initialNotice?: CliMigrationNotice;
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
initialMessages?: Message[];
initialMessages?: MessageWithMetadata[];
loadDeferredInitialMessages?: () => Promise<ResumedSessionResult>;
initialRepoStatus?: RepoStatus;
workflowSlashCommands?: InteractiveSlashCommand[];
@@ -185,6 +192,11 @@ export interface TuiProps {
}) => Promise<PendingPromptMutationResult>;
onAbort: () => boolean;
onExit: () => void;
/**
* Exit the TUI and run the CLI self-update afterwards. Invoked when the
* user accepts the "Hub was updated by another Cline installation" dialog.
*/
onHubUpdateRestart?: () => void;
onRunningChange: (isRunning: boolean) => void;
onTurnErrorReported: (reported: boolean) => void;
onAutoApproveChange: (enabled: boolean) => void;
@@ -213,12 +225,18 @@ export interface TuiProps {
| undefined
>;
getCheckpointData: () => Promise<
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
| {
messages: MessageWithMetadata[];
checkpointHistory: CheckpointEntry[];
}
| undefined
>;
onRestoreCheckpoint: (
runCount: number,
restoreWorkspace: boolean,
) => Promise<{ newSessionId: string; messages: Message[] } | undefined>;
) => Promise<
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
>;
setToolApprover: (
approver:
| ((request: ToolApprovalRequest) => Promise<ToolApprovalResult>)
+149 -1
View File
@@ -1,4 +1,6 @@
import type { Message } from "@cline/shared";
import { readFileSync, rmSync } from "node:fs";
import { dirname } from "node:path";
import type { Message, MessageWithMetadata } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
import { hydrateSessionMessages } from "./hydrate-messages";
@@ -106,6 +108,7 @@ describe("hydrateSessionMessages", () => {
},
{
kind: "tool_call",
toolCallId: "tool-1",
toolName: "switch_to_act_mode",
inputSummary: expect.any(String),
rawInput: {},
@@ -135,6 +138,36 @@ describe("hydrateSessionMessages", () => {
]);
});
// Regression test for https://github.com/cline/cline/issues/13036:
// persisted sessions with malformed tool inputs must stay resumable.
it("hydrates tool calls with malformed inputs without throwing", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "run_commands",
input: { command: null },
},
],
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{
kind: "tool_call",
toolCallId: "tool-1",
toolName: "run_commands",
inputSummary: "",
rawInput: { command: null },
streaming: false,
mode: undefined,
},
]);
});
it("leaves mode undefined for transcripts without user_input wrappers", () => {
const messages = [
{ role: "user", content: "plain old message" },
@@ -151,4 +184,119 @@ describe("hydrateSessionMessages", () => {
},
]);
});
it("materializes generated images from resumed assistant history", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "image",
data: Buffer.from("history-image").toString("base64"),
mediaType: "image/webp",
},
],
},
] as Message[];
const [entry] = hydrateSessionMessages(messages);
expect(entry).toMatchObject({
kind: "assistant_media",
modality: "image",
mediaType: "image/webp",
byteLength: 13,
mode: undefined,
});
if (entry?.kind !== "assistant_media" || !entry.location) {
throw new Error("Expected a materialized assistant image");
}
try {
expect(readFileSync(entry.location, "utf8")).toBe("history-image");
} finally {
rmSync(dirname(entry.location), { recursive: true, force: true });
}
});
it("hydrates provider model tools through the ordinary tool card path", () => {
const messages: MessageWithMetadata[] = [
{
id: "assistant-search",
role: "assistant",
content: "Bun 1.3.14 is the latest stable release.",
metadata: {
modelToolActivities: [
{
toolCallId: "search-1",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun stable release" },
output: { sources: ["https://bun.sh/blog/bun-v1.3.14"] },
},
],
},
},
];
expect(hydrateSessionMessages(messages)).toEqual([
{
kind: "tool_call",
toolCallId: "search-1",
toolName: "web_search",
inputSummary: expect.any(String),
rawInput: { query: "latest Bun stable release" },
streaming: false,
mode: undefined,
result: {
outputSummary: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
rawOutput: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
error: undefined,
},
},
{
kind: "assistant_text",
text: "Bun 1.3.14 is the latest stable release.",
streaming: false,
mode: undefined,
},
]);
});
it("hydrates structured native search output and mirrors live error payloads", () => {
const nativeResult = {
type: "web_search_result",
url: "https://bun.sh/blog/bun-v1.3.14",
title: "Bun v1.3.14",
pageAge: "2026-08-12",
encryptedContent: "encrypted",
};
const messages: MessageWithMetadata[] = [
{
role: "assistant",
content: "Search failed.",
metadata: {
modelToolActivities: [
{
toolCallId: "search-native",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun" },
output: [nativeResult],
isError: true,
},
],
},
},
];
const [toolEntry] = hydrateSessionMessages(messages);
expect(toolEntry).toMatchObject({
kind: "tool_call",
toolCallId: "search-native",
result: {
outputSummary: "",
rawOutput: undefined,
error: JSON.stringify([nativeResult]),
},
});
});
});
+68 -15
View File
@@ -1,18 +1,16 @@
import type { AgentMode } from "@cline/core";
import { type AgentMode, projectSessionMessagesForDisplay } from "@cline/core";
import {
formatDisplayUserInput,
type Message,
type GeneratedMedia,
type MessageWithMetadata,
parseUserInputMode,
} from "@cline/shared";
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
import { materializeGeneratedMedia } from "../../utils/generated-media";
import { formatToolInput } from "../../utils/helpers";
import type { ChatEntry } from "../types";
type PersistedMessage = Message & {
metadata?: Record<string, unknown>;
};
function getDisplayRole(msg: PersistedMessage): string | undefined {
function getDisplayRole(msg: MessageWithMetadata): string | undefined {
const role = msg.metadata?.displayRole;
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
}
@@ -33,13 +31,29 @@ function stringifyToolResult(
return block.text;
if (block.type === "file" && typeof block.path === "string")
return `Attached file: ${block.path}`;
return "";
if (block.type === "image") return "[image]";
try {
return JSON.stringify(block);
} catch {
return String(block);
}
})
.filter(Boolean)
.join("\n");
}
export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
function stringifyToolError(content: unknown): string {
if (typeof content === "string") return content;
try {
return JSON.stringify(content) ?? String(content);
} catch {
return String(content);
}
}
export function hydrateSessionMessages(
messages: MessageWithMetadata[],
): ChatEntry[] {
const entries: ChatEntry[] = [];
const toolUseMap = new Map<string, number>();
// Mode each entry was produced in, recovered from <user_input mode="...">
@@ -49,7 +63,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
// wrappers on session restarts).
let mode: AgentMode | undefined;
for (const msg of messages as PersistedMessage[]) {
for (const { message: msg } of projectSessionMessagesForDisplay(messages)) {
const displayRole = getDisplayRole(msg);
if (displayRole === "system" || displayRole === "status") {
continue;
@@ -76,6 +90,39 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
const userTextParts: string[] = [];
for (const block of msg.content) {
if (
msg.role === "assistant" &&
(block.type === "image" || block.type === "media")
) {
const media: GeneratedMedia =
block.type === "media"
? block.media
: {
id: `${msg.id ?? "history"}:media:${entries.length}`,
modality: "image",
mediaType: block.mediaType,
source: { type: "base64", data: block.data },
};
if (media.source.type !== "base64" || media.source.data.length > 0) {
const saved = materializeGeneratedMedia(media);
entries.push({
kind: "assistant_media",
modality: media.modality,
mediaType: media.mediaType,
byteLength: saved?.byteLength ?? media.sizeBytes ?? 0,
location:
saved?.path ??
(media.source.type === "url"
? media.source.url
: media.source.type === "artifact"
? `artifact:${media.source.artifactId}`
: undefined),
mode,
});
}
continue;
}
if (block.type === "text") {
if (msg.role === "user") {
userTextParts.push(block.text);
@@ -107,6 +154,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
if (block.type === "tool_use") {
entries.push({
kind: "tool_call",
toolCallId: block.id,
toolName: block.name,
inputSummary: formatToolInput(block.name, block.input),
rawInput: block.input,
@@ -132,11 +180,16 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
| string
| Array<{ type: string; text?: string; path?: string }>,
);
entry.result = {
outputSummary: resultText.slice(0, 500),
rawOutput: block.content,
error: block.is_error ? resultText : undefined,
};
const error = block.is_error
? stringifyToolError(block.content)
: undefined;
entry.result = error
? { outputSummary: "", rawOutput: undefined, error }
: {
outputSummary: resultText.slice(0, 500),
rawOutput: block.content,
error: undefined,
};
}
}
}
+66 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
import {
buildReadFilesKeys,
extractFullOutputText,
parseReadFilesInput,
} from "./tool-parsing";
describe("buildReadFilesKeys", () => {
it("produces unique keys when the same path is read twice", () => {
@@ -32,3 +36,64 @@ describe("buildReadFilesKeys", () => {
expect(buildReadFilesKeys([])).toEqual([]);
});
});
describe("extractFullOutputText", () => {
it("extracts text with real newlines from the MCP CallToolResult shape", () => {
const raw = {
content: [
{ type: "text", text: "# Memory\n\nline one" },
{ type: "text", text: "line two" },
],
};
expect(extractFullOutputText(raw)).toBe("# Memory\n\nline one\nline two");
});
it("keeps binary payloads behind placeholders in mixed MCP content", () => {
const raw = {
content: [
{ type: "text", text: "before" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
{
type: "resource",
resource: { uri: "file:///a.md", blob: "d29ybGQ=" },
},
{ type: "resource_link", uri: "file:///b.md", name: "b.md" },
{ type: "text", text: "after" },
],
};
expect(extractFullOutputText(raw)).toBe(
"before\n[image: image/png]\naGVsbG8=\n[resource: file:///a.md]\nd29ybGQ=\n[resource_link: file:///b.md]\nafter",
);
});
it("chunks base64 payloads into 76-char lines so collapse stays compact", () => {
const raw = {
content: [
{ type: "image", data: "A".repeat(160), mimeType: "image/png" },
],
};
expect(extractFullOutputText(raw)?.split("\n")).toEqual([
"[image: image/png]",
"A".repeat(76),
"A".repeat(76),
"A".repeat(8),
]);
});
it("extracts embedded resource text from MCP content", () => {
const raw = {
content: [
{
type: "resource",
resource: { uri: "file:///memory.md", text: "resource body\nline 2" },
},
],
};
expect(extractFullOutputText(raw)).toBe("resource body\nline 2");
});
it("falls back to pretty JSON for objects without text content", () => {
const raw = { structuredContent: { ok: true } };
expect(extractFullOutputText(raw)).toBe(JSON.stringify(raw, null, 2));
});
});
+51
View File
@@ -185,6 +185,12 @@ export function parseSpawnAgentInput(
return { task: input.task };
}
// Base64 payloads are one giant line; chunk to MIME width so GenericOutput's
// line-based collapse stays compact and expand shows the full data.
function chunkBase64(data: string): string {
return data.match(/.{1,76}/g)?.join("\n") ?? data;
}
export function extractFullOutputText(raw: unknown): string | undefined {
if (raw === null || raw === undefined) return undefined;
if (typeof raw === "string") return raw;
@@ -213,6 +219,51 @@ export function extractFullOutputText(raw: unknown): string | undefined {
}
if (typeof raw === "object") {
// MCP tools return {content: [{type: "text", text}, ...]}. Extract the
// text so multi-line results keep real newlines instead of being
// JSON-escaped into one giant line that floods the terminal (#13038).
// Non-text blocks keep their identifying metadata plus their base64
// payloads so mixed results are not silently truncated.
const content = (raw as { content?: unknown }).content;
if (Array.isArray(content)) {
const parts = content
.map((part) => {
if (!isRecord(part)) return "";
if (part.type === "text" && typeof part.text === "string") {
return part.text;
}
if (part.type === "resource" && isRecord(part.resource)) {
if (typeof part.resource.text === "string") {
return part.resource.text;
}
if (typeof part.resource.blob === "string" && part.resource.blob) {
const label =
typeof part.resource.uri === "string"
? `[resource: ${part.resource.uri}]`
: "[resource]";
return `${label}\n${chunkBase64(part.resource.blob)}`;
}
if (typeof part.resource.uri === "string") {
return `[resource: ${part.resource.uri}]`;
}
}
if (part.type === "resource_link" && typeof part.uri === "string") {
return `[resource_link: ${part.uri}]`;
}
if (
(part.type === "image" || part.type === "audio") &&
typeof part.mimeType === "string"
) {
if (typeof part.data === "string" && part.data) {
return `[${part.type}: ${part.mimeType}]\n${chunkBase64(part.data)}`;
}
return `[${part.type}: ${part.mimeType}]`;
}
return typeof part.type === "string" ? `[${part.type}]` : "";
})
.filter(Boolean);
if (parts.length > 0) return parts.join("\n");
}
try {
return JSON.stringify(raw, null, 2);
} catch {
+1 -1
View File
@@ -28,7 +28,7 @@ describe("cline-pass-errors", () => {
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
+2 -2
View File
@@ -21,11 +21,11 @@ export { getClineOrgIndividualInferenceSubscriptionMessage };
export const CLI_PROMO_CODE = "";
export function getCliSubscriptionUrl(): string {
if(!CLI_PROMO_CODE) {
if (!CLI_PROMO_CODE) {
return new URL(
`/dashboard/subscription?personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString()
).toString();
}
return `${new URL(
+37
View File
@@ -1,3 +1,5 @@
import { readFileSync, rmSync } from "node:fs";
import { dirname } from "node:path";
import type { AgentEvent, TeamEvent } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -118,6 +120,41 @@ describe("handleEvent text formatting", () => {
expect(output).toMatch(/\[run_commands\].*\n.*\[read_files\]/s);
});
it("saves generated images and prints an openable path", () => {
handleEvent(
{
type: "content_end",
contentType: "media",
media: {
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: {
type: "base64",
data: Buffer.from("one-shot-image").toString("base64"),
},
},
} as AgentEvent,
{} as Config,
);
expect(output).toContain("[generated image]");
const suffix = "/generated.png";
const pathEnd = output.indexOf(suffix);
const pathStart = output.lastIndexOf(" ", pathEnd);
const path =
pathEnd >= 0 && pathStart >= 0
? output.slice(pathStart + 1, pathEnd + suffix.length)
: undefined;
expect(path).toBeDefined();
if (!path) throw new Error("Expected generated image path in CLI output");
try {
expect(readFileSync(path, "utf8")).toBe("one-shot-image");
} finally {
rmSync(dirname(path), { recursive: true, force: true });
}
});
it("does not echo ask_question through the generic tool renderer", () => {
handleEvent(
{
+25
View File
@@ -4,6 +4,7 @@ import {
parseCompactionNoticeMetadata,
} from "../tui/utils/compaction-status";
import { formatCliErrorMessage } from "./cline-pass-errors";
import { materializeGeneratedMedia } from "./generated-media";
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
import {
c,
@@ -184,6 +185,30 @@ export function handleEvent(event: AgentEvent, config: Config): void {
}
shouldPrefixNextTextWithBlankLine = false;
break;
case "media": {
closeInlineStreamIfNeeded();
const media = event.media;
if (!media) break;
const saved = materializeGeneratedMedia(media);
if (saved) {
write(
`${c.dim}[generated ${media.modality}]${c.reset} ${saved.path}\n`,
);
} else if (media.source.type === "url") {
write(
`${c.dim}[generated ${media.modality}]${c.reset} ${media.source.url}\n`,
);
} else if (media.source.type === "artifact") {
write(
`${c.dim}[generated ${media.modality}]${c.reset} artifact:${media.source.artifactId}\n`,
);
} else {
write(
`${c.dim}[generated ${media.modality}]${c.reset} ${media.mediaType} could not be saved\n`,
);
}
break;
}
}
break;
@@ -0,0 +1,58 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
cleanupMaterializedGeneratedMedia,
materializeGeneratedMedia,
} from "./generated-media";
describe("materializeGeneratedMedia", () => {
afterEach(cleanupMaterializedGeneratedMedia);
it("writes decoded media data to a private temporary file", () => {
const saved = materializeGeneratedMedia({
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: {
type: "base64",
data: Buffer.from("generated-image").toString("base64"),
},
});
expect(saved).toBeDefined();
if (!saved) throw new Error("Expected generated media to be saved");
expect(saved).toMatchObject({ mediaType: "image/png", byteLength: 15 });
expect(saved.path).toMatch(/generated\.png$/);
expect(readFileSync(saved.path, "utf8")).toBe("generated-image");
});
it("cleans up materialized media directories", () => {
const saved = materializeGeneratedMedia({
id: "generated-cleanup",
modality: "audio",
mediaType: "audio/mpeg",
source: { type: "base64", data: "SUQz" },
});
expect(saved).toBeDefined();
if (!saved) throw new Error("Expected generated media to be saved");
const directory = dirname(saved.path);
expect(existsSync(directory)).toBe(true);
cleanupMaterializedGeneratedMedia();
expect(existsSync(directory)).toBe(false);
});
it("rejects non-materializable payloads", () => {
expect(
materializeGeneratedMedia({
id: "generated-remote",
modality: "audio",
mediaType: "audio/mpeg",
source: { type: "url", url: "https://example.com/audio.mp3" },
}),
).toBeUndefined();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { GeneratedMedia } from "@cline/shared";
export interface MaterializedGeneratedMedia {
path: string;
mediaType: string;
modality: GeneratedMedia["modality"];
byteLength: number;
}
const materializedMediaDirectories = new Set<string>();
let exitCleanupRegistered = false;
/** Remove temporary files created for terminal display. */
export function cleanupMaterializedGeneratedMedia(): void {
for (const directory of materializedMediaDirectories) {
rmSync(directory, { recursive: true, force: true });
}
materializedMediaDirectories.clear();
}
function trackMaterializedMediaDirectory(directory: string): void {
materializedMediaDirectories.add(directory);
if (exitCleanupRegistered) return;
exitCleanupRegistered = true;
process.once("exit", cleanupMaterializedGeneratedMedia);
}
const MEDIA_EXTENSIONS: Readonly<Record<string, string>> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/avif": "avif",
"image/svg+xml": "svg",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"video/mp4": "mp4",
"video/webm": "webm",
};
/**
* Persist generated media where terminal users can open it with their usual
* local tools. Each item gets a private temporary directory so concurrent
* sessions cannot overwrite one another.
*/
export function materializeGeneratedMedia(
media: GeneratedMedia,
): MaterializedGeneratedMedia | undefined {
const mediaType = media.mediaType.trim().toLowerCase();
if (media.source.type !== "base64" || media.source.data.length === 0) {
return undefined;
}
let directory: string | undefined;
try {
const bytes = Buffer.from(media.source.data, "base64");
if (bytes.byteLength === 0) {
return undefined;
}
directory = mkdtempSync(join(tmpdir(), "cline-generated-media-"));
const extension = MEDIA_EXTENSIONS[mediaType] ?? "bin";
const path = join(directory, `generated.${extension}`);
writeFileSync(path, bytes, { mode: 0o600 });
trackMaterializedMediaDirectory(directory);
return {
path,
mediaType,
modality: media.modality,
byteLength: bytes.byteLength,
};
} catch {
if (directory !== undefined) {
rmSync(directory, { recursive: true, force: true });
}
return undefined;
}
}
+68
View File
@@ -10,6 +10,7 @@ import {
isCliHookPayload,
normalizeAutoApproveArgs,
parseArgs,
truncate,
} from "./helpers";
type EnvSnapshot = {
@@ -374,6 +375,73 @@ describe("format helpers", () => {
).toBe("first (+2 more)");
expect(formatToolOutput(null)).toBe("");
});
// Regression tests for https://github.com/cline/cline/issues/13036:
// malformed tool inputs crossing the model/tool boundary must never
// throw from display-only formatters.
it("does not crash on run_commands with a null command", () => {
expect(formatToolInput("run_commands", { command: null })).toBe("");
});
it("does not crash on run_commands with a non-string command", () => {
expect(formatToolInput("run_commands", { command: { nested: true } })).toBe(
'{"nested":true}',
);
expect(formatToolInput("run_commands", { commands: { command: 42 } })).toBe(
"42",
);
});
it("keeps valid empty-string args in structured command summaries", () => {
expect(
formatToolInput("run_commands", {
commands: [{ command: "grep", args: ["", "pattern", "file.txt"] }],
}),
).toBe("grep pattern file.txt");
expect(
formatToolInput("run_commands", {
commands: [{ command: "git", args: [null, "status", undefined] }],
}),
).toBe("git status");
});
it("skips null entries in run_commands command arrays", () => {
expect(
formatToolInput("run_commands", { commands: [null, "echo hi"] }),
).toBe("echo hi");
expect(formatToolInput("run_commands", [undefined, "echo hi"])).toBe(
"echo hi",
);
});
it("does not crash on fetch_web_content with malformed requests", () => {
expect(
formatToolInput("fetch_web_content", {
requests: [null, { url: "https://example.com" }, { url: 42 }, "raw"],
}),
).toBe("https://example.com, 42");
});
it("falls back to an empty summary for unserializable inputs", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatToolInput("unknown_tool", circular)).toBe("");
expect(formatToolOutput(circular)).toBe("");
expect(
formatToolInput("unknown_tool", {
toJSON() {
throw new Error("boom");
},
}),
).toBe("");
});
it("truncates non-string values without throwing", () => {
expect(truncate(null, 10)).toBe("");
expect(truncate(undefined, 10)).toBe("");
expect(truncate(42, 10)).toBe("42");
expect(truncate({ nested: true }, 60)).toBe('{"nested":true}');
});
});
describe("hook payload validation and audit logging", () => {
+58 -14
View File
@@ -53,8 +53,38 @@ export function resolveWorkspaceRoot(cwd: string): string {
return cwd;
}
export function truncate(str: string, maxLen: number): string {
const oneLine = str.replace(/\n/g, " ").trim();
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
/**
* Normalizes an untrusted runtime value into a display string without ever
* throwing. Tool inputs/outputs cross the model/tool boundary, so they may
* not match their TypeScript annotations (e.g. `{ command: null }`).
*/
export function toDisplayString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return safeJsonStringify(value);
}
try {
return String(value);
} catch {
return "";
}
}
export function truncate(value: unknown, maxLen: number): string {
const oneLine = toDisplayString(value).replace(/\n/g, " ").trim();
if (oneLine.length <= maxLen) {
return oneLine;
}
@@ -66,14 +96,21 @@ export function formatStructuredCommand(cmd: unknown): string {
return cmd;
}
if (cmd && typeof cmd === "object" && "command" in cmd) {
const structured = cmd as { command: string; args?: unknown };
const args = Array.isArray(structured.args) ? structured.args : [];
const structured = cmd as { command?: unknown; args?: unknown };
const command = toDisplayString(structured.command);
// Drop only nullish entries: they carry no display value, while an
// empty string is a valid argv entry that must stay in the summary.
const args = Array.isArray(structured.args)
? structured.args
.filter((arg) => arg !== null && arg !== undefined)
.map(toDisplayString)
: [];
if (args.length === 0) {
return structured.command;
return command;
}
return `${structured.command} ${args.join(" ")}`;
return `${command} ${args.join(" ")}`;
}
return String(cmd);
return toDisplayString(cmd);
}
function summarizeRunCommandsInput(input: unknown): string {
@@ -82,14 +119,17 @@ function summarizeRunCommandsInput(input: unknown): string {
}
if (Array.isArray(input)) {
return input.map(formatStructuredCommand).join("; ");
return input.map(formatStructuredCommand).filter(Boolean).join("; ");
}
if (input && typeof input === "object") {
const obj = input as Record<string, unknown>;
if (obj.commands !== undefined) {
if (Array.isArray(obj.commands)) {
return obj.commands.map(formatStructuredCommand).join("; ");
return obj.commands
.map(formatStructuredCommand)
.filter(Boolean)
.join("; ");
}
return formatStructuredCommand(obj.commands);
}
@@ -157,7 +197,11 @@ export function formatToolInput(toolName: string, input: unknown): string {
if (Array.isArray(obj.requests)) {
return truncate(
obj.requests
.map((r) => r.url)
.map((r) =>
r && typeof r === "object" && "url" in r
? toDisplayString((r as { url?: unknown }).url)
: "",
)
.filter(Boolean)
.join(", "),
120,
@@ -286,7 +330,7 @@ export function formatToolInput(toolName: string, input: unknown): string {
return "list";
}
return truncate(JSON.stringify(input), 60);
return truncate(input, 60);
}
export function formatToolOutput(output: unknown): string {
@@ -330,10 +374,10 @@ export function formatToolOutput(output: unknown): string {
)
.filter(Boolean)
.join(" ") || "Successfully read image"
: String(result ?? "");
: toDisplayString(result);
return truncate(resultStr, 80);
}
return truncate(JSON.stringify(item), 80);
return truncate(item, 80);
})
.filter((s) => s.length > 0);
@@ -346,7 +390,7 @@ export function formatToolOutput(output: unknown): string {
return `${results[0]} (+${results.length - 1} more)`;
}
return truncate(JSON.stringify(output), 100);
return truncate(output, 100);
}
function isRecord(value: unknown): value is Record<string, unknown> {
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ClineCore } from "@cline/core";
import type { Message } from "@cline/shared";
import type { MessageWithMetadata } from "@cline/shared";
export async function loadInteractiveResumeMessages(
sessionManager: ClineCore,
resumeSessionId?: string,
): Promise<Message[] | undefined> {
): Promise<MessageWithMetadata[] | undefined> {
const target = resumeSessionId?.trim();
if (!target) {
return undefined;
+33 -1
View File
@@ -8,6 +8,7 @@ import {
type McpServerEntry,
type McpTransport,
removeServer,
setServerOAuthClient,
toggleServer,
updateServer,
} from "./settings";
@@ -49,6 +50,7 @@ type RemoteAuthMode = "none" | "headers" | "oauth";
interface UrlServerConfig {
transport: McpTransport;
authMode: RemoteAuthMode;
oauthClient?: { clientId: string; clientSecret?: string };
}
export interface McpAddDefaults {
@@ -196,7 +198,29 @@ async function collectUrlTransport(
});
if (isCancel(authMode)) return null;
if (authMode === "oauth" || authMode === "none") {
if (authMode === "oauth") {
const clientId = await p.text({
message: "OAuth client ID (leave empty for dynamic registration)",
});
if (isCancel(clientId)) return null;
const normalizedClientId = (clientId as string).trim();
let clientSecret: string | undefined;
if (normalizedClientId) {
const secret = await p.password({
message: "OAuth client secret (leave empty for public clients)",
});
if (isCancel(secret)) return null;
clientSecret = (secret as string).trim() || undefined;
}
return {
transport: { type, url: (url as string).trim() },
authMode,
oauthClient: normalizedClientId
? { clientId: normalizedClientId, clientSecret }
: undefined,
};
}
if (authMode === "none") {
return {
transport: { type, url: (url as string).trim() },
authMode,
@@ -270,6 +294,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
let oauthClient: UrlServerConfig["oauthClient"];
if (type === "stdio") {
transport = await collectStdioTransport(defaults?.command);
} else {
@@ -279,6 +304,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
);
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
oauthClient = config?.oauthClient;
}
if (!transport) return;
@@ -286,6 +312,8 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
addServer(serverName, transport);
if (authMode !== "oauth") {
clearServerOAuth(serverName);
} else {
setServerOAuthClient(serverName, oauthClient);
}
p.log.success(`Added "${serverName}" to ${getSettingsPath()}`);
if (authMode === "oauth") {
@@ -374,18 +402,22 @@ async function actionEdit(): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
let oauthClient: UrlServerConfig["oauthClient"];
if (type === "stdio") {
transport = await collectStdioTransport();
} else {
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
oauthClient = config?.oauthClient;
}
if (!transport) return;
updateServer(name, transport);
if (type === "stdio" || authMode !== "oauth") {
clearServerOAuth(name);
} else {
setServerOAuthClient(name, oauthClient);
}
p.log.success(`Updated "${name}"`);
if (authMode === "oauth") {
+32
View File
@@ -8,6 +8,7 @@ import {
clearServerOAuth,
loadServers,
removeServer,
setServerOAuthClient,
} from "./settings";
describe("MCP wizard settings", () => {
@@ -139,4 +140,35 @@ describe("MCP wizard settings", () => {
await expect(readFile(settingsPath, "utf8")).resolves.toBe(before);
});
it("clears OAuth state when the configured client changes", async () => {
const settingsPath = await useTempSettingsPath();
await writeFile(
settingsPath,
JSON.stringify({
mcpServers: {
github: {
transport: {
type: "streamableHttp",
url: "https://api.githubcopilot.com/mcp/",
},
oauthClient: { clientId: "old-client", clientSecret: "old-secret" },
oauth: { tokens: { access_token: "old-token" } },
},
},
}),
);
setServerOAuthClient("github", {
clientId: "new-client",
clientSecret: "new-secret",
});
const [github] = loadServers();
expect(github?.oauth).toBeUndefined();
expect(github?.oauthClient).toEqual({
clientId: "new-client",
clientSecret: "new-secret",
});
});
});
+29
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import {
type McpServerOAuthClientConfig,
type McpServerOAuthState,
McpSettingsUpdateSkippedError,
resolveDefaultMcpSettingsPath,
@@ -10,6 +11,7 @@ export interface McpServerEntry {
name: string;
transport: McpTransport;
disabled?: boolean;
oauthClient?: McpServerOAuthClientConfig;
oauth?: McpServerOAuthState;
}
@@ -49,6 +51,9 @@ export function loadServers(): McpServerEntry[] {
name,
transport,
disabled: entry.disabled === true,
oauthClient: entry.oauthClient as
| McpServerOAuthClientConfig
| undefined,
oauth,
};
});
@@ -138,6 +143,7 @@ export function clearServerOAuth(name: string): void {
);
}
delete existing.oauth;
delete existing.oauthClient;
servers[name] = existing;
});
} catch (error) {
@@ -148,6 +154,29 @@ export function clearServerOAuth(name: string): void {
}
}
export function setServerOAuthClient(
name: string,
client: McpServerOAuthClientConfig | undefined,
): void {
mutateServers((servers) => {
const existing = getOwnServerRecord(servers, name);
if (!existing)
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
const previous = existing.oauthClient as
| McpServerOAuthClientConfig
| undefined;
if (
previous?.clientId !== client?.clientId ||
previous?.clientSecret !== client?.clientSecret
) {
delete existing.oauth;
}
if (client) existing.oauthClient = client;
else delete existing.oauthClient;
servers[name] = existing;
});
}
export function toggleServer(name: string, disabled: boolean): void {
mutateServers((servers) => {
const existing =
+4 -3
View File
@@ -1,10 +1,10 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
} from "../../commands/schedule/client";
import { resolveAddress } from "../../commands/schedule/common";
import { resolveScheduleModelSelection } from "../../commands/schedule/model-selection";
import { CRON_PRESETS } from "./cron-presets";
function isCancel(value: unknown): value is symbol {
@@ -211,12 +211,13 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
}
}
const modelSelection = resolveScheduleModelSelection({ provider, model });
const created = (await client.createSchedule({
name: (name as string).trim(),
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? CLINE_DEFAULT_MODEL_ID,
provider: modelSelection.provider,
model: modelSelection.model,
mode: mode as "act" | "plan" | "yolo",
workspaceRoot: (workspace as string).trim(),
systemPrompt,
+1
View File
@@ -50,6 +50,7 @@ export default defineConfig({
},
test: {
environment: "node",
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.ts"],
exclude: ["src/**/*.e2e.test.ts", "src/tests/**"],
// Default 5s is tight on CI: each test uses `resetModules()` + dynamic `import("./main")`
+15
View File
@@ -0,0 +1,15 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Unit tests (and any subprocess they spawn, via env inheritance) must never
// touch the developer's real ~/.cline: a test that reaches core startup can
// otherwise spawn a real hub daemon against the real discovery record, or
// trigger a real background auto-update. Point everything at a per-worker
// temp dir before any test file is imported. Tests that need specific paths
// still override these per-test.
const isolatedRoot = mkdtempSync(join(tmpdir(), "cline-cli-vitest-"));
process.env.CLINE_DIR = join(isolatedRoot, ".cline");
process.env.CLINE_DATA_DIR = join(isolatedRoot, "data");
process.env.CLINE_HUB_DISCOVERY_PATH = join(isolatedRoot, "hub-discovery.json");
process.env.CLINE_NO_AUTO_UPDATE = "1";
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreSessionEvent } from "@cline/core";
import type { HubContext } from "./state";
// agent-events.ts only needs these siblings at runtime; mocking them keeps the
// test from loading @cline/core (state-payloads imports it at module scope),
// whose transitive provider deps do not survive vitest's module interop.
vi.mock("./approvals", () => ({ rejectPendingApprovalsForSession: vi.fn() }));
vi.mock("./state-payloads", () => ({ broadcastHubState: vi.fn() }));
import { handleSessionEvent } from "./agent-events";
function makeContextWithPeer(sessionId: string): {
ctx: HubContext;
sent: unknown[];
} {
const sent: unknown[] = [];
const ctx = {
peers: new Set([{ selectedSessionId: sessionId }]),
sessions: new Map(),
send: (_peer: unknown, payload: unknown) => sent.push(payload),
sendToSelectedPeers(id: string, payload: unknown) {
if (id === sessionId) sent.push(payload);
},
} as unknown as HubContext;
return { ctx, sent };
}
function agentErrorEvent(
sessionId: string,
recoverable: boolean,
): CoreSessionEvent {
return {
type: "agent_event",
payload: {
sessionId,
event: {
type: "error",
error: new Error(
"1 tool call(s) failed: [run_commands] Command not executed",
),
recoverable,
iteration: 1,
},
},
};
}
describe("handleSessionEvent — agent error events", () => {
it("forwards recoverable errors flagged as in-run notices, not turn outcomes", () => {
const { ctx, sent } = makeContextWithPeer("session-1");
handleSessionEvent(ctx, agentErrorEvent("session-1", true));
expect(sent).toEqual([
{
type: "error",
text: "1 tool call(s) failed: [run_commands] Command not executed",
recoverable: true,
},
]);
});
it("forwards non-recoverable errors unflagged", () => {
const { ctx, sent } = makeContextWithPeer("session-1");
handleSessionEvent(ctx, agentErrorEvent("session-1", false));
expect(sent).toEqual([
{
type: "error",
text: "1 tool call(s) failed: [run_commands] Command not executed",
recoverable: false,
},
]);
});
});
+14
View File
@@ -94,6 +94,13 @@ function forwardAgentEvent(
error: event.error,
},
});
return;
}
if (event.contentType === "media" && event.media) {
ctx.sendToSelectedPeers(sessionId, {
type: "assistant_media",
media: event.media,
});
}
return;
}
@@ -119,9 +126,16 @@ function forwardAgentEvent(
return;
}
if (event.type === "error") {
// Forwarded with the recoverable flag intact: recoverable errors are
// in-run notices (the MistakeTracker emits one per recorded mistake,
// e.g. a plan-mode guard-blocked run_commands call) and the run keeps
// going, so it is up to each peer to decide how to render them — the
// turn's outcome is decided by how it actually ends (turn_done /
// non-recoverable error).
ctx.sendToSelectedPeers(sessionId, {
type: "error",
text: event.error.message,
recoverable: event.recoverable,
});
}
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { dirname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import { ProviderSettingsManager } from "@cline/core";
import { ClientSettingsManager, ProviderSettingsManager } from "@cline/core";
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
import type { BrowserConfig } from "./types";
@@ -19,6 +19,12 @@ export const cliIndexPath = normalize(
);
export const providerSettingsManager = new ProviderSettingsManager();
export const desktopClientSettingsManager = new ClientSettingsManager({
clientId: "desktop",
});
desktopClientSettingsManager.initializeModesIfMissing(
providerSettingsManager.read().modes,
);
export const browserConfig: BrowserConfig = {
inviteRequired: Boolean(roomSecret),
+47 -8
View File
@@ -17,22 +17,25 @@ import {
type ProviderClient,
type ProviderProtocol,
type ProviderSettings,
parseProviderModeSettings,
readGlobalSettings,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
saveModeSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
toggleDisabledTool,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { getClineEnvironmentConfig, ProviderModeSchema } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
desktopClientSettingsManager,
providerSettingsManager,
workspaceRoot,
} from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -106,6 +109,7 @@ export async function handleDesktopCommand(
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
modeSettings: desktopClientSettingsManager.read().modes,
});
}
if (command === "list_provider_models") {
@@ -115,6 +119,18 @@ export async function handleDesktopCommand(
providerSettingsManager.getProviderConfig(provider),
);
}
if (command === "save_mode_settings") {
const mode = ProviderModeSchema.parse(args?.mode);
const settings =
args?.settings == null
? undefined
: parseProviderModeSettings(mode, args.settings);
return await saveModeSettings(
providerSettingsManager,
{ mode, settings },
desktopClientSettingsManager,
);
}
if (command === "save_provider_settings") {
return saveLocalProviderSettings(providerSettingsManager, {
...readProviderSettingsUpdate(args),
@@ -297,7 +313,13 @@ export async function handleDesktopCommand(
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
toggleDisabledTool(toolName);
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
await ctx.uiClient.toggleSetting({
type: "tools",
name: toolName,
workspaceRoot,
cwd: workspaceRoot,
});
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "set_tool_disabled") {
@@ -306,13 +328,30 @@ export async function handleDesktopCommand(
.map((name) => String(name ?? "").trim())
.filter(Boolean);
if (toolNames.length === 0) throw new Error("tool name is required");
setDisabledTools(toolNames, args?.disabled === true);
for (const name of toolNames) {
if (!ctx.uiClient)
throw new Error("Hub settings client is not connected");
await ctx.uiClient.toggleSetting({
type: "tools",
name,
enabled: args?.disabled !== true,
workspaceRoot,
cwd: workspaceRoot,
});
}
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "set_plugin_disabled") {
const pluginPath = String(args?.path ?? "").trim();
if (!pluginPath) throw new Error("plugin path is required");
setDisabledPlugin(pluginPath, args?.disabled === true);
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
await ctx.uiClient.toggleSetting({
type: "plugins",
path: pluginPath,
enabled: args?.disabled !== true,
workspaceRoot,
cwd: workspaceRoot,
});
return await listUserInstructionConfigs(workspaceRoot);
}
throw new Error(`unsupported desktop command: ${command}`);
+11 -6
View File
@@ -2,6 +2,7 @@ import process from "node:process";
import {
ensureCustomProvidersLoaded,
getLocalProviderModels,
isChatProviderModel,
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
@@ -86,12 +87,16 @@ export async function loadModels(
provider,
providerSettingsManager.getProviderConfig(provider),
);
const models: WebviewProviderModel[] = payload.models.map((model) => ({
id: model.id,
name: model.name,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
}));
const models: WebviewProviderModel[] = payload.models
.filter(isChatProviderModel)
.map((model) => ({
id: model.id,
name: model.name,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
inputModalities: model.inputModalities,
outputModalities: model.outputModalities,
}));
ctx.send(peer, { type: "models", providerId: provider, models });
}
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
import { mapHistoryToWebviewMessages } from "./session-mapping";
describe("mapHistoryToWebviewMessages", () => {
it("preserves tolerant handling of malformed history entries", () => {
expect(() => mapHistoryToWebviewMessages([null, 42])).not.toThrow();
});
it("hydrates assistant tool uses with following user tool results", () => {
const messages = mapHistoryToWebviewMessages([
{
@@ -228,4 +232,79 @@ describe("mapHistoryToWebviewMessages", () => {
},
});
});
it("hydrates provider model activities through ordinary tool events", () => {
const messages = mapHistoryToWebviewMessages([
{
id: "assistant-search",
role: "assistant",
content: "Bun 1.3.14 is current.",
metadata: {
modelToolActivities: [
{
toolCallId: "search-1",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun release" },
output: { answer: "1.3.14" },
},
],
},
},
]);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: "assistant",
toolEvents: [
{
toolCallId: "search-1",
name: "web_search",
state: "output-available",
input: { query: "latest Bun release" },
output: '{"answer":"1.3.14"}',
},
],
});
expect(messages[1]).toMatchObject({
id: "assistant-search",
role: "assistant",
text: "Bun 1.3.14 is current.",
});
});
it("keeps id-less history row ids stable as a provider result completes", () => {
const source = {
role: "assistant",
content: "Bun 1.3.14 is current.",
metadata: {
modelToolActivities: [
{
toolCallId: "search-1",
toolName: "web_search",
execution: "provider",
input: { query: "latest Bun release" },
},
],
},
};
const pending = mapHistoryToWebviewMessages([source]);
const completed = mapHistoryToWebviewMessages([
{
...source,
metadata: {
modelToolActivities: [
{
...source.metadata.modelToolActivities[0],
output: "1.3.14",
},
],
},
},
]);
expect(pending.at(-1)?.id).toBe("history-0");
expect(completed.at(-1)?.id).toBe("history-0");
});
});
+46 -7
View File
@@ -1,4 +1,10 @@
import { formatDisplayUserInput } from "@cline/shared";
import { projectSessionMessagesForDisplay } from "@cline/core";
import {
formatDisplayUserInput,
isGeneratedMedia,
type MessageWithMetadata,
validateImageMedia,
} from "@cline/shared";
import type {
WebviewActionSessionSummary,
WebviewChatMessage,
@@ -193,13 +199,17 @@ export function mapHistoryToWebviewMessages(
): WebviewChatMessage[] {
const mapped: WebviewChatMessage[] = [];
const toolLocations = new Map<string, HistoryToolLocation>();
const displayHistory = projectSessionMessagesForDisplay(
history as MessageWithMetadata[],
);
for (const [index, entry] of history.entries()) {
for (const entry of displayHistory) {
const { message, sourceIndex } = entry;
const record =
entry && typeof entry === "object"
? (entry as Record<string, unknown>)
: { content: entry };
const messageKey = asString(record.id) ?? `history-${index}`;
message && typeof message === "object"
? (message as unknown as Record<string, unknown>)
: { content: message };
const messageKey = asString(record.id) ?? `history-${sourceIndex}`;
const rawRole = asString(record.role)?.toLowerCase();
let role: WebviewChatMessage["role"] =
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
@@ -240,6 +250,35 @@ export function mapHistoryToWebviewMessages(
continue;
}
if (type === "image") {
const validation = validateImageMedia(
asString(part.mediaType),
asString(part.data) ?? "",
);
if (validation.ok) {
blocks.push({
id: `${messageKey}:media:${partIndex}`,
type: "media",
media: {
id: `${messageKey}:media:${partIndex}`,
modality: "image",
mediaType: validation.mediaType,
source: { type: "base64", data: validation.base64 },
},
});
}
continue;
}
if (type === "media" && isGeneratedMedia(part.media)) {
blocks.push({
id: `${messageKey}:media:${partIndex}`,
type: "media",
media: part.media,
});
continue;
}
if (type === "thinking" || type === "reasoning") {
reasoningRedacted =
pushReasoningBlock(
@@ -363,7 +402,7 @@ export function mapHistoryToWebviewMessages(
const text = textParts.join("\n");
const toolEventList = [...toolEvents.values()];
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
if (blocks.length === 0) {
continue;
}
if (!text && role === "user" && toolEventList.length > 0) {
+4 -4
View File
@@ -4,7 +4,7 @@ import {
type SessionRecord,
SessionSource,
} from "@cline/core";
import type { Message } from "@cline/llms";
import type { MessageWithMetadata } from "@cline/llms";
import type { WebviewConfig, WebviewReasonLevel } from "../webview-protocol";
import { rejectPendingApprovalsForSession } from "./approvals";
import { providerSettingsManager, workspaceRoot } from "./deps";
@@ -89,7 +89,7 @@ function buildSessionStartInput(
teamName?: string;
source?: SessionSource;
sessionMetadata?: Record<string, unknown>;
initialMessages?: Message[];
initialMessages?: MessageWithMetadata[];
},
): ClineCoreStartInput {
const mode = options?.mode === "plan" ? "plan" : "act";
@@ -137,7 +137,7 @@ function buildStartInputFromSession(
session: SessionRecord,
options?: {
sessionMetadata?: Record<string, unknown>;
initialMessages?: Message[];
initialMessages?: MessageWithMetadata[];
},
) {
const metadata =
@@ -348,7 +348,7 @@ export async function forkPeerSession(
try {
const rawMessages = (await ctx.cline.readMessages(
forkedFromSessionId,
)) as Message[];
)) as MessageWithMetadata[];
if (rawMessages.length === 0) {
ctx.send(peer, {
type: "fork_error",
+17 -43
View File
@@ -1,14 +1,11 @@
import { existsSync, readdirSync } from "node:fs";
import { extname, join, basename as pathBasename } from "node:path";
import {
createCoreSettingsService,
createUserInstructionConfigService,
discoverPluginModulePaths,
getCoreBuiltinToolCatalog,
getPluginDisplayName,
listHookConfigFiles,
listPluginTools,
readGlobalSettings,
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
@@ -99,49 +96,21 @@ export async function listUserInstructionConfigs(
}
};
const loadPlugins = (): Array<{
name: string;
path: string;
enabled: boolean;
}> => {
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
const pluginsByPath = new Map<
string,
{ name: string; path: string; enabled: boolean }
>();
const directories = resolvePluginConfigSearchPaths(
targetWorkspaceRoot,
).filter((d) => existsSync(d));
for (const directory of directories) {
try {
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) continue;
pluginsByPath.set(filePath, {
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
});
}
} catch {
// best-effort
}
}
return [...pluginsByPath.values()].sort((a, b) =>
a.name.localeCompare(b.name),
);
};
const [rules, workflows, skills, pluginTools] = await Promise.all([
const [rules, workflows, skills, settingsSnapshot] = await Promise.all([
loadUserInstructionSnapshot("rule"),
loadUserInstructionSnapshot("workflow"),
loadUserInstructionSnapshot("skill"),
listPluginTools({
workspacePath: targetWorkspaceRoot,
createCoreSettingsService().list({
workspaceRoot: targetWorkspaceRoot,
cwd: targetWorkspaceRoot,
}),
]);
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
// Pin spawn/teams availability so this listing matches the desktop
// sidecar's (sidecar/commands.ts) even if the preset defaults change.
const builtinToolCatalog = getCoreBuiltinToolCatalog({
enableSpawnAgent: true,
enableAgentTeams: true,
disabledToolIds: disabledTools,
});
@@ -151,7 +120,12 @@ export async function listUserInstructionConfigs(
workflows,
skills,
agents: loadAgents(),
plugins: loadPlugins(),
plugins: settingsSnapshot.plugins.map((plugin) => ({
name: plugin.name,
path: plugin.path,
enabled: plugin.enabled !== false,
contributions: plugin.contributions,
})),
tools: [
...builtinToolCatalog.map((tool) => ({
id: tool.id,
@@ -163,11 +137,11 @@ export async function listUserInstructionConfigs(
source: "builtin",
headlessToolNames: tool.headlessToolNames,
})),
...pluginTools.map((tool) => ({
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
...settingsSnapshot.tools.map((tool) => ({
id: tool.id,
name: tool.name,
description: tool.description,
enabled: tool.enabled,
enabled: tool.enabled !== false,
source: tool.source,
path: tool.path,
pluginName: tool.pluginName,
+11 -2
View File
@@ -3,6 +3,7 @@ import type {
ProviderListItem,
ProviderModel,
} from "@cline/core";
import type { GeneratedMedia } from "@cline/shared";
export type WebviewUsage = {
inputTokens?: number;
@@ -14,7 +15,7 @@ export type WebviewUsage = {
export type WebviewProviderModel = Pick<
ProviderModel,
"id" | "name" | "supportsReasoning"
"id" | "name" | "supportsReasoning" | "inputModalities" | "outputModalities"
> & {
supportsThinking?: boolean;
};
@@ -35,6 +36,7 @@ export type WebviewToolEvent = {
export type WebviewChatMessageBlock =
| { id: string; type: "text"; text: string }
| { id: string; type: "reasoning"; text: string; redacted?: boolean }
| { id: string; type: "media"; media: GeneratedMedia }
| {
id: string;
type: "tool";
@@ -272,7 +274,13 @@ export type WebviewInboundMessage =
export type WebviewOutboundMessage =
| { type: "status"; text: string }
| { type: "error"; text: string }
/**
* `recoverable: true` marks an in-run notice (e.g. a MistakeTracker
* mistake such as a plan-mode guard-blocked command) the run continues,
* so peers should not treat it as the turn's outcome. Absent/false means
* a genuine failure.
*/
| { type: "error"; text: string; recoverable?: boolean }
| {
type: "desktopCommandResult";
id: string;
@@ -296,6 +304,7 @@ export type WebviewOutboundMessage =
}
| { type: "assistant_delta"; text: string }
| { type: "reasoning_delta"; text: string; redacted?: boolean }
| { type: "assistant_media"; media: GeneratedMedia }
| { type: "tool_event"; text: string; event?: WebviewToolEvent }
| ({ type: "approval_request" } & WebviewToolApprovalRequest)
| {
+4 -2
View File
@@ -9,9 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@cline/ui": "workspace:*",
"@base-ui/react": "^1.3.0",
"@cline/shared": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@shikijs/langs": "^4.2.0",
@@ -19,7 +21,7 @@
"@streamdown/cjk": "^1.0.3",
"@tailwindcss/vite": "^4.2.1",
"@xyflow/react": "^12.10.1",
"ai": "^7",
"ai": "^7.0.58",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+53
View File
@@ -1,5 +1,7 @@
"use client";
import type { GeneratedMedia } from "@cline/shared/browser";
import { GeneratedMediaContent } from "@cline/ui";
import {
CheckIcon,
GitBranchIcon,
@@ -324,6 +326,29 @@ function appendReasoningDelta(
return [...current, assistantMessage];
}
function appendAssistantMedia(
current: ChatMessage[],
media: GeneratedMedia,
activeAssistantIdRef: MutableRefObject<string | undefined>,
): ChatMessage[] {
if (
current.some((message) =>
message.blocks?.some(
(block) => block.type === "media" && block.media.id === media.id,
),
)
) {
return current;
}
activeAssistantIdRef.current = undefined;
return [
...current,
createMessage("assistant", "", {
blocks: [{ id: `media:${media.id}`, type: "media", media }],
}),
];
}
type ToolResultEntry = {
query?: string;
result?: string;
@@ -637,6 +662,20 @@ function renderMessageBlocks(
<ReasoningContent>{block.text}</ReasoningContent>
</Reasoning>,
];
case "media": {
return [
<GeneratedMediaContent
classNames={{
image: "max-h-96 max-w-full rounded-md",
audio: "w-full",
video: "max-h-96 max-w-full",
unavailable: "rounded-md border p-3 text-sm",
}}
key={block.id}
media={block.media}
/>,
];
}
case "text":
if (options.isMeta) {
return [
@@ -807,6 +846,15 @@ export default function Chat({
setStatus(message.text);
return;
case "error":
// Recoverable errors are in-run notices (e.g. a plan-mode
// guard-blocked command recorded as a model mistake) — the
// run continues and any tool failure is already shown on
// its tool row, so keep the turn state and transcript
// intact. Only genuine failures end the turn.
if (message.recoverable) {
setStatus(`Recoverable error (run continues): ${message.text}`);
return;
}
setStatus(`Error: ${message.text}`);
setSending(false);
setHydratingSessionId(undefined);
@@ -966,6 +1014,11 @@ export default function Chat({
),
);
return;
case "assistant_media":
setMessages((current) =>
appendAssistantMedia(current, message.media, activeAssistantIdRef),
);
return;
case "tool_event":
setMessages((current) =>
appendToolEvent(
@@ -105,7 +105,7 @@ function ComboboxContent({
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
className="pointer-events-auto isolate z-[60]"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
@@ -3,9 +3,10 @@
import {
Bot,
Code,
Copy,
FileText,
MoreVertical,
Play,
Puzzle,
RefreshCw,
Server,
Trash2,
@@ -16,6 +17,12 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { desktopClient } from "@/lib/desktop-client";
@@ -97,6 +104,19 @@ type PluginItem = {
name: string;
path: string;
enabled: boolean;
contributions?: PluginContributions;
};
type PluginContributions = {
inspectionStatus?: "available" | "disabled" | "failed";
capabilities: string[];
tools: string[];
skills: string[];
rules: string[];
hooks: string[];
commands: string[];
mcpServers: string[];
providers: string[];
};
type ToolItem = {
@@ -856,6 +876,46 @@ export function CustomizationSectionView({
);
};
const renderPluginMenu = (target: LocalUninstallTarget) => {
const uninstalling = localUninstallingKeys.has(target.key);
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={`More actions for ${target.name ?? "plugin"}`}
className="m-0 size-auto shrink-0 p-0 text-muted-foreground"
onClick={(event) => event.stopPropagation()}
size="icon"
type="button"
variant="ghost"
/>
}
>
<MoreVertical className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
void navigator.clipboard.writeText(target.path ?? "")
}
>
<Copy className="size-4" />
Copy path
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={uninstalling}
onClick={() => void uninstallLocalPrimitive(target)}
>
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
{uninstalling ? "Uninstalling..." : "Uninstall"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const renderSkillCard = (item: CommandItem) => {
const key = `${item.type}:${item.path}`;
return (
@@ -910,13 +970,32 @@ export function CustomizationSectionView({
scope: ItemScope;
}) => {
const key = plugin.path;
const contributionGroups = [
{
label: "Tools",
items:
plugin.contributions?.tools ??
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => tool.name,
),
},
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
{
label: "Capabilities",
items: plugin.contributions?.capabilities ?? [],
},
].filter((group) => group.items.length > 0);
return (
<div
<details
key={plugin.path}
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<summary className="flex cursor-pointer list-none items-center gap-3">
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
@@ -929,61 +1008,61 @@ export function CustomizationSectionView({
onCheckedChange={() => {
void setPluginEnabled(plugin);
}}
onClick={(event) => event.stopPropagation()}
disabled={togglingPluginPaths.has(plugin.path)}
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
<div className="mt-3 ml-7 flex flex-col gap-2">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map((tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground">
{tool.description?.trim() || "No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
</div>
);
})}
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) === 0 && (
<p className="text-xs text-muted-foreground">
No plugin tools found.
</p>
)}
</div>
<div className="mt-3">
{renderLocalActionRow({
{renderPluginMenu({
key,
type: "plugin",
id: plugin.name,
name: plugin.name,
path: plugin.path,
})}
</summary>
<div className="mt-3">
{plugin.contributions?.inspectionStatus === "disabled" ? (
<p className="mb-2 text-xs text-muted-foreground">
Enable this plugin to inspect its dynamic contributions.
</p>
) : null}
{contributionGroups.length > 0 ? (
<div>
<div className="flex flex-wrap items-center gap-2 py-2 text-xs font-medium text-foreground">
<span className="mr-1">Contributions</span>
{contributionGroups.map((group) => (
<Badge key={group.label} variant="outline">
{group.label} {group.items.length}
</Badge>
))}
</div>
<div className="grid max-h-56 gap-3 overflow-y-auto pt-2 sm:grid-cols-2">
{contributionGroups.map((group) => (
<div key={group.label} className="min-w-0">
<p className="mb-1 text-xs font-medium text-muted-foreground">
{group.label}
</p>
<div className="flex flex-wrap gap-1">
{group.items.map((item) => (
<Badge key={item} variant="secondary">
{item}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
No plugin contributions found.
</p>
)}
</div>
</div>
{renderLocalActionMessage(key) ? (
<div className="mt-3">{renderLocalActionMessage(key)}</div>
) : null}
</details>
);
};
@@ -1000,53 +1079,56 @@ export function CustomizationSectionView({
disabled={togglingPluginPaths.has(plugin.path)}
aria-label={`Toggle ${plugin.name}`}
/>
{renderPluginMenu({
key: plugin.path,
type: "plugin",
id: plugin.name,
name: plugin.name,
path: plugin.path,
})}
</>
);
const renderPluginMatchedMeta = (plugin: PluginItem) => (
<p className="min-w-0 truncate text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
);
const renderPluginMatchedDetails = (plugin: PluginItem) => {
const pluginTools = pluginToolsByPluginKey.get(plugin.path) ?? [];
if (pluginTools.length === 0) {
const contributionGroups = [
{
label: "Tools",
items:
plugin.contributions?.tools ??
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => tool.name,
),
},
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
{
label: "Capabilities",
items: plugin.contributions?.capabilities ?? [],
},
].filter((group) => group.items.length > 0);
if (contributionGroups.length === 0) {
return null;
}
return (
<div className="grid gap-2">
{pluginTools.map((tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{tool.name}
</p>
<p className="text-xs text-muted-foreground">
{tool.description?.trim() || "No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
<div className="grid max-h-56 gap-3 overflow-y-auto sm:grid-cols-2">
{contributionGroups.map((group) => (
<div key={group.label} className="min-w-0">
<p className="mb-1 text-xs font-medium text-muted-foreground">
{group.label}
</p>
<div className="flex flex-wrap gap-1">
{group.items.map((item) => (
<Badge key={item} variant="secondary">
{item}
</Badge>
))}
</div>
);
})}
</div>
))}
</div>
);
};
@@ -1147,10 +1229,12 @@ export function CustomizationSectionView({
renderMatchedControls: () =>
renderPluginMatchedControls(item.plugin),
renderMatchedDetails:
Object.values(item.plugin.contributions ?? {}).some(
(values) => values.length > 0,
) ||
(pluginToolsByPluginKey.get(item.plugin.path) ?? []).length > 0
? () => renderPluginMatchedDetails(item.plugin)
: undefined,
renderMatchedMeta: () => renderPluginMatchedMeta(item.plugin),
}),
)
: catalogPrimitive === "mcp"
@@ -1476,7 +1560,6 @@ export function CustomizationSectionView({
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
@@ -1492,13 +1575,9 @@ export function CustomizationSectionView({
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
<div className="mt-3 ml-7 flex flex-col gap-2">
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
@@ -1513,19 +1592,6 @@ export function CustomizationSectionView({
"No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
</div>
);
},
@@ -1558,7 +1624,6 @@ export function CustomizationSectionView({
className="rounded-lg border border-border px-5 py-4"
>
<div className="flex items-center gap-3">
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{plugin.name}
</h3>
@@ -1574,13 +1639,9 @@ export function CustomizationSectionView({
aria-label={`Toggle ${plugin.name}`}
/>
</div>
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
{plugin.path}
</p>
<div className="mt-3 ml-7 flex flex-col gap-2">
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
(tool) => {
const isToggling = togglingToolIds.has(tool.id);
return (
<div
key={tool.id}
@@ -1595,19 +1656,6 @@ export function CustomizationSectionView({
"No description available."}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{tool.enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={tool.enabled}
onCheckedChange={() => {
void setToolEnabled(tool);
}}
disabled={isToggling || !plugin.enabled}
aria-label={`Toggle ${tool.name}`}
/>
</div>
</div>
);
},
@@ -1154,9 +1154,7 @@ export function RoutineSchedulesContent() {
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingSchedule ? "Edit Routine" : "Create Routine"}
</DialogTitle>
<DialogTitle>Schedule</DialogTitle>
<DialogDescription>
{editingSchedule
? "Update this scheduler routine."
+13 -4
View File
@@ -1,11 +1,16 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/schibsted-grotesk";
@import "@fontsource-variable/inter";
@import "@fontsource-variable/geist-mono";
@custom-variant dark (&:is(.dark *));
:root {
--font-weight-normal: 480;
--font-weight-medium: 560;
--font-weight-semibold: 640;
--font-weight-bold: 640;
--background: oklch(1 0 0);
--foreground: oklch(0.18 0.008 255);
--card: oklch(1 0 0);
@@ -42,10 +47,10 @@
}
@theme inline {
--font-sans: "Schibsted Grotesk Variable", sans-serif;
--font-sans: "Inter Variable", sans-serif;
--font-mono:
ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono",
monospace;
"Geist Mono Variable", ui-monospace, "SFMono-Regular", Menlo, Consolas,
"Liberation Mono", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -197,6 +202,10 @@
}
.dark {
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 600;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
@@ -16,12 +16,27 @@ export type ProviderModelCatalog = {
};
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? []).map((model) => model.id);
return (models ?? [])
.filter(
(model) =>
(model.inputModalities === undefined ||
model.inputModalities.includes("text")) &&
(model.outputModalities === undefined ||
model.outputModalities.includes("text")),
)
.map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter((model) => model.supportsReasoning)
.filter(
(model) =>
(model.inputModalities === undefined ||
model.inputModalities.includes("text")) &&
(model.outputModalities === undefined ||
model.outputModalities.includes("text")) &&
model.supportsReasoning,
)
.map((model) => model.id);
}
@@ -31,7 +46,10 @@ export function buildProviderModelCatalog(
return {
providers,
enabledProviderIds: providers
.filter((provider) => provider.enabled)
.filter(
(provider) =>
provider.enabled && toModelIds(provider.modelList).length > 0,
)
.map((provider) => provider.id),
providerModels: Object.fromEntries(
providers.map((provider) => [
@@ -1,11 +1,17 @@
import type { ProviderModesSettings } from "@cline/shared/browser";
export interface ProviderModel {
id: string;
name: string;
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
export type ProviderConfigFieldType =
| "text"
| "password"
@@ -63,6 +69,7 @@ export interface ProviderSettingsUpdate {
export interface ProviderCatalogResponse {
providers: Provider[];
settingsPath: string;
modes: ProviderModesSettings;
}
export interface ProviderModelsResponse {
+97
View File
@@ -1,5 +1,102 @@
# Cline Code Desktop Changelog
## 0.0.14-beta.1
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
- Onboarding now includes a GitHub integration step.
- Early proof of concept for running sessions in SSH remote environments.
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
## 0.0.13
- Added an app font size setting. A slider in Settings scales the interface, and your size is applied before the window paints, so launching no longer flashes at the old size first.
- Models that support it can now search the web during a task. Turn it on with the Web Search toggle in Settings; the searches and their results appear in the transcript and are still there when you reopen the session.
- Extended thinking budgets reach the provider again on Cline Pass — they had silently stopped applying when the gateway moved off the generic OpenAI-compatible path.
- Two Cline installs on different builds no longer shut each other's Hub down in a loop, which was killing live sessions with an abnormal disconnect.
- The app no longer replaces a Hub that is still serving sessions. It attaches to it instead, and the swap happens once that Hub goes idle.
- The "update required" dialog no longer interrupts when the Hub is only finishing an update on its own. This app is already the newer build, nothing was being asked of you, and the Hub replaces itself once its sessions end.
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session.
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board.
## 0.0.12
- Every tool call now gets its own row in the transcript, with its own icon, status, and expandable detail — no more "Read 3 files · Ran 2 commands" grouping. Commands read like a terminal (`$ bun test`) with their captured output on expand, and edits show their diffs inline, one per hunk.
- Running tool rows are highlighted in brand violet and settle to gray when they finish; errors stay red.
- File diffs — both in chat rows and the diff panel — now render through a shared syntax-highlighted renderer that follows the app's theme instead of the browser's.
- Refreshed session transcript layout, message surfaces, and composer, with new Inter and Geist Mono typography.
- The thinking indicator now stays up during quiet stretches of a turn, such as while tool arguments are streaming, so the turn no longer looks frozen.
- Message actions (copy, fork, timestamp) no longer crowd the message text, and expanded panels render at full opacity instead of faded.
- On the welcome screen the chat input is centered and top-aligned, and prompt suggestions are temporarily hidden.
- The first turn of a fresh session no longer wedges the composer on "Agent is working…" forever.
- Scheduled runs no longer appear in the session sidebar and history list.
- Reconnecting to a stale managed Cline Hub daemon is fixed. When another Cline install ships a newer Hub, the app now prompts to update and restart — and stages the app update first, so it no longer relaunches into the same version and immediately re-prompts.
- The Hub daemon now shuts down cleanly instead of exiting with an error when a client is still connected.
- The Claude Code provider is usable for agentic work again: sessions are anchored on the workspace folder, your `~/.claude` and project settings are loaded, and file edits under the workspace are allowed instead of every write being refused with no approval prompt.
- Truncated tool-call JSON is now rejected instead of being silently "repaired" into wrong arguments.
- Fixed strict providers (seen on Vercel with kimi-k3) rejecting a turn with "user message must have content" when a message held only empty text.
- Fixed a mid-turn crash on streamed tool calls with non-zero indexes, hit through LiteLLM's Anthropic passthrough.
- Compaction now respects your Max Output Tokens setting instead of a hardcoded 1024-token cap — reasoning models were spending the entire budget thinking, so no summary arrived and compaction was skipped every time.
- Vertex AI: added Fable 5 and custom model IDs, and the global-region picker no longer hides models from the live catalog.
## 0.0.11
- Images can now be pasted straight from the clipboard into the composer.
- Opening a folder that isn't a git repo no longer shows git jargon, and the welcome suggestions now adapt to what's actually in the folder instead of assuming a code project.
- The folder picker now reports failures instead of doing nothing, and offers a manual path entry as a fallback.
- Opening an existing session no longer overwrites the model you had selected.
- The diff panel now resolves file paths against the session's working directory, so diffs open correctly for sessions rooted outside the app's own directory.
- `/team` prompts now run through the core runtime.
- Failed turns surface their error in the transcript instead of leaving the chat blank.
- Plugins left behind as empty install directories are no longer listed as installed, and plugin settings and contributions are now managed centrally with atomic toggles.
- Fixed a startup script-load error, and webview errors are now attributed to the source URL that caused them.
- Signing out is handled as a normal state rather than surfacing as a command error.
- Native-feel and performance polish: the browser context menu is suppressed on app chrome (kept for text fields and selections), UI chrome is no longer text-selectable while chat content still is, inner scrollers no longer rubber-band the window, Settings/Sessions/Onboarding/Diff load lazily, the composer no longer flickers the caret on every keystroke, slash commands are cached across menu opens, and Escape closes the provider/model picker.
- Tool output no longer nests its own scrollbar.
- Prompts queued during a turn now survive being interrupted — they're preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped. Queued turns that fail are reported as failures.
- Session context stays durable across aborts and hub restarts.
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely.
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright.
- LiteLLM requests route through Chat Completions instead of the Responses API.
- Network interruptions mid-stream but before any model output are retried instead of failing the turn.
- Checkpoints are picked up when git is initialized part-way through a session, and checkpoint diffs include files that were untracked when the snapshot was taken.
- Scheduled run reports carry execution context — schedule metadata, durations, and lifecycle error details.
## 0.0.10
- Remote MCP servers can now authenticate with OAuth from Settings → MCP — authorize a server, see its auth status, and cancel or retry a pending authorization. Servers that require a pre-registered OAuth client (client ID/secret) instead of dynamic registration are now supported, and stored tokens are invalidated when a server's client configuration changes.
- MCP errors are now shown on the individual server rather than as a page-level error, and a server with invalid configuration is surfaced with its error instead of silently disappearing from the list.
- Failed turns no longer fail silently. Sending a message with no model credentials — or any queued turn that fails — now shows an error in the transcript, enriched with the underlying cause and a pointer to Settings → Models.
- Fixed the first message of a chat (and some queued messages) rendering twice.
- Fixed the composer getting stuck on "Agent is working…" after a turn already finished.
- New "Connect a model" notice on the welcome screen when no provider has credentials, with one click to onboarding or model settings. It reacts live as you add credentials, and correctly recognizes Bedrock/Vertex and keyless local endpoints as already connected.
- Added "Get an API key" links for popular providers in onboarding and Settings → Models, plus a link to the Cline dashboard from the Cline API key form.
- The onboarding welcome step now explains what Cline is.
- The stop button is now actually visible and clickable, Esc stops the current turn, and new shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for settings.
- Reasoning controls now resolve consistently across AI SDK providers, including Ollama, so effort levels and thinking on/off are honored wherever the provider supports them.
- Vertex AI: credential refreshes now use the configured fetch, fixing ADC authentication behind proxies and custom networking.
- Refreshed the bundled provider and model catalog.
## 0.0.9
- Cline Code now ships as a single universal macOS download that runs natively on both Apple Silicon and Intel — no more picking the right architecture. Existing per-architecture installs migrate to it automatically on their next update.
- Session history can now be filtered by where a session came from — Desktop, CLI, extension, or scheduled — from a new filter control in the sidebar.
- The composer now shows a token usage ring for the active model's context window, with cumulative cost, and it changes color as you approach the limit.
- Skills now appear in the slash command menu alongside workflows, and commands that share a name are disambiguated instead of shadowing each other.
- Installed plugins now show their real package names instead of all appearing as "index".
- The agent header can be dragged to move the window again, including on read-only titles.
- Chat message actions (copy, fork, edit, restore) no longer collide with the descenders of the message's last line.
- Application errors are now reported in diagnostics, and the packaged app's telemetry configuration is baked into the sidecar at build time — previously the packaged build shipped with it empty, so no diagnostics were ever sent.
- Plan mode now hard-blocks file-editing shell commands rather than relying on prompting alone; read-only investigation still works.
- Running out of context is now recovered from automatically — the run compacts and retries once instead of failing with a raw provider error.
- Empty model responses are now retried on every provider, not just Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported".
- Bedrock prompt caching works again — cache reads and writes were always 0 — and Bedrock foundation models now route through geo inference profiles.
- Reasoning models on OpenAI-compatible endpoints now get the correct token parameter, and models without image support substitute image content instead of failing.
- Refreshed the bundled provider and model catalog, adding Infomaniak and SCX.ai.
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native provider.
## 0.0.8
- Edit any earlier message in a conversation — the app forks the session at that point, rewinds the workspace to that run's checkpoint, and re-runs from your edited prompt. Restores are transactional and workspace-atomic, so a failed restore won't leave you half-rewound.
+136
View File
@@ -0,0 +1,136 @@
# Desktop Experimental Branch & Beta Channel
How experimental desktop features are developed on the `desktop-experimental`
branch, shipped to users as **Cline Code Beta**, and graduated into `main`.
The release mechanics (workflow internals, secrets) live in
[`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml)
and the `publish-desktop` skill
([`.cline/skills/publish-desktop/SKILL.md`](../../../.cline/skills/publish-desktop/SKILL.md));
this doc is the process.
## What the beta channel is
The beta is a **separate app**, not a mode of the stable app:
- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta`
(stable is `Cline Code` / `bot.cline.app`) — set by
[`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which
is layered over `tauri.release.conf.json` at build time.
- Both apps install and run **side by side**, so people can compare beta
features against stable directly.
- Each channel polls its own auto-update feed: stable installs poll the
rolling `desktop-latest` release, beta installs poll the rolling
`desktop-beta` release. The feed URL is compiled into the binary, so a beta
install only ever receives beta builds and vice versa. **Never delete either
rolling release.**
- Yes, the names are asymmetric: `desktop-latest` *is* the stable feed. Do
not rename it to `desktop-stable` — the URL is baked into every stable
binary ever shipped and the updater has no fallback endpoint, so renaming
(or deleting) the release silently strands every existing install on a
dead feed forever. Renaming would mean maintaining both feeds for as long
as any pre-rename install exists, i.e. permanently. Same applies to
`desktop-beta` once the first beta ships.
- Both apps share `~/.cline` (provider credentials, global settings, hub
daemon — the hub is multi-client by design, same as running the CLI and the
app together). A beta that requires a newer hub build can trigger the
hub-update-required flow in the stable app or vice versa; that's expected
version skew, not a bug in itself.
Users join the beta by downloading the beta DMG from its GitHub release
(announced on Slack). There is no auto-downgrade: leaving the beta means
deleting the beta app (stable was never touched). Beta users get the stable
version of a graduated feature through the normal stable release of the
stable app they still have installed.
## Branch model
`desktop-experimental` is a long-lived branch where experimental features
bake before graduating to `main`.
- **Feature PRs target `desktop-experimental`** and are merged there to
iterate. Keep the feature's original PR against `main` open **as a draft**
— it accumulates the follow-up work done on the experimental branch and
documents intent to graduate.
- **Graduation** = a fresh (or the updated draft) PR against `main`
containing the feature plus everything learned on the experimental branch.
Treat it as a normal `main` PR: full review, tests, no experimental
scaffolding.
- **Sync direction is one-way**: merge `main` into `desktop-experimental`
regularly — at minimum after every stable desktop release — so the branch
never drifts far. Never merge `desktop-experimental` into `main` wholesale.
- **Merge-conflict policy** when syncing `main` in:
- `package.json` / `src-tauri/tauri.conf.json` versions: keep the branch's
beta version (see versioning rule below for when to bump its base).
- `CHANGELOG.md`: keep both sides' sections, newest version first — stable
and beta sections interleave by recency.
- Feature code: main wins for anything that graduated; resolve toward
main's reviewed form.
## Versioning & tags
- Beta versions are prereleases of the **next** stable version: stable
`0.0.13` → betas `0.0.14-beta.1`, `0.0.14-beta.2`, …
- Tag format: `desktop-vX.Y.Z-beta.N`, tagged on a `desktop-experimental`
commit. Stable tags (`desktop-vX.Y.Z`, no suffix) stay on `main`; the
workflow enforces both shapes and each channel's branch ancestry.
- When a stable release ships with a version ≥ the current beta base, bump
the base for the next beta (stable `0.0.14` out → next beta is
`0.0.15-beta.1`). A beta must never share its `X.Y.Z` base with an
already-shipped stable.
- Semver keeps the channels ordered: `0.0.14-beta.N` sorts above stable
`0.0.13` and below the eventual `0.0.14`.
## Cutting a beta release
Manual, like stable — no nightly automation. Short form (the
`publish-desktop` skill walks through it):
1. On `desktop-experimental`: merge `main` in, bump both version files to the
new beta version, prepend a `## X.Y.Z-beta.N` section to `CHANGELOG.md`,
commit, push.
2. Tag `desktop-vX.Y.Z-beta.N` on that commit and push the tag.
3. Dispatch **from `main`** with the beta channel:
```sh
gh workflow run desktop-publish.yml --ref main \
-f git_tag=desktop-vX.Y.Z-beta.N \
-f channel=beta \
-f confirm_publish=publish
```
4. Approve the `PublishDesktop` environment gate; the workflow builds, signs,
and notarizes the beta bundle, creates a **prerelease** GitHub release,
refreshes `desktop-beta/latest.json`, and posts to Slack — same
announcement path as stable, marked as beta.
**Why dispatch from `main` when the code is on `desktop-experimental`?**
Security invariant: the workflow run executes `main`'s copy of
`desktop-publish.yml` and only the *checkout* points at the beta tag (the
`validate` job pins the tag to `desktop-experimental` ancestry). The
signing-secret gates — the `github.ref == main` check and the
`PublishDesktop` environment's main-only deployment-branch policy — stay
exactly as they are for stable, and a workflow file edited on
`desktop-experimental` can never reach the signing secrets. Do **not** add
`desktop-experimental` to the PublishDesktop deployment-branch policy.
One thing dispatch-from-main does *not* cover: the build job checks out the
tag and runs its build scripts (dependency install hooks, `build:sdk`,
Tauri's `beforeBuildCommand`, `build.rs`) with the signing secrets in scope
— true for stable and beta alike. The control is the PublishDesktop
required-reviewer approval: **approving a publish means vouching for the
code the tag points at**, not just for the release happening. That is why
`desktop-experimental` must keep main-grade merge controls (branch
protection, maintainer-only pushes) — anyone who can land code there can get
it executed alongside the signing keys once a publish of it is approved.
## Guardrails worth knowing about
- The updater comparator is a plain semver "newer than". Feed separation is
the entire safety story: a beta manifest on `desktop-latest` would
auto-update every stable install onto the beta. The workflow guards this
three ways: the stable channel rejects prerelease tags, the feed target is
derived fail-closed from the channel (and cross-checked in the release
job), and the build asserts the compiled binary embeds exactly its own
channel's feed URL before anything is signed into a release.
- `tauri.beta.conf.json` must exist on the tagged commit (the build checks
out the tag), so keep it present on both `main` and `desktop-experimental`.
+91
View File
@@ -30,6 +30,59 @@ API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
## SSH Remote Environments (v0)
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
profile does not activate it. From the welcome chat, open the environment
selector beside the workspace picker and choose the saved host; that selection
starts the SSH connection at the remote user's home directory. Choose **Add
project…** from the normal workspace selector to browse that machine and select
a project, or choose **Local** in the environment selector to disconnect. Recent
and last-used workspaces are remembered separately for each SSH host and for
the local machine.
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
configuration (including its configured port), or enter a port to override it.
The desktop keeps its webview and native integration local; only the
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
workspace discovery, Git metadata, and session persistence therefore run on the
SSH host, while approvals and live session events return to the desktop.
The desktop stores host metadata at
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
identity-file path, never private-key contents. On first connect it uploads a
content-addressed, branch-matched, self-contained Hub helper under
`~/.cline/code/remote/`, binds the Hub to remote loopback, and forwards it to a
random local loopback port. Linux x64 and arm64 helpers are bundled by
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
supported in v0. The current helper is roughly 110 MB because it includes its
own runtime. It is copied once per matching desktop build and cached, with no
`apt`, `npm`, root access,
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
remote Hub but leaves the helper cached for a faster reconnect. The helper
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
MCP executables remain visible.
The desktop uses its own discovery record, so an existing Cline CLI/Hub on the
same account is neither replaced nor stopped. Both Hub processes can coexist
while the desktop is connected; this isolation is intentional for the proof of
concept so a branch-matched desktop helper cannot disrupt another Cline build.
v0 intentionally leaves file attachments and opening a remote file in a local
editor disabled. Text, images, file mentions/search, Git branch operations,
session history, and remote agent tools are supported. The current desktop
provider access/API token is sent through the authenticated tunnel for the
session; reusable OAuth refresh credentials are not copied into remote provider
settings.
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
connection at the SSH user's home, starts an agent session in the test
workspace with the selected desktop provider, asks the agent to read
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
that its messages can be read back.
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
@@ -55,6 +108,12 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
shipped apps can't verify new updates).
There is also a beta channel ("Cline Code Beta", a separate app that installs
side by side with stable) cut from the `desktop-experimental` branch and
served by the rolling `desktop-beta` release — the same never-delete rule
applies to it. The experimental-branch process and beta release flow live in
[`EXPERIMENTAL.md`](./EXPERIMENTAL.md).
## Shareable Desktop Packages (manual fallback)
Tauri desktop bundles are OS-specific, so build each package on the target OS:
@@ -135,6 +194,16 @@ Desktop transport envelope:
## Data + Storage
- Session artifacts are written under `~/.cline/data/sessions/<sessionId>/` (or `CLINE_SESSION_DATA_DIR`).
- Desktop avatar packages live under `~/.cline/avatars/<avatar-name>/`. Each package
contains a v2 `spritesheet.webp` (or PNG) and either an `avatar.json` or
`pet.json` manifest with
`id`, `displayName`, `description`, `spriteVersionNumber: 2`, and
`spritesheetPath`. If both manifests exist, `avatar.json` takes precedence.
The bundled Cline Bot avatar is selected and enabled by default, with Mom also
available as a bundled option.
Visibility and the selected installed avatar are configured independently under
**Settings → General → Desktop avatar**; both values are stored in
`~/.cline/avatars/selected.json`.
- Canonical replay/export artifact: `<sessionId>.messages.json`.
- `<sessionId>.messages.json` is expected to contain ordered messages plus assistant `modelInfo` and `metrics` (including cache token fields when provided by the model runtime).
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
@@ -155,6 +224,14 @@ Logging can be configured with the same environment variables as the CLI:
- `CLINE_LOG_PATH` overrides the log destination.
- `CLINE_LOG_NAME` overrides the logger name.
In a development webview, sidecar voice-input diagnostics are also streamed to
the webview console as `[desktop:voice-input]` entries. Production builds can
enable the same console stream with `NEXT_PUBLIC_CLINE_DEBUG_LOGS=1` at build
time, or at runtime from DevTools with
`localStorage.setItem("cline.debugLogs", "1")` followed by a reload. Diagnostic
events include the selected provider/model and sanitized endpoint, but never
credentials, request headers, recorded audio, or transcript contents.
## Troubleshooting
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
@@ -165,3 +242,17 @@ Logging can be configured with the same environment variables as the CLI:
The next desktop or CLI Hub connection will reuse a compatible running Hub or
replace an incompatible one through the shared discovery path.
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
- Speech input requires an enabled provider whose models.dev metadata identifies
a dedicated `audio`-to-`text` model, or the built-in ElevenLabs provider with
its Scribe v2 model. Choose the voice input provider and model explicitly under
**Settings → Models → Voice input**. That selection is stored separately from
the chat model as `modes.voiceInput` in
`~/.cline/data/settings/providers.json`; provider credentials remain in their
existing provider entry and never enter the webview. ElevenLabs uses its native
`/v1/speech-to-text` API. Text-to-speech models with `output: ["audio"]` are
not used for microphone transcription.
- Streaming transcription models, such as Vercel AI Gateway's
`openai/gpt-realtime-whisper`, update the composer while the user speaks.
The sidecar mints a short-lived transcription token; the long-lived gateway
credential is never sent to the webview. Batch models such as
`openai/whisper-1` continue to transcribe after recording stops.
+11 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.8",
"version": "0.0.14-beta.1",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -22,17 +22,22 @@
"pretypecheck": "bun run build:ui",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
"@ai-sdk/gateway": "4.0.31",
"@ai-sdk/google": "4.0.44",
"@ai-sdk/openai": "4.0.41",
"@ai-sdk/react": "4.0.44",
"@base-ui/react": "^1.2.0",
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource/azeret-mono": "^5.2.9",
"@pierre/diffs": "^1.3.0",
"@fontsource-variable/geist-mono": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
@@ -65,13 +70,12 @@
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@vercel/analytics": "1.6.1",
"ai": "^7.0.58",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "4.1.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"next": "16.2.11",
@@ -83,13 +87,12 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"shiki": "^4.0.2",
"sonner": "^1.7.1",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
"zod": "^3.25.76"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
@@ -38,22 +38,54 @@ const sidecarOutfile = (targetTriple: string): string => {
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
};
const buildSidecar = async (targetTriple: string): Promise<string> => {
const outfile = sidecarOutfile(targetTriple);
const buildSidecar = async (
targetTriple: string,
outfile = sidecarOutfile(targetTriple),
entrypoint = "./sidecar/index.ts",
minify = false,
): Promise<string> => {
const bunTarget = resolveBunCompileTarget(targetTriple);
// Telemetry config must be inlined into the compiled binary: a packaged
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
// without this the sidecar silently ships with telemetry disabled.
// Verify with `<binary> --telemetry-selfcheck` after building.
const defines = telemetryDefineArgs();
const optimizationArgs = minify ? ["--minify"] : [];
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
// launch directory before our entrypoint runs. Remote helpers are launched
// from an SSH user's home directory, so that behavior can both make the
// helper fail on an unrelated dotenv file and leak workspace credentials
// into the Hub process. Packaged binaries must depend only on their explicit
// process environment and compiled configuration.
const runtimeIsolationArgs = [
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
];
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
}
return outfile;
};
// SSH environments run the same Hub build as the desktop in a dedicated
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
const buildRemoteHelpers = async (): Promise<void> => {
for (const targetTriple of [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]) {
await buildSidecar(
targetTriple,
`./src-tauri/bin/remote-helpers/code-sidecar-${targetTriple}`,
"./sidecar/remote-helper.ts",
true,
);
}
};
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
// but expects sidecars (externalBin) to already be fat binaries named
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
@@ -68,12 +100,13 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
const main = async () => {
const targetTriple = await resolveTargetTriple();
await $`mkdir -p src-tauri/bin`;
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacSidecar();
return;
} else {
await buildSidecar(targetTriple);
}
await buildSidecar(targetTriple);
await buildRemoteHelpers();
};
main().catch((error: unknown) => {
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
});
it("inlines the cloud-agents toggle only when set at build time", () => {
const withoutToggle = defineMap(telemetryDefineArgs({}));
expect(withoutToggle).not.toHaveProperty(
"process.env.CLINE_CODE_CLOUD_AGENTS",
);
const withToggle = defineMap(
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
);
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
});
it("JSON-escapes values so headers with quotes survive the define", () => {
const defines = defineMap(
telemetryDefineArgs({
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
"ERROR_SERVICE_API_KEY",
] as const;
/** Optional build-time overrides for packaged dogfood builds. */
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
const define = (name: string, value: string) => {
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
};
for (const name of OPTIONAL_SECRET_ENV_VARS) {
for (const name of [
...OPTIONAL_SECRET_ENV_VARS,
...OPTIONAL_FEATURE_ENV_VARS,
]) {
const value = env[name];
if (value) {
define(name, value);
@@ -0,0 +1,176 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ClineCore,
ProviderSettingsManager,
RuntimeOAuthTokenManager,
resolveProviderApiKeyFromSettings,
SessionSource,
toProviderConfig,
} from "@cline/core";
import { RemoteEnvironmentService } from "../sidecar/remote-environments";
const required = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
};
async function main(): Promise<void> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-"));
const service = new RemoteEnvironmentService({
profilesPath: join(temporaryDirectory, "remote-environments.json"),
helperBinaryPath: required("CLINE_SSH_TEST_HELPER"),
knownHostsPath: join(temporaryDirectory, "known_hosts"),
commandTimeoutMs: 60_000,
uploadTimeoutMs: 5 * 60_000,
});
let core: ClineCore | undefined;
try {
const helperPath = required("CLINE_SSH_TEST_HELPER");
const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE");
const profile = await service.upsert({
name: "SSH proof host",
host: required("CLINE_SSH_TEST_HOST"),
user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined,
identityFile: required("CLINE_SSH_TEST_KEY"),
});
const connection = await service.connect(profile.id);
const marker = await service.run(profile.id, {
command: "sed",
args: ["-n", "1p", "REMOTE_MARKER.txt"],
cwd: workspaceRoot,
});
const providerSettings = new ProviderSettingsManager();
const stored = providerSettings.read();
const providerId = stored.lastUsedProvider;
if (!providerId)
throw new Error("No configured desktop provider is available");
const settings = providerSettings.getProviderSettings(providerId);
if (!settings)
throw new Error(`No settings found for provider ${providerId}`);
const modelId = settings.model || "meta/muse-spark-1.2";
const providerConfig = {
...toProviderConfig(
{ ...settings, model: modelId },
{ includeKnownModels: false },
),
};
delete providerConfig.refreshToken;
const oauth = await new RuntimeOAuthTokenManager({
providerSettingsManager: providerSettings,
}).resolveProviderApiKey({ providerId });
const apiKey =
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(providerSettings, providerId);
if (!apiKey)
throw new Error(`No credential found for provider ${providerId}`);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const eventNames: string[] = [];
const unsubscribe = core.subscribe((event) => {
eventNames.push(event.type);
});
const started = await core.start({
config: {
providerId,
modelId,
apiKey,
providerConfig,
workspaceRoot,
cwd: workspaceRoot,
systemPrompt: "",
mode: "act",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
},
source: SessionSource.DESKTOP,
interactive: true,
toolPolicies: { "*": { autoApprove: true } },
});
const result = await core.send({
sessionId: started.sessionId,
prompt:
"Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.",
});
const sessions = await core.list(20, { hydrate: false });
const messages = await core.readMessages(started.sessionId);
unsubscribe();
await core.dispose("desktop_ssh_proof_reconnect");
core = undefined;
await service.disconnect(profile.id);
const reconnected = await service.connect(profile.id);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: reconnected.endpoint,
authToken: reconnected.authToken,
workspaceRoot: reconnected.workspaceRoot,
cwd: reconnected.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const sessionsAfterReconnect = await core.list(20, { hydrate: false });
const messagesAfterReconnect = await core.readMessages(started.sessionId);
const resultText = result?.text ?? "";
const report = {
connected: true,
remote: `${connection.platform}/${connection.arch}`,
connectionRoot: connection.workspaceRoot,
workspaceRoot: started.manifest.workspace_root,
sessionId: started.sessionId,
listContainsSession: sessions.some(
(session) => session.sessionId === started.sessionId,
),
messageCount: messages.length,
reconnected: true,
reconnectListContainsSession: sessionsAfterReconnect.some(
(session) => session.sessionId === started.sessionId,
),
reconnectMessageCount: messagesAfterReconnect.length,
helperBytes: (await stat(helperPath)).size,
sshMarker: marker.stdout.trim(),
agentText: resultText,
agentObservedMarker: resultText.includes("remote workspace proof"),
eventNames: [...new Set(eventNames)],
};
if (
report.sshMarker !== "remote workspace proof" ||
!report.agentObservedMarker ||
!report.listContainsSession ||
!report.reconnectListContainsSession ||
report.messageCount < 2 ||
report.reconnectMessageCount < 2 ||
!report.eventNames.includes("agent_event")
) {
throw new Error(`SSH proof failed: ${JSON.stringify(report)}`);
}
process.stdout.write(`${JSON.stringify(report)}\n`);
} finally {
await core?.dispose("desktop_ssh_proof_complete");
await service.dispose();
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -17,7 +17,11 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # Shared-Hub chat session adapter
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
├── cline-auth.ts # Refresh-aware Cline auth token resolution
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -89,7 +93,7 @@ online:
```typescript
const pendingApprovals = new Map<string, {
resolve: (result: ToolApprovalResult) => void;
resolve: (result: ToolApprovalResult) => void | Promise<void>;
request: ToolApprovalRequest;
}>();
@@ -97,6 +101,10 @@ const pendingApprovals = new Map<string, {
// When frontend responds → resolve promise
```
Cloud sessions route approvals the same way, but the resolver forwards the
response to the sandbox Hub (`approval.respond`), which is why `resolve` may
be async.
### 3. Provider Management — Direct ProviderSettingsManager
```typescript
@@ -138,22 +146,39 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | shared Hub through `ClineCore` |
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions route to `CloudSessionManager` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
| `create_streaming_transcription_session` | mints a short-lived, transcription-bound browser token without exposing provider credentials |
| `transcribe_audio` | configured voice input selection + provider credentials |
| `save_provider_settings` | `saveLocalProviderSettings` |
| `add_provider` | `addLocalProvider` |
| `run_provider_oauth_login` | `loginLocalProvider` |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery |
| `list_discovered_sessions` | Merged discovery |
| `read_session_messages` | Session data readers |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions (2s budget) |
| `list_discovered_sessions` | Merged discovery (local + cloud) |
| `read_session_messages` | Session data readers; cloud sessions read through the sandbox Hub |
| `read_session_hooks` | Session data readers |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
| `get_desktop_settings` | `readDesktopSettings()` |
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
| `list_mcp_servers` | Direct file I/O |
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
| `upsert_mcp_server` | Direct file I/O |
| `delete_mcp_server` | Direct file I/O |
| `get_git_branch` | async `execFile("git", ...)` |
Realtime mode sessions expose only one browser-callable tool, `run_cline`, when
the selected realtime model supports tool calling. The webview implements that
tool by sending the request through the active Cline chat session and returning
its persisted result to the realtime provider for playback. Cline remains the
owner of workspace context, agent tools, MCP, approvals, and session history;
provider credentials remain in the sidecar.
| `list_git_branches` | async `execFile("git", ...)` |
| `checkout_git_branch` | async `execFile("git", ...)` |
| `search_workspace_files` | `getFileIndex` |

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