Compare commits

..

95 Commits

Author SHA1 Message Date
Dominic Cooney a2fca629b8 fix(cli): declare computer user helper reasoning controls
The computer-user helper built its Anthropic provider config from the
bundled model catalog, which ships no reasoningOptions. The routing
treated claude-sonnet-5 as an unlisted manual-thinking model and sent
thinking.type.enabled, which current Claude models reject: the API
requires thinking.type.adaptive with an effort level.

Declare the helper model's effort controls in the provider config so
the helper always uses adaptive thinking, matching the driver path,
which resolves reasoning controls from the live models.dev catalog.
2026-09-02 23:19:37 -07:00
Dominic Cooney bfc8e62ea7 Fix sonnet thinking options for computer user. 2026-09-02 23:19:37 -07:00
Mikołaj Kondratek 815de4442d fix(cli): warn when a prompt argument disables computer use
Computer-use is wired only into the interactive runtime, so passing a
prompt argument routes to the agent path and silently produces a session
with no `computer` tool. The model then reports having no such tool,
which reads like a backend or configuration fault rather than a
consequence of how cline was invoked.

Warn when CLINE_COMPUTER_USE_PORT is set but the run won't be
interactive. Advisory only -- the run proceeds unchanged.
2026-09-02 23:19:36 -07:00
Cline Agent bf4462e959 fix(core): preserve current computer screenshot 2026-09-02 23:19:36 -07:00
Cline Agent 4eefc593ee Make computer user agent abort report instead of terminating the CLI. 2026-09-02 23:19:36 -07:00
Dominic Cooney 4315517fca Stream driver and user events to the observatory via qbt. 2026-09-02 23:19:35 -07:00
Dominic Cooney df6c57e97a Give status updates a since and timeout. 2026-09-02 23:19:35 -07:00
Dominic Cooney f5f3a19f9e Anthropic thinking fixes. 2026-09-02 23:19:35 -07:00
Dominic Cooney 168510800b Remove display-size overrides; the backend is the sole source of truth.
The CLINE_COMPUTER_USE_DISPLAY_WIDTH/HEIGHT env vars and the
displayWidthPx/HeightPx tool options let configuration disagree with the
real framebuffer, which would corrupt every coordinate the model
computes. Delete the override path: createComputerUseTool() always
queries get_display_info, and construction fails loudly when the backend
is unreachable instead of proceeding with a guessed size.

Tests that avoided a live backend via overrides now use a stub TCP
backend that answers get_display_info, matching the real qbt contract.
The CLI computer-user integration additionally checks Anthropic
credentials before dialing the backend, so a missing key no longer
costs a socket.

Dimensions remain a construction-time snapshot; a mid-session resize
still goes stale. The fix (backend reports dimensions per screenshot,
description stops embedding them) is a wire-protocol change recorded
in the README's 'Not yet done'.

Test plan:
  cd sdk/packages/core
  bunx vitest run src/extensions/computer-use --config vitest.config.ts
  cd apps/cli
  bunx vitest run src/runtime/interactive/computer-user.test.ts
2026-09-02 23:19:34 -07:00
Dominic Cooney a593ca4a6b Add CLINE_COMPUTER_USER_MODEL for independent helper model choice.
The driver and the computer user use separate inference: the driver
keeps whatever provider/model the CLI is configured with, while the
helper is always on the direct anthropic provider (the only wire
target that sends the computer-use beta header). The helper model now
resolves through one function: CLINE_COMPUTER_USER_MODEL, then the
Anthropic provider entry's saved model, then claude-sonnet-4-6.

Test plan:
  cd apps/cli
  bunx vitest run src/runtime/interactive/computer-user.test.ts
2026-09-02 23:19:34 -07:00
Dominic Cooney 76bc102066 Add the asynchronous computer user.
The raw computer tool put every screenshot and input action in the
driver's context. Now GUI work is delegated to a computer user: a
persistent, interactive helper session on the Anthropic provider that
owns the computer tool plus the normal built-ins, works in the
background, and reports to the driver through steer-injected messages.

Core (sdk/packages/core):
- computer-use: fix the wire contract (key combos travel in `text`,
  matching qwanban's serde types; there is no `keys` field), add
  AbortSignal cancellation and an action-lifecycle observer with one
  guaranteed terminal event per action.
- computer-observability: versioned artifact event contract
  (clientSequence total order, eventId idempotence, action/parent
  correlations, blob refs) plus a recorder that bridges the client
  observer; typed text never enters the stream.
- computer-user: ComputerUserCoordinator state machine (serialized
  transitions, stale settlements ignored by run identity), helper
  collaboration tools (post_driver_update, ask_driver,
  finish_computer_task), versioned helper prompt, and the four
  driver-facing computer_user_* tools.
- CoreSessionConfig.completionPolicy: explicit session policy now wins
  over the builder's submit_and_exit inference, so extraTools-based
  terminal tools can be made mandatory.

CLI (apps/cli):
- createInteractiveComputerUser wires the coordinator to a dedicated
  local-backend ClineCore helper using the Anthropic provider's own
  stored credentials; falls back to the raw computer tool when
  Anthropic is not configured. Driver notifications resolve the live
  session id at call time via sendCurrentTurn.
- Mode switches now rebuild extraTools through one shared derivation
  (buildInteractiveExtraTools) with persistentExtraTools, so the
  computer-user tools survive plan/act switches.

Test plan:
  cd sdk/packages/core
  bunx vitest run src/extensions/computer-use \
    src/extensions/computer-user src/extensions/computer-observability \
    --config vitest.config.ts
  bun tsc -p tsconfig.dev.json --noEmit
  cd apps/cli
  bunx vitest run src/runtime/interactive/computer-user.test.ts \
    src/runtime/interactive/mode.test.ts
  bun tsc --noEmit
2026-09-02 23:19:34 -07:00
Cline Agent 9328710dce fix(cli): reject deferred cd commands 2026-09-02 23:19:33 -07:00
Cline Agent fd4636edc9 fix(cli): refresh workspace resources after cd 2026-09-02 23:19:33 -07:00
Cline Agent 71fc9681e2 feat(cli): add interactive cd command 2026-09-02 23:17:21 -07:00
Dominic Cooney 3512ae5f65 Make 'zoom' region two points, not a point and a size. 2026-09-02 23:17:21 -07:00
Dominic Cooney 05d3404bab Computer use. 2026-09-02 23:17:21 -07:00
Saoud Rizwan 5de79a75d0 docs: add .clineignore hook example (#13649)
* docs: add enforced .clineignore guard plugin example

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

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

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

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

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

* fix: canonicalize paths in .clineignore guard hook

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* docs: add primary surface to deprecations

* chore: drop unrelated formatting changes from docs PR

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-02 13:12:38 -07:00
Bee 9e0af5c010 feat(desktop): manage Agent Plugins through the Hub (#13658)
* feat(desktop): manage Agent Plugins through the Hub

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

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

* test(sdk): canonicalize Windows plugin paths

* fix(sdk): await stdio MCP process shutdown

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

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

* docs(sdk): clarify Agent Plugin discovery scope

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* feat(desktop): offer session import during onboarding

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses both Greptile P1s on #13744:

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

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

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

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

listSessionMetadata is unbounded by default so dedup sees every row.

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

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

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

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

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

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

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

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

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

Two gaps Greptile flagged on the import path:

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

* Make desktop web search default seed best-effort

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

---------

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

* Include the rejected tool's name in denial reasons

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

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

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

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

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

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

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

* Trim scope back to the minimal rejection-copy fix

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

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

* Move rejection guidance suffix into agent runtime per review

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* fix(desktop): reconcile aborted tool activity

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

* fix(desktop): preserve authoritative abort status

* fix(desktop): track queue-verified completion

* test(desktop): trim duplicate abort coverage

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

* docs(core): narrow delegated abort guarantees

* fix(core): release delegated sessions after execution

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

* fix(ui): cancel disabled attachment drops

* chore(ui): simplify drop zone surface

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

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

* fix: harden session history search

* fix: evict failed restoration sessions from search

* fix: preserve deletion when search eviction fails

* fix: address session search review feedback

* fix: preserve search suppression during reconciliation

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

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

---------

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

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

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

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

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

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

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

* refactor(core): narrow teammate task status metadata

---------

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

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

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

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

---------

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

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

Harness fixes, each removing one source of that wedge:

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

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

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

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

* React to account updates

* Address comments

* Add a GitHub integration step to the onboarding

* validate domain and fix errors on auth

* Hide the step behind a feature flag

* update version

---------

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

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

Fixes #13597

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

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

* fix: harden session history search

* fix: evict failed restoration sessions from search

* fix: preserve deletion when search eviction fails

* fix: address session search review feedback

* fix: preserve search suppression during reconciliation

---------

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

Fixes #13542

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Fixes #13550

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* desktop: render submit summary in full foreground color

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

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

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

* desktop: label errored submit_and_exit rows as failed

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

---------

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

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

* Make remaining routine templates prescriptive about their final output

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

* fix(core): harden Host Bridge stream lifecycle

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

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

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

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

---------

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

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

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

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

---------

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

* fix(desktop): reserve persistent title bar space

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

* test(ui): cover welcome hero pointer states

* refactor(ui): keep welcome hero API minimal

* test(ui): verify welcome hero package assets

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

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

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

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

Replaces the publish-boundary strip with the real fix: don't build
message-bearing snapshots in the first place. emitSessionSnapshot no longer
re-reads the entire transcript from disk on every status flip, and
readCoreSessionSnapshot no longer reads it for any event or reply — a
snapshot is a state notification (status, usage, model, workspace,
checkpoint); the transcript is fetched via the session.messages command.
Checkpoint-restore snapshots (session-versioning-service) are untouched:
restore replies carry messages in their own dedicated field.
2026-08-26 02:07:28 -07:00
442 changed files with 46302 additions and 7920 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
The CLI is npm-only. Do not add alternate distribution or signing steps.
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
+4 -4
View File
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish the Cline
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
## Release contract
@@ -21,7 +21,7 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
@@ -120,7 +120,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the beta.)
+155
View File
@@ -0,0 +1,155 @@
name: Sign Windows CLI binaries
description: >
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
signatures. If the Azure Trusted Signing secrets are not configured, the
action logs a warning and exits successfully so releases keep working while
signing infrastructure is being provisioned.
inputs:
azure-client-id:
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
required: false
default: ""
azure-tenant-id:
description: Entra tenant ID.
required: false
default: ""
azure-subscription-id:
description: Azure subscription ID containing the Trusted Signing account.
required: false
default: ""
endpoint:
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
required: false
default: ""
account:
description: Trusted Signing account name.
required: false
default: ""
certificate-profile:
description: Trusted Signing certificate profile name.
required: false
default: ""
files:
description: Newline-separated list of PE files to sign.
required: true
runs:
using: composite
steps:
- name: Check signing configuration
id: check
shell: bash
env:
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
run: |
missing=()
set_count=0
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
if [ -z "${!var}" ]; then
missing+=("$var")
else
set_count=$((set_count + 1))
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
echo "enabled=true" >> "$GITHUB_OUTPUT"
elif [ "$set_count" -eq 0 ]; then
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
# Partial configuration is almost certainly a typo'd or renamed
# secret. Fail loudly instead of silently publishing unsigned.
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
exit 1
fi
- name: Azure login (OIDC)
if: steps.check.outputs.enabled == 'true'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
subscription-id: ${{ inputs.azure-subscription-id }}
- name: Sign Windows binaries
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
FILES: ${{ inputs.files }}
JSIGN_VERSION: "7.5"
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
run: |
set -euo pipefail
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
echo "::add-mask::${JSIGN_STOREPASS}"
export JSIGN_STOREPASS
# jsign expects the endpoint host, not the URL. Tolerate both the
# portal's display form (trailing slash) and the bare form.
KEYSTORE="${SIGNING_ENDPOINT#https://}"
KEYSTORE="${KEYSTORE%/}"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Signing ${file}"
java -jar "$JSIGN_JAR" \
--storetype TRUSTEDSIGNING \
--keystore "$KEYSTORE" \
--storepass env:JSIGN_STOREPASS \
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
--alg SHA-256 \
--tsaurl http://timestamp.acs.microsoft.com \
--tsmode RFC3161 \
--replace \
"$file"
done <<< "$FILES"
- name: Verify signatures
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
FILES: ${{ inputs.files }}
# Authenticode chains anchor to the Microsoft Identity Verification
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
# explicitly (pinned) for osslsigncode chain validation.
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
run: |
set -euo pipefail
if ! command -v osslsigncode >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq osslsigncode
fi
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Verifying signature on ${file}"
# Timestamp countersignature chain is checked separately by Windows;
# -ignore-timestamp only skips TSA chain validation here, not the
# Authenticode chain itself.
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
done <<< "$FILES"
+27
View File
@@ -190,6 +190,19 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with latest tag
env:
NPM_CONFIG_PROVENANCE: "true"
@@ -447,6 +460,20 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
if: steps.check_commits.outputs.skip != 'true'
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
+274 -1
View File
@@ -462,9 +462,282 @@ jobs:
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
build-windows:
name: Build Windows (x64)
needs: validate
# Same gate rationale as the macOS build job above. This job additionally
# needs id-token: write for Azure OIDC: Windows binaries are
# Authenticode-signed with Azure Trusted Signing, authenticated through the
# PublishDesktop-environment federated credential on the cline-cli-signing
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: windows-latest
timeout-minutes: 90
permissions:
contents: read
id-token: write
steps:
# All-or-nothing: an unsigned Windows desktop build is never acceptable
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
# unsigned installers), and Tauri would skip updater-artifact signing
# silently if the updater key were missing. Unlike the CLI pipeline
# there is no unsigned fallback here.
- name: Verify signing secrets are present
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing signing secrets for the Windows desktop build:"
printf ' - %s\n' "${missing[@]}"
echo
echo "The AZURE_* names are repository secrets; the TAURI_* names"
echo "live in the PublishDesktop environment. Refusing to build an"
echo "unsigned Windows desktop release."
exit 1
fi
echo "All Windows signing secrets are present."
# Every action in this job is SHA-pinned (unlike elsewhere in this
# file): they run with id-token: write and the updater signing key in
# scope, so a hijacked upstream tag must not be able to reach the
# signing identity or tamper with what gets signed and uploaded.
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
with:
# With a SHA-pinned action the toolchain no longer comes from the
# ref name, so it must be set explicitly.
toolchain: stable
# No Rust build cache, mirroring the macOS job: this job holds the
# updater signing key and an Azure signing session, and a restored cache
# archive is attacker-controlled if the Actions cache is poisoned.
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
# NSIS uninstaller, and the installer itself). The overlay is generated
# here rather than committed because signCommand needs an absolute path
# to the signing script on this runner.
- name: Write signing config overlay
shell: bash
run: |
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
cat > "$SIGN_CONF" <<EOF
{
"\$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
}
}
}
EOF
cat "$SIGN_CONF"
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
- name: Build and sign desktop bundle
shell: bash
working-directory: apps/examples/desktop-app
# NSIS only: the MSI (WiX) target adds nothing for direct-download
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
# Azure Trusted Signing; the token comes from the azure/login session)
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
# Updater artifact signing (minisign keypair, same key as macOS)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Same guardrail as the macOS job: assert the compiled binary embeds
# this channel's updater feed URL and not the other channel's. Checked
# on the unbundled main exe because NSIS compresses the installer
# contents, which defeats a string search on the installer itself.
- name: Verify updater feed endpoint
shell: bash
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
shell: bash
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- name: Collect artifacts
shell: bash
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
PREFIX="${PRODUCT// /-}"
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
if [ -z "$SETUP" ]; then
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
exit 1
fi
# The .sig is the updater (minisign) signature; without it the
# manifest generator cannot publish a windows-x86_64 entry.
if [ ! -f "${SETUP}.sig" ]; then
echo "updater signature missing next to $SETUP"
exit 1
fi
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
ls -lh "$OUT"
# Independent Authenticode gate on the exact artifact users download.
# The signing script already verifies each file it signs, but this step
# would still catch an installer that skipped signCommand entirely.
- name: Verify Authenticode signatures
shell: pwsh
working-directory: apps/examples/desktop-app
run: |
# The Tauri bundler signs the sidecar in place, so check it here too;
# a WDAC-locked machine blocks the app at runtime if the sidecar it
# spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
}
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
needs: [validate, build, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## [4.1.17]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- ClinePass is now surfaced across the app: a card on the account page describing what the plan covers, a hint in provider settings, and a banner on the home screen. Dismissed banners stay dismissed.
### Fixed
- Fixed the background Hub process ballooning in memory during long sessions. Session status updates broadcast a full copy of the conversation transcript to every connected client, so on a large task each status change shipped megabytes and could grow the process to tens of gigabytes. Snapshots now carry state only.
- Hook scripts that fail to spawn no longer crash the extension's core process and take the running task down with them.
- Fixed a chat render crash on malformed `api_req` payloads.
- Cost estimates no longer appear in task history for subscription-billed tasks (ClinePass, ChatGPT via Codex, and Claude Code), matching the task header.
- Pasted provider API keys are now stripped of the invisible characters clipboards smuggle in (newlines, zero-width spaces, BOM). A key corrupted that way was hidden by the masked field and rejected by the provider with a 401 indistinguishable from a genuinely wrong key. Credential rejections now say that the API key is the problem and point at its configuration, keeping the provider's raw response as a diagnostic tail.
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when callback port 1455 is occupied. Previously the button opened a browser to a flow whose callback could never arrive, and nothing else happened. OAuth redirect errors such as `access_denied` are surfaced instead of being reported as a missing authorization code.
- A transient network failure while refreshing OpenAI Codex or OpenAI-compatible-account tokens no longer signs you out. Only a genuinely rejected refresh token now requires re-authentication.
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request.
- Fixed images being dropped from file reads on models whose capability list is empty.
- Restoring a checkpoint now refuses to run when commits were made after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected.
- `apply_patch` now preserves a file's existing CRLF line endings.
- Global rules are now also read from `~/Cline/Rules`, which is where the Rules tab writes them on WSL and headless installs whose Documents folder resolves to the home directory.
- An enabled but unreachable remote (SSE or streamable HTTP) MCP server no longer stalls session startup; remote connects now have a 10 second budget.
- Aborting a task now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running.
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not ready in every published build while working in development.
- Cline provider models are now read from the live catalog, so newly published models appear without an extension update.
- Hook execution telemetry now fires; the task id was not threaded into hook runner creation, so those events were dropped.
### Changed
- Refreshed the built-in model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing throughout. This is an unusually wide refresh: the resolved default model changes for 57 providers, most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT following. If you use a provider without pinning a model, expect a different default.
- The message the model receives when you reject a tool call now names the rejected tool and reads as your decision rather than an error.
## [4.1.16]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
+24 -1
View File
@@ -1,7 +1,30 @@
# Cline CLI Changelog
## 3.0.59
## 3.0.61
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not-ready in every published binary while working in dev
- Restoring a checkpoint now refuses to run when you have made commits after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected
- `apply_patch` now preserves a file's existing CRLF line endings
- Global rules are now also read from `~/Cline/Rules`, which is where the VS Code Rules tab writes them on WSL and headless installs
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
- Fixed scheduled tasks disappearing after a hub restart
- Fixed markdown flashing as it settled at the end of a streamed response
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
- Refreshed the model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
## 3.0.60
- The config screen now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugins can be enabled or disabled with Space; the Hub persists the state and their skills and MCP servers follow it when the interactive runtime is rebuilt
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing on files that contain a single enormous line
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
+3
View File
@@ -270,6 +270,9 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
### Windows
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
### Windows code signing
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
### File permissions
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
+1 -1
View File
@@ -215,7 +215,7 @@ cline connect --stop
cline connect --stop telegram
```
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cwd <path>`, `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cd <path>` (also `/cwd <path>`), `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
### Schedules
+23
View File
@@ -72,6 +72,29 @@ function run(target) {
});
if (result.error) {
console.error(result.error.message);
// Windows application control (Smart App Control, WDAC, AppLocker)
// blocks the child exe at launch, which Node surfaces only as an
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
const code = result.error.code;
if (
os.platform() === "win32" &&
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
) {
console.error(
"\nWindows refused to start the Cline binary:\n " +
target +
"\n\n" +
"This usually means an application control policy (Smart App Control,\n" +
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
"run the path above directly in a terminal and check the error Windows\n" +
"reports, or inspect its signature with:\n\n" +
' Get-AuthenticodeSignature "' +
target +
'"\n\n' +
"If it was blocked by policy, allow the file or ask your administrator\n" +
"to trust it. See https://github.com/cline/cline/issues for known issues.",
);
}
process.exit(1);
}
if (typeof result.status === "number") {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.59",
"version": "3.0.61",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+16 -1
View File
@@ -19,6 +19,7 @@ import {
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { createCliCore } from "../session/session";
import { loadInteractiveConfigData } from "../tui/interactive-config";
import type { CliOutputMode } from "../utils/types";
@@ -423,8 +424,20 @@ async function loadInteractiveConfigDataForCommand(
cwd: string,
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
const userInstructionService = createConfigUserInstructionService(cwd);
const core = await createCliCore({
backendMode: "auto",
cwd,
workspaceRoot: cwd,
});
try {
await userInstructionService.start();
const [, agentPluginSettings] = await Promise.all([
userInstructionService.start(),
core.settings.list({
cwd,
workspaceRoot: cwd,
includePluginTools: true,
}),
]);
return await loadInteractiveConfigData({
userInstructionService,
cwd,
@@ -432,9 +445,11 @@ async function loadInteractiveConfigDataForCommand(
availabilityContext: {
mode: "act",
},
agentPluginSettings,
});
} finally {
userInstructionService.stop();
await core.dispose("cli_config_command_complete");
}
}
+3 -1
View File
@@ -186,7 +186,9 @@ export function createHubCommand(
);
if (!ok) {
io.writeErr(
draining ? "Hub drain request failed." : "Hub un-drain request failed.",
draining
? "Hub drain request failed."
: "Hub un-drain request failed.",
);
fail();
return;
+1 -1
View File
@@ -57,7 +57,7 @@ The Telegram connector uses the shared connector command parser:
- `/whereami` - show thread, cwd, tools, and yolo state
- `/tools [on|off|toggle]` - allow or block repo/file/shell tools
- `/yolo [on|off|toggle]` - auto-approve tool use
- `/cwd <path>` - change working directory
- `/cd <path>` or `/cwd <path>` - change working directory
- `/schedule create/list/trigger/delete` - manage scheduled workflows
- `/abort` - stop the current task
- `/exit` - stop the connector
+1 -1
View File
@@ -1028,7 +1028,7 @@ export async function handleConnectorUserTurn<
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
{ mode: startRequest.mode },
{ mode: startRequest.mode, cwd: startRequest.cwd },
);
try {
await input.client.sendRuntimeSession(
+47
View File
@@ -576,6 +576,53 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("warns that computer use is unavailable when a prompt argument is used", async () => {
const stdout = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
process.env.CLINE_COMPUTER_USE_PORT = "1234";
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
try {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(stdout).toHaveBeenCalledWith(
expect.stringContaining(
"computer use is only available in interactive mode",
),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
} finally {
delete process.env.CLINE_COMPUTER_USE_PORT;
stdout.mockRestore();
}
});
it("does not warn about computer use in interactive mode", async () => {
const stdout = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
process.env.CLINE_COMPUTER_USE_PORT = "1234";
process.argv = ["bun", "src/index.ts"];
try {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(stdout).not.toHaveBeenCalledWith(
expect.stringContaining(
"computer use is only available in interactive mode",
),
);
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
} finally {
delete process.env.CLINE_COMPUTER_USE_PORT;
stdout.mockRestore();
}
});
it("rejects a single bare positional prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
+30 -9
View File
@@ -32,6 +32,7 @@ import {
normalizeAutoApproveArgs,
resolveWorkspaceRoot,
} from "./utils/helpers";
import { createMutableUserInstructionConfigService } from "./utils/mutable-user-instruction-service";
import {
c,
installStreamErrorGuards,
@@ -915,15 +916,22 @@ export async function runCli(): Promise<void> {
});
coreServer.setSdkLogger(loggerAdapter.core);
const userInstructionService = createUserInstructionConfigService({
skills: {
workspacePath: workspaceRoot,
includePluginSkills: true,
cwd,
},
rules: { workspacePath: workspaceRoot },
workflows: { workspacePath: workspaceRoot },
});
const createCliUserInstructionService = (location: {
cwd: string;
workspaceRoot: string;
}) =>
createUserInstructionConfigService({
skills: {
workspacePath: location.workspaceRoot,
includePluginSkills: true,
cwd: location.cwd,
},
rules: { workspacePath: location.workspaceRoot },
workflows: { workspacePath: location.workspaceRoot },
});
const userInstructionService = createMutableUserInstructionConfigService(
createCliUserInstructionService({ cwd, workspaceRoot }),
);
await userInstructionService.start().catch(() => {});
let userInstructionServiceDisposed = false;
const stopUserInstructionService = () => {
@@ -990,6 +998,16 @@ export async function runCli(): Promise<void> {
(!process.stdin.isTTY && !args.interactive);
const isInteractive = (args.interactive || !args.prompt) && !isHeadless;
// Computer-use is wired only into the interactive runtime, so any other
// path yields a session with no `computer` tool. The model then reports
// having no such tool, which reads like a backend fault rather than a
// consequence of how cline was invoked.
if (process.env.CLINE_COMPUTER_USE_PORT?.trim() && !isInteractive) {
writeln(
`${c.dim}[warn] CLINE_COMPUTER_USE_PORT is set, but computer use is only available in interactive mode, so the "computer" tool will not be registered for this run. Start cline without a prompt argument (and without --yolo/--zen/--output json) to use it.${c.reset}`,
);
}
if (!apiKey && isOAuthProvider(provider) && !isHeadless && !isInteractive) {
const oauthResult = await ensureOAuthProviderApiKey({
providerId: provider,
@@ -1206,6 +1224,9 @@ export async function runCli(): Promise<void> {
await runInteractive(config, userInstructionService, resumeSessionId, {
initialPrompt: args.prompt,
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
explicitSystemPrompt: args.systemPrompt,
mutableUserInstructionService: userInstructionService,
createUserInstructionService: createCliUserInstructionService,
clineProviderSettings: initialClineProviderSettings,
startupTarget,
initialNotice,
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
abortActiveRuntime,
acquireAbortRejectionShield,
cleanupActiveRuntime,
clearAbortInProgress,
isAbortInProgress,
markAbortInProgress,
setActiveRuntimeAbort,
setActiveRuntimeCleanup,
} from "./active-runtime";
@@ -36,4 +40,26 @@ describe("active runtime hooks", () => {
expect(() => cleanupActiveRuntime()).not.toThrow();
});
it("keeps abort rejection shielding active until overlapping aborts clear", async () => {
vi.useFakeTimers();
try {
markAbortInProgress();
const releaseHelperAbort = acquireAbortRejectionShield();
expect(isAbortInProgress()).toBe(true);
clearAbortInProgress();
await vi.advanceTimersByTimeAsync(2_000);
expect(isAbortInProgress()).toBe(true);
releaseHelperAbort();
expect(isAbortInProgress()).toBe(true);
await vi.advanceTimersByTimeAsync(2_000);
expect(isAbortInProgress()).toBe(false);
} finally {
clearAbortInProgress();
await vi.runAllTimersAsync();
vi.useRealTimers();
}
});
});
+50 -23
View File
@@ -1,8 +1,9 @@
let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let abortScopeCount = 0;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
let activeRuntimeAbortRelease: (() => void) | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -35,35 +36,61 @@ export function cleanupActiveRuntime(): void {
// correctly (returns finishReason:"aborted"), but orphan rejections from
// the streaming layer or hub capability teardown surface as
// unhandledRejections and would otherwise crash the CLI.
export function acquireAbortRejectionShield(): () => void {
abortScopeCount += 1;
if (abortScopeCount === 1) {
if (abortGraceTimer) {
clearTimeout(abortGraceTimer);
abortGraceTimer = undefined;
}
if (!savedRejectionListeners) {
// Temporarily replace all unhandledRejection listeners with a
// suppressing handler. AbortController.abort() causes orphan promise
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners(
"unhandledRejection",
) as Array<(...args: unknown[]) => void>;
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
});
}
}
let released = false;
return () => {
if (released) {
return;
}
released = true;
releaseAbortRejectionShield();
};
}
export function markAbortInProgress(): void {
if (abortInProgress) {
return;
}
abortInProgress = true;
if (abortGraceTimer) {
clearTimeout(abortGraceTimer);
abortGraceTimer = undefined;
}
// Temporarily replace all unhandledRejection listeners with a
// suppressing handler. AbortController.abort() causes orphan promise
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
});
activeRuntimeAbortRelease ??= acquireAbortRejectionShield();
}
export function clearAbortInProgress(): void {
const release = activeRuntimeAbortRelease;
activeRuntimeAbortRelease = undefined;
release?.();
}
function releaseAbortRejectionShield(): void {
if (abortScopeCount === 0) {
return;
}
abortScopeCount -= 1;
if (abortScopeCount > 0) {
return;
}
if (abortGraceTimer) {
clearTimeout(abortGraceTimer);
}
abortGraceTimer = setTimeout(() => {
abortInProgress = false;
abortGraceTimer = undefined;
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
@@ -76,5 +103,5 @@ export function clearAbortInProgress(): void {
}
export function isAbortInProgress(): boolean {
return abortInProgress;
return abortScopeCount > 0 || abortGraceTimer !== undefined;
}
@@ -39,12 +39,15 @@ function makeState(config: Config): ChatCommandState {
};
}
function makeRuntime(): InteractiveChatCommandRuntime {
function makeRuntime(): InteractiveChatCommandRuntime & {
changeWorkingDirectory: (next: ChatCommandState) => Promise<void>;
} {
return {
forkCurrentSession: vi.fn(async () => undefined),
getActiveSessionId: vi.fn(() => "session-1"),
resetForNewSession: vi.fn(async () => {}),
restartEmpty: vi.fn(async () => {}),
changeWorkingDirectory: vi.fn(async () => {}),
};
}
@@ -62,6 +65,7 @@ describe("runInteractiveChatCommand", () => {
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
});
@@ -90,6 +94,7 @@ describe("runInteractiveChatCommand", () => {
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
});
@@ -116,6 +121,7 @@ describe("runInteractiveChatCommand", () => {
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
});
@@ -149,6 +155,7 @@ describe("runInteractiveChatCommand", () => {
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
});
@@ -164,6 +171,72 @@ describe("runInteractiveChatCommand", () => {
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
});
it("changes the runtime working directory before reporting /cd success", async () => {
const config = makeConfig();
const state = makeState(config);
const runtime = makeRuntime();
const target = process.cwd();
state.cwd = "/tmp";
state.workspaceRoot = "/tmp";
vi.mocked(runtime.changeWorkingDirectory).mockImplementation(
async (next) => {
Object.assign(state, next);
},
);
const result = await runInteractiveChatCommand({
prompt: `/cd ${target}`,
enabled: true,
config,
host: chatCommandHost,
chatCommandState: state,
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
});
expect(runtime.changeWorkingDirectory).toHaveBeenCalledWith(
expect.objectContaining({ cwd: target }),
);
expect(state.cwd).toBe(target);
expect(result).toMatchObject({
handled: true,
turnResult: { commandOutput: expect.stringContaining(`cwd=${target}`) },
});
});
it("does not run /cd when the submission is queued behind an active turn", async () => {
const config = makeConfig();
const state = makeState(config);
const runtime = makeRuntime();
const target = process.cwd();
state.cwd = "/tmp";
state.workspaceRoot = "/tmp";
await expect(
runInteractiveChatCommand({
prompt: `/cd ${target}`,
enabled: true,
delivery: "queue",
config,
host: chatCommandHost,
chatCommandState: state,
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
}),
).rejects.toThrow(
"Cannot change working directory while a turn is running. Wait for it to finish or abort it first.",
);
expect(runtime.changeWorkingDirectory).not.toHaveBeenCalled();
expect(state).toMatchObject({ cwd: "/tmp", workspaceRoot: "/tmp" });
});
it("returns plugin command submit prompts as model input", async () => {
const config = makeConfig();
const runtime = makeRuntime();
@@ -185,6 +258,7 @@ describe("runInteractiveChatCommand", () => {
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
changeWorkingDirectory: runtime.changeWorkingDirectory,
stop: () => {},
onCommandOutput,
});
@@ -39,12 +39,14 @@ function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
export async function runInteractiveChatCommand(input: {
prompt: string;
enabled: boolean;
delivery?: "queue" | "steer";
config: Config;
host: ChatCommandHost;
chatCommandState: ChatCommandState;
autoApproveAllRef: AutoApproveRef;
setInteractiveAutoApprove: (enabled: boolean) => void;
sessionRuntime: InteractiveChatCommandRuntime;
changeWorkingDirectory: (next: ChatCommandState) => Promise<void>;
stop: () => void;
onCommandOutput?: (text: string) => void;
}): Promise<InteractiveChatCommandResult> {
@@ -74,10 +76,22 @@ export async function runInteractiveChatCommand(input: {
autoApproveTools: input.autoApproveAllRef.current,
}),
setState: async (next) => {
input.chatCommandState.enableTools = next.enableTools;
input.chatCommandState.autoApproveTools = next.autoApproveTools;
input.chatCommandState.cwd = next.cwd;
input.chatCommandState.workspaceRoot = next.workspaceRoot;
if (
next.cwd !== input.chatCommandState.cwd ||
next.workspaceRoot !== input.chatCommandState.workspaceRoot
) {
// Workspace resources and the replacement session change together at
// an immediate submission boundary; deferred prompts cannot replay CLI
// commands after the active turn finishes.
if (input.delivery) {
throw new Error(
"Cannot change working directory while a turn is running. Wait for it to finish or abort it first.",
);
}
await input.changeWorkingDirectory(next);
} else {
Object.assign(input.chatCommandState, next);
}
input.setInteractiveAutoApprove(next.autoApproveTools);
},
reply: async (text) => {
@@ -0,0 +1,414 @@
import {
type AddressInfo,
createServer,
type Server,
type Socket,
} from "node:net";
import type { AgentResult, AgentToolContext } from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../../utils/types";
import {
createInteractiveComputerUser,
resolveHelperModelId,
withHelperReasoningControls,
} from "./computer-user";
const createCliCoreMock = vi.hoisted(() => vi.fn());
const releaseAbortRejectionShieldMock = vi.hoisted(() => vi.fn());
const acquireAbortRejectionShieldMock = vi.hoisted(() =>
vi.fn(() => releaseAbortRejectionShieldMock),
);
vi.mock("../../session/session", () => ({
createCliCore: createCliCoreMock,
}));
vi.mock("../active-runtime", () => ({
acquireAbortRejectionShield: acquireAbortRejectionShieldMock,
}));
const toolContext: AgentToolContext = {
agentId: "driver-agent",
conversationId: "driver-conversation",
iteration: 1,
};
/**
* Stub qbt backend answering get_display_info, which tool construction
* always performs (the backend is the sole source of truth for display
* dimensions). Tracks sockets so teardown can force-close the tool's
* internal client connection.
*/
function startStubBackend(): Promise<{
server: Server;
port: number;
destroyConnections: () => void;
}> {
const sockets = new Set<Socket>();
return new Promise((resolve) => {
const server = createServer((socket: Socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => {
buffer += chunk;
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (line.trim().length > 0) {
const request = JSON.parse(line) as { id: number };
socket.write(
`${JSON.stringify({
id: request.id,
ok: true,
display: { widthPx: 1920, heightPx: 1080 },
})}\n`,
);
}
newlineIndex = buffer.indexOf("\n");
}
});
});
server.listen(0, "127.0.0.1", () => {
const address = server.address() as AddressInfo;
resolve({
server,
port: address.port,
destroyConnections: () => {
for (const socket of sockets) {
socket.destroy();
}
},
});
});
});
}
function makeConfig(): Config {
return {
cwd: "C:/work",
workspaceRoot: "C:/work",
} as Config;
}
function makeSettings(settings: Record<string, unknown> | undefined) {
return {
getProviderSettings: () => settings as never,
};
}
function makeResult(overrides: Partial<AgentResult> = {}): AgentResult {
return {
text: "done",
iterations: 1,
finishReason: "completed",
messages: [],
toolCalls: [],
usage: { inputTokens: 1, outputTokens: 1 },
...overrides,
} as AgentResult;
}
describe("createInteractiveComputerUser", () => {
let server: Server | undefined;
let destroyConnections: (() => void) | undefined;
beforeEach(() => {
createCliCoreMock.mockReset();
releaseAbortRejectionShieldMock.mockReset();
acquireAbortRejectionShieldMock.mockClear();
});
afterEach(async () => {
destroyConnections?.();
destroyConnections = undefined;
if (!server) {
return;
}
await new Promise<void>((resolve) => server?.close(() => resolve()));
server = undefined;
});
it("returns undefined when computer use is not enabled by env", async () => {
const result = await createInteractiveComputerUser({
config: makeConfig(),
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
notifyDriver: () => {},
env: {} as NodeJS.ProcessEnv,
});
expect(result).toBeUndefined();
});
it("returns undefined when the Anthropic provider has no api key", async () => {
const started = await startStubBackend();
server = started.server;
destroyConnections = started.destroyConnections;
const result = await createInteractiveComputerUser({
config: makeConfig(),
providerSettingsManager: makeSettings(undefined),
notifyDriver: () => {},
env: {
CLINE_COMPUTER_USE_PORT: String(started.port),
} as NodeJS.ProcessEnv,
});
expect(result).toBeUndefined();
});
it("exposes the four driver tools when enabled and configured", async () => {
const started = await startStubBackend();
server = started.server;
destroyConnections = started.destroyConnections;
const result = await createInteractiveComputerUser({
config: makeConfig(),
providerSettingsManager: makeSettings({
apiKey: "sk-ant-x",
model: "claude-sonnet-4-6",
}),
notifyDriver: () => {},
env: {
CLINE_COMPUTER_USE_PORT: String(started.port),
} as NodeJS.ProcessEnv,
});
expect(result).toBeDefined();
expect(result?.driverTools.map((tool) => tool.name).sort()).toEqual([
"computer_user_interrupt",
"computer_user_message",
"computer_user_start",
"computer_user_status",
]);
// The raw computer tool must not be among the driver's tools.
expect(result?.driverTools.some((tool) => tool.name === "computer")).toBe(
false,
);
await result?.dispose();
});
it("starts the helper with one moderate adaptive reasoning snapshot", async () => {
const started = await startStubBackend();
server = started.server;
destroyConnections = started.destroyConnections;
const start = vi.fn(
async (_input: {
config: Record<string, unknown>;
interactive: boolean;
}) => ({ sessionId: "helper-session" }),
);
const send = vi.fn(() => new Promise(() => {}));
createCliCoreMock.mockResolvedValue({
start,
send,
abort: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
});
const driverConfig = {
...makeConfig(),
thinking: true,
reasoningEffort: "high" as const,
};
const result = await createInteractiveComputerUser({
config: driverConfig,
providerSettingsManager: makeSettings({
provider: "anthropic",
apiKey: "sk-ant-x",
model: "claude-sonnet-4-5",
client: "openai",
protocol: "openai-responses",
routingProviderId: "openai-native",
reasoning: {
enabled: true,
effort: "low",
budgetTokens: 8192,
},
}),
notifyDriver: () => {},
env: {
CLINE_COMPUTER_USE_PORT: String(started.port),
CLINE_COMPUTER_USER_MODEL: "anthropic/claude-sonnet-5",
} as NodeJS.ProcessEnv,
});
const startTool = result?.driverTools.find(
(tool) => tool.name === "computer_user_start",
);
await startTool?.execute({ task: "inspect the desktop" }, toolContext);
expect(start).toHaveBeenCalledWith({
interactive: true,
config: expect.objectContaining({
providerId: "anthropic",
modelId: "claude-sonnet-5",
thinking: true,
reasoningEffort: "medium",
providerConfig: expect.objectContaining({
providerId: "anthropic",
modelId: "claude-sonnet-5",
thinking: true,
reasoningEffort: "medium",
clientType: undefined,
routingProviderId: undefined,
thinkingBudgetTokens: undefined,
knownModels: expect.objectContaining({
"claude-sonnet-5": expect.objectContaining({
reasoningOptions: [
{
type: "effort",
values: ["low", "medium", "high", "xhigh", "max"],
},
],
}),
}),
}),
}),
});
expect(start.mock.calls[0]?.[0]?.config).not.toHaveProperty(
"thinkingBudgetTokens",
);
expect(driverConfig).toMatchObject({
thinking: true,
reasoningEffort: "high",
});
await result?.dispose();
});
it("shields abort rejections until the helper run is quiescent", async () => {
const started = await startStubBackend();
server = started.server;
destroyConnections = started.destroyConnections;
let resolveSend: ((result: AgentResult) => void) | undefined;
const send = vi.fn(
() =>
new Promise<AgentResult>((resolve) => {
resolveSend = resolve;
}),
);
const abort = vi.fn(async () => {});
createCliCoreMock.mockResolvedValue({
start: vi.fn(async () => ({ sessionId: "helper-session" })),
send,
abort,
stop: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
});
const result = await createInteractiveComputerUser({
config: makeConfig(),
providerSettingsManager: makeSettings({ apiKey: "sk-ant-x" }),
notifyDriver: () => {},
env: {
CLINE_COMPUTER_USE_PORT: String(started.port),
} as NodeJS.ProcessEnv,
});
const byName = new Map(
result?.driverTools.map((tool) => [tool.name, tool]) ?? [],
);
await byName
.get("computer_user_start")
?.execute({ task: "inspect the desktop" }, toolContext);
let stopped = false;
const interruption = byName
.get("computer_user_interrupt")
?.execute({ reason: "no progress" }, toolContext) as Promise<unknown>;
const observedInterruption = interruption.then((output) => {
stopped = true;
return output;
});
await vi.waitFor(() => {
expect(abort).toHaveBeenCalledWith(
"helper-session",
expect.objectContaining({ message: "no progress" }),
);
});
expect(acquireAbortRejectionShieldMock).toHaveBeenCalledTimes(1);
expect(releaseAbortRejectionShieldMock).not.toHaveBeenCalled();
expect(stopped).toBe(false);
resolveSend?.(makeResult({ finishReason: "aborted" }));
await expect(observedInterruption).resolves.toMatchObject({
status: "stopped",
});
expect(releaseAbortRejectionShieldMock).toHaveBeenCalledTimes(1);
await result?.dispose();
});
});
describe("withHelperReasoningControls", () => {
it("declares adaptive effort controls for the helper model", () => {
const result = withHelperReasoningControls(undefined, "claude-sonnet-5");
expect(result["claude-sonnet-5"]).toEqual({
id: "claude-sonnet-5",
reasoningOptions: [
{
type: "effort",
values: ["low", "medium", "high", "xhigh", "max"],
},
],
});
});
it("preserves other catalog entries and the helper model's own facts", () => {
const result = withHelperReasoningControls(
{
"claude-sonnet-5": {
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
contextWindow: 1000000,
},
"claude-opus-4-7": { id: "claude-opus-4-7", name: "Claude Opus 4.7" },
},
"claude-sonnet-5",
);
expect(result["claude-sonnet-5"]).toMatchObject({
name: "Claude Sonnet 5",
contextWindow: 1000000,
});
expect(result["claude-opus-4-7"]).toEqual({
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
});
});
});
describe("resolveHelperModelId", () => {
it("prefers CLINE_COMPUTER_USER_MODEL over saved provider model", () => {
expect(
resolveHelperModelId({ model: "claude-sonnet-4-6" }, {
CLINE_COMPUTER_USER_MODEL: "claude-opus-4-7",
} as NodeJS.ProcessEnv),
).toBe("claude-opus-4-7");
});
it("removes the redundant namespace for the direct Anthropic provider", () => {
expect(
resolveHelperModelId(undefined, {
CLINE_COMPUTER_USER_MODEL: "anthropic/claude-sonnet-5",
} as NodeJS.ProcessEnv),
).toBe("claude-sonnet-5");
});
it("falls back to the Anthropic provider entry's saved model", () => {
expect(
resolveHelperModelId(
{ model: "claude-haiku-4-5" },
{} as NodeJS.ProcessEnv,
),
).toBe("claude-haiku-4-5");
});
it("defaults when neither env nor settings specify a model", () => {
expect(resolveHelperModelId(undefined, {} as NodeJS.ProcessEnv)).toBe(
"claude-sonnet-4-6",
);
expect(
resolveHelperModelId({ model: " " }, {
CLINE_COMPUTER_USER_MODEL: " ",
} as NodeJS.ProcessEnv),
).toBe("claude-sonnet-4-6");
});
});
@@ -0,0 +1,333 @@
import {
type AgentHooks,
type ClineCore,
COMPUTER_USER_SYSTEM_PROMPT,
ComputerTaskArtifactRecorder,
ComputerUseClient,
ComputerUserCoordinator,
createComputerUserCollaborationTools,
createComputerUserDriverTools,
createComputerUseTool,
createJournalEventSink,
createTranscriptRecordingHooks,
type ProviderSettingsManager,
resolveComputerUseTargetFromEnv,
toProviderConfig,
} from "@cline/core";
import type {
AgentTool,
ModelInfo,
ModelReasoningOption,
} from "@cline/shared";
import { nanoid } from "nanoid";
import { createCliCore } from "../../session/session";
import type { Config } from "../../utils/types";
import { acquireAbortRejectionShield } from "../active-runtime";
/**
* CLI host integration for the asynchronous computer user.
*
* The driver session gets four `computer_user_*` tools; the helper runs as a
* dedicated interactive ClineCore session on the Anthropic provider (the
* computer-use beta header requires the direct provider — see qwanban's
* README). Enabled by the same `CLINE_COMPUTER_USE_PORT` opt-in as the raw
* `computer` tool; when the coordinator is active the driver deliberately
* does NOT get the raw tool, so all GUI work flows through the helper.
*
* Helper consistency boundary: provider, credentials, reasoning, tool
* inventory, and prompt are resolved here, once, when the runtime starts.
* Changing them requires a new CLI session.
*/
const HELPER_PROVIDER_ID = "anthropic";
const HELPER_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
const HELPER_MODEL_ENV_VAR = "CLINE_COMPUTER_USER_MODEL";
const HELPER_REASONING = {
thinking: true,
reasoningEffort: "medium" as const,
};
/**
* Reasoning controls declared for the helper's model, in the models.dev
* shape the Anthropic provider routing reads. The bundled model catalog
* ships without `reasoningOptions`, which the routing treats as an
* unlisted model with manual-only thinking and encodes as
* `thinking.type.enabled`; current Claude models reject that shape and
* require `thinking.type.adaptive` with an effort level. Declaring the
* controls here keeps the helper on the adaptive wire shape regardless of
* catalog state.
*/
const HELPER_MODEL_REASONING_OPTIONS: ModelReasoningOption[] = [
{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] },
];
function toDirectAnthropicModelId(modelId: string): string {
const directProviderPrefix = `${HELPER_PROVIDER_ID}/`;
return modelId.startsWith(directProviderPrefix)
? modelId.slice(directProviderPrefix.length)
: modelId;
}
/**
* Returns the provider config's model catalog with the helper's reasoning
* controls declared for the helper model, preserving every other entry.
*/
export function withHelperReasoningControls(
knownModels: Record<string, ModelInfo> | undefined,
modelId: string,
): Record<string, ModelInfo> {
return {
...knownModels,
[modelId]: {
...knownModels?.[modelId],
id: modelId,
reasoningOptions: HELPER_MODEL_REASONING_OPTIONS,
},
};
}
/**
* Resolves the helper's Anthropic model id. The helper's model is chosen
* independently of the driver's: CLINE_COMPUTER_USER_MODEL wins, then the
* Anthropic provider entry's saved `model`, then the default. The provider
* is always the direct `anthropic` provider — the computer-use beta header
* is only sent on that wire target, so Anthropic models reached through
* other providers (cline, openrouter, bedrock) would lack the extended
* action set.
*/
export function resolveHelperModelId(
helperSettings: { model?: unknown } | undefined,
env: NodeJS.ProcessEnv,
): string {
const fromEnv = env[HELPER_MODEL_ENV_VAR]?.trim();
if (fromEnv) {
return toDirectAnthropicModelId(fromEnv);
}
if (
typeof helperSettings?.model === "string" &&
helperSettings.model.trim()
) {
return toDirectAnthropicModelId(helperSettings.model.trim());
}
return HELPER_DEFAULT_MODEL_ID;
}
export interface InteractiveComputerUser {
driverTools: AgentTool[];
/**
* Hooks layer to merge into the driver session's config: records the
* driver's transcript and run status to the backend journal alongside
* the helper's, so the observatory can flip between both timelines.
*/
driverRecordingHooks: AgentHooks;
dispose(): Promise<void>;
}
export async function createInteractiveComputerUser(input: {
config: Config;
providerSettingsManager: Pick<ProviderSettingsManager, "getProviderSettings">;
/**
* Injects a prompt into the driver's conversation. Must resolve the
* driver session id at call time (session rebuilds change it), which
* `sessionRuntime.sendCurrentTurn` does.
*/
notifyDriver: (prompt: string, delivery: "queue" | "steer") => void;
env?: NodeJS.ProcessEnv;
}): Promise<InteractiveComputerUser | undefined> {
// Check the local precondition (credentials) before dialing the backend:
// tool construction queries the backend for display info and holds a
// socket, which would be wasted if the helper cannot be configured.
const helperSettings =
input.providerSettingsManager.getProviderSettings(HELPER_PROVIDER_ID);
const helperApiKey =
typeof helperSettings?.apiKey === "string" ? helperSettings.apiKey : "";
if (!helperApiKey) {
// No silent fallback to the driver's credentials: the helper requires
// the Anthropic provider's own configuration.
return undefined;
}
const target = resolveComputerUseTargetFromEnv(input.env ?? process.env);
if (!target) {
return undefined;
}
// One backend client shared by the computer tool and the observability
// publisher. The backend serves a single agent connection at a time, so
// splitting these across two sockets would make one of them dead.
//
// No client-side action observer: the backend journals every computer
// action (with its screenshot) as it executes it, so recording actions
// here too would give the journal two producers for one event type.
const computerClient = new ComputerUseClient(target);
const recorder = new ComputerTaskArtifactRecorder(
`task_${nanoid(10)}`,
createJournalEventSink(computerClient),
);
const computerTool = await createComputerUseTool({
...target,
client: computerClient,
});
const helperModelId = resolveHelperModelId(
helperSettings,
input.env ?? process.env,
);
// Helper model and reasoning settings become effective together when this
// session is created. Keep the provider config and session config derived
// from this snapshot so saved manual thinking budgets cannot conflict with
// adaptive thinking on current Claude models. The model's reasoning
// controls are declared explicitly: the bundled catalog ships without
// them, and without them the Anthropic routing falls back to the manual
// thinking shape those models reject.
const baseProviderConfig = toProviderConfig({
...helperSettings,
provider: HELPER_PROVIDER_ID,
model: helperModelId,
client: undefined,
protocol: undefined,
routingProviderId: undefined,
reasoning: {
enabled: HELPER_REASONING.thinking,
effort: HELPER_REASONING.reasoningEffort,
},
});
const helperProviderConfig = {
...baseProviderConfig,
clientType: undefined,
routingProviderId: undefined,
thinkingBudgetTokens: undefined,
knownModels: withHelperReasoningControls(
baseProviderConfig.knownModels,
helperModelId,
),
};
// The helper config and the coordinator reference each other (the
// collaboration tools call back into the coordinator). Break the cycle
// with one shared extraTools array: the coordinator captures the config
// object now; the tools are pushed into the same array below, before any
// session can start.
const helperExtraTools: AgentTool[] = [computerTool];
const helperConfig = {
providerId: helperProviderConfig.providerId,
modelId: helperProviderConfig.modelId,
apiKey: helperProviderConfig.apiKey,
baseUrl: helperProviderConfig.baseUrl,
headers: helperProviderConfig.headers,
knownModels: helperProviderConfig.knownModels,
providerConfig: helperProviderConfig,
...HELPER_REASONING,
cwd: input.config.cwd,
workspaceRoot: input.config.workspaceRoot?.trim() || input.config.cwd,
mode: "act" as const,
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
pluginPaths: [],
systemPrompt: COMPUTER_USER_SYSTEM_PROMPT,
extraTools: helperExtraTools,
toolPolicies: {
// Questions and completion go to the driver through the
// collaboration tools, never to a human or generic completion.
ask_question: { enabled: false },
submit_and_exit: { enabled: false },
},
// The helper's terminal tools are ask_driver/finish_computer_task
// (extraTools with completesRun). Require them explicitly: the
// builder's inference only recognizes submit_and_exit, which is
// disabled above, and a run that ends in free-form text would leave
// the driver waiting with no report.
completionPolicy: { requireCompletionTool: true },
// Record the helper's transcript and run status to the backend
// journal for the observatory.
hooks: createTranscriptRecordingHooks(recorder, {
kind: "computer_user",
}),
};
// Lazy: the helper ClineCore spawns only when the driver first delegates.
// forceLocalBackend keeps the helper in this process, where the
// computer-use backend's loopback socket is reachable — a hub daemon may
// run on a different machine from the controlled display.
let helperCorePromise: Promise<ClineCore> | undefined;
let activeHelperSend: Promise<unknown> | undefined;
const getHelperCore = () => {
helperCorePromise ??= createCliCore({
forceLocalBackend: true,
cwd: input.config.cwd,
workspaceRoot: input.config.workspaceRoot,
logger: input.config.logger,
}).catch((error) => {
helperCorePromise = undefined;
throw error;
});
return helperCorePromise;
};
const coordinator = new ComputerUserCoordinator({
host: {
start: async (startInput) =>
(await getHelperCore()).start({
config: startInput.config as never,
interactive: startInput.interactive,
}),
send: async (sendInput) => {
const send = (await getHelperCore()).send(sendInput);
if (sendInput.delivery === "steer") {
return await send;
}
activeHelperSend = send;
try {
return await send;
} finally {
if (activeHelperSend === send) {
activeHelperSend = undefined;
}
}
},
abort: async (sessionId, reason) => {
const releaseAbortShield = acquireAbortRejectionShield();
try {
await (await getHelperCore()).abort(sessionId, reason);
} catch (error) {
releaseAbortShield();
throw error;
}
const abortedSend = activeHelperSend;
if (!abortedSend) {
releaseAbortShield();
return;
}
// The coordinator owns waiting for this run to settle. The adapter
// only keeps expected provider cancellation rejections shielded for
// the same interval, without making disposal wait on host teardown.
void abortedSend.finally(releaseAbortShield).catch(() => {});
},
stop: async (sessionId) => (await getHelperCore()).stop(sessionId),
},
helperConfig,
notifyDriver: ({ prompt, delivery }) =>
input.notifyDriver(prompt, delivery),
recorder,
});
helperExtraTools.push(...createComputerUserCollaborationTools(coordinator));
return {
driverTools: createComputerUserDriverTools(coordinator),
driverRecordingHooks: createTranscriptRecordingHooks(recorder, {
kind: "driver",
}),
dispose: async () => {
await coordinator.dispose().catch(() => {});
if (helperCorePromise) {
const core = await helperCorePromise.catch(() => undefined);
await core?.dispose().catch(() => {});
}
// Push any queued journal publishes out before dropping the
// backend connection.
await recorder.flush().catch(() => {});
computerClient.close();
},
};
}
@@ -17,6 +17,7 @@ import {
import {
applyPluginFailures,
type InteractiveConfigItem,
isToggleableInteractiveConfigItem,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
import { createInteractiveConfigDataLoader } from "./config-data";
@@ -113,6 +114,172 @@ describe("interactive config data loader", () => {
return pluginPath;
}
it("merges the hub-owned Agent Plugin inventory into the config view", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-plugin-"));
tempRoots.push(tempRoot);
const pluginRoot = "/remote/home/.agents/plugins/portable-review";
const calls: unknown[] = [];
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
loadCoreSettings: async (input) => {
calls.push(input);
return {
workflows: [],
rules: [],
tools: [],
plugins: [
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: true,
toggleable: true,
agentPlugin: true,
},
],
skills: [
{
id: "portable-review:review",
name: "review",
path: `${pluginRoot}/skills/review/SKILL.md`,
kind: "skill",
source: "global-plugin",
enabled: true,
toggleable: false,
agentPlugin: true,
pluginName: "portable-review",
pluginPath: pluginRoot,
},
],
mcp: [
{
id: "portable-review.docs",
name: "portable-review.docs",
path: `${pluginRoot}/mcp.json`,
kind: "mcp",
source: "global-plugin",
enabled: true,
toggleable: false,
agentPlugin: true,
pluginName: "portable-review",
pluginPath: pluginRoot,
},
],
};
},
});
const data = await loader.loadConfigData({ includePluginTools: false });
expect(calls).toEqual([
expect.objectContaining({
includePluginTools: false,
}),
]);
expect(data.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "portable-review",
agentPlugin: true,
toggleable: true,
deletable: false,
}),
]),
);
const skill = data.skills.find(
(item) => item.id === "portable-review:review",
);
expect(skill).toMatchObject({
pluginName: "portable-review",
agentPlugin: true,
});
expect(skill && isToggleableInteractiveConfigItem(skill)).toBe(false);
expect(data.mcp).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "portable-review.docs" }),
]),
);
});
it("toggles Agent Plugins through the hub without mutating client settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-toggle-"));
tempRoots.push(tempRoot);
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
const pluginRoot = "/hub/home/.agents/plugins/portable-review";
const toggleCalls: unknown[] = [];
const loader = createInteractiveConfigDataLoader({
config: {
...createConfig(tempRoot),
agentPluginPaths: ["./portable-review"],
},
toggleCoreSettings: async (input) => {
toggleCalls.push(input);
return {
changedTypes: ["plugins", "skills", "mcp"],
snapshot: {
workflows: [],
rules: [],
tools: [],
skills: [],
mcp: [],
plugins: [
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: false,
toggleable: true,
agentPlugin: true,
},
],
},
};
},
});
const data = await loader.onToggleConfigItem(
{
id: "agent-plugin:portable-review",
name: "portable-review",
path: pluginRoot,
kind: "plugin",
source: "global-plugin",
enabled: true,
toggleable: true,
deletable: false,
agentPlugin: true,
},
{ includePluginTools: false },
);
expect(toggleCalls).toEqual([
expect.objectContaining({
type: "plugins",
id: "agent-plugin:portable-review",
path: pluginRoot,
name: "portable-review",
enabled: false,
agentPluginPaths: ["./portable-review"],
includePluginTools: false,
}),
]);
expect(data?.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "portable-review",
enabled: false,
agentPlugin: true,
}),
]),
);
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
});
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,4 +1,8 @@
import {
type CoreSettingsListInput,
type CoreSettingsMutationResult,
type CoreSettingsSnapshot,
type CoreSettingsToggleInput,
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
@@ -10,6 +14,7 @@ import {
import {
type InteractiveConfigData,
type InteractiveConfigItem,
isToggleableInteractiveConfigItem,
type LoadInteractiveConfigDataOptions,
loadInteractiveConfigData,
} from "../../tui/interactive-config";
@@ -18,6 +23,12 @@ import type { Config } from "../../utils/types";
export function createInteractiveConfigDataLoader(input: {
config: Config;
userInstructionService?: UserInstructionConfigService;
loadCoreSettings?: (
input: CoreSettingsListInput,
) => Promise<CoreSettingsSnapshot>;
toggleCoreSettings?: (
input: CoreSettingsToggleInput,
) => Promise<CoreSettingsMutationResult>;
}) {
const workspaceRoot = () =>
input.config.workspaceRoot?.trim() || input.config.cwd;
@@ -28,16 +39,36 @@ export function createInteractiveConfigDataLoader(input: {
enableSpawnAgent: input.config.enableSpawnAgent,
enableAgentTeams: input.config.enableAgentTeams,
});
const loadConfigData = async (
const buildSettingsInput = (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> =>
await loadInteractiveConfigData({
): CoreSettingsListInput => ({
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
agentPluginPaths: input.config.agentPluginPaths,
includePluginTools: options.includePluginTools,
});
const buildConfigData = async (
options: LoadInteractiveConfigDataOptions,
agentPluginSettings: CoreSettingsSnapshot | undefined,
): Promise<InteractiveConfigData> => {
return await loadInteractiveConfigData({
userInstructionService: input.userInstructionService,
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
includePluginTools: options.includePluginTools,
agentPluginSettings,
});
};
const loadConfigData = async (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> => {
const agentPluginSettings = await input
.loadCoreSettings?.(buildSettingsInput(options))
.catch(() => undefined);
return await buildConfigData(options, agentPluginSettings);
};
const refreshUserInstructionConfigs = async (): Promise<void> => {
const service = input.userInstructionService;
@@ -55,6 +86,9 @@ export function createInteractiveConfigDataLoader(input: {
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (!isToggleableInteractiveConfigItem(item)) {
return undefined;
}
const settings = createCoreSettingsService();
if (item.kind === "skill" && typeof item.enabled === "boolean") {
await settings.toggle({
@@ -72,6 +106,22 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
if (item.agentPlugin === true) {
if (!input.toggleCoreSettings) {
throw new Error(
"Agent Plugin settings require a connected Cline Hub.",
);
}
const result = await input.toggleCoreSettings({
...buildSettingsInput(options),
type: "plugins",
id: item.id,
path: item.path,
name: item.name,
enabled: !item.enabled,
});
return await buildConfigData(options, result.snapshot);
}
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
@@ -150,7 +200,11 @@ export function createInteractiveConfigDataLoader(input: {
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (item.kind !== "plugin") {
if (
item.kind !== "plugin" ||
item.agentPlugin === true ||
item.deletable === false
) {
return undefined;
}
await uninstallPlugin({
@@ -270,4 +270,37 @@ describe("applyInteractiveModeConfig", () => {
expect(config.extraTools).toEqual([]);
expect(config.systemPrompt).toBe("system prompt for act");
});
it("keeps persistent extra tools across plan/act switches", async () => {
const config = makeConfig();
const computerUserTool = {
...switchToActModeTool,
name: "computer_user_start",
};
const persistentExtraTools = [computerUserTool];
await applyInteractiveModeConfig({
config,
mode: "plan",
switchToActModeTool,
persistentExtraTools,
});
expect(config.extraTools).toEqual([switchToActModeTool, computerUserTool]);
await applyInteractiveModeConfig({
config,
mode: "act",
switchToActModeTool,
persistentExtraTools,
});
expect(config.extraTools).toEqual([computerUserTool]);
await applyInteractiveModeConfig({
config,
mode: "plan",
switchToActModeTool,
persistentExtraTools,
});
expect(config.extraTools).toEqual([switchToActModeTool, computerUserTool]);
});
});
+18 -2
View File
@@ -115,14 +115,30 @@ export {
type ModeSwitchNotice,
} from "@cline/shared";
/**
* Builds the extraTools list for an interactive mode. The single derivation
* used both at startup and on every mode switch, so mode-independent tools
* (e.g. the computer-user tools) cannot be silently dropped by a switch.
*/
export function buildInteractiveExtraTools(input: {
mode: InteractiveUiMode;
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
persistentExtraTools?: NonNullable<Config["extraTools"]>;
}): NonNullable<Config["extraTools"]> {
return [
...(input.mode === "plan" ? [input.switchToActModeTool] : []),
...(input.persistentExtraTools ?? []),
];
}
export async function applyInteractiveModeConfig(input: {
config: Config;
mode: InteractiveUiMode;
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
persistentExtraTools?: NonNullable<Config["extraTools"]>;
}): Promise<void> {
input.config.mode = input.mode;
input.config.extraTools =
input.mode === "plan" ? [input.switchToActModeTool] : [];
input.config.extraTools = buildInteractiveExtraTools(input);
input.config.systemPrompt = await resolveSystemPrompt({
cwd: input.config.cwd,
providerId: input.config.providerId,
@@ -22,6 +22,7 @@ const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
const resolveSystemPromptMock = vi.hoisted(() => vi.fn());
vi.mock("../../session/session", () => ({
createCliCore: createCliCoreMock,
@@ -47,6 +48,10 @@ vi.mock("../active-runtime", () => ({
markAbortInProgress: markAbortInProgressMock,
}));
vi.mock("../prompt", () => ({
resolveSystemPrompt: resolveSystemPromptMock,
}));
vi.mock("../session-events", () => ({
subscribeToAgentEvents: subscribeToAgentEventsMock,
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
@@ -233,10 +238,12 @@ describe("createInteractiveSessionRuntime", () => {
markAbortInProgressMock.mockReset();
submitAndExitInTerminalMock.mockReset();
createInteractiveExitSummaryMock.mockReset();
resolveSystemPromptMock.mockReset();
createRuntimeHooksMock.mockReturnValue({
hooks: undefined,
shutdown: vi.fn().mockResolvedValue(undefined),
});
resolveSystemPromptMock.mockResolvedValue("rebuilt system prompt");
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
subscribeToAgentEventsMock.mockReturnValue(() => {});
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
@@ -587,6 +594,107 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-restarted");
});
it("restarts the active session with one working-directory snapshot", async () => {
const manager = makeManager();
const config = createConfig();
const state = createChatCommandState(config);
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config,
providerSettingsManager: createProviderSettingsManager(),
explicitSystemPrompt: "custom prompt",
chatCommandState: state,
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await runtime.changeWorkingDirectory({
...state,
cwd: "/tmp/next-project",
workspaceRoot: "/tmp/next-project",
});
expect(resolveSystemPromptMock).toHaveBeenCalledWith({
cwd: "/tmp/next-project",
explicitSystemPrompt: "custom prompt",
providerId: "anthropic",
mode: "act",
});
expect(config).toMatchObject({
cwd: "/tmp/next-project",
workspaceRoot: "/tmp/next-project",
systemPrompt: "rebuilt system prompt",
});
expect(state).toMatchObject({
cwd: "/tmp/next-project",
workspaceRoot: "/tmp/next-project",
});
expect(manager.stop).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
cwd: "/tmp/next-project",
workspaceRoot: "/tmp/next-project",
systemPrompt: "rebuilt system prompt",
}),
}),
);
expect(createRuntimeHooksMock).toHaveBeenLastCalledWith(
expect.objectContaining({
cwd: "/tmp/next-project",
workspaceRoot: "/tmp/next-project",
}),
);
});
it("restores the previous working-directory snapshot when restart fails", async () => {
const manager = makeManager();
const config = createConfig();
const state = createChatCommandState(config);
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
manager.start.mockRejectedValueOnce(new Error("replacement failed"));
await expect(
runtime.changeWorkingDirectory({
...state,
cwd: "/tmp/failed-project",
workspaceRoot: "/tmp/failed-project",
}),
).rejects.toThrow("replacement failed");
expect(config).toMatchObject({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
systemPrompt: "system",
});
expect(state).toMatchObject({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
});
expect(manager.start).toHaveBeenCalledTimes(3);
expect(manager.start.mock.calls[2]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
sessionId: "session-1",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
}),
}),
);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("adds a live interactive approval policy hook to started sessions", async () => {
const manager = makeManager();
const upstreamBeforeTool = vi.fn(async () => ({
@@ -1,9 +1,15 @@
import { basename } from "node:path";
import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
type CoreSettingsListInput,
type CoreSettingsMutationResult,
type CoreSettingsSnapshot,
type CoreSettingsToggleInput,
createSessionCompactionState,
isSessionNotFoundError,
mergeAgentHooks,
type PendingPromptMutationResult,
type ProviderSettingsManager,
projectSessionCompactionState,
@@ -27,6 +33,7 @@ import { setActiveCliSession } from "../../utils/output";
import { loadInteractiveResumeMessages } from "../../utils/resume";
import type { Config } from "../../utils/types";
import { markAbortInProgress } from "../active-runtime";
import { resolveSystemPrompt } from "../prompt";
import type {
PendingPromptSnapshot,
PendingPromptSubmittedEvent,
@@ -93,6 +100,7 @@ export function createInteractiveSessionRuntime(input: {
config: Config;
providerSettingsManager: ProviderSettingsManager;
userInstructionService?: UserInstructionConfigService;
explicitSystemPrompt?: string;
resumeSessionId?: string;
chatCommandState: ChatCommandState;
requestToolApproval: (
@@ -102,6 +110,17 @@ export function createInteractiveSessionRuntime(input: {
askQuestionRef: AskQuestionRef;
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
/**
* Mode-independent extra tools (e.g. the computer-user tools) that must
* survive plan/act switches. Rebuilt into config.extraTools on every
* mode change alongside the mode-dependent switch tool.
*/
persistentExtraTools?: NonNullable<Config["extraTools"]>;
/**
* Host-supplied hooks layer (e.g. computer-use transcript recording)
* merged after the runtime's own hooks on every session build.
*/
extraAgentHooks?: AgentHooks;
onAgentEvent: (event: AgentEvent) => void;
onTeamEvent: (event: TeamEvent) => void;
onPendingPrompts: (event: PendingPromptSnapshot) => void;
@@ -126,6 +145,20 @@ export function createInteractiveSessionRuntime(input: {
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
const createWorkspaceRuntimeHooks = (
manager: CliCore,
workspace: Pick<ChatCommandState, "cwd" | "workspaceRoot">,
): RuntimeHooks =>
createRuntimeHooks({
verbose: input.config.verbose,
yolo: input.config.mode === "yolo",
cwd: workspace.cwd,
workspaceRoot: workspace.workspaceRoot,
dispatchHookEvent: async (payload) => {
await manager.ingestHookEvent(payload);
},
});
const clearActiveSession = (): void => {
activeSessionId = "";
setActiveCliSession(undefined);
@@ -175,15 +208,7 @@ export function createInteractiveSessionRuntime(input: {
throw new Error("interactive runtime shutdown requested");
}
sessionManager = manager;
runtimeHooks = createRuntimeHooks({
verbose: input.config.verbose,
yolo: input.config.mode === "yolo",
cwd: input.config.cwd,
workspaceRoot: input.config.workspaceRoot,
dispatchHookEvent: async (payload) => {
await manager.ingestHookEvent(payload);
},
});
runtimeHooks = createWorkspaceRuntimeHooks(manager, input.chatCommandState);
unsubscribeAgent = subscribeToAgentEvents(manager, input.onAgentEvent);
unsubscribePendingPrompts = subscribeToPendingPromptEvents(manager, {
onPendingPrompts: input.onPendingPrompts,
@@ -197,7 +222,7 @@ export function createInteractiveSessionRuntime(input: {
throw new Error("interactive runtime hooks are unavailable");
}
const hooks = withInteractiveApprovalPolicyHook(
runtimeHooks.hooks,
mergeAgentHooks([runtimeHooks.hooks, input.extraAgentHooks]),
input.resolveToolPolicy,
);
return buildInteractiveSessionConfig({
@@ -299,6 +324,19 @@ export function createInteractiveSessionRuntime(input: {
return await startupPromise;
};
const listCoreSettings = async (
settingsInput: CoreSettingsListInput,
): Promise<CoreSettingsSnapshot> => {
const manager = await ensureSessionManager();
return await manager.settings.list(settingsInput);
};
const toggleCoreSettings = async (
settingsInput: CoreSettingsToggleInput,
): Promise<CoreSettingsMutationResult> => {
const manager = await ensureSessionManager();
return await manager.settings.toggle(settingsInput);
};
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
const manager = sessionManager;
const sessionId = activeSessionId;
@@ -424,14 +462,14 @@ export function createInteractiveSessionRuntime(input: {
messages: MessageWithMetadata[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
options?: { preserveSessionId?: boolean },
options?: { preserveSessionId?: boolean; sessionId?: string },
): Promise<void> => {
// Config-only restarts (model/mode/account changes) continue the same
// conversation, so they must keep the session id — otherwise each
// restart mints a new session history entry for the same conversation.
const reuseSessionId = options?.preserveSessionId
? activeSessionId || undefined
: undefined;
const reuseSessionId =
options?.sessionId ??
(options?.preserveSessionId ? activeSessionId || undefined : undefined);
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
@@ -494,6 +532,99 @@ export function createInteractiveSessionRuntime(input: {
);
};
const changeWorkingDirectory = async (
next: ChatCommandState,
): Promise<void> => {
await ensureReady();
const manager = sessionManager;
if (!manager) {
throw new Error("interactive session manager is unavailable");
}
const sourceSessionId = activeSessionId;
const [{ messages, status }, compactionState, systemPrompt] =
await Promise.all([
readCurrentMessages(),
readCurrentCompactionState(),
resolveSystemPrompt({
cwd: next.cwd,
explicitSystemPrompt: input.explicitSystemPrompt,
providerId: input.config.providerId,
mode: input.config.mode,
}),
]);
if (status !== "read" || activeSessionId !== sourceSessionId) {
throw new Error("Working directory changed concurrently. Try /cd again.");
}
const previousState = { ...input.chatCommandState };
const previousSessionId = activeSessionId;
const previousConfig = {
cwd: input.config.cwd,
workspaceRoot: input.config.workspaceRoot,
systemPrompt: input.config.systemPrompt,
extensionContext: input.config.extensionContext,
};
const previousRuntimeHooks = runtimeHooks;
const nextRuntimeHooks = createWorkspaceRuntimeHooks(manager, next);
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
const initialCompactionState = projectedMessages
? createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
})
: undefined;
// The directory becomes effective as one snapshot for the replacement
// session. A concurrent ensureReady() waits on the restart barrier.
Object.assign(input.chatCommandState, next);
input.config.cwd = next.cwd;
input.config.workspaceRoot = next.workspaceRoot;
input.config.systemPrompt = systemPrompt;
if (input.config.extensionContext?.workspace) {
input.config.extensionContext = {
...input.config.extensionContext,
workspace: {
...input.config.extensionContext.workspace,
rootPath: next.workspaceRoot,
cwd: next.cwd,
workspaceName: basename(next.cwd),
},
};
}
runtimeHooks = nextRuntimeHooks;
try {
await restartWithMessages(messages, undefined, initialCompactionState, {
preserveSessionId: true,
});
} catch (error) {
Object.assign(input.chatCommandState, previousState);
input.config.cwd = previousConfig.cwd;
input.config.workspaceRoot = previousConfig.workspaceRoot;
input.config.systemPrompt = previousConfig.systemPrompt;
input.config.extensionContext = previousConfig.extensionContext;
runtimeHooks = previousRuntimeHooks;
await nextRuntimeHooks.shutdown().catch(() => {});
try {
await restartWithMessages(messages, undefined, initialCompactionState, {
sessionId: previousSessionId || undefined,
});
} catch (recoveryError) {
throw new AggregateError(
[error, recoveryError],
"Working directory change failed, and the previous session could not be restored.",
);
}
throw error;
}
await previousRuntimeHooks?.shutdown().catch(() => {});
};
const updateCurrentSessionConnection = async (
update: SessionConnectionUpdate,
): Promise<void> => {
@@ -530,6 +661,7 @@ export function createInteractiveSessionRuntime(input: {
config: input.config,
mode,
switchToActModeTool: input.switchToActModeTool,
persistentExtraTools: input.persistentExtraTools,
});
await restartWithCurrentMessages();
};
@@ -883,6 +1015,8 @@ export function createInteractiveSessionRuntime(input: {
return {
ensureReady,
listCoreSettings,
toggleCoreSettings,
sendCurrentTurn,
updatePendingPrompt,
getAccumulatedUsage,
@@ -891,6 +1025,7 @@ export function createInteractiveSessionRuntime(input: {
resetForNewSession,
restartWithMessages,
restartWithCurrentMessages,
changeWorkingDirectory,
updateCurrentSessionConnection,
resumeSession,
forkCurrentSession,
@@ -0,0 +1,222 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createUserInstructionConfigService,
type UserInstructionConfigService,
} from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createChatCommandHost } from "../../utils/chat-commands";
import { createMutableUserInstructionConfigService } from "../../utils/mutable-user-instruction-service";
import type { WorkspaceChatCommandHostResult } from "../../utils/plugin-chat-commands";
import { createInteractiveWorkspaceResources } from "./workspace-resources";
describe("interactive workspace resources", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
tempRoots.length = 0;
});
async function createWorkspace(commandName: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cli-workspace-resources-"));
tempRoots.push(root);
const workflows = join(root, "workflows");
await mkdir(workflows, { recursive: true });
await writeFile(
join(workflows, `${commandName}.md`),
`---\nname: ${commandName}\n---\nRun ${commandName}.`,
);
return root;
}
function createInstructionService(cwd: string): UserInstructionConfigService {
return createUserInstructionConfigService({
skills: { directories: [] },
rules: { directories: [] },
workflows: { directories: [join(cwd, "workflows")] },
});
}
function createPluginResult(
commandName: string,
shutdown = vi.fn(async () => {}),
): WorkspaceChatCommandHostResult {
return {
host: createChatCommandHost().register("command", {
names: [`/${commandName}`],
run: async (_parsed, context) => {
await context.reply(commandName);
},
}),
pluginSlashCommands: [{ name: commandName }],
shutdown,
};
}
it("commits workflow expansion and plugin commands as one workspace snapshot", async () => {
const workspaceA = await createWorkspace("workflow-a");
const workspaceB = await createWorkspace("workflow-b");
const initialService = createInstructionService(workspaceA);
await initialService.start();
const mutableService =
createMutableUserInstructionConfigService(initialService);
const onCommandsChanged = vi.fn();
const resources = createInteractiveWorkspaceResources({
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
userInstructionService: mutableService,
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
createPluginCommands: async ({ cwd }) =>
createPluginResult(cwd === workspaceA ? "plugin-a" : "plugin-b"),
onCommandsChanged,
});
await resources.loadPluginSlashCommands();
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
"Run workflow-a.",
);
expect(resources.getCommandSnapshot().pluginSlashCommands).toEqual([
expect.objectContaining({ name: "plugin-a" }),
]);
const applySessionChange = vi.fn(async () => {});
await resources.changeWorkspace(
{ cwd: workspaceB, workspaceRoot: workspaceB },
applySessionChange,
);
expect(applySessionChange).toHaveBeenCalledOnce();
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
"/workflow-a",
);
expect(mutableService.resolveRuntimeSlashCommand("/workflow-b")).toBe(
"Run workflow-b.",
);
expect(onCommandsChanged).toHaveBeenLastCalledWith({
workflowSlashCommands: expect.arrayContaining([
expect.objectContaining({ name: "workflow-b" }),
]),
pluginSlashCommands: [expect.objectContaining({ name: "plugin-b" })],
});
expect(
onCommandsChanged.mock.calls
.at(-1)?.[0]
.workflowSlashCommands.map((command: { name: string }) => command.name),
).not.toContain("workflow-a");
await resources.dispose();
mutableService.stop();
});
it("keeps the previous workspace active when the agent session transition fails", async () => {
const workspaceA = await createWorkspace("workflow-a");
const workspaceB = await createWorkspace("workflow-b");
const initialService = createInstructionService(workspaceA);
await initialService.start();
const mutableService =
createMutableUserInstructionConfigService(initialService);
const nextPluginShutdown = vi.fn(async () => {});
const resources = createInteractiveWorkspaceResources({
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
userInstructionService: mutableService,
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
createPluginCommands: async () =>
createPluginResult("plugin-b", nextPluginShutdown),
});
await expect(
resources.changeWorkspace(
{ cwd: workspaceB, workspaceRoot: workspaceB },
async () => {
throw new Error("session restart failed");
},
),
).rejects.toThrow("session restart failed");
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
"Run workflow-a.",
);
expect(mutableService.resolveRuntimeSlashCommand("/workflow-b")).toBe(
"/workflow-b",
);
expect(nextPluginShutdown).toHaveBeenCalledOnce();
await resources.dispose();
mutableService.stop();
});
it("rejects incompatible instruction services before changing the agent session", async () => {
const workspaceA = await createWorkspace("workflow-a");
const workspaceB = await createWorkspace("workflow-b");
const initialService = createInstructionService(workspaceA);
await initialService.start();
const mutableService =
createMutableUserInstructionConfigService(initialService);
const incompatibleService = createInstructionService(workspaceB);
incompatibleService.createSkillsExecutor = undefined;
const applySessionChange = vi.fn(async () => {});
const resources = createInteractiveWorkspaceResources({
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
userInstructionService: mutableService,
createUserInstructionService: () => incompatibleService,
createPluginCommands: async () => createPluginResult("plugin-b"),
});
await expect(
resources.changeWorkspace(
{ cwd: workspaceB, workspaceRoot: workspaceB },
applySessionChange,
),
).rejects.toThrow("incompatible skills capability");
expect(applySessionChange).not.toHaveBeenCalled();
expect(mutableService.resolveRuntimeSlashCommand("/workflow-a")).toBe(
"Run workflow-a.",
);
await resources.dispose();
mutableService.stop();
});
it("does not let a stale plugin load replace a newer workspace", async () => {
const workspaceA = await createWorkspace("workflow-a");
const workspaceB = await createWorkspace("workflow-b");
const initialService = createInstructionService(workspaceA);
await initialService.start();
const mutableService =
createMutableUserInstructionConfigService(initialService);
let resolveStaleLoad:
| ((value: WorkspaceChatCommandHostResult) => void)
| undefined;
const staleLoad = new Promise<WorkspaceChatCommandHostResult>((resolve) => {
resolveStaleLoad = resolve;
});
const staleShutdown = vi.fn(async () => {});
const resources = createInteractiveWorkspaceResources({
initialLocation: { cwd: workspaceA, workspaceRoot: workspaceA },
userInstructionService: mutableService,
createUserInstructionService: ({ cwd }) => createInstructionService(cwd),
createPluginCommands: ({ cwd }) =>
cwd === workspaceA
? staleLoad
: Promise.resolve(createPluginResult("plugin-b")),
});
const loadingA = resources.loadPluginSlashCommands();
const changing = resources.changeWorkspace(
{ cwd: workspaceB, workspaceRoot: workspaceB },
async () => {},
);
resolveStaleLoad?.(createPluginResult("plugin-a", staleShutdown));
await Promise.all([loadingA, changing]);
expect(resources.getCommandSnapshot().pluginSlashCommands).toEqual([
expect.objectContaining({ name: "plugin-b" }),
]);
expect(staleShutdown).toHaveBeenCalledOnce();
await resources.dispose();
mutableService.stop();
});
});
@@ -0,0 +1,200 @@
import type { BasicLogger, UserInstructionConfigService } from "@cline/core";
import type { InteractiveSlashCommand } from "../../tui/interactive-welcome";
import { listInteractiveSlashCommands } from "../../tui/interactive-welcome";
import {
type ChatCommandHost,
chatCommandHost,
} from "../../utils/chat-commands";
import type { MutableUserInstructionConfigService } from "../../utils/mutable-user-instruction-service";
import {
createWorkspaceChatCommandHost,
type WorkspaceChatCommandHostResult,
} from "../../utils/plugin-chat-commands";
export interface InteractiveWorkspaceLocation {
cwd: string;
workspaceRoot: string;
}
export interface InteractiveWorkspaceCommandSnapshot {
workflowSlashCommands: InteractiveSlashCommand[];
pluginSlashCommands: InteractiveSlashCommand[];
}
interface WorkspacePluginCommands {
host: ChatCommandHost;
commands: InteractiveSlashCommand[];
shutdown?: () => Promise<void>;
}
function toPluginCommands(
result: WorkspaceChatCommandHostResult,
): WorkspacePluginCommands {
return {
host: result.host,
commands: result.pluginSlashCommands.map((command) => ({
name: command.name,
instructions: "",
description: command.description ?? "Plugin command",
})),
shutdown: result.shutdown,
};
}
export function createInteractiveWorkspaceResources(input: {
initialLocation: InteractiveWorkspaceLocation;
userInstructionService: MutableUserInstructionConfigService;
createUserInstructionService: (
location: InteractiveWorkspaceLocation,
) => UserInstructionConfigService;
logger?: BasicLogger;
createPluginCommands?: (
location: InteractiveWorkspaceLocation,
) => Promise<WorkspaceChatCommandHostResult>;
onCommandsChanged?: (snapshot: InteractiveWorkspaceCommandSnapshot) => void;
}) {
let location = input.initialLocation;
let pluginCommands: WorkspacePluginCommands = {
host: chatCommandHost,
commands: [],
};
let generation = 0;
let disposed = false;
let pluginCommandsLoaded = false;
let pluginLoadPromise: Promise<InteractiveSlashCommand[]> | undefined;
let workspaceChangePromise: Promise<void> | undefined;
const createPluginCommands = async (next: InteractiveWorkspaceLocation) =>
toPluginCommands(
await (input.createPluginCommands
? input.createPluginCommands(next)
: createWorkspaceChatCommandHost({
cwd: next.cwd,
workspaceRoot: next.workspaceRoot,
logger: input.logger,
})),
);
const snapshot = (): InteractiveWorkspaceCommandSnapshot => ({
workflowSlashCommands: listInteractiveSlashCommands(
input.userInstructionService,
),
pluginSlashCommands: pluginCommands.commands,
});
const loadPluginSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => {
if (disposed) {
return [];
}
if (pluginCommandsLoaded) {
return pluginCommands.commands;
}
if (pluginLoadPromise) {
return await pluginLoadPromise;
}
const loadGeneration = generation;
const loadLocation = location;
const load = (async () => {
const loaded = await createPluginCommands(loadLocation);
if (disposed || generation !== loadGeneration) {
await loaded.shutdown?.().catch(() => {});
return pluginCommands.commands;
}
const previous = pluginCommands;
pluginCommands = loaded;
pluginCommandsLoaded = true;
await previous.shutdown?.().catch(() => {});
return loaded.commands;
})();
pluginLoadPromise = load;
try {
return await load;
} finally {
if (pluginLoadPromise === load) {
pluginLoadPromise = undefined;
}
}
};
const applyWorkspaceChange = async (
next: InteractiveWorkspaceLocation,
applySessionChange: () => Promise<void>,
): Promise<void> => {
if (disposed) {
throw new Error("interactive workspace resources are disposed");
}
generation += 1;
const nextService = input.createUserInstructionService(next);
let nextPluginCommands: WorkspacePluginCommands | undefined;
try {
await nextService.start();
input.userInstructionService.assertCompatible(nextService);
nextPluginCommands = await createPluginCommands(next);
await applySessionChange();
} catch (error) {
try {
nextService.stop();
} catch {}
await nextPluginCommands?.shutdown?.().catch(() => {});
throw error;
}
const previousService = input.userInstructionService.replace(nextService);
const previousPluginCommands = pluginCommands;
location = next;
pluginCommands = nextPluginCommands;
pluginCommandsLoaded = true;
// The instruction delegate, plugin host, and TUI catalog become visible as
// one workspace snapshot after the replacement agent session is live.
try {
input.onCommandsChanged?.(snapshot());
} catch (error) {
input.logger?.log("workspace command catalog notification failed", {
error,
});
}
try {
previousService.stop();
} catch {}
await previousPluginCommands.shutdown?.().catch(() => {});
};
const changeWorkspace = (
next: InteractiveWorkspaceLocation,
applySessionChange: () => Promise<void>,
): Promise<void> => {
let change: Promise<void>;
change = (async () => {
await workspaceChangePromise?.catch(() => {});
await applyWorkspaceChange(next, applySessionChange);
})().finally(() => {
if (workspaceChangePromise === change) {
workspaceChangePromise = undefined;
}
});
workspaceChangePromise = change;
return change;
};
const dispose = async (): Promise<void> => {
if (disposed) {
return;
}
disposed = true;
generation += 1;
await workspaceChangePromise?.catch(() => {});
await pluginLoadPromise?.catch(() => {});
await pluginCommands.shutdown?.().catch(() => {});
pluginCommands = { host: chatCommandHost, commands: [] };
pluginCommandsLoaded = false;
};
return {
changeWorkspace,
dispose,
getChatCommandHost: () => pluginCommands.host,
getCommandSnapshot: snapshot,
arePluginCommandsLoaded: () => pluginCommandsLoaded,
loadPluginSlashCommands,
};
}
+20 -1
View File
@@ -51,6 +51,21 @@ describe("buildUserInputMessage", () => {
expect(result.userImages).toEqual([]);
expect(result.userFiles).toEqual([filePath]);
});
it("resolves relative file mentions from the configured working directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "cli-prompt-cwd-"));
const filePath = join(dir, "notes.md");
writeFileSync(filePath, "# Notes\n");
const result = await buildUserInputMessage(
"summarize @./notes.md",
undefined,
{ cwd: dir },
);
expect(result.prompt).toBe("summarize [file: notes.md]");
expect(result.userFiles).toEqual([filePath]);
});
});
describe("resolveSystemPrompt workspace metadata", () => {
@@ -63,7 +78,11 @@ describe("resolveSystemPrompt workspace metadata", () => {
writeFileSync(join(cwd, "README.md"), "test\n");
execFileSync("git", ["add", "README.md"], { cwd });
execFileSync("git", ["commit", "-m", "initial"], { cwd });
execFileSync("git", ["remote", "add", "origin", "https://example.com/cline/repo.git"], { cwd });
execFileSync(
"git",
["remote", "add", "origin", "https://example.com/cline/repo.git"],
{ cwd },
);
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd,
encoding: "utf8",
+7 -4
View File
@@ -74,11 +74,11 @@ function extractFileMentions(
return matches;
}
function resolveMentionPath(filePath: string): string {
function resolveMentionPath(filePath: string, cwd: string): string {
if (filePath.startsWith("~/")) {
return resolve(homedir(), filePath.slice(2));
}
return resolve(filePath);
return resolve(cwd, filePath);
}
/**
@@ -104,7 +104,7 @@ export function shouldExpandSkillSlashCommands(mode?: string): boolean {
export async function buildUserInputMessage(
rawPrompt: string,
userInstructionService?: UserInstructionConfigService,
options?: { mode?: string },
options?: { mode?: string; cwd?: string },
): Promise<{
prompt: string;
userImages: string[];
@@ -154,7 +154,10 @@ export async function buildUserInputMessage(
for (const mention of fileMentions) {
try {
const resolvedPath = resolveMentionPath(mention.path);
const resolvedPath = resolveMentionPath(
mention.path,
options?.cwd ?? process.cwd(),
);
const stats = statSync(resolvedPath);
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${resolvedPath}`);
+1
View File
@@ -279,6 +279,7 @@ export async function runAgent(
userFiles,
} = await buildUserInputMessage(prompt, userInstructionService, {
mode: config.mode,
cwd: config.cwd,
});
const started = await sessionManager.start({
source: SessionSource.CLI,
+137 -58
View File
@@ -1,4 +1,5 @@
import {
createComputerUseToolFromEnv,
getCurrentContextSize,
type ProviderSettings,
ProviderSettingsManager,
@@ -23,7 +24,6 @@ import type {
LoadInteractiveConfigDataOptions,
} from "../tui/interactive-config";
import {
type InteractiveSlashCommand,
listInteractiveSlashCommands,
resolveClineWelcomeLine,
} from "../tui/interactive-welcome";
@@ -36,12 +36,12 @@ import {
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import type { MutableUserInstructionConfigService } from "../utils/mutable-user-instruction-service";
import {
prepareTerminalForPostTuiOutput,
writeErr,
writeln,
} from "../utils/output";
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
import { readRepoStatus } from "../utils/repo-status";
import type { Config } from "../utils/types";
import {
@@ -52,6 +52,7 @@ import {
} from "./active-runtime";
import { createInteractiveApprovalController } from "./interactive/approvals";
import { runInteractiveChatCommand } from "./interactive/chat-command-runner";
import { createInteractiveComputerUser } from "./interactive/computer-user";
import { createInteractiveConfigDataLoader } from "./interactive/config-data";
import {
formatInteractiveExitSummary,
@@ -60,6 +61,7 @@ import {
import { createMistakeLimitDecisionResolver } from "./interactive/mistakes";
import {
type AppliedModeChange,
buildInteractiveExtraTools,
createInteractiveModeSwitchTool,
createModeSwitchNoticeTracker,
type PendingModeChange,
@@ -67,6 +69,11 @@ import {
} from "./interactive/mode";
import { assertInteractivePreflight } from "./interactive/preflight";
import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
import {
createInteractiveWorkspaceResources,
type InteractiveWorkspaceCommandSnapshot,
type InteractiveWorkspaceLocation,
} from "./interactive/workspace-resources";
import { buildUserInputMessage } from "./prompt";
import { getUIEventEmitter } from "./session-events";
@@ -185,55 +192,53 @@ export async function runInteractive(
initialPrompt?: string;
initialNotice?: CliMigrationNotice;
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
explicitSystemPrompt?: string;
mutableUserInstructionService?: MutableUserInstructionConfigService;
createUserInstructionService?: (
location: InteractiveWorkspaceLocation,
) => UserInstructionConfigService;
},
): Promise<void> {
assertInteractivePreflight(config);
const initialRepoStatus = await readRepoStatus(config.cwd);
const workflowSlashCommands = listInteractiveSlashCommands(
userInstructionService,
);
let interactiveChatCommandHost = chatCommandHost;
let pluginChatCommandHostLoaded = false;
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
const mutableUserInstructionService = options?.mutableUserInstructionService;
const createUserInstructionService = options?.createUserInstructionService;
const activeUserInstructionService =
mutableUserInstructionService ?? userInstructionService;
if (
(mutableUserInstructionService === undefined) !==
(createUserInstructionService === undefined)
) {
throw new Error(
"interactive workspace resources require both the mutable instruction service and its factory",
);
}
let workspaceCommandNotifier:
| ((snapshot: InteractiveWorkspaceCommandSnapshot) => void)
| undefined;
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService,
});
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
if (pluginChatCommandHostLoaded) {
return pluginChatSlashCommands;
}
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
})
.then(({ host, pluginSlashCommands, shutdown }) => {
interactiveChatCommandHost = host;
pluginChatCommandHostShutdown = shutdown;
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
name: cmd.name,
instructions: "",
description: cmd.description ?? "Plugin command",
}));
return pluginChatSlashCommands;
})
.finally(() => {
pluginChatCommandHostLoaded = true;
pluginChatCommandHostPromise = undefined;
});
return await pluginChatCommandHostPromise;
const workspaceResources =
mutableUserInstructionService && createUserInstructionService
? createInteractiveWorkspaceResources({
initialLocation: {
cwd: config.cwd,
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
},
userInstructionService: mutableUserInstructionService,
createUserInstructionService,
logger: config.logger,
onCommandsChanged: (snapshot) => workspaceCommandNotifier?.(snapshot),
})
: undefined;
const initialCommandSnapshot = workspaceResources?.getCommandSnapshot() ?? {
workflowSlashCommands: listInteractiveSlashCommands(
activeUserInstructionService,
),
pluginSlashCommands: [],
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
const loadAdditionalSlashCommands = workspaceResources
? workspaceResources.loadPluginSlashCommands
: undefined;
const shouldTryPluginChatCommands = (prompt: string): boolean => {
return prompt.trimStart().startsWith("/");
};
@@ -262,7 +267,47 @@ export async function runInteractive(
tuiModeChanged,
});
config.extraTools = config.mode === "plan" ? [switchToActModeTool] : [];
const providerSettingsManager = new ProviderSettingsManager();
// Computer-use support, enabled when CLINE_COMPUTER_USE_PORT points at a
// running backend. Preferred shape: the asynchronous computer user (a
// dedicated Anthropic helper session behind computer_user_* tools). When
// the Anthropic provider is not configured, fall back to giving the
// driver the raw `computer` tool directly.
//
// notifyDriver closes over sessionRuntime (declared below) but only runs
// after a driver turn has started, long after initialization. It resolves
// the driver session id at call time, so session rebuilds are safe.
const computerUser = await createInteractiveComputerUser({
config,
providerSettingsManager,
notifyDriver: (prompt, delivery) => {
void sessionRuntime
.sendCurrentTurn({ prompt, delivery })
.catch((error) => {
logCliError(
config.logger,
"Computer-user driver notification failed",
{
error,
},
);
});
},
});
const computerUseTool = computerUser
? undefined
: await createComputerUseToolFromEnv();
const persistentExtraTools = [
...(computerUser ? computerUser.driverTools : []),
...(computerUseTool ? [computerUseTool] : []),
];
config.extraTools = buildInteractiveExtraTools({
mode: config.mode === "plan" ? "plan" : "act",
switchToActModeTool,
persistentExtraTools,
});
const uiEvents = getUIEventEmitter();
const chatCommandState: ChatCommandState = {
@@ -275,13 +320,13 @@ export async function runInteractive(
autoApproveAllRef,
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
providerSettingsManager,
userInstructionService,
userInstructionService: activeUserInstructionService,
explicitSystemPrompt: options?.explicitSystemPrompt,
resumeSessionId,
chatCommandState,
requestToolApproval,
@@ -289,6 +334,11 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
resolveMistakeLimitDecision,
switchToActModeTool,
persistentExtraTools,
// Record the driver's transcript to the computer-use backend's
// journal so the observatory can show it beside the computer user's
// transcript and screenshots.
extraAgentHooks: computerUser?.driverRecordingHooks,
onAgentEvent: (event) => {
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
},
@@ -302,6 +352,26 @@ export async function runInteractive(
uiEvents.emit("pending-prompt-submitted", event);
},
});
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService: activeUserInstructionService,
loadCoreSettings: sessionRuntime.listCoreSettings,
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
});
const changeInteractiveWorkingDirectory = async (
next: ChatCommandState,
): Promise<void> => {
const applySessionChange = () =>
sessionRuntime.changeWorkingDirectory(next);
if (!workspaceResources) {
await applySessionChange();
return;
}
await workspaceResources.changeWorkspace(
{ cwd: next.cwd, workspaceRoot: next.workspaceRoot },
applySessionChange,
);
};
let modeChangePromise: Promise<void> | undefined;
let modeChangeTarget: "plan" | "act" | undefined;
const modeSwitchNotice = createModeSwitchNoticeTracker();
@@ -383,11 +453,8 @@ export async function runInteractive(
try {
exitSummary = await sessionRuntime.cleanup();
} finally {
await pluginChatCommandHostPromise?.catch(() => []);
await pluginChatCommandHostShutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
pluginChatCommandHostShutdown = undefined;
await computerUser?.dispose().catch(() => {});
await workspaceResources?.dispose();
setActiveRuntimeAbort(undefined);
setActiveRuntimeCleanup(undefined);
}
@@ -521,7 +588,7 @@ export async function runInteractive(
onInitialNoticeShown: options?.onInitialNoticeShown,
loadDeferredInitialMessages,
initialRepoStatus,
workflowSlashCommands,
workflowSlashCommands: initialCommandSnapshot.workflowSlashCommands,
loadAdditionalSlashCommands,
loadWelcomeLine: async () =>
await resolveClineWelcomeLine({
@@ -580,12 +647,14 @@ export async function runInteractive(
let chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
delivery,
config,
host: interactiveChatCommandHost,
host: workspaceResources?.getChatCommandHost() ?? chatCommandHost,
chatCommandState,
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime,
changeWorkingDirectory: changeInteractiveWorkingDirectory,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
@@ -594,18 +663,21 @@ export async function runInteractive(
}
if (
shouldTryPluginChatCommands(input) &&
!pluginChatCommandHostLoaded
workspaceResources &&
!workspaceResources.arePluginCommandsLoaded()
) {
await ensurePluginChatCommandHost();
await workspaceResources.loadPluginSlashCommands();
chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
delivery,
config,
host: interactiveChatCommandHost,
host: workspaceResources.getChatCommandHost(),
chatCommandState,
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime,
changeWorkingDirectory: changeInteractiveWorkingDirectory,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
@@ -621,8 +693,9 @@ export async function runInteractive(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(input, userInstructionService, {
} = await buildUserInputMessage(input, activeUserInstructionService, {
mode,
cwd: config.cwd,
});
const mergedUserImages = [
...(attachments?.userImages ?? []),
@@ -858,6 +931,12 @@ export async function runInteractive(
setModeChangeNotifier: (fn) => {
tuiModeChanged.current = fn;
},
setWorkspaceCommandNotifier: (fn) => {
workspaceCommandNotifier = fn ?? undefined;
if (fn && workspaceResources) {
fn(workspaceResources.getCommandSnapshot());
}
},
});
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
+1
View File
@@ -82,6 +82,7 @@ export async function runZen(
// Zen runs in yolo mode, whose preset has no skills tool — skill
// commands must keep expanding textually.
mode: "yolo",
cwd: config.cwd,
});
const startRequest: ChatStartSessionRequest = {
@@ -316,4 +316,26 @@ describe("slash command registry", () => {
getVisibleSystemSlashCommands(registry).map((command) => command.name),
).toContain("account");
});
it("exposes cd as a runtime command", () => {
const registry = buildSlashCommandRegistry({
workflowSlashCommands: [
{
name: "cd",
instructions: "/cd <directory>",
description: "Change the working directory",
},
],
});
expect(resolveSlashCommand(registry, "cd")).toMatchObject({
source: "runtime",
execution: "runtime",
visible: true,
selectable: true,
});
expect(
getVisibleSystemSlashCommands(registry).map((command) => command.name),
).toContain("cd");
});
});
@@ -116,6 +116,7 @@ const TUI_LOCAL_COMMANDS: Array<{
const SYSTEM_COMMAND_ORDER = [
"settings",
"cd",
"model",
"theme",
"account",
@@ -130,7 +131,7 @@ const SYSTEM_COMMAND_ORDER = [
"history",
"help",
"quit",
] satisfies ReadonlyArray<LocalSlashCommandName | "team">;
] satisfies ReadonlyArray<LocalSlashCommandName | "cd" | "team">;
const SYSTEM_COMMAND_PRIORITY = new Map<string, number>(
SYSTEM_COMMAND_ORDER.map((name, index) => [name, index]),
+8 -1
View File
@@ -657,11 +657,18 @@ export function ChatEntryView(props: {
* token identity, so settled content never re-renders.
* tableOptions preserves the bordered table style that coalesced
* mode used by default (top-level defaults to borderless columns).
*
* streaming stays true even after the entry settles: flipping the
* prop makes MarkdownRenderable rebuild every block from scratch
* (updateBlocks(true) skips all reuse paths), so the finished
* message flashes back to unhighlighted text while tree-sitter
* re-highlights. opencode's TUI keeps streaming={true} for the
* same reason. entry.streaming still drives the spinner glyph.
*/}
<markdown
content={content}
syntaxStyle={getSyntaxStyle(theme, mode)}
streaming={entry.streaming}
streaming={true}
internalBlockMode="top-level"
tableOptions={{ style: "grid" }}
fg={defaultFg}
@@ -47,7 +47,10 @@ export function shouldCloseExtDetailForKey(keyName: string): boolean {
export function shouldToggleExtDetailForKey(
keyName: string,
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
>,
): boolean {
return (
keyName === "space" &&
@@ -57,7 +60,10 @@ export function shouldToggleExtDetailForKey(
}
export function getExtDetailFooterText(
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
>,
): string {
return typeof item.enabled === "boolean" &&
isToggleableInteractiveConfigItem(item)
@@ -133,6 +133,12 @@ const HELP_ROWS: HelpRow[] = [
key: "/theme",
desc: "Change color theme",
},
{
kind: "entry",
id: "c-cd",
key: "/cd <directory>",
desc: "Change the working directory",
},
{
kind: "entry",
id: "c-mcp",
@@ -14,6 +14,27 @@ export function resolveHubUpdateRequiredKeyAction(
return "ignore";
}
/**
* Human phrase for the live work an outdated Hub is serving, used by the
* "Hub update required" dialog. Falls back to an unquantified phrase when the
* Hub could not answer the activity query.
*/
export function describeOutdatedHubSessions(counts: {
activeSessionCount?: number;
participantClientCount?: number;
}): string {
const sessions = counts.activeSessionCount;
if (typeof sessions !== "number" || sessions <= 0) {
return "active sessions from other Cline clients";
}
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
const clients = counts.participantClientCount;
if (typeof clients !== "number" || clients <= 0) {
return sessionsPhrase;
}
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
}
/**
* Yolo and sandbox sessions force the local backend and never attach to the
* shared managed Hub (see the forceLocalBackend condition in the interactive
@@ -2,12 +2,21 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useDialogPalette } from "../../hooks/use-theme";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
import {
describeOutdatedHubSessions,
resolveHubUpdateRequiredKeyAction,
} from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
hubCoreVersion?: string;
}
/**
* Shown only for `unsupported_protocol`: the running Hub speaks a protocol
* this CLI cannot, so nothing hub-backed works until the CLI updates. The
* softer `build_mismatch` case (newer Hub, compatible protocol) is a toast
* in root.tsx, not this modal.
*/
export function HubUpdateRequiredContent(
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
) {
@@ -29,13 +38,12 @@ export function HubUpdateRequiredContent(
<text fg="yellow">Cline Hub was updated</text>
<box flexDirection="column">
<text selectable>
Another Cline installation restarted the shared Cline Hub
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
matches this CLI.
Another Cline installation updated the shared Cline Hub
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""} to a version this
CLI cannot talk to.
</text>
<text selectable>
Update and restart Cline so this CLI and the Hub run the same version
again.
Update and restart Cline to reconnect to the running Hub.
</text>
</box>
<box flexDirection="row">
@@ -49,3 +57,64 @@ export function HubUpdateRequiredContent(
</box>
);
}
export interface HubOutdatedDetails {
hubCoreVersion?: string;
activeSessionCount?: number;
participantClientCount?: number;
}
/**
* Shown when this CLI is the newer build and the shared Hub was left running
* an older one because it is still serving other clients' sessions. Enter
* replaces the Hub now (interrupting that work); Esc keeps it running.
*/
export function HubOutdatedContent(
props: ChoiceContext<boolean> & HubOutdatedDetails,
) {
const {
activeSessionCount,
dialogId,
dismiss,
participantClientCount,
resolve,
} = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
const action = resolveHubUpdateRequiredKeyAction(key);
if (action === "ignore") return;
if (action === "update") {
resolve(true);
return;
}
dismiss();
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="yellow">Cline Hub update required</text>
<box flexDirection="column">
<text selectable>
This CLI needs a newer Cline Hub, but the running one is still serving{" "}
{describeOutdatedHubSessions({
activeSessionCount,
participantClientCount,
})}
.
</text>
<text selectable>
Updating stops that Hub and interrupts its sessions.
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Update Now</text>
</box>
</box>
<text fg={palette.muted}>
Press Enter to update now, Esc to keep the Hub running
</text>
</box>
);
}
@@ -1,5 +1,9 @@
import type { AgentMode } from "@cline/core";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RuntimeToolInteraction, TuiProps } from "../types";
@@ -36,16 +40,16 @@ function toRuntimeToolInteraction(
};
}
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
function deniedToolResult(): ToolApprovalResult {
return {
approved: false,
reason: `Tool "${request.toolName}" was denied by user`,
reason: USER_REJECTED_TOOL_REASON,
};
}
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
if (pending.kind === "tool_approval") {
pending.resolve(deniedToolResult(pending.request));
pending.resolve(deniedToolResult());
return;
}
pending.resolve("[User dismissed the question]");
@@ -111,9 +115,7 @@ export function useRuntimeDialogBridge(input: {
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
return;
}
pending.resolve(
approved ? { approved: true } : deniedToolResult(pending.request),
);
pending.resolve(approved ? { approved: true } : deniedToolResult());
const hasNext = finishActive(id);
if (!hasNext) {
refocusTextarea();
+7 -1
View File
@@ -60,5 +60,11 @@ export function useSlashCommands(input: {
[registry],
);
return { registry, systemCommands, skillCommands, invokableSkillCommands };
return {
registry,
systemCommands,
skillCommands,
invokableSkillCommands,
setAdditionalSlashCommands,
};
}
+72 -8
View File
@@ -9,6 +9,8 @@ import {
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
type CoreSettingsItem,
type CoreSettingsSnapshot,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
discoverPluginModulePaths,
getPluginDisplayName,
@@ -80,6 +82,12 @@ export interface InteractiveConfigItem {
| "global-plugin"
| "workspace-plugin";
description?: string;
/** True when the hub discovered this through agent-plugins.org. */
agentPlugin?: boolean;
/** Explicitly overrides the default toggle policy for this item. */
toggleable?: boolean;
/** Explicitly overrides the default delete policy for this item. */
deletable?: boolean;
}
export interface InteractiveConfigData {
@@ -100,8 +108,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
item: Pick<
InteractiveConfigItem,
"kind" | "source" | "pluginName" | "toggleable"
>,
): boolean {
if (item.toggleable !== undefined) {
return item.toggleable;
}
if (item.kind === "mcp") {
return !item.pluginName;
}
@@ -324,12 +338,53 @@ export function applyPluginFailures(
}
}
function toAgentPluginInteractiveItem(
item: CoreSettingsItem,
): InteractiveConfigItem {
return {
id: item.id,
name: item.name,
path: item.path,
enabled: item.enabled,
kind: item.kind,
source: item.source,
description: item.description,
pluginName: item.pluginName,
pluginPath: item.pluginPath,
loadError: item.loadError,
agentPlugin: true,
toggleable: item.toggleable ?? false,
deletable: false,
...(item.kind === "plugin" ? { configKind: "plugin" as const } : {}),
};
}
function appendAgentPluginSnapshotItems(
target: InteractiveConfigItem[],
items: readonly CoreSettingsItem[],
): void {
const existing = new Set(
target.map((item) => `${item.kind}\0${item.id}\0${item.path}`),
);
for (const item of items) {
if (item.agentPlugin !== true) {
continue;
}
const key = `${item.kind}\0${item.id}\0${item.path}`;
if (!existing.has(key)) {
target.push(toAgentPluginInteractiveItem(item));
existing.add(key);
}
}
}
export async function loadInteractiveConfigData(input: {
userInstructionService?: UserInstructionConfigService;
cwd: string;
workspaceRoot: string;
availabilityContext?: BuiltinToolAvailabilityContext;
includePluginTools?: boolean;
agentPluginSettings?: CoreSettingsSnapshot;
}): Promise<InteractiveConfigData> {
const workflows: InteractiveConfigItem[] = [];
const rules: InteractiveConfigItem[] = [];
@@ -518,14 +573,23 @@ export async function loadInteractiveConfigData(input: {
}
}
if (input.agentPluginSettings) {
appendAgentPluginSnapshotItems(plugins, input.agentPluginSettings.plugins);
appendAgentPluginSnapshotItems(skills, input.agentPluginSettings.skills);
appendAgentPluginSnapshotItems(mcp, input.agentPluginSettings.mcp);
}
const existsLocallyOrComesFromHub = (item: InteractiveConfigItem) =>
item.agentPlugin === true || existsSync(item.path);
return {
workflows: toSorted(workflows.filter((item) => existsSync(item.path))),
rules: toSorted(rules.filter((item) => existsSync(item.path))),
skills: toSorted(skills.filter((item) => existsSync(item.path))),
hooks: toSorted(hooks.filter((item) => existsSync(item.path))),
agents: toSorted(agents.filter((item) => existsSync(item.path))),
plugins: toSorted(plugins.filter((item) => existsSync(item.path))),
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
workflows: toSorted(workflows.filter(existsLocallyOrComesFromHub)),
rules: toSorted(rules.filter(existsLocallyOrComesFromHub)),
skills: toSorted(skills.filter(existsLocallyOrComesFromHub)),
hooks: toSorted(hooks.filter(existsLocallyOrComesFromHub)),
agents: toSorted(agents.filter(existsLocallyOrComesFromHub)),
plugins: toSorted(plugins.filter(existsLocallyOrComesFromHub)),
mcp: toSorted(mcp.filter(existsLocallyOrComesFromHub)),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
+5
View File
@@ -123,6 +123,11 @@ export function listInteractiveSlashCommands(
instructions: "",
description: "Modify agent configuration",
},
{
name: "cd",
instructions: "/cd <directory>",
description: "Change the working directory",
},
{
name: "mcp",
instructions: "",
+91 -10
View File
@@ -2,6 +2,7 @@ import {
getCurrentContextSize,
type ManagedHubBuildMismatchEvent,
summarizeUsageFromMessages,
upgradeManagedHub,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { formatDisplayUserInput } from "@cline/shared";
@@ -40,7 +41,10 @@ import {
buildCommandPaletteItems,
findCommandPaletteShortcut,
} from "./components/dialogs/command-palette-items";
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
import {
HubOutdatedContent,
HubUpdateRequiredContent,
} from "./components/dialogs/hub-update-required";
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
import {
SKILLS_MARKETPLACE_ACTION,
@@ -136,12 +140,21 @@ function App(props: TuiProps) {
systemCommands,
skillCommands,
invokableSkillCommands,
setAdditionalSlashCommands,
} = useSlashCommands({
workflowSlashCommands,
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
canFork: canForkSession,
});
useEffect(() => {
props.setWorkspaceCommandNotifier((snapshot) => {
setWorkflowSlashCommands(snapshot.workflowSlashCommands);
setAdditionalSlashCommands(snapshot.pluginSlashCommands);
});
return () => props.setWorkspaceCommandNotifier(null);
}, [props.setWorkspaceCommandNotifier, setAdditionalSlashCommands]);
const autocomplete = useAutocomplete({
workspaceRoot,
systemCommands,
@@ -586,17 +599,85 @@ function App(props: TuiProps) {
setHubBuildMismatch(null);
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
if (hubBuildMismatch.reason === "outdated_hub") {
// This CLI is already the newer build. The Hub is behind only because
// retiring it would kill the sessions it is serving, and it is
// replaced on its own at the next launch. Nothing is wrong, nothing is
// asked, and nothing the user can act on differs - so say nothing, the
// same conclusion the desktop surface reached.
//
// The classification still earns its keep here: it is what stops the
// update-and-restart prompt below from firing at someone who has
// nothing to update.
// This CLI is already the newer build; the Hub is behind only because
// retiring it would kill the sessions it is serving. Left alone it
// would stay behind for as long as those sessions run, so put the
// choice to the user: replace it now (interrupting that work), or
// keep it running and update later. This session itself is safe
// either way - a CLI that could not attach to the outdated Hub is
// running on the local backend.
const details = {
hubCoreVersion,
activeSessionCount: hubBuildMismatch.activeSessionCount,
participantClientCount: hubBuildMismatch.participantClientCount,
};
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
<HubOutdatedContent {...ctx} {...details} />
),
})
.then(async (update) => {
if (!update) {
// choice() resolves undefined on Esc; it does not reject.
showToast(
"The running Cline Hub stays on the older version. Run 'cline hub upgrade' once its sessions finish.",
"info",
);
refocusTextareaRef.current();
return;
}
showToast("Updating the Cline Hub…", "info");
try {
const result = await upgradeManagedHub({
force: true,
reason: "cline TUI hub update",
});
if (result.outcome === "still_busy") {
showToast(
"The Hub picked up new sessions before it could be replaced. Try again in a moment.",
"info",
);
} else {
showToast(
result.outcome === "replaced" || result.outcome === "started"
? "Cline Hub updated."
: "Cline Hub is already up to date.",
"success",
);
}
} catch (error) {
showToast(
error instanceof Error && error.message
? error.message
: "Updating the Cline Hub failed. Run 'cline doctor fix' and try again.",
"error",
);
}
refocusTextareaRef.current();
})
.catch(() => {
refocusTextareaRef.current();
});
return;
}
if (hubBuildMismatch.reason === "build_mismatch") {
// The Hub is newer but still speaks this CLI's protocol, so the
// session keeps working and parity is advisable rather than urgent.
// A modal mid-session is too heavy for advice; a toast (once per
// observed Hub build, the watcher dedupes) says what changed and
// how to catch up without stealing focus.
showToast(
`The shared Cline Hub was updated${
hubCoreVersion ? ` (core ${hubCoreVersion})` : ""
}. Run 'cline update' and restart when convenient.`,
"info",
);
return;
}
// unsupported_protocol: this CLI cannot speak the running Hub's
// protocol at all, so nothing hub-backed can work until it updates.
// That is worth a blocking prompt.
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
+8
View File
@@ -246,6 +246,14 @@ export interface TuiProps {
handler: ((question: string, options: string[]) => Promise<string>) | null,
) => void;
setModeChangeNotifier: (handler: ((mode: AgentMode) => void) | null) => void;
setWorkspaceCommandNotifier: (
handler:
| ((snapshot: {
workflowSlashCommands: InteractiveSlashCommand[];
pluginSlashCommands: InteractiveSlashCommand[];
}) => void)
| null,
) => void;
}
export type InlineStream = "text" | "reasoning" | undefined;
@@ -1,5 +1,4 @@
import { ProviderSettingsManager } from "@cline/core";
import { isProviderSettingsUsable } from "../../utils/provider-readiness";
import { isProviderSettingsUsable, ProviderSettingsManager } from "@cline/core";
import type { TuiProps } from "../types";
export function isProviderConfigured(config: TuiProps["config"]): boolean {
+50 -1
View File
@@ -128,12 +128,61 @@ export function resolveActiveConfigItems(
}
}
export interface ConfigPluginSection {
label: string;
items: InteractiveConfigItem[];
}
export function getConfigPluginSections(
items: readonly InteractiveConfigItem[],
): ConfigPluginSection[] {
const clinePlugins = items.filter((item) => item.agentPlugin !== true);
const agentPlugins = items.filter((item) => item.agentPlugin === true);
return [
...(clinePlugins.length > 0
? [
{
label: `Cline Plugins (${clinePlugins.length})`,
items: clinePlugins,
},
]
: []),
...(agentPlugins.length > 0
? [
{
label: `Agent Plugins (${agentPlugins.length})`,
items: agentPlugins,
},
]
: []),
];
}
export function getConfigTabCountHeading(
tab: InteractiveConfigTab,
itemCount: number,
): string | undefined {
return tab === "plugins" ? undefined : `${toTabLabel(tab)} (${itemCount})`;
}
export function shouldRenderConfigItemAsEnabled(
item: InteractiveConfigItem,
enabledState: "enabled" | "disabled" | "partial",
): boolean {
return (
enabledState === "enabled" &&
(isToggleableInteractiveConfigItem(item) || item.agentPlugin === true)
);
}
export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
return isToggleableInteractiveConfigItem(item);
}
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
return item.kind === "plugin";
return (
item.deletable ?? (item.kind === "plugin" && item.agentPlugin !== true)
);
}
export function resolveConfigItemSelectAction(
@@ -5,11 +5,16 @@ import {
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigPluginSections,
getConfigTabCountHeading,
isDeletableConfigItem,
isInlineConfigAction,
isToggleableConfigItem,
resolveConfigItemDeleteAction,
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
shouldRenderConfigItemAsEnabled,
} from "./config-view-helpers";
function createItem(
@@ -71,6 +76,65 @@ describe("config view helpers", () => {
).toBe(false);
});
it("lets users toggle hub-discovered Agent Plugins without deleting them", () => {
const plugin = createItem({
kind: "plugin",
agentPlugin: true,
toggleable: true,
deletable: false,
source: "global-plugin",
});
expect(isToggleableConfigItem(plugin)).toBe(true);
expect(isDeletableConfigItem(plugin)).toBe(false);
expect(resolveConfigItemToggleAction(plugin)).toEqual({
kind: "toggle-item",
item: plugin,
});
expect(resolveConfigItemDeleteAction(plugin)).toBeUndefined();
expect(resolveConfigItemSelectAction(plugin)).toEqual({
kind: "toggle-item",
item: plugin,
});
});
it("separates Cline and Agent Plugins into labeled sections", () => {
const clinePlugin = createItem({
kind: "plugin",
name: "cline-plugin",
source: "workspace-plugin",
});
const agentPlugin = createItem({
kind: "plugin",
name: "portable-plugin",
source: "global-plugin",
agentPlugin: true,
});
expect(getConfigPluginSections([clinePlugin, agentPlugin])).toEqual([
{ label: "Cline Plugins (1)", items: [clinePlugin] },
{ label: "Agent Plugins (1)", items: [agentPlugin] },
]);
});
it("uses section counts instead of a combined Plugins heading", () => {
expect(getConfigTabCountHeading("plugins", 10)).toBeUndefined();
expect(getConfigTabCountHeading("skills", 20)).toBe("Skills (20)");
});
it("renders a loaded Agent Plugin as enabled", () => {
const agentPlugin = createItem({
kind: "plugin",
agentPlugin: true,
toggleable: true,
});
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "enabled")).toBe(true);
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "disabled")).toBe(
false,
);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+36 -19
View File
@@ -25,6 +25,8 @@ import {
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigPluginSections,
getConfigTabCountHeading,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
@@ -34,6 +36,7 @@ import {
resolveConfigItemSelectAction,
resolveConfigItemToggleAction,
resolveInitialConfigTab,
shouldRenderConfigItemAsEnabled,
toTabLabel,
} from "./config-view-helpers";
@@ -298,6 +301,16 @@ function appendSkillRows(
}
}
function appendPluginRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
): void {
for (const section of getConfigPluginSections(items)) {
rows.push({ kind: "head", label: section.label });
appendExtRows(rows, section.items);
}
}
function withOptimisticToggle(
data: InteractiveConfigData,
item: InteractiveConfigItem,
@@ -477,10 +490,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
} else {
const activeItems = resolveActiveConfigItems(configData, activeTab);
r.push({
kind: "head",
label: `${toTabLabel(activeTab)} (${activeItems.length})`,
});
const countHeading = getConfigTabCountHeading(
activeTab,
activeItems.length,
);
if (countHeading) {
r.push({ kind: "head", label: countHeading });
}
if (activeItems.length === 0 && !pluginToolsLoading) {
r.push({
@@ -504,6 +520,21 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
} else if (activeTab === "skills") {
appendSkillRows(r, activeItems);
} else if (activeTab === "plugins") {
appendPluginRows(r, activeItems);
if (pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
} else {
for (const item of activeItems) {
r.push({
@@ -523,19 +554,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: getPluginLoadErrorLabel(item),
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
}
if (activeTab === "mcp") {
@@ -871,11 +889,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: "○ "
: "";
const rightLabel = row.rightLabel ?? "";
const toggleable = isToggleableConfigItem(row.item);
const prefix = " ".repeat(row.indent ?? 0);
const rowColor = row.item.loadError
? "red"
: toggleable && enabledState === "enabled"
: shouldRenderConfigItemAsEnabled(row.item, enabledState)
? palette.success
: enabledState === "partial"
? "yellow"
+6 -2
View File
@@ -1,5 +1,9 @@
import { createInterface } from "node:readline";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import { truncate } from "./helpers";
import { c, getActiveCliSession, write } from "./output";
@@ -91,7 +95,7 @@ async function requestTerminalToolApproval(
}
return {
approved: false,
reason: `Tool "${request.toolName}" was denied by user`,
reason: USER_REJECTED_TOOL_REASON,
};
}
+62
View File
@@ -1,3 +1,6 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
createChatCommandHost,
@@ -199,6 +202,65 @@ describe("chat commands", () => {
expect(reply).toHaveBeenCalledWith("hello world");
});
it("changes directories with both /cd and the existing /cwd spelling", async () => {
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-"));
const target = join(root, "project with spaces");
mkdirSync(target);
for (const command of [
`/cd "project with spaces"`,
`/cwd "project with spaces"`,
]) {
const state = {
enableTools: true,
autoApproveTools: false,
cwd: root,
workspaceRoot: root,
};
const setState = vi.fn(async (next) => Object.assign(state, next));
const reply = vi.fn(async () => undefined);
expect(
await maybeHandleChatCommand(command, {
enabled: true,
getState: () => state,
setState,
reply,
}),
).toBe(true);
expect(state.cwd).toBe(target);
expect(setState).toHaveBeenCalledOnce();
expect(reply).toHaveBeenCalledWith(
expect.stringContaining(`cwd=${target}`),
);
}
});
it("leaves the working directory unchanged when /cd is not a directory", async () => {
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-invalid-"));
writeFileSync(join(root, "file.txt"), "not a directory");
const setState = vi.fn(async () => undefined);
const reply = vi.fn(async () => undefined);
expect(
await maybeHandleChatCommand("/cd file.txt", {
enabled: true,
getState: () => ({
enableTools: true,
autoApproveTools: false,
cwd: root,
workspaceRoot: root,
}),
setState,
reply,
}),
).toBe(true);
expect(setState).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith(
`invalid directory: ${join(root, "file.txt")}`,
);
});
it("shows usage for /team with no arguments", async () => {
const reply = vi.fn(async () => undefined);
+20 -3
View File
@@ -1,4 +1,5 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { resolveWorkspaceRoot } from "./helpers";
@@ -216,6 +217,22 @@ function tokenizeArgs(input: string): string[] {
return tokens;
}
function resolveChatCommandDirectory(cwd: string, args: string[]): string {
const rawPath = args.join(" ").trim();
const unquotedPath =
(rawPath.startsWith('"') && rawPath.endsWith('"')) ||
(rawPath.startsWith("'") && rawPath.endsWith("'"))
? rawPath.slice(1, -1)
: rawPath;
if (unquotedPath === "~") {
return homedir();
}
if (unquotedPath.startsWith("~/")) {
return resolve(homedir(), unquotedPath.slice(2));
}
return resolve(cwd, unquotedPath);
}
function parseFlagValues(tokens: string[]): {
positionals: string[];
flags: Record<string, string>;
@@ -282,7 +299,7 @@ function formatHelp(state: ChatCommandState): string {
"/whereami - show thread, cwd, tools, and yolo state",
"/tools [on|off|toggle] - allow repo/file/shell tools",
"/yolo [on|off|toggle] - auto-approve tool use",
"/cwd <path> - change working directory",
"/cd <path> (or /cwd <path>) - change working directory",
"/schedule create/list/trigger/delete - manage scheduled workflows",
"/abort - stop the current task",
"/mute [target] - ignore this thread or target until /unmute",
@@ -398,7 +415,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
},
})
.register("command", {
names: ["/cwd"],
names: ["/cd", "/cwd"],
run: async ({ args, state }, context) => {
const rawPath = args.join(" ").trim();
if (!rawPath) {
@@ -407,7 +424,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
);
return;
}
const nextCwd = resolve(state.cwd, rawPath);
const nextCwd = resolveChatCommandDirectory(state.cwd, args);
const fileStat = await stat(nextCwd).catch(() => undefined);
if (!fileStat?.isDirectory()) {
await context.reply(`invalid directory: ${nextCwd}`);
@@ -0,0 +1,56 @@
import type { UserInstructionConfigService } from "@cline/core";
export interface MutableUserInstructionConfigService
extends UserInstructionConfigService {
assertCompatible(next: UserInstructionConfigService): void;
replace(next: UserInstructionConfigService): UserInstructionConfigService;
}
export function createMutableUserInstructionConfigService(
initial: UserInstructionConfigService,
): MutableUserInstructionConfigService {
let current = initial;
const hasSkillsExecutor = typeof initial.createSkillsExecutor === "function";
const assertCompatible = (next: UserInstructionConfigService): void => {
if (
(typeof next.createSkillsExecutor === "function") !==
hasSkillsExecutor
) {
throw new Error(
"Replacement instruction service has incompatible skills capability",
);
}
};
const service: UserInstructionConfigService = {
start: () => current.start(),
stop: () => current.stop(),
refreshType: (type) => current.refreshType(type),
listRecords: (type) => current.listRecords(type),
listRuntimeCommands: () => current.listRuntimeCommands(),
resolveRuntimeSlashCommand: (input) =>
current.resolveRuntimeSlashCommand(input),
hasConfiguredSkills: (allowedSkillNames) =>
current.hasConfiguredSkills(allowedSkillNames),
createExtension: (options) => current.createExtension(options),
};
if (hasSkillsExecutor) {
service.createSkillsExecutor = (allowedSkillNames) => {
if (!current.createSkillsExecutor) {
throw new Error(
"Replacement instruction service has no skills executor",
);
}
return current.createSkillsExecutor(allowedSkillNames);
};
}
return {
...service,
assertCompatible,
replace: (next) => {
assertCompatible(next);
const previous = current;
current = next;
return previous;
},
};
}
@@ -264,6 +264,19 @@ export async function handleDesktopCommand(
) {
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
}
if (command === "search_sessions") {
if (!ctx.uiClient) throw new Error("Hub is not connected");
const query = String(args?.query ?? "").trim();
if (!query) return [];
return await ctx.uiClient.searchSessions({
query,
limit: typeof args?.limit === "number" ? args.limit : 50,
workspaceRoot:
typeof args?.workspaceRoot === "string"
? args.workspaceRoot
: undefined,
});
}
if (command === "read_session_hooks") {
return [];
}
+1 -1
View File
@@ -29,7 +29,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "11.16.0",
"mermaid": "11.16.1",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
+87
View File
@@ -15,6 +15,7 @@ import {
PlugIcon,
RotateCcwIcon,
RssIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
Trash2Icon,
@@ -63,6 +64,7 @@ import type {
import { PageFrame, PageHeader } from "./components/views/page-layout";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import { desktopClient } from "./lib/desktop-client";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
@@ -740,7 +742,18 @@ function SessionsView({
onRenameSession: (sessionId: string, title: string) => Promise<void> | void;
sessions: WebviewSessionSummary[];
}) {
type SearchHit = {
sessionId: string;
documentId: string;
title: string;
workspaceRoot: string;
role: string;
snippet: string;
};
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searching, setSearching] = useState(false);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [deleteSessionCandidate, setDeleteSessionCandidate] =
@@ -772,6 +785,34 @@ function SessionsView({
});
}, [sessions, sessionFilters, sortDirection]);
useEffect(() => {
const query = searchQuery.trim();
if (!query) {
setSearchHits([]);
setSearching(false);
return;
}
let cancelled = false;
setSearching(true);
const timer = setTimeout(() => {
void desktopClient
.invoke<SearchHit[]>("search_sessions", { query, limit: 50 })
.then((hits) => {
if (!cancelled) setSearchHits(hits);
})
.catch(() => {
if (!cancelled) setSearchHits([]);
})
.finally(() => {
if (!cancelled) setSearching(false);
});
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [searchQuery]);
const startRenameSession = (session: WebviewSessionSummary) => {
setEditingSessionId(session.sessionId);
setEditingTitle(session.title || shortId(session.sessionId));
@@ -894,6 +935,52 @@ function SessionsView({
</>
}
/>
<div className="relative mb-3">
<SearchIcon className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search all session history"
className="pl-9"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search messages, commands, errors, and file paths across all sessions…"
value={searchQuery}
/>
</div>
{searchQuery.trim() ? (
<section className="mb-4 overflow-hidden rounded-lg border bg-card">
{searching ? (
<p className="px-4 py-5 text-sm text-muted-foreground">
Searching
</p>
) : searchHits.length === 0 ? (
<p className="px-4 py-5 text-sm text-muted-foreground">
No matching session history.
</p>
) : (
searchHits.map((hit) => (
<button
className="block w-full border-b px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
key={hit.documentId}
onClick={() => onOpenSession(hit.sessionId)}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<span className="truncate">{hit.title}</span>
<span className="text-xs font-normal text-muted-foreground">
{hit.role}
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{hit.snippet}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{hit.workspaceRoot}
</p>
</button>
))
)}
</section>
) : null}
<section className="w-full min-w-0 overflow-x-auto">
<div className="grid w-full min-w-[56rem] grid-cols-[minmax(12rem,1.35fr)_minmax(7rem,0.85fr)_minmax(10rem,1.1fr)_5rem_5rem_4.5rem_5.5rem_2rem] gap-x-4 bg-muted/40 px-4 py-3 text-[15px] font-medium text-muted-foreground">
+5 -2
View File
@@ -1,6 +1,9 @@
"use client";
import type { GeneratedMedia } from "@cline/shared/browser";
import {
type GeneratedMedia,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared/browser";
import { GeneratedMediaContent } from "@cline/ui";
import {
CheckIcon,
@@ -1182,7 +1185,7 @@ export default function Chat({
type: "approval_response",
approvalId,
approved,
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
});
setStatus(approved ? "Approval sent." : "Rejection sent.");
};
+54
View File
@@ -1,5 +1,59 @@
# Cline Desktop Changelog
## 0.0.22
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
- Runs of a schedule now fold into a single collapsible sidebar row named after the schedule, with its run count, instead of one row per run all carrying the same prompt title. Expanding lists them newest-first as "Run N" with the usual status dot, time, hover card, context menu, and delete; the group holding the active session opens on its own
- Voice input now works on macOS. The app shipped without a microphone usage description or entitlement, so dictation failed silently
- Web search is now on by default
- The marketplace detail panel now opens on click rather than hover, with left-aligned content, a single "Learn more" link, and the selected entry staying open while you filter the list
- When the Hub is older than the app, you are now offered a choice — replace it, with a count of the sessions that would be interrupted, or keep it running — instead of the app quietly working against stale code. Replacing drains the Hub first so in-flight turns finish
- Editing and resending a message now works on sessions with no checkpoint history, such as imported ones, instead of failing with "No checkpoint found at or before run N"
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- The message the model receives when you reject a tool call now names the tool and reads as your decision rather than an error
- Refreshed the model catalog. Adds eight providers (Bothub, OpenReason, SenseNova (China), TokenRouter, Vancine, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and changes the resolved default model for 36 providers — most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Kilo Gateway, DevPass, DigitalOcean, CrossModel, and Eden AI following. If you use a provider without pinning a model, expect a different default
## 0.0.21
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
- Stopping a session now actually stops everything it started. Stop stays available while child agents are running, and an abort propagates to delegated subagents and to teammates instead of leaving orphaned work running in the background; cancelled teammate tasks now persist as cancelled
- Fixed the ask-a-question tool's option text overflowing instead of wrapping
- You can now drop file attachments anywhere over the chat input, not just on the small attach target
- Cline provider models now refresh from the live catalog, so newly released models show up without waiting for an app update
- Provider 401/403 responses are now classified as authentication errors rather than generic request failures, so a bad or missing API key is distinguishable from a real provider outage
- Fixed Langfuse tracing never initializing in release builds — the minified bundle broke tracer detection, so telemetry worked in dev and silently did nothing in the shipped app. Also updated for AI SDK 7's telemetry API
- Refreshed the model catalog. Adds TokenGo and Volcengine Ark, and updates model lists, pricing, and the resolved default model for ~36 providers (including Hugging Face, Mistral, OpenRouter, Together, NanoGPT, Requesty, Baseten, Cloudflare Workers AI, and DigitalOcean) — if you use one of those without pinning a model, you will get a different default
## 0.0.20
- Customize now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugin switches use Hub-managed enablement, contributed skills appear in the Skills inventory, and connected desktop views refresh when Hub settings change
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
- Session search now covers your full indexed history. The sidebar search icon opens the command bar (Cmd/Ctrl+P) with server-ranked results, instead of a sidebar-local dialog that first loaded every session into memory
- Onboarding has a new GitHub integration step
- Fixed scheduled tasks disappearing after the app updated — hub-managed schedules were being wiped by cron reconciliation on restart
- Agent-created schedules now live in one user-level home (`~/.cline/schedules`) instead of being scattered across whichever chat folder created them, and they now appear on the Schedules page
- A finished scheduled session now surfaces its final answer: the completing step auto-expands, is labeled "Scheduled task completed" (or failed), and its summary renders as markdown
- Suggested routine templates now ask for a specific final report, so a scheduled run ends with something readable
- Providers no longer show as "Configured" on the strength of a leftover settings entry with no real credentials, and the badge now updates live after connecting or saving credentials instead of waiting for a remount
- Fixed OpenAI Codex (ChatGPT subscription) sign-in silently dead-ending when callback port 1455 was already in use — it now fails immediately with an actionable error, and OAuth redirect errors surface instead of a confusing "Missing authorization code"
- Codex and OCA sign-ins are no longer dropped when a token refresh hits a transient network failure or server error
- Checkpoint restore now refuses to reset your workspace when commits were made after the checkpoint, instead of silently knocking them off the branch
- Fixed an enabled-but-offline remote MCP server stalling session startup until the session was torn down
- Global rules stored at `~/Cline/Rules` are now discovered (previously only `~/Documents/Cline/Rules`), fixing rules that never reached the model on WSL and headless installs
- `apply_patch` now preserves a file's own CRLF line endings
- The window title bar stays draggable across every view
- Voice input's Live and After recording badges now have tooltips explaining them
- Removed the box shadow from the chat message actions row
- The hub no longer watches agenda spec directories while the todo tool is disabled, dropping an OS watch handle per known workspace
## 0.0.19
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 0.0.18
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.18",
"version": "0.0.22",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -15,9 +15,7 @@ async function reserveAvailablePort(): Promise<number> {
reject(new Error("Failed to reserve a sidecar port"));
return;
}
server.close((error) =>
error ? reject(error) : resolve(address.port),
);
server.close((error) => (error ? reject(error) : resolve(address.port)));
});
});
}
@@ -77,6 +77,66 @@ describe("buildUpdateManifest", () => {
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("maps a Windows NSIS setup artifact to windows-x86_64", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x64-setup.exe.sig"),
"sig-windows-x64\n",
);
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(manifest.platforms["windows-x86_64"]).toEqual({
signature: "sig-windows-x64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x64-setup.exe",
});
// darwin entries from the universal artifact are unaffected.
expect(Object.keys(manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
"windows-x86_64",
]);
});
test("ignores non-updater exe files without a setup arch suffix", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64.exe"), "exe");
const manifest = buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
});
expect(Object.keys(manifest.platforms).sort()).toEqual([
"darwin-aarch64",
"darwin-x86_64",
]);
});
test("throws when a Windows setup artifact is missing its signature", () => {
const dir = makeUniversalArtifactDir();
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x64-setup.exe"), "nsis");
expect(() =>
buildUpdateManifest({
version: "0.1.0",
tag: "desktop-v0.1.0",
dir,
repo: "cline/cline",
notes: "notes",
pubDate: "2026-07-21T00:00:00.000Z",
}),
).toThrow();
});
test("throws when universal and per-arch artifacts claim the same platform", () => {
const dir = makePerArchArtifactDir();
writeFileSync(
@@ -24,17 +24,25 @@ export type UpdateManifest = {
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// Maps the arch token embedded in macOS artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
// A universal (fat) bundle serves both macOS architectures: each slice of the
// installed app requests its own compile-time arch key at runtime, and both
// keys point at the same artifact and signature.
const PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
const MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
aarch64: ["darwin-aarch64"],
x86_64: ["darwin-x86_64"],
universal: ["darwin-aarch64", "darwin-x86_64"],
};
// On Windows the updater artifact is the NSIS installer itself
// (createUpdaterArtifacts signs the setup exe with the updater key), named
// `<Product>_<version>_<arch>-setup.exe` by the Tauri bundler.
const WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX: Record<string, string[]> = {
x64: ["windows-x86_64"],
arm64: ["windows-aarch64"],
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
@@ -45,13 +53,22 @@ const getArgValue = (args: string[], name: string): string | undefined => {
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
const platformKeysOfUpdaterArtifact = (
fileName: string,
): string[] | undefined => {
if (fileName.endsWith(".app.tar.gz")) {
const arch = Object.keys(MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
(candidate) => fileName.includes(`_${candidate}`),
);
return arch ? MACOS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
}
return Object.keys(PLATFORM_KEYS_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
if (fileName.endsWith("-setup.exe")) {
const arch = Object.keys(WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX).find(
(candidate) => fileName.endsWith(`_${candidate}-setup.exe`),
);
return arch ? WINDOWS_PLATFORM_KEYS_BY_ARCH_SUFFIX[arch] : undefined;
}
return undefined;
};
export const buildUpdateManifest = (options: {
@@ -65,8 +82,8 @@ export const buildUpdateManifest = (options: {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
const platformKeys = platformKeysOfUpdaterArtifact(fileName);
if (!platformKeys) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
@@ -74,7 +91,7 @@ export const buildUpdateManifest = (options: {
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
for (const platformKey of PLATFORM_KEYS_BY_ARCH_SUFFIX[arch]) {
for (const platformKey of platformKeys) {
if (platforms[platformKey]) {
throw new Error(
`multiple updater artifacts claim platform ${platformKey}; found ${fileName} after ${platforms[platformKey].url}`,
@@ -89,7 +106,7 @@ export const buildUpdateManifest = (options: {
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
`no updater artifacts (*.app.tar.gz or *-setup.exe with a known arch suffix) found in ${options.dir}`,
);
}
@@ -0,0 +1,77 @@
# Authenticode-signs one PE file with Azure Trusted Signing via jsign.
#
# Invoked by the Tauri bundler through `bundle > windows > signCommand` (the
# desktop-publish workflow generates a config overlay pointing here), once per
# binary it stages: the main app exe, the code-sidecar external binary, the
# NSIS uninstaller, and the NSIS installer itself.
#
# Requirements (all provided by the desktop-publish Windows job):
# - an azure/login OIDC session (jsign's token comes from `az account get-access-token`)
# - AZURE_TRUSTED_SIGNING_ENDPOINT / _ACCOUNT_NAME / _CERTIFICATE_PROFILE env vars
# - java on PATH (preinstalled on GitHub Windows runners)
#
# Mirrors the CLI pipeline (.github/actions/sign-windows-cli): same jsign
# version and flags, same Microsoft timestamp service. Kept as a standalone
# script so the signing behavior is reviewable in the repo rather than inlined
# in a generated config string.
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $Path
)
$ErrorActionPreference = "Stop"
$jsignVersion = "7.5"
$jsignSha256 = "602A51C3545A6DC4FB99BD2EA7152B26D1345916D0C93DDFBD5936CB735AF91C"
$endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT
$account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME
$certProfile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE
if (-not $endpoint -or -not $account -or -not $certProfile) {
throw "Azure Trusted Signing env vars are not set (AZURE_TRUSTED_SIGNING_ENDPOINT/_ACCOUNT_NAME/_CERTIFICATE_PROFILE)"
}
$resolved = (Resolve-Path $Path).Path
# jsign expects the endpoint host; tolerate the portal's trailing-slash form.
$keystore = $endpoint -replace '^https://', '' -replace '/$', ''
$jar = Join-Path $env:RUNNER_TEMP "jsign-$jsignVersion.jar"
if (-not (Test-Path $jar)) {
Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/$jsignVersion/jsign-$jsignVersion.jar" -OutFile $jar
}
$actualHash = (Get-FileHash -Algorithm SHA256 $jar).Hash
if ($actualHash -ne $jsignSha256) {
Remove-Item $jar -Force
throw "jsign jar checksum mismatch: expected $jsignSha256, got $actualHash"
}
# Short-lived bearer token from the azure/login OIDC session. Fetched per
# invocation (signCommand runs once per file) so a long Rust build beforehand
# can never leave us with an expired token. Passed to jsign via env, not argv.
$env:JSIGN_STOREPASS = (az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
if (-not $env:JSIGN_STOREPASS) {
throw "failed to acquire an Azure access token; is azure/login configured on this job?"
}
Write-Host "Signing $resolved"
java -jar $jar `
--storetype TRUSTEDSIGNING `
--keystore $keystore `
--storepass env:JSIGN_STOREPASS `
--alias "$account/$certProfile" `
--alg SHA-256 `
--tsaurl http://timestamp.acs.microsoft.com `
--tsmode RFC3161 `
--replace `
$resolved
if ($LASTEXITCODE -ne 0) {
throw "jsign failed for $resolved (exit $LASTEXITCODE)"
}
$signature = Get-AuthenticodeSignature $resolved
if ($signature.Status -ne "Valid") {
throw "signature verification failed for ${resolved}: $($signature.Status) - $($signature.StatusMessage)"
}
Write-Host "Signed and verified: $resolved ($($signature.SignerCertificate.Subject))"
@@ -462,6 +462,89 @@ describe("session forks", () => {
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("forks trimmed messages without restoring when the edited run has no checkpoint", async () => {
const sourceSessionId = `source-imported-fork-${Date.now()}`;
const sourceMessages = [
{ role: "user" as const, content: "imported prompt" },
{ role: "assistant" as const, content: "imported response" },
{ role: "user" as const, content: "prompt to edit" },
{ role: "assistant" as const, content: "response to replace" },
];
const expectedMessages = sourceMessages.slice(0, 2);
const start = vi.fn(async () => ({ sessionId: "imported-fork" }));
const restore = vi.fn(async () => {
throw new Error("restore must not run without a checkpoint");
});
const readMessages = vi.fn(async () => expectedMessages);
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
messages: sourceMessages,
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "completed",
},
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
importedFrom: { tool: "codex", sourceId: "cdx-1" },
},
})),
readMessages,
restore,
start,
},
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
forkBeforeRunCount: 2,
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
},
})) as { sessionId: string };
expect(restore).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
initialMessages: expectedMessages,
sessionMetadata: expect.objectContaining({
fork: expect.objectContaining({
forkedFromSessionId: sourceSessionId,
beforeRunCount: 2,
}),
}),
}),
);
expect(result.sessionId).toBe("imported-fork");
expect(ctx.liveSessions.has(sourceSessionId)).toBe(false);
expect(ctx.liveSessions.get("imported-fork")?.messages).toEqual(
expectedMessages,
);
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("keeps a full-history fork on the current workspace without restoring", async () => {
const sourceSessionId = `source-full-fork-${Date.now()}`;
const sourceMessages = [
@@ -8,10 +8,12 @@ import {
type ClineCoreStartConfig,
createSessionCompactionState,
createUserInstructionConfigService,
findCheckpointForRun,
getCoreBuiltinToolCatalog,
isSkillsToolAvailable,
projectSessionCompactionState,
readGlobalSettings,
readSessionCheckpointHistory,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
@@ -1299,8 +1301,18 @@ async function handleForkUnlocked(
sessionMetadata: forkMetadata,
toolPolicies: resolveToolPolicies(forkConfig),
};
// Sessions without a checkpoint at or before the edited run (imported
// transcripts, checkpoints disabled) have no workspace state to roll back,
// so fork the trimmed messages onto the current workspace instead of
// failing the edit.
const canRestoreWorkspace =
forkBeforeRunCount !== undefined &&
findCheckpointForRun(
readSessionCheckpointHistory({ metadata: sourceMetadata }),
forkBeforeRunCount,
) !== undefined;
let newSessionId: string;
if (forkBeforeRunCount !== undefined) {
if (forkBeforeRunCount !== undefined && canRestoreWorkspace) {
const cwd =
restoreWorkspacePath ||
(typeof forkConfig.cwd === "string" && forkConfig.cwd.trim()) ||
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext, SidecarWebSocketClient } from "./types";
const upgradeManagedHubMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
upgradeManagedHub: upgradeManagedHubMock,
};
});
function createContext(): SidecarContext {
return {
workspaceRoot: "/workspace",
wsClients: new Set(),
hubBuildMismatch: {
url: "ws://127.0.0.1:25463/hub",
reason: "outdated_hub",
expectedBuildId: "current-build",
},
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
}
function connection(canApproveTools: boolean): SidecarWebSocketClient {
return { data: { canApproveTools } } as unknown as SidecarWebSocketClient;
}
beforeEach(() => {
upgradeManagedHubMock.mockReset();
});
describe("hub_upgrade command", () => {
it("rejects connections without the approval token, before touching the hub", async () => {
const { handleCommand } = await import("./commands");
const ctx = createContext();
await expect(
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(false) }),
).rejects.toThrow(/trusted desktop connection/);
await expect(handleCommand(ctx, "hub_upgrade", {}, {})).rejects.toThrow(
/trusted desktop connection/,
);
expect(upgradeManagedHubMock).not.toHaveBeenCalled();
// The pending mismatch must survive a refused request.
expect(ctx.hubBuildMismatch).not.toBeNull();
});
it("forces the upgrade for the trusted webview connection and clears the mismatch", async () => {
upgradeManagedHubMock.mockResolvedValue({
outcome: "replaced",
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
activeSessionCount: 2,
});
const { handleCommand } = await import("./commands");
const ctx = createContext();
const result = await handleCommand(
ctx,
"hub_upgrade",
{},
{ connection: connection(true) },
);
expect(upgradeManagedHubMock).toHaveBeenCalledWith({
workspaceRoot: "/workspace",
force: true,
reason: "Cline Desktop hub update",
});
expect(result).toEqual({
outcome: "replaced",
url: "ws://127.0.0.1:25463/hub",
interruptedSessionCount: 2,
});
expect(ctx.hubBuildMismatch).toBeNull();
});
it("surfaces a newer running hub as an error instead of replacing it", async () => {
upgradeManagedHubMock.mockResolvedValue({
outcome: "hub_not_older",
url: "ws://127.0.0.1:25463/hub",
});
const { handleCommand } = await import("./commands");
const ctx = createContext();
await expect(
handleCommand(ctx, "hub_upgrade", {}, { connection: connection(true) }),
).rejects.toThrow(/newer than this app/);
expect(ctx.hubBuildMismatch).not.toBeNull();
});
});
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
import {
listClineGitHubRepositories,
listClineIntegrations,
resolveGitHubInstallUrl,
} from "./commands-integrations";
import type { SidecarContext } from "./types";
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
};
});
function createContext() {
const capture = vi.fn();
const ctx = {
telemetry: { capture },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
}
const REQUEST_OPTIONS = {
apiBaseUrl: "https://api.example.com",
appBaseUrl: "https://app.example.com",
authToken: "test-token",
} as const;
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
return {
...REQUEST_OPTIONS,
fetchImpl: fetchImpl as unknown as typeof fetch,
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
getProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("listClineIntegrations", () => {
it("lists integrations through the envelope with a bearer token", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
const result = await listClineIntegrations(requestOptions(fetchImpl));
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer test-token",
);
});
});
describe("listClineGitHubRepositories", () => {
it("lists GitHub repositories from the repositories endpoint", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
);
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
expect(result).toEqual([{ full_name: "cline/cline" }]);
expect(String(fetchImpl.mock.calls[0][0])).toBe(
"https://api.example.com/api/v1/integrations/github/repositories",
);
});
it("surfaces the API envelope error message on failures", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse(
{ success: false, error: "failed to list integrations" },
500,
),
);
await expect(
listClineIntegrations(requestOptions(fetchImpl)),
).rejects.toThrow("failed to list integrations");
});
});
describe("resolveGitHubInstallUrl", () => {
it("resolves the GitHub install URL from the redirect location", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: {
location: "https://github.com/apps/cline/installations/new?state=abc",
},
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new?state=abc",
});
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(url.origin + url.pathname).toBe(
"https://api.example.com/api/v1/integrations/github/install",
);
// The post-install browser hop must land on the Cline dashboard.
expect(url.searchParams.get("redirect")).toBe(
"https://app.example.com/dashboard/integrations",
);
// The redirect must be read, not followed: the Location URL is the result.
expect(init.redirect).toBe("manual");
});
it("resolves a relative redirect location against the request URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "//github.com/apps/cline/installations/new" },
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
// A bare relative Location would blow up later in the URL opener.
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new",
});
});
it.each([
["https://evil.example/apps/cline", "evil.example"],
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
// Subdomains are not part of the install flow, so they are not allowed
// either -- the host must be exactly github.com.
["https://gist.github.com/apps/cline", "gist.github.com"],
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(null, { status: 302, headers: { location } }),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow(`unexpected host: ${host}`);
});
it("rejects a redirect that does not use https", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://github.com/apps/cline" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("must use https");
});
it("rejects a redirect location that is not a usable URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("not a valid URL");
});
it("throws when the install endpoint does not answer with a redirect", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ error: "authentication required" }, 401),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("authentication required");
});
});
describe("cline_integrations command auth states", () => {
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
});
it("calls the Cline API with the resolved fresh token when signed in", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toContain("/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer fresh-token",
);
});
it("rejects unknown operations", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
await expect(
handleCommand(ctx, "cline_integrations", {
operation: "dropIntegrations",
}),
).rejects.toThrow("Unsupported Cline integrations operation");
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import type {
ClineGitHubRepository,
ClineIntegration,
} from "../webview/lib/cline-integrations-types";
const DEFAULT_TIMEOUT_MS = 30_000;
const GITHUB_INSTALL_HOST = "github.com";
function resolveInstallRedirect(location: string, requestUrl: URL): string {
let resolved: URL;
try {
resolved = new URL(location, requestUrl);
} catch {
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
}
if (resolved.protocol !== "https:") {
throw new Error(
`GitHub install redirect must use https, got: ${resolved.protocol}`,
);
}
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
throw new Error(
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
);
}
return resolved.toString();
}
export interface ClineIntegrationsRequestOptions {
apiBaseUrl: string;
/** Frontend origin the browser install flow returns to when it finishes. */
appBaseUrl: string;
authToken: string;
requestTimeoutMs?: number;
fetchImpl?: typeof fetch;
}
export async function listClineIntegrations(
options: ClineIntegrationsRequestOptions,
): Promise<ClineIntegration[]> {
const data = await requestClineApiJson("/api/v1/integrations", options);
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
}
export async function listClineGitHubRepositories(
options: ClineIntegrationsRequestOptions,
): Promise<ClineGitHubRepository[]> {
const data = await requestClineApiJson(
"/api/v1/integrations/github/repositories",
options,
);
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
}
export async function resolveGitHubInstallUrl(
options: ClineIntegrationsRequestOptions,
): Promise<{ url: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
const installUrl = new URL(
"/api/v1/integrations/github/install",
options.apiBaseUrl,
);
installUrl.searchParams.set(
"redirect",
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
);
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(installUrl, {
method: "GET",
headers: { Authorization: `Bearer ${options.authToken}` },
redirect: "manual",
signal: controller.signal,
});
const location = response.headers.get("location");
if (response.status >= 300 && response.status < 400 && location?.trim()) {
return { url: resolveInstallRedirect(location.trim(), installUrl) };
}
const text = await response.text().catch(() => "");
let parsed: unknown;
try {
parsed = text.trim() ? JSON.parse(text) : undefined;
} catch {
parsed = undefined;
}
throw new Error(formatRequestFailure(response.status, text, parsed));
} finally {
clearTimeout(timeout);
}
}
function getEnvelopeError(parsed: unknown): string | undefined {
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
return undefined;
}
const error = (parsed as { error?: unknown }).error;
return typeof error === "string" && error.trim() ? error : undefined;
}
function formatRequestFailure(
status: number,
bodyText: string,
parsed: unknown,
): string {
const envelopeError = getEnvelopeError(parsed);
if (envelopeError) {
return envelopeError;
}
const body = bodyText.trim();
if (body) {
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
return `Cline integrations request failed with status ${status}: ${preview}`;
}
return `Cline integrations request failed with status ${status}`;
}
async function requestClineApiJson(
endpoint: string,
options: ClineIntegrationsRequestOptions,
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
method: "GET",
headers: {
Authorization: `Bearer ${options.authToken}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown;
if (text.trim()) {
try {
parsed = JSON.parse(text);
} catch {
if (!response.ok) {
throw new Error(
formatRequestFailure(response.status, text, undefined),
);
}
throw new Error("Cline integrations response was not valid JSON");
}
}
if (!response.ok) {
throw new Error(formatRequestFailure(response.status, text, parsed));
}
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
const envelope = parsed as {
success?: unknown;
error?: unknown;
data?: unknown;
};
if (typeof envelope.success === "boolean") {
if (!envelope.success) {
throw new Error(
getEnvelopeError(parsed) || "Cline integrations request failed",
);
}
return envelope.data ?? null;
}
}
return parsed ?? null;
} finally {
clearTimeout(timeout);
}
}
+290 -2
View File
@@ -35,6 +35,10 @@ import {
resolveMcpServerRegistration,
resolveSessionBackend,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
SESSION_IMPORT_TOOLS,
type SessionImportRequest,
SessionImportService,
type SessionImportTool,
SqliteSessionStore,
saveLocalProviderSettings,
saveVoiceInputSettings,
@@ -45,10 +49,13 @@ import {
transcribeConfiguredVoiceInput,
updateLocalProvider,
updateMcpSettingsFileSync,
upgradeManagedHub,
} from "@cline/core";
import { resolveAudioTranscriptionRoute } from "@cline/llms";
import {
CLINE_DEFAULT_MODEL_ID,
formatSessionSearchPreview,
formatSessionSearchTitle,
getClineEnvironmentConfig,
isCanonicalBase64,
ONE_TIME_SCHEDULE_CRON_PATTERN,
@@ -59,6 +66,11 @@ import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
import {
listClineGitHubRepositories,
listClineIntegrations,
resolveGitHubInstallUrl,
} from "./commands-integrations";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -534,6 +546,67 @@ async function listSessionsFromSidecarManager(
.slice(0, max);
}
async function withSearchDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Session search timed out")),
timeoutMs,
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function metadataSessionSearchHits(
value: unknown,
query: string,
): JsonRecord[] {
if (!Array.isArray(value)) return [];
const normalizedQuery = query.toLocaleLowerCase();
return value.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const session = item as JsonRecord;
const metadata =
session.metadata && typeof session.metadata === "object"
? (session.metadata as JsonRecord)
: {};
const sessionId = String(session.sessionId ?? "").trim();
if (!sessionId) return [];
const rawTitle = String(
metadata.title ?? session.title ?? session.prompt ?? sessionId,
).trim();
const prompt = String(session.prompt ?? metadata.prompt ?? "");
const title = formatSessionSearchTitle(rawTitle) || sessionId;
const workspaceRoot = String(session.workspaceRoot ?? session.cwd ?? "");
const searchable = [rawTitle, prompt, workspaceRoot, session.model]
.join("\n")
.toLocaleLowerCase();
if (!searchable.includes(normalizedQuery)) return [];
return [
{
sessionId,
documentId: `${sessionId}:metadata`,
ordinal: -1,
role: "session",
startedAt: String(session.startedAt ?? session.createdAt ?? ""),
workspaceRoot,
title,
snippet: formatSessionSearchPreview("session", prompt || title),
score: 0,
},
];
});
}
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
@@ -616,7 +689,15 @@ async function handleRoutineScheduleCommand(
hubCommand: string,
payload?: Record<string, unknown>,
) => {
const reply = await hubClient.command(hubCommand as never, payload);
// The desktop app runs chats (and therefore agent-created schedules)
// across many workspace folders, while this hub client is registered
// against the app's own launch directory. Ask the hub for schedules
// across all workspaces so the Schedules page manages every schedule
// on this machine, not just the launch-directory scope.
const reply = await hubClient.command(hubCommand as never, {
...payload,
allWorkspaces: true,
});
if (!reply.ok) {
throw new Error(
reply.error?.message ?? `hub command failed: ${hubCommand}`,
@@ -955,6 +1036,34 @@ async function listUserInstructionConfigs(
} finally {
userInstructionService.stop();
}
const knownSkillPaths = new Set(
skills.flatMap((skill) => {
if (!skill || typeof skill !== "object") return [];
const path = (skill as JsonRecord).path;
return typeof path === "string" ? [path] : [];
}),
);
for (const skill of hubSettings.skills) {
if (
skill.agentPlugin !== true ||
skill.enabled === false ||
knownSkillPaths.has(skill.path)
) {
continue;
}
skills.push({
id: skill.id,
name: skill.name,
description: skill.description,
instructions: "",
path: skill.path,
enabled: true,
source: skill.source,
agentPlugin: true,
pluginName: skill.pluginName,
});
knownSkillPaths.add(skill.path);
}
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
// Pin spawn/teams availability so this listing matches the hub's
@@ -974,9 +1083,15 @@ async function listUserInstructionConfigs(
runtimeCommands,
agents: loadAgents(),
plugins: hubSettings.plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
path: plugin.path,
enabled: plugin.enabled !== false,
source: plugin.source,
toggleable: plugin.toggleable === true,
agentPlugin: plugin.agentPlugin === true,
description: plugin.description,
loadError: plugin.loadError,
contributions: plugin.contributions,
})),
tools: [
@@ -1307,6 +1422,45 @@ export async function handleCommand(
return "";
}
// ── Managed hub upgrade ───────────────────────────────────────────
if (command === "hub_upgrade") {
// Replacing the shared Hub interrupts other clients' sessions, so it
// carries the same per-connection gate as the tool-approval commands:
// only the webview connection dialed with the approval token may ask,
// never an arbitrary local WebSocket client.
if (!options?.connection?.data?.canApproveTools) {
throw new Error("hub upgrade requires a trusted desktop connection");
}
// Only reached after the user accepted the blocking "Hub update
// required" dialog, so force: the old Hub is replaced even though it
// is still serving other clients' sessions. Drain-first semantics
// still give in-flight turns the wait window to finish.
const result = await upgradeManagedHub({
workspaceRoot: ctx.workspaceRoot,
force: true,
reason: "Cline Desktop hub update",
});
if (result.outcome === "hub_not_older") {
throw new Error(
"The running Cline Hub is newer than this app, so it was not replaced. Update Cline instead.",
);
}
if (result.outcome === "still_busy") {
throw new Error(
"The running Cline Hub picked up new sessions before it could be replaced, so it was left running. Try again.",
);
}
// The mismatch is resolved: a null broadcast closes the dialog in
// every connected webview and stops the replay-on-connect.
ctx.hubBuildMismatch = null;
broadcastEvent(ctx, "hub_build_mismatch", null);
return {
outcome: result.outcome,
url: result.url ?? null,
interruptedSessionCount: result.activeSessionCount ?? 0,
};
}
// ── Tool approvals (in-memory) ────────────────────────────────────
if (command === "poll_tool_approvals") {
const sessionId = String(args?.sessionId ?? "").trim();
@@ -1390,11 +1544,105 @@ export async function handleCommand(
typeof args?.limit === "number" ? args.limit : 300,
);
}
if (command === "search_sessions") {
const query = String(args?.query ?? "").trim();
if (!query) return [];
const limit =
typeof args?.limit === "number" && Number.isFinite(args.limit)
? Math.max(1, Math.min(200, Math.trunc(args.limit)))
: 50;
const workspaceRoot =
typeof args?.workspaceRoot === "string"
? args.workspaceRoot.trim() || undefined
: undefined;
if (ctx.hubClient) {
try {
const reply = await withSearchDeadline(
ctx.hubClient.command("session.search", {
query,
limit,
workspaceRoot,
}),
750,
);
if (
reply.ok &&
Array.isArray(reply.payload?.hits) &&
reply.payload.hits.length > 0
) {
return reply.payload.hits.slice(0, limit).map((hit) => ({
...hit,
title: formatSessionSearchTitle(hit.title),
snippet: formatSessionSearchPreview(hit.role, hit.snippet),
}));
}
} catch {
// Fall back to metadata-only search when the index is unavailable.
}
}
const sessions = await withSearchDeadline(
listSessionsFromSidecarManager(ctx, 500),
1_000,
).catch(() => []);
return metadataSessionSearchHits(sessions, query).slice(0, limit);
}
if (command === "get_discovered_session") {
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
if (!sessionId) throw new Error("session id is required");
return (await getSessionFromSidecarManager(ctx, sessionId)) ?? null;
}
// ── Session import from other coding tools ────────────────────────
if (command === "list_importable_sessions") {
const backend = await resolveSessionBackend({ backendMode: "local" });
const importer = new SessionImportService(backend);
return {
installedTools: importer.installedTools(),
sessions: await importer.discover(),
};
}
if (command === "import_sessions") {
const rawSelections = Array.isArray(args?.selections)
? args.selections
: [];
const requests: SessionImportRequest[] = [];
for (const selection of rawSelections) {
if (!selection || typeof selection !== "object") continue;
const tool = String((selection as JsonRecord).tool ?? "").trim();
const sourceId = String((selection as JsonRecord).sourceId ?? "").trim();
if (!sourceId) continue;
if (!(SESSION_IMPORT_TOOLS as readonly string[]).includes(tool)) {
continue;
}
requests.push({ tool: tool as SessionImportTool, sourceId });
}
if (requests.length === 0) {
throw new Error("at least one { tool, sourceId } selection is required");
}
const backend = await resolveSessionBackend({ backendMode: "local" });
const importer = new SessionImportService(backend);
// Opening a history session resumes on the row's provider/model, so the
// UI passes what a new chat would run on; the source tool's own
// provider/model stay in metadata.importedFrom.
// Never let the source tool's provider become the resume target: when
// the caller sends no selection, use the app default like other
// server-started sessions do.
const provider = asTrimmedString(args?.provider) ?? "cline";
const model = asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID;
const results = await importer.importMany(
requests,
(result, index) => {
broadcastEvent(ctx, "session_import_progress", {
index,
total: requests.length,
result,
});
},
{ provider, model },
);
return { results };
}
if (command === "update_chat_session_title") {
const sessionId = String(args?.sessionId ?? "").trim();
if (!sessionId) throw new Error("session id is required");
@@ -1603,6 +1851,37 @@ export async function handleCommand(
return result;
}
// ── Cline integrations (GitHub App) ────────────────────────────────
if (command === "cline_integrations") {
const operation = String(args?.operation ?? "").trim();
if (!operation) throw new Error("operation is required");
const manager = new ProviderSettingsManager();
const authToken = await resolveFreshClineAuthToken(ctx, manager);
if (!authToken) {
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
const environment = getClineEnvironmentConfig();
const requestOptions = {
apiBaseUrl: settings?.baseUrl?.trim() || environment.apiBaseUrl,
appBaseUrl: environment.appBaseUrl,
authToken,
};
switch (operation) {
case "list":
return await listClineIntegrations(requestOptions);
case "listGitHubRepositories":
return await listClineGitHubRepositories(requestOptions);
case "githubInstallUrl":
return await resolveGitHubInstallUrl(requestOptions);
default:
throw new Error(
`Unsupported Cline integrations operation: ${operation}`,
);
}
}
// ── Provider management ────────────────────────────────────────────
if (command === "list_provider_catalog") {
const manager = new ProviderSettingsManager();
@@ -1839,7 +2118,16 @@ export async function handleCommand(
);
});
},
{ owner: options?.connection },
{
owner: options?.connection,
// Push the device sign-in confirmation code so the webview can
// show it while the user confirms it in the browser.
onUserCode: (userCode) =>
broadcastEvent(ctx, "provider_oauth_user_code", {
provider: providerId,
userCode,
}),
},
);
}
if (command === "cancel_provider_oauth_login") {
@@ -210,6 +210,157 @@ describe("Code sidecar runtime capabilities", () => {
expect(connectMock).toHaveBeenCalledOnce();
});
it("returns indexed search results without listing every session", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
const hits = [
{
sessionId: "session-1",
documentId: "session-1:0",
ordinal: 0,
role: "user",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
title: oversizedPrompt,
snippet: oversizedPrompt,
score: -1,
},
];
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
const list = vi.fn(async () => []);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ title: string; snippet: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:0",
}),
]);
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
expect(results[0]?.title).not.toContain("user_input");
expect(results[0]?.snippet).not.toContain("user_input");
expect(command).toHaveBeenCalledWith("session.search", {
query: "generate",
limit: 50,
workspaceRoot: undefined,
});
expect(list).not.toHaveBeenCalled();
});
it("falls back to session metadata while the index has no hits", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const command = vi.fn(async () => ({ ok: true, payload: { hits: [] } }));
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: oversizedPrompt,
metadata: { title: oversizedPrompt },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ title: string; snippet: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
expect(results[0]?.title).not.toContain("user_input");
expect(results[0]?.snippet).not.toContain("user_input");
expect(list).toHaveBeenCalledOnce();
});
it("falls back to session metadata when the hub search call rejects", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const command = vi.fn(async () => {
throw new Error("hub connection lost");
});
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: "generate an image of a puppy",
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ sessionId: string; documentId: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(command).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledOnce();
});
it("falls back to session metadata when the hub search call exceeds the deadline", async () => {
vi.useFakeTimers();
try {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
// Never resolves: exercises the withSearchDeadline race timing out
// rather than the hub call rejecting.
const command = vi.fn(() => new Promise(() => {}));
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: "generate an image of a puppy",
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const pending = handleCommand(ctx, "search_sessions", {
query: "generate",
}) as Promise<Array<{ sessionId: string; documentId: string }>>;
await vi.advanceTimersByTimeAsync(750);
const results = await pending;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(command).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("forwards raw hub tool updates to attached desktop sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
@@ -929,6 +1080,7 @@ describe("Code sidecar runtime capabilities", () => {
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
allWorkspaces: true,
scheduleId: "schedule-1",
});
});
@@ -1041,6 +1193,33 @@ describe("Code sidecar runtime capabilities", () => {
},
]);
});
it("forwards Hub settings changes so open desktop views can refresh", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
handleHubLiveEvent(ctx, {
event: "settings.changed",
payload: {
types: ["plugins", "skills", "mcp"],
},
});
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: {
name: "settings.changed",
payload: {
types: ["plugins", "skills", "mcp"],
},
},
},
]);
});
});
describe("disposeSidecarContext attachment cleanup", () => {
+23 -17
View File
@@ -114,23 +114,25 @@ export function syncSidecarApprovalReadiness(
ctx: SidecarContext,
): Promise<void> {
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
const update = previous.catch(() => undefined).then(async () => {
const hubClient = ctx.hubClient;
if (!hubClient) return;
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
});
const update = previous
.catch(() => undefined)
.then(async () => {
const hubClient = ctx.hubClient;
if (!hubClient) return;
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
});
approvalReadinessUpdates.set(ctx, update);
return update.finally(() => {
if (approvalReadinessUpdates.get(ctx) === update) {
@@ -809,6 +811,10 @@ export function handleHubLiveEvent(
});
return;
}
if (event.event === "settings.changed") {
sendEvent(ctx, event.event, event.payload ?? {});
return;
}
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
if (!sessionId) {
@@ -1,7 +1,10 @@
import { homedir } from "node:os";
import {
checkManagedHubBuildMismatch,
createClineTelemetryServiceConfig,
readGlobalSettings,
setHomeDirIfUnset,
setModelToolEnabledGlobally,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
@@ -65,6 +68,20 @@ async function main() {
pid: process.pid,
});
// Web search is opt-in elsewhere in Cline, but the desktop app defaults
// it to on. Seed the shared setting only when the user has never set it,
// so an explicit off (from any Cline app) stays off. Best-effort: an
// unwritable settings file must not block startup over a default.
try {
if (readGlobalSettings().tools?.web_search === undefined) {
setModelToolEnabledGlobally("web_search", true);
}
} catch (error) {
observability.logger.error?.("Failed to seed web search default", {
error,
});
}
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
@@ -148,6 +165,34 @@ async function main() {
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
},
});
// The watcher's first check only runs after its interval, but a mismatch
// that already exists at startup - an older Hub this app attached to
// because it is still serving other clients' sessions - must prompt
// before the user starts working, not half a minute in. Session-manager
// init has already settled the hub state, so check once right away. The
// broadcast reaches webviews that are already connected; the replay in
// createWebSocketHandler covers ones that connect later. Skipped when
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
// hosts keep protocol-only compatibility and must not show update prompts.
if (!process.env.CLINE_HUB_PORT?.trim()) {
void checkManagedHubBuildMismatch()
.then((mismatch) => {
if (!mismatch || ctx.hubBuildMismatch) {
return;
}
ctx.hubBuildMismatch = mismatch;
observability.logger.log(
"Managed hub build mismatch detected at startup",
{
hubBuildId: mismatch.hubBuildId,
hubCoreVersion: mismatch.hubCoreVersion,
reason: mismatch.reason,
},
);
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
})
.catch(() => undefined);
}
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
@@ -187,4 +187,19 @@ describe("official plugin install detection", () => {
);
expect(populated.installedKeys).toEqual(["plugin:goal"]);
});
it("does not match portable Agent Plugins to Cline marketplace entries", () => {
const result = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
plugins: [
{
id: "agent-plugin:goal",
name: "goal",
path: "/home/user/.agents/plugins/goal",
agentPlugin: true,
},
],
} as JsonRecord);
expect(result.installedKeys).toEqual([]);
});
});
@@ -714,6 +714,7 @@ function hasMatchingInventoryItem(
return items.some((item) => {
if (!item || typeof item !== "object") return false;
const record = item as JsonRecord;
if (record.agentPlugin === true) return false;
const values = [
typeof record.name === "string" ? record.name : undefined,
typeof record.id === "string" ? record.id : undefined,
@@ -1,10 +1,13 @@
import type { ProviderSettingsManager } from "@cline/core";
import {
completeClineDeviceAuth,
getProviderAuthStorageId,
loginLocalProvider,
markLocalProviderEnabled,
saveLocalProviderOAuthCredentials,
startClineDeviceAuth,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
export class OAuthLoginCancelledError extends Error {
constructor(providerId: string) {
@@ -26,13 +29,41 @@ type PendingOAuthLogin = {
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
export type OAuthLoginDependencies = {
login: typeof loginLocalProvider;
login: typeof loginProviderForDesktop;
save: typeof saveLocalProviderOAuthCredentials;
markEnabled: typeof markLocalProviderEnabled;
};
/**
* Cline account providers sign in with the WorkOS device-code grant, whose
* browser page asks the user to confirm a short code. `loginLocalProvider`
* runs that flow but discards the code, so use the split helpers instead and
* surface the code through `onUserCode` for the UI to display.
*/
async function loginProviderForDesktop(
providerId: string,
existing: Parameters<typeof loginLocalProvider>[1],
openUrl: (url: string) => void,
onUserCode?: (userCode: string) => void,
): ReturnType<typeof loginLocalProvider> {
if (providerId !== "cline" && providerId !== "cline-pass") {
return loginLocalProvider(providerId, existing, openUrl);
}
const device = await startClineDeviceAuth();
onUserCode?.(device.userCode);
openUrl(device.verificationUriComplete ?? device.verificationUri);
return completeClineDeviceAuth({
deviceCode: device.deviceCode,
expiresInSeconds: device.expiresInSeconds,
pollIntervalSeconds: device.pollIntervalSeconds,
apiBaseUrl:
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
provider: providerId,
});
}
const defaultDependencies: OAuthLoginDependencies = {
login: loginLocalProvider,
login: loginProviderForDesktop,
save: saveLocalProviderOAuthCredentials,
markEnabled: markLocalProviderEnabled,
};
@@ -47,7 +78,11 @@ export async function runCancellableProviderOAuthLogin(
manager: ProviderSettingsManager,
providerId: string,
openUrl: (url: string) => void,
options: { owner?: object } = {},
options: {
owner?: object;
/** Receives the device sign-in confirmation code, when the flow has one. */
onUserCode?: (userCode: string) => void;
} = {},
dependencies: OAuthLoginDependencies = defaultDependencies,
): Promise<{ provider: string; accessToken: string }> {
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
@@ -74,7 +109,7 @@ export async function runCancellableProviderOAuthLogin(
// after cancellation is observed and cannot become an unhandled
// rejection that kills the sidecar.
const credentials = await Promise.race([
dependencies.login(providerId, existing, openUrl),
dependencies.login(providerId, existing, openUrl, options.onUserCode),
cancellation,
]);
if (entry.cancelled) {
@@ -4,5 +4,7 @@
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Cline uses the microphone to transcribe speech into chat input.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Cline uses speech recognition to turn your voice into chat input.</string>
</dict>
</plist>
@@ -9,5 +9,8 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Voice input requires audio capture access when Hardened Runtime is enabled. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+117 -11
View File
@@ -112,6 +112,12 @@ struct UpdateState {
// concurrently and the later one can overwrite a freshly staged "ready"
// with "idle"/"error" decided from its stale pre-await snapshot.
cycle: tokio::sync::Mutex<()>,
// Windows only: the downloaded-but-not-installed update. On Windows,
// Update::install launches the NSIS installer and std::process::exit(0)s
// immediately, so installation must wait for the user-initiated restart
// instead of running inside the background cycle like it does on macOS.
#[cfg(windows)]
pending_install: Mutex<Option<(tauri_plugin_updater::Update, Vec<u8>)>>,
}
impl UpdateState {
@@ -224,12 +230,30 @@ async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
return;
}
set_update_status(app, state, "downloading", Some(version.clone()), None);
// macOS: install right away — it only swaps the .app on disk and
// the running app keeps going until the user restarts. Windows:
// download only, because install() launches the NSIS installer
// and exits the process on the spot; the staged bytes are
// installed by restart_to_apply_update instead.
#[cfg(not(windows))]
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => set_update_status(app, state, "ready", Some(version), None),
Err(error) => {
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
}
}
#[cfg(windows)]
match update.download(|_, _| {}, || {}).await {
Ok(bytes) => {
if let Ok(mut pending) = state.pending_install.lock() {
*pending = Some((update, bytes));
}
set_update_status(app, state, "ready", Some(version), None);
}
Err(error) => {
set_update_status(app, state, "error", Some(version), Some(error.to_string()))
}
}
}
Ok(None) => {
if ready_version.is_none() {
@@ -285,8 +309,15 @@ impl DesktopBackendState {
// exits itself, finishing session persistence as an orphan.
#[cfg(unix)]
let _ = Command::new("kill").arg(child.id().to_string()).status();
// Windows has no SIGTERM equivalent, so terminate outright.
// Reap the child too: TerminateProcess is quick, and the
// update-restart path needs the sidecar exe's file lock
// released before the NSIS installer replaces it.
#[cfg(not(unix))]
let _ = child.kill();
{
let _ = child.kill();
let _ = child.wait();
}
}
*process_guard = None;
}
@@ -314,13 +345,29 @@ struct DesktopBackendReadyLine {
mode: Option<String>,
}
/// The release binary is a GUI-subsystem app (no console), so on Windows
/// every console-subsystem child (git, cmd, the sidecar) would otherwise
/// allocate its own visible console window. Piped stdio does not prevent
/// that; only CREATE_NO_WINDOW does.
#[cfg(windows)]
fn hide_console_window(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn hide_console_window(_command: &mut Command) {}
fn resolve_workspace_root(launch_cwd: &str) -> String {
let output = Command::new("git")
let mut command = Command::new("git");
command
.arg("-C")
.arg(launch_cwd)
.arg("rev-parse")
.arg("--show-toplevel")
.output();
.arg("--show-toplevel");
hide_console_window(&mut command);
let output = command.output();
match output {
Ok(result) if result.status.success() => {
@@ -438,7 +485,9 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stderr(Stdio::piped());
hide_console_window(&mut command);
command
.spawn()
.map_err(|e| format!("failed to start desktop backend sidecar: {e}"))
}
@@ -561,7 +610,11 @@ fn resolve_mcp_settings_path() -> Result<PathBuf, String> {
return Ok(PathBuf::from(trimmed));
}
}
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
// USERPROFILE is the Windows equivalent of HOME (and what the sidecar's
// homedir() resolves there); HOME is usually unset on Windows.
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map_err(|_| "neither HOME nor USERPROFILE is set".to_string())?;
Ok(PathBuf::from(home)
.join(".cline")
.join("data")
@@ -585,8 +638,10 @@ fn open_path_with_default_app(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let path_arg = path.to_string_lossy().to_string();
let status = Command::new("cmd")
.args(["/C", "start", "", &path_arg])
let mut command = Command::new("cmd");
command.args(["/C", "start", "", &path_arg]);
hide_console_window(&mut command);
let status = command
.status()
.map_err(|e| format!("failed to open path: {e}"))?;
if status.success() {
@@ -673,13 +728,52 @@ fn get_update_status(update_state: State<'_, Arc<UpdateState>>) -> UpdateStatus
fn restart_to_apply_update(
app: tauri::AppHandle,
backend_state: State<'_, Arc<DesktopBackendState>>,
update_state: State<'_, Arc<UpdateState>>,
) {
// restart() never returns, so the run-loop Exit handler does not get a
// chance to stop the sidecar; shut it down explicitly first.
// Neither restart() nor install() returns, so the run-loop Exit handler
// does not get a chance to stop the sidecar; shut it down explicitly
// first. On Windows this also releases the sidecar exe's file lock,
// which the NSIS installer needs in order to replace it.
backend_state.stop();
// Windows: install the bytes staged by the background cycle. install()
// launches the NSIS installer (which relaunches the app when done) and
// exits this process, so it only returns on failure — fall through to a
// plain restart of the current version in that case.
#[cfg(windows)]
if let Some((update, bytes)) = update_state
.pending_install
.lock()
.ok()
.and_then(|mut pending| pending.take())
{
if let Err(error) = update.install(bytes) {
eprintln!("[updater] failed to launch the update installer: {error}");
}
}
#[cfg(not(windows))]
let _ = update_state;
app.restart();
}
/// Relaunch the current version of the app. Used after the sidecar replaces
/// the shared Cline Hub under the running app (the "Cline Hub update
/// required" flow): a fresh launch attaches everything to the new Hub instead
/// of trying to migrate live connections. restart() never returns, so the
/// run-loop Exit handler cannot stop the sidecar; do it explicitly first.
#[tauri::command]
fn relaunch_app(app: tauri::AppHandle, backend_state: State<'_, Arc<DesktopBackendState>>) {
backend_state.stop();
app.restart();
}
/// Quit the app. Used by the "Cline Hub update required" flow when the user
/// chooses to keep the older running Hub (and its live sessions) and update
/// later. The run-loop Exit handler stops the sidecar.
#[tauri::command]
fn quit_app(app: tauri::AppHandle) {
app.exit(0);
}
/// Run one updater check/download/stage cycle immediately instead of waiting
/// for the next background interval, and report the resulting status. Used by
/// flows that need an update staged right now (e.g. the "Cline Hub was
@@ -1137,7 +1231,9 @@ fn main() {
set_app_icon,
show_session_notification,
drain_desktop_actions,
set_tray_status
set_tray_status,
relaunch_app,
quit_app
])
.build(tauri::generate_context!())
.expect("error while building tauri app")
@@ -1162,6 +1258,16 @@ mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn macos_bundle_declares_voice_input_permissions() {
let info_plist = include_str!("../Info.plist");
assert!(info_plist.contains("<key>NSMicrophoneUsageDescription</key>"));
assert!(info_plist.contains("<key>NSSpeechRecognitionUsageDescription</key>"));
let entitlements = include_str!("../entitlements.plist");
assert!(entitlements.contains("<key>com.apple.security.device.audio-input</key>"));
}
#[test]
fn desktop_actions_are_buffered_in_order_until_drained() {
let state = DesktopActionState::default();
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.18",
"version": "0.0.22",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
+151 -180
View File
@@ -1,6 +1,7 @@
"use client";
import { ImagePlus, Loader2 } from "lucide-react";
import { AttachmentDropZone } from "@cline/ui";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import {
useCallback,
@@ -13,6 +14,7 @@ import {
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
import { SessionCommandBar } from "@/components/session-command-bar";
import {
AlertDialog,
AlertDialogAction,
@@ -36,6 +38,11 @@ import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice";
import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view";
import type { SettingsSection } from "@/components/views/settings/sections";
import {
WindowTitleBar,
WindowTitleBarContent,
WindowTitleBarProvider,
} from "@/components/window-title-bar";
import { AccountProvider } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { useAppUpdate } from "@/hooks/use-app-update";
@@ -165,6 +172,9 @@ export default function Home() {
// Starts false on both server and first client render (hydration-safe);
// the effect below reads the persisted state right after mount.
const [showOnboarding, setShowOnboarding] = useState(false);
const [commandBarOpen, setCommandBarOpen] = useState(false);
// Shared by the sidebar search icon and the Cmd/Ctrl+P shortcut.
const handleOpenCommandBar = useCallback(() => setCommandBarOpen(true), []);
// "welcome" for the full first-run flow; "connect" when re-entered from
// the in-app "connect a model" notice, which should land directly on the
// provider setup step.
@@ -312,8 +322,8 @@ export default function Home() {
},
[navigateWith],
);
// Standard app shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for
// settings — matching the tray menu actions.
// Standard app shortcuts: Cmd/Ctrl+P for session search, Cmd/Ctrl+N for a
// new session, and Cmd/Ctrl+, for settings.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (showOnboarding) {
@@ -325,6 +335,9 @@ export default function Home() {
if (event.key === "n" || event.key === "N") {
event.preventDefault();
handleNewThread();
} else if (event.key === "p" || event.key === "P") {
event.preventDefault();
setCommandBarOpen((current) => !current);
} else if (event.key === ",") {
event.preventDefault();
handleViewChange("settings");
@@ -420,98 +433,117 @@ export default function Home() {
return (
<AccountProvider>
<SidebarProvider>
<div
aria-hidden={showOnboarding ? true : undefined}
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
// The onboarding overlay is opaque and sits on top of the whole
// shell; hiding the shell keeps its aurora + animations from
// being composited every frame underneath while it still mounts
// and loads (providers, history, transport) in the background.
// `inert` additionally keeps the covered controls out of the
// keyboard tab order and assistive tech while it is hidden.
inert={showOnboarding ? true : undefined}
style={showOnboarding ? { visibility: "hidden" } : undefined}
<WindowTitleBarProvider
contentEnabled={!showOnboarding && view === "chat"}
>
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
<div
aria-hidden={showOnboarding ? true : undefined}
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
// The onboarding overlay is opaque and sits on top of the whole
// shell; hiding the shell keeps its aurora + animations from
// being composited every frame underneath while it still mounts
// and loads (providers, history, transport) in the background.
// `inert` additionally keeps the covered controls out of the
// keyboard tab order and assistive tech while it is hidden.
inert={showOnboarding ? true : undefined}
style={showOnboarding ? { visibility: "hidden" } : undefined}
>
<AgentSidebar
activeSessionId={activeHistorySessionId}
newTaskActive={newTaskActive}
onHome={handleHome}
onNavigateBack={handleNavigateBack}
onNavigateForward={handleNavigateForward}
onSettingsSectionChange={handleSettingsSectionChange}
sessionHistory={sessionHistory}
setView={handleViewChange}
settingsSection={settingsSection}
view={view}
canNavigateBack={navigation.back.length > 0}
canNavigateForward={navigation.forward.length > 0}
/>
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
{view === "sessions" ? (
<SessionsView
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
>
<AgentSidebar
activeSessionId={activeHistorySessionId}
history={sessionHistory}
newTaskActive={newTaskActive}
onHome={handleHome}
onNavigateBack={handleNavigateBack}
onNavigateForward={handleNavigateForward}
onOpenSearch={handleOpenCommandBar}
onSettingsSectionChange={handleSettingsSectionChange}
sessionHistory={sessionHistory}
setView={handleViewChange}
settingsSection={settingsSection}
view={view}
canNavigateBack={navigation.back.length > 0}
canNavigateForward={navigation.forward.length > 0}
/>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
initialPromptDraft={activeThread.initialPromptDraft}
knownWorkspacePaths={historyWorkspacePaths}
onInitialPromptDraftConsumed={
handleInitialPromptDraftConsumed
}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onOpenSessionById={handleOpenSessionById}
onOpenSetup={handleOpenSetup}
onOpenModelSettings={() =>
handleSettingsSectionChange("Models")
}
parentSession={activeParentSession}
onOpenVoiceInputSettings={() =>
handleSettingsSectionChange("Voice")
}
onThreadStarted={handleThreadStarted}
<SidebarRail />
</Sidebar>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<SidebarTrigger className="absolute left-20 top-0 z-40 md:hidden" />
<WindowTitleBar />
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{view === "sessions" ? (
<SessionsView
activeSessionId={activeHistorySessionId}
history={sessionHistory}
/>
) : activeThread ? (
<div
aria-hidden={view === "settings" ? true : undefined}
className="flex min-h-0 flex-1 flex-col"
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
historySession={activeThread.historySession}
initialPromptDraft={activeThread.initialPromptDraft}
knownWorkspacePaths={historyWorkspacePaths}
onInitialPromptDraftConsumed={
handleInitialPromptDraftConsumed
}
onUpdateSessionMetadata={handleUpdateSessionMetadata}
threadId={activeThread.id}
onDeleteSession={handleDeleteSession}
onNewThread={handleNewThread}
onOpenSession={handleOpenSession}
onOpenSessionById={handleOpenSessionById}
onOpenSetup={handleOpenSetup}
onOpenModelSettings={() =>
handleSettingsSectionChange("Models")
}
parentSession={activeParentSession}
onOpenVoiceInputSettings={() =>
handleSettingsSectionChange("Voice")
}
onThreadStarted={handleThreadStarted}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={handleSettingsSectionChange}
onOpenSession={handleOpenSessionById}
section={settingsSection}
/>
</div>
) : null}
</div>
</SidebarInset>
</div>
{showOnboarding ? (
<div className="fixed inset-0 z-50 bg-background">
<WindowTitleBar
className="absolute inset-x-0 top-0 z-10"
hostContent={false}
/>
<div className="h-full">
<OnboardingView
initialStep={onboardingInitialStep}
onComplete={completeOnboarding}
/>
</div>
) : null}
{view === "settings" ? (
<div className="absolute inset-0 z-30 bg-background text-foreground">
<SettingsView
onNavigateSection={handleSettingsSectionChange}
onOpenSession={handleOpenSessionById}
section={settingsSection}
/>
</div>
) : null}
</SidebarInset>
</div>
</div>
) : null}
</WindowTitleBarProvider>
</SidebarProvider>
{showOnboarding ? (
<div className="fixed inset-0 z-50">
<OnboardingView
initialStep={onboardingInitialStep}
onComplete={completeOnboarding}
/>
</div>
) : null}
<HubUpdateRequiredDialog />
<SessionCommandBar
onOpenChange={setCommandBarOpen}
onOpenSession={handleOpenSessionById}
open={commandBarOpen && !showOnboarding}
/>
</AccountProvider>
);
}
@@ -606,8 +638,6 @@ function ChatThreadPane({
promptInputRef.current = value;
}, []);
const [pendingAttachments, setPendingAttachments] = useState<File[]>([]);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const dragDepthRef = useRef(0);
const [showDiffView, setShowDiffView] = useState(false);
const [deletingSession, setDeletingSession] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -1257,52 +1287,6 @@ function ChatThreadPane({
});
}, []);
// Drag-and-drop file attachments. Requires `dragDropEnabled: false` on the
// Tauri window — otherwise the native shell swallows OS file drags and these
// HTML5 events never fire.
const handleDragEnter = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
dragDepthRef.current += 1;
setIsDraggingFiles(true);
}, []);
const handleDragOver = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
const handleDragLeave = useCallback((event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsDraggingFiles(false);
}
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}
event.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFiles(false);
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
handleAttachFiles(files);
}
},
[handleAttachFiles],
);
const attachmentList = useMemo(
() =>
pendingAttachments.map((file, index) => ({
@@ -1526,6 +1510,7 @@ function ChatThreadPane({
const composer = (
<ChatInputBar
attachments={attachmentList}
hasRunningAgents={agentActivity.running > 0}
onAbort={handleAbort}
onAttachFiles={handleAttachFiles}
onListGitBranches={listGitBranches}
@@ -1558,55 +1543,41 @@ function ChatThreadPane({
return (
<WorkspaceProvider value={workspaceContextValue}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: Drag-and-drop target only; the paperclip button is the accessible attach path. */}
<div
{/* Requires `dragDropEnabled: false` on the Tauri window so the native shell does not swallow OS file drags. */}
<AttachmentDropZone
className={
isWelcomeState
? "relative grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "relative grid h-full min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
? "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)] overflow-hidden"
: "grid h-full min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden"
}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onAttachFiles={handleAttachFiles}
>
{isDraggingFiles ? (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-card px-10 py-8 shadow-lg">
<ImagePlus className="h-8 w-8 text-primary" />
<p className="text-sm font-medium text-foreground">
Drop to attach
</p>
<p className="text-xs text-muted-foreground">
Screenshots and files will be added to your next message
</p>
</div>
</div>
) : null}
{!isWelcomeState ? (
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
agentActivity={agentActivity}
agents={agents}
agentsError={agentsError}
agentsLoading={agentsLoading}
onAgentsOpenChange={setAgentPanelOpen}
onOpenAgentSession={onOpenAgentSession}
onOpenParentSession={onOpenSessionById}
parentSession={hideDeletedSessionUi ? undefined : parentSession}
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={headerDiff}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={handleOpenDiff}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
/>
</div>
<WindowTitleBarContent>
<div className="cline-view-enter z-20 border-b border-border/70 bg-background/85 backdrop-blur-sm">
<AgentHeader
agentActivity={agentActivity}
agents={agents}
agentsError={agentsError}
agentsLoading={agentsLoading}
onAgentsOpenChange={setAgentPanelOpen}
onOpenAgentSession={onOpenAgentSession}
onOpenParentSession={onOpenSessionById}
parentSession={hideDeletedSessionUi ? undefined : parentSession}
canEditTitle={Boolean(activeSessionForTitle)}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={headerDiff}
onDeleteSession={requestDeleteSession}
onNewThread={onNewThread}
onOpenDiff={handleOpenDiff}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
status={status}
title={threadTitle}
/>
</div>
</WindowTitleBarContent>
) : null}
<WelcomeScreen
active={isWelcomeState}
@@ -1655,7 +1626,7 @@ function ChatThreadPane({
onOpenSession={onOpenSessionById}
onSwitchGitBranch={switchGitBranch}
/>
</div>
</AttachmentDropZone>
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(open) => {
@@ -22,17 +22,17 @@ afterEach(async () => {
});
describe("AgentHeader title bar", () => {
it("makes non-interactive header space draggable", async () => {
it("leaves drag-region ownership to the persistent window title bar", async () => {
await act(async () => {
root.render(<AgentHeader status="completed" title="A session" />);
});
expect(
container.querySelector("header")?.getAttribute("data-tauri-drag-region"),
).toBe("deep");
container.querySelector("header")?.hasAttribute("data-tauri-drag-region"),
).toBe(false);
});
it("renders a read-only title as draggable text", async () => {
it("renders a read-only title as non-interactive text", async () => {
await act(async () => {
root.render(<AgentHeader status="completed" title="Read-only session" />);
});
@@ -123,10 +123,7 @@ function AgentHeaderImpl({
const triggerDeleteSession = () => onDeleteSession?.();
return (
<header
className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7"
data-tauri-drag-region="deep"
>
<header className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7">
{/* Left: thread title */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<SessionStatus
@@ -88,20 +88,6 @@ async function hover(element: Element): Promise<void> {
});
}
async function changeField(
element: HTMLInputElement | HTMLTextAreaElement,
value: string,
): Promise<void> {
await act(async () => {
const prototype = Object.getPrototypeOf(element) as object;
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
setter?.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
await Promise.resolve();
});
}
function buttonWithText(text: string, rootNode: ParentNode = container) {
const button = [
...rootNode.querySelectorAll<HTMLButtonElement>("button"),
@@ -272,6 +258,155 @@ describe("AgentSidebar session organization", () => {
expect(buttonWithText("Tasks")).toBeDefined();
});
it("folds a schedule's runs into one collapsible row", async () => {
const run = (index: number) => ({
...makeThread("alpha", index),
title: "Report today's date to the user.",
isScheduled: true,
scheduleId: "sched_daily",
scheduleName: "Daily date report",
scheduleRunNumber: index,
});
const openThread = vi.fn();
const sessionHistory = makeSessionHistory(
[run(2), makeThread("beta", 1), run(1)],
vi.fn(),
);
(sessionHistory as { openThread: unknown }).openThread = openThread;
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
// One header per schedule, named after the schedule rather than the
// prompt, with the run count; the runs themselves start collapsed.
const header = sessionRow("Daily date report");
expect(header.textContent).toContain("2 runs");
expect(header.getAttribute("aria-expanded")).toBe("false");
expect(header.querySelector('[aria-label="Scheduled"]')).not.toBeNull();
expect(sessionIsVisible("Report today's date to the user.")).toBe(false);
expect(sessionIsVisible("Run 2")).toBe(false);
// The Scheduled section counts schedules, not runs.
expect(buttonWithText("Scheduled").textContent).toContain("1");
expect(sessionIsVisible("beta session 1")).toBe(true);
await click(header);
expect(header.getAttribute("aria-expanded")).toBe("true");
expect(sessionIsVisible("Run 2")).toBe(true);
expect(sessionIsVisible("Run 1")).toBe(true);
// Nested runs don't repeat the clock the header already shows.
expect(
sessionRow("Run 1").querySelector('[aria-label="Scheduled"]'),
).toBeNull();
expect(
sessionRow("Run 2").compareDocumentPosition(sessionRow("Run 1")) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await click(sessionRow("Run 1"));
expect(openThread).toHaveBeenCalledWith("alpha-1");
await click(header);
expect(sessionIsVisible("Run 1")).toBe(false);
});
it("expands the schedule group that holds the active session", async () => {
const run = (index: number) => ({
...makeThread("alpha", index),
isScheduled: true,
scheduleId: "sched_daily",
scheduleName: "Daily date report",
scheduleRunNumber: index,
});
const sessionHistory = makeSessionHistory([run(2), run(1)], vi.fn());
const render = async (activeSessionId: string) => {
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={activeSessionId}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
};
await render("alpha-1");
expect(sessionRow("Daily date report").getAttribute("aria-expanded")).toBe(
"true",
);
expect(sessionIsVisible("Run 1")).toBe(true);
// The group can still be collapsed while it holds the active session,
// and stays collapsed across re-renders.
await click(sessionRow("Daily date report"));
await render("alpha-1");
expect(sessionIsVisible("Run 1")).toBe(false);
// Opening another run of the schedule (e.g. from the Schedules
// settings page) reopens the collapsed group so the run is visible.
await render("alpha-2");
expect(sessionRow("Daily date report").getAttribute("aria-expanded")).toBe(
"true",
);
expect(sessionIsVisible("Run 2")).toBe(true);
});
it("groups scheduled runs inside their project when sorted by project", async () => {
const run = (index: number) => ({
...makeThread("alpha", index),
isScheduled: true,
scheduleId: "sched_daily",
scheduleName: "Daily date report",
scheduleRunNumber: index,
});
await act(async () => {
root.render(
<SidebarProvider>
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onSettingsSectionChange={vi.fn()}
sessionHistory={makeSessionHistory(
[run(2), makeThread("alpha", 3), run(1)],
vi.fn(),
)}
setView={vi.fn()}
settingsSection="General"
view="chat"
/>
</SidebarProvider>,
);
});
await switchToProjectSort();
const header = sessionRow("Daily date report");
expect(header.textContent).toContain("2 runs");
expect(sessionIsVisible("alpha session 3")).toBe(true);
expect(sessionIsVisible("Run 2")).toBe(false);
await click(header);
expect(sessionIsVisible("Run 2")).toBe(true);
});
it("defaults to Pinned, Scheduled, and Tasks sections sorted by time", async () => {
const pinned = { ...makeThread("alpha", 1), pinned: true };
const scheduled = { ...makeThread("beta", 1), isScheduled: true };
@@ -321,7 +456,10 @@ describe("AgentSidebar session organization", () => {
it("deletes a session through the row's hover trash button", async () => {
const deleteThread = vi.fn(async () => undefined);
const sessionHistory = makeSessionHistory([makeThread("alpha", 1)], vi.fn());
const sessionHistory = makeSessionHistory(
[makeThread("alpha", 1)],
vi.fn(),
);
(sessionHistory as { deleteThread: unknown }).deleteThread = deleteThread;
await act(async () => {
@@ -575,6 +713,19 @@ describe("AgentSidebar session organization", () => {
expect(
getSessionOverviewItems(thread).some(([label]) => label === "Status"),
).toBe(false);
// Scheduled runs lead with the schedule they belong to and which run
// this is; the row itself only says "Run N".
expect(
getSessionOverviewItems({
...makeThread("cline", 6),
isScheduled: true,
scheduleName: "Daily date report",
scheduleRunNumber: 6,
}).slice(0, 2),
).toEqual([
["Schedule", "Daily date report"],
["Run", "6"],
]);
});
it("shows the full first line of the session title", () => {
@@ -996,9 +1147,7 @@ describe("AgentSidebar session organization", () => {
expect(customizeRow.className.split(" ")).toContain(
"bg-surface-hover-lighter",
);
expect(customizeRow.className.split(" ")).not.toContain(
"bg-surface-hover",
);
expect(customizeRow.className.split(" ")).not.toContain("bg-surface-hover");
// Sub-tabs are indented under the parent row.
expect(installedRow.className.split(" ")).toContain("pl-8!");
@@ -1048,11 +1197,12 @@ describe("AgentSidebar session organization", () => {
expect(inactiveRow.getAttribute("aria-current")).toBeNull();
});
it("opens session search in a dialog from the logo-row icon", async () => {
it("opens the global search command bar from the logo-row icon without loading full history", async () => {
const sessionHistory = makeSessionHistory(
[makeThread("alpha", 1), makeThread("beta", 1)],
vi.fn(),
);
const onOpenSearch = vi.fn();
await act(async () => {
root.render(
<AccountProvider>
@@ -1060,6 +1210,7 @@ describe("AgentSidebar session organization", () => {
<AgentSidebar
activeSessionId={null}
onHome={vi.fn()}
onOpenSearch={onOpenSearch}
onSettingsSectionChange={vi.fn()}
sessionHistory={sessionHistory}
setView={vi.fn()}
@@ -1078,33 +1229,12 @@ describe("AgentSidebar session organization", () => {
);
expect(searchButton).not.toBeNull();
await click(searchButton as Element);
// Opening search pulls the full history so unloaded sessions match too.
expect(sessionHistory.loadAllSessions).toHaveBeenCalledOnce();
const searchInput = await vi.waitFor(() => {
const input = document.querySelector<HTMLInputElement>(
'[data-slot="command-input"]',
);
expect(input).not.toBeNull();
return input as HTMLInputElement;
});
expect(searchInput.placeholder).toBe("Search sessions...");
await changeField(searchInput, "alpha");
const match = await vi.waitFor(() => {
const items = [
...document.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
];
expect(items).toHaveLength(1);
return items[0] as HTMLElement;
});
expect(match.textContent).toContain("alpha session 1");
await click(match);
expect(sessionHistory.openThread).toHaveBeenCalledWith("alpha-1");
await vi.waitFor(() =>
expect(document.querySelector('[data-slot="command-input"]')).toBeNull(),
);
// The icon opens the indexed command bar owned by the page shell...
expect(onOpenSearch).toHaveBeenCalledOnce();
// ...instead of a sidebar-local dialog that eagerly pulled the entire
// session history just to filter titles client-side.
expect(sessionHistory.loadAllSessions).not.toHaveBeenCalled();
expect(document.querySelector('[data-slot="command-input"]')).toBeNull();
});
it("uses only the Cline logo for home in the collapsed sidebar", async () => {
@@ -46,13 +46,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
@@ -103,8 +96,13 @@ import {
getSessionSources,
} from "@/lib/session-history";
import {
groupScheduledThreads,
groupThreadsByProject,
INITIAL_VISIBLE_THREAD_COUNT,
type SidebarListRow,
type SidebarScheduleGroup,
scheduleGroupKey,
scheduleRunLabel,
workspaceDisplayName,
} from "@/lib/sidebar-session-organization";
import { cn } from "@/lib/utils";
@@ -255,6 +253,7 @@ export function AgentSidebar({
onHome,
onNavigateBack,
onNavigateForward,
onOpenSearch,
onSettingsSectionChange,
setView,
settingsSection,
@@ -269,6 +268,8 @@ export function AgentSidebar({
onHome: () => void;
onNavigateBack?: () => void;
onNavigateForward?: () => void;
/** Opens the global session search command bar (also bound to Cmd/Ctrl+P). */
onOpenSearch?: () => void;
onSettingsSectionChange: (section: SettingsSection) => void;
setView: (view: AppView) => void;
settingsSection: SettingsSection;
@@ -291,7 +292,6 @@ export function AgentSidebar({
forkThread: forkHistoryThread,
hasLoadedHistory,
isLoadingMore,
loadAllSessions,
loadOlderSessions,
mayHaveMoreSessions,
openThread: openHistoryThread,
@@ -305,7 +305,6 @@ export function AgentSidebar({
const [filter, setFilter] = useState<FilterOption>("All");
const [sourceFilter, setSourceFilter] = useState(ALL_SESSION_SOURCES);
const [sortMode, setSortMode] = useState<SidebarSortMode>("time");
const [searchOpen, setSearchOpen] = useState(false);
const [showMoreCount, setShowMoreCount] = useState(
INITIAL_VISIBLE_THREAD_COUNT,
);
@@ -331,6 +330,12 @@ export function AgentSidebar({
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(
() => new Set(),
);
// Explicit expand/collapse choices per schedule group. Groups without an
// entry default to expanded only while they hold the active session, so a
// run opened from elsewhere (e.g. the Schedules settings page) is visible.
const [scheduleGroupExpanded, setScheduleGroupExpanded] = useState<
Map<string, boolean>
>(() => new Map());
const [projectVisibleCounts, setProjectVisibleCounts] = useState<
Record<string, number>
>({});
@@ -422,20 +427,6 @@ export function AgentSidebar({
const navigateForward = useCallback(() => {
onNavigateForward?.();
}, [onNavigateForward]);
const openSearch = useCallback(() => {
setSearchOpen(true);
// The sidebar only pages in recent history; pull the rest so older
// sessions are searchable too.
void loadAllSessions();
}, [loadAllSessions]);
const openSearchResult = useCallback(
(threadId: string) => {
setSearchOpen(false);
openThread(threadId);
},
[openThread],
);
const startRenameThread = useCallback((thread: Thread) => {
setEditingSessionId(thread.id);
setEditingTitle(normalizeTitle(thread.title));
@@ -557,6 +548,40 @@ export function AgentSidebar({
return next;
});
}, []);
// Opening a session drops the stored choice for the group that holds it,
// so a run opened elsewhere is visible even if its group was collapsed
// earlier. Keyed on the session and group ids rather than the thread list
// so a history refresh doesn't undo a collapse made while it is open.
const activeScheduleGroupId = useMemo(() => {
const thread = threads.find((t) => t.id === activeThread);
return thread ? scheduleGroupKey(thread) : null;
}, [activeThread, threads]);
useEffect(() => {
if (!activeThread || !activeScheduleGroupId) return;
setScheduleGroupExpanded((current) => {
if (!current.has(activeScheduleGroupId)) return current;
const next = new Map(current);
next.delete(activeScheduleGroupId);
return next;
});
}, [activeThread, activeScheduleGroupId]);
const isScheduleGroupExpanded = useCallback(
(group: SidebarScheduleGroup) =>
scheduleGroupExpanded.get(group.id) ??
group.threads.some((thread) => thread.id === activeThread),
[activeThread, scheduleGroupExpanded],
);
const toggleScheduleGroup = useCallback(
(group: SidebarScheduleGroup) => {
const expanded = isScheduleGroupExpanded(group);
setScheduleGroupExpanded((current) => {
const next = new Map(current);
next.set(group.id, !expanded);
return next;
});
},
[isScheduleGroupExpanded],
);
const toggleProject = useCallback((project: string) => {
setCollapsedProjects((current) => {
const next = new Set(current);
@@ -655,13 +680,15 @@ export function AgentSidebar({
)}
</Button>
);
const threadItem = (thread: Thread) => (
const threadItem = (thread: Thread, options?: { nested?: boolean }) => (
<ThreadItem
editTitle={editingTitle}
editing={editingSessionId === thread.id}
hoverCardOpen={hoverCardThreadId === thread.id}
isActive={activeThread === thread.id}
key={thread.id}
label={options?.nested ? scheduleRunLabel(thread) : undefined}
nested={options?.nested}
onHoverCardOpenChange={(open) =>
setHoverCardThreadId((current) =>
open ? thread.id : current === thread.id ? null : current,
@@ -682,6 +709,30 @@ export function AgentSidebar({
unread={unreadSessionIds.has(thread.id)}
/>
);
// Scheduled runs collapse into one row per schedule; everything else
// renders as before.
const listRow = (row: SidebarListRow) => {
if (row.kind === "thread") {
return threadItem(row.thread);
}
const expanded = isScheduleGroupExpanded(row);
return (
<ScheduleGroupRow
active={row.threads.some((thread) => thread.id === activeThread)}
expanded={expanded}
group={row}
key={row.id}
onToggle={() => toggleScheduleGroup(row)}
unread={row.threads.some((thread) => unreadSessionIds.has(thread.id))}
>
{row.threads.map((thread) => threadItem(thread, { nested: true }))}
</ScheduleGroupRow>
);
};
const scheduledRows = useMemo(
() => groupScheduledThreads(scheduledThreads),
[scheduledThreads],
);
const customizeSectionOpen =
view === "settings" &&
(CUSTOMIZATION_SECTIONS as readonly SettingsSection[]).includes(
@@ -840,8 +891,8 @@ export function AgentSidebar({
<Button
aria-label="Search sessions"
className="size-8 shrink-0 justify-center px-0"
onClick={openSearch}
title="Search sessions"
onClick={onOpenSearch}
title="Search sessions (Cmd/Ctrl+P)"
type="button"
variant="sidebarItem"
>
@@ -1015,20 +1066,22 @@ export function AgentSidebar({
label="Pinned"
onToggle={() => toggleSection("pinned")}
>
{pinnedThreads.map(threadItem)}
{pinnedThreads.map((thread) =>
threadItem(thread),
)}
</CategorySection>
) : null}
{scheduledThreads.length > 0 ? (
{scheduledRows.length > 0 ? (
<CategorySection
collapsed={collapsedSections.has("scheduled")}
count={scheduledThreads.length}
count={scheduledRows.length}
label="Scheduled"
onToggle={() => toggleSection("scheduled")}
>
{scheduledThreads
{scheduledRows
.slice(0, scheduledVisibleCount)
.map(threadItem)}
{scheduledThreads.length >
.map(listRow)}
{scheduledRows.length >
scheduledVisibleCount ? (
<Button
className="px-2!"
@@ -1057,19 +1110,24 @@ export function AgentSidebar({
>
{taskThreads
.slice(0, showMoreCount)
.map(threadItem)}
.map((thread) => threadItem(thread))}
{showTimeShowMore ? timeShowMoreButton : null}
</CategorySection>
) : null}
</>
) : (
taskThreads.slice(0, showMoreCount).map(threadItem)
taskThreads
.slice(0, showMoreCount)
.map((thread) => threadItem(thread))
)
) : (
projectGroups.map((project) => {
const visibleCount =
projectVisibleCounts[project.id] ??
INITIAL_VISIBLE_THREAD_COUNT;
const projectRows = groupScheduledThreads(
project.threads,
);
return (
<ProjectSection
collapsed={collapsedProjects.has(project.id)}
@@ -1077,10 +1135,8 @@ export function AgentSidebar({
label={project.label}
onToggle={() => toggleProject(project.id)}
>
{project.threads
.slice(0, visibleCount)
.map(threadItem)}
{project.threads.length > visibleCount ? (
{projectRows.slice(0, visibleCount).map(listRow)}
{projectRows.length > visibleCount ? (
<Button
className="max-w-full pl-2!"
onClick={() => showMoreForProject(project.id)}
@@ -1210,35 +1266,6 @@ export function AgentSidebar({
)}
</div>
</div>
<CommandDialog
description="Search sessions by title, project, or path"
onOpenChange={setSearchOpen}
open={searchOpen}
title="Search sessions"
>
<CommandInput placeholder="Search sessions..." />
<CommandList>
<CommandEmpty>
{isLoadingMore
? "Searching older sessions..."
: "No sessions found."}
</CommandEmpty>
{threads.map((thread) => (
<CommandItem
key={thread.id}
onSelect={() => openSearchResult(thread.id)}
value={`${normalizeTitle(thread.title)} ${thread.codebase} ${thread.workspacePath} ${thread.id}`}
>
<span className="min-w-0 flex-1 truncate">
{normalizeTitle(thread.title)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{thread.time}
</span>
</CommandItem>
))}
</CommandList>
</CommandDialog>
<AlertDialog
open={deleteConfirmThread !== null}
onOpenChange={(open) => {
@@ -1361,12 +1388,83 @@ function ProjectSection({
);
}
function ScheduleGroupRow({
group,
expanded,
active,
unread,
onToggle,
children,
}: {
group: SidebarScheduleGroup;
expanded: boolean;
/** One of the runs is the open session; the header shows it while collapsed. */
active: boolean;
unread: boolean;
onToggle: () => void;
children: ReactNode;
}) {
const running = group.threads.some((thread) => thread.status === "running");
const statusDotClass = running ? "bg-green-500" : unread ? "bg-blue-500" : "";
const runCount = group.threads.length;
return (
<div className="min-w-0">
<button
aria-expanded={expanded}
className={cn(
"grid h-8 w-full max-w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 overflow-hidden rounded-md px-2 text-left text-sm font-normal",
active && !expanded
? "bg-surface-hover text-sidebar-foreground"
: "text-sidebar-foreground/80 hover:bg-surface-hover",
)}
onClick={onToggle}
title={group.label}
type="button"
>
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
<ChevronDown
className={cn(
"size-3 shrink-0 text-muted-foreground transition-transform",
!expanded && "-rotate-90",
)}
/>
<Clock3
aria-label="Scheduled"
className="size-3 shrink-0 text-muted-foreground"
/>
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
{group.label}
</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 text-xs tabular-nums text-muted-foreground">
{statusDotClass ? (
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", statusDotClass)}
/>
) : null}
<span>
{runCount} {runCount === 1 ? "run" : "runs"}
</span>
</span>
</button>
{expanded ? (
<div className="ml-4 flex min-w-0 flex-col gap-0.5 border-l border-sidebar-border/70 pl-1">
{children}
</div>
) : null}
</div>
);
}
function ThreadItem({
thread,
editTitle,
editing,
hoverCardOpen,
isActive,
label,
nested = false,
onClick,
onHoverCardOpenChange,
onCancelRename,
@@ -1384,6 +1482,10 @@ function ThreadItem({
editing: boolean;
hoverCardOpen: boolean;
isActive: boolean;
/** Row text when it should not be the session title (a run inside a schedule group). */
label?: string;
/** Rendered inside a schedule group: the group already shows the clock. */
nested?: boolean;
onClick: () => void;
onHoverCardOpenChange: (open: boolean) => void;
onCancelRename: () => void;
@@ -1397,6 +1499,7 @@ function ThreadItem({
unread: boolean;
}) {
const title = normalizeTitle(thread.title);
const rowText = label ?? title;
const overviewTitle = getSessionOverviewTitle(title);
const pending = pendingAction !== null;
const statusDotClass = pending
@@ -1460,14 +1563,14 @@ function ThreadItem({
type="button"
>
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
{thread.isScheduled ? (
{thread.isScheduled && !nested ? (
<Clock3
aria-label="Scheduled"
className="size-3 shrink-0 text-muted-foreground"
/>
) : null}
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
{title}
{rowText}
</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 text-sm text-muted-foreground">
@@ -1557,6 +1660,8 @@ export function getSessionOverviewItems(
// Updated time is already visible in the sidebar item.
const workspacePath = thread.workspacePath || thread.codebase;
const items: Array<[string, string | null | undefined, string?]> = [
["Schedule", thread.scheduleName],
["Run", thread.scheduleRunNumber ? String(thread.scheduleRunNumber) : null],
[
"Workspace",
workspaceDisplayName(workspacePath),
@@ -90,6 +90,15 @@ class FakeSpeechRecognition extends EventTarget {
});
this.dispatchEvent(event);
}
emitError(error: string, message?: string): void {
const event = new Event("error");
Object.defineProperties(event, {
error: { value: error },
message: { value: message },
});
this.dispatchEvent(event);
}
}
let container: HTMLDivElement;
@@ -180,6 +189,28 @@ describe("SpeechInput", () => {
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
});
it("preserves browser speech-recognition error details", async () => {
const onError = vi.fn();
await act(async () => {
root.render(<SpeechInput onError={onError} recordingMode="auto" />);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => button?.click());
await act(async () => {
FakeSpeechRecognition.instances[0]?.emitError("not-allowed");
});
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0]?.[0]).toEqual(
new Error("Speech recognition failed: not-allowed"),
);
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("records audio and forwards the provider transcript", async () => {
FakeMediaRecorder.deferStopEvents = true;
let resolveTranscript: (transcript: string) => void = () => {};
@@ -21,6 +21,11 @@ interface SpeechRecognitionEvent extends Event {
resultIndex: number;
}
interface SpeechRecognitionErrorEvent extends Event {
error: string;
message?: string;
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
@@ -37,6 +42,20 @@ interface SpeechRecognitionAlternative {
confidence: number;
}
function errorFromEvent(event: Event, fallbackMessage: string): Error {
const eventError = (event as Event & { error?: unknown }).error;
if (eventError instanceof Error) return eventError;
const eventMessage = (event as Event & { message?: unknown }).message;
if (typeof eventMessage === "string" && eventMessage.trim()) {
return new Error(eventMessage.trim());
}
if (typeof eventError === "string" && eventError.trim()) {
return new Error(`${fallbackMessage}: ${eventError.trim()}`);
}
return new Error(fallbackMessage);
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition;
@@ -191,7 +210,10 @@ export function SpeechInput({
};
const handleError = (event: Event) => {
setIsListening(false);
onErrorRef.current?.(event);
const speechError = event as SpeechRecognitionErrorEvent;
onErrorRef.current?.(
errorFromEvent(speechError, "Speech recognition failed"),
);
};
recognition.addEventListener("start", handleStart);
@@ -302,7 +324,7 @@ export function SpeechInput({
for (const track of stream.getTracks()) track.stop();
streamRef.current = null;
mediaRecorderRef.current = null;
onErrorRef.current?.(event);
onErrorRef.current?.(errorFromEvent(event, "Audio recording failed"));
});
recorder.addEventListener("stop", async () => {
for (const track of stream.getTracks()) track.stop();
@@ -14,14 +14,23 @@ import {
import {
checkForUpdateNow,
restartToApplyUpdate,
useAppUpdateStatus,
} from "@/hooks/use-app-update";
import { desktopClient } from "@/lib/desktop-client";
import { resolveHubUpdateRestartDecision } from "./hub-update-required-helpers";
import {
describeOutdatedHubSessions,
isPersistableHubMismatchKey,
resolveHubUpdateRestartDecision,
retainDismissalForIncomingMismatch,
shouldShowHubMismatchDialog,
} from "./hub-update-required-helpers";
type HubBuildMismatchPayload = {
hubBuildId?: string;
hubCoreVersion?: string;
reason?: string;
activeSessionCount?: number;
participantClientCount?: number;
};
function mismatchKeyOf(payload: HubBuildMismatchPayload): string {
@@ -30,38 +39,114 @@ function mismatchKeyOf(payload: HubBuildMismatchPayload): string {
type UpdatePhase = "idle" | "updating" | "restarting";
/** Generous deadline: drain wait + graceful retire + fresh daemon startup. */
const HUB_UPGRADE_TIMEOUT_MS = 60_000;
/**
* Blocking prompt shown when the sidecar reports that another Cline
* installation (for example an updated CLI) replaced the shared Cline Hub
* with a different build. Accepting runs an updater check/download right
* away and, once an update is staged, restarts into it so the app and the
* Hub run the same version again. If nothing is staged (no release published
* yet, or the check failed), the dialog explains why instead of restarting
* into the same version and immediately re-prompting.
* "Later" must survive webview remounts and reconnects: the sidecar replays
* a pending mismatch on every new webview connection (session switches,
* reloads, relaunches), and in-memory dismissal state resurrected the modal
* each time. Storage keeps one key - a different hub build prompts again.
*/
const DISMISSED_MISMATCH_STORAGE_KEY = "cline.hub-mismatch-dismissed";
function readPersistedDismissedKey(): string | null {
try {
const key = localStorage.getItem(DISMISSED_MISMATCH_STORAGE_KEY);
return isPersistableHubMismatchKey(key) ? key : null;
} catch {
return null;
}
}
function persistDismissedKey(key: string): void {
try {
localStorage.setItem(DISMISSED_MISMATCH_STORAGE_KEY, key);
} catch {
// Best effort: without storage the dismissal lasts this mount only.
}
}
// One updater kick per observed mismatch per page lifetime. Module scope
// survives component remounts (session switches) so the update feed is not
// re-hit every time the dialog mounts.
let updateCheckKickedForKey: string | null = null;
/**
* Blocking prompt shown when the sidecar reports that the shared Cline Hub
* does not match this app's build.
*
* Two directions, two dialogs:
* - `build_mismatch` / `unsupported_protocol`: the Hub is newer than this
* app. Accepting runs an updater check/download right away and, once an
* update is staged, restarts into it so the app and the Hub run the same
* version again. Dismissible - the app keeps working over the compatible
* wire protocol.
* - `outdated_hub`: this app is the newer build, and the running Hub was
* left in place only because it is still serving other clients' sessions.
* Not dismissible: the user chooses between updating the Hub now (which
* interrupts those sessions, then relaunches the app onto the fresh Hub)
* and quitting the app (the old Hub keeps running for its clients).
*/
export function HubUpdateRequiredDialog() {
const [mismatch, setMismatch] = useState<HubBuildMismatchPayload | null>(
null,
);
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
const [dismissedKey, setDismissedKey] = useState<string | null>(
readPersistedDismissedKey,
);
const [phase, setPhase] = useState<UpdatePhase>("idle");
const [updateHint, setUpdateHint] = useState<string | null>(null);
const updateStatus = useAppUpdateStatus();
useEffect(() => {
return desktopClient.subscribe("hub_build_mismatch", (payload) => {
// A null broadcast means the mismatch was resolved (the Hub was
// upgraded, possibly from another window): close the dialog.
if (payload === null) {
setMismatch(null);
setUpdateHint(null);
return;
}
if (!payload || typeof payload !== "object") {
return;
}
setMismatch(payload as HubBuildMismatchPayload);
const incoming = payload as HubBuildMismatchPayload;
setMismatch(incoming);
// A new mismatch is a fresh prompt: drop any "no update available"
// hint left over from a previous dialog so it reopens in its
// initial state instead of pre-set to "Try again".
setUpdateHint(null);
// Delivery includes replays on in-place transport reconnects, where
// this component never remounts: a non-persistable dismissal
// (unsupported_protocol) must not survive them, or the warning about
// a Hub the app cannot talk to stays silenced indefinitely.
setDismissedKey((previous) =>
retainDismissalForIncomingMismatch(previous, mismatchKeyOf(incoming)),
);
});
}, []);
const mismatchKey = mismatch ? mismatchKeyOf(mismatch) : null;
const open = mismatchKey !== null && mismatchKey !== dismissedKey;
// When a newer Hub appears, stage the matching app update right away (if
// a release exists) so the prompt can open actionable instead of waiting
// for the next 30s background cycle. Without a staged update the
// build_mismatch modal stays hidden entirely - see
// shouldShowHubMismatchDialog.
useEffect(() => {
if (
!mismatch ||
mismatch.reason !== "build_mismatch" ||
mismatchKey === null ||
mismatchKey === dismissedKey ||
updateCheckKickedForKey === mismatchKey
) {
return;
}
updateCheckKickedForKey = mismatchKey;
void checkForUpdateNow();
}, [mismatch, mismatchKey, dismissedKey]);
const handleUpdateAndRestart = useCallback(async () => {
setPhase("updating");
@@ -79,20 +164,114 @@ export function HubUpdateRequiredDialog() {
setPhase("idle");
}, []);
// `outdated_hub` is purely informational: this app is already the newer
// build, nothing is asked of the user, and the Hub is replaced on its own
// once its sessions end. Interrupting with a modal to say "ignore me"
// helps nobody, so that reason renders nothing.
const handleUpgradeHub = useCallback(async () => {
setPhase("updating");
setUpdateHint(null);
try {
await desktopClient.invoke("hub_upgrade", undefined, {
timeoutMs: HUB_UPGRADE_TIMEOUT_MS,
});
} catch (error) {
setUpdateHint(
error instanceof Error && error.message
? error.message
: "Updating the Cline Hub failed. Try again, or run 'cline doctor fix' in a terminal.",
);
setPhase("idle");
return;
}
setPhase("restarting");
setMismatch(null);
// Relaunch into a clean slate attached to the fresh Hub. In plain
// web/dev mode there is no Tauri shell to relaunch; reloading the page
// reconnects everything instead.
try {
await desktopClient.invoke("relaunch_app");
} catch {
window.location.reload();
}
}, []);
const handleQuit = useCallback(async () => {
try {
await desktopClient.invoke("quit_app");
} catch {
// Plain web/dev mode: no Tauri shell to exit. Best-effort close;
// browsers only honor this for script-opened windows.
window.close();
}
}, []);
// This app needs a newer Hub than the one running, and the running one was
// deliberately left in place because it is serving sessions. Block until
// the user picks a side: replace the Hub now, or quit and update later.
if (mismatch?.reason === "outdated_hub") {
return null;
return (
<AlertDialog open>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cline Hub update required</AlertDialogTitle>
<AlertDialogDescription>
Cline needs a newer Cline Hub, but the running one is still
serving {describeOutdatedHubSessions(mismatch)}.
</AlertDialogDescription>
<AlertDialogDescription>
Update Now stops that Hub and interrupts its sessions. Quit Cline
closes this app and leaves the Hub running, so you can update
later.
</AlertDialogDescription>
{updateHint ? (
<AlertDialogDescription>{updateHint}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={phase !== "idle"}
onClick={(event) => {
event.preventDefault();
void handleQuit();
}}
>
Quit Cline
</AlertDialogCancel>
<AlertDialogAction
disabled={phase !== "idle"}
onClick={(event) => {
event.preventDefault();
void handleUpgradeHub();
}}
>
{phase === "restarting"
? "Restarting…"
: phase === "updating"
? "Updating…"
: updateHint
? "Try again"
: "Update Now"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
const open =
mismatchKey !== null &&
mismatchKey !== dismissedKey &&
shouldShowHubMismatchDialog(mismatch?.reason, updateStatus.state);
return (
<AlertDialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && phase === "idle") {
if (!nextOpen && phase === "idle" && mismatchKey !== null) {
setDismissedKey(mismatchKey);
// unsupported_protocol never persists: hub-backed features
// stay broken against that Hub, so its warning must return
// on the next reconnect or relaunch.
if (isPersistableHubMismatchKey(mismatchKey)) {
persistDismissedKey(mismatchKey);
}
}
}}
>
@@ -1,5 +1,113 @@
import { describe, expect, it } from "vitest";
import { resolveHubUpdateRestartDecision } from "./hub-update-required-helpers";
import {
describeOutdatedHubSessions,
isPersistableHubMismatchKey,
resolveHubUpdateRestartDecision,
retainDismissalForIncomingMismatch,
shouldShowHubMismatchDialog,
} from "./hub-update-required-helpers";
describe("shouldShowHubMismatchDialog", () => {
it("always allows the truly-broken and blocking reasons", () => {
for (const state of [
"idle",
"checking",
"downloading",
"ready",
"error",
undefined,
] as const) {
expect(shouldShowHubMismatchDialog("unsupported_protocol", state)).toBe(
true,
);
expect(shouldShowHubMismatchDialog("outdated_hub", state)).toBe(true);
}
});
it("persists dismissals only for the advisory build_mismatch case", () => {
expect(isPersistableHubMismatchKey("build_mismatch:abc123")).toBe(true);
expect(isPersistableHubMismatchKey("unsupported_protocol:abc123")).toBe(
false,
);
expect(isPersistableHubMismatchKey("outdated_hub:abc123")).toBe(false);
expect(isPersistableHubMismatchKey(null)).toBe(false);
expect(isPersistableHubMismatchKey("")).toBe(false);
});
it("reopens a dismissed protocol warning on redelivery, keeps advisory and unrelated dismissals", () => {
// A replayed unsupported_protocol mismatch clears its own dismissal:
// the app cannot talk to that Hub, so "Later" must not outlive an
// in-place reconnect replay.
expect(
retainDismissalForIncomingMismatch(
"unsupported_protocol:abc",
"unsupported_protocol:abc",
),
).toBeNull();
// The advisory newer-hub dismissal stands across replays.
expect(
retainDismissalForIncomingMismatch(
"build_mismatch:abc",
"build_mismatch:abc",
),
).toBe("build_mismatch:abc");
// A dismissal for a different mismatch is untouched.
expect(
retainDismissalForIncomingMismatch(
"build_mismatch:abc",
"unsupported_protocol:def",
),
).toBe("build_mismatch:abc");
expect(retainDismissalForIncomingMismatch(null, "build_mismatch:abc")).toBe(
null,
);
});
it("allows a newer-hub prompt only once an app update is staged", () => {
expect(shouldShowHubMismatchDialog("build_mismatch", "ready")).toBe(true);
for (const state of [
"idle",
"checking",
"downloading",
"error",
undefined,
] as const) {
expect(shouldShowHubMismatchDialog("build_mismatch", state)).toBe(false);
}
});
});
describe("describeOutdatedHubSessions", () => {
it("quantifies sessions and clients when the hub reported both", () => {
expect(
describeOutdatedHubSessions({
activeSessionCount: 2,
participantClientCount: 1,
}),
).toBe("2 active sessions from 1 connected Cline client");
expect(
describeOutdatedHubSessions({
activeSessionCount: 1,
participantClientCount: 3,
}),
).toBe("1 active session from 3 connected Cline clients");
});
it("omits the client clause when participant ids were unavailable", () => {
expect(
describeOutdatedHubSessions({
activeSessionCount: 4,
participantClientCount: 0,
}),
).toBe("4 active sessions");
});
it("falls back to an unquantified phrase when the hub could not answer", () => {
expect(describeOutdatedHubSessions({})).toBe(
"active sessions from other Cline clients",
);
});
});
describe("resolveHubUpdateRestartDecision", () => {
it("restarts only once an update is staged", () => {
@@ -4,6 +4,83 @@ export type HubUpdateRestartDecision =
| { action: "restart" }
| { action: "stay"; hint: string };
/**
* Whether a hub build mismatch may interrupt with a modal at all.
*
* - `unsupported_protocol` and `outdated_hub` always may: the first means
* the app cannot talk to the Hub, the second is the blocking
* replace-or-quit decision.
* - `build_mismatch` may only once an app update is actually staged. A
* newer Hub is advisory while the wire protocol still works, and without
* a staged update the modal's only exit is "no update available yet",
* which loops on every launch and webview reconnect until a release
* ships - so it stays silent until it can offer a real action.
*/
export function shouldShowHubMismatchDialog(
reason: string | undefined,
updateState: AppUpdateStatus["state"] | undefined,
): boolean {
if (reason === "unsupported_protocol" || reason === "outdated_hub") {
return true;
}
return updateState === "ready";
}
/**
* Only the advisory `build_mismatch` dismissal may persist across webview
* mounts and app relaunches. An `unsupported_protocol` Hub leaves hub-backed
* features broken, so that warning must return on every reconnect and
* relaunch - its "Later" lasts only for the current mount. Applied on both
* write and read, so a key persisted by any other path is ignored too.
* (Mismatch keys are `${reason}:${hubBuildId}`.)
*/
export function isPersistableHubMismatchKey(key: string | null): key is string {
return typeof key === "string" && key.startsWith("build_mismatch:");
}
/**
* What a dismissal becomes when the sidecar delivers a mismatch again - it
* replays the pending mismatch on every webview (re)connection, including
* in-place transport reconnects where the dialog never remounts. A reason
* whose dismissal may not outlive the moment (`unsupported_protocol`: the
* app cannot talk to the Hub) drops its matching in-memory "Later" so the
* warning reopens on the replay; the advisory `build_mismatch` dismissal
* stands. An unrelated dismissed key is kept either way.
*/
export function retainDismissalForIncomingMismatch(
previousDismissedKey: string | null,
incomingKey: string,
): string | null {
if (
previousDismissedKey === incomingKey &&
!isPersistableHubMismatchKey(incomingKey)
) {
return null;
}
return previousDismissedKey;
}
/**
* Human phrase for the live work an outdated Hub is serving, used by the
* blocking "Hub update required" dialog. Falls back to an unquantified
* phrase when the Hub could not answer the activity query.
*/
export function describeOutdatedHubSessions(counts: {
activeSessionCount?: number;
participantClientCount?: number;
}): string {
const sessions = counts.activeSessionCount;
if (typeof sessions !== "number" || sessions <= 0) {
return "active sessions from other Cline clients";
}
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
const clients = counts.participantClientCount;
if (typeof clients !== "number" || clients <= 0) {
return sessionsPhrase;
}
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
}
/**
* Decide what "Update and restart" should do after an on-demand updater
* check. Restart only when an update is actually staged - relaunching the
@@ -0,0 +1,571 @@
"use client";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Loader2,
Search,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import { basenamePath, formatRelativeTime } from "@/hooks/use-session-history";
import { desktopClient } from "@/lib/desktop-client";
import {
type ImportableSession,
importSelectionKey,
type ListImportableSessionsResponse,
SESSION_IMPORT_TOOL_LABELS,
SESSION_IMPORT_TOOL_ORDER,
type SessionImportProgressEvent,
type SessionImportResult,
} from "@/lib/session-import";
import { cn } from "@/lib/utils";
type ImportPhase = "loading" | "pick" | "importing" | "done";
type ImportSessionsDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after at least one session imported successfully. */
onImported?: () => void;
};
/**
* Provider/model a new chat would start with right now, resolved the same
* way a new chat resolves it (remembered selection, then the built-in
* default). Imported sessions are stamped with it so "continue this in
* Cline" runs on what the user actually uses instead of the source tool's
* provider. Reading model-selection storage directly is not enough: the
* composer only records a model when the user picks one explicitly, so
* anyone on the default model has no entry there.
*/
function resumeTarget(): { provider: string; model: string } {
const { provider, model } = getInitialChatConfig();
return { provider, model };
}
function matchesQuery(session: ImportableSession, query: string): boolean {
if (!query) return true;
const haystack =
`${session.title} ${session.cwd} ${session.preview ?? ""}`.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.every((term) => haystack.includes(term));
}
export function ImportSessionsDialog({
open,
onOpenChange,
onImported,
}: ImportSessionsDialogProps) {
const [phase, setPhase] = useState<ImportPhase>("loading");
const [scanError, setScanError] = useState<string | null>(null);
const [sessions, setSessions] = useState<ImportableSession[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [collapsedTools, setCollapsedTools] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [progress, setProgress] = useState<{ done: number; total: number }>({
done: 0,
total: 0,
});
const [results, setResults] = useState<SessionImportResult[]>([]);
const scan = useCallback(async () => {
setPhase("loading");
setScanError(null);
try {
const response =
await desktopClient.invoke<ListImportableSessionsResponse>(
"list_importable_sessions",
{},
// Scanning reads every source session file once; large
// histories can take longer than the default RPC timeout.
{ timeoutMs: 120_000 },
);
setSessions(response.sessions ?? []);
setPhase("pick");
} catch (error) {
setScanError(error instanceof Error ? error.message : String(error));
setPhase("pick");
}
}, []);
// Fresh state on every open, then scan.
useEffect(() => {
if (!open) return;
setSelected(new Set());
setCollapsedTools(new Set());
setQuery("");
setResults([]);
setProgress({ done: 0, total: 0 });
void scan();
}, [open, scan]);
useEffect(() => {
if (!open) return;
return desktopClient.subscribe("session_import_progress", (payload) => {
const event = payload as SessionImportProgressEvent | undefined;
if (!event || typeof event.index !== "number") return;
setProgress({ done: event.index + 1, total: event.total });
if (event.result) {
setResults((previous) => [...previous, event.result]);
}
});
}, [open]);
const visibleSessions = useMemo(
() => sessions.filter((session) => matchesQuery(session, query.trim())),
[sessions, query],
);
const groups = useMemo(
() =>
SESSION_IMPORT_TOOL_ORDER.map((tool) => ({
tool,
sessions: visibleSessions.filter((session) => session.tool === tool),
})).filter((group) => group.sessions.length > 0),
[visibleSessions],
);
const selectableKeys = useCallback(
(items: ImportableSession[]) =>
items
.filter((session) => !session.alreadyImportedSessionId)
.map((session) => importSelectionKey(session.tool, session.sourceId)),
[],
);
const isFiltering = query.trim().length > 0;
const visibleSelectableKeys = selectableKeys(visibleSessions);
const selectedVisibleCount = visibleSelectableKeys.filter((key) =>
selected.has(key),
).length;
const allVisibleSelected =
visibleSelectableKeys.length > 0 &&
selectedVisibleCount === visibleSelectableKeys.length;
const toggleAll = (items: ImportableSession[], checked: boolean) => {
setSelected((previous) => {
const next = new Set(previous);
for (const key of selectableKeys(items)) {
if (checked) next.add(key);
else next.delete(key);
}
return next;
});
};
const startImport = async () => {
const selections = sessions
.filter((session) =>
selected.has(importSelectionKey(session.tool, session.sourceId)),
)
.map((session) => ({ tool: session.tool, sourceId: session.sourceId }));
if (selections.length === 0) return;
setPhase("importing");
setResults([]);
setProgress({ done: 0, total: selections.length });
try {
const response = await desktopClient.invoke<{
results: SessionImportResult[];
}>(
"import_sessions",
{ selections, ...resumeTarget() },
{ timeoutMs: null },
);
// The progress events already streamed results; trust the final
// response as the authoritative list.
setResults(response.results ?? []);
if ((response.results ?? []).some((result) => result.ok)) {
onImported?.();
}
} catch (error) {
setResults([
{
tool: "claude-code",
sourceId: "",
ok: false,
error: error instanceof Error ? error.message : String(error),
},
]);
}
setPhase("done");
};
const succeeded = results.filter((result) => result.ok);
const failures = results.filter((result) => !result.ok);
const importableCount = sessions.filter(
(session) => !session.alreadyImportedSessionId,
).length;
return (
<Dialog
onOpenChange={(next) => {
// Don't let a stray overlay click abandon a running import.
if (!next && phase === "importing") return;
onOpenChange(next);
}}
open={open}
>
{/* The single column must be minmax(0,1fr): with the default auto
track, one unbreakable string in a session title widens the
column's min-content beyond the fixed dialog width and
overflow-hidden clips the header and search field. The explicit
sm:max-w-none is needed because the primitive's sm:max-w-lg
survives class merging across variants. */}
<DialogContent className="grid h-[min(680px,calc(100dvh-2rem))] w-[min(620px,calc(100vw-2rem))] max-w-none grid-cols-[minmax(0,1fr)] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 overflow-hidden sm:max-w-none">
<DialogHeader>
<DialogTitle>Import sessions</DialogTitle>
<DialogDescription>
Bring your conversation history from other coding tools into Cline.
Imported sessions appear in your history and can be continued here.
</DialogDescription>
</DialogHeader>
{phase === "loading" ? (
<div className="flex min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<p className="text-sm">Scanning for sessions</p>
</div>
) : null}
{phase === "pick" ? (
<div className="flex min-h-0 flex-col gap-3">
{scanError ? (
<p className="text-sm text-destructive" role="alert">
Couldn't scan for sessions: {scanError}
</p>
) : null}
{sessions.length === 0 && !scanError ? (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 text-center">
<p className="text-sm font-medium text-foreground">
No sessions found
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Cline looks for local history from Claude Code, Codex, and
opencode. Nothing importable turned up on this machine.
</p>
</div>
) : null}
{sessions.length > 0 ? (
<>
<div className="relative shrink-0">
<Search className="-translate-y-1/2 pointer-events-none absolute left-2.5 top-1/2 size-4 text-muted-foreground" />
<Input
aria-label="Filter sessions"
className="h-8 pl-8"
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter by title or folder"
value={query}
/>
</div>
<div className="flex shrink-0 items-center gap-2 border-b pb-2">
<Checkbox
aria-label="Select all sessions"
checked={
allVisibleSelected
? true
: selectedVisibleCount > 0
? "indeterminate"
: false
}
disabled={visibleSelectableKeys.length === 0}
id="import-select-all"
onCheckedChange={(checked) =>
toggleAll(visibleSessions, checked === true)
}
/>
<label
className="cursor-pointer text-sm text-foreground"
htmlFor="import-select-all"
>
Select all
</label>
<span className="ml-auto text-xs text-muted-foreground">
{selectedVisibleCount} of {visibleSelectableKeys.length}{" "}
selected
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{groups.map((group) => {
const keys = selectableKeys(group.sessions);
const selectedInGroup = keys.filter((key) =>
selected.has(key),
).length;
const allChecked =
keys.length > 0 && selectedInGroup === keys.length;
// A collapsed section would hide filter matches, so
// filtering forces every section open.
const collapsed =
!isFiltering && collapsedTools.has(group.tool);
return (
<section className="mb-4" key={group.tool}>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-background py-1.5">
<Checkbox
aria-label={`Select all ${SESSION_IMPORT_TOOL_LABELS[group.tool]} sessions`}
checked={
allChecked
? true
: selectedInGroup > 0
? "indeterminate"
: false
}
disabled={keys.length === 0}
onCheckedChange={(checked) =>
toggleAll(group.sessions, checked === true)
}
/>
<button
aria-expanded={!collapsed}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm py-0.5 text-left hover:text-foreground"
onClick={() =>
setCollapsedTools((previous) => {
const next = new Set(previous);
if (next.has(group.tool)) {
next.delete(group.tool);
} else {
next.add(group.tool);
}
return next;
})
}
type="button"
>
<h3 className="text-sm font-semibold text-foreground">
{SESSION_IMPORT_TOOL_LABELS[group.tool]}
</h3>
<span className="text-xs text-muted-foreground">
{group.sessions.length}
{selectedInGroup > 0
? ` · ${selectedInGroup} selected`
: ""}
</span>
{collapsed ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="ml-auto size-4 shrink-0 text-muted-foreground" />
)}
</button>
</div>
<ul hidden={collapsed}>
{group.sessions.map((session) => {
const key = importSelectionKey(
session.tool,
session.sourceId,
);
const alreadyImported = Boolean(
session.alreadyImportedSessionId,
);
const checked = selected.has(key);
const checkboxId = `import-session-${key}`;
return (
<li key={key}>
<label
className={cn(
"flex cursor-pointer items-start gap-2.5 rounded-md px-1.5 py-2 hover:bg-muted/40",
alreadyImported &&
"cursor-default opacity-60",
)}
htmlFor={checkboxId}
>
<Checkbox
aria-label={`Import "${session.title}"`}
checked={checked}
className="mt-0.5"
disabled={alreadyImported}
id={checkboxId}
onCheckedChange={(next) =>
setSelected((previous) => {
const nextSet = new Set(previous);
if (next === true) nextSet.add(key);
else nextSet.delete(key);
return nextSet;
})
}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-start gap-2">
<span className="line-clamp-2 min-w-0 break-words text-sm text-foreground">
{session.title}
</span>
{alreadyImported ? (
<Badge
className="shrink-0"
variant="secondary"
>
Imported
</Badge>
) : null}
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">
{formatRelativeTime(
new Date(
session.updatedAtMs,
).toISOString(),
)}
</span>
<span aria-hidden>·</span>
<span className="shrink-0">
{session.messageCount} message
{session.messageCount === 1 ? "" : "s"}
</span>
{session.cwd ? (
<>
<span aria-hidden>·</span>
<span className="min-w-0 truncate">
{basenamePath(session.cwd)}
</span>
</>
) : null}
</span>
</span>
</label>
</li>
);
})}
</ul>
</section>
);
})}
{groups.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No sessions match "{query.trim()}".
</p>
) : null}
</div>
</>
) : null}
</div>
) : null}
{phase === "importing" ? (
<div className="flex min-h-0 flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-sm">
<span className="text-foreground">Importing sessions</span>
<span className="text-muted-foreground">
{progress.done} / {progress.total}
</span>
</div>
<Progress
value={
progress.total > 0
? (progress.done / progress.total) * 100
: 0
}
/>
</div>
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1 text-sm">
{results.map((result) => (
<li
className="flex items-center gap-2"
key={importSelectionKey(result.tool, result.sourceId)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0 text-muted-foreground" />
) : (
<AlertCircle className="size-4 shrink-0 text-destructive" />
)}
<span className="min-w-0 truncate text-muted-foreground">
{result.title ?? result.sourceId}
</span>
</li>
))}
</ul>
</div>
) : null}
{phase === "done" ? (
<div className="flex min-h-0 flex-col gap-3">
<p className="text-sm text-foreground">
{succeeded.length > 0
? `Imported ${succeeded.length} session${succeeded.length === 1 ? "" : "s"}.`
: "No sessions were imported."}
{failures.length > 0
? ` ${failures.length} failed.`
: succeeded.length > 0
? " They're in your history now."
: ""}
</p>
{failures.length > 0 ? (
<ul className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 text-sm">
{failures.map((failure) => (
<li
className="flex items-start gap-2"
key={importSelectionKey(failure.tool, failure.sourceId)}
>
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<span className="min-w-0">
<span className="block truncate text-foreground">
{failure.title ?? failure.sourceId}
</span>
<span className="block text-xs text-muted-foreground">
{failure.error}
</span>
</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
<DialogFooter>
{phase === "pick" ? (
<>
<span className="mr-auto self-center text-xs text-muted-foreground">
{selected.size > 0
? `${selected.size} selected`
: importableCount > 0
? `${importableCount} available`
: ""}
</span>
<Button
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button
disabled={selected.size === 0}
onClick={() => void startImport()}
type="button"
>
Import{selected.size > 0 ? ` ${selected.size}` : ""}
</Button>
</>
) : null}
{phase === "importing" ? (
<Button disabled type="button">
<Loader2 className="size-4 animate-spin" />
Importing
</Button>
) : null}
{phase === "done" ? (
<Button onClick={() => onOpenChange(false)} type="button">
Done
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,171 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionCommandBar } from "@/components/session-command-bar";
const desktopMocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
type SearchHit = {
sessionId: string;
documentId: string;
ordinal: number;
role: string;
startedAt: string;
workspaceRoot: string;
title: string;
snippet: string;
};
let container: HTMLDivElement;
let root: Root;
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function changeInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function waitForDebounce() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 210));
});
}
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
desktopMocks.invoke.mockReset();
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
HTMLElement.prototype.setPointerCapture = vi.fn();
HTMLElement.prototype.releasePointerCapture = vi.fn();
HTMLElement.prototype.scrollIntoView = vi.fn();
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("SessionCommandBar", () => {
it("keeps typing responsive, ignores stale searches, and lazily renders bounded results", async () => {
const firstSearch = deferred<SearchHit[]>();
const secondSearch = deferred<SearchHit[]>();
desktopMocks.invoke
.mockReturnValueOnce(firstSearch.promise)
.mockReturnValueOnce(secondSearch.promise);
const onOpenChange = vi.fn();
const onOpenSession = vi.fn();
await act(async () => {
root.render(
<SessionCommandBar
onOpenChange={onOpenChange}
onOpenSession={onOpenSession}
open
/>,
);
});
const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
expect(input).not.toBeNull();
expect(document.body.textContent).toContain("Cmd/Ctrl+P");
await changeInput(input as HTMLInputElement, "generate");
await waitForDebounce();
expect(desktopMocks.invoke).toHaveBeenCalledTimes(1);
await changeInput(input as HTMLInputElement, "generate puppy");
expect(input?.value).toBe("generate puppy");
expect(document.querySelectorAll("[cmdk-item]")).toHaveLength(0);
await waitForDebounce();
expect(desktopMocks.invoke).toHaveBeenCalledTimes(2);
firstSearch.resolve([
{
sessionId: "stale-session",
documentId: "stale-session:0",
ordinal: 0,
role: "user",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
title: "stale result",
snippet: "stale result",
},
]);
await act(async () => Promise.resolve());
expect(document.querySelectorAll("[cmdk-item]")).toHaveLength(0);
const oversized = `<user_input mode="act">${"generate puppy ".repeat(3_000)}</user_input>`;
secondSearch.resolve(
Array.from({ length: 40 }, (_, index) => ({
sessionId: `session-${index}`,
documentId: `session-${index}:0`,
ordinal: 0,
role: "user",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
title: oversized,
snippet: oversized,
})),
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const initialItems = document.querySelectorAll<HTMLElement>("[cmdk-item]");
expect(initialItems).toHaveLength(15);
expect(document.body.textContent).toContain("Showing 15 of 40 results");
expect(document.body.textContent).not.toContain("user_input");
expect(initialItems[0]?.querySelector("svg")).toBeNull();
expect(initialItems[0]?.getAttribute("data-value")?.length).toBeLessThan(
1_000,
);
expect(onOpenChange).not.toHaveBeenCalled();
expect(onOpenSession).not.toHaveBeenCalled();
const list = document.querySelector<HTMLElement>("[cmdk-list]");
expect(list).not.toBeNull();
Object.defineProperties(list as HTMLElement, {
scrollHeight: { configurable: true, value: 1_000 },
clientHeight: { configurable: true, value: 300 },
scrollTop: { configurable: true, value: 650 },
});
await act(async () => {
list?.dispatchEvent(new Event("scroll", { bubbles: true }));
});
expect(document.querySelectorAll("[cmdk-item]")).toHaveLength(30);
expect(document.body.textContent).toContain("Showing 30 of 40 results");
});
});
@@ -0,0 +1,201 @@
"use client";
import {
formatSessionSearchPreview,
formatSessionSearchTitle,
} from "@cline/shared/browser";
import { Loader2 } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { desktopClient } from "@/lib/desktop-client";
type SessionSearchHit = {
sessionId: string;
documentId: string;
ordinal: number;
role: string;
startedAt: string;
workspaceRoot: string;
title: string;
snippet: string;
};
const SESSION_SEARCH_RESULT_BATCH_SIZE = 15;
export function SessionCommandBar({
open,
onOpenChange,
onOpenSession,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenSession: (sessionId: string) => void | Promise<void>;
}) {
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SessionSearchHit[]>([]);
const [searching, setSearching] = useState(false);
const [visibleHitCount, setVisibleHitCount] = useState(
SESSION_SEARCH_RESULT_BATCH_SIZE,
);
useEffect(() => {
if (!open) {
setQuery("");
setHits([]);
setSearching(false);
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
}
}, [open]);
useEffect(() => {
const normalized = query.trim();
if (!open || !normalized) {
setHits([]);
setSearching(false);
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
return;
}
let cancelled = false;
setSearching(true);
const timer = setTimeout(() => {
void desktopClient
.invoke<SessionSearchHit[]>(
"search_sessions",
{
query: normalized,
limit: 50,
},
{ timeoutMs: 3_000 },
)
.then((results) => {
if (!cancelled) {
setHits(
results.map((hit) => ({
...hit,
title: formatSessionSearchTitle(hit.title),
snippet: formatSessionSearchPreview(hit.role, hit.snippet),
})),
);
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
}
})
.catch(() => {
if (!cancelled) setHits([]);
})
.finally(() => {
if (!cancelled) setSearching(false);
});
}, 180);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [open, query]);
const handleQueryChange = (value: string) => {
setQuery(value);
setHits([]);
setVisibleHitCount(SESSION_SEARCH_RESULT_BATCH_SIZE);
setSearching(Boolean(value.trim()));
};
const handleResultListScroll = (event: UIEvent<HTMLDivElement>) => {
const list = event.currentTarget;
if (list.scrollHeight - list.scrollTop - list.clientHeight > 120) return;
setVisibleHitCount((current) =>
Math.min(current + SESSION_SEARCH_RESULT_BATCH_SIZE, hits.length),
);
};
const visibleHits = hits.slice(0, visibleHitCount);
return (
<CommandDialog
className="h-[min(38rem,calc(100vh-2rem))] w-[min(56rem,calc(100vw-2rem))] max-w-none sm:max-w-none"
description="Search messages across all Cline sessions"
onOpenChange={onOpenChange}
open={open}
// Hits arrive filtered and ranked by the FTS index; letting cmdk
// re-score them would reorder results and drop hits whose matched
// text lives outside the truncated snippet.
shouldFilter={false}
showCloseButton={false}
title="Search session history"
>
<CommandInput
onValueChange={handleQueryChange}
placeholder="Search all session history…"
value={query}
/>
<CommandList
className="min-h-0 max-h-none flex-1"
onScroll={handleResultListScroll}
>
{searching ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Searching sessions
</div>
) : null}
{!searching && query.trim() ? (
<CommandEmpty>No matching session history.</CommandEmpty>
) : null}
{!searching && hits.length > 0 ? (
<CommandGroup heading="Session history">
{visibleHits.map((hit) => (
<CommandItem
className="items-start py-3"
key={hit.documentId}
onSelect={() => {
onOpenChange(false);
void onOpenSession(hit.sessionId);
}}
value={`${hit.documentId} ${hit.title} ${hit.snippet} ${hit.workspaceRoot}`}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs capitalize text-muted-foreground">
{hit.role}
</span>
<span className="truncate font-medium">{hit.title}</span>
</div>
<p className="mt-1 line-clamp-2 whitespace-normal text-xs text-muted-foreground">
{hit.snippet}
</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground/70">
{hit.workspaceRoot}
</p>
</div>
</CommandItem>
))}
{visibleHitCount < hits.length ? (
<div className="py-2 text-center text-[11px] text-muted-foreground">
Showing {visibleHitCount} of {hits.length} results
</div>
) : null}
</CommandGroup>
) : null}
{!query.trim() ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
Search messages, commands, errors, and file paths.
</div>
) : null}
</CommandList>
<div className="flex items-center justify-between border-t px-3 py-2 text-[11px] text-muted-foreground">
<span>Navigate with and open with </span>
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-sans">
Cmd/Ctrl+P
</kbd>
</div>
</CommandDialog>
);
}
@@ -34,12 +34,15 @@ function CommandDialog({
children,
className,
showCloseButton = true,
shouldFilter = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
/** Pass false when results are already filtered/ranked by the caller (e.g. server-side search). */
shouldFilter?: boolean;
}) {
return (
<Dialog {...props}>
@@ -51,7 +54,10 @@ function CommandDialog({
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<Command
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
shouldFilter={shouldFilter}
>
{children}
</Command>
</DialogContent>
@@ -152,15 +152,21 @@ function deferred<T>() {
}
async function renderVoiceComposer({
hasRunningAgents = false,
onAbort = vi.fn(),
onPromptInputChange = vi.fn(),
onSend = vi.fn(),
prompt = "",
promptVersion = 0,
status = "idle",
}: {
hasRunningAgents?: boolean;
onAbort?: ReturnType<typeof vi.fn>;
onPromptInputChange?: ReturnType<typeof vi.fn>;
onSend?: ReturnType<typeof vi.fn>;
prompt?: string;
promptVersion?: number;
status?: ChatSessionStatus;
} = {}) {
await act(async () => {
root.render(
@@ -168,9 +174,10 @@ async function renderVoiceComposer({
<ChatInputBar
attachments={[]}
gitBranch="main"
hasRunningAgents={hasRunningAgents}
mode="act"
model="test-model"
onAbort={vi.fn()}
onAbort={onAbort}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
@@ -191,7 +198,7 @@ async function renderVoiceComposer({
promptsInQueue={[]}
provider="cline"
reasoningEffort="low"
status="idle"
status={status}
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
@@ -202,6 +209,23 @@ async function renderVoiceComposer({
}
describe("ChatInputBar", () => {
it("allows a parent session with a running child agent to be stopped", async () => {
const onAbort = vi.fn();
await renderVoiceComposer({
hasRunningAgents: true,
onAbort,
status: "idle",
});
const stopButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Stop agent"]',
);
expect(stopButton).not.toBeNull();
await act(async () => stopButton?.click());
expect(onAbort).toHaveBeenCalledOnce();
});
it("builds slash commands from both workflows and skills", () => {
expect(
buildUserInstructionSlashCommands({
@@ -279,6 +279,7 @@ export type PromptDraft = {
type ChatInputBarProps = {
variant?: "conversation" | "welcome";
status: ChatSessionStatus;
hasRunningAgents?: boolean;
provider: string;
model: string;
modelContextWindow?: number;
@@ -322,6 +323,7 @@ type ChatInputBarProps = {
function ChatInputBarImpl({
variant = "conversation",
status,
hasRunningAgents = false,
provider,
model,
modelContextWindow,
@@ -346,6 +348,7 @@ function ChatInputBarImpl({
onSteerPromptInQueue,
onEditPromptInQueue,
onRemovePromptInQueue,
onOpenVoiceInputSettings,
summary,
}: ChatInputBarProps) {
const {
@@ -416,7 +419,8 @@ function ChatInputBarImpl({
}, [promptDraft, setPromptInput]);
const isBusy =
status === "starting" || status === "running" || status === "stopping";
const canAbort = status === "running" || status === "stopping";
const canAbort =
status === "running" || status === "stopping" || hasRunningAgents;
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
const [speechInputActive, setSpeechInputActive] = useState(false);
const speechInputActiveRef = useRef(false);
@@ -749,24 +753,38 @@ function ChatInputBarImpl({
[transcriptionTarget],
);
const handleSpeechInputError = useCallback((error: unknown) => {
const message =
error instanceof Error
? error.message
: "Check microphone permission and audio provider settings.";
writeDesktopDebugLog({
scope: "voice-input",
level: "error",
message: "Speech input failed in the webview",
timestamp: new Date().toISOString(),
metadata: { failure: message },
});
toast({
variant: "destructive",
title: "Speech input failed",
description: message,
});
}, []);
const handleSpeechInputError = useCallback(
(error: unknown) => {
// Microphone failures surface as DOMExceptions (getUserMedia) or
// capture-layer events; provider failures (credentials, transcription
// setup) as plain Errors, and are fixed in Settings → Voice.
const isMicrophoneError =
error instanceof DOMException || error instanceof Event;
const message =
error instanceof Error
? error.message
: "Check microphone permission and audio provider settings.";
writeDesktopDebugLog({
scope: "voice-input",
level: "error",
message: "Speech input failed in the webview",
timestamp: new Date().toISOString(),
metadata: { failure: message },
});
if (!isMicrophoneError && onOpenVoiceInputSettings) {
onOpenVoiceInputSettings();
return;
}
toast({
variant: "destructive",
title: "Speech input failed",
description: isMicrophoneError
? "Check the microphone permission for Cline and try again."
: message,
});
},
[onOpenVoiceInputSettings],
);
const effortIndex = useMemo(
() => resolveEffortIndex(thinking, reasoningEffort),
@@ -191,6 +191,201 @@ describe("ChatMessages tool disclosures", () => {
);
});
it("renders image content returned by a tool instead of raw base64", async () => {
const screenshotData = "aGVsbG8=";
await renderMessages([
{
id: "tool-screenshot",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "computer_use",
input: { action: "screenshot" },
result: [
{
type: "text",
text: "Screenshot captured at /tmp/screenshot.png",
},
{
type: "image",
data: screenshotData,
mimeType: "image/png",
},
],
}),
createdAt: 1,
},
]);
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Computer use"),
);
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain(
"Screenshot captured at /tmp/screenshot.png",
);
expect(container.textContent).not.toContain(screenshotData);
const image = container.querySelector<HTMLImageElement>(
'img[alt="Generated result 1"]',
);
expect(image?.src).toBe(`data:image/png;base64,${screenshotData}`);
await act(async () => image?.closest("button")?.click());
expect(
container.querySelector(
'[role="dialog"][aria-label="Expanded attachment"]',
),
).not.toBeNull();
});
it("navigates multiple images returned by a single tool call", async () => {
await renderMessages([
{
id: "tool-multi-screenshot",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "computer_use",
input: { action: "screenshot" },
result: [
{ type: "image", data: "Zmlyc3Q=", mimeType: "image/png" },
{ type: "image", data: "c2Vjb25k", mimeType: "image/png" },
],
}),
createdAt: 1,
},
]);
expect(
container.querySelector<HTMLImageElement>('img[alt="Generated result 1"]')
?.src,
).toBe("data:image/png;base64,Zmlyc3Q=");
expect(container.querySelector('img[alt="Generated result 2"]')).toBeNull();
expect(container.textContent).toContain("1 / 2");
const next = container.querySelector<HTMLButtonElement>(
'button[aria-label="Next generated image"]',
);
await act(async () => next?.click());
expect(
container.querySelector<HTMLImageElement>('img[alt="Generated result 2"]')
?.src,
).toBe("data:image/png;base64,c2Vjb25k");
expect(container.textContent).toContain("2 / 2");
});
it("auto-expands submit_and_exit and renders its summary as markdown", async () => {
const summary = "## Report\n\nChecked **3 feeds**, all healthy.";
await renderMessages([
{
id: "tool-submit",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "submit_and_exit",
input: { summary, verified: true },
result: summary,
}),
createdAt: 1,
},
]);
// The final answer of the run is visible without a click…
const trigger = [...container.querySelectorAll("button")].find((element) =>
element.textContent?.includes("Scheduled task completed"),
);
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
// …rendered as markdown structure, not a monospace code block.
const panel = document.getElementById(
trigger?.getAttribute("aria-controls") ?? "",
);
const markdown = panel?.querySelector(".cline-markdown");
expect(markdown?.querySelector("h2")?.textContent).toBe("Report");
expect(markdown?.textContent).toContain("3 feeds");
expect(markdown?.textContent).not.toContain("##");
expect(markdown?.textContent).not.toContain("**");
// The final answer renders in full foreground color, overriding the
// panel's muted tool-detail gray.
expect(markdown?.closest(".text-foreground")).not.toBeNull();
});
it("labels an errored submit_and_exit as failed", async () => {
await renderMessages([
{
id: "tool-submit-error",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "submit_and_exit",
input: { summary: "Attempted report.", verified: false },
isError: true,
result: { error: "submit_and_exit timed out after 15000ms" },
}),
createdAt: 1,
},
]);
expect(container.textContent).toContain("Scheduled task failed");
expect(container.textContent).not.toContain("Scheduled task completed");
});
it("keeps the scheduled-task report visible when the run collapses", async () => {
// A follow-up prompt settles the scheduled run's span and folds its
// working rows into the work summary; the submit_and_exit row is the
// run's final report and must stay visible below it.
const summary = "All feeds healthy.";
await renderMessages([
{
id: "user-schedule",
sessionId: "session-1",
role: "user",
content: "Check the feeds",
createdAt: 1_000,
},
{
id: "tool-read",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "read_files",
input: { paths: ["feeds.json"] },
result: {},
}),
createdAt: 2_000,
},
{
id: "tool-submit",
sessionId: "session-1",
role: "tool",
content: JSON.stringify({
toolName: "submit_and_exit",
input: { summary, verified: true },
result: summary,
}),
createdAt: 3_000,
},
{
id: "user-followup",
sessionId: "session-1",
role: "user",
content: "Thanks!",
createdAt: 9_000,
},
]);
// The working rows folded into a collapsed work summary…
const workTrigger = container.querySelector(
"button.cline-chat-work-trigger",
);
expect(workTrigger?.getAttribute("aria-expanded")).toBe("false");
// …but the report row did not fold with them: it stays visible and
// expanded outside the summary.
expect(container.textContent).toContain("Scheduled task completed");
expect(container.textContent).toContain(summary);
});
it("renders consecutive tool calls as individual rows", async () => {
const tools: ChatMessage[] = [
{

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