Compare commits

..
Author SHA1 Message Date
Saoud Rizwan bd218aa7bb chore(sdk): release v0.0.76 2026-08-20 19:23:35 -07:00
cline-cloud[bot] 80b3b0348e docs: remove duplicate GLM-5.3 rows in ClinePass tables (#13449)
Co-authored-by: cline-cloud[bot] <276134852+cline-cloud[bot]@users.noreply.github.com>
2026-08-20 19:13:56 -07:00
Renee Huang 401cf5e12a docs: simplify Open Cline step in installing guide (#13405) 2026-08-20 18:32:58 -07:00
Bee 48feb02ae9 chore(deps): update Langfuse packages and bump app versions (#13443)
* chore(deps): update Langfuse packages and bump app versions

Update @langfuse/otel to v5.10.1 and add @langfuse/vercel-ai-sdk v5.9.1 for improved observability with Vercel AI SDK.

Bump versions for @cline/code to 0.0.14 and @cline/ui to 0.2.0-next.6, updated via bun.lock.

Other Changes:
Added optional userId to AgentRuntimeConfig.
Propagated userId, sessionId, conversationId, runId, iteration, provider, and model context into AI SDK telemetry.
Added AI SDK 7 runtimeContext with explicit includeRuntimeContext.
Added stable OTEL_SERVICE_NAME=cline-sdk.
Added runtime metadata assertions in agent tests.

* add taskId

* Revert "add taskId"

This reverts commit f20d31d96d.
2026-08-20 18:17:33 -07:00
Saoud RizwanandSaoud Rizwan eef7958cad fix(core): report truthful session status so desktop checkpoint restore stops wedging (#13418)
* fix(core): keep hub session status truthful across queue-drained turns

Queue-drained turns settle only through the event stream, but the hub
runtime host mistranslated their lifecycle in two ways:

- session.updated events carrying only a snapshot (persistence updates)
  defaulted the projected status to "running". When one trailed the
  final idle update after a turn, clients that track busy state from
  status events (the desktop sidecar's workspace restore gate) stayed
  busy forever. Use the snapshot's real status and emit nothing when
  neither source reports one.
- the per-run agent.done dedup was only reset by run.started, which the
  daemon-side queue drain never publishes, so a drained turn's done was
  swallowed as a duplicate of the previous turn's. Reset the dedup on
  session.pending_prompt_submitted, and suppress stale run.completed
  events that land inside a drained turn's window so they can neither
  emit a phantom done nor consume the drained turn's dedup slot.

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

* test(desktop): cover restore unlock after an event-settled queued turn

Exports the sidecar's core-session event handler so the queued-turn
lifecycle (busy via status events, cleared by the done agent event,
restore allowed afterwards) is testable end-to-end.

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

* fix(core): start interactive sessions without a prompt as idle

The runtime host reported every new session as "running" until its
first turn ended. Interactive hosts (the desktop app) start sessions
with no prompt and dispatch turns through separate send calls, so a
created-but-never-prompted session stayed "running" forever — wedging
clients that gate workspace operations (checkpoint restore, message
edit) on active turns.

Interactive no-prompt starts now begin idle, start emits the session's
actual status (resumed sessions no longer masquerade as running), and
markTurn* transitions keep tracking in-memory status for lazily
persisted sessions so the first turn still reports running -> idle.

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

* style: format hub-runtime-host test filter

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

* refactor: drop the drained-turn done bookkeeping, keep the minimal fix

The stuck restore is fully explained by the two status defects (fabricated
"running" from snapshot-only session.updated events, and never-prompted
interactive sessions reporting "running"). The done-dedup machinery for
queue-drained turns addressed a separate cosmetic gap (queued turns emit no
chat_done, pre-existing) and required fragile run-window heuristics, so it
is removed to keep this change reviewable. Sidecar test now settles the
queued turn through the status event, matching the shipped mechanism.

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

* docs(sdk): document the truthful session-status contract

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-20 18:17:19 -07:00
Saoud RizwanandSaoud Rizwan 04e08187f3 fix(vscode): show the diff edit view for multi-line edits in CRLF files (#13417)
The edit preview computed proposed content with an exact old_text match, but
the SDK executor normalizes old/new text to the file's own line endings before
matching (#12305) - reads strip CR, so models emit LF-only text even for CRLF
files. Any multi-line old_text in a CRLF file therefore failed the preview's
match: the diff edit view silently never opened while the executor applied the
edit. Single-line edits (no line break in old_text) were unaffected, which is
why the diff view appeared to trigger inconsistently.

Mirror the executor's EOL normalization (and its literal $-sequence insertion)
in the preview computation.

Fixes #13296

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-20 14:55:19 -07:00
Saoud RizwanandSaoud Rizwan fed502e3cf fix(vscode): honor the classic truncation range when migrating legacy tasks (#13419)
Classic Cline truncated long conversations by omitting an index range of
api_conversation_history from every API request (keep the first
user-assistant pair, drop everything through the range end, strip
orphaned tool_results from the first kept message). The range was
persisted on the history item while the full history stayed on disk.

legacyApiHistoryToSdkMessages ignored conversationHistoryDeletedRange
and converted the entire file, so resuming a migrated long task handed
the SDK an untruncated working context that could exceed the model's
context window by millions of tokens - every request failed with
'prompt is too long' and every compaction restarted from the full
history (#12996, confirmed by the reporter: the task was migrated from
an older version and broke after a restart, with each compaction
starting from ~3M tokens).

The migration now replays exactly what the classic extension sent:
slice out the deleted range and drop orphaned tool_results, mirroring
ContextManager.getTruncatedMessages (see origin/main). Malformed ranges
fall back to the full history (previous behavior).

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-20 13:33:24 -07:00
Mikołaj Kondratek e685ebfd04 fix(core): stop Windows CI worker crashes from the agenda spec watcher (#13428)
* fix(core): watch agenda task specs via the resolved long path

fs.watch on a path with 8.3 short components (e.g. C:\Users\RUNNER~1
temp dirs) trips a libuv assertion in fs-event.c on Windows and aborts
the whole process. Since the agenda task manager landed, every hub
server test spins up its spec watcher on such a path on hosted Windows
runners, killing the vitest worker and failing the sdk-test Windows job
on every branch. Resolve the specs dir with realpathSync.native before
watching so libuv only ever sees the long form.

* test(ui): stub ResizeObserver for @pierre/diffs in tool-diff tests

jsdom does not implement ResizeObserver, so every ToolFileDiff render
logged a ReferenceError from @pierre/diffs to stderr. Tests still
passed; this just silences the noise the same way the constructable
stylesheet shim does.

* fix(core): skip the agenda spec watcher when the dir does not resolve

Falling back to the raw path on realpath failure would reintroduce the
Windows short-path abort; log and go without the watcher instead.
2026-08-20 22:26:02 +02:00
Mikołaj Kondratek 9b9a067fb8 fix(hooks): collect PostToolUse hook output and honor its control (#13298)
* fix(hooks): collect PostToolUse hook output and honor its control

tool_result (PostToolUse) hooks ran fire-and-forget with stdout
ignored, so their entire JSON output — contextModification and cancel —
was discarded. Legacy awaited PostToolUse, injected its
contextModification into the conversation, and honored cancel.

- Run tool_result hook commands blocking (same 120s default timeout as
  tool_call) in both the hook-config-file layer and the agent-hook
  subprocess layer.
- Map their output: cancel stops the run with the hook's error message
  as the reason; otherwise context is injected via afterTool
  appendContext.

This restores legacy blocking semantics: tool results now wait for
tool_result hooks, but only in sessions that have one configured.

Ref: https://linear.app/cline-bot/issue/CLINE-2987

* fix(hooks): bound tool_result hook wait and isolate cancel reason

Address review findings:
- The agent-hook subprocess layer forwarded an unset timeoutMs
  unchanged, so a tool hook command that never exits would block the
  agent indefinitely. Default both tool_call and tool_result to the
  120s bound the hook-config-file layer already used.
- A cancelling hook's error message was folded into the same context
  field as other hooks' injectable context, so merging controls could
  leak unrelated hook context into the cancellation reason. Carry it as
  a separate cancelReason, and surface it as the stop reason for
  beforeTool cancels too.

* fix(hooks): prefer errorMessage as a cancelling hook's stop reason

When a cancelling hook returns both contextModification and
errorMessage, the context-first parse precedence made the injectable
context the cancel reason and discarded the actual error. Parse the two
fields separately: errorMessage wins as the cancel reason (matching
legacy), and a lone errorMessage still folds into injectable context
for non-cancelling hooks as before.

* fix(vscode): honor PostToolUse hook cancel and contextModification

The adapter awaited PostToolUse hooks but discarded their output
entirely. Map cancel to a stop control (with errorMessage as the
reason) and contextModification into the runtime appendContext channel,
matching the PreToolUse mapping and legacy semantics.

* fix(hooks): whitespace-only errorMessage no longer suppresses the cancel reason

A cancelling hook returning meaningful context alongside a blank
errorMessage lost both: the parsers selected the whitespace as the
reason and the result mappers trimmed it away. Require a non-blank
errorMessage before it wins, so context serves as the fallback reason.
Apply the same fallback in the extension adapter's stop mapping.
2026-08-20 21:09:51 +02:00
Mikołaj Kondratek 8fe5a196c4 fix(hooks): deliver tool hook contextModification to the model (#13297)
* fix(hooks): deliver tool hook contextModification to the model

On the next engine, a tool_call (PreToolUse) hook's contextModification
was parsed into HookControl.context and then silently dropped: the
runtime beforeTool/afterTool result contract had no channel for
injecting conversation context. Legacy consumed it (ToolExecutor /
ToolHookUtils pushed <hook_context> blocks into the next user turn), so
this was a regression of documented behavior.

- Add appendContext to AgentBeforeToolResult/AgentAfterToolResult.
- AgentRuntime collects appendContext across hooks during an
  iteration's tool executions and appends one <hook_context> user
  message after the tool results, keeping tool-result parts contiguous.
- Map HookControl.context into appendContext in both subprocess hook
  layers (skipped when the hook cancels, matching legacy, where the
  message doubled as the error).
- Truncate injected context at 50KB per hook output, matching legacy.
- Concatenate appendContext across merged hook layers.

tool_result (PostToolUse) hooks still run detached with stdout ignored;
making them blocking so their context can be collected is a follow-up.

Ref: https://linear.app/cline-bot/issue/CLINE-2987

* fix(hooks): stamp tool identity on injected hook context blocks

Contexts are batched into one message after the tool results, and
parallel tool execution collects them in completion order, so position
alone cannot attribute a block to its tool call. Add tool_name and
tool_call_id attributes to each <hook_context> block.

* fix(hooks): sanitize hook context block markup

Attribute values (tool_name, tool_call_id) are stripped of quote/angle
characters and embedded </hook_context> closers in hook output are
neutralized, so neither provider-supplied ids nor hook text can corrupt
or spoof a block's stamped identity.

* fix(hooks): neutralize forged opening hook_context tags in hook output

The previous sanitization only neutralized closing tags, so hook output
could still open a forged <hook_context> block claiming another tool's
identity. Escape both opening and closing embedded tags with one rule.

* fix(hooks): hide injected hook context from user-facing transcripts

Stamp the injected hook-context user message with displayRole 'system'
(the compaction-summary convention) so it reaches the model but does
not render as a user bubble in live or replayed transcripts. Without
this, resuming a session showed the raw <hook_context> block as if the
user had typed it.

* fix(hooks): neutralize case-variant embedded hook_context tags

The tag-neutralization regex was case-sensitive, so hook output could
still smuggle a forged tag as <HOOK_CONTEXT>. Match case-insensitively.

* fix(vscode): map PreToolUse contextModification into runtime appendContext

The extension's hooks adapter bridged file hooks into the SDK runtime
but forwarded only cancel/errorMessage, so a PreToolUse hook's
contextModification never reached the model. Map it into the runtime's
appendContext channel; HookFactory already truncates it at 50KB.

* fix(vscode): hide hook-injected context from replayed transcripts

Live sessions never rendered the injected <hook_context> user message,
but session reload replayed it as a user bubble (and post-resume turns
kept doing so). Treat these messages as synthetic in the user-message
mapping: honor the displayRole 'system' stamp the runtime sets, with a
text-prefix guard for paths where metadata is unavailable. This also
keeps edit/regenerate ordinal mapping aligned with visible bubbles.

* fix(hooks): run file hooks through exactly one layer per host

The VS Code extension registered two independent hook execution layers:
its own hooks adapter (config.hooks) and the SDK core's file-hook
extension from the runtime bootstrap. When both discover the same hook
files, every hook executes twice per event — and with context injection
wired, each contextModification would be injected twice.

Add a 'hooks' runtime config extension kind (in the default set, so the
CLI keeps core file hooks unchanged) and gate the bootstrap's file-hook
extension on it. The extension excludes 'hooks' at session start, so
its adapter — which also provides the hook status UI and the
hooksEnabled setting — is its single execution path.

* fix(vscode): discover hooks from the session workspace, not only global state

Hook discovery read workspaceRoots from global state shared across
every Cline instance, so another window repointing it made workspace
hooks silently stop being discovered. With the extension's adapter now
the single hook execution layer, that meant no hooks at all.

HookFactory takes an optional sessionWorkspaceRoot and unions that
root's .clinerules/hooks into discovery (and into cwd resolution), fed
from the session config's cwd. Shared-state discovery still works, so
behavior in the single-window case is unchanged.

* fix(hooks): keep sanitized hook attribute values distinguishable

Replacing every markup delimiter with the same underscore could
collapse two tool call ids that differ only by such a character into
identical stamps. Escape each delimiter with a distinct token instead.

* fix(hooks): make hook attribute sanitization injective

Escaping the underscore itself turns the attribute escaping into a
uniquely decodable code, so no two distinct tool call ids can collapse
to the same sanitized stamp (previously an id containing a literal
escape token could collide with an id containing the delimiter).

* fix(vscode): reconstruct hook status rows when replaying transcripts

hook_status messages are emitted live but never persisted, so reloading
a session dropped every hook row. The injected <hook_context> blocks
carry the hook source and tool name, so the replay translator now
rebuilds a completed hook status row from each block. The injection is
also no longer treated as a user turn boundary, so the final turn's
completion retag is unaffected by it.
2026-08-20 20:41:53 +02:00
Mikołaj Kondratek e70b3ffc4b ci(vscode): upload E2E failure recordings from the right path (#13427)
The job sets working-directory: apps/vscode, but that default applies to run
steps only, not to `uses:` steps. Since #10961 moved the extension under apps/
and added that default, the artifact path has resolved against the repo root,
matched nothing, and every failing run logged "No files were found with the
provided path: test-results/playwright/" instead of uploading recordings.

Widen to test-results/ so Playwright's error-context snapshots ship alongside
the videos.
2026-08-20 20:10:12 +02:00
Haley Park e6f4d5fef7 feat(desktop): refresh app icons and branding (#13400) 2026-08-20 08:42:50 -07:00
Saoud Rizwan 16875140fb fix(ui): update packed-Tailwind smoke contract for the picker's max-h-64 (#13421)
The ui-publish smoke check pins a set of Tailwind candidates the packed
sources must emit; #13410 grew the SearchCombobox options list from
max-h-56 to max-h-64, so the publish run failed on the stale candidate.
All other pinned candidates verified against the current sources.
2026-08-19 22:19:14 -07:00
Saoud RizwanandSaoud Rizwan 7903c76812 feat(desktop): recommended-feed badges and descriptions in provider settings (#13416)
* feat(ui): sectioned model picker support in SearchCombobox

Adds option sections with headers, badges (NEW/Free pills), keyboard
navigation (arrows/Home/End/Enter with active-row tracking and
aria-activedescendant), substring match highlighting, a configurable
panel width, a trigger chevron, and a cleaner borderless search row.
All additions are backwards compatible; bumps @cline/ui to
0.2.0-next.6.

* feat(desktop): recommended and free model tiers in the composer picker

The composer's model selector showed raw provider/model ids and listed
the entire catalog alphabetized by id. It now labels providers and
models by display name and, for the cline provider, leads with the
Recommended and Free tiers from the recommended-models feed (NEW/Free
badges, descriptions) ahead of an All models section — matching the
CLI's featured picker and the kanban selector. cline-pass gets
Subscribed/Free tiers. A new list_cline_recommended_models sidecar
command exposes @cline/core's fetchClineRecommendedModels (display-ready
names, bundled offline fallback); feed ids resolve against the catalog
with a unique-slug fallback for Vercel/OpenRouter alias spellings, and
unresolvable entries are dropped rather than rendered unselectable.

* fix(desktop): widen the provider trigger for display names

Provider labels are now display names (e.g. "Cline Usage-Billing"),
which truncated badly at max-w-28.

* chore(desktop): drop unused featured-models test helper

* style(desktop): align workspace/branch picker search rows with the model picker

The composer's workspace/branch popover and the welcome screen's
workspace and branch pickers used a boxed inner search shell that now
clashed with the model picker's borderless search row sitting next to
them. Behavior unchanged.

* feat(ui): center the selected option when SearchCombobox opens

Opening a long list previously scrolled the selection just into view at
the panel edge; it now lands centered, and keyboard/hover navigation
falls back to minimal nearest-edge scrolling.

* style(desktop): picker row contrast, transparent search fields, centered open

The workspace/branch pickers' rows had a nearly invisible
surface-hover-lighter hover; rows now hover with surface-hover and mark
the current entry with the accent background plus check, matching the
model picker. The search inputs drop the Input base class's
dark:bg-input/30 tint that rendered a gray box inside the panel in dark
mode. Opening a picker now centers the current workspace/branch via a
shared scroll helper instead of starting at the top of the list.

* fix(ui): visible option hover/selected states and no scroll-jump on hover

The option row stacked bg-transparent with the conditional state
backgrounds; at equal specificity the later-sorted bg-transparent
utility won, so hover/selected rows rendered with no background at all.
The background classes are now mutually exclusive.

Mouse-driven active-row changes also reused the keyboard scroll-into-
view effect: hovering a row at the panel edge scrolled it into view,
which moved the list under the cursor and re-triggered hover — an
endless jump. Scroll mode is now per-source: center on open, nearest
for keyboard/typing, none for hover.

* fix(desktop): show only subscribed and free tiers in the cline-pass picker

The ClinePass offer is exactly the feed's subscribed + free tiers, but
stale bundled/cached catalog entries (e.g. a nemotron model) leaked
into an "All models" tier. Match the CLI's featured picker: hide
catalog leftovers, and only fall back to the full catalog when the
subscribed bucket is empty so a subscriber is never limited to free
models offline.

* fix(ui/desktop): strengthen the selected-row highlight in light mode

The selected row used the semantic accent surface (violet step 3),
which is nearly white in light mode. SearchCombobox and the desktop
workspace/branch pickers now highlight the selected/current row with
accent step 4 (with a fallback to --accent), which reads clearly in
both themes without touching the shared --accent token that shadcn
hover states depend on.

* fix(desktop): fit full provider display names in the composer trigger

"Cline Usage-Billing" — the default provider — truncated to
"Cline Usage-Bi…" at max-w-36; the trigger now allows up to max-w-56,
which fits the longest built-in provider names.

* style(ui/desktop): animate picker panels open like the shadcn dropdowns

The thinking-effort Select (shadcn/Radix) animates open while the
model/provider/workspace/branch pickers popped in instantly. All picker
panels now share the same open treatment — 150ms fade + slight zoom,
sliding from the trigger side. SearchCombobox uses a self-contained CSS
keyframe (consumers may not ship tw-animate-css); the desktop's custom
panels use the app's tw-animate utilities. Both respect
prefers-reduced-motion.

* chore(desktop): drop stale eslint-disable comments in picker search rows

This repo lints with biome; the jsx-a11y/no-autofocus disables were
inert leftovers. Flagged in review.

* refactor(core/desktop): stamp recommended-feed tiers onto ProviderModel in the SDK

Review feedback on the composer picker: tier joining should live where
the SDK serves model lists so each client doesn't fetch and join the
recommended-models feed itself (the CLI and now the desktop each did).

ProviderModel gains description and featured ({tier, rank, tags});
getLocalProviderModels overlays the feed's recommended/free tiers onto
cline models and subscribed/free onto cline-pass via
applyClineFeaturedModels, matching feed ids through the
Vercel/OpenRouter alias rules. The feed access is a new cached wrapper
(getCachedClineRecommendedModels, 5-minute TTL, in-flight dedupe) —
this path runs on every picker open, and the bundled offline fallback
is cached too so offline users don't re-pay the 5s timeout per list.

The desktop webview now reads tiers straight off the models: the
list_cline_recommended_models sidecar command, the webview feed fetch,
and its unique-slug alias matching are all deleted. toProviderModel
also carries ModelInfo.description generally.

* feat(desktop): recommended-feed badges and descriptions in provider settings

Review suggestion on #13410: the provider settings page has room for
more model detail than the composer's picker. The cline/cline-pass
provider cards now refresh their model list through
list_provider_models (the catalog snapshot deliberately skips the
recommended-feed overlay so the startup catalog fetch never blocks on
the feed) and render Recommended/Free tier badges plus feed tags (NEW)
next to the model name, with the model description underneath. The
refreshed list also surfaces the live entries instead of the bundled
snapshot.

* fix(ui): hand focus back to the combobox trigger on selection, close on Tab

Selecting an option (Enter or click) unmounted the focused search input
without a new focus target, dropping keyboard users' focus to <body> —
only Escape restored it. And since the search input is the panel's only
tabbable element, Tab always moved focus outside the component while
leaving the popup open behind the new focus target.

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

* fix(desktop): keep the composer model selection inside the picker's visible offer

The active/remembered model was validated against the provider's full
catalog while the picker can intentionally hide models (the ClinePass
offer is exactly its subscribed/free tiers), so a stale remembered model
could become the selection while being absent from the dropdown.

Remembered and default selections (including on provider switch) now
resolve against the picker's visible options, and an explicitly
configured model that falls outside the offer stays active but is
surfaced under a 'Current model' section so the selection is always
visible and re-selectable.

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

* fix(desktop): scope the settings featured model list to its provider and revision

The fetched featured list was unscoped component state: switching
between cline and cline-pass reused the component instance, so the
previous provider's models stayed visible while the new request was
pending (or forever, when it failed), and the retained copy shadowed
later provider.modelList updates — adding a second custom model
submitted the stale list as the complete configuration and dropped the
first addition.

The fetched list now only applies to the provider and modelList
revision it was fetched for (falling back to the catalog snapshot
otherwise and refetching on membership changes), and add-model submits
the union of the displayed and configured ids so an update can never
silently unconfigure existing entries.

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

* feat(core): stamp featured tiers onto the provider catalog synchronously

listLocalProviders deliberately skipped the feed overlay so the catalog
never blocks on the network — but that left the composer's very first
picker open after a cold boot rendering an untiered flat list until the
per-provider fetch landed. Blocking was never required: stamp tiers from
a synchronous peek at data already in memory (the cached live feed when
fresh, else the bundled fallback, whose recommended ids resolve against
the bundled cline catalog). The per-provider model-list path still
refreshes with live feed data moments later.

* fix(core): harden featured-tier matching and the feed cache reset

Review findings on the tier overlay:

Vendor-prefix mismatches now match by unambiguous id slug (two-pass, so
a catalog carrying both spellings of a model stamps one row, and a slug
shared by two feed entries stamps nothing) — the bundled fallback feed's
vendor-prefixed ids can otherwise miss cline-free/-prefixed catalog
entries, leaving them untiered in degraded mode.

resetClineRecommendedModelsCacheForTests now bumps a generation so an
in-flight feed request resolving after a reset cannot repopulate the
cache it just cleared.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 22:13:40 -07:00
Saoud RizwanandSaoud Rizwan b060cecc05 refactor(core/desktop): stamp recommended-feed tiers onto ProviderModel in the SDK (#13415)
* feat(ui): sectioned model picker support in SearchCombobox

Adds option sections with headers, badges (NEW/Free pills), keyboard
navigation (arrows/Home/End/Enter with active-row tracking and
aria-activedescendant), substring match highlighting, a configurable
panel width, a trigger chevron, and a cleaner borderless search row.
All additions are backwards compatible; bumps @cline/ui to
0.2.0-next.6.

* feat(desktop): recommended and free model tiers in the composer picker

The composer's model selector showed raw provider/model ids and listed
the entire catalog alphabetized by id. It now labels providers and
models by display name and, for the cline provider, leads with the
Recommended and Free tiers from the recommended-models feed (NEW/Free
badges, descriptions) ahead of an All models section — matching the
CLI's featured picker and the kanban selector. cline-pass gets
Subscribed/Free tiers. A new list_cline_recommended_models sidecar
command exposes @cline/core's fetchClineRecommendedModels (display-ready
names, bundled offline fallback); feed ids resolve against the catalog
with a unique-slug fallback for Vercel/OpenRouter alias spellings, and
unresolvable entries are dropped rather than rendered unselectable.

* fix(desktop): widen the provider trigger for display names

Provider labels are now display names (e.g. "Cline Usage-Billing"),
which truncated badly at max-w-28.

* chore(desktop): drop unused featured-models test helper

* style(desktop): align workspace/branch picker search rows with the model picker

The composer's workspace/branch popover and the welcome screen's
workspace and branch pickers used a boxed inner search shell that now
clashed with the model picker's borderless search row sitting next to
them. Behavior unchanged.

* feat(ui): center the selected option when SearchCombobox opens

Opening a long list previously scrolled the selection just into view at
the panel edge; it now lands centered, and keyboard/hover navigation
falls back to minimal nearest-edge scrolling.

* style(desktop): picker row contrast, transparent search fields, centered open

The workspace/branch pickers' rows had a nearly invisible
surface-hover-lighter hover; rows now hover with surface-hover and mark
the current entry with the accent background plus check, matching the
model picker. The search inputs drop the Input base class's
dark:bg-input/30 tint that rendered a gray box inside the panel in dark
mode. Opening a picker now centers the current workspace/branch via a
shared scroll helper instead of starting at the top of the list.

* fix(ui): visible option hover/selected states and no scroll-jump on hover

The option row stacked bg-transparent with the conditional state
backgrounds; at equal specificity the later-sorted bg-transparent
utility won, so hover/selected rows rendered with no background at all.
The background classes are now mutually exclusive.

Mouse-driven active-row changes also reused the keyboard scroll-into-
view effect: hovering a row at the panel edge scrolled it into view,
which moved the list under the cursor and re-triggered hover — an
endless jump. Scroll mode is now per-source: center on open, nearest
for keyboard/typing, none for hover.

* fix(desktop): show only subscribed and free tiers in the cline-pass picker

The ClinePass offer is exactly the feed's subscribed + free tiers, but
stale bundled/cached catalog entries (e.g. a nemotron model) leaked
into an "All models" tier. Match the CLI's featured picker: hide
catalog leftovers, and only fall back to the full catalog when the
subscribed bucket is empty so a subscriber is never limited to free
models offline.

* fix(ui/desktop): strengthen the selected-row highlight in light mode

The selected row used the semantic accent surface (violet step 3),
which is nearly white in light mode. SearchCombobox and the desktop
workspace/branch pickers now highlight the selected/current row with
accent step 4 (with a fallback to --accent), which reads clearly in
both themes without touching the shared --accent token that shadcn
hover states depend on.

* fix(desktop): fit full provider display names in the composer trigger

"Cline Usage-Billing" — the default provider — truncated to
"Cline Usage-Bi…" at max-w-36; the trigger now allows up to max-w-56,
which fits the longest built-in provider names.

* style(ui/desktop): animate picker panels open like the shadcn dropdowns

The thinking-effort Select (shadcn/Radix) animates open while the
model/provider/workspace/branch pickers popped in instantly. All picker
panels now share the same open treatment — 150ms fade + slight zoom,
sliding from the trigger side. SearchCombobox uses a self-contained CSS
keyframe (consumers may not ship tw-animate-css); the desktop's custom
panels use the app's tw-animate utilities. Both respect
prefers-reduced-motion.

* chore(desktop): drop stale eslint-disable comments in picker search rows

This repo lints with biome; the jsx-a11y/no-autofocus disables were
inert leftovers. Flagged in review.

* refactor(core/desktop): stamp recommended-feed tiers onto ProviderModel in the SDK

Review feedback on the composer picker: tier joining should live where
the SDK serves model lists so each client doesn't fetch and join the
recommended-models feed itself (the CLI and now the desktop each did).

ProviderModel gains description and featured ({tier, rank, tags});
getLocalProviderModels overlays the feed's recommended/free tiers onto
cline models and subscribed/free onto cline-pass via
applyClineFeaturedModels, matching feed ids through the
Vercel/OpenRouter alias rules. The feed access is a new cached wrapper
(getCachedClineRecommendedModels, 5-minute TTL, in-flight dedupe) —
this path runs on every picker open, and the bundled offline fallback
is cached too so offline users don't re-pay the 5s timeout per list.

The desktop webview now reads tiers straight off the models: the
list_cline_recommended_models sidecar command, the webview feed fetch,
and its unique-slug alias matching are all deleted. toProviderModel
also carries ModelInfo.description generally.

* fix(ui): hand focus back to the combobox trigger on selection, close on Tab

Selecting an option (Enter or click) unmounted the focused search input
without a new focus target, dropping keyboard users' focus to <body> —
only Escape restored it. And since the search input is the panel's only
tabbable element, Tab always moved focus outside the component while
leaving the popup open behind the new focus target.

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

* fix(desktop): keep the composer model selection inside the picker's visible offer

The active/remembered model was validated against the provider's full
catalog while the picker can intentionally hide models (the ClinePass
offer is exactly its subscribed/free tiers), so a stale remembered model
could become the selection while being absent from the dropdown.

Remembered and default selections (including on provider switch) now
resolve against the picker's visible options, and an explicitly
configured model that falls outside the offer stays active but is
surfaced under a 'Current model' section so the selection is always
visible and re-selectable.

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

* feat(core): stamp featured tiers onto the provider catalog synchronously

listLocalProviders deliberately skipped the feed overlay so the catalog
never blocks on the network — but that left the composer's very first
picker open after a cold boot rendering an untiered flat list until the
per-provider fetch landed. Blocking was never required: stamp tiers from
a synchronous peek at data already in memory (the cached live feed when
fresh, else the bundled fallback, whose recommended ids resolve against
the bundled cline catalog). The per-provider model-list path still
refreshes with live feed data moments later.

* fix(core): harden featured-tier matching and the feed cache reset

Review findings on the tier overlay:

Vendor-prefix mismatches now match by unambiguous id slug (two-pass, so
a catalog carrying both spellings of a model stamps one row, and a slug
shared by two feed entries stamps nothing) — the bundled fallback feed's
vendor-prefixed ids can otherwise miss cline-free/-prefixed catalog
entries, leaving them untiered in degraded mode.

resetClineRecommendedModelsCacheForTests now bumps a generation so an
in-flight feed request resolving after a reset cannot repopulate the
cache it just cleared.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 22:10:41 -07:00
Saoud RizwanandSaoud Rizwan 4d1bafc443 feat(desktop/ui): recommended and free model tiers in the composer model selector (#13410)
* feat(ui): sectioned model picker support in SearchCombobox

Adds option sections with headers, badges (NEW/Free pills), keyboard
navigation (arrows/Home/End/Enter with active-row tracking and
aria-activedescendant), substring match highlighting, a configurable
panel width, a trigger chevron, and a cleaner borderless search row.
All additions are backwards compatible; bumps @cline/ui to
0.2.0-next.6.

* feat(desktop): recommended and free model tiers in the composer picker

The composer's model selector showed raw provider/model ids and listed
the entire catalog alphabetized by id. It now labels providers and
models by display name and, for the cline provider, leads with the
Recommended and Free tiers from the recommended-models feed (NEW/Free
badges, descriptions) ahead of an All models section — matching the
CLI's featured picker and the kanban selector. cline-pass gets
Subscribed/Free tiers. A new list_cline_recommended_models sidecar
command exposes @cline/core's fetchClineRecommendedModels (display-ready
names, bundled offline fallback); feed ids resolve against the catalog
with a unique-slug fallback for Vercel/OpenRouter alias spellings, and
unresolvable entries are dropped rather than rendered unselectable.

* fix(desktop): widen the provider trigger for display names

Provider labels are now display names (e.g. "Cline Usage-Billing"),
which truncated badly at max-w-28.

* chore(desktop): drop unused featured-models test helper

* style(desktop): align workspace/branch picker search rows with the model picker

The composer's workspace/branch popover and the welcome screen's
workspace and branch pickers used a boxed inner search shell that now
clashed with the model picker's borderless search row sitting next to
them. Behavior unchanged.

* feat(ui): center the selected option when SearchCombobox opens

Opening a long list previously scrolled the selection just into view at
the panel edge; it now lands centered, and keyboard/hover navigation
falls back to minimal nearest-edge scrolling.

* style(desktop): picker row contrast, transparent search fields, centered open

The workspace/branch pickers' rows had a nearly invisible
surface-hover-lighter hover; rows now hover with surface-hover and mark
the current entry with the accent background plus check, matching the
model picker. The search inputs drop the Input base class's
dark:bg-input/30 tint that rendered a gray box inside the panel in dark
mode. Opening a picker now centers the current workspace/branch via a
shared scroll helper instead of starting at the top of the list.

* fix(ui): visible option hover/selected states and no scroll-jump on hover

The option row stacked bg-transparent with the conditional state
backgrounds; at equal specificity the later-sorted bg-transparent
utility won, so hover/selected rows rendered with no background at all.
The background classes are now mutually exclusive.

Mouse-driven active-row changes also reused the keyboard scroll-into-
view effect: hovering a row at the panel edge scrolled it into view,
which moved the list under the cursor and re-triggered hover — an
endless jump. Scroll mode is now per-source: center on open, nearest
for keyboard/typing, none for hover.

* fix(desktop): show only subscribed and free tiers in the cline-pass picker

The ClinePass offer is exactly the feed's subscribed + free tiers, but
stale bundled/cached catalog entries (e.g. a nemotron model) leaked
into an "All models" tier. Match the CLI's featured picker: hide
catalog leftovers, and only fall back to the full catalog when the
subscribed bucket is empty so a subscriber is never limited to free
models offline.

* fix(ui/desktop): strengthen the selected-row highlight in light mode

The selected row used the semantic accent surface (violet step 3),
which is nearly white in light mode. SearchCombobox and the desktop
workspace/branch pickers now highlight the selected/current row with
accent step 4 (with a fallback to --accent), which reads clearly in
both themes without touching the shared --accent token that shadcn
hover states depend on.

* fix(desktop): fit full provider display names in the composer trigger

"Cline Usage-Billing" — the default provider — truncated to
"Cline Usage-Bi…" at max-w-36; the trigger now allows up to max-w-56,
which fits the longest built-in provider names.

* style(ui/desktop): animate picker panels open like the shadcn dropdowns

The thinking-effort Select (shadcn/Radix) animates open while the
model/provider/workspace/branch pickers popped in instantly. All picker
panels now share the same open treatment — 150ms fade + slight zoom,
sliding from the trigger side. SearchCombobox uses a self-contained CSS
keyframe (consumers may not ship tw-animate-css); the desktop's custom
panels use the app's tw-animate utilities. Both respect
prefers-reduced-motion.

* chore(desktop): drop stale eslint-disable comments in picker search rows

This repo lints with biome; the jsx-a11y/no-autofocus disables were
inert leftovers. Flagged in review.

* fix(ui): hand focus back to the combobox trigger on selection, close on Tab

Selecting an option (Enter or click) unmounted the focused search input
without a new focus target, dropping keyboard users' focus to <body> —
only Escape restored it. And since the search input is the panel's only
tabbable element, Tab always moved focus outside the component while
leaving the popup open behind the new focus target.

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

* fix(desktop): keep the composer model selection inside the picker's visible offer

The active/remembered model was validated against the provider's full
catalog while the picker can intentionally hide models (the ClinePass
offer is exactly its subscribed/free tiers), so a stale remembered model
could become the selection while being absent from the dropdown.

Remembered and default selections (including on provider switch) now
resolve against the picker's visible options, and an explicitly
configured model that falls outside the offer stays active but is
surfaced under a 'Current model' section so the selection is always
visible and re-selectable.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 21:46:07 -07:00
Saoud RizwanandSaoud Rizwan 74a8e06e41 Desktop: unify Plugins, MCP, and Skills into one Plugins hub with a dedicated Marketplace page (#13411)
* Unify desktop plugins, apps, MCP, and skills into one Plugins hub with a Browse directory mode

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

* Open the marketplace directory as a modal over the Plugins hub instead of swapping the page

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

* Rename directory to Marketplace: Browse Marketplace button, Marketplace modal title with icon, search placeholder

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

* Fix search input focus ring clipped by the Marketplace modal scroll container

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

* Address Greptile review: keep selected tag chip visible when its count drops to zero, and remount installed tab when a marketplace install completes after the modal closed

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

* Track marketplace modal mutation flag in a ref so a close click racing a queued render cannot skip the inventory remount

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

* Make Marketplace its own settings page under Customizations and restore Channels as a standalone page

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

* Remove icon from Marketplace page header for consistency with other settings pages

* Notify mounted inventory views when the marketplace invalidates the cache so late install completions refresh the Plugins hub

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 21:42:02 -07:00
Saoud RizwanandSaoud Rizwan 94beb6c5f1 Fix code actions failing with "command not found" on VS Code 1.134 (#13402)
* Fix @ file mentions breaking on paths with spaces

Quote mentions generated by getFileMentionFromPath (Add to Cline /
Fix / Explain / Improve commands) when the relative path contains
spaces, so the mention regex no longer truncates the path at the
first space. Also quote the path part of workspace-prefixed mentions
(workspace:/path with spaces) inserted from the @ context menu, which
previously bypassed quoting because the value does not start with '/'.

Fixes #13338

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

* Fix import ordering in mentions test (biome organize imports)

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

* Reduce fix to minimal scope

Revert the webview quoting refactor and extra tests; keep only the
getFileMentionFromPath quoting fix with a single regression test.

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

* Normalize mention paths to posix separators for Windows

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

* Fix code actions failing with 'command not found' on VS Code 1.134

Code action commands carried arguments (expandedRange, diagnostics),
which routes them through VS Code's CommandsConverter cache. VS Code
1.134 disposes the cached entries before the clicked action executes,
so every lightbulb action failed with 'Actual command not found,
wanted to execute cline.addToChat'.

Drop the arguments so the command id is passed through directly, and
recover the context in the handler instead: getContextForCommand now
expands an empty selection by 3 surrounding lines (matching the old
provider behavior) and gathers document diagnostics intersecting the
range when none are passed explicitly.

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

* Scope gathered diagnostics to the selection/cursor

Match the old CodeActionContext.diagnostics behavior: only include
diagnostics intersecting the range the action was requested for, not
the surrounding lines the text gets expanded to.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 18:36:21 -07:00
Saoud RizwanandSaoud Rizwan 8720363ba3 Fix @ file mentions breaking on paths with spaces (#13391)
* Fix @ file mentions breaking on paths with spaces

Quote mentions generated by getFileMentionFromPath (Add to Cline /
Fix / Explain / Improve commands) when the relative path contains
spaces, so the mention regex no longer truncates the path at the
first space. Also quote the path part of workspace-prefixed mentions
(workspace:/path with spaces) inserted from the @ context menu, which
previously bypassed quoting because the value does not start with '/'.

Fixes #13338

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

* Fix import ordering in mentions test (biome organize imports)

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

* Reduce fix to minimal scope

Revert the webview quoting refactor and extra tests; keep only the
getFileMentionFromPath quoting fix with a single regression test.

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

* Normalize mention paths to posix separators for Windows

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 17:59:16 -07:00
Saoud RizwanandSaoud Rizwan f291a269a6 fix(desktop): don't show "No sessions found" while session history is still loading (#13414)
* fix(desktop): don't show 'No sessions found' while session history is still loading

Replace the isLoadingHistory flag with hasLoadedHistory, set only once the
backend has actually answered a list_discovered_sessions request. The sidebar
and Sessions view now keep their loading state until that first definitive
response, so the empty-state copy can no longer appear while history is still
being fetched (or while a failed fetch is being retried).

Also retry a failed initial fetch on the 2s event cadence instead of stranding
the UI until the 12s periodic poll, which is what stretched the misleading
empty state to ~10 seconds after a webview reload when the websocket lost the
race with the page load.

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

* fix(desktop): stop history fast-retry from re-arming after hook unmount

A failed initial fetch that settles after the hook unmounted could schedule a
new retry timer after cleanup had already cleared the refs, leaving the
abandoned hook polling the backend every 2s. Guard scheduleRefresh with a
disposed ref set by the mount effect's cleanup.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 17:57:49 -07:00
Saoud RizwanandSaoud Rizwan 06013b9c08 fix(desktop): remove settings gear hover state while Account screen is open (#13408)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 17:53:47 -07:00
Saoud RizwanandSaoud Rizwan c14cc2c696 fix(desktop): work summary undercounts wall time when pre-tool thinking attaches to the answer (#13413)
* fix(desktop): anchor work summary duration on the answer row, not attached pre-tool reasoning

The collapsed 'Worked for Xs' row undercounted wall time whenever a turn's
assistant message contained thinking + tool_use with no narration text: the
canonical projection emitted the reasoning-only row after the tool row (both
stamped before the tool executed), the webview attached that row to the final
answer, and collapseCompletedWork used the answer's earliest attached
reasoning timestamp as the end anchor - excluding the entire tool execution
(e.g. 'Worked for 5s' for a turn with an 8s command).

- webview: end the work span at the answer row's own timestamp, clamped to
  the last collapsed row so a fallback answer bubble with a synthetic early
  timestamp cannot shrink the duration either
- sidecar: flush pending thinking before a tool_use row so rehydrated
  transcripts keep the live-stream order (thinking before its tool call) and
  pre-tool reasoning no longer rides on the next answer

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

* fix(desktop): keep interleaved thinking between the tool calls it separates

Address Greptile review: when one assistant message interleaves thinking
between multiple tool_use blocks, each reasoning segment now projects at its
own position (attached to a text row from its own segment when present,
otherwise as its own row) instead of merging into the first reasoning row,
which displayed later thinking before a tool call it actually followed.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 17:52:02 -07:00
Saoud RizwanandSaoud Rizwan 0a9f45a9c6 fix(ui): stack code block lines when streamdown lineNumbers is off (#13412)
streamdown renders each Shiki token line as a bare inline span with no
newline text between non-empty lines, and only applies its block line
class when lineNumbers is on. With lineNumbers off (the desktop app's
config) every multi-line fenced block collapsed into one run-on line.
Make the direct line spans under code-block-body display: block in the
shared markdown.css; empty lines keep their height via their lone "\n"
child under white-space: pre.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 17:45:41 -07:00
Saoud RizwanandSaoud Rizwan 4a63821d57 fix(desktop): treat ClinePass as OAuth-managed in the chat credential gate (#13404)
* fix(desktop): treat ClinePass as OAuth-managed in chat credential gate

ClinePass shares the Cline account OAuth credentials (its auth handler
stores under the "cline" provider), so the webview never sees a plain
API key for it. The chat pre-flight check only exempted cline/oca/
openai-codex, so switching to ClinePass while signed in via OAuth
blocked with "Missing API key" even though the sidecar resolves the
stored access token fine (which is why the CLI worked).

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

* style: format helpers.test.ts with biome

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 16:50:39 -07:00
BeeandSaoud Rizwan ff14ab601f feat: allow agents to create scheduled tasks (#13331)
* feat(core, desktop): add durable todo agenda

* fix(desktop): secure todo approvals and track tool usage

* fix(desktop): clean up failed approval delivery

* fix(desktop): authenticate approval connections

* fix(desktop): cancel approvals on broadcast failure

* fix(desktop): authenticate development approvals

* fix(desktop): harden development approvals

* test(core): make task paths cross-platform

* fix(desktop): serialize approval readiness

* refactor(core): unify todo and schedule tools

* feat(core): distinguish user todos from agent suggestions

* fix(core): hide tasks tool in yolo mode

* fix(core): enforce schedule workspace scope

* fix(core): bind schedule scope to hub connection

* fix(core): establish task scope at hub startup

* fix(core): scope task automation by workspace

* test(core): normalize workspace path expectations

* test(core): serialize Windows CI workers

* fix(core): reject unregistered schedule authority

* fix(desktop): guard task execution commands

* fix(core): avoid polynomial regex in mention parsing

* fix(core): address schedule tool review feedback

* fix(core): bind websocket clients to hub workspace

* fix(core): flatten tasks tool input schema

* fix(core): authorize multi-workspace hub clients

* test(core): type hub transport authority mock

* fix(cli): register a workspace client for remote schedule commands (#13398)

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-19 16:45:19 -07:00
Mikołaj Kondratek a8841bf96c fix(llms): surface provider-executed tool activity as observational events (#13300)
* fix(llms): surface provider-executed tool activity as observational events

Provider-executed tool parts (e.g. every tool the Claude Code CLI runs
inside its own session) were dropped by the model-tool guard added for
web search: only declared model tools were re-emitted, everything else
hit continue with nothing yielded. Those sessions modified the workspace
with no tool activity in runtime events, transcripts, or the UI.

Route all providerExecuted parts onto the observational path instead:
emit execution-tagged tool-call-delta and tool-result events, matched by
tool-call ID for providers that omit the flag on the result half. They
stay out of AgentRuntime's execution/approval loop, and the runtime
already persists them as modelToolActivities and projects them for
display.

The AgentModelEvent tool-result variant widens toolName from
ModelToolName to string to carry the provider's own tool names.

* fix(agents): keep turns that are only provider-executed tool activity

A turn consisting solely of observational tool activity has an empty
assistant content array - the activity lives in message metadata, since
projecting it into content would replay tool_use blocks the model never
gets results for. The empty-content guard threw on such turns, erroring
the run and losing the activity from the transcript. Count model-tool
activity as content for the emptiness check (error finishes still
throw); replay stays safe through the codec's empty-content placeholder.
Also drop the trailing text delta from one gateway test so the tool-only
stream shape stays covered end to end.
2026-08-20 00:46:09 +02:00
Saoud RizwanandSaoud Rizwan 878faf0a95 Rename desktop app from "Cline Code" to "Cline" (#13401)
* Rename desktop app from Cline Code to Cline

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

* Format touched Rust test assertions

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-19 15:29:00 -07:00
Saoud Rizwan 36397f47eb ci: tidy workflow cache config and job permissions (#13403)
Publish workflows now always do clean npm installs (no dependency
cache in their test gates), the e2e workflow's cache keys are
exact-match only, and the e2e job drops an id-token permission it
never used.
2026-08-19 14:31:41 -07:00
Saoud Rizwan 3f0c5cdc92 ci: stop over-long changelogs from silently dropping release Slack posts (#12955)
Slack section blocks reject text longer than 3000 characters. The Slack
action logs that rejection as ##[error] but does not fail the step, so an
over-long changelog drops the release announcement while the run stays
green — cline@3.0.50 (3272 chars) published to npm, tagged, and cut a
GitHub release with no Slack post and nothing red to notice.

Every publish workflow pasted the changelog section verbatim into one
section block, so all six were exposed; the SDK, desktop, and extension
sections were only 150-350 chars under the ceiling.

Add a slack_content output alongside content: unchanged when the section
fits, otherwise trimmed on a line boundary with a link to the full
release notes. Only the Slack payload uses it — GitHub release bodies and
the desktop updater manifest still get the whole section.
2026-08-19 13:34:40 -07:00
d9bb22883d fix(shared): run PowerShell commands with fail-fast error semantics (#13358)
* fix(shared): run PowerShell commands with fail-fast error semantics

The run_commands PowerShell wrapper never set $ErrorActionPreference, so
the default 'Continue' applied: a pipeline erroring per item (e.g. a
malformed Where-Object over Get-ChildItem -Recurse) emitted one error
record per enumerated file - tens of thousands of stderr records on
large trees, looking like a hang - and could still resolve as SUCCESS
with exit 0.

Prepend $ErrorActionPreference='Stop'; to the script content executed
by the ScriptBlock so the first error terminates the command with a
non-zero exit and a single error message. Concatenated on the same line
as the user command so error line numbers stay unshifted.

Fixes #13285

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

* fix(shared): set the fail-fast preference in the bootstrap scope

Setting $ErrorActionPreference='Stop' by string-prepending it into the
scriptblock source displaced a leading param(...) from its mandatory
first-statement position, so scripts beginning with a param block failed
with CommandNotFoundException. Preference variables are dynamically
scoped, so setting Stop in the -Command bootstrap gives the invoked
scriptblock identical fail-fast semantics while keeping the user script
byte-identical (param works, error positions unshifted) and drops the
doubled-quote escaping.

* docs(shared): document the fail-fast tradeoffs in the PowerShell wrapper

Stop promotes every non-terminating error, not only per-item pipeline
floods: partial-result commands (recursive listings over access-denied
junctions) now stop at their first error, and Windows PowerShell 5.1
turns in-script stderr redirection of succeeding native commands fatal.
State this in the wrapper comment as a deliberate tradeoff, with the
GitHub Actions precedent and the per-command opt-outs.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com>
2026-08-19 12:11:41 -07:00
Ara 398c1a1b8f fix(llms): display billed gateway cost (#13385) 2026-08-19 21:03:32 +02:00
Renee Huang 8a64372b54 docs: show DeepSeek V4 peak and off-peak pricing (#13312)
* docs: update DeepSeek V4 average pricing

* docs: show DeepSeek peak and off-peak pricing

* docs: add GLM-5.3 reference pricing (same as GLM-5.2)

* docs: add GLM-5.3 to ClinePass models table
2026-08-19 11:14:22 -07:00
dfa34ecea8 fix(desktop): strip user_input envelope when copying a user message (#13369)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bee <abeatrix@users.noreply.github.com>
2026-08-19 01:11:42 -07:00
Bee 98d3e52a02 fix(clients): filter non-chat models from chat pickers (#13317)
* fix(clients): filter non-chat models from chat pickers

* fix(clients): align chat model eligibility
2026-08-18 23:42:08 -07:00
Saoud Rizwan f80e6a5df8 chore(desktop): release v0.0.14 2026-08-18 23:02:41 -07:00
Saoud RizwanandSaoud Rizwan 9cf60cd43a fix(desktop): finalize queued turns on chat_done with canonical history reconcile (#13330)
Turns that settle through the event stream (queued prompts, including the
first prompt of a fresh session) resolve their send() RPC early, so nothing
cleared the streaming shimmer or reconciled live-streamed content against
the persisted transcript at turn end. A turn whose deltas were incomplete
stayed visually streaming forever and only healed when a later non-queued
send rehydrated history.

chat_done (and chat_session_ended / the queue-drain double check) now clears
the active assistant streaming id and schedules a short-delayed
read_session_messages + applyCanonicalHistory, guarded by turn epoch,
session id, and in-flight send submissions so it never clobbers a newer
turn or duplicates the blocking send path's own finalization.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 22:44:55 -07:00
BeeandSaoud Rizwan 2fd8d0383a fix(desktop): align system prompt with session mode (#13361)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-18 22:40:41 -07:00
Saoud RizwanandSaoud Rizwan 411282296c Use fixed selection chevron in account dialog to match other dialogs (#13364)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 22:23:54 -07:00
3705aec28f fix: skill slash commands load via the skills tool instead of expanding into the user message (#13327)
* fix(desktop): show typed slash command instead of expanded skill markdown

The sidecar expands /skill and /workflow tokens into their instructions
before dispatching, so the runtime's persisted transcript only contains the
expanded text. After a turn (and when reopening a session) the webview
re-hydrates from that history and rendered the whole SKILL.md body as the
user's message; queue events echoing the expanded prompt could also add a
second user bubble, and fresh sessions were titled with the markdown's first
line. The CLI never shows this because its TUI keeps the typed text in its
own transcript and only sends the expanded prompt to the model.

Mirror that separation inside the desktop sidecar's display boundaries:

- history projection (readSessionMessages) inverts user text that starts
  with a configured command's instructions back to '/name remainder',
  which also repairs sessions recorded before this fix
- queue snapshots and chat_queued_prompt_start events echo the typed
  prompt recorded at expansion time, so the webview's optimistic-bubble
  re-key matches again
- an untitled session sent an expanded prompt gets titled from the typed
  command instead of the instructions' first line

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

* fix(desktop): don't overwrite a mid-turn rename with the typed-command title

The untitled check ran before dispatch, so renaming a fresh slash-command
session while its first turn was running got clobbered by the post-turn
typed-command title. Re-check at write time and only replace a missing title
or the one the runtime auto-derived from the expanded prompt.

Also documents the inherent prefix-inversion ambiguity flagged in review:
text hand-typed with a command's exact instructions persists byte-identically
to that command's expansion, so stored history alone cannot distinguish them.

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

* fix(desktop): stop expanding skill commands; let the skills tool load them

Pasting the skill body into the prompt is why the transcript could ever show
it: the desktop webview re-hydrates from the runtime's persisted history, so
whatever the sidecar splices into the user message renders as if the user
typed it. The runtime already registers the skills tool, whose description
requires the model to invoke it whenever the user references a slash command
— so send the typed /skill text through and let the tool deliver the
instructions as a tool result (previously they arrived twice: pasted and via
the tool). The persisted user message, session title, and queue entries are
then simply the typed command, which deletes the typed-prompt registry, the
queue event/snapshot rewriting, and the title machinery from the previous
approach.

Workflows are not served by the skills tool and keep textual expansion, so
the read-time display inverter stays: it collapses expanded workflow prompts
— and skill prompts persisted before this change — back to the typed
/command in the history projection.

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

* feat(core): option to keep skill slash commands typed for the skills tool

resolveRuntimeSlashCommandFromWatcher (and the hub snapshot proxy) accept
expandSkillCommands: hosts whose sessions register the skills tool pass
false so the typed /skill goes through and the model loads the instructions
as a tool result, keeping the persisted transcript as what the user typed.
Workflows always expand — the tool does not serve them. isSkillsToolAvailable
exposes the catalog check hosts use to decide (yolo preset and the skills
tool toggle leave textual expansion as the only delivery path).

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

* fix(cli): skill slash commands load via the skills tool instead of expanding

The TUI user-command wrap and buildUserInputMessage now keep a typed /skill
as-is when the session's mode/toggles register the skills tool, matching the
desktop app; workflows keep expanding, and yolo (zen) keeps expanding skills
because its preset has no skills tool. This also fixes CLI resume/history
surfaces showing the skill body: the persisted user message is now the typed
command.

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

* fix(vscode): keep configured skill slash commands typed for the skills tool

expandSlashCommands no longer splices a configured skill's instructions into
the model text; the SDK session's skills tool delivers them as a tool result
(previously they arrived twice). Builtin pseudo-skills like /deep-planning
are not served by that tool and keep expanding, as do workflows.

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

* refactor(desktop): use the shared skill-expansion option in the sidecar

Replaces the sidecar's workflow-detection dance with core's
expandSkillCommands option and gates on isSkillsToolAvailable, restoring
textual expansion where the tool is missing (yolo mode or the skills tool
toggle) — a gap in the previous desktop-only change.

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

* refactor(desktop): drop the display inverter for expanded transcripts

Accepted trade-off to keep the change minimal: sessions recorded before
skills switched to the skills tool, workflow sends (deprecated), and
yolo-mode skill sends persist expanded instructions and now render that text
as-is instead of being collapsed back to the typed /command at projection
time.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 22:22:36 -07:00
Saoud RizwanandSaoud Rizwan 67e5115b85 fix(cli): make TUI dialog colors follow theme changes live (#13355)
* fix(cli): make TUI dialog colors follow theme changes live

Dialog content previously read the static palette constant, so open
dialogs (including the theme picker itself) kept the default dark-blue
accents while scrolling through theme previews. Add getDialogPalette /
useDialogPalette, which resolve dialog colors from the active theme's
dialog accents and re-render on every theme change, and migrate all
dialog-rendered components to it.

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

* feat(cli): derive dialog panel background from the active theme

Dark themes now lift their own background one OKLAB step for the dialog
surface, so panels keep the theme's hue instead of the library's fixed
#262626. DialogThemeSync pushes the surface into the dialog container
for new dialogs and repaints open panels, so the surface also follows
live theme previews. Light themes keep the neutral dark panel to match
the dark accent fallback and the light-on-dark dialog text.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 22:16:26 -07:00
61b95a62ee feat(desktop): stream run command output (#13179)
* feat(desktop): stream run command output

* fix(sdk): clean up detached command logs

* fix(sdk): reap detached logs after hub restarts

* fix(sdk): preserve live detached command logs

* fix(desktop): harden live command progress

* fix(sdk): recover detached logs for local hosts

* fix(desktop): reconcile command output tool rows

* fix(sdk): retain logs for surviving commands

* fix(core): prevent PID reuse from retaining detached logs

* fix(core): preserve detached logs on probe failures

* fix(core): retain detached logs during probe outages

* fix(desktop): resolve leftover merge conflict in messages projection test

Combine both sides of the assertion: main's incremented per-block
createdAt projection and this branch's toolCallId/hookEventName meta.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 21:10:18 -07:00
Saoud RizwanandSaoud Rizwan 6cc0328424 docs: add GLM-5.3 to ClinePass models and reference pricing (#13357)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 20:25:00 -07:00
Saoud RizwanandSaoud Rizwan 90b32cb41d fix: normalize Gemini custom base URLs for legacy host-root values (#13329)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 20:21:51 -07:00
Saoud RizwanandSaoud Rizwan 3c433d5a90 fix: run_commands object form without args routes through the shell instead of failing with ENOENT (#13336)
* fix: run_commands object form without args routes through the shell

The structured { command, args? } form of run_commands was always spawned
directly with shell: false. When a model emitted a full command line in
command with no args (e.g. { command: "echo hello" }), spawn failed with
ENOENT for any command containing a space, breaking command execution for
the whole session.

Direct exec now only applies when a non-empty args list is provided; the
object form without args is routed through getShellInvocation like the
string form. Schema descriptions are tightened so models put arguments in
args instead of embedding them in command.

Fixes #13279

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

* chore: trim structured-command schema descriptions

The union schema is only used for lenient validation of input the model
already sent; its descriptions never reach a model prompt. Keep them
short instead of restating executor behavior.

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

* chore: simplify direct-exec comment in shell executor

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

* revert: keep original structured-command schema description

The description never reaches a model prompt and the executor now handles
both shapes, so the wording change was cosmetic noise.

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

* fix: gate direct exec on args key presence, not array length

Review feedback: an explicit empty args array is intentionally structured
input and stays direct exec; only an object with no args key is treated
as a full shell command line. Matches the key-presence rule already used
by the VS Code host's formatCommandForTerminal. Also replaces the
empty-args shell test (which was PowerShell-incompatible) with a test
pinning the direct-exec contract.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 19:52:52 -07:00
Saoud Rizwan 76ac1c7f55 ci(ui-publish): build @cline/shared before ui typecheck (#13354)
@cline/ui's generated-media imports @cline/shared/browser, which resolves to
shared's dist output. The build-shared step sat after typecheck/test/build,
so the first ui-publish dispatch since #13025 failed at Typecheck UI with
TS2307. Move the step to right after install.
2026-08-18 18:01:10 -07:00
Saoud Rizwan be56c505e4 feat(ui): share the markdown pipeline, chat polish, and ThinkingBlock across products (#13323)
* feat(ui): share the markdown pipeline, chat polish CSS, and ThinkingBlock

The desktop app and the cloud dashboard both consume @cline/ui yet rendered
assistant output differently, because Markdown policy and the thinking-trace
row lived app-side. This moves the shareable parts into the package:

- components/markdown (new export): the lazy Shiki code highlighter (GitHub
  light/dark, pinned language set) and agentMarkdownControls — the standard
  Streamdown configuration. streamdown/shiki/@shikijs/* become optional peer
  dependencies, mirroring @pierre/diffs.
- components/markdown.css: the desktop's chat polish moves in — chat-scale
  headings, outside list markers, single quiet code blocks with a
  hover-revealed copy control, table cards. Kept unlayered so it beats
  Streamdown's layered Tailwind utilities without !important.
- ThinkingBlock + formatThoughtLabel in agent-chat: the standard thinking
  row (brain icon, Thinking/Thought-for-Ns label, streaming shimmer, rail
  presentation, capped scrollable body). The shimmer and the
  reasoning-hover-suppression rule move into agent-chat.css; triggers gain
  the color transition the desktop applied locally.

Version bumps to 0.2.0-next.5 for the dashboard to pick up.

* refactor(desktop): consume shared markdown and thinking primitives from @cline/ui

The local Shiki highlighter, Streamdown controls, chat markdown polish CSS,
streaming-title shimmer, and reasoning hover-suppression rule are deleted in
favor of the @cline/ui versions (the highlighter test moves to the package's
suite). ReasoningBlock becomes a thin wrapper that hands MemoizedMarkdown to
the shared ThinkingBlock, and formatThoughtLabel re-exports from the package
so grouping code and tests keep their import path.

globals.css now imports @cline/ui/components/markdown.css (unlayered, so the
polish keeps beating Streamdown's layered utilities); the app keeps only what
is genuinely app-specific: link/image policy in markdown.tsx, selectability
rules, accent palettes, and the view-enter transition.

* style(ui/desktop): make thinking-trace prose legible

Thinking body text rendered too faint: plain muted-foreground plus the
desktop's font-thin weight. The shared thinking content now leans 75% of the
way back toward the body text color (still slightly de-emphasized), and the
desktop drops the thin font weight.
2026-08-18 17:52:17 -07:00
Saoud Rizwan 3f9c9c3f33 feat(ui/desktop): collapse finished runs into a work summary and remove hover-state dead space (#13315)
* feat(ui): add WorkActivity collapsed-run summary and float message actions as a pill

WorkActivity/WorkActivityTrigger/WorkActivityContent fold a finished agent
run's working rows (tool calls, thinking traces, narration) behind a single
"Worked for 4m 12s · 14 tool calls" disclosure built on the shared animated
disclosure primitives, with formatWorkActivityLabel/formatWorkDuration
exported for consumers.

Message hover actions no longer rely on the transcript reserving blank space
below each message: the action row is now a self-backed pill (border,
blurred background, shadow) that floats over whatever follows, so
conversations can pack rows tightly without hover chrome colliding with the
next message.

* feat(desktop): collapse finished runs into a work summary and tighten chat spacing

collapseCompletedWork post-processes the grouped transcript: once a run ends
on assistant text with no further tool calls, its working rows fold into one
expandable WorkActivity row while the final answer stays visible. Runs are
delimited by user messages; the trailing run only collapses when the session
has stopped running and actually produced an answer, so live streams and
cancelled/failed tails keep their rows. Assistant messages carrying images
or media are treated as deliverables and never collapse.

The conversation list gap drops from gap-8 to gap-4 now that hover actions
are self-backed pills that need no reserved space, and user messages add
their own top margin so turn boundaries stay visually distinct.

* refactor(ui/desktop): work summary label wording, flat expansion, stable in-run rhythm

Feedback round on #13315:

- Label reads "Worked for 4m 12s and made 14 tool calls" instead of joining
  with a dot; without a duration it falls back to "Made N tool calls".
- Expanded work rows render at transcript level — no rail or extra indent —
  since tool rows and thinking traces already carry their own nesting when
  expanded. The work content keeps the tight working-row rhythm.
- Live working rows (thinking traces + tool calls) now group into a 'run'
  render item with the same tight 0.25rem rhythm, so there is no oversized
  gap under a "Thought for Ns" row and every row keeps its exact position
  when the finished run folds into the work summary. A trailing
  answer-in-progress stays outside the group at transcript level, and pure
  prose spans keep normal spacing.
- The transient "Thinking..." indicator moves inside the transcript column
  and mirrors a trigger row's geometry, so the first real row replaces it in
  place with no jump.

* style(ui/desktop): hover-pill metrics, right-pointing work chevron, scroll and spacing fixes

Another feedback round on #13315:

- Hover action pill: +2px internal padding, a trailing inset after the
  timestamp (it sat flush against the pill border), and more clearance
  between the message content and the pill (2px -> 6px; the hover bridge
  grows to match).
- The work summary chevron points right while collapsed and continues
  counterclockwise to point up when expanded.
- Conversation bottom padding drops pb-20 -> pb-8: the composer sits below
  the scroller, so the padding only needs to clear a pinned action pill.
- Sending a message scrolls back to the bottom even if the reader had
  scrolled up (new AutoScrollOnSend on the user-message count, which ignores
  optimistic-bubble re-keying; @cline/ui now exports useConversation for
  this).
- An assistant answer directly under its run's working rows pulls itself
  0.5rem closer than the full transcript gap.

* style(desktop): leave a visible gap between a pinned action pill and the composer

pb-8 exactly matched the pill's ~40px footprint, so the last row's hover
actions sat flush against the composer top; pb-12 restores ~8px of daylight.

* style(desktop): widen the gap between the pinned action pill and the composer to ~24px

pb-12 left only ~8px of daylight under the pill; pb-16 reads comfortable
without reverting to pb-20's dead space.

* fix(desktop): keep the thinking indicator at the working-row offset mid-run

The indicator matched a trigger row's geometry but sat a full transcript gap
(1rem) below the last working row, while the tool/thinking row replacing it
joins the tight run group at 0.25rem — a visible upward jump. When the last
transcript item is working rows (or streamed assistant output), the
indicator now pulls up to the same tight offset; only at the start of a run,
under the user message, does it keep the normal gap.

* style(ui): calm the hover actions surface per team feedback

Borderless rectangle instead of the bordered pill: radius drops to
var(--radius), the side padding goes entirely (the icon buttons carry their
own hit areas), and the vertical padding halves. Blurred background and
shadow stay so it remains legible over following content.

* feat(ui/desktop): full-band hover reveal and iOS-style disclosure easing

The hover actions only appeared while the pointer was inside the message
box itself. The invisible bridge under each message now spans the full
height of the band the floating actions occupy (full row width), so
hovering anywhere in that strip reveals them. Sibling row types
(.cline-chat-tool, .cline-chat-work, and the desktop's run/tool groups)
become position: relative so they paint above the bridge — their own
content keeps its hover and clicks, and the bridge only wins in the band's
genuinely empty space.

All expandable rows (work summary, tool panels, thinking) open and close on
a 240ms symmetric ease-in-out cubic-bezier instead of the 60ms snap, with
chevron rotation on the same curve. Reduced-motion still disables both.

* revert(ui/desktop): drop the full-band hover reveal; quicken disclosure easing to 180ms

The full-band hover bridge (and the position: relative changes that made it
safe) is reverted per feedback — back to the narrow bridge that only spans
the gap under the message. The iOS-style ease-in-out on disclosures stays
but speeds up from 240ms to 180ms.

* fix(ui): recover live tool diffs that mount as a blank pierre skeleton

Live-streamed edit rows could show an empty diff for the whole run, with the
diff only appearing after the collapsed work row was expanded (fresh mount).
Root cause, confirmed by driving a live session and inspecting the element:
React StrictMode double-invokes @pierre/diffs' ref callback; the first
instance's async highlight work aborts on its immediate cleanup, and the
second instance adopts the abandoned half-rendered shadow tree as if it were
complete prerendered output — zero height, no code, no theme stylesheet,
permanently. A rendered diff always carries style[data-theme-css] in its
shadow root, so ToolFileDiff now checks for it shortly after mount and
remounts FileDiff (bounded attempts) when missing; the fresh host element
takes the normal render path and recovers within ~400ms. Verified live: the
diff now renders during the run.

* fix(desktop): keep interrupted runs expanded even with partial trailing text

The trailing-run collapse gated on 'ended with assistant text', which
misread a Stop that landed mid-answer as a finished run and folded the tool
calls the user wants to inspect. The gate is now the terminal status itself:
only completed (or restored-idle) sessions collapse the trailing run;
cancelled/failed/error tails keep their rows regardless of partial text.
(Greptile P1 on #13315 — matches the PR's stated rule.)
2026-08-18 17:46:57 -07:00
Saoud RizwanandCursor Agent cf07572a07 feat(desktop): show provider web-search support under the settings toggle (#13328)
* feat(desktop): show provider web-search support under the settings toggle

The global Web search toggle silently does nothing unless the session's
provider offers native web search, which made the setting read as if it
worked with any provider. The desktop General settings row now explains
that only providers with built-in web search honor it, and shows a live
status line: which connected providers are ready to use it (no extra
setup needed), or an amber warning with a link to the Models section
when none of them support it.

Support is resolved in the webview via a new providerOffersModelTool
helper in @cline/llms (browser export), sharing the same builtin-manifest
source of truth as the runtime's supportsModelTool attachment check.

* fix(desktop): address review — refetch web-search status on catalog invalidation, clarify per-model support

Greptile P2: the one-time catalog fetch could race an in-flight provider
save and show stale status; the row now refetches when the provider
catalog cache is invalidated (fired after saves complete).

Greptile P1: the ready line implied every model on the provider works;
Vertex excludes Claude routes, so the copy now scopes the promise to
models that support it.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-18 17:20:55 -07:00
a5ac26f279 fix(vscode): clear task-scoped settings overlay when task view is cleared or switched (#13310)
* fix(vscode): clear task-scoped settings overlay when task view is cleared or switched

Toggling an auto-approve setting while a task is open writes
autoApprovalSettings into the StateManager's task-settings overlay
(updateAutoApprovalSettings -> setTaskSettings). The SDK controller never
cleared that overlay on clearTask/showTaskWithId (the legacy controller
did), so after New Task the stale overlay kept shadowing global settings
in getGlobalSettingsKey(): toggle RPCs were accepted into global state,
but every posted state still carried the overlay's old version, which the
webview rejects as not newer - the auto-approve checkboxes froze forever.

Restore legacy parity in SdkTaskControlCoordinator: drop the overlay
(persisting pending writes first) in clearTask() and before installing a
different task's proxy in showTaskWithId().

Fixes #13260

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

* changeset

* test(vscode): add end-to-end regression test for auto-approve freeze after New Task

Wires the real StateManager, the real updateAutoApprovalSettings handler,
and the real SdkTaskControlCoordinator.clearTask() together with the
webview's version gate modeled on ExtensionStateContext, pinning the
end-to-end invariant behind #13260: checkbox toggles must keep reaching
the webview after a mid-task toggle followed by New Task. Verified the
test fails when the clearTaskSettings() call is removed from clearTask().

* fix implicit any in regression test

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-18 16:54:59 -07:00
BeeandSaoud Rizwan 508a5322af feat(desktop): native notifications (#13166)
* feat(desktop): native notifications

* macos target

* fix(desktop): isolate macOS dev app identity

* fix(desktop): address notification review feedback

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-18 16:46:25 -07:00
Max 38f8260bc3 fix(vscode): SDK remote-config parity — refresh coordination, session gating, and fail-closed opt-out (#13226) 2026-08-18 16:17:24 -07:00
Saoud Rizwan eeaed357ef fix(ci): lock the legacy publish workflow to the legacy-extension branch (#13350)
The branch dispatch input was a free-form string with no validation. Both
jobs checked it out and ran full npm lifecycle scripts from it: the publish
job next to VSCE_PAT/OVSX_PAT (and npm run publish:marketplace executes a
script from that same ref with the PATs in env), and the test job with NO
environment approval at all while inheriting the workflow-level
contents/packages/checks/pull-requests write grants. A dispatch pointing at
e.g. refs/pull/N/head would run outside-contributor code with the
marketplace keys behind one approval, or with a repo-write token behind
none.

Remove the input and hardcode the protected legacy-extension branch, drop
the workflow-level permissions to contents: read, and elevate only the
publish job to contents: write (tag push + GitHub release). The branch
input's default was legacy-extension, so normal publishes are unchanged.
publish-extension skill dispatch command updated to match.
2026-08-18 16:12:04 -07:00
Saoud Rizwan bca9b64206 fix(ci): always build the legacy bundle from the legacy-extension branch (#13349)
The combined-VSIX workflow took legacy-ref as a free-form dispatch input
with no publish-time validation (next-ref has one: publish requires main).
Any typed ref — a PR merge ref, an unprotected branch — would be built
into the published VSIX by the environment-less build job, and the publish
environment approver only ever sees an opaque prebuilt artifact, so the
approval protected the marketplace PAT but not the shipped bytes.

Remove the input entirely and hardcode the protected legacy-extension
branch, which makes that branch's protection rules load-bearing for
releases. The tested-sha pinning between test-legacy and build is
unchanged. publish-extension skill dispatch command updated to match.
2026-08-18 16:00:35 -07:00
Mikołaj Kondratek 8a038022a4 fix(vscode): point provider signup URLs at their API key pages (#13337)
* fix(vscode): point Mistral signup URL at the general API keys console

The Mistral provider's signup link led to the Codestral console, which
issues Codestral-scoped keys that fail with 401 on api.mistral.ai — the
endpoint the provider actually calls. Point it at the general API keys
page instead.

Fixes #13288

* fix(vscode): deep-link DeepSeek and Fireworks signup URLs to their API key pages

Both pointed at marketing homepages; link straight to the key-creation
pages instead, matching the rest of the registry and the desktop app's
provider-key-urls map.
2026-08-18 16:53:54 +02:00
JasmineLCY c2e293e293 fix(vscode): preserve LiteLLM input token limits (#13293)
* fix(vscode): preserve LiteLLM input token limits

* fix(vscode): prefer live LiteLLM model metadata

* fix(vscode): generalize private catalog metadata

* test(vscode): preserve llms exports in vscode lm mock
2026-08-18 12:00:33 +02:00
Saoud RizwanandSaoud Rizwan b9efa96826 fix(vscode): continue the surviving session on resume instead of rebuilding with the original task text (#13175)
* fix(vscode): stop resubmitting the original task text on bare resume (#12975)

A bare Resume after Stop rebuilt the session from task history and
injected historyItem.task into the resumption prompt as 'New
instructions from the user'. The model treated the already-completed
original request as fresh instructions and re-executed it (e.g. re-ran
all terminal commands after stopping a queued follow-up turn).

The preserved conversation history is the source of truth on resume, so
the fallback prompt now just asks the model to reassess the history and
continue, matching the legacy resume prompt which also never resent the
original task. User-typed text still takes precedence when provided.

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

* fix(vscode): continue the surviving idle session on follow-ups instead of rebuilding

Stopping a turn keeps the session alive, but every idle follow-up (bare
Resume after Stop, and typed follow-ups after a completed turn) tore
that session down and rebuilt it from persisted task history before
sending. Continue the matching idle session in place instead, the same
way the CLI reuses the live session after an abort. Rebuilding from
history now only happens when no live session matches the displayed
task (task opened from history, extension host reload).

A bare resume still needs a prompt to start a turn, so it sends the
neutral [TASK RESUMPTION] prompt (shared with the rebuild fallback and
hidden from the transcript); user-typed content is echoed and sent
as-is. If the send lands while the abort is still settling, the runtime
auto-queues it and drains once the abort completes.

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

* refactor(vscode): consolidate follow-up send paths in SdkFollowupCoordinator

Now that idle follow-ups continue the live session in place, the
two-mode sendToActiveSession helper was redundant: its non-queued branch
duplicated continueIdleSession minus the bare-resume prompt. Split it
into a single-purpose queueToActiveSession and fold the idle no-task
send into continueIdleSession, flattening askResponse's decision tree
to: queue onto a running turn, continue a matching live idle session,
rebuild from history, or abandon.

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

* refactor(vscode): reuse the existing neutral resumption prompt for bare resumes

Drop the newly invented long resumption wording in favor of the phrase
that already existed as the no-history fallback and that the transcript
hiding logic and test fixtures recognize: '[TASK RESUMPTION] Please
continue where you left off.' The net change to resumeSessionFromTask
against main is now just deleting the branch that resubmitted
historyItem.task as new instructions.

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

* fix(vscode): stop resubmitting the original task text on bare resume (#12975)

A bare Resume after Stop rebuilds the session from task history and
injected historyItem.task into the resumption prompt as 'New
instructions from the user'. The model treated the already-completed
original request as fresh instructions and re-executed it (e.g. re-ran
all terminal commands after stopping a queued follow-up turn).

Bare resumes now always use the neutral prompt that already existed as
the no-history fallback; user-typed text still takes precedence. This
matches the legacy resume prompt (responses.taskResumption), which only
ever included user-supplied text as new instructions.

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

* fix(vscode): hide synthetic prompts from the queued-prompt echo

A send that races a settling abort is auto-queued by the runtime, so a
bare Resume can reach the pending_prompt_submitted echo carrying the
synthetic [TASK RESUMPTION] prompt. Echoing it leaked model-facing text
as a visible user bubble and shifted the visible-user-message ordinals
that edit/regenerate mapping relies on. Filter synthetic prompts with
isSyntheticUserPrompt, keeping user attachments visible (matching
isSyntheticSdkUserMessage semantics).

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-17 19:10:59 -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
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 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
Bee 87be867599 fix(desktop): make routine selectors clickable (#13324) 2026-08-17 17:05:33 -07:00
Bee 26cb0ec9eb test(llms): use Google language operation (#13318) 2026-08-17 15:35:56 -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
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
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
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
638 changed files with 88727 additions and 8629 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
+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 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 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 the Cline desktop app, 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".
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline 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)
+6 -2
View File
@@ -93,7 +93,9 @@ Release prep on `main` (PR, not direct push):
```bash
gh workflow run ext-vscode-ab-package.yml --ref main \
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
-f version=<VERSION> -f next-ref=main -f publish=true
# (the legacy bundle always builds from the protected legacy-extension branch;
# it is deliberately not an input)
# publish=false builds an installable .vsix artifact without publishing and
# needs NO environment approval — the ungated build job uploads the artifact
# and the run completes.
@@ -156,7 +158,9 @@ For shipping a fix on the `legacy-extension` branch — or as the **structural r
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main \
-f release-type=release -f branch=legacy-extension
-f release-type=release
# (the branch is hardcoded to legacy-extension in the workflow; it is
# deliberately not an input)
```
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
+29 -1
View File
@@ -206,6 +206,8 @@ jobs:
- name: Get Changelog Entry
id: changelog
env:
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
run: |
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
@@ -213,6 +215,32 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
# and link out to the full notes. The GitHub release body stays whole.
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
@@ -248,7 +276,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
+229 -29
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"
;;
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 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" -> Cline, "Cline Beta" -> Cline-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,14 +489,50 @@ 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
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green. Post a trimmed copy to Slack and link out to the full
# notes. The GitHub release body and updater manifest stay whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
@@ -390,8 +551,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 +570,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 +579,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 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 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 "Published Cline 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,17 +639,17 @@ jobs:
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
text: "Cline 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 desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- 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) || '' }}"
+44 -14
View File
@@ -23,11 +23,6 @@ on:
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
required: true
@@ -130,31 +125,37 @@ jobs:
name: Test legacy bundle
runs-on: ubuntu-latest
# The tested revision, exported so the build job builds EXACTLY what
# this suite ran against. legacy-ref is a mutable branch name and the
# build job starts later — re-resolving the name there could pick up
# commits this gate never saw.
# this suite ran against. legacy-extension is a mutable branch name and
# the build job starts later — re-resolving the name there could pick
# up commits this gate never saw.
outputs:
tested-sha: ${{ steps.rev.outputs.sha }}
defaults:
run:
working-directory: apps/vscode
steps:
# Always the protected legacy-extension branch — deliberately not
# an input. An arbitrary ref here would be built into the published
# VSIX by the environment-less build job, and the publish
# environment approver only ever sees an opaque prebuilt artifact:
# the approval would protect the marketplace PAT but not the
# shipped bytes. Hardcoding the branch makes its protection rules
# load-bearing for releases. Legacy hotfix testing has its own
# workflow (ext-vscode-publish-legacy.yml).
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
ref: legacy-extension
- name: Record tested revision
id: rev
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Deliberately no dependency cache here: publish workflows do clean
# installs and should not restore actions caches.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
@@ -489,6 +490,35 @@ jobs:
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
{
echo "slack_content<<CHANGELOG_EOF"
echo "$SLACK_CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve previous release tag
id: prev_tag
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
@@ -544,7 +574,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
+53 -20
View File
@@ -21,20 +21,17 @@ on:
options:
- pre-release
- release
branch:
description: "Branch holding the legacy extension code"
required: true
default: "legacy-extension"
type: string
# Read-only by default. The publish job elevates itself to contents: write for
# the tag push and GitHub release; nothing here needs packages/checks/PR
# write. Keeping the default minimal matters doubly in this workflow because
# the test job runs BEFORE any environment approval — it must never hold a
# write token while executing checked-out code.
permissions:
contents: write
packages: write
checks: write
pull-requests: write
contents: read
concurrency:
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
group: ext-vscode-publish-legacy
cancel-in-progress: false
jobs:
@@ -50,18 +47,23 @@ jobs:
run:
working-directory: apps/vscode
steps:
# Always the protected legacy-extension branch — deliberately not
# an input. This job runs full npm lifecycle scripts from the
# checked-out code with no environment approval, and the publish
# job below does the same next to the marketplace PATs; an
# arbitrary ref here would hand both of them attacker-controlled
# code. Hardcoding the branch makes its protection rules
# load-bearing for releases.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
ref: legacy-extension
# Deliberately no dependency cache here: publish workflows do clean
# installs and should not restore actions caches.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
@@ -99,16 +101,20 @@ jobs:
name: Publish Legacy Extension
runs-on: ubuntu-latest
environment: publish
# For the tag push in Resolve Release Tag and the GitHub release.
permissions:
contents: write
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
# can create/push the release tag and compute the previous tag.
# Check out the legacy branch (NOT main; hardcoded — see the test
# job's checkout comment). fetch-depth: 0 + tags so we can
# create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
ref: legacy-extension
fetch-depth: 0
fetch-tags: true
lfs: true
@@ -117,7 +123,7 @@ jobs:
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: ${{ github.event.inputs.branch }}
BRANCH: legacy-extension
run: |
# Tag is derived from the package version on the legacy branch.
VERSION=$(node -p "require('./apps/vscode/package.json').version")
@@ -258,6 +264,33 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
@@ -287,7 +320,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
@@ -234,6 +234,33 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -303,7 +330,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
+9 -8
View File
@@ -80,8 +80,9 @@ jobs:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
# Nothing in this job uses OIDC, so it does not need an id-token
# permission.
permissions:
id-token: write
contents: read
defaults:
run:
@@ -93,6 +94,9 @@ jobs:
with:
bun-version: 1.3.14
# Cache keys below are exact-match only (no restore-keys prefix
# fallbacks); a miss just means a cold install, which is acceptable.
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
uses: actions/cache@v4
@@ -100,8 +104,6 @@ jobs:
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache VS Code installation
- name: Cache VS Code
@@ -110,8 +112,6 @@ jobs:
with:
path: apps/vscode/.vscode-test
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
restore-keys: |
vscode-${{ runner.os }}-stable-
# Cache Playwright browsers
- name: Cache Playwright browsers
@@ -123,8 +123,6 @@ jobs:
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
@@ -171,9 +169,12 @@ jobs:
if: matrix.runner != 'ubuntu'
run: bun run test:e2e:optimal
# Repo-root relative: the job's `working-directory` default applies to `run`
# steps only, so an apps/vscode-relative path here silently matches nothing
# and every failing run uploads no recordings at all.
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
test-results/playwright/
apps/vscode/test-results/
+28 -1
View File
@@ -282,6 +282,33 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green. Post a trimmed copy to Slack and link out to the full
# notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: softprops/action-gh-release@v1
@@ -333,7 +360,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
- type: "context"
elements:
- type: "mrkdwn"
+6 -5
View File
@@ -46,6 +46,12 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
# @cline/ui imports @cline/shared/browser (generated-media), which
# resolves to dist output — build it before anything typechecks or
# builds the ui package.
- name: Build shared package
run: bun -F @cline/shared build
- name: Typecheck UI
run: bun -F @cline/ui typecheck
@@ -58,11 +64,6 @@ jobs:
- name: Build UI package
run: bun -F @cline/ui build
# The desktop chat test imports @cline/shared/browser, which resolves to
# dist output that nothing else in this job builds.
- name: Build shared package
run: bun -F @cline/shared build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
+41
View File
@@ -1,5 +1,46 @@
# 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
+24
View File
@@ -1,5 +1,29 @@
# 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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.53",
"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) {
+26 -9
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";
@@ -70,6 +70,10 @@ import {
sendSessionInfoUpdate,
} from "./session-updates";
const CHAT_MODEL_QUERY_OPTIONS = {
filter: "chat",
} satisfies Llms.GetModelsForProviderOptions;
interface SessionState {
id: string;
cwd: string;
@@ -100,7 +104,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 {
@@ -185,7 +189,10 @@ export class AcpAgent implements Agent {
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(providerId);
const providerModels = await Llms.getModelsForProvider(
providerId,
CHAT_MODEL_QUERY_OPTIONS,
);
// Model ids are provider-scoped, so the default must come from the
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
// nothing to `cline`, and vice versa.
@@ -240,7 +247,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
@@ -256,7 +263,10 @@ export class AcpAgent implements Agent {
// provider's own catalog just like newSession.
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(providerId);
const providerModels = await Llms.getModelsForProvider(
providerId,
CHAT_MODEL_QUERY_OPTIONS,
);
session = {
id: params.sessionId,
cwd: params.cwd,
@@ -289,6 +299,7 @@ export class AcpAgent implements Agent {
const providerModels = await Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
);
const availableModels = Object.entries(providerModels).map(
([availableModelId, info]) => ({
@@ -473,7 +484,10 @@ export class AcpAgent implements Agent {
// current one when it's offered there too, otherwise fall back to the
// provider's declared default rather than whichever model happens to
// be listed first (for cline-pass that is an unrelated free model).
const providerModels = await Llms.getModelsForProvider(value);
const providerModels = await Llms.getModelsForProvider(
value,
CHAT_MODEL_QUERY_OPTIONS,
);
session.currentModelId = await resolveDefaultModelId(
value,
session.currentModelId,
@@ -676,7 +690,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 +709,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)
@@ -907,7 +921,10 @@ async function buildAllConfigOptions(
): Promise<SessionConfigOption[]> {
const [providerOption, providerModels] = await Promise.all([
buildProviderConfigOption(session.currentProviderId),
Llms.getModelsForProvider(session.currentProviderId),
Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
),
]);
return [
providerOption,
+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",
+2
View File
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getProcessStartToken: vi.fn(() => undefined),
getConnector: vi.fn(),
listActiveConnectors: vi.fn(),
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
@@ -29,6 +30,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("@cline/core", () => ({
disableConnectorAutostart: mocks.disableConnectorAutostart,
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
getProcessStartToken: mocks.getProcessStartToken,
listActiveConnectors: mocks.listActiveConnectors,
persistConnectorConnection: mocks.persistConnectorConnection,
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
+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;
}
+72 -70
View File
@@ -4,7 +4,8 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createScheduleCommand } from "./schedule";
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockHubClientCommand = vi.hoisted(() => vi.fn());
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
@@ -16,7 +17,17 @@ vi.mock("@cline/core", async () => {
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
sendHubCommand: mockSendHubCommand,
NodeHubClient: class {
command = mockHubClientCommand;
constructor(options: Record<string, unknown>) {
mockNodeHubClientCtor(options);
}
async connect(): Promise<void> {}
close(): void {}
},
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
@@ -74,7 +85,7 @@ describe("runScheduleCommand list output", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
@@ -96,18 +107,21 @@ describe("runScheduleCommand list output", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["No schedules found."]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.list",
payload: {
limit: 100,
enabled: undefined,
tags: undefined,
},
},
// Schedule commands are workspace-scoped: the hub client must register
// with a workspace context (and the hub auth token) before commanding.
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
workspaceRoot: process.cwd(),
cwd: process.cwd(),
authToken: "test-token",
}),
);
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
limit: 100,
enabled: undefined,
tags: undefined,
});
});
it("keeps JSON list output unchanged when --json is provided", async () => {
@@ -115,7 +129,7 @@ describe("runScheduleCommand list output", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
@@ -137,7 +151,7 @@ describe("runScheduleCommand list output", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["[]"]);
expect(mockSendHubCommand).toHaveBeenCalled();
expect(mockHubClientCommand).toHaveBeenCalled();
});
});
@@ -157,7 +171,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -189,15 +203,19 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
url: "ws://127.0.0.1:25463/hub",
workspaceRoot: "/tmp/workspace",
cwd: "/tmp/workspace",
authToken: "test-token",
}),
);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
);
});
@@ -215,7 +233,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -246,14 +264,11 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
);
});
@@ -292,7 +307,7 @@ describe("runScheduleCommand create", () => {
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();
expect(mockHubClientCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -300,7 +315,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_delivery" } },
});
@@ -339,21 +354,17 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
},
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
},
}),
},
},
}),
);
});
});
@@ -370,7 +381,7 @@ describe("runScheduleCommand import", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -411,16 +422,12 @@ describe("runScheduleCommand import", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
},
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
);
});
});
@@ -444,7 +451,7 @@ describe("runScheduleCommand export", () => {
prompt: "review status",
workspaceRoot: "/tmp/workspace",
};
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
@@ -484,14 +491,9 @@ describe("runScheduleCommand export", () => {
const written = await readFile(targetPath, "utf8");
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.get",
payload: { scheduleId: "sched_abc" },
},
);
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
scheduleId: "sched_abc",
});
} finally {
await rm(targetPath, { force: true });
}
@@ -507,7 +509,7 @@ describe("runScheduleCommand export", () => {
name: "Weekly Sync",
cronPattern: "0 9 * * 1",
};
mockSendHubCommand.mockResolvedValue({
mockHubClientCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
+62 -26
View File
@@ -2,7 +2,7 @@ import {
createLocalHubScheduleRuntimeHandlers,
HubScheduleCommandService,
HubScheduleService,
sendHubCommand,
NodeHubClient,
} from "@cline/core";
import {
ensureCliHubServer,
@@ -11,28 +11,51 @@ import {
import type { CommandIo } from "./types";
export class HubScheduleClient {
private hub: Promise<NodeHubClient> | undefined;
constructor(
private readonly endpoint: {
host?: string;
port?: number;
pathname?: string;
},
private readonly url: string,
private readonly workspaceRoot: string,
private readonly authToken?: string,
) {}
close(): void {}
close(): void {
const hub = this.hub;
this.hub = undefined;
void hub?.then((client) => client.close()).catch(() => undefined);
}
// Schedule commands are authorized against the workspace bound to the
// connection's client registration, so all commands must share one
// registered connection instead of fire-and-forget envelopes.
private connectedHub(): Promise<NodeHubClient> {
this.hub ??= (async () => {
const client = new NodeHubClient({
url: this.url,
clientType: "cli-schedule",
displayName: "Cline CLI scheduler",
workspaceRoot: this.workspaceRoot,
cwd: this.workspaceRoot,
authToken: this.authToken,
});
try {
await client.connect();
} catch (error) {
client.close();
this.hub = undefined;
throw error;
}
return client;
})();
return this.hub;
}
private async command(
command: string,
payload?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const reply = await sendHubCommand(this.endpoint, {
clientId: "cline-schedule",
command: command as never,
payload,
});
if (!reply.ok) {
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
}
const client = await this.connectedHub();
const reply = await client.command(command as never, payload);
return (reply.payload ?? {}) as Record<string, unknown>;
}
@@ -97,6 +120,7 @@ export class LocalScheduleClient {
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
private readonly commands = new HubScheduleCommandService(this.service);
constructor(private readonly workspaceRoot: string) {}
close(): void {
void this.service.dispose();
@@ -106,12 +130,21 @@ export class LocalScheduleClient {
command: string,
payload?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const reply = await this.commands.handleCommand({
version: "v1",
clientId: "cline-schedule-local",
command: command as never,
payload,
});
const reply = await this.commands.handleCommand(
{
version: "v1",
clientId: "cline-schedule-local",
command: command as never,
payload,
},
{
clientId: "cline-schedule-local",
workspaceContext: {
workspaceRoot: this.workspaceRoot,
cwd: this.workspaceRoot,
},
},
);
if (!reply.ok) {
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
}
@@ -185,24 +218,27 @@ export async function ensureSchedulerHub(
if (!address?.trim()) {
return {
ok: true,
client: new LocalScheduleClient() as unknown as HubScheduleClient,
client: new LocalScheduleClient(
workspaceRoot,
) as unknown as HubScheduleClient,
};
}
try {
const requestedEndpoint = parseHubEndpointOverride(address);
const { url: hubUrl } = await ensureCliHubServer(
const { url: hubUrl, authToken } = await ensureCliHubServer(
workspaceRoot,
requestedEndpoint,
);
const endpoint = parseHubEndpointOverride(hubUrl);
return {
ok: true,
client: new HubScheduleClient(endpoint),
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
};
} catch (_error) {
return {
ok: true,
client: new LocalScheduleClient() as unknown as HubScheduleClient,
client: new LocalScheduleClient(
workspaceRoot,
) as unknown as HubScheduleClient,
};
}
}
+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}`,
+6 -56
View File
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
closeSync,
@@ -13,7 +13,11 @@ import {
} from "node:fs";
import { join } from "node:path";
import type { HubSessionClient, HubSessionRow } from "@cline/core";
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
import {
ensureParentDir,
getProcessStartToken,
resolveClineDataDir,
} from "@cline/core";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
withResolvedClineBuildEnv,
@@ -85,60 +89,6 @@ type ProcessProbe = {
getStartToken: (pid: number) => string | undefined;
};
function getProcessStartToken(pid: number): string | undefined {
if (!Number.isInteger(pid) || pid <= 0) {
return undefined;
}
try {
if (process.platform === "linux") {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
if (commandEnd < 0) {
return undefined;
}
// Fields after the command name begin at field 3 (state), so field
// 22 (starttime) is index 19.
const startTime = stat
.slice(commandEnd + 1)
.trim()
.split(/\s+/)[19];
const bootId = readFileSync(
"/proc/sys/kernel/random/boot_id",
"utf8",
).trim();
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
}
const result =
process.platform === "win32"
? spawnSync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
},
)
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
encoding: "utf8",
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
const startTime = result.status === 0 ? result.stdout.trim() : "";
return startTime ? `${process.platform}:${startTime}` : undefined;
} catch {
return undefined;
}
}
const defaultProcessProbe: ProcessProbe = {
isRunning: isProcessRunning,
getStartToken: getProcessStartToken,
+74
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
@@ -962,6 +1028,7 @@ export async function handleConnectorUserTurn<
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
{ mode: startRequest.mode },
);
try {
await input.client.sendRuntimeSession(
@@ -1051,6 +1118,7 @@ async function runConnectorRuntimeTurnWithRecovery<
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
{ mode: startRequest.mode },
);
const request: ChatRunTurnRequest = {
config: startRequest,
@@ -1151,6 +1219,7 @@ async function runConnectorRuntimeTurn<
client: input.client,
sessionId,
});
const generatedMedia: GeneratedMedia[] = [];
const activeTurn: ActiveConnectorTurn = {
sessionId,
@@ -1200,6 +1269,9 @@ async function runConnectorRuntimeTurn<
formatConnectorApprovalPrompt(approval),
);
},
onMedia: (media) => {
generatedMedia.push(media);
},
onCompleted: async (result) => {
await input.onReplyCompleted?.({
sessionId,
@@ -1219,7 +1291,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);
})();
}
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useMemo, useState } from "react";
import { palette } from "../tui/palette";
import { useDialogPalette } from "../tui/hooks/use-theme";
import {
type DialogDismissKey,
isAnyKeyDismiss,
@@ -35,6 +35,7 @@ export function MigrationNoticeContent(
},
) {
const { dialogId, notice, resolve } = props;
const palette = useDialogPalette();
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
+4 -1
View File
@@ -17,6 +17,7 @@ import {
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import type { TuiStartupTarget } from "./tui/types";
import { filterChatModels } from "./utils/chat-models";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
@@ -1029,7 +1030,9 @@ export async function runCli(): Promise<void> {
`${c.dim}[model-catalog] catalog resolution failed (${message})${c.reset}`,
);
}
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
const knownModelIds = knownModels
? Object.keys(filterChatModels(knownModels))
: [];
const resolvedReasoning = resolveCliReasoning({
thinking: args.thinking,
thinkingExplicitlySet: args.thinkingExplicitlySet,
@@ -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;
+26 -1
View File
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
import { basename, resolve } from "node:path";
import {
buildWorkspaceMetadata,
isSkillsToolAvailable,
mergeRulesForSystemPrompt,
readGlobalSettings,
type UserInstructionConfigService,
} from "@cline/core";
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
@@ -79,9 +81,30 @@ function resolveMentionPath(filePath: string): string {
return resolve(filePath);
}
/**
* Whether a typed `/skill` command must be textually expanded into the
* prompt. When the session registers the runtime's `skills` tool (its
* description requires the model to invoke it on slash-command references),
* the typed command passes through and the instructions arrive as a tool
* result keeping the persisted transcript as what the user typed. When the
* tool is unavailable (yolo preset, user toggle), expansion is the only
* delivery path.
*/
export function shouldExpandSkillSlashCommands(mode?: string): boolean {
try {
return !isSkillsToolAvailable({
mode: mode === "plan" || mode === "yolo" ? mode : "act",
disabledToolIds: new Set(readGlobalSettings().disabledTools ?? []),
});
} catch {
return true;
}
}
export async function buildUserInputMessage(
rawPrompt: string,
userInstructionService?: UserInstructionConfigService,
options?: { mode?: string },
): Promise<{
prompt: string;
userImages: string[];
@@ -90,7 +113,9 @@ export async function buildUserInputMessage(
// First, resolve slash commands if the core config service is available.
let prompt = rawPrompt;
if (userInstructionService) {
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt);
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt, {
expandSkillCommands: shouldExpandSkillSlashCommands(options?.mode),
});
}
if (!hasFileMentions(prompt)) {
+3 -1
View File
@@ -277,7 +277,9 @@ export async function runAgent(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(prompt, userInstructionService);
} = await buildUserInputMessage(prompt, userInstructionService, {
mode: config.mode,
});
const started = await sessionManager.start({
source: SessionSource.CLI,
config: {
@@ -81,6 +81,7 @@ describe("applyInteractiveModelChange", () => {
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
providers: {},
modes: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
+3 -1
View File
@@ -621,7 +621,9 @@ export async function runInteractive(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(input, userInstructionService);
} = await buildUserInputMessage(input, userInstructionService, {
mode,
});
const mergedUserImages = [
...(attachments?.userImages ?? []),
...userImages,
+5 -1
View File
@@ -78,7 +78,11 @@ export async function runZen(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(prompt, userInstructionService);
} = await buildUserInputMessage(prompt, userInstructionService, {
// Zen runs in yolo mode, whose preset has no skills tool — skill
// commands must keep expanding textually.
mode: "yolo",
});
const startRequest: ChatStartSessionRequest = {
workspaceRoot,
+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);
+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";
@@ -169,6 +169,39 @@ describe("slash command registry", () => {
expect(expandUserCommandPrompt("/settings", registry)).toBe("/settings");
});
it("keeps skill commands typed when skill expansion is off, but still wraps workflows", () => {
const registry = buildSlashCommandRegistry({
workflowSlashCommands: [
{
name: "review",
instructions: "Review carefully",
description: "Review files",
kind: "skill",
},
{
name: "release",
instructions: "Run the release workflow",
description: "Release",
kind: "workflow",
},
],
});
const options = { expandSkillCommands: false };
// The skills tool delivers the instructions; the transcript keeps the
// typed command.
expect(
expandUserCommandPrompt("/review this file", registry, options),
).toBe("/review this file");
expect(
expandUserCommandPrompt("please /review this file", registry, options),
).toBe("please /review this file");
// Workflows are not served by the skills tool and keep expanding.
expect(expandUserCommandPrompt("/release now", registry, options)).toBe(
'<user_command slash="release">Run the release workflow</user_command> now',
);
});
it("does not expand commands omitted from a refreshed user-command registry", () => {
const staleRegistry = buildSlashCommandRegistry({
workflowSlashCommands: [
@@ -245,19 +245,33 @@ export function formatSlashCommandAutocompleteValue(
return `/${entry.name} `;
}
export interface ExpandUserCommandPromptOptions {
/**
* Whether matched skill commands are wrapped into the prompt. Pass false
* when the session registers the runtime's skills tool: the typed
* `/skill args` then goes through as-is and the model loads the
* instructions via the tool. Workflows always expand (the tool does not
* serve them). Defaults to true.
*/
expandSkillCommands?: boolean;
}
export function expandUserCommandPrompt(
input: string,
registry: SlashCommandRegistry,
options?: ExpandUserCommandPromptOptions,
): string {
if (input.includes("<user_command")) {
return input;
}
const skipCommand = (command: SlashCommandRegistryEntry): boolean =>
command.kind === "skill" && options?.expandSkillCommands === false;
const expandedSlashCommands = input.replace(
USER_COMMAND_SLASH_PATTERN,
(match, prefix: string, name: string) => {
const command = resolveSlashCommand(registry, name);
if (command?.execution !== "user-command") {
if (command?.execution !== "user-command" || skipCommand(command)) {
return match;
}
return `${prefix}${formatUserCommandBlock(command.instructions, command.name)}`;
@@ -272,7 +286,11 @@ export function expandUserCommandPrompt(
return input;
}
const command = resolveSlashCommand(registry, match[1] ?? "");
if (!command || command.execution !== "user-command") {
if (
!command ||
command.execution !== "user-command" ||
skipCommand(command)
) {
return input;
}
const rest = (match[2] ?? "").trim();
@@ -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,44 @@
import { useRenderer } from "@opentui/react";
import { DialogContainerRenderable } from "@opentui-ui/dialog";
import { useDialogState } from "@opentui-ui/dialog/react";
import { useEffect } from "react";
import { useTheme } from "../hooks/use-theme";
import { getDialogSurface } from "../themes";
/**
* Keeps dialog panel backgrounds in sync with the active theme.
*
* The dialog library computes a panel's style once when the dialog opens
* (from the container's dialogOptions), so theme changes made while a dialog
* is open most visibly the live preview while scrolling the theme picker
* would leave the panel on the old surface color. This component pushes the
* theme's dialog surface into the container (for dialogs opened later) and
* onto every open dialog renderable (repainting them in place).
*
* Must be mounted inside the DialogProvider. Re-runs when a dialog opens so
* the first dialog after mount is covered too (the container is only added
* to the renderer root after this component's initial effect).
*/
export function DialogThemeSync() {
const renderer = useRenderer();
const theme = useTheme();
const dialogCount = useDialogState((state: { count: number }) => state.count);
const surface = getDialogSurface(theme);
// biome-ignore lint/correctness/useExhaustiveDependencies: dialogCount re-runs the sync when a dialog opens, covering dialogs opened before the container-level option applied (see docblock).
useEffect(() => {
const container = renderer.root
.getChildren()
.find(
(child): child is DialogContainerRenderable =>
child instanceof DialogContainerRenderable,
);
if (!container) return;
container.dialogOptions = { style: { backgroundColor: surface } };
for (const [, dialogRenderable] of container.getDialogRenderables()) {
dialogRenderable.backgroundColor = surface;
}
}, [renderer, surface, dialogCount]);
return null;
}
@@ -8,7 +8,7 @@ import {
formatClineCredits,
isClineAccountAuthErrorMessage,
} from "../../cline-account";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export type AccountDialogAction =
| "change-model"
@@ -121,6 +121,7 @@ function AccountActionRow(props: {
selected: boolean;
onSelect: () => void;
}) {
const palette = useDialogPalette();
const fg = props.selected ? palette.textOnSelection : undefined;
return (
<box
@@ -138,7 +139,7 @@ function AccountActionRow(props: {
fg={props.selected ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{props.selected ? ">" : " "}
{props.selected ? "" : " "}
</text>
<text fg={fg} flexShrink={0}>
{props.action.label}
@@ -161,6 +162,7 @@ function OrganizationRow(props: {
selected: boolean;
onSelect: () => void;
}) {
const palette = useDialogPalette();
return (
<box
flexDirection="row"
@@ -177,7 +179,7 @@ function OrganizationRow(props: {
fg={props.selected ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{props.selected ? ">" : " "}
{props.selected ? "" : " "}
</text>
<text
fg={props.selected ? palette.textOnSelection : undefined}
@@ -234,6 +236,7 @@ export function AccountDialogContent(
switchAccount,
onAccountChange,
} = props;
const palette = useDialogPalette();
const [state, setState] = useState<AccountState>({
status: "loading",
message: "Loading account details...",
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useRef, useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export function AskQuestionContent(
props: ChoiceContext<string | null> & {
@@ -11,6 +11,7 @@ export function AskQuestionContent(
},
) {
const { resolve, dialogId, question, options } = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const [inputKey, setInputKey] = useState(0);
const customRef = useRef("");
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useRef, useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export type CheckpointRestoreMode = "chat-only" | "chat-and-workspace";
@@ -29,6 +29,7 @@ export function CheckpointConfirmContent(
},
) {
const { resolve, dismiss, dialogId, messagePreview } = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const selectedRef = useRef(0);
const selectedMode = OPTIONS[selected]?.value;
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export interface CheckpointPickerItem {
runCount: number;
@@ -36,6 +36,7 @@ export function CheckpointPickerContent(
},
) {
const { resolve, dismiss, dialogId, items } = props;
const palette = useDialogPalette();
const lastIndex = Math.max(0, items.length - 1);
const [selected, setSelected] = useState(lastIndex);
const selectedRef = useRef(lastIndex);
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import {
buildCommandPaletteItems,
type CommandPaletteResult,
@@ -32,6 +32,7 @@ export function CommandPaletteContent(
},
) {
const { resolve, dismiss, dialogId, canForkSession, contentWidth } = props;
const palette = useDialogPalette();
const { height } = useTerminalDimensions();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
@@ -1,12 +1,12 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../../interactive-config";
import { palette } from "../../palette";
import {
getExtDetailFooterText,
getExtDetailRows,
@@ -23,6 +23,7 @@ export function ExtDetailContent(
) => Promise<InteractiveConfigData | undefined>;
},
) {
const palette = useDialogPalette();
const [item, setItem] = useState(props.item);
const [toggleError, setToggleError] = useState<string | undefined>();
@@ -1,7 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
type HelpRow =
| { kind: "heading"; id: string; text: string }
@@ -255,6 +255,7 @@ const KEY_WIDTH = 20;
export function HelpDialogContent(props: ChoiceContext<void>) {
const { dismiss, dialogId } = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
if (
@@ -1,7 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
@@ -12,6 +12,7 @@ export function HubUpdateRequiredContent(
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
) {
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
const action = resolveHubUpdateRequiredKeyAction(key);
@@ -5,7 +5,7 @@ import {
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export interface McpEntry {
name: string;
@@ -70,6 +70,7 @@ export function McpManagerContent(
servers: McpEntry[];
},
) {
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const [servers, setServers] = useState(props.servers);
const [changed, setChanged] = useState(false);
@@ -22,7 +22,7 @@ import {
} from "../../../utils/codex-cli";
import open from "../../../utils/open";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import {
getDefaultAwsRegion,
type ProviderConfigValues,
@@ -63,6 +63,7 @@ export function ProviderPickerContent(
props: ChoiceContext<string> & { currentProviderId: string },
) {
const { resolve, dismiss, dialogId, currentProviderId } = props;
const palette = useDialogPalette();
const [providers, setProviders] = useState<ProviderItem[]>([]);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState(0);
@@ -272,6 +273,7 @@ export function UseExistingOrReconfigureContent(
},
) {
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
const palette = useDialogPalette();
const options: ExistingProviderOption[] = useMemo(
() => [
{ value: "use_existing", label: "Use existing configuration" },
@@ -351,6 +353,7 @@ function ClinePassBrowserPageContent(
url,
openedStatus,
} = props;
const palette = useDialogPalette();
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
@@ -489,6 +492,7 @@ export function ProviderConfigInputContent(
providerName,
providerSettingsManager,
} = props;
const palette = useDialogPalette();
const config = useMemo(
() => getProviderConfigFields(providerId),
@@ -656,6 +660,7 @@ export function CodexCliStatusContent(
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const palette = useDialogPalette();
const [status, setStatus] = useState<CodexCliStatus | undefined>();
const [checking, setChecking] = useState(false);
@@ -749,6 +754,7 @@ export function OAuthLoginContent(
providerName,
allowApiKeyFallback,
} = props;
const palette = useDialogPalette();
const [mode, setMode] = useState<"browser" | "device">(
providerId === "cline" ? "device" : "browser",
);
@@ -969,6 +975,7 @@ export function OAuthApiKeyInputContent(
providerName,
providerSettingsManager,
} = props;
const palette = useDialogPalette();
const [value, setValue] = useState("");
const submit = () => {
@@ -3,7 +3,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useRef, useState } from "react";
import type { SlashCommandRegistryEntry } from "../../commands/slash-command-registry";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export const SKILLS_MARKETPLACE_ACTION = "__skills_marketplace__";
export const SKILLS_MARKETPLACE_URL = "https://skills.sh/";
@@ -26,6 +26,7 @@ function matchesFilter(
export function SkillsPickerContent(props: SkillsPickerContentProps) {
const { resolve, dismiss, dialogId, commands } = props;
const palette = useDialogPalette();
const { height, width } = useTerminalDimensions();
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState(0);
@@ -2,8 +2,7 @@ import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useEffect, useRef, useState } from "react";
import { useThemeController } from "../../hooks/use-theme";
import { palette } from "../../palette";
import { useDialogPalette, useThemeController } from "../../hooks/use-theme";
import { getThemeSwatchColors, THEMES } from "../../themes";
const SWATCH_BLOCK = "\u25a0";
@@ -12,6 +11,7 @@ export function ThemePickerContent(props: ChoiceContext<string>) {
const { resolve, dismiss, dialogId } = props;
const { height } = useTerminalDimensions();
const controller = useThemeController();
const palette = useDialogPalette();
const [selected, setSelected] = useState(() => {
const index = THEMES.findIndex(
(theme) => theme.id === controller.selectedThemeId,
@@ -2,7 +2,7 @@ import type { ToolApprovalRequest } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import type React from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import {
buildReadFilesKeys,
parseApplyPatchInput,
@@ -139,6 +139,7 @@ export function formatApprovalParams(
export function ToolApprovalContent(
props: ChoiceContext<boolean> & { request: ToolApprovalRequest },
) {
const palette = useDialogPalette();
useDialogKeyboard((key) => {
if (key.name === "y" || key.name === "return") {
props.resolve(true);
@@ -7,7 +7,8 @@ import {
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import type { DialogPalette } from "../../themes";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
@@ -24,7 +25,7 @@ export {
freeTierDescriptionFor,
} from "./cline-model-entries";
function tagColor(tag: string): string {
function tagColor(tag: string, palette: DialogPalette): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return palette.act;
@@ -58,6 +59,7 @@ export function ClineModelPicker(props: {
currentModelId?: string;
}) {
const { entries, selected, loading, currentModelId } = props;
const palette = useDialogPalette();
if (loading) {
return (
@@ -119,7 +121,7 @@ export function ClineModelPicker(props: {
{tags.map((t) => (
<text
key={t}
fg={isSel ? palette.textOnSelection : tagColor(t)}
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
flexShrink={0}
>
{t}
@@ -2,7 +2,8 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import type { DialogPalette } from "../../themes";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
@@ -18,7 +19,7 @@ type ClineModelEntriesState =
| { status: "loaded"; entries: ClineModelPickerEntry[] }
| { status: "error"; message: string };
function tagColor(tag: string): string {
function tagColor(tag: string, palette: DialogPalette): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return palette.act;
@@ -39,6 +40,7 @@ export function ClineModelSelectorContent(
currentProviderName,
entries,
} = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const [onProvider, setOnProvider] = useState(false);
@@ -188,7 +190,7 @@ export function ClineModelSelectorContent(
{row.tags.map((t) => (
<text
key={t}
fg={isSel ? palette.textOnSelection : tagColor(t)}
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
flexShrink={0}
>
{t}
@@ -222,6 +224,7 @@ export function ClineModelSelectorDialogContent(
},
) {
const { dismiss, dialogId, loadEntries } = props;
const palette = useDialogPalette();
const [state, setState] = useState<ClineModelEntriesState>({
status: "loading",
message: "Loading Cline models...",
@@ -3,7 +3,7 @@ import type { Llms } from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useState } from "react";
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
import { ProviderRow } from "./provider-row";
export interface ModelOption {
@@ -65,6 +65,7 @@ export function ModelIdInputContent(
) {
const { resolve, dismiss, dialogId, currentModel, currentProviderName } =
props;
const palette = useDialogPalette();
const [modelId, setModelId] = useState(currentModel);
const [error, setError] = useState("");
const [onProvider, setOnProvider] = useState(false);
@@ -328,6 +329,7 @@ export function ThinkingLevelContent(
},
) {
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(() => {
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
@@ -516,6 +518,7 @@ function CreateCustomModelRow(props: {
onSelect: () => void;
}) {
const { isSelected, dimmed, onSelect } = props;
const palette = useDialogPalette();
const active = isSelected && !dimmed;
const bg = active
? palette.selection
@@ -553,6 +556,7 @@ function ModelRow(props: {
onSelect: (key: string) => void;
}) {
const { model, isSelected, dimmed, isCurrent, onSelect } = props;
const palette = useDialogPalette();
const active = isSelected && !dimmed;
const bg = active
? palette.selection
@@ -1,5 +1,5 @@
// @jsxImportSource @opentui/react
import { palette } from "../../palette";
import { useDialogPalette } from "../../hooks/use-theme";
export function ProviderRow({
providerName,
@@ -8,6 +8,7 @@ export function ProviderRow({
providerName: string;
focused: boolean;
}) {
const palette = useDialogPalette();
return (
<box flexDirection="row" paddingX={1} gap={1}>
<text fg={focused ? palette.selection : "gray"} flexShrink={0}>
@@ -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;
}
@@ -1,4 +1,5 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { shouldExpandSkillSlashCommands } from "../../runtime/prompt";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
@@ -308,6 +309,12 @@ export function usePromptInputController(input: {
const promptForSubmit = expandUserCommandPrompt(
expandedPrompt,
slashCommandRegistry,
// Skills load through the runtime's skills tool when it is
// available for the current mode; the typed command then goes
// through as-is so the transcript keeps what the user typed.
{
expandSkillCommands: shouldExpandSkillSlashCommands(session.uiMode),
},
);
session.setHasSubmitted(true);
+19 -2
View File
@@ -1,6 +1,12 @@
import { createContext, useContext } from "react";
import { createContext, useContext, useMemo } from "react";
import type { TerminalTheme } from "../palette";
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
import {
AUTO_THEME_ID,
type DialogPalette,
getDialogPalette,
type ResolvedTheme,
resolveTheme,
} from "../themes";
export interface TerminalColors {
background: string | null;
@@ -41,6 +47,17 @@ export function useTheme(): ResolvedTheme {
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
}
/**
* Theme-following colors for dialog content. Unlike the static `palette`
* constant, this re-resolves whenever the active theme changes, so open
* dialogs repaint live during theme previews (e.g. scrolling the theme
* picker).
*/
export function useDialogPalette(): DialogPalette {
const theme = useTheme();
return useMemo(() => getDialogPalette(theme), [theme]);
}
/**
* Background that adaptive colors (input field, user bubbles, rules) derive
* from: the theme's painted background when set, else the detected one.
+9
View File
@@ -169,6 +169,15 @@ export function getUserMessageBackground(terminalBg: string | null): string {
return liftedFromTerminalBg(terminalBg, BASE_LIFT, 0, 0);
}
// Dialog panels lift the theme background the same way, but a touch softer:
// they cover a large area and sit over a dimmed backdrop, so a smaller step
// already reads as "raised" without washing out the theme's hue.
const DIALOG_SURFACE_LIFT = 0.16;
export function getDialogSurfaceBackground(terminalBg: string | null): string {
return liftedFromTerminalBg(terminalBg, DIALOG_SURFACE_LIFT, 0, 0);
}
export function getModeInputForeground(
mode: string,
terminalBg: string | null,
+14
View File
@@ -23,6 +23,7 @@ import {
} from "../utils/repo-status";
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
import type { TranscriptScrollHandle } from "./components/chat-message-list";
import { DialogThemeSync } from "./components/dialog-theme-sync";
import {
CheckpointConfirmContent,
type CheckpointRestoreMode,
@@ -584,6 +585,18 @@ function App(props: TuiProps) {
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>) => (
@@ -1026,6 +1039,7 @@ export function Root(
<TerminalColorsContext value={terminalColors}>
<ThemeProvider initialThemeId={props.initialThemeId}>
<DialogProvider size="medium">
<DialogThemeSync />
<SessionProvider
config={props.config}
initialEntries={initialEntries}
+65
View File
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { diffPalettes, themePalette } from "./palette";
import {
AUTO_THEME_ID,
DEFAULT_DIALOG_SURFACE,
getDialogAccents,
getDialogPalette,
getDialogSurface,
getThemeDefinition,
getThemeModeAccent,
getThemeSwatchColors,
@@ -164,4 +167,66 @@ describe("theme helpers", () => {
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
expect(getThemeDefinition("missing")).toBeUndefined();
});
it("getDialogPalette follows the theme's dialog accents", () => {
const dracula = getDialogPalette(resolveTheme("dracula", noDetection));
expect(dracula.act).toBe("#bd93f9");
expect(dracula.selection).toBe("#bd93f9");
expect(dracula.success).toBe("#50fa7b");
expect(dracula.error).toBe("#ff5555");
expect(dracula.textOnSelection).toBe("#000000");
// Light themes fall back to dark accents (dialog surfaces stay dark).
const solarizedLight = getDialogPalette(
resolveTheme("solarized-light", noDetection),
);
expect(solarizedLight.act).toBe(themePalette.dark.act);
for (const definition of THEMES) {
const dialogPalette = getDialogPalette(
resolveTheme(definition.id, noDetection),
);
expect(dialogPalette.selection).toBe(dialogPalette.act);
expect(["#000000", "#ffffff"]).toContain(dialogPalette.textOnSelection);
}
});
it("getDialogSurface lifts dark theme backgrounds and keeps hue", () => {
// Dark themes derive the panel from their own background: a different,
// lighter color than both the background and the neutral default.
for (const id of ["tokyo-night", "dracula", "gruvbox-dark", "nord"]) {
const theme = resolveTheme(id, noDetection);
const surface = getDialogSurface(theme);
expect(surface).toMatch(/^#[0-9a-f]{6}$/i);
expect(surface).not.toBe(theme.background);
expect(surface).not.toBe(DEFAULT_DIALOG_SURFACE);
}
// Auto with no detected background has nothing to derive from.
expect(getDialogSurface(resolveTheme(AUTO_THEME_ID, noDetection))).toBe(
DEFAULT_DIALOG_SURFACE,
);
// Auto on a detected dark terminal lifts the detected background.
expect(
getDialogSurface(
resolveTheme(AUTO_THEME_ID, {
background: "#000000",
foreground: null,
}),
),
).not.toBe(DEFAULT_DIALOG_SURFACE);
// Light themes keep the neutral dark panel (dialog content still uses
// the dark accent fallback, and hardcoded light text must stay legible).
expect(getDialogSurface(resolveTheme("light", noDetection))).toBe(
DEFAULT_DIALOG_SURFACE,
);
expect(getDialogSurface(resolveTheme("solarized-light", noDetection))).toBe(
DEFAULT_DIALOG_SURFACE,
);
// The palette exposes the same surface.
const dracula = resolveTheme("dracula", noDetection);
expect(getDialogPalette(dracula).surface).toBe(getDialogSurface(dracula));
});
});
+48
View File
@@ -1,6 +1,7 @@
import {
diffPalettes,
getDefaultForeground,
getDialogSurfaceBackground,
getTerminalTheme,
hexToOklab,
oklabToHex,
@@ -567,6 +568,53 @@ export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
}
/** Neutral dark panel used when no theme background is available to derive
* a surface from (auto theme on an undetected terminal, light themes). */
export const DEFAULT_DIALOG_SURFACE = "#262626";
/**
* Background for dialog panels. Dark themes get their own background lifted
* one perceptual step (OKLAB), so the panel keeps the theme's hue and reads
* as a raised surface of the same world. Light themes and undetectable
* backgrounds keep the neutral dark panel that matches the dark accent
* fallback in getDialogAccents.
*/
export function getDialogSurface(theme: ResolvedTheme): string {
if (theme.variant !== "dark" || !theme.background) {
return DEFAULT_DIALOG_SURFACE;
}
return getDialogSurfaceBackground(theme.background);
}
/**
* Colors for content rendered on the always-dark dialog surface, following
* the active theme's accents (see getDialogAccents). Mirrors the shape of
* the static `palette` so dialog components can consume it as a drop-in
* replacement that re-resolves on every theme change (including previews).
*/
export interface DialogPalette extends ThemeAccents {
selection: string;
textOnSelection: string;
muted: string;
/** Panel background the dialog content is rendered on. */
surface: string;
}
export function getDialogPalette(theme: ResolvedTheme): DialogPalette {
const accents = getDialogAccents(theme);
const selection = accents.act;
return {
...accents,
selection,
textOnSelection:
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
? "#000000"
: "#ffffff",
muted: "gray",
surface: getDialogSurface(theme),
};
}
/** Small color strip rendered next to each entry in the theme picker. */
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
const variant = definition.variant === "auto" ? "dark" : definition.variant;
+18 -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[];
@@ -218,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>)
+120 -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: {},
@@ -155,6 +158,7 @@ describe("hydrateSessionMessages", () => {
expect(hydrateSessionMessages(messages)).toEqual([
{
kind: "tool_call",
toolCallId: "tool-1",
toolName: "run_commands",
inputSummary: "",
rawInput: { command: null },
@@ -180,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,
};
}
}
}
+13 -13
View File
@@ -16,9 +16,8 @@ import {
import type { CliCompactionMode, Config } from "../../utils/types";
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
import { resolveModelDisplayName } from "../components/status-bar";
import { useThemeController } from "../hooks/use-theme";
import { palette } from "../palette";
import { getDialogAccents, getThemeDefinition } from "../themes";
import { useDialogPalette, useThemeController } from "../hooks/use-theme";
import { type DialogPalette, getThemeDefinition } from "../themes";
import {
type ConfigAction,
canDeleteConfigFooterRow,
@@ -118,11 +117,13 @@ function getVisibleWindow<T>(
return { items: items.slice(start, end), startIndex: start };
}
const COMPACTION_MODE_COLORS: Record<CliCompactionMode, string> = {
agentic: palette.success,
basic: "yellow",
off: "gray",
};
function getCompactionModeColor(
mode: CliCompactionMode,
palette: DialogPalette,
): string {
if (mode === "agentic") return palette.success;
return mode === "basic" ? "yellow" : "gray";
}
export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
config: Config;
@@ -409,7 +410,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [toggleError, setToggleError] = useState<string | undefined>();
const [navPos, setNavPos] = useState(0);
const themeController = useThemeController();
const dialogAccents = getDialogAccents(themeController.theme);
const palette = useDialogPalette();
const currentThemeLabel =
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
@@ -823,11 +824,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
let valueColor: string;
if (row.id === "mode") {
value = mode === "plan" ? "Plan" : "Act";
valueColor =
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
valueColor = mode === "plan" ? palette.plan : palette.act;
} else if (row.id === "theme") {
value = currentThemeLabel;
valueColor = dialogAccents.act;
valueColor = palette.act;
} else if (row.id === "auto-approve") {
value = autoApprove ? "● on" : "○ off";
valueColor = autoApprove ? palette.success : "gray";
@@ -836,7 +836,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
valueColor = autoUpdateEnabled ? palette.success : "gray";
} else if (row.id === "compaction") {
value = formatCliCompactionMode(compactionMode);
valueColor = COMPACTION_MODE_COLORS[compactionMode];
valueColor = getCompactionModeColor(compactionMode, palette);
} else {
value = verbose ? "● on" : "○ off";
valueColor = verbose ? palette.success : "gray";
+2 -1
View File
@@ -15,7 +15,7 @@ import { listSessions } from "../../session/session";
import { mergeHistoryStatusRows } from "../../utils/history-format";
import { formatUsd } from "../../utils/output";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import { palette } from "../palette";
import { useDialogPalette } from "../hooks/use-theme";
import {
buildHistoryFooterText,
HISTORY_EXPORT_OPTIONS,
@@ -98,6 +98,7 @@ function HistoryListContent({
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
registerKeyHandler,
}: HistoryListContentProps) {
const palette = useDialogPalette();
const { width } = useTerminalDimensions();
const [rows, setRows] = useState<SessionHistoryRecord[]>(
() => initialRows ?? [],
@@ -11,6 +11,7 @@ import {
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isChatProviderModel } from "../../../utils/chat-models";
import {
getCliSubscriptionUrl,
getIndividualPlanFeatures,
@@ -233,7 +234,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const ids = new Set<string>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const m of result.value.models) {
for (const m of result.value.models.filter(isChatProviderModel)) {
if (m.supportsReasoning) ids.add(m.id);
}
}
@@ -283,7 +284,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
providerId,
providerConfig,
);
return models.map(toModelEntry);
return models.filter(isChatProviderModel).map(toModelEntry);
})
.then((models) => {
setModelEntries(models);
@@ -128,6 +128,31 @@ describe("onboarding model helpers", () => {
]);
});
it("keeps non-chat models out of the onboarding model picker", () => {
expect(
toModelEntriesFromKnownModels({
"operation-only-whisper": {
name: "Operation-only Whisper",
operation: "transcription",
},
"whisper-large-v3": {
name: "Whisper Large V3",
modalities: { input: ["audio"], output: ["text"] },
},
"llama-chat": {
name: "Llama Chat",
modalities: { input: ["text"], output: ["text"] },
},
}),
).toEqual([
{
id: "llama-chat",
name: "Llama Chat",
supportsReasoning: false,
},
]);
});
it("formats OAuth provider labels for onboarding status views", () => {
expect(getOAuthProviderLabel("cline")).toBe("Cline");
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
@@ -1,3 +1,9 @@
import type {
ChatModelModalities,
ModelModality,
ModelOperation,
} from "@cline/shared";
import { isChatProviderModel } from "../../../utils/chat-models";
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
import { isOAuthProvider } from "../../../utils/provider-auth";
@@ -151,11 +157,16 @@ export interface ProviderModelItem {
id: string;
name?: string;
supportsReasoning?: boolean;
operation?: ModelOperation;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export interface KnownModelInfo {
name?: string;
capabilities?: string[];
operation?: ModelOperation;
modalities?: ChatModelModalities;
}
export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
@@ -185,6 +196,13 @@ export function toModelEntriesFromKnownModels(
): ModelEntry[] {
if (!knownModels) return [];
return Object.entries(knownModels)
.filter(([, info]) =>
isChatProviderModel({
operation: info.operation,
inputModalities: info.modalities?.input,
outputModalities: info.modalities?.output,
}),
)
.map(([id, info]) => ({
id,
name: info.name || id,
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { filterChatModels, isChatProviderModel } from "./chat-models";
describe("chat model filtering", () => {
it("keeps unknown and text-capable catalog models", () => {
const models = {
legacy: { name: "Legacy" },
chat: {
name: "Chat",
modalities: { input: ["text"] as const, output: ["text"] as const },
},
mixed: {
name: "Mixed",
modalities: {
input: ["text", "image"] as const,
output: ["text", "image"] as const,
},
},
};
expect(Object.keys(filterChatModels(models))).toEqual([
"legacy",
"chat",
"mixed",
]);
});
it("removes dedicated transcription and media-generation models", () => {
const models = {
operationOnly: { operation: "speech-generation" as const },
whisper: {
modalities: { input: ["audio"] as const, output: ["text"] as const },
},
tts: {
modalities: { input: ["text"] as const, output: ["audio"] as const },
},
};
expect(filterChatModels(models)).toEqual({});
});
it("filters flattened provider model responses with the same rule", () => {
expect(isChatProviderModel({ operation: "transcription" })).toBe(false);
expect(
isChatProviderModel({
inputModalities: ["audio"],
outputModalities: ["text"],
}),
).toBe(false);
expect(
isChatProviderModel({
inputModalities: ["text", "image"],
outputModalities: ["text"],
}),
).toBe(true);
expect(isChatProviderModel({})).toBe(true);
});
});
+34
View File
@@ -0,0 +1,34 @@
import {
type ChatModelModalities,
isChatCompatibleModel,
type ModelModality,
type ModelOperation,
} from "@cline/shared";
export type ChatCatalogModel = {
readonly operation?: ModelOperation;
readonly modalities?: ChatModelModalities;
readonly [key: string]: unknown;
};
export function filterChatModels<T extends ChatCatalogModel>(
models: Readonly<Record<string, T>>,
): Record<string, T> {
return Object.fromEntries(
Object.entries(models).filter(([, model]) => isChatCompatibleModel(model)),
);
}
export function isChatProviderModel(model: {
readonly operation?: ModelOperation;
readonly inputModalities?: readonly ModelModality[];
readonly outputModalities?: readonly ModelModality[];
}): boolean {
return isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
});
}
+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;
}
}
+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;
+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";
@@ -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;
}
@@ -253,7 +253,7 @@ export async function handleDesktopCommand(
return path;
}
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
return await handleRoutineScheduleCommand(command, args);
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
}
if (command === "get_process_context") {
return { workspaceRoot, cwd: workspaceRoot };
+20 -6
View File
@@ -9,6 +9,7 @@ import {
normalizeOAuthProvider,
saveLocalProviderSettings,
} from "@cline/core";
import { isChatCompatibleModel } from "@cline/shared";
import type {
WebviewInboundMessage,
WebviewProviderModel,
@@ -86,12 +87,25 @@ 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((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
)
.map((model) => ({
id: model.id,
name: model.name,
operation: model.operation,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
inputModalities: model.inputModalities,
outputModalities: model.outputModalities,
}));
ctx.send(peer, { type: "models", providerId: provider, models });
}
+27 -17
View File
@@ -27,13 +27,20 @@ function getCommands(): HubScheduleCommandService {
async function clientCommand(
hubCommand: string,
payload?: Record<string, unknown>,
workspaceRoot = process.cwd(),
): Promise<Record<string, unknown>> {
const reply = await getCommands().handleCommand({
version: "v1",
clientId: "cline-hub-schedules",
command: hubCommand as never,
payload,
});
const reply = await getCommands().handleCommand(
{
version: "v1",
clientId: "cline-hub-schedules",
command: hubCommand as never,
payload,
},
{
clientId: "cline-hub-schedules",
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
},
);
if (!reply.ok) {
throw new Error(
reply.error?.message ?? `hub command failed: ${hubCommand}`,
@@ -70,14 +77,17 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
workspaceRoot = process.cwd(),
): Promise<unknown> {
const commandHub = (hubCommand: string, payload?: Record<string, unknown>) =>
clientCommand(hubCommand, payload, workspaceRoot);
if (command === "list_routine_schedules") {
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
clientCommand("schedule.list", {
commandHub("schedule.list", {
limit: toPositiveInt(args?.limit) ?? 200,
}),
clientCommand("schedule.active"),
clientCommand("schedule.upcoming", { limit: 30 }),
commandHub("schedule.active"),
commandHub("schedule.upcoming", { limit: 30 }),
]);
const scheduleRows = Array.isArray(schedules.schedules)
? schedules.schedules
@@ -88,7 +98,7 @@ export async function handleRoutineScheduleCommand(
(schedule as Record<string, unknown>).scheduleId,
);
if (!scheduleId) return undefined;
const reply = await clientCommand("schedule.list_executions", {
const reply = await commandHub("schedule.list_executions", {
scheduleId,
limit: 1,
});
@@ -115,7 +125,7 @@ export async function handleRoutineScheduleCommand(
"createSchedule requires name, timing, prompt, and workspace_root",
);
}
const created = await clientCommand("schedule.create", {
const created = await commandHub("schedule.create", {
name,
...timing,
prompt,
@@ -149,7 +159,7 @@ export async function handleRoutineScheduleCommand(
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
);
}
const reply = await clientCommand("schedule.update", {
const reply = await commandHub("schedule.update", {
scheduleId,
name,
...timing,
@@ -180,25 +190,25 @@ export async function handleRoutineScheduleCommand(
return { schedule: reply.schedule ?? null };
}
if (command === "pause_routine_schedule") {
const reply = await clientCommand("schedule.disable", { scheduleId });
const reply = await commandHub("schedule.disable", { scheduleId });
return { schedule: reply.schedule ?? null };
}
if (command === "resume_routine_schedule") {
const reply = await clientCommand("schedule.enable", { scheduleId });
const reply = await commandHub("schedule.enable", { scheduleId });
return { schedule: reply.schedule ?? null };
}
if (command === "trigger_routine_schedule") {
const existing = await clientCommand("schedule.get", { scheduleId });
const existing = await commandHub("schedule.get", { scheduleId });
if (!existing.schedule)
throw new Error(`schedule not found: ${scheduleId}`);
const reply = await clientCommand("schedule.trigger", {
const reply = await commandHub("schedule.trigger", {
scheduleId,
wait: false,
});
return { execution: reply.execution ?? null };
}
if (command === "delete_routine_schedule") {
const reply = await clientCommand("schedule.delete", { scheduleId });
const reply = await commandHub("schedule.delete", { scheduleId });
return { deleted: reply.deleted === true };
}
throw new Error(`unsupported routine schedule command: ${command}`);
@@ -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");
});
});

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