Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 7d7cddb9d9 chore(desktop): release v0.0.7 2026-07-28 18:33:51 -07:00
Saoud Rizwan 55e169f1f8 feat(vscode): working-directory badge in task header for out-of-workspace tasks (#12637)
* feat(vscode): show working-directory badge when a task runs outside the open workspace

Tasks resumed from the CLI or another workspace keep their original cwd,
so Cline reads, edits, and runs commands in a directory that is not the
one visible in the window - previously with no indication anywhere.

- Add TaskWorkingDirectoryBadge: a persistent warning chip in the task
  header (folder icon + cwd basename, full path + explanation in the
  tooltip) shown only when the task cwd is neither an open workspace
  root nor inside one. Hidden when roots or cwd are unknown to avoid
  false positives.
- Fix SdkController.getStateToPostToWebview to pass its workspace
  manager into the shared state builder; the SDK path previously always
  sent workspaceRoots: [] to the webview.
- Unit tests for the outside-workspace predicate (case, separators,
  multi-root, prefix collisions) and badge render states.

* fix(vscode): platform-aware path comparison in working-directory badge

Address PR #12637 review findings:
- Case folding is now platform-aware (win32/darwin insensitive, linux
  and unknown strict), so case-only path differences on Linux are no
  longer hidden; mirrors arePathsEqual in src/utils/path.ts.
- Backslashes are treated as separators only on win32; on POSIX a
  backslash is an ordinary filename character.
- Containment prefix no longer doubles the separator when a workspace
  root already ends with one, fixing false warnings for '/' and drive
  roots.
- Tests cover case-only pairs under win32/darwin/linux/unknown,
  POSIX-backslash filenames, '/' and 'C:\' workspace roots.

* fix(vscode): make darwin path comparison strict in working-directory badge

Follow-up to PR #12637 review: darwin volumes can be case-sensitive, and
the host's canonical arePathsEqual (src/utils/path.ts) already treats
only win32 as case-insensitive. Align the badge predicate with that
convention: case folding and backslash separators apply on win32 only;
darwin, linux, and unknown compare strictly. For a warning badge a rare
spurious warning beats silently hiding a real mismatch.
2026-07-28 18:23:41 -07:00
Saoud Rizwan c227e1ae36 fix(vscode): restore legacy workflow invocation and management UI (#12562)
* fix(vscode): restore legacy workflow invocation and management UI

- Expand /workflow slash commands typed with the legacy .md filename
  spelling (what the autocomplete menu inserts) and mid-message, and
  honor the user's workflow enable/disable toggles, instead of only
  expanding a leading extension-less /name via the SDK resolver.
- Restore the Workflows tab in the rules modal (view, toggle, create,
  edit, delete; enterprise section) that was dropped in the SDK-backed
  extension while all its gRPC handlers remained wired.

* chore: add changeset for workflow fixes

* fix(vscode): refresh workflow toggles on webview launch

The slash command menu is driven by workflowToggles state, but nothing
refreshed it at startup in the SDK-backed extension (only opening the
rules modal or creating a rule file did), so workflows never appeared in
the chat autocomplete until the user opened the modal. Legacy refreshed
toggles on task init.

* feat(vscode): move Workflows tab last and add deprecation warning

Workflows tab now appears after Rules/Hooks/Skills, and its view leads
with a warning banner: workflows are being deprecated in favor of
skills, with a docs link.

* chore: update changeset for workflow deprecation notice

* fix(vscode): address review findings on workflow expansion

- Honor remoteWorkflowToggles (and locked alwaysEnabled remote
  workflows) when building the disabled set, so disabled enterprise
  workflows no longer expand.
- Treat a workflow as disabled only when no scope has it enabled, so a
  disabled workspace file no longer shadows a same-named enabled global
  one (legacy expanded the enabled scope).
- Strip all workflow extensions the SDK discovers (.md/.markdown/.txt)
  when matching typed commands, not just .md.
- Re-read toggle state after the async directory scan in
  refreshWorkflowToggles so a toggle flipped mid-scan is not overwritten
  by the stale snapshot.

* fix(vscode): map workflow toggles to records so frontmatter names are governed

Compute the disabled set from the discovered workflow records
(listRecords) instead of toggle-path basenames alone: a file's toggle is
matched by its basename and disables the record's actual command name,
so a frontmatter 'name' that differs from the filename is still governed
by the Workflows toggle. Remote-config-materialized records are governed
by the name-keyed remote toggles (locked alwaysEnabled remain on).

* fix(vscode): harden workflow toggle-name mapping for expansion

- A command name shared by several records now counts as enabled when
  any record is enabled, so a disabled local workflow can no longer
  suppress an enabled or locked (alwaysEnabled) enterprise workflow.
- Remote toggles/locks are matched via a sanitizeSegment-compatible key,
  so config names that get rewritten during materialization (e.g. 'Org
  Standards' -> org-standards.md) still govern expansion.
- Typed filenames (e.g. /my-workflow.md from autocomplete) now resolve
  to workflows whose frontmatter renames the command, via the record's
  file basename.

* fix(vscode): govern each workflow command by its own record's toggle

Key the disabled set by exact command name and decide each record
independently instead of OR-aggregating by canonical name: distinct
commands whose names only differ by case or extension (e.g. a local
'Release' and a remote 'release') no longer influence each other, so an
enabled local workflow cannot keep a disabled enterprise workflow
expandable, and a disabled one cannot suppress a locked enterprise
workflow.

* fix(vscode): exact remote-name sanitization and keep mid-scan toggle additions

- Port @cline/shared's sanitizeSegment verbatim (incl. the 80-char cap)
  for remote workflow name comparison, so long enterprise workflow names
  cannot bypass a disabled toggle after filename truncation.
- The post-scan toggle merge now also keeps entries added while the scan
  was running (e.g. a workflow created via the modal), instead of
  pruning them with the deleted files.

* fix(vscode): handle mid-scan deletions and sanitized remote-name collisions

- The post-scan toggle merge now also drops entries that were removed
  from state while the scan ran, so a workflow deleted mid-refresh is
  not restored by the stale scan result.
- Remote toggle names that sanitize to the same materialized name merge
  as enabled-if-any-enabled instead of last-write-wins.

* fix(vscode): serialize workflow toggle refreshes

Queue refreshWorkflowToggles runs on a promise chain so overlapping
refreshes (webview launch, modal open, file create/delete) cannot
interleave scans and writes. Combined with the post-scan merge for
direct toggle flips, this closes the remaining stale-refresh races.

* fix(vscode): key remote workflow toggles off the materialized filename

The materializer names remote workflow files from the config name, so
derive the remote toggle key from the file basename instead of the
parsed command name; a frontmatter alias can no longer bypass a
disabled remote toggle.
2026-07-28 18:20:54 -07:00
Saoud RizwanandSaoud Rizwan d0a0c802af fix(vscode): interrupted tasks disappear from History (ENG-2336) (#12613)
* fix(vscode): make interrupted tasks findable in History and restore Resume button

Interrupted/cancelled sessions were presented as gone (ENG-2336):

- History fuzzy search used location-based Fuse scoring (ignoreLocation:
  false, threshold 0.6), so any match more than ~60 characters into the
  task title scored above the threshold and the task silently vanished
  from search results even though it was in the list. Search now matches
  anywhere in the title.

- Opening a task from History never updated the authoritative TurnState,
  so the footer kept the previous context's phase (usually idle) and the
  Resume Task button never appeared for interrupted/failed sessions.
  showTaskWithId now derives the phase from the reopened conversation:
  resumable for interrupted tasks, completed for completed ones.

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

* fix(vscode): decide Resume vs Start New Task from persisted session status

SDK conversations do not record a completion tool call in the transcript
(a completed turn and one interrupted mid-stream both end with plain
assistant text), and history rendering appends a synthetic trailing
ask:"completion_result" either way, so the message tail always looked
"completed". Reopening a task from History now reads the persisted
session status: "completed" gets the Start New Task affordance, while
cancelled/failed (interrupted) sessions get Resume Task. When reopening
the currently-active task, the stop is awaited first so the status read
reflects how the last turn actually ended.

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

* fix(vscode): fence concurrent history opens and default unknown status to Resume

Address review feedback:

- showTaskWithId now takes a generation fence: a request that loses the
  race to a newer showTaskWithId or clearTask abandons installation after
  its awaited reads, so a slow older request can never clobber the user's
  latest selection (task proxy, messages, or turn phase). clearTask bumps
  the generation too so New Task wins over an in-flight history open.

- The resume affordance no longer falls back to the message tail when the
  persisted session status is unavailable: the tail always ends with the
  synthetic ask:"completion_result" that history rendering appends, which
  misclassified interrupted tasks as completed on a failed status read.
  Only an explicit "completed" status gets Start New Task; anything else
  (including unknown) gets Resume Task, the safe direction.

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

* fix(vscode): allocate history-open generation before the lookup and fence before session stop

Address review feedback: the latest-selection-wins fence started too late.
SdkController.showTaskWithId awaited findHistoryItem() before entering the
coordinator, so a stalled preflight for an older selection could re-enter
with a NEWER generation than a later selection and replace it — and since
the first fence check sat after endActiveSession, a superseded request
could also stop a session the newer selection had just installed.

The history lookup now lives inside the coordinator (skipHistoryLookup is
gone), the generation is allocated synchronously before all asynchronous
work, and a fence check runs before endActiveSession so a superseded open
never stops the newer selection's session. The coordinator returns the
HistoryItem so SdkController keeps its TaskResponse contract. Regression
test covers the exact reported sequence: stalled lookup for task A, task B
selected and loaded, A resolves last — B stays installed and A stops
nothing.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 18:16:19 -07:00
Saoud Rizwan f9227fead4 Show completion feedback box for inferred turn-final responses in SDK path (#12638)
* Show completion feedback box for inferred turn-final responses in SDK path

The SDK agent usually ends a turn with a plain text response instead of an
attempt_completion / plan_mode_respond tool call, so the legacy green 'Task
Completed' box (act) and 'Plan Created' box (plan) never rendered in the new
extension — making a finished turn look stuck or frozen.

Now, when a turn ends cleanly (done reason 'completed', no completion tool
used) and its last content is a text response, that text row is retagged in
place to say:'completion_result' (act, green box) or the new
say:'plan_completion_result' (plan, yellow-accented 'Plan Created' box).

- Track the turn-final text candidate in MessageTranslatorState; cleared on
  tool activity, errors, aborts, and new user turns
- Replay the same inference during history rehydration, recovering each
  turn's plan/act mode from the persisted <user_input mode="..."> wrapper
- Add plan_completion_result ClineSay type (+ proto enum) rendered via
  PlanCompletionOutputRow, restyled with the plan-yellow accent to match
  the plan/act toggle and the CLI's plan color
- Turn phase semantics unchanged: footer buttons still come from TurnState

* Remove attempt_completion tool and strip completion box headers

- Drop the attempt_completion extra tool (and its shell-command executor)
  from VS Code SDK sessions; the SDK's built-in submit_and_exit is already
  disabled for act/plan presets, so the agent now always ends its turn with
  a plain text response and the turn-end inference styles it.
- Translator keeps recognizing attempt_completion/submit_and_exit for
  replaying persisted transcripts from older sessions.
- Remove the 'Task Completed' header, check icon, and copy button from the
  green completion box, and the 'Plan Created' header, notepad icon, and
  copy button from the yellow plan box. The final text of a turn may be a
  question rather than an actual completion or plan, so the boxes are now
  quiet color cues that make no claim.

* Skip completion retag for terminal text of failed/cancelled sessions

The trailing text of a session whose last run failed or was cancelled is a
dangling partial response, not a completion. Gate the history converter's
final synthesized turn end on the session record's status so reopening a
broken task keeps its terminal text as a plain row instead of an inferred
completion box. Mid-transcript turns are unaffected: the user continued
after them and history carries no per-turn outcome.

* Require clean at-rest session status before retagging terminal text

Tighten the negative failed/cancelled check into an allowlist: the history
converter now only retags the transcript's terminal text when the session
record is 'completed' (formally stopped clean run) or 'idle' (the normal
at-rest state between interactive turns). 'running'/'pending' at rest means
the process died mid-turn, so its dangling partial response stays plain.

* Restrict history completion retag to the transcript's final turn

Persisted SDK transcripts carry no per-turn outcome, so a mid-conversation
turn the user cancelled mid-response (then followed up on) is
indistinguishable from one that ended cleanly. Retagging those presented
interrupted responses as deliberate turn ends. History rehydration now only
retags the final turn's terminal text, gated on the session record's
at-rest status; earlier turns always render as plain text. Live sessions
are unaffected — their per-turn boxes come from real done events.

* Trust only status 'completed' for the history completion retag

'idle' is written by markTurnIdle for every interactive finish reason,
including aborted turns, so an at-rest idle record cannot prove the last
turn ended cleanly. Terminal statuses are reliably written when sessions
are released (task switch, clear, dispose), so requiring 'completed' keeps
the box on normal reopened tasks while never styling an interrupted
response as a deliberate turn end.

* Treat missing session records as unknown outcome in history retag

A transcript with no session record has no recorded outcome, so its
terminal text stays a plain row instead of getting completion styling.
2026-07-28 17:38:00 -07:00
Saoud RizwanandSaoud Rizwan bd83980359 fix(vscode): also check file-backed stores before re-onboarding upgraders (ENG-2346) (#12639)
migrateWelcomeViewCompleted derived the flag solely from VS Code's
per-profile stores, which are empty for users upgrading from the live
4.x extension (file-backed config under ~/.cline/data). The flag landed
as false and fully configured users were pushed back through onboarding.

Purely additive: the existing VS Code checks are untouched; the same
signals (completed flag, provider secrets, keyless provider configs) are
now also read from the file-backed globalState.json/secrets.json and
OR-ed into the result.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 17:16:28 -07:00
Saoud Rizwan 4d3b161b9a fix(core): coerce line-number tool args (#12641)
Models sometimes emit numeric tool arguments as JSON strings. `insert_line`
and the `read_files` line bounds were plain `z.number()`, so an
`insert_line: "3"` rejected the whole tool call before it ran:

  1 tool call(s) failed: [editor] {"error":"✖ Invalid input: expected number,
  received string\n  → at insert_line"}

The model is handed that error and burns a round trip re-deriving the argument.

`z.coerce` leaves the JSON Schema advertised to the model untouched (still
`integer`), and `.int()` / `.positive()` still reject "abc", "3.5" and 3.5.
2026-07-28 16:49:01 -07:00
Saoud RizwanandSaoud Rizwan 255be9ad29 fix: stop Ollama model picker polling /api/tags once per second forever (ENG-2344) (#12621)
* fix(webview): stop unbounded polling of local model endpoints (ENG-2344)

The Ollama provider form polled /api/tags every 2s from two places at once
(OllamaProvider and a dead duplicate poll in ApiOptions whose result was
never read), producing ~1 req/s for as long as the settings pane was open.
Since the base URL is user-configurable, this could hammer a remote or
metered endpoint. VSCodeLmProvider and LMStudioProvider had the same
interval pattern.

- Remove all useInterval model polling; fetch on mount and when the
  base URL changes instead
- Refresh the Ollama model list when the picker field gains focus so a
  server started after the pane opened is still discovered
- Delete the dead _ollamaModels poll in ApiOptions

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

* fix(webview): add on-demand model refresh for LM Studio and VS Code LM

Greptile review follow-up: removing the polling intervals left these two
pickers pinned to their mount-time snapshot. Mirror the Ollama picker's
interaction-driven refresh:

- LM Studio: refetch models when the model dropdown or the manual model
  id field gains focus
- VS Code LM: refetch when the dropdown gains focus, and add an explicit
  'Refresh the model list' link to the empty state

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:44:46 -07:00
Saoud Rizwan 77b181a472 fix(vscode): label editor insert_line edits as edits, not new-file creations (#12635)
An `editor` tool call with `insert_line` (e.g. a prepend) targets an
existing file — the SDK editor executor requires the file to already exist
for inserts — but sdkToolToClineSayTool only treated `old_text`/`replace_in_file`
as edits, so inserts were classified as newFileCreated and the approval card
read "Cline wants to create a new file:" for an existing file.

Treat insert_line as an edit so the card reads "Cline wants to edit this file:".
2026-07-28 16:30:38 -07:00
Saoud Rizwan 45476650b7 fix(vscode): stop Preferred Language from silently resetting on settings mount (#12632)
The webview-ui-toolkit VSCodeDropdown fires a spurious change event with
the wrong option (index 2, Portuguese - Brasil) while its slotted options
initialize after a window reload, and the handler persisted that value
unconditionally. Any saved language not at the top of the list could be
silently rewritten to Portuguese just by opening the General settings tab.

Replace the toolkit dropdown with the ui/select component already used by
the other settings dropdowns (Auto Compact Strategy, MCP Display Mode),
which only emits onValueChange for real user selections, and render the
options from the shared languageOptions list instead of a hardcoded copy.
2026-07-28 16:30:28 -07:00
Saoud Rizwan f847b06cfd Fix thinking indicator flashing when a turn completes (#12631)
At turn end the final message is finalized (partial: false) via the fast
partial-message stream a moment before the done event flips turnState out
of "streaming" via a full state post. During that gap the in-list
"Thinking..." loader row appeared and immediately disappeared, flashing
on every turn completion.

- Extract the loader show/hide logic from MessagesArea into a testable
  useThinkingLoaderRow hook.
- Debounce the loader when its trigger is the tail message finishing
  streaming: mid-turn a real wait outlives the grace period, while the
  turn-end phase change cancels it before it ever shows.
- Add the legacy path's say("completion_result") anti-flicker guard to
  the turnState path so attempt_completion turns never flash regardless
  of timing.
2026-07-28 16:30:18 -07:00
Saoud RizwanandSaoud Rizwan 598b3af7eb fix(cli): report correct default directories for --config and --data-dir in --help (#12627)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:30:00 -07:00
Saoud RizwanandSaoud Rizwan fc936b7305 fix(vscode): restore Retry/Start New Task buttons after API failure (ENG-2339) (#12625)
* fix(vscode): restore Retry/Start New Task buttons after API failure

A provider stream error emits ask:'api_req_failed', but the session-event
coordinator resolved the turn-end phase to 'awaiting_followup', clobbering
the error state — so the footer never showed the error-recovery buttons and
the error surface offered no way to recover (ENG-2339).

Record the error outcome in MessageTranslatorState when the error event is
translated, and resolve turn end to the 'error' phase so the existing
api_req_failed button config (Retry / Start New Task) is reachable again,
matching legacy behavior.

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

* fix(vscode): also record error outcome for done(reason:'error') terminations

A turn can terminate with done(reason:'error') without a separate 'error'
event; record the error outcome there too so turn end still resolves to the
'error' phase and the Retry / Start New Task buttons appear.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:29:48 -07:00
Saoud Rizwan 9247dc30bb chore(vscode): remove dead task history command (#12624) 2026-07-28 16:29:35 -07:00
Saoud RizwanandSaoud Rizwan 1b499161cc Fix ignored China/international endpoint toggles for Qwen, Moonshot, Z AI (ENG-2340) (#12623)
* Fix ignored China/international API line toggles for Qwen, Moonshot, Z AI (ENG-2340)

The regional apiLine setting was persisted through both storage layers but
never consulted when resolving the request endpoint, silently sending
regional users to the wrong host.

- @cline/llms: record china/international base URLs on the builtin specs
  for qwen, qwen-code, moonshot, zai, zai-coding-plan, and minimax; expose
  resolveProviderApiLineBaseUrl; resolve options.apiLine against the
  registered apiLineBaseUrls in GatewayRegistry.createProvider (explicit
  base URLs still win).
- @cline/core: toProviderConfig now resolves the base URL from apiLine
  between the explicit setting and the static provider default.
- VS Code: buildSessionConfig resolves the API line from legacy state
  (qwenApiLine/moonshotApiLine/zaiApiLine/minimaxApiLine) with a
  providers.json fallback and forwards it on the provider config so the
  gateway can route regionally.

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

* Share the base provider's legacy API line with qwen-code and zai-coding-plan

The coding variants have regional endpoints in the SDK but no legacy
state field of their own, so a China-line user selecting them from the
VS Code UI would silently fall back to the international default. The
variant's own providers.json apiLine still wins over the shared legacy
field.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:29:28 -07:00
Saoud RizwanandSaoud Rizwan 0e5cb433b0 fix(core): expose live in-memory session messages for mode-switch rebuilds (#12622)
Session rebuilds seed the replacement session from readMessages, but the
persisted transcript only catches up at assistant-message/turn boundaries
and abort() does not flush. Toggling plan/act mode while a task's first
turn is mid-flight (e.g. a command approval pending) therefore rebuilt the
session with no history at all and the new mode's model lost the task.

Add RuntimeHost.readLiveSessionMessages (optional) which prefers the
resident session's agent.getMessages() and falls back to the persisted
transcript, expose it as ClineCore.readLiveMessages, and use it in the
VS Code history loader that feeds session rebuilds. readSessionMessages
keeps its persisted-transcript semantics for existing callers (compaction
validation, session snapshots, history).

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 16:29:18 -07:00
Saoud Rizwan 53a46c309b Revert "Add a built-in cline-settings skill and broaden the legacy resume war…" (#12669)
This reverts commit 36c78267c6.
2026-07-28 16:15:25 -07:00
Dominic CooneyandSaoud Rizwan 36c78267c6 Add a built-in cline-settings skill and broaden the legacy resume warning (#12660)
* Add a built-in cline-settings skill and broaden the legacy resume
warning

Models diagnosing configuration problems have no authoritative source
for where Cline stores settings: the SDK migration removed the old MCP
documentation tool, and resumed legacy conversations can carry stale
paths and instructions from older runtimes (CLINE-2570).

Add a core-owned virtual skill, cline-settings, whose instructions are
generated at invocation from the shared storage path resolvers. It is
listed and invoked through the existing skills registry on both the
local and Hub session paths, is reserved against shadowing by
file-backed skills (case-insensitive), honors session skill allowlists
(an explicit empty allowlist disables all skills including built-ins),
and never appears in editable listRecords.

Broaden LEGACY_RESUME_MODEL_WARNING to cover stale configuration
paths, file formats, and product instructions, not just tool names.
Anchor the persisted history boundary on a stable marker; recognize
and upgrade the historical warning in place so previously resumed
tasks get the new wording without duplicate warnings, and preserve
resumed user text that shares a message with the warning.

* Fix Windows MCP stdio spawn for paths with spaces; add settings-skill
rule

The runtime-builder MCP test failed on Windows because the stdio
client spawns with shell: true there, and cmd.exe split the unquoted
executable path at the space in "C:\Program Files\nodejs\node.exe".
Quote the command and arguments for cmd.exe so any server whose
command or arguments contain spaces can start. Also raise the connect
timeout to match the request timeout: connect covers process spawn
plus the first initialize round-trip, and 1.5s is tight for cold
starts on loaded machines.

Add a brief .clinerule noting that settings/storage-path changes may
require updating the cline-settings built-in skill.

* Quote empty MCP arguments for cmd.exe

An empty-string argument passed through unquoted disappears when
cmd.exe re-parses the concatenated command line, silently shifting the
server's argument list. Quote empty values so they survive as "".

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-28 16:10:27 -07:00
Tomás Barreiro 76c30b1c60 Update ClineFreeModelLimitError wording (#12666) 2026-07-29 01:03:44 +02:00
Saoud Rizwan b496c5a1f3 chore(cli): release v3.0.47 2026-07-28 15:36:26 -07:00
Bee 2fc06ce446 feat(desktop): add pagination to session history view (#12618)
* fix(desktop): order sessions by last activity and unify status dot colors

* feat(desktop): add pagination to session history view

Display sessions in ten-item pages and fetch older history only after reaching the final local page. Add coverage for pagination, session opening, and compact token formatting.

* page numbers

* Support adding a session to favorite list

* apply feedback

* fix
2026-07-28 15:30:43 -07:00
Bee 64c50380f3 feat(chat): refine tool and reasoning message disclosures (#12616)
* feat(chat): refine tool and reasoning message disclosures

Add tool-specific icons with a fallback, display elapsed reasoning time, and restyle reasoning disclosures. Position hidden message actions outside the layout and update attachment sizing to valid Tailwind utilities.

* fix(desktop): improve chat message actions and scrolling

Refine action positioning, sizing, timestamps, and visibility for chat messages. Remove nested overflow constraints so scrolling remains controlled by the conversation viewport, and tighten tool disclosure spacing.

* tools icon mapping

* fix(desktop): align chat timestamps and tool icons
2026-07-28 15:30:33 -07:00
Saoud Rizwan e36197d911 chore(sdk): release v0.0.66 2026-07-28 15:22:13 -07:00
Bee 52d187578e feat(desktop): show subagent/teammates execution history (#12615)
* feat(desktop): expose session agent execution history

Add a list_session_agents sidecar command to retrieve agent and team run details from child sessions and tool messages. Include comprehensive tests for agent discovery, message parsing, status handling, and result normalization.

* apply feedback

* add test

* feedback fix

* fix

* fix p1
2026-07-28 15:09:27 -07:00
Tomás Barreiro a6239c420c Update generated files (#12649) 2026-07-28 13:56:05 -07:00
Bee c7c5e6518b fix(desktop): order sessions by last activity and unify status dot colors (#12617) 2026-07-28 12:06:59 -07:00
Bee 98c0717302 feat(desktop): system tray session status (#12659)
* feat(desktop): add system tray session status support

Enable Tauri tray icon and PNG image features for desktop tray integration. Expose the running session count in process context so the tray can reflect active work, with test coverage for running and idle sessions.

* fix(desktop): buffer tray actions and show app status
2026-07-28 20:59:36 +02:00
Dominic Cooney b4aed24ff8 fix(vscode): compact tasks opened from history (#12002)
* fix(vscode): compact tasks opened from history

The compact button only worked while a session was actively running.
Opening a task from history and clicking compact errored with "There is
no active task to compact."

Compaction is defined over a session transcript, so rather than grow a
second implementation for displayed tasks, resume a displayed history
task on an isolated session host and compact it through the existing
path. The coordinator owns and disposes that host, so task navigation
cannot make cleanup stop a replacement active session.

Follow-up resume and both compaction paths (idle active session and
displayed task) acquire the same session-rebuild boundary around
transcript read, session start, and persistence. Task and session
object identity are rechecked across awaits; cleanup targets only the
exact host and session started by the operation. A follow-up abandoned
by task navigation settles the streaming turn phase it pre-set, so the
newly displayed task never shows a stuck Thinking/Cancel footer.

The resume-start preparation shared by follow-up and compaction is
extracted into prepareTaskResumeStartInput, including legacy task
conversion, so the two callers cannot drift apart.

The compaction divider UX and context-meter shrink remain owned by the
already-merged webview compaction change.

* fix(vscode): deliver follow-ups across a same-task proxy reload

Follow-up targeting checks compared the displayed TaskProxy by object
identity, but showTaskWithId allocates a fresh proxy for the same task
id, so reloading the task mid-resume silently dropped the message.
Compare targeting by taskId; cleanup keeps object identity.
2026-07-28 10:52:13 -07:00
Saoud RizwanandSaoud Rizwan e9ec82d2ef docs: remove stray double space in README CLI example (#12594)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 09:45:51 -07:00
Saoud RizwanandSaoud Rizwan e3ff875e09 fix(cli): persist /settings general toggles (mode, auto-approve, compaction) across restarts (#12614)
* feat(core): persist plan/act mode, tool auto-approve, and compaction mode in global settings

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

* fix(cli): restore /settings general toggles across restarts

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

* fix(core): make global settings updates cross-process safe

Targeted setters previously did unlocked read-modify-write cycles over the
shared global-settings.json, so concurrent hosts (two CLIs, or CLI + VS Code)
could silently discard each other's changes. Route all setters through a new
updateGlobalSettings(mutate) helper that re-reads the latest on-disk state
under a short-lived lock file (with stale-lock reclaim and a bounded wait)
and replaces the file atomically via temp-file rename so readers never see
torn writes.

* Revert "fix(core): make global settings updates cross-process safe"

This reverts commit 198c1c831b.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 08:56:55 -07:00
Tomás Barreiro dc175c73a8 Cline free ux (#12593)
* Introduce the concept of free models that have the cline-free ID

* Add (free) to explicitly free models

* Render (free) in free models name

* Add pricing to the free model info

* Fix free model pricing

* fix tests

* SEt cline-free model pricing to 0

* revert pricing changes

* Add free limit error handling

* Include the reset time in the message

* Add button to switch model in VSCode

* add model not found error

* remove problematic tests

* fix review messages

* Fix model promotion ended

* Revert "revert pricing changes"

This reverts commit 7e5b2a34fd.
2026-07-28 06:20:28 +02:00
Tomás Barreiro d91f1ce166 Add support for cline-free models. (#12591)
* Introduce the concept of free models that have the cline-free ID

* Add (free) to explicitly free models

* Render (free) in free models name

* Add pricing to the free model info

* Fix free model pricing

* fix tests

* SEt cline-free model pricing to 0
2026-07-28 05:50:15 +02:00
Saoud Rizwan bc5a2e85a4 chore(desktop): release v0.0.6 2026-07-27 19:01:44 -07:00
Saoud Rizwan 9c56a726a7 Add persistent sidebar update indicator for staged desktop app updates (#12611)
* Add persistent sidebar update indicator for staged app updates

* Surface restart failures from the update indicator and reset its pending state
2026-07-27 18:37:51 -07:00
Saoud Rizwan 72c9bbfcaa Clarify desktop app auto-update setting: it governs the CLI, not the app (#12601)
* Reword desktop auto-update setting description to drop CLI mention

* Clarify desktop settings copy: auto-update toggle governs the CLI, not the app
2026-07-27 18:14:39 -07:00
BeeandSaoud Rizwan 53a7c3e80e fix(desktop): align startup appearance and session context (#12608)
* fix(desktop): align startup appearance and session context

* update cline logo size

* display full workspace name

* fix: fits in narrow screen size

* header in narrow screen

* Transient failures no longer replace a valid branch with no-git.

* header alignments

* account settings button row

* fix(desktop): improve collapsed sidebar settings layout

Use a compact overlay-friendly width and left-align navigation controls in collapsed settings. Adjust header padding, stack account details, anchor the expand button, and add layout regression tests.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-27 18:13:55 -07:00
John Choi 8751daaba9 refactor(ui): extract desktop session status (#12596) 2026-07-27 17:16:04 -07:00
Bee aa1fffbe80 fix(ui): use solid primary color for checked switch (#12600)
Update the switch's checked state background from translucent to solid primary for clearer visual feedback and improved contrast.
2026-07-27 16:55:43 -07:00
Saoud Rizwan 283c3ba937 ci: grant pull-requests write so promo-comment deletion works on PRs (#12606) 2026-07-27 16:51:00 -07:00
Saoud Rizwan 69c9a9ac28 ci: delete coding-agent promo comments on PRs (#12604) 2026-07-27 16:44:48 -07:00
BeeandSaoud Rizwan 4d238558d0 feat(deaktop): queue ui update (#12534)
* feat(deaktop): queue ui update

* fix(desktop): address queue review feedback

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-27 16:06:16 -07:00
Bee 51e5daf8eb feat(core): persist and restore connector sessions (#12125)
* feat(core): persist and restore connector sessions

Persist successful connector runs for autostart and disable persistence when connectors are stopped. Reconnect saved connector channels during CLI daemon or hub startup so previously connected adapters are restored after restarts without blocking startup.

* fix(core): harden connector autostart recovery

* fix(core): address connector autostart edge cases

* fix(core): address connector reconnect review feedback

* fix(core): address remaining connector review feedback

* fix(core): address connector persistence review feedback

* fix(core): address connector lifecycle review feedback

* fix(core): restart surviving connectors after hub restart

* fix(core): refresh Telegram identity after token rotation

* fix(hub): restart active connectors on start

* fix(connectors): address lifecycle review regressions

* fix(connectors): make reconnects instance-safe

* fix(connectors): harden restart failure handling
2026-07-27 15:59:23 -07:00
John Choi 28ef954258 feat(ui): add host-safe theme and Markdown exports (#12439)
* feat(ui): add host-safe theme and package contract

* fix(ui): preserve scoped theme contract

* refactor(ui): establish generated theme contract

* chore(ui): refresh committed build

* fix(ui): isolate markdown presentation

* chore(ui): prepare next package preview

* fix(ui): harden preview package contract

* fix(ui): enforce clean package builds in CI

* refactor(ui): rely on npm package builds

* test(ui): make package smoke failures actionable

* ci(sdk): restrict pull requests to main

* test(ui): verify the published package contract

* docs(ui): document the React types floor

* chore(ui): keep package checks out of shared workflows

* fix(ui): preserve standalone markdown cascade

* fix(ui): reject stale generated theme builds

* refactor(ui): narrow foundation to adoption needs

* test(ui): verify packed CSS exports exist

* fix(ui): restore publish contract safeguards

* refactor(ui): keep foundation adoption-focused

* test(ui): verify new packed CSS exports
2026-07-27 15:26:27 -07:00
Saoud Rizwan 9c4841ea07 chore(desktop): release v0.0.5 2026-07-27 15:21:49 -07:00
Saoud Rizwan 1bd200e906 ci: strip cloud-agent promo wrappers from PR bodies (#12588)
* ci: strip cloud-agent promo wrappers from PR bodies

* ci: make PR body strip vendor-agnostic, pin github-script to SHA
2026-07-27 15:10:05 -07:00
Saoud RizwanandCursor Agent fabbc144d6 perf(desktop): make the app feel snappy end-to-end (#12568)
* perf(desktop): make the app feel snappy end-to-end

Fixes several compounding sources of UI jank that made every click and
keystroke feel seconds-slow:

- Aurora background: drop per-frame 46-64px CSS blur re-rasterization;
  bake softness into gradients + a static mask and animate only
  opacity/transform (compositor-only). Onboarding/home idle went from
  ~10fps to a locked 60fps under 4x CPU throttling.
- Hide the app shell while the opaque onboarding overlay is up so a
  second aurora + hero animations are not composited underneath.
- Hero verb animation: opacity/transform only (no text blur filter).
- Composer: keystroke state now lives inside ChatInputBar (versioned
  promptDraft injections for quick actions/undo/resets), and mention/
  slash detection is derived instead of effect-synced; typing went from
  245/246 keystrokes over 50ms to 3/240.
- Chat streaming: coalesce per-token text/reasoning deltas into ~48ms
  flushes; memoize MessageBubble/ToolMessageBlock with stable callbacks
  so finished messages skip re-rendering during streams.
- Session history: only surface isLoadingHistory before the first load;
  background refreshes no longer re-render the whole app twice each.
- Provider catalog (~700KB): dedupe concurrent fetches with a short TTL
  so app boot issues one round-trip instead of three.
- Sidecar: session-log appends are now ordered async writes instead of
  writeFileSync per streamed token; git/folder-picker/editor discovery
  use async execFile so the native picker no longer freezes every
  pending command; editor discovery results cached for 60s.

* fix(desktop): address Bugbot review findings

- Invalidate the shared provider-catalog cache after any provider
  mutation (onboarding connect paths, account sign-in/out, settings
  save, add provider) so post-save reloads never see a pre-save copy.
- Clear the injected composer draft on send so a composer remount
  cannot repopulate the previous prompt.
- Mark the hidden app shell inert + aria-hidden while the onboarding
  overlay covers it, keeping covered controls out of the tab order.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-27 15:04:49 -07:00
Amy Duquette 372f029343 docs: add Poolside provider setup guide (#12586) 2026-07-27 21:38:35 +02:00
Tomás Barreiro 9d9b7aa6f8 Add data dirs when running specific environments (#12585) 2026-07-27 21:19:07 +02:00
Dominic CooneyandCline Agent 8c7095b805 test(cli): avoid hard-coded dialog background color (#12579)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-27 11:07:54 -07:00
Dominic CooneyandCline Agent 05535e844c chore(vscode): remove unused OpenTelemetry dependencies (#12573)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-27 10:29:13 -07:00
Tran Binh MinhandMinhkunn c3dc4a95bc fix(cli): use unique keys for read_files rows (#12144)
read_files rendered file rows keyed by raw path, so the same path listed twice (e.g. find-skills reading a SKILL.md repeatedly) produced duplicate React keys and the two-children-with-the-same-key warning. Build index-namespaced keys instead, at both render sites.

Fixes #9784

Signed-off-by: Minhkunn <minh.12072k6@gmail.com>
Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-07-26 13:41:30 -07:00
Sufiyan Khan 9466fcc018 docs: fix typos, incorrect slash command, and hooks vs plugins mismatch (#12154)
* docs: fix typos and incorrect slash command reference

- Fix double period in MiniMax provider description
- Remove duplicate 'through' in kanban install description
- Fix /new -> /newtask (correct slash command name)

* docs(hooks): fix description to reference SDK Plugins, not SDK Hooks

The description said 'SDK Hooks page' but the content links to the
SDK Plugins page (/sdk/plugins). Align the description with the
actual destination.
2026-07-26 13:13:39 -07:00
Saoud RizwanandCursor Agent dd7a1c5fa6 fix(desktop): onboarding Cline API key path, stuck "Agent is working..." composer, OAuth sign-in cancel (#12564)
* fix(desktop): clear busy status when queued turns finish; add Cline API key onboarding path

* feat(desktop): allow cancelling a pending Cline browser sign-in during onboarding

* fix(desktop): address review findings on OAuth cancel, API key verification, and queued-turn status

* fix(desktop): cancel pending OAuth logins when the initiating transport connection closes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-26 02:10:15 -07:00
fe75eb6271 fix(vscode/core): agentic compaction silently fell back to basic, and manual /compact never reached the model (#12563)
* fix(vscode): resolve base URL and knownModels for compaction summarizer

The agentic compaction summarizer creates its LLM handler from the
session's ProviderConfig alone. For the OpenAI Compatible provider
stored under its SDK spelling (openai-compatible), resolveBaseUrl had
no mapping, so ProviderConfig was built without a baseUrl and the
summarizer silently hit the provider default endpoint (api.openai.com),
failed auth, and fell back to basic compaction - the UI still showed
'Context compacted' with no hint that agentic summarization never ran.

- resolveBaseUrl: accept the SDK spelling of the OpenAI Compatible
  provider, and fall back to the providers.json base URL (mirroring
  resolveApiKey) when legacy state has none.
- buildSessionConfig: expose knownModels at the top level of
  CoreSessionConfig, so manual compaction (sdk-compaction.ts) budgets
  against the real model context window instead of the 64k fallback.

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

* fix(core): project compaction sidecar even when auto-compaction is disabled

Manual /compact persists a compaction sidecar and promises the next turn
will use the compacted working context, but the runtime host only wired
the compaction-state-aware prepareTurn when compaction was enabled. With
Auto Compact off (the VS Code extension default), a manual /compact was
a silent no-op for the model: the sidecar was saved and the UI showed
'Context compacted', yet every subsequent request still sent the full
canonical transcript.

createCompactionStateAwarePrepareTurn already supports an undefined
compact fn (project existing state, never re-compact), so wire it
unconditionally; sessions without a sidecar are unaffected. Also keep a
resumed/initial sidecar instead of dropping it when compaction is
disabled.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-26 01:20:08 -07:00
Saoud RizwanandCursor Agent e4091c3686 Stop the task at the mistake limit like the CLI and remove the max-mistakes setting (#12561)
* fix(vscode): stop the task at the mistake limit like the CLI, drop the max-mistakes setting

When the SDK's consecutive-mistake limit is hit, the extension used to
block on an ask (Proceed Anyways / Start New Task) while the agent loop
kept running against the provider — reproduced 2,100+ consecutive API
requests behind the unanswered prompt.

Replicate the CLI's non-interactive resolver instead: show an error row
and resolve the decision as an immediate stop. The run aborts cleanly at
the turn boundary, the turn phase becomes awaiting_followup, and the
user continues whenever they want by sending a new message (which also
resets the SDK's mistake tracking on the next productive turn).

Also remove the extension's maxConsecutiveMistakes setting (state key,
settings RPC, webview state, proto fields now reserved). It was never
wired into the SDK session config — the SDK's own default governs — so
the setting was dead weight. Legacy mistake_limit_reached asks from
persisted conversations still render via the existing webview paths.

* fix(proto): reserve retired Settings field 139 (max_consecutive_mistakes)

The original removal added 'reserved 139' but the proto generator at the
branch base had no reserved-statement support and silently dropped it on
regeneration. Main (b4c640733) taught generate-state-proto.mjs to
preserve reserved statements, so after the merge the reservation now
survives. Also reserve the field name, mirroring the custom_prompt
removal pattern.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-26 00:53:41 -07:00
bcf8c1d03e fix(vscode): relative read_files paths and diff-preview stalls no longer fail read/edit tools (#12558)
* fix(core): resolve relative read_files paths against the session cwd

The built-in FileReadExecutor resolved relative paths against process.cwd(),
which in a VS Code extension host is typically '/' rather than the workspace.
Every relative-path read failed with ENOENT, so models fell back to reading
files through the terminal. Resolve relative paths against the tool's
configured cwd in createReadFilesTool before invoking the executor.

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

* fix(vscode): never let a stalled diff preview open fail or delay an edit

Thrown errors from opening the edit diff preview were already swallowed, but
a hung vscode.diff call was unbounded: on auto-approve it burned the editor
tool's 30s execution timeout (failing the whole edit), and on manual approval
it delayed the approval ask indefinitely. Bound the preview open with a 5s
timeout; on timeout the edit proceeds without a preview and the late-opening
tab is closed once the open settles.

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

* style(core): order node:path import first for biome

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

* refactor(vscode): flatten the preview-open timeout into a plain race

Replace the custom timeout error class, the race helper with timer
bookkeeping, and the two-branch cleanup with a single Promise.race and one
settle-then-close line. Same behavior: a rejected or stalled preview open
never blocks the approval ask or fails the edit, and any late-appearing tab
is closed once the open settles.

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

* refactor(vscode): move the read_files cwd fix out of the SDK into the host

Revert the SDK change and instead override the read_files executor in the
extension, alongside the existing editor/apply_patch/askQuestion overrides.
The override resolves relative paths against the workspace root before
delegating to the SDK's built-in reader, since the extension host's
process.cwd() is usually '/' and every relative-path read failed with ENOENT,
pushing the model into terminal fallbacks. The SDK is left untouched.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-26 00:22:29 -07:00
Saoud RizwanandCursor Agent 5099eed5ee fix(desktop): consistent MCP server cards and single uninstall affordance (#12555)
* fix(desktop): consistent installed cards and single uninstall in marketplace views

* fix(desktop): surface marketplace setup guidance on matched installed cards

* fix(desktop): show setup guidance for all matched marketplace entries, not just first match

* fix(desktop): unambiguous entry-to-item matching and no stale installed card flash

* fix(desktop): drop orphaned installed keys optimistically instead of hiding cards during recheck

* fix(desktop): guard recheck races and avoid duplicate uninstall for ambiguous matches

* fix(desktop): keep uninstall action on ambiguous fallback marketplace cards

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-26 00:12:28 -07:00
b4c640733b Remove dead "Use compact prompt" toggle from LM Studio provider settings (#12551)
* Remove dead 'Use compact prompt' toggle from LM Studio settings

The compact system prompt option was never wired up in the SDK-based
extension: the customPrompt value was stored in state and echoed back
to the webview, but nothing in the session factory or SDK ever read it
to alter the system prompt. Remove the checkbox (only shown for the
LM Studio provider) and all the dead state/proto plumbing behind it.

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

* Add changeset for compact prompt toggle removal

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

* Reserve removed custom_prompt field number/name in Settings proto

Teach generate-state-proto.mjs to preserve reserved statements in the
generated Secrets/Settings messages so removed fields keep their wire
identity reserved across regenerations, and reserve field 150 and the
custom_prompt name (plus the name in UpdateSettingsRequest).

Addresses Greptile review feedback on #12551.

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

* Never assign reserved proto field numbers to new Settings fields

If the highest-numbered field was removed and reserved, the generator
would hand that same number to the next new field, emitting both a
reserved statement and a live field at the same number. Parse reserved
numbers (including ranges) from the existing message, skip them when
assigning new numbers, and fail fast if an active field collides with
a reservation.

Addresses Bugbot review feedback on #12551.

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

* Format generate-state-proto.mjs

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:57:10 -07:00
40ba9d25eb fix(webview): use VS Code theme selection colors in dropdown menus (#12554)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:54:45 -07:00
Saoud RizwanandCursor Agent adb8ee0c4d fix(vscode): model ID under chat field stale / resets to gpt-4o for OpenAI Compatible (#12552)
* fix(vscode): fold SDK openai-compatible provider id to legacy openai spelling

The settings provider dropdown sources ids from the SDK catalog, so picking
OpenAI Compatible stored 'openai-compatible' into plan/actModeApiProvider.
Every provider-keyed code path (webview model label, planModeOpenAiModelId
slots, session factory) expects the legacy 'openai' spelling, so the model
id under the chat field went stale after Done and fell back to the catalog
default (gpt-4o).

- parseProviderId + toLegacyApiProvider now alias openai-compatible -> openai
- state-keys load transform migrates already-stored SDK spellings
- convertProtoToApiProvider normalizes provider ids written from the webview
- commitModelSelection writes the legacy spelling and posts state to the
  webview so model-only commits refresh the chat model label immediately
- session factory normalizes provider ids from state and providers.json

* fix(vscode): make toLegacyApiProvider alias lookup case-insensitive

parseProviderId lowercases before its alias lookup, but toLegacyApiProvider
(used directly by convertProtoToApiProvider and the state-keys load
transform) matched aliases case-sensitively, so a mixed-case
'OpenAI-Compatible' would not fold. Fall back to a lowercased lookup while
preserving original casing for unknown ids.

* fix(vscode): treat spelling-only provider differences as the same provider

Addresses the Bugbot finding on PR #12552: stale snapshots can still hold
the SDK spelling (openai-compatible) while new writes use the legacy
spelling (openai). Normalize both sides of the provider comparisons in
SdkProviderChangeCoordinator.providerForMode and
SdkController.isSelectionForActiveModeProvider so a spelling-only
difference neither restarts the active session nor skips the lightweight
in-session model update.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-25 22:53:58 -07:00
01617f9a05 chore: remove orphaned legacy auto-retry UI and dead onRetryAttempt plumbing (#12557)
The pre-SDK extension auto-retried failed API requests and surfaced
'Auto-retrying in X seconds' rows (say:'error_retry') plus a retryStatus
header on api_req_started. The SDK-based extension never emits either:
errors map straight to an api_req_failed ask with a manual Retry button,
and retrying is handled silently by the AI SDK / auth-refresh retry.

Remove the orphaned webview rendering (ChatRow error_retry case,
ErrorBlockTitle, combineErrorRetryMessages, isRequestInProgress chain,
stories), the unused say types and proto enum values (reserved), the
retryStatus field, and the never-invoked onRetryAttempt callback from
ApiHandlerOptions, sdk-api-handler, and @cline/llms provider config.

Legacy transcripts may still contain error_retry / api_req_retried rows;
readUiMessages now drops them so old tasks don't render raw JSON.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:52:11 -07:00
b72d7f1a6f fix(vscode): keep webview alive when moved between primary and secondary sidebars (#12553)
* fix(vscode): keep webview alive when moved between sidebars

Moving the Cline view between the primary and secondary sidebars made the
view go blank with 'this.unsubscribeHostTelemetrySettings is not a function'.

Two fixes:
- The vscode host bridge streaming client returned the async IIFE's Promise
  instead of the cancel function its contract declares, so callers invoking
  the stored unsubscribe function threw a TypeError. It now returns a
  synchronous wrapper that resolves the real cancel function in the background.
- VscodeWebviewProvider disposed the whole Controller on WebviewView
  onDidDispose. VS Code destroys and re-resolves the view when it is moved
  between sidebars, so the re-resolved view was served by a dead controller
  (postStateToWebview no-ops after dispose) and rendered blank. onDidDispose
  now only releases view-scoped resources; the controller is disposed on
  extension deactivation via WebviewProvider.disposeAllInstances.

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

* fix(vscode): address review — don't clear active task on re-resolve, guard stale view dispose

- resolveWebviewView no longer calls clearTask on re-resolves (moving the
  view between sidebars must not terminate a running task); it only clears
  stale task state on the first resolve after activation.
- onDidDispose now only tears down view resources if the disposed view is
  still the active one, so a stale dispose event arriving after a newer view
  resolved cannot clobber the active view's listeners. resolveWebviewView
  also releases the previous view's resources up front.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:47:40 -07:00
d5b966d0a8 fix: center-align sign-in verification code box in chat (#12533)
The 'Enter this code in your browser' box shown after clicking
'Sign in to Cline' was left-aligned while the surrounding logged-out
message and button are centered. Center the label and the code/copy row.

Fixes #12531

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 18:48:45 -07:00
Sufiyan Khan 1bb0833287 fix(cli): isolate hub daemon abort handling (#12500) 2026-07-24 18:44:51 -07:00
Saoud RizwanandCursor Agent 2c64c4ce5b fix: OpenAI Compatible model list fails when base URL has a trailing slash (#12532)
* fix: normalize trailing slash in OpenAI Compatible base URL for model list fetch

* fix: construct OpenAiModelsRequest via proto create in refreshOpenAiModels test

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-24 15:28:51 -07:00
Bee 0bdf2e3468 fix(desktop): restore window dragging (#12529) 2026-07-24 22:07:34 +02:00
Dominic CooneyandCline Agent 98024c4243 fix(cli): patch @opentui-ui/dialog for opentui 0.4.x remove() contract (#12516)
@opentui-ui/dialog@0.1.2 is built against @opentui/core ^0.1.69, whose
Renderable.remove(id) took a string id. Core 0.4.x renamed it to
remove(child) and throws when handed anything but a renderable, so the
dialog package's removeDialog()/provider teardown aborted before
detaching the panel: the React portal content unmounted but the
imperative grey box stayed on screen over the chat after every dialog
close (model picker, help, command palette, ...).

The upstream package is abandoned at 0.1.2, so pin the fix with a bun
patch that passes the renderable object on all three bindings (react,
solid, core container). A tui-test opens and dismisses the help dialog
and asserts the panel's #262626 background is fully gone, not just its
text.

Fixes #12506

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-24 15:18:15 +09:00
Saoud RizwanandCursor Agent 359e771ab3 Set up Cloud dev environment (CLI, VS Code extension, desktop app) (#12515)
* docs: add Cursor Cloud dev environment setup notes (AGENTS.md)

* docs: document VS Code extension + desktop app dev setup (AGENTS.md)

* docs: trim AGENTS.md cloud agent instructions

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-23 19:26:10 -07:00
Saoud Rizwan a21b21de84 chore(desktop): release v0.0.4 2026-07-23 18:50:17 -07:00
BeeandSaoud Rizwan ca6f6a6c23 feat(desktop): use the shared Cline Hub runtime (#12508)
* feat(desktop): use the shared Cline Hub runtime

* fix(hub): group code-sidecar-observer clients under Code App

The desktop observer client type was renamed from code-sidecar-approvals
to code-sidecar-observer, but the Code App grouping matchers in the hub
dashboard and menubar sidecar still only matched the old type. Since the
observer now registers on the shared Hub, it showed up as a separate
ungrouped client. Keep the old type matched for older desktop builds.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:21:39 -07:00
BeeandSaoud Rizwan 3e06abc366 feat(desktop): support chat without workspaces (#12412)
* feat(core): support pathless sessions with temporary workspaces

* fix(desktop): mark editor icons as decorative

* fix(core): omit absent auth request IDs

* test(sdk): restore request_id auth telemetry param in core-events test

The branch's drive-by request_id -> requestId rename was dropped while
resolving the merge conflict with #12444 (which added requestIdDetails on
main), so the public captureAuthLoggedOut/captureAuthRefreshSoftFailure
API keeps its original parameter name.

* refactor(sdk): root pathless session workspaces under the cline data dir

Move the workspace created for pathless session starts from
<os.tmpdir()>/cline/sessions/<id>-temp/project to
<cline-data-dir>/workspaces/<id>/project (default
~/.cline/data/workspaces/<id>/project), per PR review:

- OS tmp reapers (macOS ~3-day purge, systemd-tmpfiles, reboot cleanup)
  silently delete user work created in 'New Project' sessions
- /tmp is a shared namespace on Linux: the first user to create /tmp/cline
  owns it (EACCES for everyone else), and guessable session IDs let a local
  attacker pre-create the workspace directory
- under the data dir the workspace shares the session store's lifecycle and
  the existing CLINE_DATA_DIR / CLINE_DIR overrides for tests and sandboxes

isTemporaryWorkspacePath now matches the .cline/data/workspaces/<id>/project
segment shape, and the -temp suffix is gone since the id-scoped directory no
longer needs to mark itself as reapable.

* feat(sdk): open pathless sessions in one shared chat workspace

Instead of minting a workspace directory per session
(<data>/workspaces/<session-id>/project), all sessions started without a
cwd/workspaceRoot now share <cline-data-dir>/workspaces/chat (default
~/.cline/data/workspaces/chat). Starting a pathless session seeds the
directory with an AGENTS.md rules file (only when missing, so users can
edit it) that tells the agent to treat the session as a chat: don't create
or edit files unprompted, ask where a project should live when the user
wants one built, and default to a new named folder inside the chat
directory that later sessions can reference.

This avoids unbounded per-session directory sprawl, gives chat sessions a
stable home the user can revisit, and groups them naturally in the desktop
sidebar. The desktop app now labels the shared workspace "Chat" (menu
action "Just chat") instead of "New Project", and isChatWorkspacePath
matches only the chat directory itself, so project folders created inside
it behave as regular workspaces.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:09:03 -07:00
Saoud Rizwan 6518b05b6c feat(desktop): first-run onboarding flow (#12495)
* feat(desktop): add replay new-user-experience setting

Adds an onboarding state module (localStorage-backed, with a reset event
the app shell can subscribe to) and a 'New user experience' row in
Settings -> General with a Replay button so beta testers and designers
can re-run the first-run flow. The flow itself ships in the stacked
follow-up PR.

* feat(desktop): first-run onboarding flow

Full-screen first-run experience shown until completed once: a welcome
step (3D glass logo over the aurora background), a connect step offering
Cline sign-in (recommended) or bring-your-own API key against the
provider catalog, and a done step that drops the user into a fresh
thread. Completion is tracked by the onboarding state module from the
previous PR; the Settings replay row now re-enters the flow in place via
the reset event, so its toast is gone. Skipping is always available so
nobody gets trapped; the connected provider (and its default model when
known) is remembered so the chat composer opens pointed at it.

* fix(desktop): address greptile review on onboarding flow

- Filter the bring-your-own-key picker to providers a lone API key can
  fully configure: providers with structured config fields (Vertex gcp.*,
  Bedrock aws.*) or no API-key field at all (Claude Code) no longer appear,
  since connecting them here would report success without working.
- Record Cline as the active provider when a signed-in user hits Continue,
  so replaying onboarding doesn't leave the chat pointed at a previously
  selected provider.

* feat(desktop): accent color themes and switchable app icon (#12496)

* feat(desktop): accent color themes and switchable app icon

Settings -> General grows an appearance cluster next to Dark mode:

- Accent color: six palettes from the Figma exploration (violet default,
  graphite, cyan, pink, espresso, ember). Non-default accents re-anchor
  --primary/--primary-foreground/--primary-emphasis/--ring per light and
  dark mode via html[data-cline-accent] overrides in globals.css, tuned in
  OKLCH to mirror the brand token relationships; chart and sidebar tokens
  alias var(--primary) so they follow. Persisted in localStorage and
  applied at boot alongside the dark-mode sync.

- App icon: the four Figma variants (Classic, Sunrise, Steel, Midnight).
  The webview persists the choice, swaps the favicon in browser mode, and
  in the Tauri shell calls the new set_app_icon native command, which
  loads the matching bundled resource (icons/dock/*.png) and applies it
  via NSApplication.applicationIconImage on the main thread. macOS resets
  the dock icon every launch, so the shell re-applies the stored choice at
  boot; classic is also loaded from a resource because the objc2 binding
  warns against passing nil to restore the bundled icon. Other platforms
  no-op (Ok(false)).

* fix(desktop): don't let a stale app-icon failure roll back a newer selection
2026-07-23 16:59:50 -07:00
BeeandSaoud Rizwan f9f492319e feat(desktop): redesign channel setup as expandable cards (#12490)
* feat(desktop): redesign channel setup as expandable cards

Replace the add-channel dialog with inline expandable configuration forms, including per-channel validation and error handling.

Add comprehensive tests for channel configuration, conditional fields, security options, and connection workflows.

* Connect

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:54:29 -07:00
Saoud Rizwan c3ab557194 polish(desktop): cleaner chat markdown + external links that actually open (#12497)
* polish(desktop): cleaner chat markdown + external links that actually open

Links in chat markdown never opened in the packaged app: the confirm
dialog's window.open(_blank) is silently dropped by the Tauri shell.
Route opens through openExternalUrl (open_external_url sidecar command)
and only keep the confirmation dialog for deceptive links whose visible
text reads as a URL on a different host than the real destination —
ordinary external links now open directly in the default browser.

Visual pass on Streamdown output for the chat pane: collapse the
double-boxed code block card and drop the language header row, reveal
the copy button on hover only, turn off line numbers, single-box tables,
chat-scale the heading ramp (h1 was text-3xl next to 14px body), outside
list markers, and tighter block rhythm.

* fix(desktop): harden deceptive-link detection per review

Recurse into element children when extracting link label text so inline
formatting (e.g. a bolded hostname) can't dodge the deception check, and
compare port and (when the label states one) scheme in addition to
hostname so same-host links to an unexpected scheme or port still get
the confirmation dialog. An unparseable destination behind URL-shaped
label text is now treated as deceptive rather than waved through.

* fix(desktop): treat trailing-dot FQDN labels like their plain hostname

Browsers resolve 'github.com.' identically to 'github.com', but the
URL-shaped-label pattern rejected the trailing dot, so a deceptive label
like [github.com.](https://evil.example) skipped the deception check and
opened directly. Accept one trailing dot in the pattern and strip
trailing dots during hostname normalization on both sides, so the FQDN
form is deceptive exactly when the plain form is.

* fix(desktop): treat protocol-relative labels like their https form

A label spelled '//github.com' reads as a URL but failed the URL-shaped
pattern (which only tolerated an https?:// prefix), so it skipped the
deception check and opened an unrelated destination directly. Accept a
protocol-relative prefix in the pattern, and parse '//'-prefixed values
as https-relative in parseLinkParts — prepending 'https://' to them
produced an empty hostname and made the comparison a no-op.
2026-07-23 16:48:50 -07:00
BeeandSaoud Rizwan 9fa37a9128 feat(desktop): display image attachments in chat (#12502)
* feat(desktop): drag and drop files to attach them to the chat

The Tauri webview swallows OS file drags by default (dragDropEnabled),
so HTML5 drop events never fire. Disable it on the main window per the
Tauri v2 docs, then handle standard dragenter/dragover/dragleave/drop on
the chat pane: dropped files feed the same dedupe-and-append pipeline as
the paperclip picker, with a depth-counted 'Drop to attach' overlay while
files are dragged over. Image drops become data-URL images via the
existing serializeAttachments path.

* feat(desktop): display image attachments in chat

* 225x225

* fix(desktop): preserve queued attachments

* fix(desktop): distinguish queued image turns

* fix pending

* fixed

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:46:36 -07:00
BeeandSaoud Rizwan 9cc7796bbe feat(desktop): add custom overlay title bar navigation (#12504)
* feat(desktop): add custom overlay title bar navigation

Configure Tauri to use a hidden overlay title bar and host back/forward navigation in the draggable sidebar header. Preserve agent title width during editing to prevent layout shifts, with tests covering both behaviors.

* fix(desktop): reconcile deleted navigation entries

* fix(desktop): dedupe session deletion events

* fix(desktop): serialize session deletion state

* fix(desktop): use exported DesktopAppView type in page.tsx

AppView is a non-exported type local to agent-sidebar.tsx, so referencing
it in page.tsx was a TS2304 error hidden by the typecheck script's webview
exclusion and next's ignoreBuildErrors.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:46:07 -07:00
Saoud Rizwan 439baee17c feat(desktop): add replay new-user-experience setting (#12494)
Adds an onboarding state module (localStorage-backed, with a reset event
the app shell can subscribe to) and a 'New user experience' row in
Settings -> General with a Replay button so beta testers and designers
can re-run the first-run flow. The flow itself ships in the stacked
follow-up PR.
2026-07-23 16:43:33 -07:00
BeeandSaoud Rizwan 469debdb30 fix(schedules): default headless routines to yolo (#12489)
* fix(schedules): default headless routines to yolo

Centralize the Cline default model ID in @cline/shared while preserving the @cline/llms export. Keep explicit modes stable and disable ask_question for unattended scheduled runs.

* autoapprove

* fix unit test

* fix(schedules): harden headless routine execution

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 14:53:03 -07:00
John Choi 79589c8417 test(cli): relax cold history dispatch timeout (#12511) 2026-07-23 14:36:33 -07:00
Saoud Rizwan 8e5471de9c feat(vscode): show compaction progress and results in the webview (#12487)
* feat(vscode): show compaction progress and results in the webview

Port the CLI's compaction UX to the VS Code extension:

- Translate the SDK's compaction status notices into a say:'compaction'
  divider row with a spinner while running, updated in place (same ts) to
  'Context compacted · x → y tokens · n → m messages' when done, matching
  the CLI's divider. Dangling dividers finalize as failed/cancelled when
  the turn errors or ends mid-compaction.
- Manual /compact (button or slash command) drives the same divider from
  the compaction coordinator instead of plain info lines, capturing token
  counters from the SDK's status notices.
- Drop raw status-notice slugs ('auto-compacting') that previously
  rendered as info rows.
- Context window bar now reads the compacted size (tokensAfter) from a
  compaction row newer than the last API request, so it drops immediately
  after compaction instead of waiting for the next turn.
- Fix the Auto Compact Strategy selector showing 'basic' when unset; the
  effective default is agentic (core defaults strategy ?? 'agentic').

* fix(vscode): apply compaction shrink as a ratio to the context meter

Address review feedback from #12487:

- getLastApiReqTotalTokens: instead of substituting the compaction
  notice's tokensAfter (an SDK estimate on a different scale than
  provider-reported usage, which made the bar re-snap when the next
  request's real usage landed), scale the last provider-reported request
  total by the compaction's tokensAfter/tokensBefore ratio. Both
  counters come from the same estimator, so the ratio is scale-free.
  Multiple compactions since the last request compound. A completed
  divider without token counters leaves the total unscaled.
- Suppress only the known-internal status notices explicitly
  (compaction-budget-adjusted); an unrecognized status notice now falls
  through to an info row so future notices surface instead of silently
  vanishing.
- Cross-reference the two compaction-divider finalization paths (auto:
  translator finalizeDanglingCompaction; manual: coordinator catch) so
  terminal-state rule changes touch both.
- Post state to the webview before re-throwing in the coordinator's
  failure path, consistent with the other terminal branches.
2026-07-23 10:07:57 -07:00
Saoud Rizwan cd553d2343 feat(desktop): drag and drop files to attach them to the chat (#12498)
The Tauri webview swallows OS file drags by default (dragDropEnabled),
so HTML5 drop events never fire. Disable it on the main window per the
Tauri v2 docs, then handle standard dragenter/dragover/dragleave/drop on
the chat pane: dropped files feed the same dedupe-and-append pipeline as
the paperclip picker, with a depth-counted 'Drop to attach' overlay while
files are dragged over. Image drops become data-URL images via the
existing serializeAttachments path.
2026-07-23 10:06:16 -07:00
Mikołaj KondratekandClaude Fable 5 7b776225b9 fix(telemetry): report host identity on SDK-pipeline events (#12503)
* fix(telemetry): report host identity on SDK-pipeline events

On JetBrains standalone cline-core, SDK-pipeline events (task lifecycle,
token usage, tool usage, provider failures) reported the hardcoded
cline_type "VSCode Extension", platform "VS Code", and
platform_version "unknown", unlike the classic TelemetryService which
resolves these from HostProvider.env.getHostVersion().

Extend the host_plugin_version resolution in VscodeTelemetryPolicyService
to apply the full host identity (cline_type, platform, platform_version)
with the same mapping the classic pipeline uses, before the telemetry
gate opens. Fields the host does not report keep the construction-time
fallbacks.

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

* fix(telemetry): fall back to unknown host identity, not VSCode labels

A failed getHostVersion lookup previously left the hardcoded VSCode
identity in place, hiding the failure as a plausible-looking row.
"unknown" makes the failure visible and matches the classic
TelemetryService's || "unknown" semantics.

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

* fix(telemetry): defer provider_created until host identity is applied

telemetry.provider_created was captured synchronously inside the core
factory, before VscodeTelemetryPolicyService resolves getHostVersion —
so that one event always carried the construction-time fallback identity
(pre-existing: it reported the hardcoded VSCode identity on JetBrains
and never had host_plugin_version).

Add an opt-in deferProviderCreatedEvent to the core telemetry factories
that skips the construction-time capture and exposes it as
ConfiguredTelemetryHandle.emitProviderCreated; the policy service emits
it right after applying the resolved host metadata. Other handle
consumers (CLI, hub daemon, examples) keep immediate emission.

Also close the subscription race on the same guarantee: a host setting
flip arriving while getHostVersion is still resolving now waits for the
metadata to be applied before opening the gate, and a slow initial
settings fetch no longer overwrites a newer subscription update.

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

* fix(telemetry): emit deferred provider_created on early dispose

If the policy service is disposed while the host-version lookup is
still pending, the deferred provider_created would never be captured —
the undeferred event was always emitted (with construction-time
identity) and exported by the shutdown flush. Emit-once semantics:
dispose fires the event with the fallback identity before shutting the
handle down, and the late metadata continuation cannot double-emit.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:25:53 +09:00
Bee ae033761ab feat: auto generate built-in provider list (#12204)
* feat: auto generate built-in provider list

- Generate `providers.generated.ts` and `provider-ids.generated.ts` from `models.dev/api.json` alongside the model catalog
- Merge generated provider specs with handwritten built-in overrides for Cline, Codex, local/OAuth providers, routing metadata, and product defaults
- Include additional `models.dev` providers only when they are OpenAI-compatible for now
- Keep lightweight provider ID utilities from importing the full generated provider spec catalog

* Removed redundant handwritten definitions for providers that are fully described by generated metadata

* update unit test
2026-07-23 14:27:59 +02:00
Mikołaj KondratekandClaude Fable 5 c961ae7730 feat(telemetry): emit host_plugin_version metadata on all events (#12478)
* feat(telemetry): emit host_plugin_version metadata on all events

The host already reports its Cline distribution version over the
hostbridge (getHostVersion.clineVersion — the JetBrains plugin version
on JetBrains, the extension version on VSCode), but telemetry never
attached it: extension_version is always the cline-core bundle version,
so JetBrains events could not be tied to a plugin release.

Attach it as a new optional host_plugin_version metadata field, omitted
when the host does not report one.

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

* test(telemetry): loop over host version cases instead of interleaving stubs

Review feedback: the two host_plugin_version cases were interleaved via
onFirstCall/onSecondCall stubs across two service instances. Run one
mock-assert-reset cycle per case so the only differences between them —
the host version response and the expected reported value — are visible
in the case table.

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

* test(telemetry): guarantee stub cleanup in host_plugin_version test

Review feedback: the loop installed process-global stubs and only
restored them on the happy path — a rejected create() or failed
assertion would leak exhausted stubs into subsequent tests and leave
the service undisposed. Use a sinon sandbox restored in finally, and
dispose the service there too.

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

* feat(telemetry): carry host_plugin_version on SDK-pipeline events too

Review feedback: main's production controller emits task lifecycle,
token usage, tool usage, and provider-failure events through a separate
SDK telemetry service whose metadata is built independently, so those
events still omitted the plugin version.

Add the optional host_plugin_version field to the shared SDK
TelemetryMetadata contract and resolve it from the authoritative
getHostVersion response during the policy service's init. The metadata
update is sequenced before the host telemetry setting is applied, and
events stay gated until that setting lands, so no event can be emitted
without the field in place. A failed host-version lookup degrades to
the previous behavior (field absent, telemetry still enabled).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:37 +09:00
Dominic CooneyandCline Agent e940b6a335 fix(vscode): preserve edit preview focus (#12491)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-23 16:23:18 +09:00
David Knaack 5a0780a6f9 fix(llms): set metering header and use fetch adapter for sap ai core (#12337) 2026-07-23 00:05:27 -07:00
BeeandSaoud Rizwan 847276f4a5 feat(desktop): add one-time routines and run navigation (#12477)
* feat(schedules): add one-time routines and run navigation

* fix schedule review concerns

* fix repository lint errors

* fix optional auth request ID telemetry

* fix one-time schedule lifecycle

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-22 16:56:49 -07:00
Dominic Cooney 8b4d1973b5 fix(vscode): reclaim unobserved fallback terminals (#12352)
Classify unobservable terminal outcomes so cleanup and reporting share one source of truth. Reclaim managed sendText fallbacks at the disclosed next-acquisition boundary while preserving markerless, continued, detached, and uncertain-error terminals. Make cleanup, CWD reservations, process listeners, and detached logs failure-safe.
2026-07-22 16:22:11 -07:00
Dominic Cooney a5211a3a5c fix(vscode): make 'Proceed While Running' sticky to the command batch (#12359)
Register every foreground command before terminal acquisition so a parallel batch observes one Proceed While Running decision. If a command is still acquiring a terminal, settle its tool result immediately and transfer the approved command to an owned detached lifecycle that logs acquisition, output, completion, and failure. Abort before startup unregisters the handle and prevents the command from starting later.
2026-07-23 06:39:38 +09:00
Saoud Rizwan fb1333fdc2 fix(desktop): route external link opens through sidecar so they work in Tauri (#12481)
* fix(desktop): route external link opens through sidecar so they work in Tauri

The markdown 'Open external link?' dialog confirmed via window.open, which
the Tauri webview silently drops (no window opener configured), so clicking
'Open link' did nothing (ENG-2302). Route confirmation through
openExternalUrl, which invokes the open_external_url sidecar command inside
the Tauri shell and falls back to window.open in plain web mode.

Also fixes the marketplace 'Get value' env-var link, which relied on the
same dead target=_blank behavior.

* fix(desktop): open mailto/tel links and middle-clicked marketplace links

Greptile review fixes:
- open_external_url now allows mailto: and tel: alongside http(s) — the
  platform openers already dispatch any scheme to the OS protocol handler,
  the gate is just the allowlist. Streamdown's harden step blocks every
  other scheme before it reaches SafeMarkdownLink (test added to guard
  that assumption, since the sidecar allowlist relies on it).
- Protocol-relative URLs pass streamdown but fail the sidecar's new URL()
  parse; pin them to https before handing off.
- The marketplace 'Get value' link now intercepts middle clicks (auxclick)
  too, which bypassed the onClick handler and fell into the dead
  target=_blank path.
2026-07-22 14:27:01 -07:00
Renee Huang 59113c309c docs: update ClinePass wording to '2-5x the usage on popular open coding models compared to standard API rate' (#12479)
* docs: update ClinePass wording from '2-5x API rate limits' to '2-5x the usage on popular open coding models compared to standard API rate'

* docs: update ClinePass wording in cline-provider.mdx for consistency

* nit
2026-07-22 11:44:55 -07:00
TheRealSpencer 48d0c38f52 fix(security): bump axios to 1.18.0 to resolve multiple CVEs in axios (#12473)
* fix(security): bump axios to 1.18.0 to resolve multiple CVEs in axios

* fix(security): bump axios to 1.18.0 in docs project
2026-07-22 12:38:45 -05:00
aikido-autofix[bot] c7ab9ff839 fix(security): update js-yaml from 4.1.1 to 4.3.0 (#12456)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-07-22 09:49:42 -05:00
Tomás Barreiro 045518d19f Add Request-ID to auth events (#12444)
* Add the X-Request-ID to the ClineOAuthTokenError

* Add request id to events

* address comments
2026-07-22 13:20:30 +09:00
Saoud Rizwan 099c6179e4 fix(desktop): resolve login shell PATH so agent can find gh and other CLI tools (#12429)
* fix(desktop): resolve login shell PATH so agent can find gh and other CLI tools

When the Tauri app is launched from Finder/the Dock on macOS it inherits
launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), so the sidecar and
every process it spawns for the agent (bash tool, MCP servers) can't find
tools installed via shell profiles, e.g. Homebrew's gh in /opt/homebrew/bin.
The same task works from the CLI because a terminal runs with the full
login-shell PATH.

At sidecar startup, ask the user's login+interactive shell for its PATH
(sentinel markers isolate it from profile noise, 5s timeout, kill on hang)
and merge it into process.env.PATH: shell entries first, current-only
entries preserved. No-op on Windows; CLINE_SIDECAR_SKIP_SHELL_PATH=1 is the
escape hatch. Failures never block startup.

Fixes CLINE-2740

* fix(desktop): address greptile review on shell PATH resolution

- Don't let shell resolution eat the Tauri endpoint-readiness window: kick
  it off first so it overlaps sidecar startup (awaited before the session
  manager exists, which is what spawns children), drop the shell timeout
  5s -> 2s, and give the fallback attempt half the budget so the combined
  worst case (3s) stays inside the 5s readiness poll.
- Handle non-POSIX login shells: run the marker printf inside /bin/sh so
  $PATH expansion never depends on the outer shell's rules (fish would
  space-join it), pass -i/-l/-c as separate flags, give csh/tcsh only -c
  (their -l is valid only as the sole flag), and retry with the platform
  default shell when $SHELL can't produce a PATH.
- Don't log the resolved PATH: the applied result now carries an entry
  count instead of the merged PATH string.

* fix(desktop): read login shell from the account database, document PATH resolution

$SHELL is set by a parent shell, so a GUI-launched process may not have it.
Use os.userInfo().shell (getpwuid — DirectoryServices on macOS, same source
as dscl UserShell; NSS/etc/passwd on Linux) as the authoritative source,
with $SHELL and the platform default as fallbacks. Also documents the whole
mechanism in the app README.

* fix(desktop): widen endpoint readiness poll, source csh login profile

- The 5s get_desktop_backend_endpoint poll was already tight for
  session-manager init on slow machines; shell PATH resolution (bounded 3s
  worst case) made it tighter. Poll 15s instead — it returns as soon as the
  ready line arrives, so only genuine failure waits longer.
- csh/tcsh can't take -l alongside -c, so mark them as login shells via the
  argv[0] dash convention (argv0: "-tcsh") to get ~/.login sourced on top
  of the always-read rc file.

* fix(desktop): never spawn a second sidecar while one is alive

ensure_desktop_backend_started treated a live child with a pending
endpoint as absent and fell through to spawn a duplicate, orphaning the
first process. Hold the process lock across the whole check-and-spawn
(concurrent callers serialize), return early for any live child, fail
the endpoint poll fast when the child exits instead of respawning, and
stop a stale stdout-reader from wiping a successor's endpoint. The spawn
is injectable so regression tests cover repeated and concurrent startup
checks (exactly one spawn while pending) and dead-child replacement.

* style(desktop): tighten mergePaths and csh comment per review

* docs(desktop): codify backend state lock ordering
2026-07-21 18:11:55 -07:00
BeeandClaude Fable 5 26037b17ac feat(core): default to agentic compaction (#12317)
* feat(core): default to agentic compaction

Use agentic compaction when no valid strategy is configured while preserving explicit basic selection. Add a session compaction CLI and package script for testing and comparing compaction strategies.

* createHandlerMock

* fix(core): let the agentic compaction cut land on assistant boundaries

Agentic auto-compaction only accepted typed user messages (turn starts)
as cut boundaries. The canonical host transcript — one typed task
followed by a long assistant tool_use / user tool_result loop — has no
turn start past index 0, so findCutIndex snapped to 0 and
runAgenticCompaction returned undefined: the UI showed "auto-compacting"
then "auto-compaction-skipped" on every turn while the context kept
growing. Re-compaction had the same failure permanently, because the
projected transcript starts with a compaction summary message, which is
excluded from turn starts.

Assistant messages are equally safe boundaries: an assistant's tool_use
keeps its result in the user message that follows it, so a cut there
never orphans half of a tool pair. Typed-user protection is preserved —
when a typed turn exists past index 0 the cut still stays at or before
it, so the latest typed prompt is never folded into the summary.

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

* add compaction fixtures for testing

* basic compaction improvement

* feat: attach metadata to the merged compaction message

* fix(core): address review comments on compact-session script

- add cline provider to the API key env defaults (CLINE_API_KEY)
- accept legacy string-content messages in readMessages
- print usage instead of a stack trace when --provider/--model are missing

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

* fix(core): preserve basic compaction across restores

## Summary

- keep tool-result message IDs stable across restore/persist round-trips
- preserve concluding assistant responses as real messages during basic compaction
- freeze prior compaction output so later passes only fold newly added history
- accumulate removed-message and usage metadata across repeated compactions
- update the basic compaction fixture and regression coverage

## Problem

Tool-result IDs were re-suffixed every time persisted messages were converted
back into agent messages. Because compaction state hashes the source message
prefix, restoring a session changed that hash and invalidated an otherwise
successful compaction, causing the full transcript to be sent again.

Basic compaction also reprocessed its own output on subsequent passes. This
could stack duplicate system notices, discard assistant conclusions retained by
the previous pass, and replace cumulative compaction statistics with values
from only the latest pass.

## Solution

Only add tool-result suffixes when splitting a mixed message, leaving already
split and single-result message IDs unchanged. Mark non-user compaction
survivors as preserved, carry those messages through future passes verbatim,
and budget older turns' final assistant answers as first-class messages.
Compaction metadata now adds prior removed-message and usage totals to the work
performed by the current pass.

## Validation

- 66 focused codec and compaction tests pass
- @cline/core typecheck and smoke typecheck pass
- Biome checks pass for all changed TypeScript files
- git diff --check passes

* fix unit test

* fix compaction defaults and fallback

* fix basic compaction credential lookup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 17:41:58 -07:00
Saoud Rizwan 1adce5e56d chore(desktop): release v0.0.3 2026-07-21 17:31:29 -07:00
Saoud Rizwan 73583ea178 polish(ui): reasoning trigger reads just 'Thinking' — drop status text and brain icon (#12460)
The collapsed reasoning block header showed 'Thought process · Complete'
with a brain icon; PM feedback is that the status text and icon read as
noise. The trigger is now just the label + disclosure chevron, with
'Thinking' as the default label in both streaming and complete states.
Removes the now-unused cline-chat-reasoning-status style and BrainIcon.
2026-07-21 17:28:25 -07:00
Saoud Rizwan 21a0141b86 chore(desktop): release v0.0.2 2026-07-21 17:00:20 -07:00
Saoud Rizwan ecb71ba7cc fix(desktop): make scheduler row actions work and add hover tooltips (#12428)
* fix(desktop): make scheduler row actions work and add tooltips (CLINE-2745)

- Replace the view icon's window.alert (a no-op inside the Tauri webview)
  with a proper schedule-details dialog
- Trigger schedule.trigger with wait: false so 'run now' queues the run
  and returns immediately instead of blocking until the whole agent run
  finishes (which outlived the webview's 120s request timeout)
- Add hover tooltips to all schedule row actions (view, edit, run now,
  pause/resume, delete, enable switch)
- Show a spinner on the run-now button while triggering and toast on
  success/failure
- Return lastExecutions from list_routine_schedules so 'Last result'
  actually populates (it was always '-')
- Mount <Toaster /> in the root layout; toast() calls app-wide were
  previously rendered nowhere

* fix(desktop): per-schedule last executions and concurrent row actions

- list_routine_schedules backfills the latest execution for schedules
  whose runs fell outside the 50-newest global window (skipping
  schedules that have never run), so every row can show a last result
- busy/triggering row state is now a set keyed by schedule id, so one
  action finishing no longer clears another row's in-flight spinner

* fix(desktop): reject same-row schedule actions synchronously

Two rapid clicks on the same row action could both fire before React
rendered the disabled state; the first completion then cleared the
shared busy id while the second request was still pending (and run-now
would enqueue two runs). Guard entry through a ref that mirrors
busyScheduleIds so the duplicate click is rejected before any request
is sent.
2026-07-21 16:41:05 -07:00
Saoud Rizwan f5224abdf5 feat(desktop): auto-updates + automated signed releases from GitHub Actions (#12420)
* feat(desktop): auto-update via Tauri updater with restart prompt

The Rust shell now checks the desktop-latest GitHub release feed on launch
and every 2 hours, downloads and stages updates in the background, and
exposes get_update_status/restart_to_apply_update commands. The webview
polls the status and shows a persistent toast with a one-click restart once
an update is staged; ignored updates apply on next launch. Updater
artifacts are only produced with the CI config overlay
(tauri.release.conf.json) so local packaging keeps working without the
updater signing key. Also mounts the previously-unmounted Toaster so
existing toast() calls render.

* ci(desktop): add desktop-publish release workflow and publish-desktop skill

desktop-publish.yml mirrors cli-publish: dispatch with a desktop-vX.Y.Z
tag + confirm gate, validates the tag against package.json and
tauri.conf.json, builds signed+notarized DMGs for aarch64 (native) and
x86_64 (cross-compiled sidecar via bun --target), generates the updater
manifest, publishes the versioned GitHub release, refreshes the rolling
desktop-latest auto-update feed, and posts to Slack. Adds the release
skill, changelog, and README docs for the required GitHub secrets.

* fix(desktop): address review — outlast sidecar shutdown window, dedupe update toast across remounts

stop() now polls for 7s before escalating to kill, past the sidecar's own
5s SHUTDOWN_TIMEOUT_MS graceful-shutdown budget, so clicking Restart now
(or quitting) during session persistence can't SIGKILL the sidecar
mid-write. notifiedVersion moves to module scope so a page remount doesn't
re-toast an update the user already dismissed.

* docs(desktop): move publish-desktop skill to .cline/skills, slim README release section

Match the publish-cli convention: the skill lives in .cline/skills/ and is
symlinked from both .agents/skills/ and .claude/skills/ so all agents pick
it up. The README's release section shrinks to a pointer + the two
never-lose invariants (desktop-latest feed, updater private key); the repo
secrets table moves into the skill, which also fixes its dangling reference
to a 'Release automation' README section and escapes the pipe that broke
the GFM table cell.
2026-07-21 16:37:18 -07:00
Saoud Rizwan 85484abf7a feat(desktop): align settings page with hub dashboard (#12427)
* feat(desktop): align settings with hub dashboard (ENG-2286)

- Break out Customizations into its own sidebar nav group (Plugins,
  Skills, MCP, Hooks, Rules, Agents, Tools), mirroring the hub
  dashboard's customizations break-out, replacing the single
  Customizations entry (Rules-only) and the MCP Marketplace entry
- Port the hub's account view: signed-out state with working Sign
  in/Sign out (the old Sign Out button had no handler), auth-error
  detection, disabled tabs when signed out, PageFrame/PageHeader layout
- Port the hub's add-provider view for consistent PageFrame layout

* fix(desktop): merge the two MCP sidebar entries into one

The sidebar showed MCP twice: 'MCP Servers' under Settings (full
management: add/edit/toggle/delete) and 'MCP' under Customizations
(marketplace browse with uninstall-only cards). Keep the single 'MCP'
entry under Customizations to match the hub sidebar, and route it to
McpServersContent with the marketplace embedded: the management cards
now render as the marketplace view's Installed section, so one page
covers add/edit/toggle/delete plus catalog install.

* fix(desktop): stop long marketplace taglines forcing page-wide overflow

line-clamp (webkit-box) paragraphs report their full unwrapped text
width as intrinsic min-content, and grid/flex items default to
min-width:auto, so long MCP taglines pushed the whole marketplace grid
(and the page) wider than the viewport. Add min-w-0 at each grid-item
level so cards clamp to the container and the tag row scrolls within
itself.
2026-07-21 16:33:54 -07:00
Saoud Rizwan 0b0e2fbab8 feat(desktop): add open-in-editor and copy-path actions to diff view (#12434)
* feat(desktop): add open-in-editor and copy-path actions to diff view

Adds per-file actions to the session diff view (CLINE-2738):
- copy the file path (resolved to an absolute path against the session cwd)
- open the file in a code editor via a new open_file_in_editor sidecar
  command that prefers editor CLIs (code/cursor/windsurf/zed/subl) and
  falls back to macOS app bundles, then the OS default opener

* fix(desktop): handle Windows editor shims and mount Toaster for failure feedback

Address greptile review on #12434:
- route .cmd/.bat editor shims through cmd.exe (spawn can't launch them
  directly) and attach spawn error listeners so async launch failures
  fall back to the OS opener instead of crashing the sidecar
- mount the app-wide Toaster (same lines as #12428) so copy/open failure
  toasts are actually visible

* fix(desktop): guard Windows shell launches against cmd metacharacters

cmd.exe re-parses metacharacters inside arguments even when Node quotes
them (the reason spawning .cmd files without a shell is banned), so a
file path like 'report & evil.cmd' handed to the cmd /c shim launch
could execute a second command. Reject such paths with a clear error on
win32 and skip shim executables containing metacharacters (CodeQL
js/shell-command-injection-from-environment on #12434).

* feat(desktop): editor picker dropdown + copy button next to path in diff view

Review feedback on #12434:
- Renee: open-in-editor is now a dropdown listing the editors actually
  installed on the machine (new list_available_editors sidecar command;
  PATH CLIs + macOS app bundles), plus a system-default entry.
  open_file_in_editor accepts an optional editor id; omitted keeps the
  old auto-cascade, so older sidecars and existing callers still work.
- Beatrix: copy-path button now sits right after the filename (GitHub
  style) instead of grouped at the right edge; an invisible flex spacer
  keeps the dead space clickable as a collapse toggle.

* feat(desktop): brand icons + kanban editor set in diff-view editor picker

Match the kanban open-in dropdown: monochrome brand glyphs (VS Code,
Cursor, Windsurf, Zed, Xcode, IntelliJ IDEA) rendered inline with
currentColor so they follow the theme, an 'Open in' menu header, and a
system-default entry with a generic icon. Catalog grows to the kanban
editor list (adds VS Code Insiders via code-insiders, IntelliJ via
idea, Xcode via xed; macApps is now a list so IntelliJ CE is found).
Sublime Text keeps a generic file-code glyph (kanban has no sublime
icon).
2026-07-21 16:31:48 -07:00
Saoud Rizwan 1e2e8fe81b fix(desktop): preserve MCP server oauth tokens and metadata across dialog edits (#12426)
* fix(desktop): preserve oauth and metadata when upserting MCP servers

upsert_mcp_server rebuilt the settings record from scratch, so editing a
remote server through the dialog silently wiped its oauth block (tokens)
and any plugin-ownership metadata. Merge machine-managed fields from the
existing record (following previousName across renames) into the upserted
entry.

* fix(desktop): drop MCP server oauth tokens when transport or URL changes

Editing a remote server's URL or transport previously carried the old
server's OAuth tokens onto the new registration, sending credentials
issued for one endpoint to a different one. Preserve oauth only when
the effective transport type + URL are unchanged (rename-safe).

* fix(desktop): treat legacy "http" MCP transport as streamableHttp alias

Core config-loader maps transportType "http" to streamableHttp, so a
legacy record resaved through the dialog is the same endpoint; without
normalizing, mcpTransportIdentity saw it as changed and dropped oauth.

* fix(desktop): default typeless URL-based legacy MCP records to sse

Core config-loader resolves a legacy flat record with a url but no
type/transportType as sse, while the sidecar defaulted to stdio. That
skewed mcpTransportIdentity (dropping oauth on a no-op edit) and made
list_mcp_servers report such records as stdio to the dialog.
2026-07-21 16:24:28 -07:00
Tomás Barreiro 78c6724c6a Add auth metadata to the auth telemetry (#12274)
* Add session and user id to auth telemetry events

* Add the auth metadata

* Address comments

* Add metadata to successful events

* remove user ids from the types

* fix tests

* address comments

* replace startedAtMs with sessionDurationMs

* fix tests

* update based on latest main

* fix imports
2026-07-22 00:41:43 +02:00
Bee f2a895cf86 fix(desktop): rebuild sessions when switching providers (#12454)
* fix(desktop): rebuild sessions when switching providers

Recreate active sessions with their existing transcript and compaction state before sending to a different provider. Preserve provider-specific connection settings and distinguish provider changes from model-only updates.

Add coverage to verify provider switches rebuild the session before sending.

Currently SendSessionInput has no provider/model configuration, so the desktop client must perform that lifecycle transition before sending. The cleaner long-term API would make provider selection part of an atomic turn request—something like send({ sessionId, prompt, providerId, modelId })—and let Core decide whether rebootstrap is necessary.

* fix(desktop): harden provider session transitions

* fix(desktop): make provider rebuilds transactional
2026-07-21 15:20:15 -07:00
Saoud Rizwan 1585999251 fix(desktop): make account page functional (#12424)
* fix(desktop): make account page functional

The account page rendered data but every interaction was dead:

- Sign Out button had no click handler at all. Wire it to clear the
  cline provider auth (same flow as cline-hub), show a signed-out card
  with a working Sign In (browser OAuth) instead of a raw error + Retry,
  and refresh the shared account context so the sidebar identity updates.
- Organization rows were static divs. Make them switchable (including a
  Personal row) via the existing cline_account switchAccount operation,
  with a pending spinner and overview + context reload after switching.
- External links (+ Credit, + Create org, open dashboard) used
  target=_blank anchors, which are silently dropped inside the Tauri
  shell (no window opener configured). Route them through a new
  open_external_url sidecar command that opens the host default browser
  (http/https only); plain web mode falls back to window.open.
- + Credit pointed at the organization credits page even for personal
  accounts; use dashboard/account?tab=credits when no org is active.
- Guard the browser-open spawn with an error listener so a missing
  opener binary can't crash the sidecar with an unhandled error event.
- Disable Usage/Billing tabs while signed out (they can only error).

Closes CLINE-2737

* fix(desktop): harden external URL opener and auth error classification

- open URLs on Windows via rundll32 instead of cmd /c start so URL
  metacharacters cannot be parsed as shell operators
- surface opener spawn failures instead of always reporting opened: true
- classify only definitive signals (missing token, re-auth required,
  status 401) as signed-out; transient refresh/permission errors keep
  the retryable error UI

* fix(desktop): reject external URL open when the launcher exits non-zero

The opener promise resolved on the spawn event, so a launcher that
started but failed to hand off (xdg-open exits 3 when no handler is
available) still reported opened: true. Reject on a fast non-zero exit;
if the launcher is still running after a 2s grace window, assume the
handoff worked rather than blocking on a launcher that lingers.
rundll32 exits 0 even on failure, so Windows stays best-effort.
2026-07-21 15:19:02 -07:00
Saoud Rizwan 2c556a4c94 feat(desktop): simplify Add MCP Server dialog with Local/Remote server types (#12425)
Replaces the raw stdio/sse/streamableHttp transport dropdown with a
plain-language Local vs Remote choice (CLINE-2748). Local (stdio) stays
the default per the MCP spec's "Clients SHOULD support stdio whenever
possible"; picking Remote defaults to Streamable HTTP with SSE offered
as a legacy option. Working directory and Metadata JSON move behind an
Advanced collapsible (auto-expanded when editing a server that uses
them), and the server list badge now shows friendly transport labels.
2026-07-21 14:53:33 -07:00
Saoud Rizwan 9a80fa04c2 fix(desktop): keep thinking indicator visible until first model output (#12432)
* fix(desktop): keep thinking indicator visible until first model output

The webview only rendered the Thinking indicator while the chat status
was 'starting', but Core reports 'running' as soon as the turn is
dispatched -- well before the first streamed token arrives. The spinner
flashed for the RPC roundtrip and then disappeared, leaving ~1s of dead
air (model time-to-first-token) before the assistant bubble appeared.

Keep the indicator up while the session is running and the model has
not produced output yet: no streaming assistant message, last visible
message is the user's prompt, and no approvals/questions pending.

Closes CLINE-2739

* test(desktop): tighten thinking indicator test formatting
2026-07-21 14:52:21 -07:00
Saoud Rizwan 22a1fa2c84 feat(cli): upgrade opentui 0.1.102 -> 0.4.3 (#12453)
* feat(cli): upgrade opentui 0.1.102 -> 0.4.3

Brings the TUI stack up from April's 0.1.102 to the current 0.4.x line
(0.4.4/0.4.5 are <7 days old and blocked by the registry release-age
gate; bump again once they age out).

- @opentui/core + @opentui/react 0.1.102 -> 0.4.3
- opentui-spinner ^0.0.6 -> ^0.0.7 (0.0.7 peers on @opentui/core ^0.3.4)
- react-reconciler pin 0.32.0 -> 0.33.0 to match @opentui/react 0.4.x

@opentui-ui/dialog stays at 0.1.2 (abandoned upstream, peers ^0.1.69 so
bun warns on install) but its runtime surface (DialogProvider,
useDialog, useDialogKeyboard) works against core 0.4.3 - the tui-test
command-palette spec renders a real dialog in a pty and passes.

Validation: tsc clean, unit 889/890 (the one failure repros on an
untouched main checkout - stale bun pm pack guard expectation), tui-test
62/62 across repeated runs.

* fix(cli): force single opentui generation via root overrides

The previous commit left @opentui-ui/dialog's ^0.1.69 peer range
unsatisfied by core/react 0.4.3, so bun recorded nested
@opentui/core@0.1.102 + @opentui/react@0.1.102 copies under the dialog
package in bun.lock. Local installs happened to link the dialog against
the hoisted 0.4.3 store variant (which is why tui-test passed), but a
fresh install from the lockfile - CI, release builds - would follow the
nested entries and run two renderer generations in one process: dialog
components extending 0.1.102 Renderable classes inside a 0.4.3 renderer
tree.

Pinning @opentui/core and @opentui/react in the root overrides block
forces every consumer, dialog included, onto 0.4.3. The nested lockfile
entries are gone and a runtime identity check confirms
DialogContainerRenderable's prototype chain reaches the same class
objects as the 0.4.3 core the app imports.

Side effect: changing overrides makes bun fully re-resolve the
lockfile. The only drift is ~108 @radix-ui entries nested under the
vscode webview-ui workspace moving to newer patch versions (~1.1.15 ->
~1.1.19); webview-ui's full build (tsc -b && vite build) passes with
them. This drift would land at the next release anyway since bun run
version deletes and re-resolves bun.lock.

Re-validated: tsc clean, tui-test 62/62, unit 889/890 (same single
pre-existing bun pm pack guard failure that repros on untouched main).
2026-07-21 14:49:26 -07:00
Parafee41andSaoud Rizwan 57d364ffc2 fix(cli): keep status delivery failures non-fatal (#12401)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-21 14:18:02 -07:00
TheRealSpencer 0912f34286 fix(deps): bump mermaid to 11.16.0 and protobufjs to 7.6.5 (#12445)
Address mermaid CVEs (CVE-2026-41148/41149/41150/41159) and
protobufjs CVEs (CVE-2026-54269, CVE-2026-48712) by pinning
patched versions via package deps and workspace overrides.
2026-07-21 15:38:59 -05:00
Etisha Garg bdb216c110 Revert "docs: add Kimi K3 to ClinePass documentation (#12380)" (#12407)
This reverts commit cc29955c2d.
2026-07-21 10:02:58 -07:00
Mikołaj KondratekandClaude Fable 5 c92d4e7553 fix(sdk): preserve file line endings in the editor tool executor (#12305)
* fix(sdk): preserve file line endings in editor tool executor

The native editor executor split and joined file content on "\n" only.
On CRLF files (common on Windows), insertInFile left existing lines with
trailing "\r" while inserted lines were LF-only, producing mixed line
endings. Because reads go through readline with crlfDelay (which strips
"\r"), the model always emits LF-only old_text, so subsequent exact-match
replaceInFile calls failed; multi-line replace on pure-CRLF files was
broken the same way.

Detect the file's dominant EOL and normalize: insertInFile now splits
content and new_text on /\r\n|\n/ and joins with the detected EOL, and
replaceInFile normalizes old_text/new_text to the file's EOL before
matching. The str_replace diff output also splits on /\r\n|\n/ so it no
longer embeds stray "\r" in diff lines sent back to the model.

Reported via JetBrains marketplace review #141234 (DeepSeek + CLion on
Windows).

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

* fix(sdk): address review — accurate EOL doc, literal $-sequences in replace

Reword the detectLineEnding JSDoc: it is a presence check for CRLF, not a
majority vote, so say so instead of claiming "dominant" EOL.

Use a replacer function in replaceInFile so "$"-sequences in new_text
($&, $', $`, $$, $n) are inserted literally instead of being expanded by
String.prototype.replace. Pre-existing bug surfaced during review; adds a
regression test.

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

* docs(sdk): clarify why EOL detection is a CRLF presence check

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 16:33:37 +09:00
b919a7e86c docs: mark .clineignore as deprecated soon (#12410)
* docs: mark .clineignore as deprecated soon

Add a deprecation notice to the .clineignore page and update pages that recommend it. Enforcement of ignore rules is extremely difficult (agents can get around them via @ mentions or shell commands), and the feature is orphaned in the VS Code/JetBrains extension (ClineIgnoreController), not part of the Cline SDK or CLI.

* docs: update clineignore deprecation wording

* wording changes

* update clineignore docs with plugin reference

* fix plugin example url

* edit clineignore docs file

* update formatting for clineignore doc

* clean up clineignore docs file

---------

Co-authored-by: Cline <bot@cline.bot>
Co-authored-by: TheRealSpencer <spencer@cline.bot>
2026-07-20 18:01:37 -07:00
Bee eefbe9fb18 feat(desktop): log telemetry events in desktop app (#12416)
* feat(desktop): log telemetry events in desktop app

* Address feedback
2026-07-21 02:50:56 +02:00
Bee 402b9994d8 feat(desktop): tool use group (#12415)
* feat(desktop): tool use group

* focus block

* apple p2 feedback
2026-07-20 15:24:05 -07:00
Bee fce1b97512 feat(desktop): improve session overviews and clarify workspace errors (#12414)
* feat(desktop): improve session overview and workspace errors

* address p1
2026-07-20 14:41:30 -07:00
Bee e7b0cec8e1 fix(desktop): diff view layout with proper scrolling (#12411)
Use full-height flex sizing, prevent header shrinking, and allow the scroll area to contract so file diffs remain scrollable within the view.
2026-07-20 23:04:53 +02:00
Bee 353ddc10f4 feat(desktop): add account context and window title utilities (#12348)
* fix(desktop): filter project paths

Best effort to remove desktop and user's home directory from showing up in project list in the desktop app.

* feat(desktop-app): add account context and window title utilities

- Add AccountContext provider and hooks for managing Cline account identity
- Add account-context.tsx and account-context.test.tsx
- Add desktop-window-title.ts and desktop-window-title.test.ts
- Update workspace-paths.ts with new utility functions
- Update agent-sidebar.tsx and agent-sidebar.test.tsx to use account context
- Update page.tsx to integrate account context
- Update sidecar/commands.ts to support account operations
- Update core SDK exports

This adds proper account identity management and window title utilities for the desktop app.

* dedup normalizeWorkspacePath

* home page update
2026-07-20 21:40:39 +02:00
Ara cabeb61036 Add minimal task lifecycle telemetry (#11851)
* Add minimal task lifecycle telemetry

* fix(vscode): use runner-safe auto approval assertions

* fix task lifecycle telemetry cancellation ordering
2026-07-20 20:45:02 +02:00
Etisha Garg cc29955c2d docs: add Kimi K3 to ClinePass documentation (#12380) 2026-07-20 09:23:02 -07:00
Saoud Rizwan c2faf38d72 chore(cli): release v3.0.46 2026-07-18 23:14:41 -07:00
Saoud Rizwan 4dab17769c fix(cli): detect real insufficient_credits error from Cline API (#12394) 2026-07-18 23:11:46 -07:00
Saoud Rizwan 396032cd3b chore(cli): release v3.0.45 2026-07-18 21:03:37 -07:00
Saoud Rizwan f33ab3a872 chore(sdk): release v0.0.65 2026-07-18 20:46:28 -07:00
Saoud Rizwan 2ca8364ffc docs: add Kimi K3 to ClinePass model list and reference pricing (#12393) 2026-07-18 20:35:48 -07:00
Saoud Rizwan 2ef81be703 feat(llms): make Claude Code and Codex provider packages optional peers (#12379)
ai-sdk-provider-claude-code and ai-sdk-provider-codex-cli were hard
dependencies of @cline/llms, so every npm install of the cline CLI
pulled their native binaries (~250MB claude-agent-sdk platform binary,
~105MB @openai/codex) even for users who never select those providers.

Move both to optional peerDependencies (kept as devDependencies so
monorepo builds still bundle the JS) and load them via literal dynamic
imports in community.ts, mirroring the existing opencode-sdk pattern.

The Claude Code provider now resolves the claude executable explicitly:
bundled platform package when present, otherwise a user-installed
claude from PATH, passed via defaultSettings.pathToClaudeCodeExecutable.
The agent SDK's own resolution cannot be used from Bun-compiled
binaries because it anchors on the virtual bunfs where node_modules
lookups never see packages on disk. Codex already degrades gracefully
(npx -y @openai/codex, then codex on PATH).
2026-07-18 20:30:24 -07:00
Saoud Rizwan 359445ae0c fix(sdk): stop exposing the team spawn tool to teammates (#12371)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* fix(sdk): report errored teammate runs as failed instead of completed

Model-stream failures return results with finishReason 'error' rather
than throwing, so executeQueuedRun marked such runs 'completed' with
the error buried in resultSummary. Throw into the existing failure
path so the run reports status 'failed' (with run.error set and a
RunFailed event) and the retry machinery engages when maxRetries
allows.

* fix(sdk): stop exposing the team spawn tool to teammates

Spawning is lead-only, enforced at execution time, so teammates that
saw team_spawn_teammate in their toolset burned turns on 'Only the
lead agent can manage teammates.' rejections before falling back to
doing the work themselves.
2026-07-18 20:21:35 -07:00
Saoud Rizwan d9e2e9c76b fix(sdk): report errored teammate runs as failed instead of completed (#12370)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* fix(sdk): report errored teammate runs as failed instead of completed

Model-stream failures return results with finishReason 'error' rather
than throwing, so executeQueuedRun marked such runs 'completed' with
the error buried in resultSummary. Throw into the existing failure
path so the run reports status 'failed' (with run.error set and a
RunFailed event) and the retry machinery engages when maxRetries
allows.
2026-07-18 20:19:17 -07:00
Saoud Rizwan d859a86a6f fix(sdk): retry runs once after refreshing expired OAuth credentials (#12369)
* fix(sdk): retry runs once after refreshing expired OAuth credentials

Teammate and subagent sessions inherit the OAuth access token as a
snapshot at spawn time and had no refresh path: when the token expired
while the lead was blocked (e.g. in team_await_runs), their next model
call died with the provider's raw 401 body. Only the lead's turn-start
sync and runWithAuthRetry could refresh, and neither runs mid-turn.

Add an onAuthError hook to AgentConfig, wired once per session by
LocalRuntimeHost: it refreshes credentials through the shared
single-flight RuntimeOAuthTokenManager and propagates the new key to
the lead, delegated defaults, and all teammates via the existing
updateConnection channels. SessionRuntime retries a run once when it
failed with an auth-like error and the refresh succeeded, continuing
from the persisted trail so completed iterations aren't replayed.

Also fix isLikelyAuthError to lowercase string inputs; the server's
'Unauthorized: ...' message only matched when wrapped in an Error.

* feat(telemetry): emit user.auth_run_retry when a run is retried after credential refresh

Addresses Greptile review on the auth-retry PR: the refresh itself was
already instrumented (auth_refresh_soft_failure / auth_logged_out fire
inside getValidClineCredentials), but the new retry transition was not.
The recovered flag counts runs that would previously have died with the
raw provider 401 — the direct production measure of this fix working.
2026-07-18 20:08:18 -07:00
Saoud Rizwan 0b7b9c1b3d fix(llms): add Kimi K3 to bundled ClinePass model fallback (#12392)
* fix(llms): add cline-pass/kimi-k3 to bundled model catalog fallback

* fix(llms): derive cline-pass default model from catalog authored order

Adding kimi-k3 (newest releaseDate) to the bundled cline-pass catalog
would have flipped firstGeneratedModelId — which sorts by release date —
to cline-pass/kimi-k3, silently changing the default model for new
ClinePass setups. Use the catalog's authored order instead, which mirrors
the recommended-models endpoint's curated order (intended default first,
subscription models before free ones).
2026-07-18 20:03:19 -07:00
Dominic Cooney 557d725690 fix(vscode): shell mismatch between prompt, execution, and user configuration on Windows (#12331)
* Rationalize shell identification and prompting, especially on Windows.

* Probe all pwsh install locations for the Windows default shell.

The default-shell fallback only checked the Program Files pwsh path,
so Microsoft Store installs of PowerShell 7 fell back to Windows
PowerShell while VS Code's own terminal launched pwsh. Share one
candidate list between the sync default-shell check and the async
PowerShell prober. Also drop an 'as string' cast that hid the
setting's type from the checker.

* Address shell resolution review feedback

* Resolve array-valued terminal profile paths on macOS and Linux too

VS Code permits terminal profile 'path' to be string | string[] on every
platform, not just Windows. The resolver (env expansion, first-existing
selection, PATH lookup) is now platform-generic: it uses the host path
module's separators and delimiter, probes PATHEXT only on Windows, and
treats env var names case-insensitively only on Windows. The macOS and
Linux getters route through it instead of returning the raw config value,
which crashed getShellKind() for array values.

* Apply terminal profile changes at the model-request boundary

A terminal profile change previously triggered a deferred session rebuild
to refresh the run_commands tool description. While a task was running the
rebuild waited, so the description could name one shell while commands
executed in another for the rest of the turn.

Instead of rebuilding, createShellTool now accepts a shell provider
function and re-derives the description each time the runtime reads it,
which happens exactly when a model request is built. The VS Code tool
snapshots {profileId, shell} in that provider; both execution paths (the
background spawn and the foreground terminal, via a new profile parameter
on getOrCreateTerminal) consume the snapshot. Commands produced by an
in-flight inference therefore run with the shell the model was told about,
and a mid-turn profile change takes effect when the tool results are sent
back: the next request names and uses the new shell.

The profile-change session rebuild path (handleTerminalProfileChanged) is
removed along with its deferred-rebuild window.

* Use the real createShellTool in the vitest @cline/core stub

The stub's hand-rolled createShellTool duplicated the 'shell must be a
string' invariant instead of exercising the code that enforces it
(getShellKind via description building), so the array-valued-profile
regression test proved only that the stub threw, not that the real tool
survives. Re-export the real implementation from SDK source — the same
pattern the stub already uses for the apply-patch and editor executors —
and assert on the actual generated descriptions, including that a profile
change is reflected at the next description read.

* Harden shell profile path resolution edge cases

- Warn and skip profile paths containing variable references beyond
  \ (e.g. \) instead of silently probing a
  literal path that can never exist; later candidates and the platform
  default still apply.
- Document that an overriding bash executor in createBuiltinTools bypasses
  the resolved canonical shell and must honor it to keep the run_commands
  description truthful.
2026-07-18 04:23:00 +02:00
Saoud Rizwan 7274d8badc feat(ui): add agent chat components, Storybook, and npm releases (#12374)
* feat(ui): add shared agent chat components and Storybook

* ci(ui): add standalone npm publishing

* docs(ui): keep release commands environment-neutral

* refactor(ui): simplify package validation

* refactor(ui): tighten package and release contracts

* docs(ui): remove duplicate install guidance

* ci(ui): make publishing workflow manual-only
2026-07-17 18:22:57 -07:00
Bee d1837366c0 chore(llms): update model catalog (#12366)
Update model catalog with bun run build:models
Version updated to 1784318695007
2026-07-17 13:28:59 -07:00
Saoud Rizwan c380daf4a3 docs(ui): add adoption primer (#12367) 2026-07-17 13:21:15 -07:00
Bee c564045d81 chore(cli): includes version numbers in hub status output (#12358)
Includes version numbers in hub status output and doctor command to make debugging with user easier.
2026-07-17 05:37:54 +02:00
Saoud Rizwan 9a5e1751b2 chore(cli): release v3.0.44 2026-07-16 18:38:54 -07:00
590 changed files with 141481 additions and 34595 deletions
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-desktop
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-desktop
+127
View File
@@ -0,0 +1,127 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release. Guides changelog drafting, version bumps in package.json + tauri.conf.json, desktop-vX.Y.Z tags, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish Cline Code, bump the desktop version, create a `desktop-vX.Y.Z` tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
Desktop releases are macOS-only today (signed + notarized DMG for 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.
## Release contract
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Release tag: `desktop-vX.Y.Z`, where `X.Y.Z` matches both version files.
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from `origin/main`).
- The workflow creates the `desktop-vX.Y.Z` GitHub release (DMGs + updater artifacts + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
## Workflow
1. Gather context.
```sh
git status --short --branch
git fetch origin --tags
git tag --list 'desktop-v*' --sort=-v:refname | head -10
node -p "require('./apps/examples/desktop-app/package.json').version"
node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version"
```
If there is no `desktop-v*` tag yet, this is the first release; use the desktop app's first commit as the baseline and say the baseline is inferred.
2. Collect release commits.
```sh
git log <last-desktop-tag>..HEAD --oneline --no-merges -- apps/examples/desktop-app sdk/packages .github/workflows/desktop-publish.yml
```
The sidecar bundles `@cline/core` and friends from the monorepo, so SDK changes ship inside the desktop app too. Fold user-visible SDK changes (providers, models, behavior fixes) into the notes; skip purely internal ones.
3. Draft user-facing release notes.
Flat bullet list, user-facing language. Present the draft and wait for approval before editing files.
4. Decide the version bump.
Ask whether this is patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
5. Update release files.
- `apps/examples/desktop-app/package.json` → new version
- `apps/examples/desktop-app/src-tauri/tauri.conf.json` → same version
- Prepend `## X.Y.Z` (no date) to `apps/examples/desktop-app/CHANGELOG.md` with the approved notes.
6. Verify before committing.
```sh
bun -F @cline/code typecheck
bun test apps/examples/desktop-app/scripts/generate-update-manifest.test.ts
```
The full desktop bundle can only be built on macOS; the workflow's build job is the real verification. For extra local confidence on a Mac checkout, `bun run package:desktop:mac --allow-unsigned-mac` from the app directory.
7. Commit release changes.
```sh
git add apps/examples/desktop-app/package.json apps/examples/desktop-app/src-tauri/tauri.conf.json apps/examples/desktop-app/CHANGELOG.md
git commit -m "chore(desktop): release vX.Y.Z"
```
Ask before pushing the release commit, then before creating and pushing the tag:
```sh
git push origin HEAD
git tag -a desktop-vX.Y.Z -m "Desktop vX.Y.Z"
git push origin refs/tags/desktop-vX.Y.Z
```
8. Publish.
The release commit must be on `main` and the tag pushed first.
```sh
gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=desktop-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
The workflow builds both architectures in parallel (aarch64 native, x86_64 cross-compiled), signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs updater artifacts with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
```sh
curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.json | head -30
```
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` URLs must point at the new `desktop-vX.Y.Z` assets. Installed apps pick the update up on next launch or within 2 hours.
10. Final response.
Report: version, tag, changelog updated, commit hash, what was pushed, workflow URL, and the feed verification result.
## Repo secrets (one-time setup)
The workflow needs these repository secrets. The Apple ones come from the same
Apple Developer account used for manual signing (see the app README's "macOS
signing & notarization" section for how to obtain them):
| Secret | Value |
| --- | --- |
| `APPLE_CERTIFICATE` | Base64 of the **Developer ID Application** identity exported from Keychain Access as `.p12` (must include the private key): `base64 -i certificate.p12 \| pbcopy` |
| `APPLE_CERTIFICATE_PASSWORD` | The password chosen when exporting the `.p12` |
| `APPLE_SIGNING_IDENTITY` | `Developer ID Application: <Team Name> (<TEAMID>)` — from `security find-identity -v -p codesigning` |
| `APPLE_API_KEY` | App Store Connect API **Key ID** (notarization) |
| `APPLE_API_KEY_CONTENT` | Contents of the `AuthKey_<KEYID>.p8` file |
| `APPLE_API_ISSUER` | App Store Connect **Issuer ID** (UUID from Users and Access → Integrations) |
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the Tauri updater private key (`tauri signer generate`). If this key is ever lost, shipped apps can no longer verify updates — guard it. |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for that key |
The Slack + telemetry secrets (`SLACK_RELEASE_BOT_TOKEN`, `TELEMETRY_SERVICE_API_KEY`,
OTEL settings) are shared with the CLI publish workflow and already configured.
+158
View File
@@ -0,0 +1,158 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -0,0 +1,4 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+310
View File
@@ -0,0 +1,310 @@
name: desktop-publish
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
required: true
type: string
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
validate:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like desktop-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#desktop-v}"
PACKAGE_VERSION=$(node -p "require('./apps/examples/desktop-app/package.json').version")
TAURI_VERSION=$(node -p "require('./apps/examples/desktop-app/src-tauri/tauri.conf.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if [ "$TAURI_VERSION" != "$VERSION" ]; then
echo "apps/examples/desktop-app/src-tauri/tauri.conf.json version ${TAURI_VERSION} does not match ${TAG}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
needs: validate
runs-on: macos-latest
timeout-minutes: 90
strategy:
fail-fast: true
matrix:
include:
- target: aarch64-apple-darwin
arch: aarch64
- target: x86_64-apple-darwin
arch: x86_64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
- 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: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
run: |
if [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "APPLE_API_KEY_CONTENT secret is not configured"
exit 1
fi
printf "%s" "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/AuthKey.p8"
- name: Build, sign, and notarize desktop bundle
working-directory: apps/examples/desktop-app
run: bunx tauri build --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json
env:
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# Notarization via App Store Connect API key. Tauri reads the Key ID
# from APPLE_API_KEY; APPLE_API_KEY_ID alone silently skips notarization.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
# Updater artifact signing (minisign keypair, independent of Apple)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Collect artifacts
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
ARCH: ${{ matrix.arch }}
TARGET: ${{ matrix.target }}
run: |
BUNDLE_DIR="src-tauri/target/${TARGET}/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
if [ -z "$DMG" ]; then
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist/desktop
merge-multiple: true
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/examples/desktop-app/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
bun apps/examples/desktop-app/scripts/generate-update-manifest.ts \
--version "$VERSION" \
--tag "$TAG" \
--dir dist/desktop \
--out dist/desktop/latest.json \
--repo "$GITHUB_REPOSITORY" \
--notes-file "$RUNNER_TEMP/release-notes.md"
cat dist/desktop/latest.json
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
make_latest: "false"
files: dist/desktop/*
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update auto-update feed (desktop-latest)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if ! gh release view desktop-latest >/dev/null 2>&1; then
gh release create desktop-latest \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
fi
gh release upload desktop-latest dist/desktop/latest.json --clobber
- name: Summary
env:
VERSION: ${{ needs.validate.outputs.version }}
TAG: ${{ needs.validate.outputs.tag }}
run: |
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/desktop-latest/latest.json"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://github.com/${{ github.repository }}/releases/tag/${{ needs.validate.outputs.tag }}|Download DMG> — installed apps auto-update on next launch${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, needs.validate.outputs.tag) || '' }}"
@@ -0,0 +1,60 @@
# Some coding-agent GitHub Apps advertise themselves by auto-commenting on every
# new PR ("<Tool> Agent can help with this pull request. Just @<tool> ..."). The
# app needs pull_requests:write for its real job (pushing branches, opening PRs),
# and GitHub offers no per-behavior control over an installed App, so the ad
# cannot be disabled at the source. This deletes those promo comments as they
# appear. Genuine agent output comments (work results, reviews) don't match the
# promo pattern and are left alone.
#
# No checkout, API-calls-only — comment text is only ever handled as data inside
# the script, never interpolated into the workflow definition.
name: repo-delete-agent-promo-comments
on:
issue_comment:
types: [created]
jobs:
delete:
runs-on: ubuntu-latest
timeout-minutes: 2
# Prefilter so a runner only spins up for bot comments that look like the
# ad; the script re-verifies before deleting.
if: >-
github.event.issue.pull_request &&
endsWith(github.event.comment.user.login, '[bot]') &&
contains(github.event.comment.body, 'can help with this pull request')
# Comment deletion goes through the issues API, but GitHub gates the
# endpoint by where the comment lives: issue comments need `issues`,
# PR-conversation comments need `pull-requests`. The prefilter restricts
# this job to PR comments, so pull-requests is the one that matters;
# issues is kept in case the prefilter is ever widened.
permissions:
issues: write
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions and fires on attacker-postable events.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const comment = context.payload.comment
// Belt and suspenders on top of the job-level prefilter: only
// delete when the author is a real GitHub App bot AND the body
// matches the self-promotion shape ("... can help with this
// pull request. Just @<handle> ..."). A human quoting the ad
// text is not a Bot; a bot posting real work output doesn't
// match the promo shape.
const isBot = comment.user.type === "Bot"
const isPromo = /\bcan help with this pull request\b[\s\S]*@\w/i.test(comment.body || "")
if (!isBot || !isPromo) {
core.info("not an agent promo comment, leaving it alone")
return
}
await github.rest.issues.deleteComment({
...context.repo,
comment_id: comment.id,
})
core.info(`deleted promo comment ${comment.id} by ${comment.user.login} on #${context.payload.issue.number}`)
@@ -0,0 +1,65 @@
# Cloud coding agents append promotional badge blocks to PR bodies after the
# agent's final turn, wrapped around <!-- <VENDOR>_AGENT_PR_BODY_BEGIN/END -->
# marker comments. The agent itself never sees that content, so no repo rule or
# agent instruction can prevent it. This strips it from the PR description on
# open/edit, keeping only the agent-authored content between the markers.
#
# Uses pull_request_target so the token has write access on PRs from forks. That
# trigger is only unsafe when a job checks out and executes PR code — this one
# never checks out the repository, it only calls the REST API.
name: repo-strip-agent-badges
on:
pull_request_target:
types: [opened, edited]
concurrency:
group: strip-agent-badges-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
strip:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.body, '_AGENT_PR_BODY')
permissions:
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions under pull_request_target.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
// Re-fetch instead of trusting the event payload: the body may have
// been edited again between the event firing and this run (agent
// harnesses edit PR bodies post-open), and updating from the stale
// snapshot would clobber the newer content.
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.pull_request.number,
})
const body = pr.body || ""
// The BEGIN/END comments wrap the agent-authored content; everything
// outside them (vendor promo badges, "open in <tool>" links) is
// appended by the harness. Keep only what's between the markers.
// The backreference requires BEGIN and END to name the same vendor.
// No markers -> no match -> body passes through unchanged.
const cleaned = body
.replace(
/^[\s\S]*?<!--\s*([A-Z][A-Z0-9_]*?)_AGENT_PR_BODY_BEGIN\s*-->\r?\n?([\s\S]*?)<!--\s*\1_AGENT_PR_BODY_END\s*-->[\s\S]*$/,
"$2",
)
.trimEnd()
// No change means a previous run already cleaned this body. Returning
// without an update is what stops `edited` from retriggering forever.
if (cleaned === body) {
core.info("nothing to strip")
return
}
await github.rest.pulls.update({
...context.repo,
pull_number: pr.number,
body: cleaned,
})
core.info(`stripped ${body.length - cleaned.length} chars from PR #${pr.number}`)
+145
View File
@@ -0,0 +1,145 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
+4 -2
View File
@@ -51,7 +51,8 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "staging"
"CLINE_ENVIRONMENT": "staging",
"CLINE_DIR": "${userHome}/.cline_staging"
}
},
{
@@ -75,7 +76,8 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "local"
"CLINE_ENVIRONMENT": "local",
"CLINE_DIR": "${userHome}/.cline_local"
}
},
{
+32
View File
@@ -0,0 +1,32 @@
This is the **Cline** monorepo. Toolchain is **Bun 1.3.13** (package manager + task runner) with **Node >=22** as the runtime. Do not use npm/yarn/pnpm.
## Cloud Agent Instructions
### Cline CLI
- Run from source: `bun run cli` (interactive: `bun run cli -i`; one-shot: append a prompt). This resolves to `apps/cli` and **auto-spawns the `@cline/cline-hub` daemon** — you do not start the hub separately.
- Inspect local health with `bun run cli doctor`; `bun run cli version` prints the version.
- An actual agent turn requires an **LLM provider credential**. With no credentials the default `cline` provider fails fast with an `Unauthorized` error and the interactive TUI shows a provider sign-in screen. Configure via `cline auth` or provider env vars (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`, `OPENROUTER_API_KEY`); see `apps/cli/README.md`.
### Build / Lint / test
- SDK packages (`@cline/shared|llms|agents|core|sdk`) resolve each other through compiled `dist/` (their `exports` point only at `dist/`, with no `development` source condition). You **must** run `bun run build:sdk` after changing SDK dependencies/source before running the CLI or SDK tests, otherwise imports fail with missing `@cline/*` / missing `dist/` errors. Running processes do **not** hot-reload SDK source changes — rebuild and restart.\
- Known cloud-env test artifact: `@cline/core` test `src/services/workspace/workspace-manifest.test.ts > readGitWorkspaceState > prefers origin and returns the current branch` fails because cloud VMs configure git `insteadOf` rules that rewrite GitHub remotes to `https://x-access-token:...@github.com/...`. This is an environment artifact, not a code bug.
- Some `@cline/cli` e2e assertions (`bun -F @cline/cli test:e2e`) may fail on exact tool-listing string formats; treat as pre-existing test drift, not an environment problem.
### GUI display
- A virtual X display is live at **`DISPLAY=:1`** (the same desktop used for screenshots). GUI apps (VS Code, the Tauri desktop window) launched with `DISPLAY=:1` render there and can be screenshotted — no need to start your own `xvfb`. Prefer starting long-running GUI/dev processes in a `tmux` session (see the tmux guidance) so they survive.
### VS Code extension (`apps/vscode`, package `claude-dev`)
Toolchain is pre-installed and persisted in the VM: generated gRPC/proto code, the bundled `ripgrep` binaries (`apps/vscode/bin/`), the built webview (`webview-ui/build`), the esbuild bundle (`dist/extension.js`), VS Code itself (`/usr/bin/code`), and the GUI system libraries its tests need.
- **Codegen prerequisite:** `bun run protos` (from `apps/vscode`) regenerates `src/generated/*` and the webview grpc client. The `dev`, `build:webview`, and `check-types` scripts already run it, so proto changes are picked up by those commands; run it manually only if you edit `.proto` files without a full build.
- **Build:** `bun run build:webview` (webview UI, ~15s) then `bun esbuild.mjs` (extension bundle). `bun run package` does the full production build.
- **Run it (dev host):** `DISPLAY=:1 code --no-sandbox --user-data-dir=/tmp/vscode-userdata --extensionDevelopmentPath=/workspace/apps/vscode <some-folder>`, then click the Cline icon in the Activity Bar to open the webview. (`--no-sandbox` is required in this container.)
- **Test:** `bun run test:unit` (bun-based, ~984 tests, no VS Code host needed). `bun run test:integration` (`@vscode/test-electron`, downloads a VS Code build, runs under the GUI libs) and `bun run test:e2e` (Playwright) exercise a real extension host — heavier, and the GUI libs for them are already installed.
- One-time deps (already installed, listed here in case they must be recreated): ripgrep via `bun run download-ripgrep`; VS Code test GUI libs per `CONTRIBUTING.md` (`libnss3`, `libatk*`, `libgbm1`, `xvfb`, etc.).
### Desktop app (`apps/examples/desktop-app`, package `@cline/code`)
A Tauri v2 (Rust) shell + Next.js webview + a Bun "sidecar" backend. Rust and the Tauri Linux system libs are pre-installed and persisted.
- **Headless (no Rust/window):** run the backend and UI separately — `bun run dev:sidecar` (Bun backend on `127.0.0.1:3126`, serves `ws://.../transport`) and `bun run dev:web` (Next.js UI on `http://localhost:3125`).
- **Native window:** `bun run dev` (`tauri dev`) — its `beforeDevCommand` builds the sidecar binary and starts `dev:web` (`:3125`), then Rust `main.rs` spawns the sidecar; so free ports `3125`/`3126` first. Launch with `DISPLAY=:1` to see the window. A `libEGL: DRI3 error` warning is benign (software rendering) — the WebKitGTK window still renders.
- **Rust version caveat:** the crate graph needs Cargo's `edition2024` feature, so **Rust ≥1.85** is required (the VM's base 1.83 fails with "feature `edition2024` is required"). The toolchain here was updated via `rustup default stable` (currently 1.97). First `cargo` build downloads/compiles the full Tauri crate graph (a few minutes); subsequent builds are cached.
- **System libs (already installed):** `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `build-essential`.
- **Test/typecheck:** `bun run typecheck`, `bun run test:chat-ui` (Vitest). Both trigger `build:ui` first.
+1 -1
View File
@@ -226,7 +226,7 @@ Run Cline with zero interaction for scripting and automation. Pipe input, get JS
```bash
cline "Run tests and fix any failures"
git diff origin/main | cline "Review these changes for issues"
git diff origin/main | cline "Review these changes for issues"
cline --json "List all TODO comments" | jq -r 'select(.type == "agent_event" and .event.text) | .event.text'
```
+32
View File
@@ -1,5 +1,37 @@
# Cline CLI Changelog
## 3.0.47
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
- Aborting a task no longer risks killing the shared hub daemon
- Connector status delivery failures are no longer fatal to the turn
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
- Updated the bundled model catalog (from SDK v0.0.66)
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
+2 -2
View File
@@ -257,10 +257,10 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
| `--kanban` | Run the external `kanban` app |
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.43",
"version": "3.0.47",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -78,19 +78,19 @@
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"@opentui/core": "0.4.3",
"@opentui/react": "0.4.3",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"opentui-spinner": "^0.0.7",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
"react-reconciler": "0.33.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
+494
View File
@@ -0,0 +1,494 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
import {
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
stopAllConnectors,
} from "./connect";
const mocks = vi.hoisted(() => ({
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getConnector: vi.fn(),
listActiveConnectors: vi.fn(),
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
persistConnectorConnection: vi.fn(),
removePersistedConnectorConnection: vi.fn(),
run: vi.fn(),
validate: vi.fn(),
}));
vi.mock("@cline/core", () => ({
disableConnectorAutostart: mocks.disableConnectorAutostart,
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
listActiveConnectors: mocks.listActiveConnectors,
persistConnectorConnection: mocks.persistConnectorConnection,
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
}));
vi.mock("../connectors/registry", () => ({
getConnector: mocks.getConnector,
listConnectors: mocks.listConnectors,
}));
describe("runConnectAdapter", () => {
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.listConnectors.mockReturnValue([]);
mocks.listActiveConnectors.mockReturnValue([]);
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.validate.mockResolvedValue(0);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
});
});
afterEach(() => {
if (previousDetachedChild === undefined) {
delete process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
} else {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = previousDetachedChild;
}
});
it("persists a successful detached connector start", async () => {
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "token"],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists a successful env-only connector start", async () => {
await expect(runConnectAdapter("telegram", [], io)).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
[],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists connector-resolved launch arguments", async () => {
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("resolved_bot");
context.setPersistenceArgs([
"--bot-token",
"token",
"--bot-username",
"resolved_bot",
]);
return 0;
},
);
await expect(
runConnectAdapter("telegram", ["--bot-token", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"resolved_bot",
["--bot-token", "token", "--bot-username", "resolved_bot"],
);
});
it("does not rewrite persistence when a connector is already running", async () => {
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it.each([
"-i",
"--interactive",
])("disables autostart after a successful %s foreground run exits", async (interactiveFlag) => {
await expect(
runConnectAdapter("telegram", ["-k", "token", interactiveFlag], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith(
"telegram",
"cline_bot",
);
});
it("does not change persistence after a failed foreground run", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist a failed detached launch", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves persistence unchanged when an internal detached child exits", async () => {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = "1";
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist help invocations", async () => {
await expect(runConnectAdapter("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves autostart unchanged during shared process cleanup", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(stopAllConnectors(io)).resolves.toEqual({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
executed: 1,
});
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("disables autostart for an explicit stop-all command", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(runStopAllConnectors(io)).resolves.toBe(0);
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith();
});
it("validates a replacement before stopping the active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.validate.mockResolvedValue(1);
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "bad-token"], io),
).resolves.toBe(1);
expect(mocks.validate).toHaveBeenCalledWith(["-k", "bad-token"], io);
expect(stopInstance).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
});
it("shows restart help without stopping an active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(runRestartConnector("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.run).toHaveBeenCalledWith(["--help"], io, expect.any(Object));
expect(mocks.validate).not.toHaveBeenCalled();
expect(stopInstance).not.toHaveBeenCalled();
});
it("restores the last successful launch when a replacement fails", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run
.mockResolvedValueOnce(1)
.mockImplementationOnce(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenNthCalledWith(
1,
["-k", "new-token"],
io,
expect.any(Object),
);
expect(mocks.run).toHaveBeenNthCalledWith(
2,
["-k", "old-token"],
io,
expect.any(Object),
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "old-token"],
);
});
it("restarts an active instance without persisted rollback arguments", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue(undefined);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(0);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenCalledWith(
["-k", "new-token"],
io,
expect.any(Object),
);
});
it("does not start a replacement when the active process cannot be stopped", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).not.toHaveBeenCalled();
});
it("does not count an already-running instance as a successful replacement", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(mocks.run).toHaveBeenCalledTimes(1);
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] replacement was not started because telegram instance cline_bot is still running",
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
});
});
+203 -15
View File
@@ -1,10 +1,29 @@
import {
disableConnectorAutostart,
getPersistedConnectorConnection,
listActiveConnectors,
persistConnectorConnection,
removePersistedConnectorConnection,
} from "@cline/core";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import { getConnector, listConnectors } from "../connectors/registry";
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
import type {
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../connectors/types";
const HELP_FLAGS = new Set(["-h", "--help"]);
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
export async function stopAllConnectors(
io: ConnectIo,
): Promise<ConnectStopResult & { executed: number }> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
let executed = 0;
for (const entry of listConnectors()) {
@@ -18,42 +37,209 @@ export async function stopAllConnectors(
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, stoppedSessions, executed };
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, stoppedSessions, executed } =
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
await stopAllConnectors(io);
if (executed === 0) {
io.writeln("[connect] no adapters support stop yet");
return 0;
}
disableConnectorAutostart();
io.writeln(
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
`[connect] stopped processes=${stoppedProcesses} failed=${failedProcesses} sessions=${stoppedSessions}`,
);
return 0;
return failedProcesses === 0 ? 0 : 1;
}
export async function runStopConnector(
adapterName: string,
io: ConnectIo,
options: {
autostart: "disable" | "preserve";
instanceId?: string;
} = {
autostart: "disable",
},
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
if (!connector.stopAll) {
const stop = options.instanceId
? connector.stopInstance
? () => connector.stopInstance?.(options.instanceId ?? "", io)
: undefined
: connector.stopAll
? () => connector.stopAll?.(io)
: undefined;
if (!stop) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
const result: ConnectStopResult = await connector.stopAll(io);
const result = await stop();
if (!result) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
if (options.autostart === "disable") {
disableConnectorAutostart(connector.name, options.instanceId);
}
io.writeln(
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
);
return 0;
return result.failedProcesses === 0 ? 0 : 1;
}
export async function runRestartConnector(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
requestedInstanceId?: string,
): Promise<number> {
if (passthroughArgs.some((arg) => HELP_FLAGS.has(arg))) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const activeInstances = listActiveConnectors().filter(
(record) => record.type === adapterName,
);
if (!requestedInstanceId && activeInstances.length > 1) {
io.writeErr(
`cannot safely restart ${adapterName}: ${activeInstances.length} instances are active; specify an instance`,
);
return 1;
}
const instanceId = requestedInstanceId ?? activeInstances[0]?.instanceId;
const targetIsActive =
instanceId !== undefined &&
activeInstances.some((record) => record.instanceId === instanceId);
if (!targetIsActive || !instanceId) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const validationExitCode = await connector.validate(passthroughArgs, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
const previousConnection = getPersistedConnectorConnection(
adapterName,
instanceId,
);
const stopExitCode = await runStopConnector(adapterName, io, {
autostart: "preserve",
instanceId,
});
if (stopExitCode !== 0) {
return stopExitCode;
}
const replacement = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
if (replacement.exitCode === 0) {
if (replacement.instanceId && replacement.instanceId !== instanceId) {
removePersistedConnectorConnection(adapterName, instanceId);
}
return 0;
}
if (replacement.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
io.writeErr(
`[connect] replacement was not started because ${adapterName} instance ${instanceId} is still running`,
);
return 1;
}
if (!previousConnection) {
io.writeErr(
`[connect] replacement failed and ${adapterName} instance ${instanceId} has no successful launch arguments for rollback`,
);
return replacement.exitCode;
}
io.writeErr(
`[connect] replacement failed; restoring ${adapterName} instance ${instanceId}`,
);
const rollback = await runConnectAdapterWithResult(
adapterName,
previousConnection.lastSuccessfulArgs,
io,
);
if (rollback.exitCode === 0) {
io.writeln(`[connect] restored ${adapterName} instance ${instanceId}`);
} else {
io.writeErr(
`[connect] failed to restore ${adapterName} instance ${instanceId}`,
);
}
return replacement.exitCode;
}
interface ConnectAdapterResult {
exitCode: number;
instanceId?: string;
}
async function runConnectAdapterWithResult(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<ConnectAdapterResult> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return { exitCode: 1 };
}
let persistenceArgs = passthroughArgs;
let persistenceInstanceId: string | undefined;
const context: ConnectRunContext = {
setPersistenceArgs: (args) => {
persistenceArgs = [...args];
},
setPersistenceInstanceId: (instanceId) => {
persistenceInstanceId = instanceId;
},
};
const exitCode = await connector.run(passthroughArgs, io, context);
if (exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
return { exitCode, instanceId: persistenceInstanceId };
}
const isHelpInvocation = passthroughArgs.some((arg) => HELP_FLAGS.has(arg));
const isInteractiveInvocation = passthroughArgs.some((arg) =>
INTERACTIVE_FLAGS.has(arg),
);
const isDetachedChild =
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
isInteractiveInvocation
) {
disableConnectorAutostart(connector.name, persistenceInstanceId);
} else if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
persistenceInstanceId
) {
persistConnectorConnection(
connector.name,
persistenceInstanceId,
persistenceArgs,
);
}
return { exitCode, instanceId: persistenceInstanceId };
}
export async function runConnectAdapter(
@@ -61,12 +247,14 @@ export async function runConnectAdapter(
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
return connector.run(passthroughArgs, io);
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
? 0
: result.exitCode;
}
export function formatAdapterList(): string {
+41
View File
@@ -9,6 +9,7 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -21,6 +22,7 @@ const {
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
@@ -49,8 +51,10 @@ const {
mockClearHubDiscovery: vi.fn(),
mockStopLocalHubServerGracefully: vi.fn(async () => false),
mockEnsureFileExists: vi.fn(),
mockListActiveConnectors: vi.fn(() => []),
mockStopAllConnectors: vi.fn(async () => ({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
})),
@@ -69,6 +73,7 @@ vi.mock("@cline/core", () => ({
readHubDiscovery: mockReadHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
}));
vi.mock("../connectors/common", () => ({
@@ -99,6 +104,7 @@ describe("runDoctorCommand", () => {
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
@@ -174,6 +180,40 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
@@ -248,6 +288,7 @@ describe("runDoctorCommand", () => {
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
failedProcesses: 0,
stoppedSessions: 5,
executed: 3,
});
+14 -6
View File
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import {
clearHubDiscovery,
ensureFileExists,
listActiveConnectors,
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
@@ -11,14 +12,15 @@ import {
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
listActiveConnectors,
} from "../connectors/status";
formatUptime,
resolveClineBuildEnv,
} from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import { getCliBuildInfo } from "../utils/common";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
@@ -49,6 +51,8 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -337,6 +341,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -419,6 +425,8 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
+4
View File
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
+3
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -134,6 +135,8 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
+61
View File
@@ -0,0 +1,61 @@
import { relative, sep } from "node:path";
import {
resolveClineDataDir,
resolveClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createProgram } from "./program";
/** Render an absolute path under `home` the way help text does: `~/...`. */
function tildePath(absolutePath: string, home: string): string {
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
}
describe("root option help text", () => {
const FAKE_HOME = "/home/cline-help-test";
const savedEnv: Record<string, string | undefined> = {};
beforeAll(() => {
// Pin the resolver inputs so the defaults below are the true defaults
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
setHomeDir(FAKE_HOME);
});
afterAll(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("reports the actual resolver defaults for --config and --data-dir", () => {
// A wide help width keeps each option description on one line so the
// full default text can be matched.
const help = createProgram()
.configureHelp({ helpWidth: 500 })
.helpInformation();
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
// Sanity-check the resolvers themselves so the assertions below can't
// silently drift along with a resolver regression.
expect(configDefault).toBe("~/.cline");
expect(dataDirDefault).toBe("~/.cline/data");
expect(help).toContain(
`Configuration directory (default: ${configDefault})`,
);
expect(help).toContain(
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
);
});
});
+3 -5
View File
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
"--acp",
"Run in Agent Client Protocol (ACP) mode for editor integration",
)
.option(
"--config <path>",
"Configuration directory (default: ~/.cline/data/settings)",
)
.option("--config <path>", "Configuration directory (default: ~/.cline)")
.option(
"--data-dir <path>",
"Use isolated local state at this directory path (default: ~/.cline)",
"Use isolated local state at this directory path (default: ~/.cline/data)",
)
.option(
"--hooks-dir <path>",
@@ -136,6 +133,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
interactive: !!opts.tui,
outputMode: opts.json ? "json" : "text",
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen),
sandbox: !!opts.dataDir,
acpMode: !!opts.acp,
thinking: false,
+4 -2
View File
@@ -148,8 +148,10 @@ export function isJsonPath(path: string): boolean {
return path.toLowerCase().endsWith(".json");
}
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
if (raw === "act" || raw === "plan") {
export function parseMode(
raw: string | undefined,
): "act" | "plan" | "yolo" | undefined {
if (raw === "act" || raw === "plan" || raw === "yolo") {
return raw;
}
return undefined;
+5 -3
View File
@@ -1,3 +1,4 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -9,6 +10,7 @@ import {
mergeScheduleMetadata,
parseJsonObjectFlag,
parseList,
parseMode,
resolveAddress,
toPositiveInt,
} from "./common";
@@ -63,8 +65,8 @@ export function registerScheduleCommands(
.option("--disabled", "Create in disabled state")
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan>", "Execution mode")
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
@@ -96,7 +98,7 @@ export function registerScheduleCommands(
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
mode: opts.mode === "plan" ? "plan" : "act",
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
systemPrompt: opts.systemPrompt,
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -39,7 +40,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
"openai/gpt-5.3-codex",
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
}
@@ -165,7 +166,10 @@ export function registerScheduleImportCommand(
prompt: String(parsed.prompt ?? "").trim(),
provider,
model,
mode: parsed.mode === "plan" ? "plan" : "act",
mode:
parseMode(
typeof parsed.mode === "string" ? parsed.mode : undefined,
) ?? "yolo",
workspaceRoot,
cwd: String(parsed.cwd ?? "").trim() || undefined,
systemPrompt:
@@ -229,7 +233,7 @@ export function registerScheduleUpdateCommand(
.option("--enabled", "Enable the schedule")
.option("--max-parallel <n>", "New max parallel executions")
.option("--metadata-json <json>", "New metadata as JSON object")
.option("--mode <act|plan>", "New execution mode")
.option("--mode <act|plan|yolo>", "New execution mode")
.option("--model <model>", "New model")
.option("--name <name>", "New name")
.option("--pause", "Pause the schedule")
+39 -22
View File
@@ -59,6 +59,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -937,9 +938,18 @@ class DiscordConnector extends ConnectorBase<
);
}
protected override async runWithOptions(
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopDiscordConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
): Promise<number> {
if (!options.applicationId) {
@@ -960,7 +970,16 @@ class DiscordConnector extends ConnectorBase<
);
return 1;
}
return 0;
}
protected override async runWithOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.applicationId);
const statePath = this.resolveConnectorStatePath(options.applicationId);
const bindingsPath = this.resolveBindingsPath(options.applicationId);
const staleState = this.removeStaleState(
@@ -971,26 +990,24 @@ class DiscordConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<DiscordThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
foregroundHint:
"[discord] use `cline connect discord -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Discord connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
foregroundHint:
"[discord] use `cline connect discord -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Discord connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+76 -52
View File
@@ -55,6 +55,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -409,11 +410,66 @@ class GoogleChatConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopGoogleChatConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
private parseCredentials(
options: ConnectGoogleChatOptions,
):
| { client_email: string; private_key: string; project_id?: string }
| undefined {
if (!options.credentialsJson) {
return undefined;
}
const parsed = JSON.parse(options.credentialsJson) as Record<
string,
unknown
>;
if (
typeof parsed.client_email !== "string" ||
typeof parsed.private_key !== "string"
) {
throw new Error(
"credentials JSON must include string client_email and private_key fields",
);
}
return {
client_email: parsed.client_email,
private_key: parsed.private_key,
project_id:
typeof parsed.project_id === "string" ? parsed.project_id : undefined,
};
}
protected override async validateOptions(
options: ConnectGoogleChatOptions,
io: ConnectIo,
): Promise<number> {
try {
this.parseCredentials(options);
return 0;
} catch (error) {
io.writeErr(
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
protected override async runWithOptions(
options: ConnectGoogleChatOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const staleState = this.removeStaleState(
@@ -424,26 +480,25 @@ class GoogleChatConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<GoogleChatThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Google Chat connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Google Chat connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
@@ -452,38 +507,7 @@ class GoogleChatConnector extends ConnectorBase<
});
const logger = createChatSdkLogger(loggerAdapter);
const consoleLogger = new ConsoleLogger("info", "gchat-connect");
let parsedCredentials:
| { client_email: string; private_key: string; project_id?: string }
| undefined;
if (options.credentialsJson) {
try {
const parsed = JSON.parse(options.credentialsJson) as Record<
string,
unknown
>;
if (
typeof parsed.client_email !== "string" ||
typeof parsed.private_key !== "string"
) {
throw new Error(
"credentials JSON must include string client_email and private_key fields",
);
}
parsedCredentials = {
client_email: parsed.client_email,
private_key: parsed.private_key,
project_id:
typeof parsed.project_id === "string"
? parsed.project_id
: undefined,
};
} catch (error) {
io.writeErr(
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
const parsedCredentials = this.parseCredentials(options);
const endpointUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/gchat`;
const gchat = createGoogleChatAdapter(
parsedCredentials
+31 -19
View File
@@ -51,6 +51,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import { getConnectorSystemPrompt } from "./prompts";
@@ -477,11 +478,23 @@ class LinearConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopLinearConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectLinearOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const staleState = this.removeStaleState(
@@ -492,25 +505,24 @@ class LinearConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<LinearThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[linear] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[linear] use `cline connect linear -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Linear connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[linear] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[linear] use `cline connect linear -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Linear connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+33 -21
View File
@@ -61,6 +61,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -668,11 +669,23 @@ class SlackConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopSlackConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectSlackOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const stateStorePath = this.resolveStateStorePath(options.userName);
@@ -684,27 +697,26 @@ class SlackConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<SlackThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
state.connectionMode === "socket"
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
"[slack] use `cline connect slack -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Slack connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
state.connectionMode === "socket"
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
"[slack] use `cline connect slack -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Slack connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
@@ -2,9 +2,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectTelegramOptions } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
import { __test__, telegramConnector } from "./telegram";
const mocks = vi.hoisted(() => ({
spawnDetachedConnector: vi.fn(),
}));
vi.mock("../common", async (importOriginal) => ({
...(await importOriginal<typeof import("../common")>()),
spawnDetachedConnector: mocks.spawnDetachedConnector,
}));
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
(
telegramConnector as unknown as {
@@ -15,6 +25,11 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
const originalClineDataDir = process.env.CLINE_DATA_DIR;
const tempDataDirs: string[] = [];
beforeEach(() => {
vi.clearAllMocks();
mocks.spawnDetachedConnector.mockReturnValue(42);
});
function useTempClineDataDir(): string {
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
tempDataDirs.push(dataDir);
@@ -153,7 +168,7 @@ describe("telegramConnector", () => {
expect(options.botUsername).toBe("test_bot");
});
it("does not call getMe when the token-only connector is already running", async () => {
it("validates a token before reporting its connector as already running", async () => {
const dataDir = useTempClineDataDir();
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
@@ -167,26 +182,87 @@ describe("telegramConnector", () => {
startedAt: new Date().toISOString(),
}),
);
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected getMe call");
});
const fetchImpl = vi.fn(async () =>
Response.json({
ok: true,
result: { username: "resolved_bot" },
}),
);
vi.stubGlobal("fetch", fetchImpl);
const output: string[] = [];
const errors: string[] = [];
await expect(
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
}),
).resolves.toBe(0);
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
},
{
setPersistenceArgs: vi.fn(),
setPersistenceInstanceId: vi.fn(),
},
),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(fetchImpl).not.toHaveBeenCalled();
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
expect(output).toEqual([
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
]);
});
it("reports the resolved bot username in persistence args", async () => {
const dataDir = useTempClineDataDir();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
ok: true,
result: { username: "resolved_bot" },
}),
);
}),
);
const setPersistenceArgs = vi.fn();
const setPersistenceInstanceId = vi.fn();
mocks.spawnDetachedConnector.mockImplementation(() => {
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
writeFileSync(
join(connectorDir, "resolved_bot.json"),
JSON.stringify({
botUsername: "resolved_bot",
pid: process.pid,
}),
);
return process.pid;
});
await expect(
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: () => {},
writeErr: () => {},
},
{ setPersistenceArgs, setPersistenceInstanceId },
),
).resolves.toBe(0);
expect(setPersistenceArgs).toHaveBeenCalledWith([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--bot-username",
"resolved_bot",
]);
expect(setPersistenceInstanceId).toHaveBeenCalledWith("resolved_bot");
expect(mocks.spawnDetachedConnector).toHaveBeenCalled();
});
});
describe("telegram bot username resolution", () => {
+50 -22
View File
@@ -20,7 +20,7 @@ import {
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
import { ConnectorBase } from "../base";
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
import { isProcessRunning } from "../common";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
import {
type ActiveConnectorTurn,
handleConnectorUserTurn,
@@ -51,6 +51,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -604,10 +605,37 @@ class TelegramConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopTelegramConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
options: ConnectTelegramOptions,
io: ConnectIo,
): Promise<number> {
try {
await resolveTelegramBotUsername({
...options,
botUsername: undefined,
});
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
protected override async runWithOptions(
inputOptions: ConnectTelegramOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
if (
!inputOptions.botUsername &&
@@ -621,7 +649,7 @@ class TelegramConnector extends ConnectorBase<
io.writeln(
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
);
return 0;
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
}
let resolvedBotUsername: string;
@@ -638,6 +666,8 @@ class TelegramConnector extends ConnectorBase<
const backgroundArgs = inputOptions.botUsername
? rawArgs
: [...rawArgs, "--bot-username", resolvedBotUsername];
context.setPersistenceArgs(backgroundArgs);
context.setPersistenceInstanceId(options.botUsername);
const statePath = this.resolveConnectorStatePath(options.botUsername);
const bindingsPath = this.resolveBindingsPath(options.botUsername);
const staleState = this.removeStaleState(
@@ -648,26 +678,24 @@ class TelegramConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<TelegramThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
formatBackgroundStartMessage: (pid) =>
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
foregroundHint:
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Telegram connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
formatBackgroundStartMessage: (pid) =>
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
foregroundHint:
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Telegram connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+31 -20
View File
@@ -55,6 +55,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -444,15 +445,27 @@ class WhatsAppConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopWhatsAppConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectWhatsAppOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
const instanceKey = resolveInstanceKey({
phoneNumberId: options.phoneNumberId,
userName: options.userName,
});
context.setPersistenceInstanceId(instanceKey);
const statePath = this.resolveConnectorStatePath(instanceKey);
const bindingsPath = this.resolveBindingsPath(instanceKey);
const staleState = this.removeStaleState(
@@ -463,26 +476,24 @@ class WhatsAppConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<WhatsAppThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch WhatsApp connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
launchFailureMessage: "failed to launch WhatsApp connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+208
View File
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectorBase } from "./base";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "./common";
import type { ConnectIo } from "./types";
const mocks = vi.hoisted(() => ({
isProcessRunning: vi.fn(),
spawnDetachedConnector: vi.fn(),
terminateProcess: vi.fn(),
}));
vi.mock("./common", async (importOriginal) => ({
...(await importOriginal<typeof import("./common")>()),
isProcessRunning: mocks.isProcessRunning,
spawnDetachedConnector: mocks.spawnDetachedConnector,
terminateProcess: mocks.terminateProcess,
}));
class TestConnector extends ConnectorBase<
Record<string, never>,
{ pid: number }
> {
constructor() {
super("test", "Test connector");
}
protected readOptions(): Record<string, never> {
return {};
}
protected async runWithOptions(): Promise<number> {
return 0;
}
runBackground(
io: ConnectIo,
options?: {
readState?: () => { pid: number } | undefined;
isRunning?: (state: { pid: number }) => boolean;
startupTimeoutMs?: number;
},
): Promise<number | undefined> {
return this.maybeRunInBackground({
rawArgs: ["--token", "secret"],
io,
interactive: false,
childEnvVar: "CLINE_TEST_CONNECT_CHILD",
statePath: "/tmp/test-connector.json",
readState: options?.readState ?? (() => undefined),
isRunning: options?.isRunning ?? (() => false),
formatAlreadyRunningMessage: () => "already running",
formatBackgroundStartMessage: (pid) => `started ${pid}`,
foregroundHint: "foreground hint",
launchFailureMessage: "launch failed",
startupTimeoutMs: options?.startupTimeoutMs,
});
}
stopProcess(
io: ConnectIo,
options: {
statePath: string;
readState: (path: string) => { pid: number } | undefined;
stopSessions?: (state: { pid: number }) => Promise<number>;
clearBindings?: (state: { pid: number }) => void;
},
) {
return this.stopManagedProcess({
io,
statePath: options.statePath,
readState: options.readState,
describeStoppedProcess: (state) => `stopped pid=${state.pid}`,
getPid: (state) => state.pid,
stopSessions: options.stopSessions ?? (async () => 0),
clearBindings: options.clearBindings,
});
}
}
describe("ConnectorBase background launch", () => {
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.isProcessRunning.mockReturnValue(true);
mocks.terminateProcess.mockResolvedValue(true);
});
it("returns a failure exit code when the detached process is not created", async () => {
mocks.spawnDetachedConnector.mockReturnValue(0);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith("launch failed");
});
it("returns success only after a detached process receives a pid", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
let reads = 0;
await expect(
new TestConnector().runBackground(io, {
readState: () => (++reads > 1 ? { pid: 42 } : undefined),
isRunning: () => true,
}),
).resolves.toBe(0);
expect(io.writeln).toHaveBeenCalledWith("started 42");
});
it("fails when the detached child exits before becoming ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
mocks.isProcessRunning.mockReturnValue(false);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: child exited before becoming ready",
);
});
it("terminates a detached child that never becomes ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
await expect(
new TestConnector().runBackground(io, { startupTimeoutMs: 0 }),
).resolves.toBe(1);
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: timed out after 0ms",
);
});
it("returns a distinct result when a connector is already running", async () => {
await expect(
new TestConnector().runBackground(io, {
readState: () => ({ pid: 99 }),
isRunning: () => true,
}),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(io.writeln).toHaveBeenCalledWith("already running");
expect(mocks.spawnDetachedConnector).not.toHaveBeenCalled();
});
it("keeps state and reports failure when the process survives termination", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(true);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
expect(removeStateFile).not.toHaveBeenCalled();
expect(stopSessions).not.toHaveBeenCalled();
expect(clearBindings).not.toHaveBeenCalled();
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] failed to stop connector process pid=42",
);
});
it("cleans stale state after confirming the process is already gone", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(false);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 1,
});
expect(removeStateFile).toHaveBeenCalledWith("/tmp/test-connector.json");
expect(stopSessions).toHaveBeenCalledWith({ pid: 42 });
expect(clearBindings).toHaveBeenCalledWith({ pid: 42 });
});
});
+86 -11
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
import { Command, CommanderError } from "commander";
import {
CONNECT_ALREADY_RUNNING_EXIT_CODE,
isProcessRunning,
readJsonFile,
removeFile,
@@ -13,15 +14,19 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "./types";
const SHOW_HELP_ERROR = "__SHOW_HELP__";
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
const CONNECTOR_STARTUP_POLL_MS = 100;
export abstract class ConnectorBase<Options, State>
implements ConnectCommandDefinition
{
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
constructor(
public readonly name: string,
@@ -41,8 +46,16 @@ export abstract class ConnectorBase<Options, State>
options: Options,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
protected async validateOptions(
_options: Options,
_io: ConnectIo,
): Promise<number> {
return 0;
}
showHelp(io: ConnectIo): void {
const output = this.createCommand().helpInformation().trimEnd();
for (const line of output.split("\n")) {
@@ -50,7 +63,11 @@ export abstract class ConnectorBase<Options, State>
}
}
async run(rawArgs: string[], io: ConnectIo): Promise<number> {
async run(
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
@@ -63,7 +80,27 @@ export abstract class ConnectorBase<Options, State>
io.writeErr(message);
return 1;
}
return this.runWithOptions(options, rawArgs, io);
const validationExitCode = await this.validateOptions(options, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
return this.runWithOptions(options, rawArgs, io, context);
}
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === SHOW_HELP_ERROR) {
this.showHelp(io);
return 0;
}
io.writeErr(message);
return 1;
}
return await this.validateOptions(options, io);
}
protected parseArgs(rawArgs: string[]): Options {
@@ -145,14 +182,15 @@ export abstract class ConnectorBase<Options, State>
formatBackgroundStartMessage: (pid: number) => string;
foregroundHint: string;
launchFailureMessage: string;
}): Promise<boolean> {
startupTimeoutMs?: number;
}): Promise<number | undefined> {
if (input.interactive || process.env[input.childEnvVar] === "1") {
return false;
return undefined;
}
const runningState = input.readState(input.statePath);
if (runningState && input.isRunning(runningState)) {
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
return true;
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
const pid = spawnDetachedConnector(
["connect", this.name],
@@ -161,11 +199,32 @@ export abstract class ConnectorBase<Options, State>
);
if (!pid) {
input.io.writeErr(input.launchFailureMessage);
return true;
return 1;
}
input.io.writeln(input.formatBackgroundStartMessage(pid));
input.io.writeln(input.foregroundHint);
return true;
const startedAt = Date.now();
const timeoutMs = input.startupTimeoutMs ?? CONNECTOR_STARTUP_TIMEOUT_MS;
while (Date.now() - startedAt < timeoutMs) {
const state = input.readState(input.statePath);
if (state && input.isRunning(state)) {
return 0;
}
if (!isProcessRunning(pid)) {
input.io.writeErr(
`${input.launchFailureMessage}: child exited before becoming ready`,
);
return 1;
}
await new Promise((resolve) =>
setTimeout(resolve, CONNECTOR_STARTUP_POLL_MS),
);
}
await terminateProcess(pid);
input.io.writeErr(
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
);
return 1;
}
protected async stopAllFromStatePaths(
@@ -177,13 +236,15 @@ export abstract class ConnectorBase<Options, State>
) => Promise<ConnectStopResult>,
): Promise<ConnectStopResult> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
for (const statePath of statePaths) {
const result = await stopInstance(statePath, io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, stoppedSessions };
return { stoppedProcesses, failedProcesses, stoppedSessions };
}
protected async stopManagedProcess(input: {
@@ -198,17 +259,31 @@ export abstract class ConnectorBase<Options, State>
const state = input.readState(input.statePath);
if (!state) {
this.removeStateFile(input.statePath);
return { stoppedProcesses: 0, stoppedSessions: 0 };
return {
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
};
}
const pid = input.getPid(state);
let stoppedProcesses = 0;
if (await terminateProcess(input.getPid(state))) {
if (await terminateProcess(pid)) {
stoppedProcesses = 1;
input.io.writeln(input.describeStoppedProcess(state));
} else if (isProcessRunning(pid)) {
input.io.writeErr(
`[connect] failed to stop connector process pid=${pid}`,
);
return {
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
};
}
const stoppedSessions = await input.stopSessions(state);
input.clearBindings?.(state);
this.removeStateFile(input.statePath);
return { stoppedProcesses, stoppedSessions };
return { stoppedProcesses, failedProcesses: 0, stoppedSessions };
}
protected parseOptionalInteger(
+18
View File
@@ -83,6 +83,24 @@ describe("spawnDetachedConnector", () => {
],
});
});
it("marks detached children and removes the hub-daemon-only environment flag", () => {
const env = {
CLINE_BUILD_ENV: "production",
CLINE_RUN_AS_HUB_DAEMON: "1",
UNCHANGED: "value",
};
expect(
__test__.buildDetachedConnectorEnv("CLINE_TELEGRAM_CONNECT_CHILD", env),
).toEqual({
CLINE_BUILD_ENV: "production",
CLINE_CONNECTOR_DETACHED_CHILD: "1",
CLINE_TELEGRAM_CONNECT_CHILD: "1",
UNCHANGED: "value",
});
expect(env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
});
});
describe("readSessionReplyText", () => {
+29 -5
View File
@@ -10,11 +10,24 @@ import {
import { join } from "node:path";
import type { HubSessionClient, HubSessionRow } from "@cline/core";
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
import { withResolvedClineBuildEnv } from "@cline/shared";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
withResolvedClineBuildEnv,
} from "@cline/shared";
import { createCliLoggerAdapter } from "../logging/adapter";
import { logSpawnedProcess } from "../logging/process";
import { resolveCliLaunchSpec } from "../utils/internal-launch";
export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
"CLINE_CONNECTOR_DETACHED_CHILD";
/**
* Internal success from a detached connect when an instance is already running.
* `runConnectAdapter` maps this to exit 0 without changing persisted autostart
* state.
*/
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
return rawArgs.includes(flag);
}
@@ -123,6 +136,19 @@ function buildDetachedConnectorCommand(
};
}
function buildDetachedConnectorEnv(
childEnvKey: string,
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const childEnv = {
...withResolvedClineBuildEnv(env),
[childEnvKey]: "1",
[CLINE_CONNECTOR_DETACHED_CHILD_ENV]: "1",
};
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
return childEnv;
}
export function resolveConnectorDebugLogPath(
adapterName: string,
instanceKey: string,
@@ -190,10 +216,7 @@ export function spawnDetachedConnector(
detachedLogFd === undefined
? "ignore"
: ["ignore", detachedLogFd, detachedLogFd],
env: {
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
env: buildDetachedConnectorEnv(childEnvKey),
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
@@ -245,6 +268,7 @@ export function spawnDetachedConnector(
export const __test__ = {
buildDetachedConnectorArgs,
buildDetachedConnectorCommand,
buildDetachedConnectorEnv,
};
export function readJsonFile<T>(path: string, fallback: T): T {
@@ -67,6 +67,62 @@ describe("createConnectorRuntimeTurnStream", () => {
});
});
it("keeps streaming when tool status delivery fails", async () => {
let handlers: StreamHandlers | undefined;
const log = vi.fn();
const statusError = new Error("message_not_found");
const client = {
streamEvents: (_request: unknown, callbacks: StreamHandlers) => {
handlers = callbacks;
return () => {};
},
sendRuntimeSession: async () => {
handlers?.onEvent({
eventType: "runtime.chat.tool_call_start",
payload: { toolName: "run_commands" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
handlers?.onEvent({
eventType: "runtime.chat.text_delta",
payload: { text: "Final response" },
});
return {
result: {
text: "Final response",
finishReason: "stop",
iterations: 1,
},
};
},
};
const chunks: string[] = [];
for await (const chunk of createConnectorRuntimeTurnStream({
client: client as never,
sessionId: "session-1",
request: { config: {} as never, prompt: "hi" },
clientId: "client-1",
logger: { core: { log } } as unknown as CliLoggerAdapter,
transport: "slack",
conversationId: "thread-1",
onToolStatus: async () => {
throw statusError;
},
})) {
chunks.push(chunk);
}
expect(chunks.join("")).toBe("Final response");
expect(log).toHaveBeenCalledWith(
"Connector tool status delivery failed",
expect.objectContaining({
severity: "warn",
transport: "slack",
error: statusError,
}),
);
});
it("treats queued runtime turns as non-error completion", async () => {
const log = vi.fn();
const client = {
+11 -1
View File
@@ -169,7 +169,17 @@ export function createConnectorRuntimeTurnStream(input: {
return;
}
lastStatusMessage = message;
await input.onToolStatus?.(message);
try {
await input.onToolStatus?.(message);
} catch (error) {
input.logger.core.log("Connector tool status delivery failed", {
severity: "warn",
transport: input.transport,
conversationId: input.conversationId,
sessionId: input.sessionId,
error,
});
}
};
const stopStreaming = input.client.streamEvents(
+13 -1
View File
@@ -5,13 +5,25 @@ export type ConnectIo = {
export type ConnectStopResult = {
stoppedProcesses: number;
failedProcesses: number;
stoppedSessions: number;
};
export type ConnectRunContext = {
setPersistenceArgs: (args: string[]) => void;
setPersistenceInstanceId: (instanceId: string) => void;
};
export interface ConnectCommandDefinition {
name: string;
description: string;
run(args: string[], io: ConnectIo): Promise<number>;
run(
args: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
validate(args: string[], io: ConnectIo): Promise<number>;
showHelp(io: ConnectIo): void;
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
}
+20 -6
View File
@@ -1,13 +1,19 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import { disposeAll, initVcr, isHubDaemonProcess } from "@cline/shared";
import {
disposeAll,
initVcr,
isHubDaemonProcess,
setConnectorCliLaunchSpec,
} from "@cline/shared";
import { logCliProcessError } from "./logging/errors";
import {
abortActiveRuntime,
cleanupActiveRuntime,
isAbortInProgress,
} from "./runtime/active-runtime";
import { resolveCliLaunchSpec } from "./utils/internal-launch";
import { writeErr } from "./utils/output";
// Initialize VCR before any HTTP requests are made.
@@ -16,7 +22,20 @@ initVcr(process.env.CLINE_VCR);
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (isHubDaemonProcess()) {
// The hub daemon owns its process-level abort handling. Installing the CLI's
// fatal rejection handler first would make expected abort rejections exit it.
void import("@cline/core/hub/daemon-entry");
} else {
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
if (cliLaunchSpec) {
setConnectorCliLaunchSpec({
launcher: cliLaunchSpec.launcher,
connectArgsPrefix: [...cliLaunchSpec.childArgsPrefix, "connect"],
cwd: process.cwd(),
});
}
let shuttingDown = false;
let handlingFatalProcessError = false;
const forwardSignalToRuntime = () => {
@@ -57,11 +76,6 @@ if (!isMainThread) {
});
void (async () => {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
let exitCode = 0;
try {
const { runCli } = await import("./main");
+257 -4
View File
@@ -1,4 +1,6 @@
import { fstatSync } from "node:fs";
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CliMigrationNotice,
@@ -18,6 +20,7 @@ vi.mock("node:fs", async () => {
const originalArgv = [...process.argv];
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const mockState = vi.hoisted(() => ({
runAgentImports: 0,
runInteractiveImports: 0,
@@ -61,6 +64,13 @@ const kanbanMocks = vi.hoisted(() => ({
const dashboardMocks = vi.hoisted(() => ({
runDashboardCommand: vi.fn(),
}));
const connectMocks = vi.hoisted(() => ({
formatAdapterList: vi.fn(() => ""),
runConnectAdapter: vi.fn(async () => 0),
runRestartConnector: vi.fn(async () => 0),
runStopAllConnectors: vi.fn(async () => 0),
runStopConnector: vi.fn(async () => 0),
}));
const migrationNoticeMocks = vi.hoisted(() => ({
getClineCliMigrationNotice: vi.fn<
(
@@ -198,6 +208,7 @@ vi.mock("./runtime/prompt", () => ({
}));
vi.mock("./commands/kanban", () => kanbanMocks);
vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./commands/connect", () => connectMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
@@ -208,8 +219,17 @@ vi.mock("./utils/telemetry", () => telemetryMocks);
vi.mock("./utils/worktree", () => worktreeMocks);
describe("runCli lightweight command dispatch", () => {
let globalSettingsRoot: string | undefined;
beforeEach(() => {
process.exitCode = undefined;
// Startup now reads persisted general settings; point the resolver at a
// fresh temp file so the developer's real settings cannot leak in.
globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-"));
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
globalSettingsRoot,
"global-settings.json",
);
mockState.runAgentImports = 0;
mockState.runInteractiveImports = 0;
mockState.runAgentCalls = 0;
@@ -273,6 +293,16 @@ describe("runCli lightweight command dispatch", () => {
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
connectMocks.formatAdapterList.mockReset();
connectMocks.formatAdapterList.mockReturnValue("");
connectMocks.runConnectAdapter.mockReset();
connectMocks.runConnectAdapter.mockResolvedValue(0);
connectMocks.runRestartConnector.mockReset();
connectMocks.runRestartConnector.mockResolvedValue(0);
connectMocks.runStopAllConnectors.mockReset();
connectMocks.runStopAllConnectors.mockResolvedValue(0);
connectMocks.runStopConnector.mockReset();
connectMocks.runStopConnector.mockResolvedValue(0);
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
@@ -299,6 +329,16 @@ describe("runCli lightweight command dispatch", () => {
afterEach(() => {
process.exitCode = undefined;
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (globalSettingsRoot) {
rmSync(globalSettingsRoot, { recursive: true, force: true });
globalSettingsRoot = undefined;
}
process.argv = [...originalArgv];
Object.defineProperty(process.stdin, "isTTY", {
value: originalStdinIsTTY,
@@ -333,6 +373,55 @@ describe("runCli lightweight command dispatch", () => {
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
}, 30_000);
it("routes connector restart arguments through the restart lifecycle", async () => {
process.argv = [
"bun",
"src/index.ts",
"connect",
"--restart",
"telegram",
"-k",
"token",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
"telegram",
["-k", "token"],
expect.any(Object),
undefined,
);
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
});
it("routes a targeted connector restart to one instance", async () => {
process.argv = [
"bun",
"src/index.ts",
"connect",
"--restart-instance",
"cline_bot",
"telegram",
"-k",
"token",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
"telegram",
["-k", "token"],
expect.any(Object),
"cline_bot",
);
});
it("does not load runtime modules for root update", async () => {
@@ -848,6 +937,172 @@ describe("runCli lightweight command dispatch", () => {
);
});
describe("persisted general settings at startup", () => {
function writePersistedSettings(settings: Record<string, unknown>) {
const path = process.env.CLINE_GLOBAL_SETTINGS_PATH;
if (!path) {
throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set");
}
writeFileSync(path, JSON.stringify(settings));
}
it("restores the persisted plan mode when no mode flag is provided", async () => {
writePersistedSettings({ planActMode: "plan" });
promptMocks.resolveSystemPrompt.mockClear();
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({ mode: "plan" }),
expect.anything(),
undefined,
expect.any(Object),
);
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({ mode: "plan" }),
);
});
it("prefers an explicit --act flag over the persisted plan mode", async () => {
writePersistedSettings({ planActMode: "plan" });
promptMocks.resolveSystemPrompt.mockClear();
process.argv = ["bun", "src/index.ts", "--act"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({ mode: "act" }),
expect.anything(),
undefined,
expect.any(Object),
);
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({ mode: "act" }),
);
});
it("restores the persisted auto-approve setting as a runtime policy", async () => {
writePersistedSettings({ toolAutoApprove: false });
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
defaultToolAutoApprove: true,
toolPolicies: {
"*": { autoApprove: false },
},
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("prefers an explicit --auto-approve flag over the persisted setting", async () => {
writePersistedSettings({ toolAutoApprove: false });
process.argv = ["bun", "src/index.ts", "--auto-approve", "true"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
toolPolicies: {
"*": { autoApprove: true },
},
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("restores disabled compaction across restarts", async () => {
writePersistedSettings({
compactionEnabled: false,
compactionStrategy: "basic",
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: false },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("restores the persisted compaction strategy across restarts", async () => {
writePersistedSettings({
compactionEnabled: true,
compactionStrategy: "basic",
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: true, strategy: "basic" },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("prefers an explicit --compaction flag over the persisted mode", async () => {
writePersistedSettings({ compactionEnabled: false });
process.argv = ["bun", "src/index.ts", "--compaction", "agentic"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: true, strategy: "agentic" },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("applies persisted settings to single-prompt runs as well", async () => {
writePersistedSettings({
compactionEnabled: true,
compactionStrategy: "basic",
planActMode: "plan",
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
compaction: { enabled: true, strategy: "basic" },
mode: "plan",
}),
expect.anything(),
);
});
});
it("forces chat view when resuming a session", async () => {
process.argv = ["bun", "src/index.ts", "--id", "sess_123"];
@@ -1302,7 +1557,6 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
thinking: true,
reasoningEffort: "medium",
@@ -1389,7 +1643,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("enables truncation compaction by default for prompt runs", async () => {
it("uses Core's agentic compaction default for prompt runs", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1404,7 +1658,6 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
}),
expect.anything(),
+51 -12
View File
@@ -43,6 +43,11 @@ import {
normalizeProviderId,
} from "./utils/provider-auth";
import { resolveCliReasoning } from "./utils/reasoning";
import {
resolveStartupCompactionMode,
resolveStartupMode,
resolveStartupToolAutoApprove,
} from "./utils/startup-settings";
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
@@ -362,6 +367,11 @@ export async function runCli(): Promise<void> {
.description("Connect to an external channel")
.argument("[channel]", "Channel to connect Cline CLI to")
.option("--stop", "Kill all current channel connections")
.option("--restart", "Restart a channel connection")
.option(
"--restart-instance <id>",
"Restart one connector instance (used by daemon recovery)",
)
.allowUnknownOption()
.passThroughOptions()
.addHelpText(
@@ -372,16 +382,32 @@ export async function runCli(): Promise<void> {
const {
formatAdapterList,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
runStopConnector,
} = await import("./commands/connect");
const opts = connectCmd.opts();
if (opts.stop) {
if (opts.stop && (opts.restart || opts.restartInstance)) {
io.writeErr("connect accepts only one of --stop or --restart");
ctx.exitCode = 1;
} else if (opts.stop) {
if (adapter) {
ctx.exitCode = await runStopConnector(adapter, io);
} else {
ctx.exitCode = await runStopAllConnectors(io);
}
} else if (opts.restart || opts.restartInstance) {
if (!adapter) {
io.writeErr("connect --restart requires a channel");
ctx.exitCode = 1;
} else {
ctx.exitCode = await runRestartConnector(
adapter,
connectCmd.args.slice(1),
io,
opts.restartInstance,
);
}
} else if (adapter) {
// connectCmd.args = [adapter, ...passthroughFlags]. Pass only the
// connector-specific flags (everything after the adapter name).
@@ -844,14 +870,6 @@ export async function runCli(): Promise<void> {
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
args.autoApproveOverride ?? defaultToolAutoApprove;
const toolPolicies: Record<string, ToolPolicy> = {
"*": {
autoApprove: effectiveToolAutoApprove,
},
};
if (args.outputMode === "json" && (args.interactive || !args.prompt)) {
writeErr(
@@ -928,6 +946,27 @@ export async function runCli(): Promise<void> {
runAgent,
} = await loadCliRuntimeModules();
// General settings toggled in the TUI /settings panel persist to the
// global settings file; explicit CLI flags take precedence over the
// persisted values, which in turn override the built-in defaults.
const persistedGlobalSettings = coreServer.readGlobalSettings();
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove = resolveStartupToolAutoApprove(
args,
persistedGlobalSettings,
defaultToolAutoApprove,
);
const toolPolicies: Record<string, ToolPolicy> = {
"*": {
autoApprove: effectiveToolAutoApprove,
},
};
const effectiveMode = resolveStartupMode(args, persistedGlobalSettings);
const effectiveCompactionMode = resolveStartupCompactionMode(
args,
persistedGlobalSettings,
);
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
// These components operate before/outside ClineCore sessions, so the
@@ -1086,13 +1125,13 @@ export async function runCli(): Promise<void> {
cwd,
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: args.mode ?? "act",
mode: effectiveMode,
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
},
checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG,
compaction: buildCliCompactionConfig(args.compactionMode),
compaction: buildCliCompactionConfig(effectiveCompactionMode),
timeoutSeconds: args.timeoutSeconds,
sandbox: sandboxEnabled,
sandboxDataDir,
@@ -1100,7 +1139,7 @@ export async function runCli(): Promise<void> {
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
outputMode: args.outputMode,
mode: args.mode,
mode: effectiveMode,
logger: loggerAdapter.core,
loggerConfig: loggerAdapter.runtimeConfig,
telemetry: getCliTelemetryService(loggerAdapter.core),
+5 -8
View File
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let savedRejectionListeners: Function[] | undefined;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
// 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 Function[];
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
@@ -68,10 +68,7 @@ export function clearAbortInProgress(): void {
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
for (const listener of savedRejectionListeners) {
process.on(
"unhandledRejection",
listener as (...args: unknown[]) => void,
);
process.on("unhandledRejection", listener);
}
savedRejectionListeners = undefined;
}
@@ -12,6 +12,25 @@ import {
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
@@ -46,6 +65,7 @@ function createProviderSettingsManager(): ProviderSettingsManager {
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -163,6 +183,15 @@ describe("compactInteractiveMessages", () => {
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -189,6 +218,17 @@ describe("compactInteractiveMessages", () => {
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
+38 -48
View File
@@ -55,54 +55,44 @@ const CLI_CLINE_PASS_LIMIT_MESSAGE = [
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
vi.mock("@cline/core", () => ({
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
extractClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
const prefix = "you have reached your";
const suffix = "please try again later.";
const start = normalized.indexOf(prefix);
if (start === -1) return undefined;
const suffixStart = normalized.indexOf(suffix, start);
if (suffixStart === -1) return undefined;
const end = suffixStart + suffix.length;
if (!normalized.slice(start, end).includes("clinepass limit")) {
return undefined;
}
return text.slice(start, end);
},
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
},
}));
vi.mock(
"@cline/core",
async (importActual: () => Promise<typeof import("@cline/core")>) => ({
...(await importActual()),
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
},
}),
);
vi.mock("../utils/approval", () => ({
askQuestionInTerminal: vi.fn(),
+7 -3
View File
@@ -205,7 +205,9 @@ export async function runAgent(
event.error.message.trim()
) {
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message).trim(),
formatCliErrorMessage(event.error.message, {
modelId: config.modelId,
}).trim(),
);
}
handleEvent(event, config);
@@ -390,7 +392,9 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = formatCliErrorMessage(result.text).trim();
const errorText = formatCliErrorMessage(result.text, {
modelId: config.modelId,
}).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -411,7 +415,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = formatCliErrorMessage(err);
const message = formatCliErrorMessage(err, { modelId: config.modelId });
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
+8
View File
@@ -2,6 +2,9 @@ import {
getCurrentContextSize,
type ProviderSettings,
ProviderSettingsManager,
setCompactionModeGlobally,
setPlanActModeGlobally,
setToolAutoApproveGlobally,
type UserInstructionConfigService,
} from "@cline/core";
import { formatModeSwitchNotice } from "@cline/shared";
@@ -712,15 +715,20 @@ export async function runInteractive(
onTurnErrorReported: () => {},
onAutoApproveChange: (enabled) => {
setInteractiveAutoApprove(enabled);
setToolAutoApproveGlobally(enabled);
void refreshInteractiveSessionPolicies();
},
onCompactionModeChange: async (mode) => {
await sessionRuntime.ensureReady();
applyCliCompactionMode(config, mode);
setCompactionModeGlobally(mode);
await sessionRuntime.restartWithCurrentMessages();
},
onModeChange: async (mode) => {
if (!isInteractiveMode(mode)) return;
// Persist the user's choice immediately, even when the switch is
// deferred until the current turn aborts, so it survives restarts.
setPlanActModeGlobally(mode);
if (isRunning) {
pendingModeChange.current = mode;
pendingModeChange.source = "ui";
+117 -2
View File
@@ -17,14 +17,19 @@
// - Auto-approve all (Shift+Tab)
// ---------------------------------------------------------------------------
import { test } from "@microsoft/tui-test";
import { expect, test } from "@microsoft/tui-test";
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js";
import { clineEnv } from "../helpers/env.js";
import {
toggleAutoApproveAll,
waitForChatReady,
} from "../helpers/page-objects/chat.js";
import { expectVisible } from "../helpers/terminal.js";
import {
expectNotVisible,
expectVisible,
typeAndSubmit,
} from "../helpers/terminal.js";
test.describe("cline (authenticated) - shows chat view", () => {
test.use({
@@ -53,3 +58,113 @@ test.describe("Auto-approve all - Shift+Tab toggle", () => {
await toggleAutoApproveAll(terminal);
});
});
test.describe("Dialog dismissal - panel is fully removed", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
...TERMINAL_WIDE,
env: clineEnv("default"),
});
type Background = {
mode: number | undefined;
color: number | undefined;
};
type TerminalSnapshot = ReturnType<Terminal["serialize"]> & {
baseY: number;
};
const backgroundsEqual = (
left: Background | undefined,
right: Background | undefined,
): boolean => left?.mode === right?.mode && left?.color === right?.color;
const snapshotTerminal = (terminal: Terminal): TerminalSnapshot => ({
...terminal.serialize(),
baseY: terminal.getCursor().baseY,
});
const findTextPosition = (
terminal: Terminal,
text: string,
): { x: number; y: number } => {
const lines = terminal.getViewableBuffer();
for (let y = 0; y < lines.length; y++) {
const x = lines[y].join("").indexOf(text);
if (x !== -1) {
return { x, y };
}
}
throw new Error(`Unable to locate visible text: ${text}`);
};
const getCellBackground = (
snapshot: TerminalSnapshot,
position: { x: number; y: number },
): Background => {
const targetRow = snapshot.baseY + position.y;
let background: Background = { mode: undefined, color: undefined };
for (let y = snapshot.baseY; y <= targetRow; y++) {
for (let x = 0; x < TERMINAL_WIDE.columns; x++) {
const shift = snapshot.shifts.get(`${x},${y}`);
if (shift?.bgColorMode !== undefined) {
background = { mode: shift.bgColorMode, color: shift.bgColor };
}
if (x === position.x && y === targetRow) {
return background;
}
}
}
throw new Error(
`Cell is outside the visible terminal: ${position.x},${position.y}`,
);
};
// @opentui-ui/dialog is built against @opentui/core ^0.1.69, whose
// Renderable.remove(id) took an id. Core 0.4.x renamed it to
// remove(child) and throws on a non-renderable argument, so the
// package's removeDialog() aborted before detaching its panel — the React
// portal content unmounted, but the imperative grey box stayed on screen
// over the chat. Asserting on the panel's background (not its text) is what
// distinguishes a leaked box from a clean teardown.
test("closing the help dialog removes its grey panel", async ({
terminal,
}) => {
await waitForChatReady(terminal);
const terminalBeforeDialog = snapshotTerminal(terminal);
await typeAndSubmit(terminal, "/help");
await expectVisible(terminal, "Keyboard Shortcuts");
const dialogPosition = findTextPosition(terminal, "Keyboard Shortcuts");
const backgroundAtDialogPosition = getCellBackground(
terminalBeforeDialog,
dialogPosition,
);
const dialogBackground = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
expect(dialogBackground).not.toEqual(backgroundAtDialogPosition);
terminal.keyEscape();
await expectNotVisible(terminal, "Keyboard Shortcuts");
// The panel unmounts a frame after its content. Poll the title's former
// position until the background captured from the visible panel is gone.
const deadline = Date.now() + 10_000;
let backgroundAfterDialog = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
while (
!backgroundsEqual(backgroundAfterDialog, backgroundAtDialogPosition) &&
Date.now() < deadline
) {
await new Promise((resolve) => setTimeout(resolve, 100));
backgroundAfterDialog = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
}
expect(backgroundAfterDialog).toEqual(backgroundAtDialogPosition);
});
});
+59
View File
@@ -309,3 +309,62 @@ describe("loadIndividualSubscriptionPlans", () => {
expect(result).toEqual(plans);
});
});
describe("isClineAccountCreditsErrorMessage", () => {
it("matches the raw insufficient_credits JSON payload from the Cline API 402", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'{"code":"insufficient_credits","current_balance":-0.14,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the insufficient_credits payload wrapped in an error prefix", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
'Error: {"code":"insufficient_credits","current_balance":0,"message":"Not enough credits available"}',
),
).toBe(true);
});
it("matches the plain human-readable Cline API message", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage("Not enough credits available"),
).toBe(true);
});
it("matches the legacy insufficient balance phrasing", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(
isClineAccountCreditsErrorMessage(
"Insufficient balance. Your Cline credits balance is $0.00.",
),
).toBe(true);
});
it("does not match unrelated errors", async () => {
const { isClineAccountCreditsErrorMessage } = await import(
"./cline-account"
);
expect(isClineAccountCreditsErrorMessage("Payment Required")).toBe(false);
expect(
isClineAccountCreditsErrorMessage(
"Your credit balance is too low to access the Anthropic API.",
),
).toBe(false);
expect(
isClineAccountCreditsErrorMessage("insufficient balance on gateway"),
).toBe(false);
});
});
+9 -2
View File
@@ -51,9 +51,16 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
// The Cline API's 402 response carries `code: "insufficient_credits"` and
// the message "Not enough credits available". Depending on how much of the
// payload survives error extraction, the CLI may see the raw JSON blob or
// just the human-readable message, so match both. The
// "insufficient balance" pair is an older backend phrasing kept for safety.
return (
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
normalized.includes("insufficient_credits") ||
normalized.includes("not enough credits") ||
(normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance"))
);
}
+85 -10
View File
@@ -1,4 +1,7 @@
import type { ClineSubscriptionPlan } from "@cline/core";
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
} from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
@@ -8,6 +11,8 @@ import {
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -29,6 +34,7 @@ import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseAskQuestionInput,
parseEditorInput,
@@ -129,12 +135,13 @@ function formatToolParams(
case "read_files": {
const info = parseReadFilesInput(rawInput);
if (!info?.files.length) return fallback;
const keys = buildReadFilesKeys(info.files);
return info.files.map((f, i) => {
const sl = f.startLine != null ? String(f.startLine) : "undefined";
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={`${i}:${f.path}`}>
<span key={keys[i]}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -478,14 +485,6 @@ function ClinePassLimitErrorView(props: {
selectable
content="Switch to Cline usage-based billing and retry with the Cline provider."
/>
<box flexDirection="row">
<text fg="gray">Interactive CLI: </text>
<text
fg={props.defaultFg}
selectable
content="type /model, press tab to change provider, choose Cline, then retry."
/>
</box>
<box flexDirection="row">
<text fg="gray">Headless CLI: </text>
<text fg={props.defaultFg} selectable content="rerun with " />
@@ -502,6 +501,71 @@ function ClinePassLimitErrorView(props: {
);
}
function ClineFreeModelLimitErrorView(props: {
message: string;
defaultFg?: string;
}) {
const resetTime = extractClineFreeModelLimitResetTime(props.message);
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">Daily free model limit reached</text>
<text
fg={props.defaultFg}
selectable
content="You've reached today's free usage limit for this model."
/>
<text
fg={props.defaultFg}
selectable
content={
resetTime
? `Try again in ${resetTime} or select another model.`
: "Try again later or select another model."
}
/>
<text fg="gray">Open the model selector with /model.</text>
</box>
</box>
);
}
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">Free model promotion ended</text>
<text
fg={props.defaultFg}
selectable
content="The free promotion for this model has ended and it is no longer available."
/>
<text
fg={props.defaultFg}
selectable
content="Select another model to continue."
/>
<text fg="gray">Open the model selector with /model.</text>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -626,6 +690,17 @@ export function ChatEntryView(props: {
/>
);
}
if (isClineFreeModelLimitErrorMessage(entry.text)) {
return (
<ClineFreeModelLimitErrorView
defaultFg={defaultFg}
message={entry.text}
/>
);
}
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -4,6 +4,7 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import type React from "react";
import { palette } from "../../palette";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseEditorInput,
parseReadFilesInput,
@@ -22,13 +23,14 @@ export function formatApprovalParams(
case "read_files": {
const info = parseReadFilesInput(rawInput);
if (!info?.files.length) break;
const keys = buildReadFilesKeys(info.files);
return info.files.map((f, i) => {
const range =
f.startLine != null
? ` lines ${f.startLine}-${f.endLine ?? "end"}`
: "";
return (
<text key={f.path} fg="gray" selectable>
<text key={keys[i]} fg="gray" selectable>
{" "}
{shortenPath(f.path, 60)}
{range && <span fg="gray">{range}</span>}
+4 -1
View File
@@ -30,6 +30,7 @@ interface AgentEventDeps {
}) => void;
onTurnErrorReported: TuiProps["onTurnErrorReported"];
verbose: boolean;
modelId?: string;
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
@@ -45,6 +46,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
} = deps;
// Compaction dividers that arrived while an assistant message was still
@@ -224,7 +226,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
if (!event.recoverable || verbose) {
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error),
text: formatCliErrorMessage(event.error, { modelId }),
});
}
break;
@@ -289,6 +291,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
@@ -38,6 +38,7 @@ export function usePromptInputController(input: {
onSubmit: TuiProps["onSubmit"];
initialPrompt?: string;
providerId: string;
modelId?: string;
configVerbose: boolean;
refreshRepoStatus: () => void;
setAppView: (view: AppView) => void;
@@ -50,6 +51,7 @@ export function usePromptInputController(input: {
onSubmit,
initialPrompt,
providerId,
modelId,
configVerbose,
refreshRepoStatus,
setAppView,
@@ -377,7 +379,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: formatCliErrorMessage(error),
text: formatCliErrorMessage(error, { modelId }),
});
}
} finally {
@@ -393,6 +395,7 @@ export function usePromptInputController(input: {
clearPasteAttachments,
configVerbose,
inputHistory,
modelId,
onSubmit,
providerId,
refreshRepoStatus,
+2
View File
@@ -724,6 +724,7 @@ function App(props: TuiProps) {
addUsageDelta: session.addUsageDelta,
onTurnErrorReported: props.onTurnErrorReported,
verbose: props.config.verbose ?? false,
modelId: props.config.modelId,
});
const promptInput = usePromptInputController({
@@ -733,6 +734,7 @@ function App(props: TuiProps) {
onSubmit: props.onSubmit,
initialPrompt: props.initialPrompt,
providerId: props.config.providerId,
modelId: props.config.modelId,
configVerbose: props.config.verbose ?? false,
refreshRepoStatus,
setAppView,
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
describe("buildReadFilesKeys", () => {
it("produces unique keys when the same path is read twice", () => {
const info = parseReadFilesInput({
files: [{ path: "/a/SKILL.md" }, { path: "/a/SKILL.md" }],
});
const keys = buildReadFilesKeys(info?.files ?? []);
expect(keys).toHaveLength(2);
expect(new Set(keys).size).toBe(keys.length);
});
it("produces unique keys for duplicate paths from the file_paths shape", () => {
const info = parseReadFilesInput({
file_paths: ["/a/SKILL.md", "/a/SKILL.md", "/b/SKILL.md"],
});
const keys = buildReadFilesKeys(info?.files ?? []);
expect(keys).toHaveLength(3);
expect(new Set(keys).size).toBe(keys.length);
});
it("keeps distinct paths in unique keys", () => {
const keys = buildReadFilesKeys([{ path: "/a.ts" }, { path: "/b.ts" }]);
expect(new Set(keys).size).toBe(2);
});
it("returns no keys for an empty list", () => {
expect(buildReadFilesKeys([])).toEqual([]);
});
});
+6
View File
@@ -91,6 +91,12 @@ export function parseReadFilesInput(input: unknown): ReadFilesInfo | undefined {
return undefined;
}
// A read_files call can list the same path more than once, so the raw path is
// not a unique React key. Prefix the array index to keep keys unique per row.
export function buildReadFilesKeys(files: { path: string }[]): string[] {
return files.map((f, i) => `${i}:${f.path}`);
}
export interface RunCommandsInfo {
commands: string[];
}
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClineFreeModelLimitMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -48,6 +51,9 @@ describe("cline-pass-errors", () => {
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
"deepseek-v4-flash",
);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
@@ -67,4 +73,44 @@ describe("cline-pass-errors", () => {
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
it("recognizes and formats daily free model limits without usage-billing guidance", () => {
const raw =
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
expect(isClineFreeModelLimitErrorMessage(raw)).toBe(true);
expect(isClineFreeModelLimitErrorMessage(new Error(raw))).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClineFreeModelLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).not.toContain("Error 429");
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Try again in 23h 59m",
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"select another model",
);
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
"usage-based billing",
);
expect(
isClineFreeModelLimitErrorMessage(getCliClineFreeModelLimitMessage(raw)),
).toBe(true);
});
it("formats model-not-found errors for removed free models", () => {
const raw = new Error("Error 404: model not found");
expect(
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
).toContain("Free model promotion ended");
expect(
isClineFreePromotionEndedErrorMessage(
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
),
).toBe(true);
expect(
formatCliErrorMessage(raw, { modelId: "vendor/retired-model" }),
).toBe(raw.message);
});
});
+86 -1
View File
@@ -1,7 +1,11 @@
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineFreeModelLimitError,
isClineFreeModelLimitMessage,
isClineModelNotFoundMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
@@ -39,6 +43,31 @@ export function getCliClinePassLimitMessage(message: string): string {
return lines.filter((line) => line.trim().length > 0).join("\n");
}
const CLINE_FREE_MODEL_PREFIX = "cline-free/";
const CLINE_FREE_PROMOTION_ENDED_HEADER = "Free model promotion ended";
const CLINE_FREE_MODEL_LIMIT_HEADER = "Daily free model limit reached";
export function getCliClineFreePromotionEndedMessage(): string {
return [
CLINE_FREE_PROMOTION_ENDED_HEADER,
"The free promotion for this model has ended and it is no longer available.",
"Select another model to continue.",
"Open the model selector with /model.",
].join("\n");
}
export function getCliClineFreeModelLimitMessage(message: string): string {
const resetTime = extractClineFreeModelLimitResetTime(message);
return [
CLINE_FREE_MODEL_LIMIT_HEADER,
"You've reached today's free usage limit for this model.",
resetTime
? `Try again in ${resetTime} or select another model.`
: "Try again later or select another model.",
"Open the model selector with /model.",
].join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -114,7 +143,55 @@ export function isClinePassLimitErrorMessage(error: unknown): boolean {
return typeof error === "string" && isClinePassLimitMessage(error);
}
export function formatCliErrorMessage(error: unknown): string {
// Detects that a deleted free model was requested: the backend answers "model
// not found" once a free promotion ends and the cline-free/ model is removed.
// The modelId gate keeps regular model-not-found errors on their generic path.
export function isClineFreePromotionEndedErrorMessage(
error: unknown,
modelId?: string,
): boolean {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
if (
message
.toLowerCase()
.includes(CLINE_FREE_PROMOTION_ENDED_HEADER.toLowerCase())
) {
return true;
}
if (!modelId?.startsWith(CLINE_FREE_MODEL_PREFIX)) {
return false;
}
return isClineModelNotFoundMessage(message);
}
export function isClineFreeModelLimitErrorMessage(error: unknown): boolean {
if (isClineFreeModelLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineFreeModelLimitError" ||
isClineFreeModelLimitMessage(error.message)
);
}
return (
typeof error === "string" &&
(error
.toLowerCase()
.includes(CLINE_FREE_MODEL_LIMIT_HEADER.toLowerCase()) ||
isClineFreeModelLimitMessage(error))
);
}
export function formatCliErrorMessage(
error: unknown,
options?: { modelId?: string },
): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
}
@@ -126,6 +203,14 @@ export function formatCliErrorMessage(error: unknown): string {
error instanceof Error ? error.message : String(error),
);
}
if (isClineFreeModelLimitErrorMessage(error)) {
return getCliClineFreeModelLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (isClineFreePromotionEndedErrorMessage(error, options?.modelId)) {
return getCliClineFreePromotionEndedMessage();
}
if (error instanceof Error) {
return error.message;
}
+3 -6
View File
@@ -15,14 +15,12 @@ function createConfig(compaction?: Config["compaction"]): Config {
}
describe("CLI compaction mode helpers", () => {
it("defaults enabled compaction to basic truncation", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
it("defaults enabled compaction to agentic summarization", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
expect(getCliCompactionMode(createConfig())).toBe(
DEFAULT_CLI_COMPACTION_MODE,
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
"Truncation",
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
});
it("maps basic and off modes to core compaction config", () => {
@@ -47,7 +45,6 @@ describe("CLI compaction mode helpers", () => {
it("builds default and explicit core compaction config", () => {
expect(buildCliCompactionConfig()).toEqual({
enabled: true,
strategy: "basic",
});
expect(buildCliCompactionConfig("agentic")).toEqual({
enabled: true,
+7 -6
View File
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
CliCompactionMode,
"agentic" | "basic"
> = "basic";
> = "agentic";
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
agentic: "agentic",
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
} as const satisfies Record<CliCompactionMode, string>;
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
"Context compaction mode: agentic|basic|off (default: basic)";
"Context compaction mode: agentic|basic|off (default: agentic)";
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
@@ -31,8 +31,11 @@ export function parseCliCompactionMode(
}
export function buildCliCompactionConfig(
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
mode?: CliCompactionMode,
): NonNullable<Config["compaction"]> {
if (mode === undefined) {
return { enabled: true };
}
if (mode === "off") {
return { enabled: false };
}
@@ -43,9 +46,7 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
if (config.compaction?.enabled === false) {
return "off";
}
return config.compaction?.strategy === "agentic"
? "agentic"
: DEFAULT_CLI_COMPACTION_MODE;
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
}
export function applyCliCompactionMode(
+6 -1
View File
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
workspacePath,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
+31
View File
@@ -222,6 +222,37 @@ describe("handleEvent text formatting", () => {
expect(errorOutput).toContain("--provider cline");
});
it("formats daily free model limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("Daily free model limit reached");
expect(errorOutput).toContain("select another model");
expect(errorOutput).not.toContain("usage-based billing");
});
it("formats removed free model errors using the configured model id", () => {
handleEvent(
{
type: "error",
error: new Error("Error 404: model not found"),
recoverable: false,
} as unknown as AgentEvent,
{ modelId: "cline-free/retired-model" } as Config,
);
expect(errorOutput).toContain("Free model promotion ended");
expect(errorOutput).toContain("Select another model");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+3 -1
View File
@@ -204,7 +204,9 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(formatCliErrorMessage(event.error));
writeErr(
formatCliErrorMessage(event.error, { modelId: config.modelId }),
);
}
break;
case "notice":
@@ -39,6 +39,36 @@ describe("shouldZeroClineFreeModelCost", () => {
);
});
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "cline-free/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "cline-free/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
+10
View File
@@ -46,6 +46,7 @@ describe("parseArgs", () => {
interactive: false,
outputMode: "text",
mode: "act",
modeExplicitlySet: false,
sandbox: false,
acpMode: false,
thinking: false,
@@ -220,6 +221,15 @@ describe("parseArgs", () => {
expect(parsedYolo.autoApproveOverride).toBe(true);
});
it("marks explicit mode flags so persisted settings do not override them", () => {
expect(parseArgs([]).modeExplicitlySet).toBe(false);
expect(parseArgs(["Audit the repo"]).modeExplicitlySet).toBe(false);
expect(parseArgs(["--plan"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--act"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--yolo"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--zen", "do it"]).modeExplicitlySet).toBe(true);
});
it("parses --zen flag for background hub dispatch", () => {
const parsedLong = parseArgs(["--zen", "do it"]);
expect(parsedLong.mode).toBe("zen");
+116
View File
@@ -0,0 +1,116 @@
import type { GlobalSettings } from "@cline/core";
import { describe, expect, it } from "vitest";
import {
resolveStartupCompactionMode,
resolveStartupMode,
resolveStartupToolAutoApprove,
} from "./startup-settings";
function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
autoUpdateEnabled: true,
telemetryOptOut: false,
...overrides,
};
}
describe("resolveStartupMode", () => {
it("uses the parsed default when nothing is persisted", () => {
expect(
resolveStartupMode(
{ mode: "act", modeExplicitlySet: false },
makeSettings(),
),
).toBe("act");
});
it("restores the persisted plan/act mode when no mode flag is provided", () => {
expect(
resolveStartupMode(
{ mode: "act", modeExplicitlySet: false },
makeSettings({ planActMode: "plan" }),
),
).toBe("plan");
});
it("prefers an explicit mode flag over the persisted mode", () => {
expect(
resolveStartupMode(
{ mode: "act", modeExplicitlySet: true },
makeSettings({ planActMode: "plan" }),
),
).toBe("act");
expect(
resolveStartupMode(
{ mode: "yolo", modeExplicitlySet: true },
makeSettings({ planActMode: "plan" }),
),
).toBe("yolo");
});
});
describe("resolveStartupToolAutoApprove", () => {
it("falls back to the built-in default when nothing is persisted", () => {
expect(resolveStartupToolAutoApprove({}, makeSettings(), true)).toBe(true);
});
it("restores the persisted auto-approve setting", () => {
expect(
resolveStartupToolAutoApprove(
{},
makeSettings({ toolAutoApprove: false }),
true,
),
).toBe(false);
});
it("prefers an explicit --auto-approve flag over the persisted setting", () => {
expect(
resolveStartupToolAutoApprove(
{ autoApproveOverride: true },
makeSettings({ toolAutoApprove: false }),
true,
),
).toBe(true);
expect(
resolveStartupToolAutoApprove(
{ autoApproveOverride: false },
makeSettings({ toolAutoApprove: true }),
true,
),
).toBe(false);
});
});
describe("resolveStartupCompactionMode", () => {
it("returns undefined so Core's default applies when nothing is persisted", () => {
expect(resolveStartupCompactionMode({}, makeSettings())).toBeUndefined();
});
it("restores the persisted strategy", () => {
expect(
resolveStartupCompactionMode(
{},
makeSettings({ compactionEnabled: true, compactionStrategy: "basic" }),
),
).toBe("basic");
});
it("restores the off state even when a strategy is retained on disk", () => {
expect(
resolveStartupCompactionMode(
{},
makeSettings({ compactionEnabled: false, compactionStrategy: "basic" }),
),
).toBe("off");
});
it("prefers an explicit --compaction flag over the persisted mode", () => {
expect(
resolveStartupCompactionMode(
{ compactionMode: "agentic" },
makeSettings({ compactionEnabled: false }),
),
).toBe("agentic");
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { GlobalSettings } from "@cline/core";
import type { CliAgentMode, CliCompactionMode, ParsedArgs } from "./types";
/**
* Resolves general settings at CLI startup with the precedence
* explicit CLI flag -> persisted global setting -> built-in default,
* so choices made in the TUI /settings panel survive restarts (see
* https://github.com/cline/cline/issues/12158).
*/
export function resolveStartupMode(
args: Pick<ParsedArgs, "mode" | "modeExplicitlySet">,
settings: GlobalSettings,
): CliAgentMode {
if (args.modeExplicitlySet) {
return args.mode;
}
return settings.planActMode ?? args.mode;
}
export function resolveStartupToolAutoApprove(
args: Pick<ParsedArgs, "autoApproveOverride">,
settings: GlobalSettings,
defaultToolAutoApprove: boolean,
): boolean {
return (
args.autoApproveOverride ??
settings.toolAutoApprove ??
defaultToolAutoApprove
);
}
/**
* Returns undefined when neither a flag nor a persisted value exists, so
* callers fall through to Core's compaction default.
*/
export function resolveStartupCompactionMode(
args: Pick<ParsedArgs, "compactionMode">,
settings: GlobalSettings,
): CliCompactionMode | undefined {
if (args.compactionMode) {
return args.compactionMode;
}
if (settings.compactionEnabled === false) {
return "off";
}
return settings.compactionStrategy;
}
+2
View File
@@ -71,6 +71,8 @@ export interface ParsedArgs {
interactive: boolean;
outputMode: CliOutputMode;
mode: CliAgentMode;
/** Whether a mode flag (--plan/--act/--yolo/--zen) was explicitly provided */
modeExplicitlySet?: boolean;
timeoutSeconds?: number;
invalidTimeoutSeconds?: string;
thinking: boolean;
+5 -3
View File
@@ -1,4 +1,5 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
@@ -135,10 +136,11 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
const mode = await p.select({
message: "Agent mode",
options: [
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
{ value: "act", label: "Act", hint: "execute tasks" },
{ value: "plan", label: "Plan", hint: "plan only" },
],
initialValue: "act",
initialValue: "yolo",
});
if (isCancel(mode)) return;
@@ -214,8 +216,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? "openai/gpt-5.3-codex",
mode: (mode as string) === "plan" ? "plan" : "act",
model: model ?? CLINE_DEFAULT_MODEL_ID,
mode: mode as "act" | "plan" | "yolo",
workspaceRoot: (workspace as string).trim(),
systemPrompt,
maxIterations,
+71 -2
View File
@@ -1,7 +1,33 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
import {
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
readConnectorCliLaunchSpec,
} from "@cline/shared";
import { afterEach, describe, expect, it } from "vitest";
import { __test__, configureConnectorCliLaunch } from "./connectors";
describe("connector launch command", () => {
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
afterEach(() => {
if (originalLaunchSpec === undefined) {
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
} else {
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
}
});
it("registers the CLI connect command for the detached daemon", () => {
const expected = __test__.buildCliConnectCommand([]);
configureConnectorCliLaunch();
expect(readConnectorCliLaunchSpec()).toEqual({
launcher: expected.launcher,
connectArgsPrefix: expected.childArgs,
cwd: expect.any(String),
});
});
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
@@ -94,4 +120,47 @@ describe("connector launch command", () => {
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
it("builds connector start arguments through the shared platform definition", () => {
expect(
__test__.buildConnectorStartArgs({
channel: "telegram",
values: { "-k": " 123456:token " },
security: {
enabled: true,
values: { userId: " 987654321 " },
},
}),
).toEqual([
"telegram",
"-k",
"123456:token",
"--allowed-user-id",
"987654321",
]);
});
it("uses the atomic restart command for an active connector", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "restart"),
).toEqual(["--restart", "telegram", "-k", "token"]);
});
it("starts an inactive connector directly", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "start"),
).toEqual(["telegram", "-k", "token"]);
});
it("rejects a channel-wide restart when multiple instances are active", () => {
expect(() => __test__.resolveConnectorLaunchMode("telegram", 2)).toThrow(
"cannot safely restart telegram: 2 instances are active",
);
});
it("rejects when connector readiness times out", async () => {
await expect(
__test__.waitForConnectorState(() => false, 0),
).rejects.toThrow("connector did not reach expected state within 0ms");
});
});
+67 -34
View File
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { listActiveConnectors } from "@cline/core";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
buildConnectorConnectArgs,
CONNECTOR_PLATFORMS,
listConnectorCatalog,
setConnectorCliLaunchSpec,
withResolvedClineBuildEnv,
} from "@cline/shared";
import type {
WebviewConnectorChannel,
WebviewConnectorChannelsResponse,
@@ -78,12 +79,21 @@ function buildCliConnectCommand(
return { launcher, childArgs };
}
export function configureConnectorCliLaunch(): void {
const command = buildCliConnectCommand([]);
setConnectorCliLaunchSpec({
launcher: command.launcher,
connectArgsPrefix: command.childArgs,
cwd: workspaceRoot,
});
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
(platform) => supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
@@ -154,12 +164,15 @@ async function waitForConnectorState(
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`connector did not reach expected state within ${timeoutMs}ms`,
);
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -177,32 +190,40 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
const securityInput = asRecord(args?.security);
const rawSecurityValues = asRecord(securityInput?.values) ?? {};
const securityValues: Record<string, string> = {};
for (const [key, value] of Object.entries(rawSecurityValues)) {
if (typeof value === "string") {
securityValues[key] = value.trim();
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
return [
channel,
...buildConnectorConnectArgs(platform, fieldValues, {
enabled: securityInput?.enabled === true,
values: securityValues,
}),
];
}
function buildConnectorLaunchArgs(
cliArgs: string[],
mode: "start" | "restart",
): string[] {
return mode === "restart" ? ["--restart", ...cliArgs] : cliArgs;
}
function resolveConnectorLaunchMode(
channel: string,
activeCount: number,
): "start" | "restart" {
if (activeCount > 1) {
throw new Error(
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
);
}
return cliArgs;
return activeCount === 1 ? "restart" : "start";
}
export async function startConnectorChannel(
@@ -210,7 +231,13 @@ export async function startConnectorChannel(
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(cliArgs);
const activeCount = listActiveConnectors().filter(
(connector) => connector.type === channel,
).length;
const mode = resolveConnectorLaunchMode(channel, activeCount);
const result = await runCliConnectCommand(
buildConnectorLaunchArgs(cliArgs, mode),
);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
@@ -227,7 +254,11 @@ export async function startConnectorChannel(
export const __test__ = {
buildCliConnectCommand,
buildConnectorLaunchArgs,
buildConnectorStartArgs,
normalizeConnectorError,
resolveConnectorLaunchMode,
waitForConnectorState,
};
export async function stopConnectorChannel(
@@ -241,7 +272,9 @@ export async function stopConnectorChannel(
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand([channel, "--stop"]);
// `--stop` must precede the channel: the connect command uses
// passThroughOptions, so flags after the channel go to the adapter.
const result = await runCliConnectCommand(["--stop", channel]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
+2
View File
@@ -12,6 +12,7 @@ import {
rejectAllPendingApprovals,
requestToolApprovalFromWebview,
} from "./approvals";
import { configureConnectorCliLaunch } from "./connectors";
import { workspaceRoot } from "./deps";
import {
formatClientName,
@@ -90,6 +91,7 @@ export async function syncHubClientsAndSessions(
}
export async function attachHub(ctx: HubContext): Promise<void> {
configureConnectorCliLaunch();
const hub = await ensureDetachedHubServer(workspaceRoot);
ctx.hubUrl = hub.url;
ctx.hubAuthToken = hub.authToken;
+46 -23
View File
@@ -3,6 +3,12 @@ import {
HubScheduleCommandService,
HubScheduleService,
} from "@cline/core";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
readHubScheduleMode,
} from "@cline/shared";
import { asTrimmedString, toPositiveInt } from "./utils";
let scheduleService: HubScheduleService | undefined;
@@ -36,6 +42,31 @@ async function clientCommand(
return (reply.payload ?? {}) as Record<string, unknown>;
}
function routineScheduleTiming(
args?: Record<string, unknown>,
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
if (args?.schedule_type === "once") {
const runAt =
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
return Number.isFinite(runAt)
? {
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
}
: undefined;
}
const cronPattern = asTrimmedString(args?.cron_pattern);
return cronPattern ? { cronPattern } : undefined;
}
function asTrimmedStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const values = value
.map((item) => asTrimmedString(item))
.filter((item): item is string => item !== undefined);
return values.length > 0 ? values : undefined;
}
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
@@ -76,23 +107,23 @@ export async function handleRoutineScheduleCommand(
if (command === "create_routine_schedule") {
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const timing = routineScheduleTiming(args);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
throw new Error(
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
"createSchedule requires name, timing, prompt, and workspace_root",
);
}
const created = await clientCommand("schedule.create", {
name,
cronPattern,
...timing,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
},
mode: args?.mode === "plan" ? "plan" : "act",
mode: readHubScheduleMode(args, "yolo"),
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd),
systemPrompt: asTrimmedString(args?.system_prompt),
@@ -100,12 +131,7 @@ export async function handleRoutineScheduleCommand(
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags:
Array.isArray(args?.tags) && args.tags.length > 0
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: undefined,
tags: asTrimmedStringArray(args?.tags),
});
return { schedule: created.schedule ?? null };
}
@@ -113,25 +139,26 @@ export async function handleRoutineScheduleCommand(
const scheduleId = asTrimmedString(args?.schedule_id);
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
if (command === "update_routine_schedule") {
const mode = readHubScheduleMode(args);
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const timing = routineScheduleTiming(args);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
throw new Error(
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
);
}
const reply = await clientCommand("schedule.update", {
scheduleId,
name,
cronPattern,
...timing,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
},
mode: args?.mode === "plan" ? "plan" : "act",
...(mode === undefined ? {} : { mode }),
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd) ?? null,
systemPrompt:
@@ -148,11 +175,7 @@ export async function handleRoutineScheduleCommand(
: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags: Array.isArray(args?.tags)
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: [],
tags: asTrimmedStringArray(args?.tags) ?? [],
});
return { schedule: reply.schedule ?? null };
}
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
if (
normalizedType === "code-sidecar" ||
normalizedType === "code-sidecar-approvals" ||
normalizedType === "code-sidecar-observer" ||
normalizedType === "code-sidecar-list"
) {
return { key: "code-app", label: "Code App", name: "Code App" };
+1 -1
View File
@@ -1,4 +1,4 @@
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { listActiveConnectors } from "@cline/core";
import type { WebviewHubState } from "../webview-protocol";
import {
clientSummariesPayload,
+2 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@cline/shared": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
@@ -26,7 +27,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "^11.15.0",
"mermaid": "11.16.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
@@ -1,5 +1,10 @@
"use client";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
} from "@cline/shared";
import {
Circle,
Eye,
@@ -72,6 +77,7 @@ interface RoutineSchedule {
scheduleId: string;
name: string;
cronPattern: string;
metadata?: Record<string, unknown>;
prompt: string;
provider?: string;
model?: string;
@@ -145,7 +151,7 @@ interface ProcessContext {
}
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
cline: [CLINE_DEFAULT_MODEL_ID],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.3-codex"],
openrouter: ["anthropic/claude-sonnet-4.6"],
@@ -170,13 +176,9 @@ interface RoutineFormState {
prompt: string;
provider: string;
model: string;
mode: "act" | "plan";
workspaceRoot: string;
cwd: string;
systemPrompt: string;
maxIterations: string;
timeoutSeconds: string;
maxParallel: string;
tags: string;
enabled: boolean;
}
@@ -203,6 +205,15 @@ function formatDateTime(value?: DateTimeValue | null): string {
return parsed.toLocaleString();
}
function getOneTimeScheduleRunAt(
schedule: RoutineSchedule,
): number | undefined {
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
return typeof runAt === "number" && Number.isFinite(runAt)
? runAt
: undefined;
}
function formatScheduleModel(schedule: RoutineSchedule): string {
const provider =
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
@@ -226,7 +237,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
model:
schedule.modelSelection?.modelId?.trim() ||
schedule.model?.trim() ||
"openai/gpt-5.3-codex",
CLINE_DEFAULT_MODEL_ID,
};
}
@@ -241,20 +252,27 @@ function formatExecutionResult(execution?: RoutineExecution): string {
return when === "-" ? status : `${status} at ${when}`;
}
function parseOptionalPositiveInt(text: string): number | undefined {
const trimmed = text.trim();
function asTrimmedFormString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function parseOptionalPositiveInt(value: unknown): number | undefined {
const trimmed = asTrimmedFormString(value);
if (!trimmed) {
return undefined;
}
const value = Number.parseInt(trimmed, 10);
if (!Number.isFinite(value) || value <= 0) {
const parsedValue = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
return undefined;
}
return value;
return parsedValue;
}
function parseTags(text: string): string[] | undefined {
const tags = text
function parseTags(value: unknown): string[] | undefined {
if (typeof value !== "string") {
return undefined;
}
const tags = value
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
@@ -382,14 +400,10 @@ export function RoutineSchedulesContent() {
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
prompt: "Review PRs opened yesterday and summarize issues.",
provider: "cline",
model: "openai/gpt-5.3-codex",
mode: "act",
model: CLINE_DEFAULT_MODEL_ID,
workspaceRoot: "",
cwd: "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -702,13 +716,9 @@ export function RoutineSchedulesContent() {
prompt: "Review PRs opened yesterday and summarize issues.",
provider: preferredProvider,
model: preferredModel,
mode: "act",
workspaceRoot: context.workspaceRoot || context.cwd,
cwd: context.cwd || "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -716,6 +726,9 @@ export function RoutineSchedulesContent() {
};
const openEditDialog = (schedule: RoutineSchedule) => {
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
return;
}
const { provider, model } = getScheduleProviderModel(schedule);
const parsedCron = parseCronPattern(schedule.cronPattern);
setEditingSchedule(schedule);
@@ -738,22 +751,12 @@ export function RoutineSchedulesContent() {
prompt: schedule.prompt,
provider,
model,
mode: schedule.mode === "plan" ? "plan" : "act",
workspaceRoot: schedule.workspaceRoot ?? "",
cwd: schedule.cwd ?? "",
systemPrompt: schedule.systemPrompt ?? "",
maxIterations:
typeof schedule.maxIterations === "number"
? String(schedule.maxIterations)
: "",
timeoutSeconds:
typeof schedule.timeoutSeconds === "number"
? String(schedule.timeoutSeconds)
: "",
maxParallel:
typeof schedule.maxParallel === "number"
? String(schedule.maxParallel)
: "1",
tags: schedule.tags?.join(",") ?? "",
enabled: schedule.enabled,
});
@@ -761,7 +764,7 @@ export function RoutineSchedulesContent() {
};
const submitCreateForm = async () => {
const name = createForm.name.trim();
const name = asTrimmedFormString(createForm.name);
if (!name) {
setCreateFormError("Routine name is required.");
return;
@@ -775,12 +778,12 @@ export function RoutineSchedulesContent() {
setCreateFormError("Select at least one day and a valid time.");
return;
}
const prompt = createForm.prompt.trim();
const prompt = asTrimmedFormString(createForm.prompt);
if (!prompt) {
setCreateFormError("Prompt is required.");
return;
}
const workspaceRoot = createForm.workspaceRoot.trim();
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
if (!workspaceRoot) {
setCreateFormError("Workspace root is required.");
return;
@@ -789,18 +792,17 @@ export function RoutineSchedulesContent() {
setIsCreating(true);
try {
const provider =
normalizeProviderId(createForm.provider) ||
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
availableProviders[0] ||
"cline";
const model =
createForm.model.trim() ||
asTrimmedFormString(createForm.model) ||
(visibleProviderModels[provider] ?? [])[0] ||
"openai/gpt-5.3-codex";
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
CLINE_DEFAULT_MODEL_ID;
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
const timeoutSeconds = parseOptionalPositiveInt(
createForm.timeoutSeconds,
);
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
const tags = parseTags(createForm.tags);
const command = editingSchedule
? "update_routine_schedule"
@@ -814,19 +816,16 @@ export function RoutineSchedulesContent() {
prompt,
provider,
model,
mode: createForm.mode,
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
workspace_root: workspaceRoot,
cwd: createForm.cwd.trim() || undefined,
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
system_prompt: editingSchedule
? createForm.systemPrompt.trim() || null
: createForm.systemPrompt.trim() || undefined,
max_iterations: editingSchedule
? (maxIterations ?? null)
: maxIterations,
? systemPrompt || null
: systemPrompt || undefined,
timeout_seconds: editingSchedule
? (timeoutSeconds ?? null)
: timeoutSeconds,
max_parallel: maxParallel,
max_parallel: 1,
enabled: createForm.enabled,
tags: tags ?? [],
});
@@ -948,7 +947,9 @@ export function RoutineSchedulesContent() {
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
: schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
@@ -967,7 +968,10 @@ export function RoutineSchedulesContent() {
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
disabled={
isBusy ||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
@@ -1373,27 +1377,6 @@ export function RoutineSchedulesContent() {
</Combobox>
</div>
<div>
<Label>Mode</Label>
<Select
value={createForm.mode}
onValueChange={(value) =>
setCreateForm((prev) => ({
...prev,
mode: value === "plan" ? "plan" : "act",
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="act">act</SelectItem>
<SelectItem value="plan">plan</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-workspace">Workspace root</Label>
<Input
@@ -1408,20 +1391,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-cwd">CWD (optional)</Label>
<Input
id="routine-cwd"
value={createForm.cwd}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
cwd: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-system-prompt">
System prompt (optional)
@@ -1439,23 +1408,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-iterations">
Max iterations (optional)
</Label>
<Input
id="routine-max-iterations"
value={createForm.maxIterations}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxIterations: event.target.value,
}))
}
placeholder="50"
/>
</div>
<div>
<Label htmlFor="routine-timeout">
Timeout seconds (optional)
@@ -1473,21 +1425,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-parallel">Max parallel</Label>
<Input
id="routine-max-parallel"
value={createForm.maxParallel}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxParallel: event.target.value,
}))
}
placeholder="1"
/>
</div>
<div>
<Label htmlFor="routine-tags">
Tags (comma-separated, optional)
+59
View File
@@ -0,0 +1,59 @@
# Cline Code Desktop Changelog
## 0.0.7
- New system tray icon showing app status and how many agent sessions are currently running.
- Session history is now paginated in ten-session pages, fetching older history only when you reach the end.
- You can favorite sessions, and sessions are now ordered by most recent activity with consistent status dot colors across views.
- Subagent and teammate runs from a session now show up in the app with their status and results.
- Chat polish: tool-specific icons on tool disclosures, elapsed thinking time and restyled reasoning sections, aligned timestamps, and message actions that no longer shift the layout while scrolling stays anchored to the conversation viewport.
- Free Cline models are now supported and labeled "(free)" in model pickers, with a clear message — including reset time — when you hit the free-tier limit.
- Fixed the China/international endpoint toggles for Qwen, Moonshot, Z AI, and MiniMax being ignored, which silently routed regional users to the wrong host.
- Fixed tool calls failing when a model emitted a line number as a string (e.g. `insert_line: "3"`), forcing the agent to waste a round trip retrying.
- Refreshed the bundled provider and model catalog.
## 0.0.6
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
- New sidebar update indicator: once an update has been downloaded, an accent-colored icon stays in the sidebar showing the new version with a one-click restart, so the update is still reachable after you dismiss the toast (and restart failures are now surfaced instead of silently doing nothing).
- No more appearance flash on launch — the app paints in your saved (or system) light/dark theme before the first frame.
- The header and sidebar now show the full workspace name and git branch, and lay out correctly on narrow windows; a transient git lookup no longer wipes a valid branch name back to "no git".
- Cleaner collapsed-sidebar settings layout: compact width, left-aligned navigation, stacked account details.
- Clarified the auto-update setting — it's now "Keep CLI up to date" and explains that it governs the `cline` terminal command, not the app itself (the app updates separately).
- Toggle switches now use a solid accent color when on, for clearer contrast.
## 0.0.5
- Major performance overhaul: the app now feels snappy end-to-end. The animated background renders at a locked 60fps instead of ~10fps, typing in the composer no longer stutters (245 slow keystrokes → 3), streaming responses coalesce updates instead of re-rendering the whole chat per token, and app boot fetches the provider catalog once instead of three times.
- The native folder picker and command execution no longer freeze the app while the sidecar writes session logs or discovers your editor.
- Fixed the composer getting stuck on "Agent is working..." after queued turns finished.
- Added a Cline API key path to onboarding, and you can now cancel a pending browser sign-in instead of being stuck waiting for it.
- Fixed window dragging.
- MCP server cards are now consistent across marketplace views, with a single uninstall action and setup guidance shown on installed servers.
- Fixed agentic compaction silently falling back to basic compaction for OpenAI-Compatible providers, and manual /compact never actually reaching the model when auto-compaction was off.
## 0.0.4
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
- New first-run onboarding flow to get you set up on launch.
- Drag and drop files directly onto the chat to attach them.
- Image attachments now display inline in the chat transcript.
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
- New custom overlay title bar with in-app navigation.
- Redesigned channel setup as expandable cards.
- Added a setting to replay the new-user experience.
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
- Headless routines now default to YOLO mode so they can run unattended.
- Fixed request metering for the SAP AI Core provider.
## 0.0.3
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
## 0.0.2
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
- Automatic updates: the app checks on launch and every 2 hours, downloads new versions in the background, and prompts for a one-click restart. Ignored updates apply on the next launch.
- Download the DMG once from GitHub Releases — every future release arrives automatically.
+52 -5
View File
@@ -16,6 +16,20 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
agent-run commands would miss Homebrew-installed tools like `gh` even though
they work fine from a terminal. At startup the sidecar asks the user's login
shell — read from the account database via `getpwuid`, falling back to
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
@@ -25,7 +39,21 @@ Tailwind adapter and shared base styles without depending on the desktop
runtime. See [`webview/styles/README.md`](./webview/styles/README.md) for the
desktop integration notes.
## Shareable Desktop Packages
## Releases & Auto-Updates
Releases are built, signed, notarized, and published by the `desktop-publish`
GitHub workflow. The step-by-step flow (version bumps, changelog, tag, repo
secrets) lives in the `publish-desktop` skill
(`.cline/skills/publish-desktop/SKILL.md`).
Installed apps auto-update via the Tauri updater: they poll the rolling
`desktop-latest` release's `latest.json` on launch and every 2 hours, install
updates in the background, and prompt for a restart. Two things must never be
lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
shipped apps can't verify new updates).
## Shareable Desktop Packages (manual fallback)
Tauri desktop bundles are OS-specific, so build each package on the target OS:
@@ -72,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
Startup flow:
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
2. The desktop backend starts the Bun sidecar, which discovers or starts the
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
for desktop commands, queries, and pushed events.
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
4. Tool approval updates are pushed from the backend instead of polled from the UI.
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
@@ -93,8 +123,8 @@ Desktop transport envelope:
## Key Files
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
@@ -108,11 +138,28 @@ Desktop transport envelope:
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
- Full v1 schema for the persisted messages file, including failure/retry semantics and golden fixtures, is documented in [`packages/core/docs/messages-contract-v1.md`](../../../sdk/packages/core/docs/messages-contract-v1.md).
## Sidecar observability
The desktop sidecar sends SDK telemetry through the same configured OpenTelemetry
pipeline used by the CLI and writes structured runtime logs to
`~/.cline/data/logs/code.log` by default. Telemetry continues to honor the global
opt-out setting exposed in the desktop settings UI. The sidecar truncates stale
logs and rotates the active file before it exceeds 50 MiB.
Logging can be configured with the same environment variables as the CLI:
- `CLINE_LOG_ENABLED=0` disables file logging.
- `CLINE_LOG_LEVEL` sets the Pino level (for example, `debug` or `warn`).
- `CLINE_LOG_PATH` overrides the log destination.
- `CLINE_LOG_NAME` overrides the logger name.
## Troubleshooting
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
The next desktop or CLI Hub connection will reuse a compatible running Hub or
replace an incompatible one through the shared discovery path.
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
+8 -1
View File
@@ -1,11 +1,14 @@
{
"name": "@cline/code",
"version": "0.0.1",
"version": "0.0.7",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
"predev:web": "bun run build:ui",
"dev:web": "next dev webview -p 3125 --turbo",
"dev:sidecar": "bun run sidecar/index.ts",
"dev": "tauri dev",
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
@@ -16,7 +19,10 @@
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"pretypecheck": "bun run build:ui",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -70,6 +76,7 @@
"lucide-react": "^0.564.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -17,13 +17,33 @@ const resolveTargetTriple = async (): Promise<string> => {
return host;
};
// Bun cross-compiles --compile binaries, so a CI runner can produce the
// sidecar for a different architecture than its own (e.g. the x86_64 macOS
// bundle from an arm64 runner). Without an explicit --target, bun always
// emits a host-arch binary even when Tauri is building for another triple.
const resolveBunCompileTarget = (targetTriple: string): string | undefined => {
if (targetTriple.startsWith("aarch64-apple-darwin"))
return "bun-darwin-arm64";
if (targetTriple.startsWith("x86_64-apple-darwin")) return "bun-darwin-x64";
if (targetTriple.startsWith("x86_64-pc-windows")) return "bun-windows-x64";
if (targetTriple.startsWith("x86_64-unknown-linux")) return "bun-linux-x64";
if (targetTriple.startsWith("aarch64-unknown-linux"))
return "bun-linux-arm64";
return undefined;
};
const main = async () => {
const targetTriple = await resolveTargetTriple();
const extension = targetTriple.includes("windows") ? ".exe" : "";
const outfile = `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
const bunTarget = resolveBunCompileTarget(targetTriple);
await $`mkdir -p src-tauri/bin`;
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile --outfile ${outfile}`;
}
};
main().catch((error: unknown) => {
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildUpdateManifest } from "./generate-update-manifest";
const makeArtifactDir = (): string => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz.sig"),
"sig-aarch64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz"), "tar");
writeFileSync(
path.join(dir, "Cline-Code_0.1.0_x86_64.app.tar.gz.sig"),
"sig-x86_64\n",
);
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
return dir;
};
describe("buildUpdateManifest", () => {
test("maps updater artifacts to darwin platform entries", () => {
const dir = makeArtifactDir();
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.version).toBe("0.1.0");
expect(manifest.platforms["darwin-aarch64"]).toEqual({
signature: "sig-aarch64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_aarch64.app.tar.gz",
});
expect(manifest.platforms["darwin-x86_64"]).toEqual({
signature: "sig-x86_64",
url: "https://github.com/cline/cline/releases/download/desktop-v0.1.0/Cline-Code_0.1.0_x86_64.app.tar.gz",
});
// The DMG is a first-install artifact, not an updater artifact.
expect(Object.keys(manifest.platforms)).toHaveLength(2);
});
test("throws when a signature file is missing", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.app.tar.gz"), "tar");
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 no updater artifacts exist", () => {
const dir = mkdtempSync(path.join(tmpdir(), "update-manifest-"));
writeFileSync(path.join(dir, "Cline-Code_0.1.0_aarch64.dmg"), "dmg");
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(/no updater artifacts/);
});
});
@@ -0,0 +1,133 @@
// Generates the Tauri updater manifest (latest.json) from the updater
// artifacts produced by the desktop-publish workflow. The manifest is uploaded
// to the rolling `desktop-latest` GitHub release, which is the static endpoint
// configured in src-tauri/tauri.conf.json; its platform URLs point back at the
// immutable per-version release assets.
//
// Usage:
// bun scripts/generate-update-manifest.ts \
// --version 0.1.0 --tag desktop-v0.1.0 --dir dist/desktop \
// --out dist/desktop/latest.json [--repo cline/cline] [--notes-file notes.md]
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
type UpdaterPlatformEntry = {
signature: string;
url: string;
};
export type UpdateManifest = {
version: string;
notes: string;
pub_date: string;
platforms: Record<string, UpdaterPlatformEntry>;
};
// Maps the arch token embedded in artifact file names (see the "Collect
// artifacts" workflow step) to the platform keys the Tauri updater requests.
const PLATFORM_KEY_BY_ARCH_SUFFIX: Record<string, string> = {
aarch64: "darwin-aarch64",
x86_64: "darwin-x86_64",
};
const getArgValue = (args: string[], name: string): string | undefined => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
return args[index + 1];
}
const prefix = `${name}=`;
const inline = args.find((arg) => arg.startsWith(prefix));
return inline?.slice(prefix.length);
};
const archOfUpdaterArtifact = (fileName: string): string | undefined => {
if (!fileName.endsWith(".app.tar.gz")) {
return undefined;
}
return Object.keys(PLATFORM_KEY_BY_ARCH_SUFFIX).find((arch) =>
fileName.includes(`_${arch}`),
);
};
export const buildUpdateManifest = (options: {
version: string;
tag: string;
dir: string;
repo: string;
notes: string;
pubDate: string;
}): UpdateManifest => {
const platforms: Record<string, UpdaterPlatformEntry> = {};
for (const fileName of readdirSync(options.dir).sort()) {
const arch = archOfUpdaterArtifact(fileName);
if (!arch) {
continue;
}
const signaturePath = path.join(options.dir, `${fileName}.sig`);
const signature = readFileSync(signaturePath, "utf8").trim();
if (!signature) {
throw new Error(`empty updater signature at ${signaturePath}`);
}
platforms[PLATFORM_KEY_BY_ARCH_SUFFIX[arch]] = {
signature,
url: `https://github.com/${options.repo}/releases/download/${options.tag}/${encodeURIComponent(fileName)}`,
};
}
if (Object.keys(platforms).length === 0) {
throw new Error(
`no updater artifacts (*.app.tar.gz with a known arch suffix) found in ${options.dir}`,
);
}
return {
version: options.version,
notes: options.notes,
pub_date: options.pubDate,
platforms,
};
};
const main = () => {
const args = process.argv.slice(2);
const version = getArgValue(args, "--version");
const tag = getArgValue(args, "--tag");
const dir = getArgValue(args, "--dir");
const out = getArgValue(args, "--out");
const repo =
getArgValue(args, "--repo") ??
process.env.GITHUB_REPOSITORY ??
"cline/cline";
const notesFile = getArgValue(args, "--notes-file");
if (!version || !tag || !dir || !out) {
throw new Error(
"usage: generate-update-manifest.ts --version X.Y.Z --tag desktop-vX.Y.Z --dir <artifact dir> --out <latest.json> [--repo owner/repo] [--notes-file <file>]",
);
}
const notes = notesFile
? readFileSync(notesFile, "utf8").trim()
: `Cline Code v${version}`;
const manifest = buildUpdateManifest({
version,
tag,
dir,
repo,
notes,
pubDate: new Date().toISOString(),
});
writeFileSync(out, `${JSON.stringify(manifest, null, "\t")}\n`);
console.log(`wrote ${out}`);
for (const [platform, entry] of Object.entries(manifest.platforms)) {
console.log(`- ${platform}: ${entry.url}`);
}
};
if (import.meta.main) {
main();
}
@@ -2,9 +2,12 @@
## Overview
The sidecar is a single Bun process that handles the desktop backend runtime directly.
The sidecar is a Bun process that adapts the desktop UI and native operations to
the shared Cline Hub.
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
sidecar does not own a private agent runtime Hub.
## Directory Structure
@@ -14,7 +17,7 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # In-process chat session management
├── chat-session.ts # Shared-Hub chat session adapter
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
## Key Design Decisions
### 1. Chat Sessions — In-Process via LocalRuntimeHost
### 1. Chat Sessions — Shared Hub Client
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
the same compatible Hub discovered by the CLI or starts the canonical detached
Hub when the desktop is the first client:
```typescript
import { LocalRuntimeHost } from "@cline/core";
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
hub: {
strategy: "require-hub",
workspaceRoot,
cwd: workspaceRoot,
clientType: "code-sidecar",
displayName: "Code App sidecar",
},
capabilities: {
requestToolApproval: async (request) => {
// Push approval request to frontend via WebSocket event
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
});
```
### 2. Tool Approval — In-Memory Promise Resolution
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
No more file-system watchers. Tool approvals use in-memory promise maps:
### 2. Tool Approval — Client-Owned Promise Resolution
The shared Hub routes approval requests back to the client that created the
session. Desktop approvals use in-memory promise maps while the webview is
online:
```typescript
const pendingApprovals = new Map<string, {
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
### 5. Routine Schedules — Direct Hub Commands
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
Routine operations use the same connected Hub client as chat session
observation. They never start a second in-process Hub:
```typescript
import { ensureHubServer, sendHubCommand } from "@cline/core";
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
await ctx.hubClient.command("schedule.list", { limit: 200 });
```
### 6. Native Commands
@@ -122,7 +138,7 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | `LocalRuntimeHost` in-process |
| `chat_session_command` | shared Hub through `ClineCore` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_provider_settings` | `saveLocalProviderSettings` |
@@ -137,14 +153,14 @@ Supported commands:
| `list_mcp_servers` | Direct file I/O |
| `upsert_mcp_server` | Direct file I/O |
| `delete_mcp_server` | Direct file I/O |
| `get_git_branch` | `execFileSync("git", ...)` |
| `list_git_branches` | `execFileSync("git", ...)` |
| `checkout_git_branch` | `execFileSync("git", ...)` |
| `get_git_branch` | async `execFile("git", ...)` |
| `list_git_branches` | async `execFile("git", ...)` |
| `checkout_git_branch` | async `execFile("git", ...)` |
| `search_workspace_files` | `getFileIndex` |
| `get_process_context` | In-memory context |
| `poll_tool_approvals` | In-memory pending map |
| `respond_tool_approval` | In-memory promise resolution |
| `list_routine_schedules` | local hub schedule commands |
| `list_routine_schedules` | shared Hub schedule commands |
| `list_user_instruction_configs` | Direct core API |
| `pick_workspace_directory` | OS native dialog |
| `open_mcp_settings_file` | OS `open` command |
@@ -0,0 +1,158 @@
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
materializeUserFiles,
reconcileQueuedAttachments,
sessionAttachmentsDir,
trackQueuedAttachments,
} from "./attachments";
import type { LiveSession } from "./types";
const sessionId = "attachment-test-session";
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
function createSession(): LiveSession {
return {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
};
}
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
describe("materialized attachment lifecycle", () => {
it("only deletes files inside the session attachments dir", () => {
const [staged] = materializeUserFiles(sessionId, [
{ name: "notes.txt", content: "hello" },
]) as string[];
const outside = join(testSessionDataDir, "outside.txt");
writeFileSync(outside, "keep me", "utf8");
deleteMaterializedAttachments(sessionId, [staged, outside]);
expect(existsSync(staged)).toBe(false);
expect(existsSync(outside)).toBe(true);
});
it("deletes consumed files when the turn ends", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
expect(existsSync(files[0] as string)).toBe(true);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(false);
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
});
it("keeps files for a submitted prompt that gets requeued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
// Drain send failed → prompt is back in the queue snapshot.
reconcileQueuedAttachments(session, ["pending_1"]);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(true);
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
});
it("tracks files as consumed when the prompt is no longer queued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(session, [], files);
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
expect(session.consumedAttachmentFiles?.size).toBe(1);
});
it("discards all tracked files on session end", () => {
const session = createSession();
const queued = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const consumed = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: queued,
},
],
queued,
);
trackQueuedAttachments(session, [], consumed);
discardAllTrackedAttachments(sessionId, session);
expect(existsSync(queued[0] as string)).toBe(false);
expect(existsSync(consumed[0] as string)).toBe(false);
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
});
});
@@ -0,0 +1,157 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve, sep } from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { SessionPendingPrompt } from "@cline/core";
import { sharedSessionDataDir } from "./paths";
import type { ChatTurnAttachments, LiveSession } from "./types";
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.queuedAttachmentFiles) {
session.queuedAttachmentFiles = new Map();
}
return session.queuedAttachmentFiles;
}
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.consumedAttachmentFiles) {
session.consumedAttachmentFiles = new Map();
}
return session.consumedAttachmentFiles;
}
// ---------------------------------------------------------------------------
// Materialized user-attachment lifecycle
//
// Non-image attachments arrive from the webview as inline content and are
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
// load them by path at turn start. The sidecar owns these files and must
// delete them once consumed (turn completed) or discarded (queued prompt
// removed / session ended) — otherwise user data accumulates on disk.
// ---------------------------------------------------------------------------
export function sessionAttachmentsDir(sessionId: string): string {
return join(sharedSessionDataDir(), sessionId, "user-attachments");
}
export function materializeUserFiles(
sessionId: string,
files: ChatTurnAttachments["userFiles"],
): string[] | undefined {
if (!files?.length) {
return undefined;
}
const attachmentDir = sessionAttachmentsDir(sessionId);
mkdirSync(attachmentDir, { recursive: true });
return files.map((file) => {
const requestedName = basename(file.name.trim());
const safeName =
requestedName && requestedName !== "." && requestedName !== ".."
? requestedName
: "attachment.txt";
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
writeFileSync(path, file.content, "utf8");
return path;
});
}
/**
* Delete materialized attachment files. Only paths inside the session's
* user-attachments directory are removed, so files referenced from elsewhere
* (e.g. `@`-mentions) are never touched.
*/
export function deleteMaterializedAttachments(
sessionId: string,
paths: string[] | undefined,
): void {
if (!paths?.length) return;
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
for (const path of paths) {
if (!resolve(path).startsWith(attachmentDir)) continue;
try {
rmSync(path, { force: true });
} catch {
// Best-effort cleanup; leftover files are removed with the session dir.
}
}
}
/**
* Track files staged for a queued/steered prompt so they can be deleted once
* the prompt is consumed or discarded. If the prompt is no longer in the
* queue (already submitted), the files are tracked as consumed and deleted
* when the running turn finishes.
*/
export function trackQueuedAttachments(
session: LiveSession | undefined,
prompts: SessionPendingPrompt[],
userFiles: string[] | undefined,
): void {
if (!session || !userFiles?.length) return;
const match = prompts.find((prompt) =>
isDeepStrictEqual(prompt.userFiles, userFiles),
);
if (match) {
queuedFilesMap(session).set(match.id, userFiles);
} else {
// Not in the queue → already being consumed by the running turn. Key by a
// fresh id so it never collides with a prompt-id key used elsewhere in the
// consumed bucket.
consumedFilesMap(session).set(randomUUID(), userFiles);
}
}
/** Move a submitted queued prompt's files into the consumed bucket. */
export function markQueuedAttachmentsSubmitted(
session: LiveSession | undefined,
promptId: string,
): void {
const files = session?.queuedAttachmentFiles?.get(promptId);
if (!session || !files) return;
session.queuedAttachmentFiles?.delete(promptId);
consumedFilesMap(session).set(promptId, files);
}
/**
* A prompt id reappearing in the queue means a submitted prompt was requeued
* (e.g. the drain send failed) move its files back to the queued bucket so
* the turn-end flush does not delete files still pending consumption.
*/
export function reconcileQueuedAttachments(
session: LiveSession | undefined,
queuedPromptIds: string[],
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const id of queuedPromptIds) {
const files = session.consumedAttachmentFiles.get(id);
if (!files) continue;
session.consumedAttachmentFiles.delete(id);
queuedFilesMap(session).set(id, files);
}
}
/** Delete files for prompts whose turn has finished. */
export function flushConsumedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const files of session.consumedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.consumedAttachmentFiles.clear();
}
/** Delete every tracked file for a session (queued prompts are discarded). */
export function discardAllTrackedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session) return;
flushConsumedAttachments(sessionId, session);
if (!session.queuedAttachmentFiles?.size) return;
for (const files of session.queuedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.queuedAttachmentFiles.clear();
}
@@ -1,8 +1,14 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
prewarmWorkspaceMetadata,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
@@ -87,6 +93,87 @@ describe("shouldUpdateSessionConnection", () => {
});
});
describe("hasProviderChanged", () => {
it("distinguishes provider switches from model switches", () => {
expect(
hasProviderChanged(
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
{ provider: "openai-codex", model: "gpt-5.3-codex" },
),
).toBe(true);
expect(
hasProviderChanged(
{ provider: "cline", model: "anthropic/claude-sonnet-4.6" },
{ provider: "cline", model: "openai/gpt-5.3-codex" },
),
).toBe(false);
});
it("honors a providerId-only update when the stored config uses provider", () => {
const current = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
};
const update = {
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
};
expect(hasProviderChanged(current, update)).toBe(true);
expect(mergeSessionConfig(current, update)).toMatchObject({
provider: "openai-codex",
providerId: "openai-codex",
model: "gpt-5.3-codex",
modelId: "gpt-5.3-codex",
});
});
});
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const ctx = {
liveSessions: new Map(),
sessionManager: { start },
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
enableTools: true,
},
})) as {
sessionId: string;
cwd: string;
workspaceRoot: string;
};
expect(result).toEqual({
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
@@ -100,12 +187,19 @@ describe("first-send connection updates", () => {
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
const send = vi.fn(async (_input?: unknown) => ({
text: "done",
finishReason: "completed",
messages: [],
}));
const readMessages = vi.fn(async () => [
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
const readSessionCompactionState = vi.fn(async () => undefined);
const stop = vi.fn(async () => undefined);
const sessionId = "session-connection-test";
const start = vi.fn(async (_input?: unknown) => ({ sessionId }));
const ctx = {
liveSessions: new Map([
[
@@ -121,9 +215,29 @@ describe("first-send connection updates", () => {
},
],
]),
sessionManager: { send, updateSessionConnection },
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
},
},
} as unknown as SidecarContext;
return { ctx, send, sessionId, updateSessionConnection };
return {
ctx,
readMessages,
send,
sessionId,
start,
stop,
updateSessionConnection,
};
}
it("skips an identical update for a locally-created session", async () => {
@@ -140,6 +254,266 @@ describe("first-send connection updates", () => {
expect(send).toHaveBeenCalledTimes(1);
});
it("allows an image-only user turn", async () => {
const { ctx, send, sessionId } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
attachments: {
userImages: ["data:image/png;base64,aGVsbG8="],
userFiles: [],
},
});
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery: undefined,
userImages: ["data:image/png;base64,aGVsbG8="],
});
});
it.each([
undefined,
"queue",
] as const)("forwards file attachments for %s delivery", async (delivery) => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
);
let sentFileContent: string | undefined;
send.mockImplementation(async (input?: unknown) => {
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
if (files?.[0]) {
sentFileContent = readFileSync(files[0], "utf8");
}
return { text: "done", finishReason: "completed", messages: [] };
});
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
delivery,
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const input = send.mock.calls[0]?.[0] as
| { userFiles?: string[] }
| undefined;
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery,
userImages: undefined,
userFiles: [expect.stringMatching(/notes\.txt$/)],
});
expect(sentFileContent).toBe("hello");
if (delivery === "queue") {
// Queued attachments stay on disk until the prompt is consumed.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
} else {
// Immediate turns delete the materialized file once the send resolves.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
}
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes materialized attachments when a queued prompt is removed", async () => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-remove-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const queue: Array<{
id: string;
prompt: string;
delivery: "queue";
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
delete: (input: {
sessionId: string;
promptId: string;
}) => Promise<unknown>;
};
};
manager.send = vi.fn(async (input?: unknown) => {
const { prompt, userFiles } = input as {
prompt: string;
userFiles?: string[];
};
queue.push({
id: "pending_1",
prompt,
delivery: "queue",
attachmentCount: userFiles?.length ?? 0,
userFiles,
});
return undefined;
}) as unknown as typeof send;
manager.pendingPrompts = {
list: vi.fn(async () => [...queue]),
delete: vi.fn(async ({ promptId }) => {
const index = queue.findIndex((entry) => entry.id === promptId);
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
return {
sessionId,
prompts: [...queue],
prompt: removed,
removed: index >= 0,
};
}),
};
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queued with file",
delivery: "queue",
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const filePath = queue[0]?.userFiles?.[0] ?? "";
expect(existsSync(filePath)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([filePath]);
await handleChatSessionCommand(ctx, {
action: "remove_pending_prompt",
sessionId,
promptId: "pending_1",
});
expect(existsSync(filePath)).toBe(false);
expect(
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
).toBe(0);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes tracked attachments when a session is reset", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-reset-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const [consumedFile] = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
session.consumedAttachmentFiles = new Map([
["pending_2", [consumedFile]],
]);
await handleChatSessionCommand(ctx, {
action: "reset",
sessionId,
});
expect(existsSync(queuedFile)).toBe(false);
expect(existsSync(consumedFile)).toBe(false);
expect(ctx.liveSessions.has(sessionId)).toBe(false);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("preserves tracked attachments across re-attach", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-attach-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
const queuedMap = new Map([["pending_1", [queuedFile]]]);
session.queuedAttachmentFiles = queuedMap;
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}),
);
await handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
});
expect(existsSync(queuedFile)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([queuedFile]);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
@@ -158,6 +532,265 @@ describe("first-send connection updates", () => {
);
});
it("rebuilds the same session with its transcript before a provider switch", async () => {
const {
ctx,
readMessages,
send,
sessionId,
start,
stop,
updateSessionConnection,
} = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
expect(readMessages).toHaveBeenCalledWith(sessionId);
expect(stop).toHaveBeenCalledWith(sessionId);
expect(start).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
sessionId,
}),
initialMessages: [
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
],
}),
);
expect(updateSessionConnection).toHaveBeenCalledWith(sessionId, {
providerId: "openai-codex",
modelId: "gpt-5.3-codex",
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: null,
});
expect(start.mock.invocationCallOrder[0]).toBeLessThan(
send.mock.invocationCallOrder[0] ?? 0,
);
});
it("blocks a concurrent send throughout provider-switch preparation", async () => {
let resolveMessages:
| ((messages: Array<{ role: string; content: string }>) => void)
| undefined;
const messages = new Promise<Array<{ role: string; content: string }>>(
(resolve) => {
resolveMessages = resolve;
},
);
const { ctx, readMessages, sessionId } = createContext();
readMessages.mockImplementationOnce(async () => await messages);
const switching = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "racing prompt",
config: { ...baseConfig },
}),
).rejects.toThrow("A provider switch is already in progress");
resolveMessages?.([
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
await switching;
});
it.each([
"queue",
"steer",
] as const)("locks provider-switch preparation for explicit %s delivery", async (delivery) => {
let resolveMessages:
| ((messages: Array<{ role: string; content: string }>) => void)
| undefined;
const messages = new Promise<Array<{ role: string; content: string }>>(
(resolve) => {
resolveMessages = resolve;
},
);
const { ctx, readMessages, sessionId } = createContext();
readMessages.mockImplementationOnce(async () => await messages);
const switching = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queue this for Codex",
delivery,
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
});
await vi.waitFor(() => expect(readMessages).toHaveBeenCalledOnce());
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "racing prompt",
config: { ...baseConfig },
}),
).rejects.toThrow("A provider switch is already in progress");
resolveMessages?.([
{ role: "user", content: "first prompt" },
{ role: "assistant", content: "first response" },
]);
await switching;
});
it("restores the previous provider runtime when replacement startup fails", async () => {
const { ctx, send, sessionId, start, stop } = createContext();
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
const testKanbanDataDir = join(
tmpdir(),
`cline-provider-rollback-${process.pid}`,
);
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
start
.mockRejectedValueOnce(new Error("Codex bootstrap failed"))
.mockResolvedValueOnce({ sessionId });
try {
const result = (await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
})) as { result?: { finishReason?: string; text?: string } };
expect(stop).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledTimes(2);
expect(start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId,
}),
}),
);
expect(send).not.toHaveBeenCalled();
expect(result.result).toEqual({
finishReason: "error",
text: "Codex bootstrap failed",
});
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Cline",
config: { ...baseConfig },
});
expect(send).toHaveBeenCalledOnce();
} finally {
if (previousKanbanDataDir === undefined) {
delete process.env.CLINE_KANBAN_DATA_DIR;
} else {
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
}
rmSync(testKanbanDataDir, { recursive: true, force: true });
}
});
it("restores the previous provider when replacement label sync fails", async () => {
const { ctx, send, sessionId, start, stop, updateSessionConnection } =
createContext();
const previousKanbanDataDir = process.env.CLINE_KANBAN_DATA_DIR;
const testKanbanDataDir = join(
tmpdir(),
`cline-provider-label-rollback-${process.pid}`,
);
process.env.CLINE_KANBAN_DATA_DIR = testKanbanDataDir;
try {
updateSessionConnection
.mockRejectedValueOnce(new Error("manifest write failed"))
.mockResolvedValueOnce(undefined);
const result = (await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Codex",
config: {
...baseConfig,
provider: "openai-codex",
model: "gpt-5.3-codex",
},
})) as { result?: { finishReason?: string; text?: string } };
expect(stop).toHaveBeenCalledTimes(2);
expect(start).toHaveBeenCalledTimes(2);
expect(start.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
sessionId,
}),
}),
);
expect(updateSessionConnection).toHaveBeenNthCalledWith(2, sessionId, {
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
thinking: true,
reasoningEffort: "high",
thinkingBudgetTokens: null,
});
expect(send).not.toHaveBeenCalled();
expect(result.result).toEqual({
finishReason: "error",
text: "manifest write failed",
});
expect(ctx.liveSessions.get(sessionId)?.config).toEqual(baseConfig);
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "continue with Cline",
config: { ...baseConfig },
});
expect(send).toHaveBeenCalledOnce();
expect(start).toHaveBeenCalledTimes(2);
} finally {
if (previousKanbanDataDir === undefined) {
delete process.env.CLINE_KANBAN_DATA_DIR;
} else {
process.env.CLINE_KANBAN_DATA_DIR = previousKanbanDataDir;
}
rmSync(testKanbanDataDir, { recursive: true, force: true });
}
});
it("refreshes hub-attached sessions even when the cached config matches", async () => {
const { ctx, sessionId, updateSessionConnection } = createContext({
attachedViaHub: true,
+363 -83
View File
@@ -5,13 +5,22 @@ import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
type ClineCoreStartConfig,
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
type SessionPendingPrompt,
SessionSource,
splitCoreSessionConfig,
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import type {
@@ -159,6 +168,8 @@ function createLiveSession(
prompt: overrides?.prompt,
title: overrides?.title,
attachedViaHub: overrides?.attachedViaHub ?? false,
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
};
}
@@ -194,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
const cwd =
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
@@ -212,8 +228,11 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
modelId: config.model ?? config.modelId ?? "",
mode: config.mode ?? "act",
apiKey: config.apiKey ?? config.api_key ?? "",
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
baseUrl: config.baseUrl,
headers: config.headers,
providerConfig: config.providerConfig,
...(workspaceRoot ? { workspaceRoot } : {}),
...(cwd ? { cwd } : {}),
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
@@ -293,6 +312,54 @@ export function shouldUpdateSessionConnection(
);
}
function readAliasedString(
config: JsonRecord,
primaryKey: string,
aliasKey: string,
): string | undefined {
for (const key of [primaryKey, aliasKey]) {
if (!Object.hasOwn(config, key)) continue;
const value = String(config[key] ?? "").trim();
return value || undefined;
}
return undefined;
}
export function mergeSessionConfig(
currentConfig: JsonRecord,
updates: JsonRecord,
): JsonRecord {
const providerId =
readAliasedString(updates, "provider", "providerId") ??
readAliasedString(currentConfig, "provider", "providerId");
const modelId =
readAliasedString(updates, "model", "modelId") ??
readAliasedString(currentConfig, "model", "modelId");
return {
...currentConfig,
...updates,
...(providerId ? { provider: providerId, providerId } : {}),
...(modelId ? { model: modelId, modelId } : {}),
};
}
export function hasProviderChanged(
currentConfig: JsonRecord,
nextConfig: JsonRecord,
): boolean {
const currentProviderId = readAliasedString(
currentConfig,
"provider",
"providerId",
);
const nextProviderId = readAliasedString(
nextConfig,
"provider",
"providerId",
);
return nextProviderId !== undefined && currentProviderId !== nextProviderId;
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
const cwd = String(
config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
@@ -348,7 +415,13 @@ function sendPromptsInQueueSnapshot(
const session = ctx.liveSessions.get(sessionId);
sendEvent(ctx, "prompts_in_queue_state", {
sessionId,
items: session?.promptsInQueue ?? [],
items:
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
})) ?? [],
});
}
@@ -358,6 +431,7 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
prompt: item.prompt,
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount,
userImages: item.userImages,
};
}
@@ -372,7 +446,12 @@ function applyPendingPrompts(
session.promptsInQueue = mapped;
}
sendPromptsInQueueSnapshot(ctx, sessionId);
return mapped;
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
}));
}
function getSessionManager(ctx: SidecarContext): ClineCore {
@@ -412,11 +491,12 @@ async function handleStart(
// the frontend call the separate "send" action to dispatch the prompt.
// This avoids a double-execution bug where start() would run the turn AND
// the subsequent manager.send() fire-and-forget would run it again.
console.error(
`[sidecar:handleStart] calling manager.start provider=${coreConfig.providerId} model=${coreConfig.modelId}`,
);
ctx.logger?.log("Starting desktop chat session", {
providerId: String(coreConfig.providerId ?? ""),
modelId: String(coreConfig.modelId ?? ""),
});
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
source: SessionSource.DESKTOP,
interactive: true,
...(initialMessages
@@ -425,19 +505,24 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
console.error(`[sidecar:handleStart] session started sessionId=${sessionId}`);
const session = createLiveSession(request.config, {
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
});
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId };
return { sessionId, cwd, workspaceRoot };
}
async function handleAttach(
@@ -495,6 +580,11 @@ async function handleAttach(
existing?.title,
endedAt: isoTimestampToMs(session.endedAt),
attachedViaHub: true,
// Preserve tracked attachment files so re-attach (called on every
// webview hydrate) does not orphan materialized files still awaiting
// cleanup.
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
@@ -510,80 +600,251 @@ async function handleAttach(
};
}
async function startRebuiltSession(
manager: ClineCore,
sessionId: string,
config: JsonRecord,
systemPrompt: string,
messages: Message[],
compactionState: SessionCompactionState | undefined,
): Promise<void> {
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
const restarted = await manager.start({
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
sessionId,
systemPrompt,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
initialMessages: messages,
...(projectedMessages
? {
initialCompactionState: createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
systemPrompt: compactionState?.system_prompt,
}),
}
: {}),
toolPolicies: resolveToolPolicies(config),
});
if (restarted.sessionId !== sessionId) {
throw new Error(
`Provider switch changed session id from ${sessionId} to ${restarted.sessionId}`,
);
}
}
async function rebuildSessionForProviderChange(
ctx: SidecarContext,
manager: ClineCore,
sessionId: string,
previousConfig: JsonRecord,
nextConfig: JsonRecord,
): Promise<void> {
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
await Promise.all([
manager.readMessages(sessionId),
manager.readSessionCompactionState(sessionId).catch((error) => {
ctx.logger?.log?.("Failed to read desktop session compaction state", {
sessionId,
error,
severity: "warn",
});
return undefined;
}),
resolveSystemPrompt(previousConfig),
resolveSystemPrompt(nextConfig),
]);
await manager.stop(sessionId);
let replacementStarted = false;
try {
await startRebuiltSession(
manager,
sessionId,
nextConfig,
nextSystemPrompt,
messages,
compactionState,
);
replacementStarted = true;
// Reusing a session id preserves its existing manifest. Treat refreshing
// its connection label as part of the replacement transaction so a
// persistence failure cannot leave runtime and cached state diverged.
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
);
} catch (replacementError) {
try {
if (replacementStarted) {
await manager.stop(sessionId);
}
await startRebuiltSession(
manager,
sessionId,
previousConfig,
previousSystemPrompt,
messages,
compactionState,
);
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(previousConfig),
);
} catch (rollbackError) {
throw new AggregateError(
[replacementError, rollbackError],
"Provider switch and rollback both failed",
);
}
throw replacementError;
}
}
async function handleSend(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
const prompt = request.prompt?.trim() ?? "";
const hasAttachments =
(request.attachments?.userImages?.length ?? 0) > 0 ||
(request.attachments?.userFiles?.length ?? 0) > 0;
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (request.config) {
if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, request.config)
) {
const connectionUpdate = buildSessionConnectionUpdate(request.config);
await manager.updateSessionConnection(sessionId, connectionUpdate);
}
if (session) {
session.config = { ...session.config, ...request.config };
}
if (session?.transitioningProvider) {
throw new Error("A provider switch is already in progress");
}
// Determine effective delivery mode.
// When the session is busy and no explicit delivery was requested, queue it
// via Core so that Core's own pending-prompts mechanism handles draining.
// This avoids a sidecar-only local queue that never calls manager.send().
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
}
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
}
// Delegate queuing to Core — it will drain the prompt once the current
// turn finishes and emit pending_prompts / pending_prompt_submitted events.
await manager.send({
sessionId,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
};
const nextConfig = request.config
? mergeSessionConfig(session?.config ?? {}, request.config)
: undefined;
const providerChanged = Boolean(
session &&
request.config &&
hasProviderChanged(session.config, request.config),
);
if (providerChanged && session?.busy) {
throw new Error("Cannot switch providers while a turn is running");
}
const ownsBusyState = Boolean(
session && delivery !== "queue" && delivery !== "steer",
);
if (session) {
session.prompt = prompt;
session.busy = true;
session.status = "running";
if (ownsBusyState) {
session.prompt = prompt;
session.busy = true;
session.status = "running";
}
if (providerChanged) {
session.transitioningProvider = true;
}
}
try {
console.error(
`[sidecar:handleSend] calling manager.send sessionId=${sessionId} prompt=${prompt.slice(0, 80)}`,
);
const result = await manager.send({
if (request.config && nextConfig) {
if (providerChanged && session) {
await rebuildSessionForProviderChange(
ctx,
manager,
sessionId,
session.config,
nextConfig,
);
} else if (
!session ||
session.attachedViaHub ||
shouldUpdateSessionConnection(session.config, nextConfig)
) {
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
);
}
if (session) {
session.config = nextConfig;
if (providerChanged) {
session.attachedViaHub = false;
}
}
}
const userFiles = materializeUserFiles(
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
});
console.error(
`[sidecar:handleSend] manager.send resolved sessionId=${sessionId} finishReason=${result?.finishReason} textLen=${result?.text?.length ?? 0}`,
request.attachments?.userFiles,
);
if (session) {
session.busy = false;
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
}
await manager.send({
sessionId,
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
userFiles,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
trackQueuedAttachments(session, prompts, userFiles);
return {
sessionId,
ok: true,
queued: true,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
};
}
ctx.logger?.debug("Sending desktop chat prompt", {
sessionId,
promptLength: prompt.length,
delivery,
});
let result: Awaited<ReturnType<ClineCore["send"]>>;
try {
result = await manager.send({
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
userFiles,
});
} catch (error) {
deleteMaterializedAttachments(sessionId, userFiles);
throw error;
}
if (result === undefined) {
// The runtime queued or steered the prompt instead of running it
// (busy interactive session / steer delivery) — track the files so
// they are deleted once the prompt is consumed or discarded.
if (userFiles?.length) {
trackQueuedAttachments(
session,
await manager.pendingPrompts.list({ sessionId }),
userFiles,
);
}
} else {
deleteMaterializedAttachments(sessionId, userFiles);
}
ctx.logger?.log("Desktop chat prompt completed", {
sessionId,
finishReason: result?.finishReason,
textLength: result?.text?.length ?? 0,
});
if (session && ownsBusyState) {
session.status = "idle";
if (result?.messages) session.messages = result.messages as unknown[];
}
@@ -602,11 +863,8 @@ async function handleSend(
: undefined,
};
} catch (error) {
console.error(
`[sidecar:handleSend] manager.send THREW sessionId=${sessionId} error=${error instanceof Error ? error.message : String(error)}`,
);
if (session) {
session.busy = false;
ctx.logger?.error?.("Desktop chat prompt failed", { sessionId, error });
if (session && ownsBusyState) {
session.status = "error";
}
emitChunk(
@@ -626,6 +884,15 @@ async function handleSend(
text: error instanceof Error ? error.message : String(error),
},
};
} finally {
if (session) {
if (ownsBusyState) {
session.busy = false;
}
if (providerChanged) {
session.transitioningProvider = false;
}
}
}
}
@@ -732,7 +999,7 @@ async function handleFork(
...forkConfig,
systemPrompt,
initialMessages: sourceMessages,
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -741,6 +1008,10 @@ async function handleFork(
toolPolicies: resolveToolPolicies(forkConfig),
});
const newSessionId = startResult.sessionId;
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
@@ -780,6 +1051,7 @@ async function handleReset(
) {
await getSessionManager(ctx).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
@@ -818,7 +1090,7 @@ async function handleRestoreCheckpoint(
buildCoreSessionConfig({
...request.config,
systemPrompt: await resolveSystemPrompt(request.config),
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -830,6 +1102,10 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
@@ -931,6 +1207,10 @@ async function handleRemovePendingPrompt(
sessionId,
promptId,
});
if (result.removed === true) {
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
}
return {
sessionId,
removed: result.removed === true,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import {
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
readConnectorCliLaunchSpec,
} from "@cline/shared";
import { afterEach, describe, expect, it } from "vitest";
import { __test__, configureConnectorCliLaunch } from "./connectors";
describe("desktop connector lifecycle", () => {
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
afterEach(() => {
if (originalLaunchSpec === undefined) {
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
} else {
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
}
});
it("registers the CLI connect command for a desktop-started daemon", () => {
const workspaceRoot = "/repo";
const expected = __test__.buildCliConnectCommand(workspaceRoot, []);
configureConnectorCliLaunch(workspaceRoot);
expect(readConnectorCliLaunchSpec()).toEqual({
launcher: expected.launcher,
connectArgsPrefix: expected.childArgs,
cwd: workspaceRoot,
});
});
it("uses the atomic restart command for an active channel", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true),
).toEqual(["--restart", "telegram", "-k", "token"]);
});
it("starts an inactive channel directly", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], false),
).toEqual(["telegram", "-k", "token"]);
});
it("rejects a channel-wide restart when multiple instances are active", () => {
expect(() => __test__.shouldRestartConnector("telegram", 2)).toThrow(
"cannot safely restart telegram: 2 instances are active",
);
});
});
+72 -45
View File
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename, join, normalize } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { listActiveConnectors } from "@cline/core";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
buildConnectorConnectArgs,
CONNECTOR_PLATFORMS,
listConnectorCatalog,
setConnectorCliLaunchSpec,
withResolvedClineBuildEnv,
} from "@cline/shared";
import type { JsonRecord } from "./types";
type ConnectorField = {
@@ -75,6 +76,16 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function asStringRecord(value: unknown): Record<string, string> {
const record = asRecord(value);
if (!record) return {};
return Object.fromEntries(
Object.entries(record).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
@@ -125,12 +136,21 @@ function buildCliConnectCommand(
return { launcher, childArgs };
}
export function configureConnectorCliLaunch(workspaceRoot: string): void {
const command = buildCliConnectCommand(workspaceRoot, []);
setConnectorCliLaunchSpec({
launcher: command.launcher,
connectArgsPrefix: command.childArgs,
cwd: workspaceRoot,
});
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
(platform) => supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
@@ -211,7 +231,7 @@ async function waitForConnectorState(
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -219,42 +239,36 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
const rawValue = values[field.flag];
if (typeof rawValue === "string") {
fieldValues[field.flag] = rawValue.trim();
} else if (field.initialValue) {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
return [
channel,
...buildConnectorConnectArgs(
platform,
asStringRecord(args?.values),
security
? {
enabled: security.enabled === true,
values: asStringRecord(security.values),
}
: undefined,
),
];
}
function buildConnectorLaunchArgs(
cliArgs: string[],
isRestart: boolean,
): string[] {
return isRestart ? ["--restart", ...cliArgs] : cliArgs;
}
function shouldRestartConnector(channel: string, activeCount: number): boolean {
if (activeCount > 1) {
throw new Error(
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
);
}
return cliArgs;
return activeCount === 1;
}
export async function startConnectorChannel(
@@ -263,7 +277,14 @@ export async function startConnectorChannel(
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
const activeCount = listActiveConnectors().filter(
(connector) => connector.type === channel,
).length;
const isRestart = shouldRestartConnector(channel, activeCount);
const result = await runCliConnectCommand(
workspaceRoot,
buildConnectorLaunchArgs(cliArgs, isRestart),
);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
@@ -278,6 +299,12 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
buildConnectorLaunchArgs,
shouldRestartConnector,
};
export async function stopConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
@@ -290,7 +317,7 @@ export async function stopConnectorChannel(
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
+216 -38
View File
@@ -1,12 +1,19 @@
import { existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RuntimeCapabilities } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext } from "./types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import type { LiveSession, SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
const hubCommandMock = vi.hoisted(() => vi.fn());
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
const hubGetUrlMock = vi.hoisted(() => vi.fn());
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -17,19 +24,16 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
command = hubCommandMock;
getConnectionError = hubGetConnectionErrorMock;
getUrl = hubGetUrlMock;
isConnected = hubIsConnectedMock;
subscribe = subscribeMock;
dispose = vi.fn();
},
@@ -53,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
ensureCompatibleLocalHubUrlMock.mockReset();
hubCommandMock.mockReset();
hubGetConnectionErrorMock.mockReset();
hubGetUrlMock.mockReset();
hubIsConnectedMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
"ws://127.0.0.1:25463/hub",
);
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
hubGetConnectionErrorMock.mockReturnValue(null);
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
hubIsConnectedMock.mockReturnValue(true);
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -83,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -102,22 +98,130 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
const hubOptions = createCoreMock.mock.calls[0][0].hub;
expect(hubOptions).not.toHaveProperty("endpoint");
expect(hubOptions).not.toHaveProperty("authToken");
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar-approvals",
clientType: "code-sidecar-observer",
displayName: "Code App observer",
}),
);
});
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const logger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const telemetry = { capture: vi.fn() };
const ctx = createSidecarContext("/workspace/project", {
logger,
telemetry: telemetry as never,
});
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
logger,
telemetry,
}),
);
});
it("reports the connected shared Hub endpoint in process context", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const session = {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
} satisfies LiveSession;
ctx.liveSessions.set("running-session", session);
ctx.liveSessions.set("idle-session", {
...session,
busy: false,
status: "idle",
});
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
expect.objectContaining({
runningSessionCount: 1,
hub: {
status: "connected",
url: "ws://127.0.0.1:25463/hub",
error: null,
},
}),
);
});
it("starts or reuses the shared Hub when a command needs a client", async () => {
const { createSidecarContext, ensureSharedHubClient } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
});
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
}),
);
expect(connectMock).toHaveBeenCalledOnce();
});
it("serializes queued image data when a queued prompt starts", async () => {
const { serializeQueuedPromptStart } = await import("./context");
expect(
JSON.parse(
serializeQueuedPromptStart({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
}),
),
).toEqual({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
});
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -192,8 +296,7 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
strategy: "require-hub",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -253,4 +356,79 @@ describe("Code sidecar runtime capabilities", () => {
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
).toEqual([]);
});
it("routes routine commands through the connected shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "pause_routine_schedule", {
schedule_id: "schedule-1",
}),
).resolves.toEqual({
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
scheduleId: "schedule-1",
});
});
});
describe("disposeSidecarContext attachment cleanup", () => {
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
it("deletes tracked attachments for all live sessions on shutdown", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const sessionId = "dispose-session";
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session: LiveSession = {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
};
ctx.liveSessions.set(sessionId, session);
await disposeSidecarContext(ctx, "test_shutdown");
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
});
+153 -46
View File
@@ -1,21 +1,27 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { appendFile, mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname } from "node:path";
import {
type AgentToolContext,
type BasicLogger,
ClineCore,
createLocalHubScheduleRuntimeHandlers,
type CoreSessionEvent,
ensureCompatibleLocalHubUrl,
type ITelemetryService,
NodeHubClient,
resolveHubOwnerContext,
type RuntimeCapabilities,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import type { AgentEvent } from "@cline/shared";
import {
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -26,6 +32,10 @@ import type {
} from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
SidecarContext,
Promise<NodeHubClient>
>();
// ---------------------------------------------------------------------------
// Helpers — WebSocket broadcast
@@ -49,6 +59,11 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
}
}
// Session log appends are chained per session so writes stay ordered, but
// they run asynchronously: a synchronous write per streamed token would stall
// the sidecar event loop (and therefore every pending UI command) under load.
const sessionLogWriteTails = new Map<string, Promise<void>>();
function appendSessionChunk(
sessionId: string,
stream: string,
@@ -56,9 +71,21 @@ function appendSessionChunk(
ts: number,
): void {
const path = sessionLogPath(sessionId);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ ts, stream, chunk })}\n`, {
flag: "a",
const line = `${JSON.stringify({ ts, stream, chunk })}\n`;
const tail = sessionLogWriteTails.get(sessionId) ?? Promise.resolve();
const next = tail
.then(async () => {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, line);
})
.catch(() => {
// Session logs are best-effort diagnostics; never fail the stream.
});
sessionLogWriteTails.set(sessionId, next);
void next.finally(() => {
if (sessionLogWriteTails.get(sessionId) === next) {
sessionLogWriteTails.delete(sessionId);
}
});
}
@@ -109,7 +136,29 @@ export function broadcastChunk(
// ---------------------------------------------------------------------------
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
return session.promptsInQueue;
return session.promptsInQueue.map(
({ id, prompt, steer, attachmentCount, userImages }) => ({
id,
prompt,
steer,
attachmentCount,
userImages,
}),
);
}
export function serializeQueuedPromptStart(input: {
promptId: string;
prompt: string;
attachmentCount?: number;
userImages?: string[];
}): string {
return JSON.stringify({
promptId: input.promptId,
prompt: input.prompt,
attachmentCount: input.attachmentCount ?? 0,
userImages: input.userImages,
});
}
function sendPromptsInQueueSnapshot(
@@ -301,9 +350,16 @@ function handleCoreSessionEvent(
prompt: item.prompt ?? "",
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount ?? 0,
userImages: item.userImages,
}))
.filter((item) => item.id && item.prompt);
.filter(
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
);
if (session) {
reconcileQueuedAttachments(
session,
mapped.map((item) => item.id),
);
const previous = session.promptsInQueue;
session.promptsInQueue = mapped;
if (
@@ -315,9 +371,11 @@ function handleCoreSessionEvent(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
serializeQueuedPromptStart({
promptId: previous[0].id,
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
}),
);
}
@@ -326,14 +384,18 @@ function handleCoreSessionEvent(
break;
}
case "pending_prompt_submitted": {
const { sessionId, prompt, attachmentCount } = event.payload;
const { sessionId, id, prompt, attachmentCount, userImages } =
event.payload;
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
serializeQueuedPromptStart({
promptId: id,
prompt,
attachmentCount: attachmentCount ?? 0,
userImages,
}),
);
break;
@@ -346,6 +408,7 @@ function handleCoreSessionEvent(
session.endedAt = nowMs();
session.status = reason || "ended";
}
discardAllTrackedAttachments(sessionId, session);
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
break;
}
@@ -365,6 +428,10 @@ function handleCoreSessionEvent(
if (session) {
session.status = status;
session.busy = status === "running";
if (status !== "running") {
// The turn that consumed submitted attachments has finished.
flushConsumedAttachments(sessionId, session);
}
}
sendEvent(ctx, "chat_session_status", { sessionId, status });
break;
@@ -380,7 +447,13 @@ function handleCoreSessionEvent(
// Context factory
// ---------------------------------------------------------------------------
export function createSidecarContext(workspaceRoot: string): SidecarContext {
export function createSidecarContext(
workspaceRoot: string,
observability: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
} = {},
): SidecarContext {
return {
liveSessions: new Map(),
streamIndices: new Map(),
@@ -389,8 +462,9 @@ export function createSidecarContext(workspaceRoot: string): SidecarContext {
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
unsubscribeSessionEvents: null,
};
}
@@ -404,6 +478,11 @@ export async function disposeSidecarContext(
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
}
ctx.liveSessions.clear();
for (const client of ctx.wsClients) {
try {
client.close?.();
@@ -434,12 +513,6 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -692,19 +765,14 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -717,25 +785,64 @@ export async function initializeSessionManager(
handleCoreSessionEvent(ctx, event);
});
const runtimeAddress = sessionManager.runtimeAddress?.trim();
let hubClient: NodeHubClient | null = null;
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
return await pending;
}
const initialization = (async () => {
const url =
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
}
const client = new NodeHubClient({
url,
clientType: "code-sidecar-observer",
displayName: "Code App observer",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
ctx.hubClient = client;
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
throw error;
}
})().finally(() => {
hubClientInitialization.delete(ctx);
});
hubClientInitialization.set(ctx, initialization);
return await initialization;
}
+75 -3
View File
@@ -1,14 +1,23 @@
import { homedir } from "node:os";
import { setHomeDirIfUnset } from "@cline/core";
import { isHubDaemonProcess } from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { configureConnectorCliLaunch } from "./connectors";
import {
createSidecarContext,
disposeSidecarContext,
initializeSessionManager,
} from "./context";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { ensureLoginShellPath } from "./shell-path";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
let activeObservability:
| ReturnType<typeof createDesktopObservability>
| undefined;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -32,19 +41,49 @@ async function main() {
throw new Error("sidecar must be run with Bun");
}
// When launched from Finder/the Dock the app inherits launchd's minimal
// PATH, so agent-spawned processes can't find shell-profile-installed
// tools like `gh`. Kick resolution off first so it overlaps the rest of
// startup, but await it before the session manager exists — that's what
// spawns children (agent sessions, MCP servers, scheduled runs).
const shellPathPromise = ensureLoginShellPath();
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
const ctx = createSidecarContext(workspaceRoot);
setHomeDirIfUnset(homedir());
configureConnectorCliLaunch(workspaceRoot);
const observability = createDesktopObservability();
activeObservability = observability;
const ctx = createSidecarContext(workspaceRoot, observability);
observability.logger.log("Desktop sidecar starting", {
workspaceRoot,
pid: process.pid,
});
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
await initializeSessionManager(ctx);
let shuttingDown = false;
let handlingFatalError = false;
const shutdown = async (reason = "code_sidecar_shutdown"): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
await withTimeout(disposeSidecarContext(ctx, reason), SHUTDOWN_TIMEOUT_MS);
observability.logger.log("Desktop sidecar shutting down", { reason });
await withTimeout(
(async () => {
try {
await disposeSidecarContext(ctx, reason);
} finally {
await observability.dispose();
}
})(),
SHUTDOWN_TIMEOUT_MS,
);
};
const shutdownAndExit = (signal: string): void => {
@@ -55,11 +94,32 @@ async function main() {
process.once("SIGINT", () => shutdownAndExit("SIGINT"));
process.once("SIGTERM", () => shutdownAndExit("SIGTERM"));
const handleFatalError = (kind: string, error: unknown): void => {
if (handlingFatalError) {
process.exit(1);
}
handlingFatalError = true;
observability.logger.error?.("Desktop sidecar process error", {
kind,
error,
});
void shutdown(`code_sidecar_${kind}`).finally(() => process.exit(1));
};
process.on("uncaughtException", (error) => {
handleFatalError("uncaught_exception", error);
});
process.on("unhandledRejection", (error) => {
handleFatalError("unhandled_rejection", error);
});
process.once("beforeExit", () => {
void shutdown("code_sidecar_before_exit");
});
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
observability.logger.log("Desktop sidecar ready", {
port,
mode: SIDECAR_MODE,
});
// 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;
@@ -76,8 +136,20 @@ async function main() {
);
}
main().catch((error) => {
async function runEntrypoint(): Promise<void> {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
await main();
}
runEntrypoint().catch(async (error) => {
const message = error instanceof Error ? error.message : String(error);
activeObservability?.logger.error?.("Desktop sidecar process failed", {
error,
});
await activeObservability?.dispose();
process.stderr.write(`${message}\n`);
process.exit(1);
});

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