Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 7e31fb9e0d chore(vscode): prepare 4.1.8 release 2026-08-10 20:03:53 -07:00
John SimoneandSaoud Rizwan e0eb0167da feat(vscode): add Fable 5 + custom model IDs to Vertex; drop global-region picker filter (#12461)
* add custom model selection to the vertex provider

* fix race conditions from PR review

* fix linter warnings

* fix test failures

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

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

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

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-10 19:58:20 -07:00
Mikołaj Kondratek d5748b2939 fix: remove stale Double-Check Completion feature tip (#13147)
* fix: remove stale Double-Check Completion feature tip

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

* desktop: carry nullable branch state to all consumers

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

---------

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

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

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

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

* desktop: format welcome-workspace-controls test

* desktop: distinguish picker launch failures from user cancellation

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

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

* Classify picker launch failures separately from user cancellation

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

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

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

This reverts commit 24d27a004d.

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* fix(desktop): reclaim leftover partial plugin install dirs with --force
2026-08-08 12:06:35 -07:00
Saoud RizwanandSaoud Rizwan e973ce4f33 fix(cli): make queued message text readable on light theme TUI (#13098)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-08 12:00:50 -07:00
Saoud RizwanandSaoud Rizwan 4f25692d70 fix(desktop): canonicalize diff panel paths against the session cwd (#13092)
* fix(desktop): canonicalize diff panel paths against the session cwd

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

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

* style: collapse editorReplaceEvent signature per biome format

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

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

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

---------

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

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

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

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

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

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

* chore(core): restore untouched settings-service formatting
2026-08-08 11:23:25 -07:00
Saoud RizwanandSaoud Rizwan d011d049a1 fix(core): keep session context durable across aborts and hub restarts (#13078)
* fix(core): keep session context durable across aborts and hub restarts

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

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

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

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

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

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

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

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

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

This reverts commit 5a7e0b37f1.

---------

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

The projector now publishes run.failed (with the error text and a core
session snapshot) for non-recoverable lead-agent error events, but only
when no RPC-driven turn is awaiting sessionHost.runTurn for that
session — the awaiting run.start handler already publishes the
authoritative terminal event, so this avoids double-reporting a turn
that resolves through both paths.
2026-08-07 20:52:01 -07:00
Saoud Rizwan 3f83be51a2 feat(vscode): fade View Changes button until changes since last message are confirmed (#13076) 2026-08-07 20:16:15 -07:00
Saoud RizwanandSaoud Rizwan 1efe5577e1 fix(core/cli): drain queued prompts after self-aborted turns and surface the stop (#13061)
* fix(core/cli): drain queued prompts after self-aborted turns and surface the stop

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 19:27:47 -07:00
Saoud RizwanandSaoud Rizwan 9cbc24d6e5 Restore "View Changes" on completion rows using SDK checkpoints (#13072)
* fix(core): read untracked-at-snapshot files from stash third parent in checkpoint diff

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

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

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

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

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

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

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

---------

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

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

* simplify to minimal payload-preserving fix

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 18:10:28 -07:00
Saoud Rizwan 361ac90978 fix(llms): route LiteLLM through Chat Completions instead of the Responses API (#13053)
The litellm builtin spec pinned protocol: "openai-responses", so every
request went to POST {baseUrl}/responses. Self-hosted LiteLLM proxies
commonly implement only /chat/completions, so all prompts failed with
404 Not Found on the SDK path (CLI, and now the Next extension bundle).

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

Fixes #13003, fixes #10781
2026-08-08 02:06:46 +02:00
Saoud RizwanandSaoud Rizwan 4eb7402334 fix(cli): render MCP tool result text instead of escaped JSON in TUI (#13066)
* fix(cli): render MCP tool result text instead of escaped JSON in TUI

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

Fixes #13038

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

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

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

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

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

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

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

---------

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

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

Fixes #13035

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

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

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

* fix(desktop): preserve team tool defaults

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:45:49 -07:00
98e5458b09 feat(hub): centralize plugin settings and contributions (#12942)
* fix(desktop): plugin package names

* feat(hub): centralize plugin settings and contributions

* fix(hub): address plugin settings review feedback

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

* fix(core): make host plugin toggles atomic

---------

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

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

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

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

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

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 16:41:13 -07:00
Saoud Rizwan 83182c0d96 fix(llms): retry mid-stream network interruptions before any model output (#13052)
* fix(llms): retry mid-stream network interruptions before any model output

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

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

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

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

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

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

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

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

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

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

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

* Add changeset for response header copy button

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

* Rename turn-final headers to Completed and Plan

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

---------

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

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

* fix cron report formatting edge cases

* Escape schedule titles in reports

---------

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

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

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

Fixes #13036

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

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

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

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-07 15:53:01 -07:00
Saoud Rizwan 1e24807d1a fix(vscode): fall back to session cwd or Desktop for @-mention file search in empty windows (#12982)
* fix(vscode): fall back to session cwd/Desktop for @-mention search in empty windows

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

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

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

* chore: retrigger checks after runner outage

---------

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

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

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

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

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

* fix(desktop): avoid nested tool output scrolling

* chore(desktop): remove obsolete scroll utility

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

* refactor(desktop): adopt shared semantic theme roles

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

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

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

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

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

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

Replace legacy arbitrary/has selectors with current utility syntax.

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

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

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

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

* chore: ignore desktop-app Cursor settings

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

* chore: retrigger checks

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-07 10:15:18 -07:00
Saoud Rizwan 7348ba1847 chore(desktop): release v0.0.10 2026-08-06 23:45:56 -07:00
BeeandSaoud Rizwan 3e96fc6112 feat(cli): add mcp uninstall command (#12985)
* feat(cli): add mcp uninstall command

* unit test wiring

---------

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

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

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

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

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

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

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

* desktop: fix Biome a11y error in WelcomeSetupNotice

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* fix(core): preserve compatible MCP OAuth sessions

* fix tests

* feat(desktop): wire mcp oauth in desktop

* UI update

* fix(core): reject stale mcp oauth callbacks

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-06 18:58:52 -07:00
BeeandCline Agent c18d9478ba feat(cli): use saved provider settings for schedules (#10667)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-06 17:34:40 -07:00
Ara 574b8eb45e fix(llms): use configured fetch for Vertex ADC refreshes (#12981) (#12991) 2026-08-06 10:07:06 -07:00
Saoud Rizwan 81cce3d70e chore(vscode): prepare 4.1.6 release 2026-08-06 00:47:54 -07:00
Saoud Rizwan e1352fa709 chore(cli): release v3.0.51 2026-08-06 00:28:37 -07:00
Saoud Rizwan 394fb04518 chore(sdk): release v0.0.71 2026-08-06 00:14:21 -07:00
Saoud Rizwan f1aebbfd5a feat(llms): surface meta/muse-spark-1.2-contributor for the Cline provider (#12995)
* chore(llms): regenerate model catalog from models.dev

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

* test(llms): guard Vercel-only Cline model allowlist
2026-08-06 00:04:16 -07:00
Saoud Rizwan 543dd0d818 fix(telemetry): attribute agent.run sdk.error events to the active model (#12972)
* fix(telemetry): attribute agent.run sdk.error events to the active model

* fix(telemetry): strip undefined values from sdk.error properties
2026-08-05 17:51:06 -07:00
Saoud Rizwan 1f2cbbeb9f chore(vscode): prepare 4.1.5 release 2026-08-05 14:04:04 -07:00
Saoud Rizwan b1a89156d6 feat(vscode): explain when a free model promotion ends (#12970)
* feat(vscode): explain when a free model promotion ends

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

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

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

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

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

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

* fix(llms): prioritize explicit reasoning disable

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-05 12:17:49 -07:00
Mikołaj Kondratek 78b7c3d8ac fix(desktop): stop rendering the first chat message twice (#12779)
* fix(desktop): dedupe chat_queued_prompt_start emitted for the same prompt

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

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

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

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

Review follow-up: matching by content alone could swallow a new queued
prompt that repeats the text of a message left at the transcript tail
by an earlier cancelled/failed turn. Track in-flight optimistic bubble
ids explicitly (registered on optimistic append; cleared on re-key,
turn end, error, and history hydration) and only re-key those.
2026-08-05 14:49:05 +02:00
Saoud Rizwan d626cfb0b5 chore(vscode): prepare 4.1.4 release 2026-08-05 03:03:51 -07:00
Saoud Rizwan e14f354c59 chore(desktop): release v0.0.9 2026-08-05 02:29:46 -07:00
Saoud Rizwan 41ba332f0a chore(cli): release v3.0.50 2026-08-05 02:16:56 -07:00
Saoud Rizwan 6997fae815 chore(sdk): release v0.0.70 2026-08-05 01:59:51 -07:00
Saoud Rizwan 5594512eb0 fix(vscode,cli): recoverable agent errors must not kill a turn that completes with a plan (#12953)
* fix(core): don't count plan-mode guard-blocked commands as model mistakes

The plan-mode command guard (#12906) rejects file-editing run_commands
calls with a tool error. The orchestrator counted that error as a failed
tool call, so a turn whose only tool call was guard-blocked fed the
MistakeTracker, which emits a recoverable "error" AgentEvent
("1 tool call(s) failed: [run_commands] ...").

Hosts render that event as a failed turn. In the VS Code extension the
turn ended in the "error" phase (Retry / Start New Task footer), the
final plan text was never retagged to plan_completion_result, and
toggling to Act therefore rebuilt the session without the auto-continue
send - the toggle appeared to do nothing and the presented plan was
never acted on. In the CLI TUI the same event flipped the footer to
idle mid-turn.

A guard rejection is deliberate session policy, not a model mistake:
the run continues and the model is expected to fold the change into
its plan. Tag the guard error with a stable marker sentence, expose
isPlanModeBlockedCommandError, and skip the failed-tool bookkeeping for
matching results so no mistake is recorded and no error event is
emitted. Repeated blocked attempts are still bounded by loop detection
and maxIterations.

* docs(core): flag plan-mode guard error string matching for typed skip channel

FIXME on isPlanModeBlockedCommandError: recognizing guard rejections by
sniffing the error text is brittle. The intended replacement is a typed
skipSource/skipCode on the tool-finished runtime event so the
orchestrator (and the VS Code approval-denial suppression) can identify
skipped tools structurally instead of via string matching.

* Revert core mistake-counting change for plan-guard blocks

A model attempting a file-editing command in plan mode is disobeying
its instructions - that IS a model mistake, and the MistakeTracker
should keep counting it (it is the brake that stops weak models from
flailing at blocked commands indefinitely). The real bug is host-side:
a recoverable mid-turn mistake must not kill a turn that afterwards
completes with a presented plan. The follow-up commit fixes that in
the hosts instead.

* fix(vscode,cli): treat recoverable agent errors as in-run notices, not turn outcomes

The MistakeTracker emits a recoverable error event for every recorded
mistake while the run continues - e.g. a plan-mode guard-blocked
run_commands call as the turn's only tool call. Both hosts treated any
error event as terminal:

- The VS Code translator cleared the pending completion retag, set
  errorSeen (turn phase "error": Retry / Start New Task footer), marked
  the turn complete, and rendered the error recovery UI. A plan turn
  that recovered from the mistake and completed cleanly therefore never
  produced plan_completion_result, so togglePlanActMode's planPresented
  check failed and switching to act mode rebuilt the session without
  the auto-continue send - the toggle appeared to do nothing.

- The CLI TUI flipped isRunning/isStreaming to idle mid-turn, so the
  footer lied about the still-running turn.

Recoverable errors are informational: the turn's outcome is decided by
how it actually ends (done/error). VS Code now logs them and keeps them
out of the chat (the tool failure is already shown inline on its tool
row, and provider-failure telemetry already ignores recoverable events
for the same reason); the CLI keeps its running state and surfaces them
only in verbose mode, as it already did for display. Genuine run
failures carry recoverable: false and keep the existing error UI.
2026-08-05 01:02:23 -07:00
Saoud Rizwan 6712d43c69 Revert "fix(llms): flatten top-level tool schema unions before sending to pro…" (#12950)
This reverts commit 21edad82a6.
2026-08-04 21:11:33 -07:00
BeeandSaoud Rizwan bd27d9c41b feat(desktop): session source filtering (#12943)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-04 21:01:40 -07:00
Saoud RizwanandSaoud Rizwan 21edad82a6 fix(llms): flatten top-level tool schema unions before sending to providers (#12948)
Anthropic (and several providers OpenRouter fans out to) rejects any tool
whose input_schema has oneOf, allOf, or anyOf at the top level, failing the
whole request with:

  tools.N.custom.input_schema: input_schema does not support oneOf, allOf,
  or anyOf at the top level

MCP servers commonly advertise tools whose input schema is a union of object
shapes (e.g. generated from a Zod union), so one such tool bricked every
turn of the session. Merge union branch properties into a single object
schema at the provider boundary; tools still validate their real input
shapes in execute().

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 20:56:39 -07:00
2f58bbe4ed Add Auto Approval to ACP (#12897)
* Add Auto Approval to ACP

* Update apps/cli/src/acp/auto-approve.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update apps/cli/src/acp/auto-approve.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-04 20:54:39 -07:00
Saoud RizwanandSaoud Rizwan ddfb67515b fix(desktop): inline telemetry config into the packaged sidecar binary (#12925)
* fix(desktop): inline telemetry config into the packaged sidecar binary

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

* fix(desktop): reject non-http OTLP endpoints in the telemetry selfcheck

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 20:43:01 -07:00
Saoud RizwanandSaoud Rizwan 472f9c88c5 Add plan-mode command blocklist to run_commands (#12906)
* Add plan-mode command blocklist to run_commands

Plan mode kept run_commands available (needed for read-only
investigation) but relied on prompting alone to prevent file edits,
and weaker models routinely ignore that. Add a hard guard in
@cline/core's createShellTool that inspects each command before
execution and rejects file-editing constructs with a plan-mode tool
error instead of running them.

The guard is a quote/heredoc-aware scan that blocks file-manipulation
commands (rm/mv/cp/tee/touch/...), in-place editors (sed -i, perl -i,
gawk -i inplace, sort -o), output redirection to files (allowing /dev
sinks and /tmp for the documented output-capture pattern), mutating
git subcommands, package-manager installs, find -delete/-exec, and
nested command strings (sh -c, eval, sudo, xargs, ...). Windows and
PowerShell equivalents are covered too.

Enabled via a new blockFileEditingCommands flag on DefaultToolsConfig,
set by the plan tool preset (CLI and core runtime) and plumbed through
the VS Code extension's custom run_commands tool from the session mode.
The tool description and PLAN_MODE_INSTRUCTIONS now state the hard
block so models are forewarned.

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

* Simplify plan-mode command guard to a plain blacklist

Replace the char-by-char shell tokenizer (heredoc queues, process
substitution, recursion into sh -c/eval, find -exec analysis) with a
simple scan: mask quoted text/heredoc bodies/escapes/comments so they
cannot false-positive, split on shell separators, and compare the
leading command word of each part against flat blacklists (commands,
mutating subcommands, in-place edit flags), plus one redirect check.
Quoted nested commands (bash -c 'rm x') are a documented false
negative. Also drop the guard from the package's public exports; it
is internal to createShellTool.

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

* Move plan-mode command guard into a built-in beforeTool hook

Review feedback (abeatrix): command blocking is session policy, not
shell-executor configuration. Replace the blockFileEditingCommands
flag threaded through preset -> tool config -> VS Code host with a
core extension registered by the runtime builder for plan-mode
sessions. The beforeTool hook intercepts every run_commands tool in
the runtime - the SDK builtin, host replacements like the VS Code
terminal tool, and delegated sub-agents - and rejects file-editing
calls with the plan-mode error before tool policy and user approval,
so users are no longer prompted to approve a command that would only
fail. All VS Code wiring for the guard is removed.

Also adds block telemetry (sdk.plan_mode_command_blocked with the
blocked construct, never raw command content), per review.

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

* Address review feedback on the plan-mode command blacklist

False positives (mkondratek):
- perl -Ilib / uppercase value-taking flags no longer match the
  in-place check; the flag cluster must end at a lowercase i
  (sed -Ei still blocked)
- awk inplace detection is tied to the -i/--include flag instead of
  matching the substring anywhere (filenames like inplace-notes.txt
  no longer trip it)
- read-only git forms allowed: stash list/show, worktree list,
  submodule status/summary, and any git subcommand with --help/-h
- arithmetic expansion (1) is masked before the redirect scan

Hardening and coverage (mkondratek, dominiccooney):
- temp-path redirect allowance rejects .. traversal (/tmp/../...)
- Windows gets a temp escape hatch: %TEMP%/%TMP%/$env:TEMP redirect
  targets are allowed and the block error mentions it
- curl -o/-O/--output/--remote-name and wget downloads blocked
  (--spider and -qO- stdout forms stay allowed)
- python -m pip resolves to the pip subcommand check
- unambiguous PowerShell aliases (mi, ri, cpi, rni, ac, clc) plus a
  case-variant test
- more package managers: winget, nuget, gem, composer, dotnet add,
  go install/get; bare classic yarn blocked again
- find -exec/-execdir/-ok chains and xargs -I {} placeholders are
  checked for mutating commands

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 20:35:54 -07:00
Saoud RizwanandSaoud Rizwan f77d0930ba fix(llms): preserve models.dev reasoning options in generated catalog so adaptive-era Claude models never get manual thinking (#12908)
* chore(llms): regenerate model catalog from models.dev to pick up reasoning options

The baked fallback catalog was last regenerated before toModelInfo started
mapping models.dev reasoning_options into ModelInfo.reasoningOptions, so it
carried no reasoning metadata. Whenever the live models.dev fetch fails or a
model resolves from the baked catalog, adaptive-era Claude models (4.6+/5.x)
fell through the missing-reasoningOptions path to Anthropic manual thinking
and the API rejected the request with 'thinking.type.enabled is not
supported'.

This regen also picks up upstream models.dev drift; test expectations that
hardcoded stale catalog values (GLM 5.2 context window, OpenRouter GLM 4.7
reasoning controls, Vercel AI Gateway Qwen 3.6 Plus budget controls) are
updated to the current published values.

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

* fix(llms): infer adaptive thinking for adaptive-era Claude ids when catalog reasoning options are missing

Claude 4.6+ and 5.x models reject the manual thinking wire shape
(thinking.type 'enabled') on the Anthropic API. When a model resolves
without reasoningOptions metadata (offline baked catalog before the regen,
or user-typed unlisted ids such as claude-opus-4-6:1m), the reasoning
policy previously fell through to anthropic-manual and every
reasoning-enabled request failed with a hard API error.

Add isClaudeAdaptiveEraModelId as a narrowly scoped id fallback (name-first
Claude ids with version 4.6+ or 5.x, plus the Fable line) and use it in the
missing-reasoningOptions branch of resolveAnthropicReasoningRequestPolicy.
Genuinely old or unknown Claude-compatible ids keep the manual default,
which remains the safe shape for third-party Claude-compatible endpoints.

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

* fix(llms): prefer adaptive thinking over manual when a model advertises an effort control

A numeric reasoning.budgetTokens (e.g. a thinkingBudgetTokens setting
migrated from the legacy extension) used to force the anthropic-manual
policy whenever the model advertised a budget_tokens control. Claude 4.6+
models advertise both effort and budget_tokens on models.dev but reject
thinking.type 'enabled' on the Anthropic API, so those requests failed.
Effort now wins: adaptive is selected and the numeric budget is ignored.
Budget-only models (Sonnet 4.5 and older) keep honoring explicit budgets
via the manual shape.

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

* test(llms): guard baked catalog reasoning options for adaptive-era Claude models

Resolve adaptive-era Claude models through the generated (offline fallback)
catalog and assert their entries carry effort reasoning options that the
Anthropic reasoning policy resolves to adaptive thinking.

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

* test(core): update GLM 5.2 context window to current models.dev value

The catalog regen picked up upstream drift: models.dev now publishes a
1,000,000-token context window for zai/glm-5.2 (was 1,040,000).

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

* revert(llms): drop the Claude id-based adaptive-thinking fallback

Keep the fix surface minimal: the catalog regen covers every model
models.dev lists (the overwhelming share of the production failures), and
the effort-over-budget policy covers listed models that advertise both
controls. Unlisted id variants (e.g. claude-opus-4-6:1m) keep the
pre-existing manual fallback rather than introducing id-version parsing in
model-facts.ts; if they appear in models.dev the catalog picks them up
automatically.

This reverts commit 6d725b1ecd7c896a08c3c58dbb37afeaf34bb31e, keeping the
regenerated catalog and the effort-precedence change.

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

* fix(llms): default unknown Claude ids to adaptive thinking when catalog options are missing

Reintroduce the id-based fallback with the forward-compatible policy the
ecosystem converged on (vercel/ai#17804 for @ai-sdk/anthropic's capability
lookup; opencode's transform.ts after repeated allowlist misses for
opus-4.7, sonnet-5, and opus-5): when catalog reasoningOptions metadata is
unavailable, treat unrecognized Claude ids as newer than the known model
list and use adaptive thinking, since new Claude releases reject the manual
wire shape. Known legacy families (Instant, 2.x, 3.x, and name-first 4.0-4.5)
keep manual, as do non-Claude Anthropic-compatible ids and unknown Claude
ids carrying an explicit numeric budget (a custom-endpoint signal).

Unlike the earlier reverted allowlist (which defaulted unknown ids to
manual), this fails open for future models: claude-opus-4-6:1m-style
variants and next year's Claude work without a code change.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 20:34:13 -07:00
Saoud Rizwan d1462cf919 fix(llms): retry empty model turns on all providers, not just Ollama (#12927)
* fix(llms): retry empty model turns on all providers, not just Ollama

Production telemetry shows 'Model returned empty response' hard failures
on hosted backends (openrouter, cline, openai-compatible endpoints), not
just local Ollama — 46 tasks / 120 events in 24h on the SDK extension vs
~0 on legacy, which has its own empty-response fallback.

The retry-empty-response middleware already existed but was wired only
into the Ollama vendor. Move the wrap to the central AI SDK composition
point (createAiSdkProvider in ai-sdk.ts), where every vendor's model is
constructed, so all providers get it: retry only when a turn produced
genuinely nothing (no text, no reasoning, no tool call), tool-call-only
turns are never retried, non-empty turns stream through live, and error/
token-limit finishes pass through unchanged. Vendors can opt out or tune
attempts via ProviderFactoryResult.retryEmptyResponses. The agent
runtime's loud failure after persistently empty turns is unchanged.

* docs(sdk): drop changelog edit — release commits own the changelog

v0.0.69 is already published; its section must not be edited
retroactively. The next release commit will describe this change.

* ci: re-trigger checks (flaky Windows runner test timeouts)

* ci: re-trigger checks (flaky Windows runner test timeouts)

* fix(llms): classify stream parts exhaustively, buffer retry attempts, aggregate usage

Review follow-up (dominiccooney): the retry predicate and the response
parser were two independent, incomplete interpretations of the
LanguageModelV4StreamPart union, and rejected attempts leaked structural
parts and dropped billable usage.

- stream-part-classification.ts is now the single exhaustive boundary:
  every part is converted content, explicitly unsupported output,
  structural metadata, stream-start, finish, or error, with a never
  check so new AI SDK part types fail compilation. Retry eligibility
  derives from it: only turns with no output at all are retried;
  unsupported-but-real output (custom, reasoning-file, source,
  provider-executed tool-result) is never retried.
- Generated file parts are converted end to end: emitAiSdkEvents emits
  a new file AgentModelEvent and the agent runtime assembles it onto
  the assistant message (image part for image/*, file part otherwise),
  so a file-only turn is no longer an empty message. The legacy
  ApiStream bridge explicitly skips file events (no chunk type).
- Each retry attempt is buffered until it proves non-empty (first
  output or error part), so discarded attempts leak nothing — one
  retried request produces one clean stream with exactly one
  stream-start.
- finish.usage from discarded attempts is aggregated field-by-field
  (cache and reasoning detail included) into the emitted finish, so a
  three-request turn reports three requests' worth of tokens.
2026-08-04 20:33:06 -07:00
Saoud Rizwan 49d33aa793 fix(vscode): show cwd-relative tool paths in the chat view (#12900)
* fix(vscode): show cwd-relative tool paths in the chat view

The SDK message translator copied the model's absolute file paths straight
into the ClineSayTool messages, so chat cards like "Cline wants to read this
file" showed full absolute paths. Relativize them against the task's cwd for
display (classic getReadablePath behavior: relative inside the cwd, basename
for the cwd itself, absolute when outside), including apply_patch's
"*** Update File:" markers which DiffEditRow parses for its headers.

Also restores the readFile card's click-to-open target by setting content to
the absolute path, matching the classic extension.

* refactor: apply display-path relativization as a single ClineSayTool transform

Instead of threading cwd through every case of sdkToolToClineSayTool, leave
the tool mapping untouched and apply one toDisplaySayTool transform (with a
filesystem-path tool whitelist) at the points where tool cards are emitted.
Same behavior, much smaller footprint; MCP/unknown tools keep their exact
prior behavior.

* fix: keep absolute readFile open-target untouched on Windows; match '..' as whole segment

path.resolve(cwd, absPath) rewrites a drive-less absolute path onto the
current drive on Windows, breaking the readFile card's click-to-open target
(and the tests asserting it). Guard with path.isAbsolute instead.

Also match '..' only as a whole path segment in toDisplayPath so an in-cwd
entry literally named '..config' is not misclassified as outside the cwd
(greptile P1).

* fix: keep Desktop-fallback paths absolute; relativize '*** Move to:' destinations

When VS Code has no workspace open, getWorkspaceRoot() falls back to the
Desktop; classic getReadablePath deliberately keeps full absolute paths in
that case so the user can see where operations occur. Restore that guard in
toDisplayPath.

Also enroll PATCH_MARKERS.MOVE in relativizePatchPaths so a rename renders
both source and destination relative (covers the split-patch path too).
2026-08-04 20:03:07 -07:00
Saoud RizwanandSaoud Rizwan 9b5dcc6405 Fix Bedrock prompt caching: emit Converse cachePoint markers instead of anthropic cache_control (#12928)
* Fix Bedrock prompt caching: emit Converse cachePoint markers instead of anthropic cache_control

The Bedrock provider manifest routed prompt caching through the
anthropic-cache-control format, so requests carried cache_control
provider options that @ai-sdk/amazon-bedrock silently drops - its
Converse message converter only reads providerOptions.bedrock.cachePoint.
Bedrock never received a cache checkpoint, cacheRead/cacheWrite were
always 0, and a stray top-level cache_control field leaked into the
Converse request body.

Adds a bedrock-cache-point prompt-cache format that attaches a
message-level cachePoint marker to the last user message, which the
converter appends as a cachePoint content block, caching the whole
prefix up to it.

Fixes #12913

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

* Format gateway.test.ts assertion

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 20:00:16 -07:00
f2ac1ef10a fix(desktop): show skills in slash command menu (#12894)
* fix(desktop): show skills in slash command menu

* fix(core): normalize runtime slash command names

* fix(core): disambiguate colliding slash commands

* fix(core): preserve same-kind slash commands

* fix(core): stabilize colliding slash command aliases

* fix(core): avoid slow runtime command regex

* fix(core): remove quadratic hyphen trim

* fix(core): prefer skills over workflows on slash command collisions

Workflows are effectively deprecated in favor of skills, so when a
workflow's normalized name collides with a skill the skill now owns the
token and the workflow is dropped. This removes the collision
qualification machinery (-skill/-workflow/-hash aliases), which silently
renamed established CLI and VS Code command tokens, and removes the
duplicate-token throw that sat in the CLI send path, the hub snapshot
capability, and the desktop list_user_instruction_configs command.
Same-kind collisions resolve to the first entry of the deterministic
(name, id) sort, stable across discovery order.

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

* fix(vscode): resolve typed workflow filenames through record ids

Name normalization broke the legacy /my-workflow.md fallback for
workflows renamed via frontmatter: the configured record name (e.g.
"Ship It") no longer compares equal to the normalized command token
("ship-it"), so a typed filename stopped expanding. Match the discovered
record to its runtime command by the stable record id instead, keeping
the canonical-name comparison as a fallback for callers that pass
records without ids.

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

* fix(core): normalize snapshot names in hub slash command proxy

The hub-side proxy normalizes the typed token but compared it against
snapshot command names verbatim. Snapshots served by older clients carry
raw configured names (e.g. "Ship It"), which could previously exact-match
typed input and would now never match. Normalize both sides of the
comparison so mixed-version hub setups keep resolving.

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

* fix(desktop): expand slash commands in the sidecar send path

Selecting a skill or workflow from the desktop slash menu inserted the
token but the sidecar dispatched it verbatim, so the model received
literal text like '/publish-ui write docs' instead of the configured
instructions. handleSend now expands a leading runtime slash command via
the core user-instruction service before dispatch (mirroring the CLI's
buildUserInputMessage), keeping the raw token as the session's display
prompt. Built-in webview commands (/fork, /team) and unknown tokens pass
through unchanged, and discovery failures fall back to the raw prompt.

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

* fix(desktop): expand slash commands when editing queued prompts

Editing a pending prompt stored the raw slash token, which the runtime
later delivered to the model unexpanded — only the initial send path
went through expandRuntimeSlashCommand. handleUpdatePendingPrompt now
expands a leading skill/workflow token before persisting the update,
matching the enqueue behavior in handleSend.

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

* fix(core): preserve Unicode letters in slash command tokens

Normalization stripped all non-ASCII characters, so a skill named 发布
got an unrelated generated token while typing /发布 could never resolve —
a regression from pre-normalization behavior where the exact name
matched. Keep Unicode letters and numbers in normalized tokens and only
collapse whitespace and symbol runs into hyphens.

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>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-04 19:59:05 -07:00
Saoud Rizwan 10da3bf5d6 fix: emit AI SDK 7 image shapes and correct the claude-code peer range (#12901)
* fix(shared): emit AI SDK 7 file parts for images

formatMessagesForAiSdk still built the retired shapes: user images as
{type:'image'} message parts and tool-result images as {type:'image-data'}
content parts. AI SDK 7 auto-migrates both at runtime, but logs a
DeprecationWarning through process.emitWarning on every image-bearing
request, and the shims are slated for removal in the next major.

Emit the canonical shapes instead: {type:'file', data, mediaType} for
user images and {type:'file', data:{type:'data', data}, mediaType} for
tool-result media. mediaType is required on file parts, so URL-backed
images without a known type use the bare 'image' top-level segment,
which AI SDK 7 resolves per provider.

* fix(llms): allow the AI SDK 7 major of ai-sdk-provider-claude-code peer

The AI SDK 7 upgrade moved the ai-sdk-provider-claude-code
devDependency to ^4 but left the peer range at ^3.4.3, so consumers
resolving the peer would install the AI SDK 6 (Provider V3) major.
Align the peer range with the version the package is built against.
2026-08-04 19:53:51 -07:00
Saoud Rizwan 0034efe48c fix(llms): send max_completion_tokens for reasoning models on OpenAI-compatible endpoints (#12902)
* fix(llms): send max_completion_tokens for reasoning models on OpenAI-compatible endpoints

* fix(llms): require leading boundary in gpt-5 model-id pattern

* docs(llms): add maintenance notes to reasoning-era model-id patterns
2026-08-04 19:42:44 -07:00
Saoud RizwanandSaoud Rizwan 64993e78d5 fix(llms): substitute image content for models without image support (#12903)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 17:34:44 -07:00
Saoud RizwanandSaoud Rizwan 06f31f2821 fix(llms): route Bedrock foundation models through geo inference profiles (#12926)
* fix(llms): route Bedrock foundation models through geo inference profiles

AWS Bedrock offers no on-demand throughput for newer foundation models;
they must be invoked through an inference profile. The SDK Bedrock vendor
passed model ids through unmodified, so every request with a bare modern
model id (e.g. anthropic.claude-sonnet-4-6) failed with "Invocation of
model ID ... with on-demand throughput isn't supported".

Resolve the wire-level model id in the Bedrock vendor: honor the existing
useCrossRegionInference / useGlobalInference settings (already plumbed
through provider config but previously ignored), and auto-prefix bare ids
of models known to have no on-demand throughput so they work without the
toggle. Ids that are already profile-prefixed, ARNs, and custom-model
configurations are never rewritten; unknown regions fall back to the raw
id. Country profiles (jp./au.) are preferred over apac. where the model
catalog shows AWS ships them.

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

* fix(llms): future-proof Bedrock profile-required model patterns

Match Anthropic tier-first naming generically (excluding the frozen
legacy claude-3-*/claude-v2/claude-instant naming schemes) instead of
enumerating tier names, so future profile-only Claude tiers work without
pattern-list updates. Also cover the profile-only Amazon Nova 2 series.

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

* fix(llms): gate Bedrock geo profiles on catalog availability

Address review feedback on inference-profile resolution:

- Never manufacture apac. (or other unconfirmed) profile ids: prefer a
  catalog-confirmed variant among the region's candidates (jp./au./apac.),
  and otherwise keep the raw id so AWS returns the actionable on-demand
  error instead of "provided model identifier is invalid". Profile-only
  models still fall back to us./us-gov./eu. prefixes, where AWS reliably
  ships geo profiles for such models.

- Drop the customModelBaseId short-circuit: legacy migration copies the
  base id without the custom-selected flag, so its presence must not
  disable profile routing for a normal catalog model. Custom/provisioned
  ids stay raw on the cross-region path because no catalog variant can be
  confirmed for them, and ARN-based custom models were already passed
  through.

Adds a per-region wire-id table test, an injected-catalog apac test, and
a stale-customModelBaseId regression test.

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

* fix(llms): require catalog confirmation for every Bedrock geo profile

Remove the us./us-gov./eu. pattern fallback: AWS documents
inference-profile availability per model and geography, so no geographic
prefix is assumed valid without a catalog-confirmed variant (the catalog
had bare amazon.nova-lite/micro/pro ids with no geo variants, which the
fallback would have rewritten to unconfirmed eu./us. ids). The pattern
list now only gates eligibility for automatic routing; the catalog
always picks the actual prefix, and the raw id is preserved when no
variant is confirmed.

Adds boundary tests asserting pattern-matched models without confirmed
variants stay raw (with and without cross-region inference), plus
injected-catalog positive tests for us-gov. and future tier-first ids.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 17:34:13 -07:00
Saoud RizwanandSaoud Rizwan 71e6b44ef7 Consolidate plugin display-name resolution into @cline/shared (#12905)
* Fix installed plugins all displaying as "index" in the desktop app

Hoist getPluginDisplayName (nearest-ancestor package.json name with
basename fallback) into @cline/shared storage paths, re-export it via
@cline/core, and replace the duplicated copies in cline-hub, the CLI
TUI, and VS Code marketplace helpers. Fix the desktop sidecar and
'cline config plugins', which still named plugins by entry-file
basename, so package-backed installs showed up as "index".

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

* Use shared getPluginDisplayName in desktop sidecar after #12933

Merging main brought in PR #12933, which fixed the desktop plugin
naming with another local copy of the helper. Drop that copy in favor
of the shared @cline/shared implementation this branch introduces, and
remove the node:path imports it needed.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 17:25:36 -07:00
Saoud RizwanandSaoud Rizwan 0bcd602150 fix: task with attachments not resetting on New Task click (#12924) (#12937)
The webview posts an optimistic say:'task' message carrying the user's
images/files, and only clears it once an identical authoritative message
arrives from the extension. emitInitialTaskMessage omitted attachments,
so the optimistic copy was never confirmed and withPendingUserMessage
kept re-injecting the old task into the transcript even after New Task
cleared it - leaving the chat permanently stuck on the previous task.

- Include images/files on the authoritative initial task message so the
  optimistic pending copy is confirmed and cleared as designed.
- Defensively drop any unconfirmed optimistic message in startNewTask so
  an explicit New Task click always yields a clean slate.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 17:02:27 -07:00
Saoud RizwanandSaoud Rizwan d759f4c646 fix(cli): track external git branch changes in the TUI status bar (#12930)
* fix(cli): track external git branch changes in the TUI status bar

The branch shown below the prompt was read once at startup and only
refreshed after an agent turn, so checkouts made from another terminal
or an editor left the TUI showing a stale branch (#12911).

Watch the repo's git dir for HEAD changes (git replaces HEAD via
rename, so a directory watch is used) and refresh the status bar
immediately, with a slow 5s poll as a fallback for filesystems where
fs.watch is unreliable. State updates are skipped when nothing changed
to avoid needless re-renders.

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

* refactor(cli): replace subprocess polling with stat-based HEAD backstop

Drop the unconditional 5s git-subprocess poll from useRepoStatus. The
fs.watch directory watcher stays for instant updates where the runtime
delivers HEAD events, but Bun on Linux drops them, so add fs.watchFile
on the HEAD file as the backstop: one in-process stat() every 2s that
only triggers git subprocesses when HEAD actually changed. Verified in
the Bun-run TUI that external checkouts show up within ~2s.

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

* refactor(cli): simplify HEAD watching to a single fs.watchFile

Drop the fs.watch directory watcher (Bun on Linux never delivers its
HEAD events, making it dead weight on the runtime the CLI ships on) and
the debounce it required. watchGitHead now just stat-watches the single
.git/HEAD file via fs.watchFile, which survives git's rename-based HEAD
updates and works on network mounts.

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

* refactor(cli): use a plain 5s poll for repo status

Remove the HEAD watcher entirely per review preference for minimal
code: root.tsx now just polls readRepoStatus every 5 seconds, skipping
state updates (via isSameRepoStatus) when nothing changed so idle ticks
don't re-render the app.

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

* fix(cli): skip repo status poll ticks while a read is in flight

Bounds concurrent git subprocesses when a read exceeds the 5s interval
(slow git on huge repos) and prevents an older completion from
overwriting newer status. Addresses Greptile review feedback.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 16:50:30 -07:00
Saoud RizwanandSaoud Rizwan b035bf9255 fix(desktop): give chat message actions breathing room under the last line (#12921)
The copy/fork (and user copy/edit/restore) action row was pulled up 8px
(-translate-y-2), which made the icons collide with the descenders of the
message's last line of text. Reduce the raise to 4px (-translate-y-1) so the
actions sit with a small, deliberate gap under the message content while
still hugging the message closely enough to read as attached to it.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 16:49:09 -07:00
Saoud RizwanandSaoud Rizwan ca9cb7f554 Slow hero heading verb rotation from 2.6s to 5s (#12940)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 16:27:12 -07:00
Saoud Rizwan 1ae2f71a0f fix(telemetry): dedupe sdk.error across layers and rate-limit repeated failures (#12931)
* fix(telemetry): dedupe sdk.error across layers and rate-limit repeated failures

Every provider failure was emitted twice — once by the model layer
(provider.stream, handled: true) and again verbatim by the agent loop
(agent.run, handled: false) — and unattended retry loops emitted the
same failure every iteration, unbounded. 24h of CLI data: 300K events
from 1.7K users, top 10 machines at 70% of volume.

Two changes:

- The agent loop no longer re-reports model stream failures. run-failed
  events carry errorClass exactly when the run failed on a model stream
  error, and the model layer already reports those at its own error
  boundary — so the run loop reports only failures that originate in
  the loop itself (empty response, max iterations, ...).

- captureSdkError caps identical failures per process: 5 per hour per
  (event, component, operation, error_type, normalized message), with
  digit runs collapsed so retry counters coalesce. Suppressed emissions
  surface as suppressed_count on the next emission after the window
  rolls over. In-memory only; the cap never blocks reporting.

Event name, attributes, and all call sites are unchanged;
suppressed_count is the only additive field.

* fix(telemetry): make sdk.error dedup ownership explicit and key limiter on status/code

Review follow-ups (#12931):

- Reporting ownership is now an explicit signal instead of being inferred
  from errorClass. captureSdkError returns whether the failure was
  recorded, the model layer forwards that as errorReported on the finish
  event, and the run loop skips only failures marked reported. Custom
  AgentModel implementations that never call captureSdkError leave the
  bit unset, so their failures still produce exactly one sdk.error from
  the run loop (regression test added).

- The rate-limit key now includes the structured error_status and
  error_code that normalizeSdkError already extracts, so an HTTP 429
  hot loop cannot consume an HTTP 401's budget even though their
  messages differ only by digits (tests added for both fields).

- resetSdkErrorRateLimiterForTests is tagged @internal; it stays
  re-exported because package test suites can only reach it through the
  package entry point.
2026-08-04 16:25:44 -07:00
Saoud Rizwan 62c57a0ccd ci(ui): publish @cline/ui without a manual approval gate (#12938)
The publish job ran under the shared `Publish` GitHub environment, whose
required reviewers turned every @cline/ui release into a two-person
ceremony. Nothing in the job reads secrets from that environment — it
authenticates to npm purely over OIDC trusted publishing — so the
environment bought us an approval prompt and nothing else. sdk-publish
and cli-publish already publish unattended the same way.

The npm trusted publisher for @cline/ui was registered with
`environment: Publish`, which pins the OIDC token's environment claim, so
it has been re-registered without it (same repo, workflow file, and
permissions). That change is already live; landing this without it would
have broken publishing.

Access is still gated by workflow_dispatch (write access required), the
`refs/heads/main` ref check, and the typed `publish` confirmation.
2026-08-04 16:23:46 -07:00
Saoud Rizwan f3c8b6748b Remove model-initiated plan-to-act switching from the VS Code extension (#12929)
* Remove model-initiated plan-to-act switching from the VS Code extension

Match the legacy extension: the model can no longer call switch_to_act_mode
to move itself from plan mode to act mode. The user must flip the Plan/Act
toggle manually. The CLI keeps the tool and its prompt unchanged.

- Stop registering the switch_to_act_mode extra tool in plan-mode sessions
  and drop the pending-mode-change queue, beforeModel stop hook, and idle
  apply path that existed only for the tool-initiated switch.
- Add a planModeSwitchTool option to buildClineSystemPrompt (default true,
  CLI output unchanged) and a PLAN_MODE_INSTRUCTIONS_MANUAL_SWITCH variant
  that directs the model to ask the user to toggle to Act mode instead of
  calling a tool it does not have; the extension passes false.
- The user-driven toggle path (togglePlanActModeProto), including
  auto-continue when a completed plan is presented, is unchanged.

* Use a generic completing-tool name in translator retag test

Review feedback: submit_and_exit is a yolo-mode tool and does not exist
in plan/act sessions. The test exercises tool-agnostic translator
behavior, so use a neutral example name and clarify the comment.
2026-08-04 16:09:05 -07:00
e6104cfd2c feat(desktop): capture application errors in telemetry (#12893)
* feat(desktop): capture application errors in telemetry

* fix(desktop): deduplicate errors across reporting layers

* fix desktop error telemetry fallback

* fix(desktop): skip idle transport-close reports and reuse http endpoint helper

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 15:42:28 -07:00
12431cd97b fix(cli): claim connector instance before socket connect (#12765)
* fix(cli): claim connector instance before socket connect

Prevent racing foreground or detached connector launches from
both opening socket-mode with the same bot token by exclusively
claiming the state file via tryClaimConnectorStateFile before
connecting, and exit with CONNECT_ALREADY_RUNNING_EXIT_CODE when
another live instance already holds the claim.

* serializes stale-generation replacement without a removable mutex

* lint

* fix

* base

* fix(cli): keep pre-claim Slack state files manageable

State files written by CLI versions that predate connector claiming have
no claimId, so requiring it in readConnectorState made a live legacy
connector invisible: stop deleted its state without stopping it, which
let the next connect open a second socket-mode connection with the same
bot token. Treat claimId as optional metadata; claiming itself never
relied on the validator.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 15:41:19 -07:00
Bee 2b34b48ec6 fix(desktop): plugin package names (#12933) 2026-08-04 15:38:28 -07:00
Mikołaj Kondratek dc7eb755cf chore(telemetry): drop capture defs @cline/core owns, keep agent identity on late tool events (#12914)
* chore(telemetry): remove duplicate capture defs owned by @cline/core

The cline-core bundle layer (apps/vscode, which also compiles into the
cline-core.js that JetBrains runs) still carries event constants and
public capture methods inherited from the legacy architecture. Where
@cline/core now emits the same event, the bundle-layer twin is a second
capture path on the same telemetry service — which is how the
task.provider_api_error double-emission happened (cline/cline#12820,
follow-up to the removals in cline/cline#12818).

Removes 20 such methods and the EVENTS constants only they referenced:

- 15 whose signal @cline/core emits today (task lifecycle, tokens, tool
  and skill usage, auth start/success/failure, opt-out, workspace init,
  and summarize_task, which core replaced with task.compaction_*).
- 3 obsolete on both architecture lines: captureModelSelected (its
  model_selected signal survives as an action on
  captureOnboardingProgress), captureRulesMenuOpened, captureHostEvent.
- 2 whose trigger moved into core/sdk, so this layer can no longer
  observe them and re-wiring here would be wrong:
  captureWorkspacePathResolved (core already owns workspace.path_resolved)
  and captureGeminiApiPerformance (providers live in core; generic
  provider-timing events supersede it).

Deliberately NOT removed: capture methods with no caller here but a live
caller on legacy-extension. Those emit signals originating in this bundle
(webview UI, VS Code storage, host terminal, checkpoints, focus chain,
legacy-task migration), so core cannot emit them and the missing piece is
a call site on this line, not a redundant definition. They are the
SDK-parity backlog and are flagged as such in the file.

Verified against the JetBrains plugin repo: it references none of these
methods or event names, and no proto surface changes.

Also drops the unused TokenUsage interface, the taskTurnCounts and
taskToolCallCounts maps (only deleted methods wrote to them), and EVENTS
constants that were already orphaned before this change.

Tests that only exercised a removed method are gone; tests that used one
merely as a vehicle for provider/metadata assertions now use a surviving
method, so that coverage is preserved.

* fix(telemetry): keep agent identity on events dispatched after session teardown

A small share of task.tool_used events (~285 of 229k over 48h on
extension_variant=next) arrive without any agentId/agentKind/isSubagent
attributes. Root cause: AgentEventBridge.dispatchAgentEvent resolves
identity solely from the live-session map (AgentEvent metadata never
carries agentId in practice), and session teardown deletes the map entry
before the agent's run fully drains — dispose/stopSession paths can skip
or fail agent.shutdown() without aborting first. Late events from the
still-draining run then hit the session-map miss branch, which passed no
identity at all, so buildTelemetryAgentIdentity returned undefined and
the event was emitted bare.

Fix: snapshot the identity stamped on each session's events while the
session is registered (bounded FIFO map) and reuse it on a session-map
miss. Purely additive — no event is added, removed, or renamed; the
live-session and sub-agent paths emit byte-identical properties.
2026-08-04 22:44:19 +02:00
BeeandSaoud Rizwan accd7e5809 feat(sdk): add session initiation mode and lazy session persistence (#12807)
* feat(sdk): add session initiation mode and lazy session persistence

- Introduce top-level `StartSessionInput.mode` (`user`, `automation`, `subagent`, `team`) alongside `source`, so persisted history records both the client surface and how the session began; missing mode defaults to `user`.
- Make root-session persistence lazy: starting a runtime allocates the session ID in memory without creating a database row, manifest, or messages artifact. The first accepted user turn persists that same ID, so closing a runtime before any user turn leaves no empty history entry, and persistence never allocates a replacement ID for unknown sessions.
- Require automation runtime adapters to explicitly persist `mode: "automation"` for every run.
- Document the provenance model in `sdk/ARCHITECTURE.md`, update the VS Code session factory comment, and add tests for the automation runtime handlers.

* fix(sdk): persist automation trigger source as session provenance

The runtime adapters stopped writing the cron request source into the
session row when source became the client surface, which silently
dropped the spec-defined trigger label. Record it as
sessionHistoryOrigin.trigger instead, surface it in the messages-file
origin, and sort the new history-origin import in HubRuntimeHost.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-04 13:29:36 -07:00
BeeandSaoud Rizwan 400ba47387 fix(llms): switch ollama provider package (#12892)
* fix(ollama): use native AI SDK provider

* fix(ollama): patch ollama-ai-provider-v2 wire contracts and lock them with real-provider tests

The pinned ollama-ai-provider-v2@4.0.1 breaks four native Ollama wire
contracts (review findings on #12892). Patch the package via Bun
patchedDependencies:

- omit think from the request when no reasoning setting resolves,
  instead of forcing think: false (lets the server default apply)
- surface mid-stream {"error": ...} objects as error stream parts with
  an error finish reason, instead of dropping them before a clean finish
- serialize attachment-only user turns as string content (""), not []
- include the documented tool_name field on tool result messages

Add ollama.wire.test.ts exercising doStream through the vendor module
against the real (patched) package with a stubbed fetch, asserting on
the actual /api/chat request bodies and parsed stream so regressions in
the dependency's request converter or stream parser are caught.

* fix ollama model list refresh

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-04 13:28:53 -07:00
3af23c1c4c fix(cli,core): stop duplicate connector launches (#12770)
* fix: prevent duplicate connector launches during doctor/connect

Mark connectors as starting before the hub daemon spawns so autostart
skips in-flight instances, and improve doctor process filtering with
container-aware namespace/cgroup checks plus detached log rotation.

* Update apps/cli/src/connectors/common.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* feat(hub): supervise connector processes

* feat(connectors): enable tools by default, and stop replaying the Slack greeting

Tools were on by default only for Telegram (via --no-tools); Slack, Discord,
Linear, Google Chat and WhatsApp all required an explicit --enable-tools. All
six now default to tools on and opt out with --no-tools.

--enable-tools still parses everywhere, including Telegram which never accepted
it, so deployed scripts, systemd units and persisted autostart arguments keep
working. Passing both resolves to the safer answer: --no-tools wins. This also
affects hub/webview starts, which never emitted a tools flag and so ran those
five connectors with tools off.

Slack no longer posts the "Connected to Cline." first-contact message. It was
gated on per-thread welcomeSentAt, so a connector restart or a cleared history
made the next user message look like first contact and replayed the greeting.
The host mechanism is unchanged and the other adapters still greet.

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

* fix(connectors): recover a thread whose session is wedged mid-run

A connector thread keeps a long-lived mapping to a hub session. When that
session's runtime still had a run in flight and no abort had been requested,
every message in the thread came back as "SessionRuntime.shutdown called while a
run is in progress" instead of an answer, and stayed that way until someone
cleared the binding by hand. Observed on the Cline Mom Slack bot after a stack
restart.

The connector host already recovers from a session the hub no longer knows
about: it forgets the mapping and replays the turn once against a fresh session.
This widens the trigger from "session not found" to "session cannot serve
another turn" via isUnusableSessionError, so a wedged runtime takes the same
path.

The shutdown error now carries a stable code (SessionRunInProgressError,
session_run_in_progress) so callers can recognise it structurally. The predicate
also matches on message, because an error reaching a connector has crossed the
hub's JSON boundary and arrives as a bare message - and because a host commonly
runs a hub and CLI of different versions. Ordinary run failures still propagate
untouched: replacing the session on those would hide real errors and drop the
conversation.

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

* fix(connectors): serialise turns that share a session

Answering "what happens if I message the bot in another thread while it is still
replying": channel threads were already independent, but DMs were not.

findBindingForThread deliberately reuses one binding — and therefore one runtime
session — for every message in a DM channel, so a DM stays one continuous
conversation. The turn queue, though, was keyed by thread id, and a DM thread id
carries the message timestamp. Two messages in flight in the same DM therefore
got two independent queues and ran concurrently against a single session, which
fails with "shutdown called while a run is in progress" or interleaves two
conversations in one session history.

The queue key now follows the same identity rule as the binding lookup, via
resolveThreadTurnQueueKey next to findBindingForThread so the two cannot drift.
DM messages queue behind each other on the shared session; channel threads keep
their own key and still run in parallel. Applied to all six adapters, which all
had the same mismatch.

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

* fix(core): abort an in-flight run before tearing its session down

Where the Slack bot's "plugin-sandbox process exited (code=null, signal=SIGTERM)"
came from, and its "shutdown called while a run is in progress" sibling: both are
one event, a session released while a run was still going.

stopSession aborts the agent first "so shutdown can proceed", but callers that
reach shutdownSession or releaseSessionRuntime another way did not - hub
dispose() on a restart being the one that hurt. Without an abort the runtime
refuses to shut down, that error is rethrown from the cleanup, and the plugin
sandbox is SIGTERMed while tool calls are still pending, so those calls reject
with "plugin-sandbox process exited". A connector turn awaiting the run reports
whichever surfaced first instead of answering.

Both paths now abort and let the run drain before shutting the agent, runtime and
sandbox down, guarded on session.aborting so callers that already aborted do not
abort twice.

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

* fix(connectors): stop announcing "Steering current task."

Every follow-up sent while the bot was replying added an acknowledgement line to
the thread, and the wording overstated what happens: the host treats delivery
"steer" the same as "queue", enqueuing the prompt for the session rather than
injecting it into the loop already running. The follow-up is now handed over
silently.

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

* fix(core): retire a dead supervised entry before replacing it

A start arriving while an instance sat in backoff left the old entry's
restart timer live. The timer closes over the old entry object, so when
it fired it spawned a second process for the same (channel, instanceId)
- untracked by the supervisor's map, so invisible to list() and
unreachable by stop() - two connectors holding one bot token, which is
the exact failure supervision exists to prevent. Its exit handler then
kept reaping the live instance's state and rescheduling restarts.

The same window exists before the timer is even scheduled: the
exit-cleanup chain runs first, and a replacement made mid-chain would be
followed by a restart scheduled for the retired entry.

start() now retires a dead existing entry explicitly - cancel its timer,
mark it stopped, drop its exit listener. Both the timer callback and the
cleanup chain already stand down on "stopped", so one mark covers both
phases.

* fix(core): serialise supervisor start/stop and wait for stopped processes to die

Found by exercising a hub restart against a live webhook connector: the
new hub's boot reconnect restarts the adopted survivor - which suspends
inside stop() on the CLI cleanup - while the user's `cline connect`
arrives as connector.start. With no per-instance serialisation the two
starts interleaved across that suspension and both spawned. The map
tracked one process while the other lived on untracked, holding the
connector's webhook port; the tracked chain crash-looped on EADDRINUSE
through all five attempts and ended state=failed, while the ghost kept
running with no way to reach it through list() or stop().

Two changes:

- start/stop (and the backoff-restart spawn) now run under a per-
  instance-key promise queue, so one instance has exactly one lifecycle
  operation in flight. The exit-cleanup chain also stands down when its
  entry is no longer the one in the map.
- stop() waits for the process to actually die after SIGTERM (bounded,
  then SIGKILL) instead of returning while it still holds its listen
  port - the race that turned the double-spawn into a crash loop, and
  that could burn a backoff cycle on any webhook connector restart.

process.kill is now injectable (killProcess), which also stops the test
suite from signalling arbitrary real pids like 600 on the host.

Verified live: the same kill-hub-then-reconnect sequence now converges
to one tracked running process, with the concurrent user start
correctly answered "already running under the hub".

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-04 13:28:18 -07:00
dependabot[bot] ce04e80909 chore(deps): bump rand (#11231)
Bumps the cargo group with 1 update in the /apps/examples/desktop-app/src-tauri directory: [rand](https://github.com/rust-random/rand).


Updates `rand` from 0.9.2 to 0.9.4
- [Release notes](https://github.com/rust-random/rand/releases)
- [Changelog](https://github.com/rust-random/rand/blob/0.9.4/CHANGELOG.md)
- [Commits](https://github.com/rust-random/rand/compare/rand_core-0.9.2...0.9.4)

---
updated-dependencies:
- dependency-name: rand
  dependency-version: 0.9.4
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 12:53:15 -07:00
Saoud RizwanandSaoud Rizwan 058c8c90a2 feat(desktop): ship a single universal macOS DMG instead of per-arch downloads (#12923)
Tauri's universal-apple-darwin target lipos the Rust binary but expects
sidecars to already be fat binaries, so build-sidecar-bin.ts now compiles
both Bun slices and merges them when the target triple is universal.
The publish workflow builds one universal bundle instead of a two-leg
matrix, verifies every Mach-O in the bundle carries both slices, and the
updater manifest points both darwin-aarch64 and darwin-x86_64 at the same
universal artifact so existing per-arch installs migrate automatically.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 12:42:55 -07:00
Saoud RizwanandSaoud Rizwan 0f7e4b66ef Add themes to the CLI (#12899)
* Add user-selectable color themes to the CLI TUI

Adds a theme system to the interactive TUI (cline -i):
- New tuiTheme global setting persisted in global-settings.json
- Built-in themes: Auto (terminal-adaptive, default), Cline Dark,
  Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin
  Mocha, One Dark, Solarized Dark, Solarized Light
- /theme command, command palette entry, and a Theme row in
  /settings General tab, all opening a live-preview theme picker
- Named themes paint their background, default foreground, accents,
  syntax highlighting, and derived diff colors across the TUI
- CLINE_THEME env var overrides the persisted theme at startup

Closes #12872

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

* Widen theme picker dialog and label column

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

* Format theme picker

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

* Give each theme a descriptive picker blurb

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

* Theme all main-surface components instead of static palette colors

The ask-question / tool-approval element, toasts, queued prompts,
autocomplete dropdown, chat error cards, searchable lists, and the
onboarding screens hardcoded the brand palette (act blue, selection
highlight, black-on-selection text) and fixed dark grays, so they
ignored the active theme.

- ResolvedTheme gains selection/textOnSelection; the selected-row text
  flips between black and white by WCAG contrast against the accent
- Inline ask-question / tool-approval, Toast, QueuedPrompts,
  AutocompleteDropdown, SearchableList, and chat error cards now use
  theme accents and the themed selection pair
- Onboarding screens derive subtle borders/details from the theme
  background instead of #333333/#555555, and use themed accents
- Dialog surfaces (settings, pickers, history) intentionally keep their
  static dark surface styling

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-04 12:26:00 -07:00
Bee f1c45b63c8 feat(desktop): add token usage warning colors (#12919)
* feat(desktop): add token usage warning colors

* context info
2026-08-04 11:17:20 -07:00
DeachandDominic Cooney d6d1c789b3 fix: correct Linux keybinding label in Plan/Act mode tooltip (#11067)
* fix: correct Linux keybinding label in Plan/Act mode tooltip

On Linux, event.metaKey maps to the Super (Win) key, not Alt.
detectMetaKeyChar was returning "Alt" for Linux, causing the Plan/Act
mode toggle tooltip to display "Alt+Shift+A" instead of "Super+Shift+A".

Fixes #11026

* fix: update platformUtils.spec.ts Linux test expectation to Super

---------

Co-authored-by: Dominic Cooney <dominic.cooney@cline.bot>
2026-08-04 10:44:18 -07:00
oab24413gmaiandMira Sato 3e81006863 docs: capitalize GitHub in security note (#11088)
Co-authored-by: Mira Sato <275437409+oab24413gmai@users.noreply.github.com>
2026-08-04 10:11:29 -07:00
John Choi 8ea52a2eea fix(desktop): make agent header draggable (#12910)
* fix(desktop): make agent header draggable

* fix(desktop): keep read-only title draggable
2026-08-04 09:21:22 -07:00
John Choi 5ec2d47b21 refactor(ui): extract agent prompt queue (#12791)
* feat(ui): extract agent prompt queue

* fix(ui): keep prompt queue usable on failures

* fix(ui): surface prompt queue action failures

- report failed edit, steer, and remove callbacks inline as a row alert
- disable all queue actions while any action is in flight
- mark the busy row aria-busy and accept readonly item arrays

* fix(ui): preserve prompt queue failures

* chore(ui): drop unrelated formatting changes

* refactor(ui): style prompt queue with Tailwind

* style(ui): remove redundant prompt queue reset
2026-08-03 20:01:37 -07:00
Saoud RizwanandSaoud Rizwan 46fcde0a96 ci(desktop): drop the Rust build cache from the code-signing job (#12898)
* ci(desktop): drop the Rust build cache from the code-signing job

The `build` job is the only one that can read the Apple Developer ID
certificate and the Tauri updater signing key, and it restored a
swatinem/rust-cache archive before running them. A restored cache archive
is attacker-controlled the moment the Actions cache is poisoned, which is
the pivot used against this repo's nightly workflow in Feb 2026 and the
reason actions/cache was stripped from the credential-bearing publish
jobs at the time. This workflow was added months later and reintroduced
the pattern. The updater key is the worst thing here to leak: it signs
every auto-update the installed desktop app accepts.

The cache was also not buying anything. Across the eight runs of this
workflow, seven logged "No cache found" on both matrix legs; only the run
32 minutes after another one hit, saving 1-3 minutes. A release cadence
measured in days does not outlive the entry under the repo's 10 GB LRU
eviction, so the steady state was a cold build regardless. Cold builds
took 5-7 minutes against a 90-minute timeout.

No behaviour change otherwise: the step had no id and no outputs, so
nothing referenced it.

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

* ci(desktop): trim the cache-removal comment to the constraint

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-03 19:16:23 -07:00
John Choi fbaa44be96 refactor(ui): extract agent ask question (#12790)
* feat(ui): extract agent ask question

* fix(ui): harden ask-question presentation

- dedupe repeated model-supplied options so keys stay stable
- mark pending items aria-busy to match the approval card
- expose the accent palette as overridable custom properties

* fix(ui): polish ask question feedback

* fix(ui): label follow-up question sections

* refactor(ui): style ask question with Tailwind
2026-08-03 18:55:12 -07:00
John Choi aec350bb9d refactor(ui): use Tailwind for shared components (#12719)
* refactor(ui): extract desktop approval card

* fix(ui): preserve approval card parity

* refactor(ui): keep approval labels fixed

* refactor(ui): use Tailwind for shared components

* test(ui): cover each Tailwind component source

* refactor(ui): migrate approval card styles

* fix(ui): preserve host and quick-action behavior

* fix(ui): preserve component hover behavior

* fix(ui): preserve selected option hover

* fix(ui): isolate embedded Tailwind contract

* fix(ui): reset approval button block padding

* chore(ui): bump preview package version
2026-08-03 18:28:57 -07:00
oab24413gmaiandMira Sato 16129cf90a docs: capitalize GitHub in security note (#10723)
Co-authored-by: Mira Sato <275437409+oab24413gmai@users.noreply.github.com>
2026-08-03 17:34:27 -07:00
3520786f4a security: hygiene sweep — docs pin lifts, example next bump, workspace overrides (closes ~153 Vanta findings) (#12749)
* security: docs/examples/tooling hygiene sweep — lift fix-blocking pins, bump example next, workspace overrides

VMP 2026-07-30 quarterly run, PR 3 of the condensed worklist (closes ~153 Vanta findings).

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

* chore(deps): refresh lockfiles for security updates

* fix(deps): keep Discord on patched Undici 6

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-03 17:21:03 -07:00
Bee 8f0ff70215 chore(llms): upgrade to AI SDK 7 (#12891)
* chore(llms): upgrade to AI SDK 7

* fix(llms): include Codex provider during builds

* fix(llms): address AI SDK 7 runtime regressions
2026-08-03 17:10:41 -07:00
Saoud RizwanandSaoud Rizwan 0619e5a016 fix(cli): deliver Telegram slash commands to the connector command host (#12888)
The @chat-adapter/telegram library intercepts any message whose leading
entity is a bot_command and routes it to slash-command handlers instead
of the mention/subscribed-message handlers. The Telegram connector
registered no onSlashCommand handler (unlike Discord and Slack), so
commands like /clear were consumed by the library and silently dropped.

Register a slash-command handler that rebuilds the originating chat
thread and forwards the original message text (preserving @bot
addressing for group chats) into the same turn pipeline as regular
messages, so connector commands reach the chat command host.

Fixes #12871

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-03 15:57:41 -07:00
Dominic CooneyandCline Agent 2a0dd197bf chore(vscode): remove dead next-gen model classifier (#12887)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-04 07:10:04 +09:00
Tomás Barreiro 82e3596f96 Add a script to do dev work on ACP (#12886) 2026-08-03 22:21:16 +02:00
OctopusandCline Agent 895ab53b78 fix(llms): inherit MiniMax default from models.dev (#11218)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-03 21:23:44 +02:00
Tran Binh MinhandMikołaj Kondratek b8f51b9e55 fix(vscode): surface a clear error when a provider has no API key (#12859)
* fix(vscode): surface a clear error when a provider has no API key

A key-based provider with no API key sends the request without an Authorization header, and the provider's raw 401 reached the chat panel unclassified because reshapeErrorForWebview falls through to the raw message and ClineError's auth regexes do not match it. Rewrite the missing-Authorization-header case into actionable guidance naming the provider, alongside the existing model-not-found matcher. Matching is limited to the no-header signature so a present-but-wrong key is never relabelled as missing, and no preflight is added because authMethod misclassifies local providers and 175 of 179 builtins resolve keys from the environment.

* fix: don't name a fallback provider in the missing-key message

reshapeErrorForWebview defaults providerId to "cline" for its
ClineError-JSON branches, but state.activeProviderId() can be undefined —
the missing-credential message would then blame the cline provider for a
key it doesn't take. Keep the "cline" fallback for the JSON branches and
pass the raw id to the credential matcher, which now only names a provider
it was actually given.

---------

Co-authored-by: Mikołaj Kondratek <19799111+mkondratek@users.noreply.github.com>
2026-08-03 11:27:08 -07:00
Michael Gasperini 25dc89eab4 feat(vscode): recognize Chutes provider (#12068) 2026-08-03 11:16:09 -07:00
Mikołaj Kondratek 5fdd840d5f refactor(llms): classify typed AI SDK errors before the structural walk (#12814)
* refactor(llms): classify typed AI SDK errors before the structural walk

Add a typed pre-pass to classifyProviderError that recognizes real AI SDK
error instances via their symbol-based isInstance() guards: RetryError
unwraps to its last attempt, APICallError is judged on message/responseBody/
data with its typed statusCode as the sole authoritative status,
TypeValidationError on the payload in value, and any other AISDKError
recurses into its cause. The detection rules (overflow patterns, provider
codes, rate-limit vetoes, invalid-request status gate) are extracted into a
shared verdict function used unchanged by both the typed pass and the
existing structural walk, which remains the fallback for gateway-forwarded
plain-JSON payloads that only name an AI SDK error (ENG-2394).

* fix(llms): classify a RetryError by its final attempt even when untyped

When a RetryError's last attempt was not a typed AI SDK error, the typed
pre-pass fell back to structurally walking the whole wrapper, letting
signals from earlier (retried-away) attempts veto or fake the final
attempt's verdict — e.g. a retryable 429 on attempt one vetoing a plain
overflow rejection on the final attempt. Walk the final attempt alone
instead; a RetryError with no recorded attempts still falls back to the
plain structural walk.

* fix(llms): gate typed APICallError verdicts on the authoritative statusCode

verdictFromSignals checks the explicit context_length_exceeded code before
the rate-limit veto and the invalid-request status gate, so a typed
APICallError with statusCode 429 or 500 whose body echoed that code was
still classified as an overflow, contradicting the branch's contract that
the typed statusCode is the sole authoritative status. Gate the whole
payload verdict on the typed statusCode first (absent a statusCode the
payload still decides), and cover the explicit-code case at 429/500/400
with real instances.
2026-08-03 06:40:40 -07:00
Mikołaj Kondratek cdcaa74422 feat(sdk): detect and recover from context-window overflow errors (#12804)
* feat(sdk): detect and recover from context-window overflow errors

Port of the legacy arch's context-window-exceeded handling to the SDK
arch (the SDK arch previously surfaced these as raw unclassified stream
errors with no recovery; see ENG-2394 root-cause investigation).

- llms: new classifyProviderError() walks the raw provider error
  structure (AI SDK wrappers, gateway value.error_message, responseBody,
  cause chains) and classifies it before extractErrorMessage flattens
  it. Reuses the legacy detectors' message patterns with rate-limit
  vetoes and an invalid-request status gate.
- shared: ProviderErrorClass union; errorClass on the model finish
  event, run-failed event, runtime snapshot, and prepare-turn contexts.
- core: prepare-turn overflowRecovery flag forces a compaction that
  bypasses the token-estimate trigger (the estimate just proved wrong)
  and runs the deterministic basic strategy directly, so recovery never
  depends on another successful LLM request. New overflow_recovery
  compaction mode in status notices and compaction telemetry.
- agents: on a classified overflow the runtime force-compacts and
  retries once per run, emitting a status notice. Terminal states fail
  with actionable messages instead of raw provider dumps: nothing to
  compact (first-prompt overflow), no prepare-turn pipeline, or a retry
  that still overflows. The doomed request is not re-sent when forced
  compaction cannot shrink the transcript.
- telemetry: task.provider_api_error gains errorClass and
  task.provider_stream_failed gains error_class, populated from the
  same classification, so context-overflow failures become countable.

* fix(core): keep overflow-recovery compaction deterministic with custom compactors

A session-supplied compaction.compact previously took precedence over
the overflow_recovery basic-strategy branch, so an LLM-backed custom
compactor could hit the same context overflow mid-recovery. The custom
compactor still gets first shot (it sees mode overflow_recovery and
owns its transcript invariants), but if it throws or declines, basic
compaction now runs so recovery never depends on another successful
LLM request. Cancellation still propagates.

* fix(core): fall back to basic compaction when a custom compactor does not shrink during overflow recovery

A custom compactor that returns unchanged or larger messages would
previously satisfy the recovery branch, and the runtime would then
reject the retry as non-shrinking and fail terminally even though
basic compaction could still prune the transcript. Recovery now
treats a non-shrinking custom result like a decline and runs basic
compaction.

* fix(core): hold custom overflow-recovery compaction to the recovery token target

A custom compactor result that was only marginally smaller than the
input passed the shrink check, skipped the basic fallback, and spent
the run's single recovery retry on a request that still could not fit.
The custom result is now accepted only when it is strictly smaller AND
within the recovery token target basic compaction aims for; otherwise
basic compaction runs.

* fix(core): reject empty custom compaction results during overflow recovery

An empty transcript from a custom compactor passed both the shrink and
token-target checks (trivially smaller, zero tokens) and suppressed the
basic fallback, so the retry would have been sent without the request
it was supposed to re-send. The acceptance bar now covers the full
input space in one predicate: non-empty AND strictly smaller AND within
the recovery token target.

* feat(core): expose the turn abort signal to custom compactors

CoreCompactionContext now carries the prepare-turn abort signal, so a
custom compact implementation that calls a model or external service
can observe cancellation instead of blocking the turn (including the
overflow-recovery path) on a stalled request. Builtin strategies
already received the signal via providerConfig; this closes the gap
for custom compactors across auto, manual, and recovery modes.

* fix(sdk): classify provider errors from registered ApiHandler models

Registered handlers (VS Code LM and any other host-supplied provider)
reach the runtime through createAgentModelFromApiHandler, which flattens
failures to a message string — so context-window rejections on that path
were never classified and never entered overflow recovery.

- The adapter now classifies at its own error boundary, where the raw
  error is still structured (status codes, response bodies), for both
  thrown errors and failed done chunks. Aborts stay unclassified.
- The runtime falls back to classifying the finish message when a model
  supplies no class, so custom AgentModel implementations are covered
  too.
- Hold the custom-compactor acceptance check to token estimates on both
  sides instead of mixing serialized length with a token target, and
  document why the runtime's shrink backstop keeps a serialized-size
  proxy (the shared estimator is linear in characters, so the verdict is
  identical) with a TODO to surface real estimates from prepareTurn.
- Drop the now-unused errorClass parameter from captureProviderApiError:
  #12820 removed core's capture site, so host adapters own that event.

* test(core): reuse the handler harness for the overflow classification case

The hand-rolled throwing generator had no yield, which biome's
correctness/useYield rejects as an error (the repo's lint gate runs on
sdk/ and apps/, and biome does not honor the eslint require-yield
directive the existing harness carries). fakeHandler now accepts the
error to throw, so the new case reuses it instead.
2026-08-03 09:03:22 +02:00
Mikołaj Kondratek 5acc98474a fix(mcp): refresh lists on list_changed notifications instead of toasting (#12619)
* fix(mcp): refresh lists on list_changed notifications instead of toasting

Servers emit notifications/tools/list_changed in bursts (a toolset change
or shutdown can produce a dozen at once), and the fallback notification
handler surfaced every one of them as a host toast, flooding the user
with identical messages (ENG-2298, found testing the JetBrains IDE MCP
server integration).

Handle tools/resources/prompts list_changed notifications by refreshing
the corresponding cached lists, debounced 300ms per server and list
kind, then pushing the update through notifyWebviewOfServerChanges() so
the webview and the SDK session tool-list check pick it up. Downgrade
remaining unhandled notification types to logger output.

* fix(mcp): guard list_changed refreshes against races and failed fetches

Address review: serialize per-key refreshes by chaining onto any
in-flight one, so overlapping fetches can't complete out of order and
publish a stale list. Make the fetch helpers return undefined on
failure (instead of an empty list) so the refresh path can keep the
previous cached list and skip the webview notification, rather than
erasing valid entries on a transient error; connect-time call sites
keep their old empty-list fallback.

* fix(mcp): drop in-flight list refresh when the connection was replaced

Address review: refreshChangedList captured the connection object before
awaiting the list fetches, so a reconnect mid-fetch wrote the result to
the removed connection while the replacement kept its own state. Re-check
connection identity after the fetches and drop the result when it
changed — the replacement fetched fresh lists at connect time, after the
change that produced the notification, so the in-flight result is older.

* fix(mcp): retry failed list refreshes and publish state after reconnect

Address review. A list_changed notification consumes the server's change
signal, so a transiently failed refresh left the cached list stale until
the next notification; retry with exponential backoff (1s/2s/4s, max 3)
per server and list kind, with a fresh notification superseding any
pending retry. Also publish server state after a successful streamable
HTTP reconnect: connectToServer() loads fresh lists but never sent them,
leaving the webview on 'connecting' with pre-reconnect capabilities.

* fix(mcp): don't restart a live connection when post-reconnect publish fails

Address review: the post-reconnect notifyWebviewOfServerChanges() sat
inside the connect retry loop's try block, so a publication failure
(e.g. a settings file read error) was treated as a transport failure
and re-ran connectToServer() against the already-live connection,
leaking its client/transport. Publication now happens outside the
connect try/catch and only logs on failure.

* fix(mcp): drop superseded in-flight list refreshes instead of publishing

Address review: a newer list_changed notification queued its refresh
behind one already in flight without invalidating it, so the older run
could briefly publish an obsolete list (and churn the SDK session)
before the newer refresh corrected it. Each schedule now starts a new
generation per server+kind; a run whose generation is no longer current
skips fetching (when caught early), drops its result before publishing,
and doesn't schedule retries — the superseding refresh covers it.

* fix(mcp): harden list refresh and reconnect publication paths

Address review (post-reconnect publish failure leaving consumers stuck
on 'connecting' with stale lists) plus an adversarial pass over the
whole change to close the remaining gaps in one batch:

- Retry publications bounded (publishServerChanges) after a successful
  reconnect AND in both terminal disconnected paths, which are equally
  terminal; never throw from handleError, whose promise transport.onerror
  discards. Guard the stdio/SSE onerror publishes the same way.
- Treat an undeclared capability or a method-not-found answer as an
  authoritatively empty list instead of a retryable failure, so servers
  without e.g. resources/templates/list don't burn the full retry ladder
  on every list_changed notification.
- Retry when the fetch succeeded but the webview publish failed: the
  cache is updated but consumers haven't seen it.
- Cap debounce deferral at 2s so a sustained sub-300ms notification
  stream can't starve the refresh indefinitely.
- Cancel pending refresh timers in deleteConnection; return 'skipped'
  (not 'failed') when a fetch failure coincides with connection
  teardown or supersession, so no retry fires against a replacement
  connection that already fetched fresh lists.
- Clear the pre-existing toolListChangeDebounceTimer in dispose().

* fix(mcp): supersede in-flight refreshes during connection teardown

Address review: deleteConnection removes the connection from
this.connections only after awaiting transport/client close, so a list
refresh completing inside that window passed its identity check and
published state for a connection being torn down. Bump the per-key
generation at the start of deleteConnection so any in-flight refresh is
superseded and drops its result; bumping (never resetting) keeps
generations monotonic across reconnects.

* fix(mcp): close reconnect-retry and teardown-publication races

Address review (cline-cloud):

1. The streamable HTTP reconnect loop revalidated only after the first
   backoff. Later retries could resurrect a server removed or disabled
   from settings during a delay, or displace a replacement connection
   another path had installed — connectToServer() drops a same-name
   connection without closing it, leaking its transport. The loop now
   revalidates before every attempt: it aborts when a live replacement
   exists (our own original connection and the 'disconnected' husk left
   by our own failed attempt don't count) or when fresh-read settings no
   longer define the server as enabled (isStillWanted callback; a
   settings read failure keeps the chain alive).

2. deleteConnection removed the connection from this.connections only
   after awaiting transport/client close, so a publication passing its
   suspension points inside that window could still serialize and
   publish the dying connection's state. The connection is now removed
   from published state before the close handshake is awaited.

The exhausted-retries test's partial-connection mock now carries status
'disconnected', matching what connectToServer's error path actually
leaves behind — that status is what distinguishes our own husk from a
live replacement.

* fix(mcp): don't displace an OAuth-required replacement during reconnect retries

Address review: the retry loop's replacement guard treated every
'disconnected' connection as our own failed-connect husk. An
OAuth-required connection is also 'disconnected' but retains its
client, transport, and authProvider for authentication — a retry that
displaced it would orphan that session and clobber the pending auth
state. Distinguish by client presence: the husk's creation sites set
client: null, so a 'disconnected' connection holding a client is a
replacement and aborts the retry chain.

* fix(mcp): distinguish OAuth replacements by flag, not client presence

Address review: an ordinary post-registration connect failure leaves a
'disconnected' connection with its (already-closed) client still
attached, so the client-presence check classified it as an OAuth-style
replacement — aborting the reconnect chain after a single failure,
including our own retries. Discriminate on server.oauthRequired
instead: only the OAuth-required connection retains live
client/transport/authProvider state worth protecting; ordinary failed
connections closed their client before being marked disconnected, so
retrying past them displaces nothing live. The exhausted-retries test
mock now carries the real husk shape (closed client attached) to pin
this regression.

* test(mcp): cover retry succeeding after a failed attempt's registered connection

Requested in review: the guard must recognize the 'disconnected'
connection a failed non-OAuth connectToServer() leaves behind (closed
client still attached) as our own attempt, and the following retry must
proceed and succeed.

* fix(mcp): don't retry reconnects with a config settings no longer define

Address review: a retry reconnects with the config captured at
connection creation, so if the user changed the server's config during
a backoff delay (and the watcher's reconnect with the new config
failed, leaving a disconnected husk our guard rightly retries past),
the retry would resurrect the obsolete URL/headers/command — and a
successful stale connection would contradict settings until the next
file touch. isStillWanted now also compares the captured config against
current settings via configsRequireRestart (connection-relevant fields
only), aborting the chain when they differ: the settings watcher owns
reconnection after a config change.
2026-08-03 07:51:04 +02:00
Bee 53a5266239 feat(desktop): show token usage in input toolbar (#12803)
* feat(desktop): show token usage in input toolbar

Load per-model context window sizes from the provider catalog and
pass the active model's limit down to ChatInputBar. Render a token
ring that visualizes current token usage against the model's context
window, and hydrate token usage plus cumulative cost from messages
and chat_usage events so the indicator stays accurate across turns
and session reloads. Add tests covering the ring rendering and usage
hydration.

* bigger ring

* move submit button to input box

* fix

* add cost tracker

* fix

* fix queued turn cost tracking
2026-08-02 17:19:33 +02:00
Saoud Rizwan 1654517614 ci(desktop): gate desktop publish secrets behind PublishDesktop environment (#12854)
* ci(desktop): gate desktop publish secrets behind PublishDesktop environment

The Apple signing/notarization and Tauri updater secrets were repository
secrets, readable by any workflow in the repo and by anyone with push
access via a branch carrying a modified workflow. Move them behind the
PublishDesktop environment, which requires reviewer approval and
restricts deployments to main.

The build job now declares the environment, so those secrets are readable
only there and only after an approval. Add a preflight check because a
missing secret fails dangerously rather than loudly: Tauri silently skips
code signing when APPLE_CERTIFICATE is empty and skips notarization when
APPLE_API_KEY is empty, so a misconfigured environment would still
publish an unsigned, un-notarized bundle. Only a missing updater key was
already caught, by the .sig check in Collect artifacts.

validate stays ungated so a bad tag fails in seconds rather than after an
approval, matching the ungated-build/gated-publish split in
ext-vscode-ab-package. The shared Slack and telemetry secrets stay where
they are; scoping them to this environment would silently empty them in
the CLI, SDK, and extension publish workflows.

* ci(desktop): verify signing secrets are not repository-scoped

The preflight added in the previous commit checks that the signing
secrets are non-empty, which proves presence but not scope, and then
reported that they had resolved from PublishDesktop. An environment-gated
job resolves repository and organization secrets too — environment values
merely take precedence — so a credential left at repository level would
pass that check while the message claimed the migration had worked. This
workflow already demonstrates it: the gated build job reads the shared
Slack and telemetry secrets, none of which are on the environment.

Add the complementary check to validate, which declares no environment: a
signing secret that resolves there can only be repository- or
organization-scoped, so it fails the run and names the offenders. Neither
check establishes provenance alone; together they do. validate is
ungated, so a misplaced secret now fails before the approval rather than
after it.

Also drop the provenance claim from the build message and correct the
skill doc, which stated that a repository-level secret would be invisible
to the gated job.

Reported by greptile on #12854.
2026-08-02 01:55:24 -07:00
Saoud Rizwan bbfcbcd31d chore(vscode): bump to 4.1.3 for stable release 2026-08-01 22:11:03 -07:00
Saoud Rizwan 102ef1ab84 chore(desktop): release v0.0.8 2026-08-01 21:53:13 -07:00
Saoud Rizwan 55d1476169 chore(cli): release v3.0.49 2026-08-01 21:37:00 -07:00
556 changed files with 88875 additions and 21470 deletions
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
+40 -10
View File
@@ -9,7 +9,7 @@ Use this skill when the user asks to release the desktop app, publish Cline Code
> 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.
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user.
## Release contract
@@ -17,7 +17,7 @@ Desktop releases are macOS-only today (signed + notarized DMG for Apple Silicon
- 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 workflow creates the `desktop-vX.Y.Z` GitHub release (universal DMG + updater artifact + `latest.json`) and refreshes the rolling `desktop-latest` release, which is the static auto-update feed every installed app polls. Never delete the `desktop-latest` release or tag.
- The changelog's top `## X.Y.Z` section is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
@@ -90,9 +90,22 @@ gh workflow run desktop-publish.yml -f git_tag=desktop-vX.Y.Z -f confirm_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.
**The run pauses for approval.** `validate` runs immediately, then the `build`
job waits on the `PublishDesktop` environment until a required reviewer approves
it — the run sits in `waiting`, which is expected, not a hang. Approve it in the
run's web UI ("Review deployments"), or:
If the workflow fails on missing credentials, see "Repo secrets (one-time setup)" below.
```sh
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
--method POST -f state=approved -f comment="desktop vX.Y.Z" \
-F 'environment_ids[]=19152605990' # PublishDesktop
```
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`), verifies every Mach-O in the bundle carries both slices, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release, refreshes `desktop-latest/latest.json`, and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
9. Verify the update feed after the run succeeds.
@@ -100,17 +113,30 @@ If the workflow fails on missing credentials, see "Repo secrets (one-time setup)
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.
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new `desktop-vX.Y.Z` universal `.app.tar.gz` asset (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps — including older per-arch installs — pick the update up on next launch or within 2 hours.
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)
## Publish 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):
These live on the **`PublishDesktop` environment**, not at repository level, so
only the `build` job can read them and only after an approval. Set them under
Settings → Environments → PublishDesktop → Environment secrets. The environment
also restricts deployments to `main` and requires a reviewer.
Adding one of these as a *repository* secret is the common mistake. The build
would still succeed — an environment-gated job resolves repository secrets too,
with environment values simply taking precedence — so the credential would sit
repo-wide while everything looked fine. `validate` therefore fails the run if any
of them resolves in a job with no environment. If you hit that, delete the
repository-level copy rather than duplicating it.
If a secret is missing everywhere, the preflight in `build` fails the run naming
the missing entries. The Apple values 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 |
| --- | --- |
@@ -124,4 +150,8 @@ signing & notarization" section for how to obtain them):
| `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.
`ERROR_SERVICE_API_KEY`, OTEL settings) are shared with the CLI, SDK, and extension
publish workflows and already configured. **Do not move these into
`PublishDesktop`** — scoping them to this environment empties them in every other
publish workflow, silently, with no error beyond missing telemetry and a failed
Slack post.
+168 -23
View File
@@ -31,6 +31,45 @@ jobs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
# Companion to the presence check in `build`, and the half that actually
# establishes scope. This job declares no environment, so a signing secret
# that resolves here can only be a repository or organization secret —
# meaning it is still readable by every workflow in the repo, which is the
# thing the PublishDesktop environment exists to prevent. Neither check
# proves provenance alone (an environment-gated job resolves repository
# secrets too, with environment values merely taking precedence), but
# together they do: empty here plus present in `build` means the value came
# from the environment.
- name: Verify signing secrets are not repository-scoped
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
unscoped=()
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -z "${!name}" ] || unscoped+=("$name")
done
if [ ${#unscoped[@]} -gt 0 ]; then
echo "These signing secrets resolve in a job with no environment:"
printf ' - %s\n' "${unscoped[@]}"
echo
echo "That means they are still repository or organization secrets and"
echo "are readable by any workflow in this repo. Delete them at that"
echo "level and add them to the PublishDesktop environment instead."
exit 1
fi
echo "No signing secret resolves outside the PublishDesktop environment."
- name: Checkout code
uses: actions/checkout@v4
with:
@@ -79,19 +118,59 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
build:
name: Build macOS (${{ matrix.arch }})
name: Build macOS (universal)
needs: validate
# The Apple signing/notarization and Tauri updater secrets live in the
# PublishDesktop environment rather than at repository level, so they are
# readable only by this job and only once a required reviewer approves the
# run. Defense in depth: this `if` is advisory because a dispatched branch
# runs its own copy of this file; the enforced gate is the PublishDesktop
# environment's deployment-branch policy, which must also allow only main.
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
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:
# A secret missing here is dangerous rather than merely broken: Tauri skips
# code signing when APPLE_CERTIFICATE is empty and skips notarization when
# APPLE_API_KEY is empty, both silently, so the build would still succeed
# and publish an unsigned, un-notarized bundle. Only the missing updater
# key is caught later (by the .sig check in "Collect artifacts"). Fail up
# front instead, before any build work, if the environment is misconfigured.
- name: Verify PublishDesktop secrets are present
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_CONTENT \
APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing from the PublishDesktop environment:"
printf ' - %s\n' "${missing[@]}"
echo
echo "Check that every secret above is set on the PublishDesktop"
echo "environment and that this job still declares"
echo "'environment: PublishDesktop'."
exit 1
fi
# Deliberately not phrased as "resolved from PublishDesktop": a
# non-empty value here could also be a repository or organization
# secret. The repository-scope check in `validate` is what rules that
# out.
echo "All 8 signing secrets are present."
- name: Checkout code
uses: actions/checkout@v4
with:
@@ -102,16 +181,18 @@ jobs:
with:
bun-version: "1.3.13"
# A universal (fat) macOS bundle needs both architecture slices, so
# install both Rust targets; `tauri build --target universal-apple-darwin`
# compiles each and lipos the results into one binary.
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Cache Rust build
uses: swatinem/rust-cache@v2
with:
workspaces: apps/examples/desktop-app/src-tauri
key: ${{ matrix.target }}
# No Rust build cache here, deliberately. This is the only job that can
# read the Apple signing certificate and the Tauri updater key, and a
# restored cache archive is attacker-controlled the moment the Actions
# cache is poisoned.
- name: Install dependencies
run: bun install
@@ -142,8 +223,22 @@ jobs:
- 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
run: bunx tauri build --target universal-apple-darwin --config src-tauri/tauri.release.conf.json
env:
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
# this step and inlines these values into the binary via `--define`
# (scripts/telemetry-define-args.ts); a packaged app launched from
# Finder/the Dock has no runtime env, so build-time inlining is the
# only way the shipped sidecar can ever report telemetry.
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 }}
# Developer ID signing (Tauri imports the cert into a temp keychain)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -157,14 +252,64 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Tauri lipos the main binary itself but sidecars are merged by our own
# build-sidecar-bin.ts, so assert every Mach-O in the bundle really
# carries both slices before anything is published. A single-arch
# sidecar would otherwise ship fine and only crash on the other arch.
- name: Verify bundle is a universal binary
working-directory: apps/examples/desktop-app
run: |
APP="src-tauri/target/universal-apple-darwin/release/bundle/macos/Cline Code.app"
if [ ! -d "$APP" ]; then
echo "app bundle not found at $APP"
exit 1
fi
for bin in "$APP/Contents/MacOS/"*; do
archs=$(lipo -archs "$bin")
echo "$bin: $archs"
case "$archs" in
*arm64*x86_64*|*x86_64*arm64*) ;;
*)
echo "$bin is not a universal binary (archs: $archs)"
exit 1
;;
esac
done
# Guardrail: assert the telemetry config actually made it into the
# compiled sidecar. Missing env on the build step (or a regression in
# the --define inlining) would otherwise ship a release with telemetry
# silently disabled — exactly what happened for every release before
# this check existed. Being enabled is not enough on its own: an empty,
# malformed, or non-http(s) OTLP endpoint would still drop every event
# at runtime (the SDK exporters speak OTLP http/json only), so the
# selfcheck must also report a usable endpoint host.
- name: Verify sidecar telemetry config was inlined
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-universal-apple-darwin --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build, sign, and notarize desktop bundle' step and the"
echo "--define inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL, so"
echo "every event would be dropped at runtime. Check the"
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- 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"
BUNDLE_DIR="src-tauri/target/universal-apple-darwin/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
@@ -173,22 +318,22 @@ jobs:
echo "no DMG produced under $BUNDLE_DIR/dmg"
exit 1
fi
cp "$DMG" "$OUT/Cline-Code_${VERSION}_${ARCH}.dmg"
cp "$DMG" "$OUT/Cline-Code_${VERSION}_universal.dmg"
TARBALL=$(find "$BUNDLE_DIR/macos" -name '*.app.tar.gz' -print -quit)
if [ -z "$TARBALL" ] || [ ! -f "${TARBALL}.sig" ]; then
echo "updater artifact or signature missing under $BUNDLE_DIR/macos"
exit 1
fi
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_${ARCH}.app.tar.gz.sig"
cp "$TARBALL" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz"
cp "${TARBALL}.sig" "$OUT/Cline-Code_${VERSION}_universal.app.tar.gz.sig"
ls -lh "$OUT"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.arch }}
name: desktop-universal
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
-1
View File
@@ -102,7 +102,6 @@ jobs:
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
+1
View File
@@ -92,3 +92,4 @@ apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
apps/examples/desktop-app/webview/next-env.d.ts
apps/examples/desktop-app/.cursor/settings.json
+117
View File
@@ -1,5 +1,122 @@
# Changelog
## [4.1.8]
### Added
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
- Support Fable 5 on Vertex.
### Changed
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
### Fixed
- Respect your configured max output tokens when the compaction summarizer requests a summary.
- Remove the stale "Double-Check Completion" feature tip.
## [4.1.7]
### Added
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
- Bring back a copy button on turn-final response rows.
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
### Changed
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
### Fixed
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
- Settle the turn phase when a mode switch aborts a running turn.
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
## [4.1.6]
### Added
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
### Fixed
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
## [4.1.5]
### Added
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
### Changed
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
## [4.1.4]
### Added
- Recognize Chutes as a provider.
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
### Changed
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
### Fixed
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
- Show installed plugins under their real package names instead of all appearing as "index".
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
## [4.1.3]
### Fixed
- Stop the two bundles of the combined rollout package from invalidating each other's Cline account session. A still-open legacy window that refreshed its token after the machine was promoted to the new extension would consume the shared refresh token, producing spurious "Unauthorized" / re-authenticate prompts and unexpected sign-outs. Promoted legacy windows now keep working on their current session and offer a one-time Reload Window prompt instead.
- Fall back to the default Cline model when migrating a setup that references a model id the new extension doesn't recognize, instead of leaving the provider unconfigured.
- Restore reliable checkpoints: checkpoints are created consistently, and restoring one now rewinds the whole workspace rather than a subset of files.
- Keep settings edits that are made before the provider config finishes loading — base URLs, API keys, and the Qwen/Moonshot API line are no longer silently discarded.
- Stop losing keystrokes in custom base URL fields, and keep the custom URL checkbox state after a failed clear.
- Use the AskSage custom API URL at inference time instead of ignoring it.
- Settle a pending tool approval when an edited message replaces the session, so the task no longer hangs waiting on a prompt that is gone.
- Drop attachments from messages that have been edited.
- Complete terminal commands when the shell execution ends, so tasks no longer stall on commands that already finished.
- Include untracked files when generating commit messages.
- Run Windows Store PowerShell profiles correctly.
- Surface the upstream provider error when a gateway-forwarded stream fails, instead of a generic failure.
- Retry empty Ollama responses at the model boundary, and raise the response-start timeout to 5 minutes so cold model loads no longer error out.
- Show proper display names for Cline free models and recommended models in the model picker.
- Preserve video input capability for models that support it.
- Keep the plan/act input border in sync with the actual textarea focus.
## [4.1.2]
### Added
+1 -1
View File
@@ -7,7 +7,7 @@ We're thrilled you're interested in contributing to Cline. Whether you're fixing
Bug reports help make Cline better for everyone! Before creating a new issue, please [search existing ones](https://github.com/cline/cline/issues) to avoid duplicates. When you're ready to report a bug, head over to our [issues page](https://github.com/cline/cline/issues/new/choose) where you'll find a template to help you with filling out the relevant information.
<blockquote class='warning-note'>
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">GitHub security tool to report it privately</a>.
</blockquote>
+61
View File
@@ -1,5 +1,66 @@
# Cline CLI Changelog
## 3.0.52
- Added `cline mcp uninstall` for removing an installed MCP server
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
## 3.0.51
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
## 3.0.50
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
- Auto-approval settings are now honored over ACP
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
## 3.0.49
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
- Checkpoints are actually created again; a run-boundary regression meant none were ever recorded in the CLI (from SDK v0.0.69)
- Checkpoint restore is now a full workspace rewind: files Cline created during the task come back at their checkpoint-time content and files created after the checkpoint are removed, while `.gitignore`d paths (build output, `node_modules`, `.env`) are left alone (from SDK v0.0.69)
- After a restore, the rewound message is prefilled as plain text instead of the raw `<user_input mode="act">` envelope
- Ollama's response-start timeout is now 5 minutes instead of 30 seconds, so cold-loading a large local model no longer errors out mid-load (from SDK v0.0.69)
- Empty Ollama responses are now retried instead of failing the task with "Model returned empty response" (from SDK v0.0.69)
- Migrated users whose stored Cline model id isn't in the catalog now fall back to the default model instead of sending an unknown model id on every request (from SDK v0.0.69)
- The ClinePass promo dialog can be dismissed with any key (Enter still opens the subscription page), and it is marked as shown when it appears, so force-quitting no longer replays it on every launch
- Opening a URL no longer crashes the CLI on hosts without an opener binary (headless Linux without `xdg-open`); WSL2 containers now use `xdg-open`, Windows tries the absolute PowerShell path first, and `cline doctor log` converts Linux paths to `\\wsl$` UNC paths
- The hub now restarts through the installed wrapper after a Unix self-update, so npm cannot reuse a deleted cached executable
- ACP: ClinePass is selectable as a provider, organizations can be selected, session resolution and text rendering on session restart are fixed, and agent errors now describe the actual failure
- Provider errors forwarded through the Vercel AI Gateway now surface the real upstream message instead of a raw Zod dump or `[object Object]` (from SDK v0.0.68)
- Cline free models and recommended models now show their real display names in the model picker (from SDK v0.0.68)
- Sessions rooted at the filesystem root (`/`) no longer fail every command (from SDK v0.0.68)
- On Windows, PowerShell commands now travel over UTF-8 stdin, so non-ASCII commands survive the active code page and long commands are not capped by the command-line limit (from SDK v0.0.68)
- The live model catalog no longer drops the video input capability (from SDK v0.0.68)
- Removed the CLI promo code flow
## 3.0.48
- `cline history` now opens inside the existing TUI, with resume and delete actions, instead of rendering a second view in the same process
+4 -2
View File
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
Schedule agents on cron-like intervals or external events.
If `--provider` and `--model` are omitted, schedules use the last configured
provider and model. If only `--provider` is given, the schedule uses that
provider's saved model.
```sh
cline schedule create "Daily code review" \
--cron "0 9 * * MON-FRI" \
--prompt "Review PRs opened yesterday and summarize issues." \
--workspace /path/to/repo \
--provider cline \
--model openai/gpt-5.3-codex \
--timeout 3600 \
--tags automation,review
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.48",
"version": "3.0.52",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Launch the Cline CLI in ACP mode from source, for use as a Zed custom agent.
#
# Zed spawns agents without your interactive shell's PATH, so `bun` (installed
# via mise/asdf/nvm/homebrew) is usually not resolvable. This wrapper finds bun
# explicitly and execs it from the repo root.
#
# IMPORTANT: stdout is the JSON-RPC channel. Never echo to stdout here — any
# stray byte corrupts the ACP stream. Diagnostics go to stderr.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
# Prefer an explicit override, then PATH, then common version-manager locations.
if [ -n "${BUN_BIN:-}" ]; then
bun_bin="$BUN_BIN"
elif command -v bun > /dev/null 2>&1; then
bun_bin="$(command -v bun)"
else
bun_bin=""
for candidate in \
"$HOME"/.local/share/mise/installs/bun/*/bin/bun \
"$HOME"/.bun/bin/bun \
/opt/homebrew/bin/bun \
/usr/local/bin/bun; do
if [ -x "$candidate" ]; then
bun_bin="$candidate"
break
fi
done
fi
if [ -z "$bun_bin" ]; then
echo "acp-dev.sh: could not find the 'bun' executable; set BUN_BIN to its path" >&2
exit 127
fi
cd "$REPO_ROOT"
exec "$bun_bin" --conditions=development --cwd apps/cli dev --acp "$@"
+32 -2
View File
@@ -46,6 +46,11 @@ import {
authenticateAcpProvider,
isAcpAuthMethodId,
} from "./auth";
import {
AUTO_APPROVE_CONFIG_ID,
buildAutoApproveConfigOption,
parseAutoApproveValue,
} from "./auto-approve";
import {
buildOrganizationConfigOption,
fetchClineOrganizations,
@@ -75,6 +80,8 @@ interface SessionState {
currentProviderId: string;
/** Current model id for the session. */
currentModelId: string;
/** When true, all tool calls are approved without asking the client. */
autoApproveTools: boolean;
/** Active session manager for the running agent, if any. */
sessionManager?: ClineCore;
/** Internal session id within the session manager. */
@@ -100,12 +107,17 @@ export class AcpAgent implements Agent {
private sessions = new Map<string, SessionState>();
private readonly conn: AgentSideConnection;
private readonly providerSettingsManager = new ProviderSettingsManager();
private readonly defaultAutoApproveTools: boolean;
/** Set after a successful `authenticate` call. */
private authResult?: AcpAuthResult;
constructor(conn: AgentSideConnection) {
constructor(
conn: AgentSideConnection,
options?: { autoApproveTools?: boolean },
) {
this.conn = conn;
this.defaultAutoApproveTools = options?.autoApproveTools ?? false;
}
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
@@ -190,6 +202,7 @@ export class AcpAgent implements Agent {
currentMode: defaultMode,
currentProviderId: providerId,
currentModelId: defaultModelId,
autoApproveTools: this.defaultAutoApproveTools,
});
const availableModels = Object.entries(providerModels).map(
@@ -217,6 +230,7 @@ export class AcpAgent implements Agent {
await buildProviderConfigOption(providerId),
buildModelConfigOption(defaultModelId, providerModels),
buildModeConfigOption(defaultMode),
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
...(organizationOption ? [organizationOption] : []),
],
};
@@ -254,6 +268,7 @@ export class AcpAgent implements Agent {
process.env.CLINE_MODEL,
providerModels,
),
autoApproveTools: this.defaultAutoApproveTools,
};
this.sessions.set(params.sessionId, session);
}
@@ -511,6 +526,18 @@ export class AcpAgent implements Agent {
break;
}
case AUTO_APPROVE_CONFIG_ID: {
const autoApprove = parseAutoApproveValue(params.value);
if (autoApprove === undefined) {
throw RequestError.invalidParams(
undefined,
`Invalid auto-approve value: ${String(params.value)} (must be a boolean)`,
);
}
session.autoApproveTools = autoApprove;
break;
}
default:
throw RequestError.invalidParams(
undefined,
@@ -660,7 +687,9 @@ export class AcpAgent implements Agent {
toolPolicies: config.toolPolicies,
capabilities: {
requestToolApproval: (request) =>
requestAcpToolApproval(this.conn, acpSessionId, request),
session.autoApproveTools
? Promise.resolve({ approved: true })
: requestAcpToolApproval(this.conn, acpSessionId, request),
},
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
@@ -884,6 +913,7 @@ async function buildAllConfigOptions(
providerOption,
buildModelConfigOption(session.currentModelId, providerModels),
buildModeConfigOption(session.currentMode),
buildAutoApproveConfigOption(session.autoApproveTools),
];
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
AUTO_APPROVE_CONFIG_ID,
buildAutoApproveConfigOption,
parseAutoApproveValue,
} from "./auto-approve";
describe("buildAutoApproveConfigOption", () => {
it("builds a boolean config option reflecting the current value", () => {
const option = buildAutoApproveConfigOption(true);
expect(option).toMatchObject({
type: "boolean",
id: AUTO_APPROVE_CONFIG_ID,
currentValue: true,
});
expect(option.name).toBeTruthy();
});
it("defaults to disabled when the session has it off", () => {
const option = buildAutoApproveConfigOption(false);
expect(option).toMatchObject({ type: "boolean", currentValue: false });
});
});
describe("parseAutoApproveValue", () => {
it("accepts booleans", () => {
expect(parseAutoApproveValue(true)).toBe(true);
expect(parseAutoApproveValue(false)).toBe(false);
});
it("accepts the string forms sent by older clients", () => {
expect(parseAutoApproveValue("true")).toBe(true);
expect(parseAutoApproveValue("false")).toBe(false);
});
it("fails closed for unrecognized values", () => {
expect(parseAutoApproveValue("yes")).toBeUndefined();
expect(parseAutoApproveValue(1)).toBeUndefined();
expect(parseAutoApproveValue(null)).toBeUndefined();
});
it("returns undefined when no value was provided", () => {
expect(parseAutoApproveValue(undefined)).toBeUndefined();
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
export const AUTO_APPROVE_CONFIG_ID = "auto_approve";
export function buildAutoApproveConfigOption(
currentValue: boolean,
): SessionConfigOption {
return {
type: "boolean",
id: AUTO_APPROVE_CONFIG_ID,
name: "Auto-approve tools",
description:
"Automatically approve all tool calls without asking for permission",
currentValue,
};
}
/**
* Interpret the value of a `session/set_config_option` request for the
* auto-approve option.
*
* The ACP schema sends booleans for boolean options, but clients that predate
* boolean options may send the string form, so both are accepted. Returns
* `undefined` for anything else so the caller can reject the request.
*/
export function parseAutoApproveValue(value: unknown): boolean | undefined {
if (typeof value === "boolean" || value === undefined) {
return value;
}
return value === "true" ? true : value === "false" ? false : undefined;
}
+8 -2
View File
@@ -1,7 +1,11 @@
import { Readable, Writable } from "node:stream";
import { writeDiagnostic } from "../utils/output";
export async function runAcpMode(): Promise<void> {
export interface AcpModeOptions {
autoApproveTools?: boolean;
}
export async function runAcpMode(options?: AcpModeOptions): Promise<void> {
const { AgentSideConnection, ndJsonStream } = await import(
"@agentclientprotocol/sdk"
);
@@ -15,7 +19,9 @@ export async function runAcpMode(): Promise<void> {
);
const connection = new AgentSideConnection((conn) => {
return new AcpAgent(conn);
return new AcpAgent(conn, {
autoApproveTools: options?.autoApproveTools,
});
}, stream);
// Keep the process alive until the connection closes
+65
View File
@@ -767,6 +767,71 @@ Break work into clear steps.`,
);
});
it("routes mcp uninstall and its rm alias", () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
tempDirs.push(tempRoot);
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
const writeSettings = () => {
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: { transport: { type: "stdio", command: "node" } },
remote: {
transport: {
type: "streamableHttp",
url: "https://mcp.example.com",
},
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
};
const readServers = () =>
(
JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, unknown>;
}
).mcpServers ?? {};
writeSettings();
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(uninstallResult.status).toBe(0);
expect(asText(uninstallResult.stdout)).toContain(
"Uninstalled MCP server docs.",
);
expect(Object.keys(readServers())).toEqual(["remote"]);
writeSettings();
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(aliasResult.status).toBe(0);
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
name: "remote",
status: "uninstalled",
});
expect(Object.keys(readServers())).toEqual(["docs"]);
writeSettings();
const missingResult = runCli(["mcp", "remove", "missing"], {
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
});
expect(missingResult.status).toBe(1);
expect(asText(missingResult.stderr)).toContain(
'MCP server "missing" is not installed.',
);
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
+2 -1
View File
@@ -5,6 +5,7 @@ import {
type BuiltinToolAvailabilityContext,
createUserInstructionConfigService,
discoverPluginModulePaths,
getPluginDisplayName,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginTools,
@@ -270,7 +271,7 @@ async function runPluginsConfigCommand(
continue;
}
pluginsByPath.set(filePath, {
name: basename(filePath, extname(filePath)),
name: getPluginDisplayName(filePath, directory),
path: filePath,
});
}
@@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectIo } from "../connectors/types";
const mocks = vi.hoisted(() => ({
ensureDetachedHubServer: vi.fn(),
readHubDiscovery: vi.fn(),
connect: vi.fn(),
command: vi.fn(),
close: vi.fn(),
clientOptions: vi.fn(),
}));
vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mocks.ensureDetachedHubServer,
readHubDiscovery: mocks.readHubDiscovery,
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-production",
discoveryPath: "/tmp/production.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/owner.json",
}),
NodeHubClient: class {
constructor(options: unknown) {
mocks.clientOptions(options);
}
connect = mocks.connect;
command = mocks.command;
close = mocks.close;
},
}));
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
describe("startConnectorViaHub", () => {
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: ["session.create", "connector.start"],
});
mocks.connect.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
function startRequest(overrides: Record<string, unknown> = {}) {
return {
channel: "slack",
instanceId: "cline-slack",
args: ["--bot-token", "xoxb"],
io,
cwd: "/workspace",
...overrides,
};
}
it("hands the start to the hub and reports supervision", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: {
started: true,
record: { pid: 4242, state: "running" },
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 0,
});
expect(mocks.command).toHaveBeenCalledWith("connector.start", {
channel: "slack",
instanceId: "cline-slack",
args: ["--bot-token", "xoxb"],
restart: false,
});
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("started under hub supervision pid=4242"),
);
expect(mocks.close).toHaveBeenCalled();
});
it("passes a restart through", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: { started: true, record: { state: "running" } },
});
await startConnectorViaHub(startRequest({ restart: true }));
expect(mocks.command).toHaveBeenCalledWith(
"connector.start",
expect.objectContaining({ restart: true }),
);
});
it("treats an already-running instance as success", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: {
started: false,
reason: "already_running",
record: { pid: 99, state: "running" },
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 0,
});
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("already running under the hub"),
);
});
it("falls back when the hub cannot be reached", async () => {
mocks.ensureDetachedHubServer.mockRejectedValue(new Error("EADDRINUSE"));
const outcome = await startConnectorViaHub(startRequest());
expect(outcome.delegated).toBe(false);
expect(mocks.command).not.toHaveBeenCalled();
});
it("falls back when a running hub predates connector supervision", async () => {
// The normal state of a long-lived host mid-upgrade: a new CLI, an old hub.
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: ["session.create"],
});
const outcome = await startConnectorViaHub(startRequest());
expect(outcome).toEqual({
delegated: false,
reason: "hub does not support connector supervision",
});
expect(mocks.command).not.toHaveBeenCalled();
});
it("falls back when the hub reports supervision unavailable", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: false,
error: {
code: "connector_command_failed",
message: "connector supervision is unavailable in this hub",
},
});
const outcome = await startConnectorViaHub(startRequest());
expect(outcome.delegated).toBe(false);
});
it("falls back when the hub command throws", async () => {
mocks.command.mockRejectedValue(new Error("socket closed"));
const outcome = await startConnectorViaHub(startRequest());
expect(outcome.delegated).toBe(false);
expect(mocks.close).toHaveBeenCalled();
});
it("surfaces a genuine start refusal instead of starting locally", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: false,
error: {
code: "connector_command_failed",
message: "instanceId is required",
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 1,
});
expect(io.writeErr).toHaveBeenCalledWith(
expect.stringContaining("hub refused to start slack"),
);
});
it("reports a hub that accepted the command but did not start anything", async () => {
mocks.command.mockResolvedValue({
version: "v1",
ok: true,
payload: {
started: false,
record: { state: "failed", lastError: "bad token" },
},
});
await expect(startConnectorViaHub(startRequest())).resolves.toEqual({
delegated: true,
exitCode: 1,
});
expect(io.writeErr).toHaveBeenCalledWith(
expect.stringContaining("bad token"),
);
});
});
describe("stopConnectorsViaHub", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: [
"connector.start",
"connector.stop",
"connector.supervised",
],
});
mocks.connect.mockResolvedValue(undefined);
});
it("retires every supervised instance of a channel", async () => {
mocks.command.mockImplementation(async (command: string) => {
if (command === "connector.supervised") {
return {
ok: true,
payload: {
supervised: [
{ channel: "slack", instanceId: "a" },
{ channel: "slack", instanceId: "b" },
{ channel: "telegram", instanceId: "c" },
],
},
};
}
return { ok: true, payload: { stopped: true } };
});
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(2);
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
channel: "slack",
instanceId: "a",
});
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
channel: "slack",
instanceId: "b",
});
// A different channel is left alone.
expect(mocks.command).not.toHaveBeenCalledWith("connector.stop", {
channel: "telegram",
instanceId: "c",
});
});
it("retires only the requested instance", async () => {
mocks.command.mockImplementation(async (command: string) => {
if (command === "connector.supervised") {
return {
ok: true,
payload: {
supervised: [
{ channel: "slack", instanceId: "a" },
{ channel: "slack", instanceId: "b" },
],
},
};
}
return { ok: true, payload: { stopped: true } };
});
await expect(
stopConnectorsViaHub({ channel: "slack", instanceId: "b" }),
).resolves.toBe(1);
expect(mocks.command).toHaveBeenCalledWith("connector.stop", {
channel: "slack",
instanceId: "b",
});
});
it("reports nothing to stop when the hub supervises none of them", async () => {
mocks.command.mockResolvedValue({ ok: true, payload: { supervised: [] } });
await expect(stopConnectorsViaHub({ channel: "slack" })).resolves.toBe(0);
});
it("returns undefined when the hub cannot supervise", async () => {
mocks.readHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
capabilities: ["session.create"],
});
await expect(
stopConnectorsViaHub({ channel: "slack" }),
).resolves.toBeUndefined();
});
});
+289
View File
@@ -0,0 +1,289 @@
import {
ensureDetachedHubServer,
NodeHubClient,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "@cline/core";
import {
type ConnectorStartResult,
resolveClineBuildEnv,
type SupervisedConnectorRecord,
} from "@cline/shared";
import type { ConnectIo } from "../connectors/types";
/**
* Error codes that mean "this hub cannot supervise connectors", as opposed to
* "the start failed". Both are answers from the hub, but only the former should
* send the caller back to starting the connector itself.
*/
const UNSUPPORTED_ERROR_CODES = new Set([
"unsupported_command",
"unsupported_connector_command",
]);
const UNSUPPORTED_MESSAGE_FRAGMENT = "connector supervision is unavailable";
export type HubDelegationOutcome =
/** The hub owns the connector now; `exitCode` is the command's result. */
| { delegated: true; exitCode: number }
/** Nothing was started; the caller should start the connector locally. */
| { delegated: false; reason: string };
function resolveHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
/**
* Whether the hub at `url` advertises connector supervision.
*
* A newer CLI regularly talks to an older running hub — that is the normal state
* of a long-lived host mid-upgrade — and such a hub would reject
* `connector.start` outright. Checking the advertised capability first keeps that
* case on the local path instead of turning it into a failed start.
*/
async function hubSupportsSupervision(): Promise<boolean> {
try {
const owner = resolveHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
return record?.capabilities?.includes("connector.start") === true;
} catch {
return false;
}
}
function describeRecord(record: SupervisedConnectorRecord | undefined): string {
if (!record) {
return "";
}
const details = [
record.pid === undefined ? undefined : `pid=${record.pid}`,
`state=${record.state}`,
].filter(Boolean);
return details.length > 0 ? ` ${details.join(" ")}` : "";
}
/**
* What the running hub is supervising, or undefined when it cannot say.
*
* Deliberately does not start a hub: this exists for diagnostics, and `cline
* doctor` reporting on the system must never change it.
*/
export async function listSupervisedConnectorsViaHub(): Promise<
SupervisedConnectorRecord[] | undefined
> {
let url: string;
let authToken: string | undefined;
try {
const owner = resolveHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
if (
!record?.url ||
!record.capabilities?.includes("connector.supervised")
) {
return undefined;
}
url = record.url;
authToken = record.authToken;
} catch {
return undefined;
}
const client = new NodeHubClient({
url,
...(authToken ? { authToken } : {}),
clientType: "cli-doctor",
displayName: "doctor",
});
try {
await client.connect();
const reply = await client.command("connector.supervised");
if (!reply.ok) {
return undefined;
}
const supervised = (reply.payload as { supervised?: unknown })?.supervised;
return Array.isArray(supervised)
? (supervised as SupervisedConnectorRecord[])
: undefined;
} catch {
return undefined;
} finally {
try {
client.close();
} catch {
// One-shot connection; a failed close changes nothing.
}
}
}
/**
* Ask the hub to stop supervising a channel's connectors, or one instance of it.
*
* Returns how many the hub stopped, or undefined when it cannot supervise. The
* local stop path alone is not enough: it finds processes through their state
* files, so a connector that has not written one yet — still starting, or failing
* to start — would keep running under the hub and be restarted.
*/
export async function stopConnectorsViaHub(input: {
channel: string;
instanceId?: string;
}): Promise<number | undefined> {
const supervised = await listSupervisedConnectorsViaHub();
if (!supervised) {
return undefined;
}
const targets = supervised.filter(
(record) =>
record.channel === input.channel &&
(input.instanceId === undefined ||
record.instanceId === input.instanceId),
);
if (targets.length === 0) {
return 0;
}
let url: string;
let authToken: string | undefined;
try {
const owner = resolveHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
if (!record?.url) {
return undefined;
}
url = record.url;
authToken = record.authToken;
} catch {
return undefined;
}
const client = new NodeHubClient({
url,
...(authToken ? { authToken } : {}),
clientType: "cli-connect",
displayName: `stop ${input.channel}`,
});
let stopped = 0;
try {
await client.connect();
for (const target of targets) {
const reply = await client.command("connector.stop", {
channel: target.channel,
instanceId: target.instanceId,
});
if (reply.ok) {
stopped += 1;
}
}
return stopped;
} catch {
return stopped > 0 ? stopped : undefined;
} finally {
try {
client.close();
} catch {
// One-shot connection; a failed close changes nothing.
}
}
}
/**
* Ask the hub to start and own a connector.
*
* The hub spawning the connector — rather than the connector spawning itself and
* then bringing up a hub — is what makes the hub the single authority on how many
* processes hold one connector's credentials, and what lets it reap and restart
* them when they die. Every failure mode here falls back to the local path so a
* missing or older hub cannot stop a connector from starting.
*/
export async function startConnectorViaHub(input: {
channel: string;
instanceId: string;
args: string[];
restart?: boolean;
io: ConnectIo;
cwd?: string;
}): Promise<HubDelegationOutcome> {
const cwd = input.cwd ?? process.cwd();
let hub: { url: string; authToken: string };
try {
hub = await ensureDetachedHubServer(cwd);
} catch (error) {
return {
delegated: false,
reason: `hub unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
};
}
if (!(await hubSupportsSupervision())) {
return {
delegated: false,
reason: "hub does not support connector supervision",
};
}
const client = new NodeHubClient({
url: hub.url,
authToken: hub.authToken,
clientType: "cli-connect",
displayName: `connect ${input.channel}`,
cwd,
});
try {
await client.connect();
const reply = await client.command("connector.start", {
channel: input.channel,
instanceId: input.instanceId,
args: input.args,
restart: input.restart === true,
});
if (!reply.ok) {
const code = reply.error?.code ?? "";
const message = reply.error?.message ?? "connector start failed";
if (
UNSUPPORTED_ERROR_CODES.has(code) ||
message.includes(UNSUPPORTED_MESSAGE_FRAGMENT)
) {
return { delegated: false, reason: message };
}
input.io.writeErr(
`[connect] hub refused to start ${input.channel}: ${message}`,
);
return { delegated: true, exitCode: 1 };
}
const payload = reply.payload as ConnectorStartResult | undefined;
const record = payload?.record;
if (payload?.started === false && payload.reason === "already_running") {
input.io.writeln(
`[connect] ${input.channel} connector ${input.instanceId} is already running under the hub${describeRecord(record)}`,
);
return { delegated: true, exitCode: 0 };
}
if (payload?.started !== true) {
input.io.writeErr(
`[connect] hub could not start ${input.channel} connector ${input.instanceId}${
record?.lastError ? `: ${record.lastError}` : ""
}`,
);
return { delegated: true, exitCode: 1 };
}
input.io.writeln(
`[connect] ${input.channel} connector ${input.instanceId} started under hub supervision${describeRecord(record)}`,
);
input.io.writeln(
"[connect] the hub will restart it if it exits; use `cline connect --stop` to retire it",
);
return { delegated: true, exitCode: 0 };
} catch (error) {
return {
delegated: false,
reason: `hub command failed: ${
error instanceof Error ? error.message : String(error)
}`,
};
} finally {
try {
client.close();
} catch {
// The connection is one-shot; a failed close changes nothing.
}
}
}
+200
View File
@@ -5,6 +5,7 @@ import {
} from "../connectors/common";
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
import {
runCleanupConnectorInstance,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
@@ -12,6 +13,8 @@ import {
} from "./connect";
const mocks = vi.hoisted(() => ({
startConnectorViaHub: vi.fn(),
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getConnector: vi.fn(),
@@ -36,6 +39,11 @@ vi.mock("../connectors/registry", () => ({
listConnectors: mocks.listConnectors,
}));
vi.mock("./connect-via-hub", () => ({
startConnectorViaHub: mocks.startConnectorViaHub,
stopConnectorsViaHub: mocks.stopConnectorsViaHub,
}));
describe("runConnectAdapter", () => {
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
const io: ConnectIo = {
@@ -492,3 +500,195 @@ describe("runConnectAdapter", () => {
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
});
});
describe("runCleanupConnectorInstance", () => {
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it("reaps one instance without disabling its autostart", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runCleanupConnectorInstance("slack", "cline-slack", io),
).resolves.toBe(0);
expect(stopInstance).toHaveBeenCalledWith("cline-slack", io);
// The instance crashed; it was not retired. Disabling autostart here would
// make every crash silently opt the connector out of supervision.
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("reports a failed reap", async () => {
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance: vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
}),
});
await expect(
runCleanupConnectorInstance("slack", "cline-slack", io),
).resolves.toBe(1);
});
it("rejects an adapter without per-instance stop", async () => {
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
});
await expect(
runCleanupConnectorInstance("slack", "cline-slack", io),
).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
'connect adapter "slack" does not support per-instance stop',
);
});
it("rejects an unknown adapter", async () => {
mocks.getConnector.mockResolvedValue(undefined);
await expect(
runCleanupConnectorInstance("nope", "instance", io),
).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith('unknown connect adapter "nope"');
});
});
describe("hub-delegated connector starts", () => {
const io: ConnectIo = { writeln: vi.fn(), writeErr: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
mocks.listConnectors.mockReturnValue([]);
mocks.listActiveConnectors.mockReturnValue([]);
mocks.validate.mockResolvedValue(0);
mocks.run.mockResolvedValue(0);
mocks.getConnector.mockResolvedValue({
name: "slack",
description: "Slack",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
resolveInstanceId: () => "cline-slack",
});
mocks.startConnectorViaHub.mockResolvedValue({
delegated: true,
exitCode: 0,
});
});
afterEach(() => {
delete process.env.CLINE_CONNECTOR_SUPERVISED;
});
it("asks the hub to own a background connector and records the intent", async () => {
await expect(
runConnectAdapter("slack", ["--bot-token", "xoxb"], io),
).resolves.toBe(0);
expect(mocks.startConnectorViaHub).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
instanceId: "cline-slack",
args: ["--bot-token", "xoxb"],
}),
);
// The adapter must not also run here: the hub owns the process now.
expect(mocks.run).not.toHaveBeenCalled();
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"slack",
"cline-slack",
["--bot-token", "xoxb"],
);
});
it("runs locally for a foreground connector", async () => {
await runConnectAdapter("slack", ["--bot-token", "xoxb", "-i"], io);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).toHaveBeenCalled();
});
it("runs locally inside a supervised process instead of asking the hub again", async () => {
process.env.CLINE_CONNECTOR_SUPERVISED = "1";
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
// Delegating here would send the hub straight back to spawning this same
// process.
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).toHaveBeenCalled();
});
it("runs locally when the instance id cannot be known up front", async () => {
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
resolveInstanceId: () => undefined,
});
await runConnectAdapter("telegram", ["-k", "token"], io);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).toHaveBeenCalled();
});
it("falls back to a local start when the hub declines", async () => {
mocks.startConnectorViaHub.mockResolvedValue({
delegated: false,
reason: "hub does not support connector supervision",
});
await runConnectAdapter("slack", ["--bot-token", "xoxb"], io);
expect(mocks.run).toHaveBeenCalled();
});
it("does not validate or delegate a help invocation", async () => {
await runConnectAdapter("slack", ["--help"], io);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.validate).not.toHaveBeenCalled();
});
it("reports a validation failure without contacting the hub", async () => {
mocks.validate.mockResolvedValue(2);
await expect(
runConnectAdapter("slack", ["--bot-token", "bad"], io),
).resolves.toBe(2);
expect(mocks.startConnectorViaHub).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
});
});
+143 -1
View File
@@ -5,6 +5,10 @@ import {
persistConnectorConnection,
removePersistedConnectorConnection,
} from "@cline/core";
import {
isSupervisedConnectorProcess,
setStartingConnectorInstance,
} from "@cline/shared";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
@@ -15,6 +19,7 @@ import type {
ConnectRunContext,
ConnectStopResult,
} from "../connectors/types";
import { startConnectorViaHub, stopConnectorsViaHub } from "./connect-via-hub";
const HELP_FLAGS = new Set(["-h", "--help"]);
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
@@ -83,6 +88,20 @@ export async function runStopConnector(
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
// Retire it with the hub first. The local stop below finds processes through
// their state files, so a supervised connector that has not written one yet
// would survive and be restarted.
const stoppedByHub = await stopConnectorsViaHub({
channel: connector.name,
...(options.instanceId === undefined
? {}
: { instanceId: options.instanceId }),
});
if (stoppedByHub) {
io.writeln(
`[connect] hub stopped supervising ${stoppedByHub} ${connector.name} connector${stoppedByHub === 1 ? "" : "s"}`,
);
}
const result = await stop();
if (!result) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
@@ -97,6 +116,39 @@ export async function runStopConnector(
return result.failedProcesses === 0 ? 0 : 1;
}
/**
* Reap one connector instance that is no longer running.
*
* Invoked by the hub supervisor when it observes a connector die. It clears the
* same things a normal stop does — process state file, thread→session bindings,
* the instance's hub sessions — but deliberately leaves the autostart record
* intact: the instance crashed, it was not retired, so the supervisor still
* intends to restart it. `runStopConnector` with `autostart: "disable"` would
* make every crash silently opt the connector out of recovery.
*/
export async function runCleanupConnectorInstance(
adapterName: string,
instanceId: string,
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
if (!connector.stopInstance) {
io.writeErr(
`connect adapter "${adapterName}" does not support per-instance stop`,
);
return 1;
}
const result = await connector.stopInstance(instanceId, io);
io.writeln(
`[connect] ${connector.name} instance=${instanceId} cleaned processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
);
return result.failedProcesses === 0 ? 0 : 1;
}
export async function runRestartConnector(
adapterName: string,
passthroughArgs: string[],
@@ -132,6 +184,21 @@ export async function runRestartConnector(
if (validationExitCode !== 0) {
return validationExitCode;
}
// The supervisor replaces an instance in one step, so let it do the whole
// restart rather than stopping here and racing it to start the replacement.
// Only when the target is the instance these arguments describe: a
// `--restart-instance` pointing elsewhere is not ours to reinterpret.
if (
requestedInstanceId === undefined ||
connector.resolveInstanceId?.(passthroughArgs) === requestedInstanceId
) {
const delegated = await tryDelegateToHub(connector, passthroughArgs, io, {
restart: true,
});
if (delegated !== undefined) {
return delegated;
}
}
const previousConnection = getPersistedConnectorConnection(
adapterName,
instanceId,
@@ -208,6 +275,14 @@ async function runConnectAdapterWithResult(
},
setPersistenceInstanceId: (instanceId) => {
persistenceInstanceId = instanceId;
// Adapters report their instance id before they build a Cline core, so
// this lands in the environment before the hub daemon is spawned and
// inherited by it. Without it the daemon's autostart pass cannot tell
// that this instance is mid-startup and launches a second copy of it.
setStartingConnectorInstance({
channel: connector.name,
instanceId,
});
},
};
const exitCode = await connector.run(passthroughArgs, io, context);
@@ -218,8 +293,12 @@ async function runConnectAdapterWithResult(
const isInteractiveInvocation = passthroughArgs.some((arg) =>
INTERACTIVE_FLAGS.has(arg),
);
// A supervised process is the hub's own connector, not a user invocation, so
// it makes the same autostart bookkeeping choices as a detached child: the
// process that asked for the start already recorded the intent.
const isDetachedChild =
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
isSupervisedConnectorProcess();
if (
exitCode === 0 &&
!isHelpInvocation &&
@@ -242,11 +321,74 @@ async function runConnectAdapterWithResult(
return { exitCode, instanceId: persistenceInstanceId };
}
/**
* Hand a background connector start to the hub, when that is possible.
*
* Returns the exit code once the hub owns the connector, or undefined to mean
* "start it locally instead". Delegation is skipped for foreground (`-i`) runs,
* which are attached to the user's terminal, and for connectors the hub itself
* launched, which would otherwise ask the hub to start them again.
*/
async function tryDelegateToHub(
connector: {
name: string;
validate: (args: string[], io: ConnectIo) => Promise<number>;
resolveInstanceId?: (args: string[]) => string | undefined;
},
passthroughArgs: string[],
io: ConnectIo,
options: { restart?: boolean } = {},
): Promise<number | undefined> {
if (
passthroughArgs.some((arg) => HELP_FLAGS.has(arg)) ||
passthroughArgs.some((arg) => INTERACTIVE_FLAGS.has(arg)) ||
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1" ||
isSupervisedConnectorProcess()
) {
return undefined;
}
// Without an instance id the hub cannot enforce one process per connector,
// which is the entire point of routing through it.
const instanceId = connector.resolveInstanceId?.(passthroughArgs);
if (!instanceId) {
return undefined;
}
// Check the arguments here rather than after handing off: a bad token should
// fail in front of the user instead of becoming a supervised crash loop.
const validationExitCode = await connector.validate(passthroughArgs, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
const outcome = await startConnectorViaHub({
channel: connector.name,
instanceId,
args: passthroughArgs,
...(options.restart === undefined ? {} : { restart: options.restart }),
io,
});
if (!outcome.delegated) {
return undefined;
}
if (outcome.exitCode === 0) {
// Recorded here rather than in the hub-spawned process: this is the
// invocation that expressed the intent to keep the connector running.
persistConnectorConnection(connector.name, instanceId, passthroughArgs);
}
return outcome.exitCode;
}
export async function runConnectAdapter(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (connector) {
const delegated = await tryDelegateToHub(connector, passthroughArgs, io);
if (delegated !== undefined) {
return delegated;
}
}
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
+175 -1
View File
@@ -24,6 +24,7 @@ const {
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
mockListSupervisedConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
@@ -58,6 +59,7 @@ const {
stoppedSessions: 0,
executed: 0,
})),
mockListSupervisedConnectors: vi.fn(async () => undefined as unknown),
}));
vi.mock("node:child_process", () => ({
@@ -84,7 +86,11 @@ vi.mock("./connect", () => ({
stopAllConnectors: mockStopAllConnectors,
}));
import { createDoctorCommand, runDoctorCommand } from "./doctor";
vi.mock("./connect-via-hub", () => ({
listSupervisedConnectorsViaHub: mockListSupervisedConnectors,
}));
import { __test__, createDoctorCommand, runDoctorCommand } from "./doctor";
describe("runDoctorCommand", () => {
const tempDirs: string[] = [];
@@ -450,3 +456,171 @@ describe("createDoctorCommand log subcommand", () => {
expect(errors[0]).toContain("open failed");
});
});
describe("container-aware process filtering", () => {
const { decideForeignContainer, CONTAINER_CGROUP_PATTERN } = __test__;
it("treats a process in a different pid namespace as foreign", () => {
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [
["pid:[4026531836]", "pid:[4026532500]"],
[undefined, undefined],
],
ownContainerId: undefined,
otherContainerId: undefined,
}),
).toBe(true);
});
it("keeps a sibling process in our own namespaces", () => {
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [
["pid:[4026531836]", "pid:[4026531836]"],
["mnt:[4026531840]", "mnt:[4026531840]"],
],
ownContainerId: undefined,
otherContainerId: undefined,
}),
).toBe(false);
});
it("falls back to cgroup container ids when namespaces are unreadable", () => {
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [[undefined, undefined]],
ownContainerId: undefined,
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
}),
).toBe(true);
// Same container: our own sibling process, not something to retire.
expect(
decideForeignContainer({
platform: "linux",
namespacePairs: [[undefined, undefined]],
ownContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
otherContainerId: "7c6ffadc42f0bc0bc7c6ca47de4cd702",
}),
).toBe(false);
});
it("never filters off Linux, where containers cannot share our pid space", () => {
expect(
decideForeignContainer({
platform: "darwin",
namespacePairs: [["pid:[1]", "pid:[2]"]],
ownContainerId: undefined,
otherContainerId: "abcdef123456",
}),
).toBe(false);
});
it("extracts container ids from real cgroup paths", () => {
const docker =
"0::/system.slice/docker-7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad.scope";
expect(docker.match(CONTAINER_CGROUP_PATTERN)?.[1]).toBe(
"7c6ffadc42f0bc0bc7c6ca47de4cd702206e79b4068d172d8c2a2350063913ad",
);
// A plain host session must not look like a container.
expect(
"0::/user.slice/user-1001.slice/session-121.scope".match(
CONTAINER_CGROUP_PATTERN,
),
).toBeNull();
});
});
describe("doctor supervision reporting", () => {
const { formatSupervisedConnector } = __test__;
afterEach(() => {
vi.clearAllMocks();
mockListSupervisedConnectors.mockResolvedValue(undefined);
});
async function runDoctorJson(): Promise<Record<string, unknown>> {
const output: string[] = [];
await runDoctorCommand(
{ cwd: "/workspace", json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
return JSON.parse(output[0] || "{}") as Record<string, unknown>;
}
it("reports what the hub is supervising", async () => {
mockListSupervisedConnectors.mockResolvedValue([
{
channel: "slack",
instanceId: "cline-slack",
state: "backoff",
origin: "spawned",
restarts: 3,
},
]);
await expect(runDoctorJson()).resolves.toMatchObject({
supervisedConnectors: [
{ channel: "slack", instanceId: "cline-slack", state: "backoff" },
],
});
});
it("omits supervision when the hub cannot report it", async () => {
mockListSupervisedConnectors.mockResolvedValue(undefined);
const status = await runDoctorJson();
expect(status.supervisedConnectors).toBeUndefined();
});
it("stays usable when the supervision query fails", async () => {
mockListSupervisedConnectors.mockRejectedValue(new Error("hub gone"));
// Diagnostics must degrade quietly rather than fail.
const status = await runDoctorJson();
expect(status.supervisedConnectors).toBeUndefined();
expect(status).toHaveProperty("hubHealthy");
});
it("formats restart and failure state so a crash loop is visible", () => {
expect(
formatSupervisedConnector({
channel: "slack",
instanceId: "cline-slack",
state: "failed",
origin: "adopted",
pid: 42,
restarts: 5,
lastExitCode: 1,
lastError: "invalid token",
}),
).toBe(
"slack | instance=cline-slack | state=failed | origin=adopted | pid=42 | restarts=5 | lastExit=1 | error=invalid token",
);
});
it("leaves out fields that do not apply to a healthy connector", () => {
expect(
formatSupervisedConnector({
channel: "telegram",
instanceId: "cline_bot",
state: "running",
origin: "spawned",
pid: 7,
restarts: 0,
}),
).toBe(
"telegram | instance=cline_bot | state=running | origin=spawned | pid=7",
);
});
});
+131 -2
View File
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { existsSync, readFileSync, readlinkSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import {
clearHubDiscovery,
@@ -16,6 +16,7 @@ import {
type ActiveConnectorRecord,
formatUptime,
resolveClineBuildEnv,
type SupervisedConnectorRecord,
} from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
@@ -24,6 +25,7 @@ import { getCliBuildInfo } from "../utils/common";
import open from "../utils/open";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
import { listSupervisedConnectorsViaHub } from "./connect-via-hub";
type DoctorIo = {
writeln: (text?: string) => void;
@@ -64,6 +66,8 @@ type DoctorStatus = {
staleCliPids: number[];
staleSidecarPids: number[];
activeConnectors: ActiveConnectorRecord[];
/** Undefined when the running hub cannot report supervision. */
supervisedConnectors?: SupervisedConnectorRecord[];
recentSpawnedProcesses: SpawnedProcessRecord[];
};
@@ -72,6 +76,13 @@ type ProcessRecord = {
command: string;
};
// Container id inside a cgroup path, e.g.
// "0::/system.slice/docker-<64-hex>.scope" (docker/containerd/podman) or
// "/kubepods/.../<64-hex>" (kubernetes). Captures the id so two different
// containers can be told apart, not merely "is containerised".
const CONTAINER_CGROUP_PATTERN =
/(?:docker[-/]|containerd[-/]|libpod[-/]|crio[-/]|lxc[-/.])([0-9a-f]{12,64})/;
function parsePids(raw: string): number[] {
return raw
.split(/\r?\n/)
@@ -79,6 +90,77 @@ function parsePids(raw: string): number[] {
.filter((pid) => Number.isInteger(pid) && pid > 0);
}
function tryReadLink(target: string): string | undefined {
try {
return readlinkSync(target);
} catch {
return undefined;
}
}
function readContainerCgroupId(pid: number | "self"): string | undefined {
let raw: string;
try {
raw = readFileSync(`/proc/${pid}/cgroup`, "utf8");
} catch {
return undefined;
}
return raw.match(CONTAINER_CGROUP_PATTERN)?.[1];
}
/**
* Decide whether a process belongs to a container other than our own.
*
* Namespace identity is the reliable signal: a containerised process has
* different PID/mount namespaces than the host process running the scan.
* Container ids parsed from cgroup paths are the fallback for kernels where the
* namespace links are unreadable. Unknown on both sides means "assume ours",
* preserving the previous behaviour rather than silently dropping processes the
* user does want cleaned up.
*/
function decideForeignContainer(input: {
platform: string;
namespacePairs: Array<[string | undefined, string | undefined]>;
ownContainerId: string | undefined;
otherContainerId: string | undefined;
}): boolean {
// /proc/<pid>/ns exists only on Linux. Elsewhere containers run inside a VM
// and never share a pid space with us, so there is nothing to disambiguate.
if (input.platform !== "linux") {
return false;
}
for (const [own, other] of input.namespacePairs) {
if (own && other && own !== other) {
return true;
}
}
return (
Boolean(input.otherContainerId) &&
input.otherContainerId !== input.ownContainerId
);
}
/**
* True when `pid` belongs to a container other than this process's own.
*
* `pgrep` sees every process on the host, containers included: a Docker agent's
* hub daemon shows up beside ours, and when the container shares our uid `kill`
* on it succeeds. Those daemons are emphatically not stale — they belong to a
* live agent with its own data dir — so reporting them, and killing them in
* `doctor fix`, takes down an unrelated agent.
*/
function isForeignContainerPid(pid: number): boolean {
return decideForeignContainer({
platform: process.platform,
namespacePairs: (["pid", "mnt"] as const).map((namespace) => [
tryReadLink(`/proc/self/ns/${namespace}`),
tryReadLink(`/proc/${pid}/ns/${namespace}`),
]),
ownContainerId: readContainerCgroupId("self"),
otherContainerId: readContainerCgroupId(pid),
});
}
function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
@@ -108,7 +190,8 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
pid <= 0 ||
!command ||
pid === process.pid ||
pid === process.ppid
pid === process.ppid ||
isForeignContainerPid(pid)
) {
continue;
}
@@ -354,6 +437,7 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
activeConnectors: listActiveConnectors(),
...((await listSupervisedConnectorsSafely()) ?? {}),
recentSpawnedProcesses: readRecentSpawnedProcesses(),
};
}
@@ -378,6 +462,39 @@ function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
return pieces.join(" | ");
}
/**
* Supervision is reported by the running hub, so it is unavailable whenever
* there is no hub or it predates supervision. Diagnostics must degrade quietly
* rather than fail.
*/
async function listSupervisedConnectorsSafely(): Promise<
{ supervisedConnectors: SupervisedConnectorRecord[] } | undefined
> {
try {
const supervised = await listSupervisedConnectorsViaHub();
return supervised ? { supervisedConnectors: supervised } : undefined;
} catch {
return undefined;
}
}
function formatSupervisedConnector(record: SupervisedConnectorRecord): string {
const pieces = [
record.channel,
`instance=${record.instanceId}`,
`state=${record.state}`,
`origin=${record.origin}`,
record.pid === undefined ? undefined : `pid=${record.pid}`,
record.restarts > 0 ? `restarts=${record.restarts}` : undefined,
record.nextRestartAt ? `nextRestart=${record.nextRestartAt}` : undefined,
record.lastExitCode === undefined
? undefined
: `lastExit=${record.lastExitCode}`,
record.lastError ? `error=${record.lastError}` : undefined,
];
return pieces.filter(Boolean).join(" | ");
}
function formatActiveConnector(record: ActiveConnectorRecord): string {
const identity =
record.type === "telegram"
@@ -411,6 +528,12 @@ function killPids(pids: number[]): number {
return killed;
}
export const __test__ = {
decideForeignContainer,
CONTAINER_CGROUP_PATTERN,
formatSupervisedConnector,
};
export async function runDoctorCommand(
opts: { cwd: string; json?: boolean; fix?: boolean; verbose?: boolean },
io: DoctorIo,
@@ -450,6 +573,12 @@ export async function runDoctorCommand(
writeln(`- ${c.dim}${formatActiveConnector(record)}${c.reset}`);
}
}
if (before.supervisedConnectors?.length) {
writeln("hub-supervised connectors:");
for (const record of before.supervisedConnectors) {
writeln(`- ${c.dim}${formatSupervisedConnector(record)}${c.reset}`);
}
}
if (verbose && before.recentSpawnedProcesses.length > 0) {
writeln("recent spawned processes:");
for (const record of before.recentSpawnedProcesses) {
+146 -1
View File
@@ -1,9 +1,13 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
runMcpUninstallCommand,
} from "./mcp";
vi.mock("@cline/core", async (importOriginal) => {
@@ -56,6 +60,19 @@ describe("mcp install command", () => {
});
});
it("shows mcp-remote marketplace entries as native remote servers", () => {
expect(
buildMcpInstallDefaults({
name: "linear",
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
}),
).toEqual({
name: "linear",
type: "streamableHttp",
url: "https://mcp.linear.app/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
@@ -269,3 +286,131 @@ describe("mcp install command", () => {
});
});
});
describe("mcp uninstall command", () => {
let root = "";
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function writeSettings(): string {
const settingsPath = join(root, "cline_mcp_settings.json");
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
},
},
keep: {
transport: { type: "stdio", command: "node" },
disabled: true,
},
},
customTopLevelKey: true,
},
null,
2,
),
"utf8",
);
return settingsPath;
}
function readSettings(settingsPath: string): Record<string, unknown> & {
mcpServers?: Record<string, unknown>;
} {
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
string,
unknown
> & { mcpServers?: Record<string, unknown> };
}
it("uninstalls the requested server and reports success", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
const written = readSettings(settingsPath);
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
expect(written.mcpServers?.keep).toEqual({
transport: { type: "stdio", command: "node" },
disabled: true,
});
expect(written.customTopLevelKey).toBe(true);
});
it("prints uninstall JSON with --json", async () => {
const settingsPath = writeSettings();
const writeln = vi.fn();
const code = await runMcpUninstallCommand({
name: "docs",
settingsPath,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
name: "docs",
status: "uninstalled",
});
expect(writeln).toHaveBeenCalledTimes(1);
});
it("reports an error and leaves settings intact for an unknown server", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: "missing",
settingsPath,
io: { writeln, writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
'MCP server "missing" is not installed.',
);
expect(writeln).not.toHaveBeenCalled();
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
it("rejects a blank name without rewriting settings", async () => {
const settingsPath = writeSettings();
const before = readFileSync(settingsPath, "utf8");
const writeErr = vi.fn();
const code = await runMcpUninstallCommand({
name: " ",
settingsPath,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
expect(readFileSync(settingsPath, "utf8")).toBe(before);
});
});
+52 -58
View File
@@ -1,12 +1,16 @@
import {
buildMcpInstallTransport as buildCoreMcpInstallTransport,
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
type McpUninstallOptions as CoreMcpUninstallOptions,
type McpUninstallResult as CoreMcpUninstallResult,
uninstallMcpServer,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
export interface McpCommandIo {
writeln?: (text: string) => void;
@@ -28,39 +32,6 @@ export interface McpInstallDirectResult {
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpServerTransportConfig["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertValidUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid MCP server URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid MCP server URL: ${url} (only http and https are supported)`,
);
}
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
@@ -73,36 +44,20 @@ export function buildMcpInstallDefaults(options: {
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const targetArgs = options.targetArgs ?? [];
if (type === "stdio") {
if (targetArgs.length === 0) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
const { name, transport } = buildCoreMcpInstallTransport(options);
if (transport.type === "stdio") {
return {
name,
type,
command: targetArgs.map(quoteCommandArg).join(" "),
type: transport.type,
command: [transport.command, ...(transport.args ?? [])]
.map(quoteCommandArg)
.join(" "),
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
type,
url,
type: transport.type,
url: transport.url,
};
}
@@ -158,3 +113,42 @@ export async function runMcpInstallCommand(
return 1;
}
}
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
io?: McpCommandIo;
json?: boolean;
}
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
export function uninstallMcpServerDirect(
options: McpUninstallOptions,
): McpUninstallDirectResult {
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
return {
name: result.name,
status: result.status,
};
}
export async function runMcpUninstallCommand(
options: McpUninstallOptions,
): Promise<number> {
try {
const name = options.name?.trim() ?? "";
if (!name) {
throw new Error("MCP server name is required");
}
const result = uninstallMcpServerDirect({ ...options, name });
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
}
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
+177 -4
View File
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", () => ({
sendHubCommand: mockSendHubCommand,
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
providers: {} as Record<string, { provider?: string; model?: string }>,
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
sendHubCommand: mockSendHubCommand,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
}
getProviderSettings(providerId: string) {
return mockProviderSettings.providers[providerId];
}
},
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
parseHubEndpointOverride: (rawAddress: string | undefined) => {
@@ -47,6 +65,8 @@ async function runScheduleCommand(
describe("runScheduleCommand list output", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it('prints "No schedules found." for empty non-json list output', async () => {
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
});
});
describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand create", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("uses the last used provider and model when both flags are omitted", async () => {
mockProviderSettings.lastUsed = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("uses an explicit provider with that provider's configured model", async () => {
mockProviderSettings.lastUsed = {
provider: "cline",
model: "openai/gpt-5.3-codex",
};
mockProviderSettings.providers.anthropic = {
provider: "anthropic",
model: "claude-sonnet-4-6",
};
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
it("fails when an explicit provider has no configured model and no model flag", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Health check",
"--cron",
"0 */6 * * *",
"--prompt",
"Run tests",
"--workspace",
"/tmp/workspace",
"--provider",
"anthropic",
"--address",
"127.0.0.1:25463",
],
{
writeln: () => {},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(1);
expect(errors).toEqual([
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
]);
expect(mockSendHubCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("preserves exported modelSelection providerId/modelId values", async () => {
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
describe("runScheduleCommand export", () => {
afterEach(() => {
vi.clearAllMocks();
mockProviderSettings.lastUsed = undefined;
mockProviderSettings.providers = {};
});
it("writes JSON content to the --to file path", async () => {
+9 -5
View File
@@ -1,4 +1,3 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -19,6 +18,7 @@ import {
registerScheduleImportCommand,
registerScheduleUpdateCommand,
} from "./import-export";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
export function registerScheduleCommands(
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--model <model>", "Model to use")
.option("--provider <id>", "Provider ID")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
.option("--timeout <seconds>", "Timeout in seconds");
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
parseJsonObjectFlag(opts.metadataJson),
opts,
);
const modelSelection = resolveScheduleModelSelection({
provider: opts.provider,
model: opts.model,
});
const created = await client.createSchedule({
name,
cronPattern: opts.cron,
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
provider: modelSelection.provider,
model: modelSelection.model,
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
+15 -14
View File
@@ -1,6 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -18,8 +17,13 @@ import {
resolveAddress,
toPositiveInt,
} from "./common";
import { resolveScheduleModelSelection } from "./model-selection";
import type { CommandIo, ScheduleActionWrapper } from "./types";
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
provider: string;
model: string;
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
!Array.isArray(parsed.modelSelection)
? (parsed.modelSelection as Record<string, unknown>)
: undefined;
const provider = String(
modelSelection?.providerId ??
parsed.providerId ??
parsed.provider ??
"cline",
).trim();
const model = String(
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
return resolveScheduleModelSelection({
provider:
stringValue(modelSelection?.providerId) ??
stringValue(parsed.providerId) ??
stringValue(parsed.provider),
model:
stringValue(modelSelection?.modelId) ??
stringValue(parsed.modelId) ??
stringValue(parsed.model),
});
}
export function registerScheduleExportCommand(
@@ -0,0 +1,52 @@
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
interface ProviderSettingsReader {
getLastUsedProviderSettings(): ProviderSettings | undefined;
getProviderSettings(providerId: string): ProviderSettings | undefined;
}
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveScheduleModelSelection(
options: {
provider?: string;
model?: string;
},
providerSettingsManager?: ProviderSettingsReader,
): { provider: string; model: string } {
const explicitProvider = trimToUndefined(options.provider);
const explicitModel = trimToUndefined(options.model);
if (explicitProvider && explicitModel) {
return { provider: explicitProvider, model: explicitModel };
}
const manager = providerSettingsManager ?? new ProviderSettingsManager();
const lastUsedSettings = manager.getLastUsedProviderSettings();
const provider =
explicitProvider ??
trimToUndefined(lastUsedSettings?.provider) ??
DEFAULT_SCHEDULE_PROVIDER;
const selectedProviderSettings = explicitProvider
? manager.getProviderSettings(provider)
: lastUsedSettings;
const model =
explicitModel ??
trimToUndefined(selectedProviderSettings?.model) ??
(provider === DEFAULT_SCHEDULE_PROVIDER
? CLINE_DEFAULT_MODEL_ID
: undefined);
if (!model) {
throw new Error(
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
);
}
return { provider, model };
}
+74 -56
View File
@@ -2,6 +2,7 @@ import {
createDiscordAdapter,
type DiscordAdapter,
} from "@chat-adapter/discord";
// TODO: Remove the root Undici 6 override when discord.js no longer requires Undici ^6.27.0.
import type { ChatStartSessionRequest } from "@cline/core";
import {
createUserInstructionConfigService,
@@ -55,6 +56,7 @@ import {
loadThreadState,
persistMergedThreadState,
readBindings,
resolveThreadTurnQueueKey,
} from "../thread-bindings";
import type {
ConnectCommandDefinition,
@@ -729,60 +731,69 @@ class DiscordConnector extends ConnectorBase<
}
protected override createCommand(): Command {
return super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Discord bot username label")
.option("--application-id <id>", "Discord application id")
.option("--app-id <id>", "Alias for --application-id")
.option("--bot-token <token>", "Discord bot token")
.option("--token <token>", "Alias for --bot-token")
.option("--public-key <key>", "Discord application public key")
.option(
"--owner-user-id <id>",
"Discord user id that should be marked as connector owner",
)
.option("--ignore-bot-authors", "Ignore messages from other Discord bots")
.option(
"--mention-role-ids <ids>",
"Comma-separated role IDs that should trigger mention handlers",
)
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--enable-tools", "Enable tools for Discord sessions")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option(
"--base-url <url>",
"Public base URL for Discord interactions webhook",
)
.addHelpText(
"after",
[
"",
"Environment:",
" DISCORD_APPLICATION_ID Discord application id",
" DISCORD_BOT_TOKEN Discord bot token",
" DISCORD_PUBLIC_KEY Discord application public key",
" DISCORD_OWNER_USER_ID Optional connector owner user id",
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
].join("\n"),
);
return (
super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Discord bot username label")
.option("--application-id <id>", "Discord application id")
.option("--app-id <id>", "Alias for --application-id")
.option("--bot-token <token>", "Discord bot token")
.option("--token <token>", "Alias for --bot-token")
.option("--public-key <key>", "Discord application public key")
.option(
"--owner-user-id <id>",
"Discord user id that should be marked as connector owner",
)
.option(
"--ignore-bot-authors",
"Ignore messages from other Discord bots",
)
.option(
"--mention-role-ids <ids>",
"Comma-separated role IDs that should trigger mention handlers",
)
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for Discord sessions")
// Retained so existing invocations and persisted autostart arguments
// keep parsing; tools are on unless --no-tools is passed.
.option("--enable-tools", "Enable tools (default)")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() ||
resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option(
"--base-url <url>",
"Public base URL for Discord interactions webhook",
)
.addHelpText(
"after",
[
"",
"Environment:",
" DISCORD_APPLICATION_ID Discord application id",
" DISCORD_BOT_TOKEN Discord bot token",
" DISCORD_PUBLIC_KEY Discord application public key",
" DISCORD_OWNER_USER_ID Optional connector owner user id",
" DISCORD_IGNORE_BOT_AUTHORS Set to 1 to ignore messages from other bots",
" DISCORD_MENTION_ROLE_IDS Optional comma-separated role ids",
].join("\n"),
)
);
}
protected override readOptions(command: Command): ConnectDiscordOptions {
@@ -804,6 +815,7 @@ class DiscordConnector extends ConnectorBase<
mode?: string;
interactive?: boolean;
enableTools?: boolean;
tools?: boolean;
rpcAddress?: string;
hookCommand?: string;
port?: string;
@@ -856,7 +868,7 @@ class DiscordConnector extends ConnectorBase<
systemPrompt: opts.system,
mode: this.parseMode(opts.mode),
interactive: Boolean(opts.interactive),
enableTools: Boolean(opts.enableTools),
enableTools: opts.tools !== false,
rpcAddress:
opts.rpcAddress?.trim() ||
process.env.CLINE_RPC_ADDRESS?.trim() ||
@@ -973,6 +985,12 @@ class DiscordConnector extends ConnectorBase<
return 0;
}
protected override instanceIdFromOptions(
options: ConnectDiscordOptions,
): string | undefined {
return options.applicationId;
}
protected override async runWithOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
@@ -1119,7 +1137,7 @@ class DiscordConnector extends ConnectorBase<
isSubscribedThreadMessage?: boolean;
},
) => {
const queueKey = thread.id;
const queueKey = resolveThreadTurnQueueKey(thread);
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
+58 -44
View File
@@ -51,6 +51,7 @@ import {
loadThreadState,
persistMergedThreadState,
readBindings,
resolveThreadTurnQueueKey,
} from "../thread-bindings";
import type {
ConnectCommandDefinition,
@@ -235,48 +236,54 @@ class GoogleChatConnector extends ConnectorBase<
}
protected override createCommand(): Command {
return super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Google Chat bot username label")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--enable-tools", "Enable tools for Google Chat sessions")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option("--base-url <url>", "Public base URL for webhook configuration")
.option(
"--pubsub-topic <topic>",
"Optional Pub/Sub topic for all-message events",
)
.option("--impersonate-user <email>", "Optional delegation user email")
.option("--use-adc", "Use Google Application Default Credentials")
.option("--credentials-json <json>", "Service account credentials JSON")
.addHelpText(
"after",
[
"",
"Environment:",
" GOOGLE_CHAT_CREDENTIALS Service account JSON",
" GOOGLE_CHAT_USE_ADC=true Use Application Default Credentials",
" GOOGLE_CHAT_PUBSUB_TOPIC Optional Pub/Sub topic",
" GOOGLE_CHAT_IMPERSONATE_USER Optional delegation user",
].join("\n"),
);
return (
super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Google Chat bot username label")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for Google Chat sessions")
// Retained so existing invocations and persisted autostart arguments
// keep parsing; tools are on unless --no-tools is passed.
.option("--enable-tools", "Enable tools (default)")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() ||
resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option("--base-url <url>", "Public base URL for webhook configuration")
.option(
"--pubsub-topic <topic>",
"Optional Pub/Sub topic for all-message events",
)
.option("--impersonate-user <email>", "Optional delegation user email")
.option("--use-adc", "Use Google Application Default Credentials")
.option("--credentials-json <json>", "Service account credentials JSON")
.addHelpText(
"after",
[
"",
"Environment:",
" GOOGLE_CHAT_CREDENTIALS Service account JSON",
" GOOGLE_CHAT_USE_ADC=true Use Application Default Credentials",
" GOOGLE_CHAT_PUBSUB_TOPIC Optional Pub/Sub topic",
" GOOGLE_CHAT_IMPERSONATE_USER Optional delegation user",
].join("\n"),
)
);
}
protected override readOptions(command: Command): ConnectGoogleChatOptions {
@@ -290,6 +297,7 @@ class GoogleChatConnector extends ConnectorBase<
mode?: string;
interactive?: boolean;
enableTools?: boolean;
tools?: boolean;
rpcAddress?: string;
hookCommand?: string;
port?: string;
@@ -316,7 +324,7 @@ class GoogleChatConnector extends ConnectorBase<
systemPrompt: opts.system,
mode: this.parseMode(opts.mode),
interactive: Boolean(opts.interactive),
enableTools: Boolean(opts.enableTools),
enableTools: opts.tools !== false,
rpcAddress:
opts.rpcAddress?.trim() ||
process.env.CLINE_RPC_ADDRESS?.trim() ||
@@ -463,6 +471,12 @@ class GoogleChatConnector extends ConnectorBase<
}
}
protected override instanceIdFromOptions(
options: ConnectGoogleChatOptions,
): string | undefined {
return options.userName;
}
protected override async runWithOptions(
options: ConnectGoogleChatOptions,
rawArgs: string[],
@@ -614,7 +628,7 @@ class GoogleChatConnector extends ConnectorBase<
thread: Thread<GoogleChatThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey = resolveThreadTurnQueueKey(thread);
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
+58 -44
View File
@@ -47,6 +47,7 @@ import {
loadThreadState,
persistMergedThreadState,
readBindings,
resolveThreadTurnQueueKey,
} from "../thread-bindings";
import type {
ConnectCommandDefinition,
@@ -290,48 +291,54 @@ class LinearConnector extends ConnectorBase<
}
protected override createCommand(): Command {
return super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Linear bot display name")
.option("--api-key <key>", "Linear personal API key")
.option("--client-id <id>", "Linear OAuth client id")
.option("--client-secret <secret>", "Linear OAuth client secret")
.option("--access-token <token>", "Pre-obtained Linear access token")
.option("--webhook-secret <secret>", "Linear webhook signing secret")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--provider-api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--enable-tools", "Enable tools for Linear sessions")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option("--base-url <url>", "Public base URL for webhook configuration")
.addHelpText(
"after",
[
"",
"Environment:",
" LINEAR_API_KEY Personal API key",
" LINEAR_CLIENT_ID OAuth client id",
" LINEAR_CLIENT_SECRET OAuth client secret",
" LINEAR_ACCESS_TOKEN Pre-obtained access token",
" LINEAR_WEBHOOK_SECRET Webhook signing secret",
" LINEAR_BOT_USERNAME Bot display name (default: linear-bot)",
].join("\n"),
);
return (
super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Linear bot display name")
.option("--api-key <key>", "Linear personal API key")
.option("--client-id <id>", "Linear OAuth client id")
.option("--client-secret <secret>", "Linear OAuth client secret")
.option("--access-token <token>", "Pre-obtained Linear access token")
.option("--webhook-secret <secret>", "Linear webhook signing secret")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--provider-api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for Linear sessions")
// Retained so existing invocations and persisted autostart arguments
// keep parsing; tools are on unless --no-tools is passed.
.option("--enable-tools", "Enable tools (default)")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() ||
resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option("--base-url <url>", "Public base URL for webhook configuration")
.addHelpText(
"after",
[
"",
"Environment:",
" LINEAR_API_KEY Personal API key",
" LINEAR_CLIENT_ID OAuth client id",
" LINEAR_CLIENT_SECRET OAuth client secret",
" LINEAR_ACCESS_TOKEN Pre-obtained access token",
" LINEAR_WEBHOOK_SECRET Webhook signing secret",
" LINEAR_BOT_USERNAME Bot display name (default: linear-bot)",
].join("\n"),
)
);
}
protected override readOptions(command: Command): ConnectLinearOptions {
@@ -350,6 +357,7 @@ class LinearConnector extends ConnectorBase<
mode?: string;
interactive?: boolean;
enableTools?: boolean;
tools?: boolean;
rpcAddress?: string;
hookCommand?: string;
port?: string;
@@ -396,7 +404,7 @@ class LinearConnector extends ConnectorBase<
systemPrompt: opts.system,
mode: this.parseMode(opts.mode),
interactive: Boolean(opts.interactive),
enableTools: Boolean(opts.enableTools),
enableTools: opts.tools !== false,
rpcAddress:
opts.rpcAddress?.trim() ||
process.env.CLINE_RPC_ADDRESS?.trim() ||
@@ -488,6 +496,12 @@ class LinearConnector extends ConnectorBase<
);
}
protected override instanceIdFromOptions(
options: ConnectLinearOptions,
): string | undefined {
return options.userName;
}
protected override async runWithOptions(
options: ConnectLinearOptions,
rawArgs: string[],
@@ -637,7 +651,7 @@ class LinearConnector extends ConnectorBase<
thread: Thread<LinearThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey = resolveThreadTurnQueueKey(thread);
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
@@ -433,3 +433,53 @@ describe("slack binding lookup", () => {
).toBe(false);
});
});
describe("slack legacy connector state", () => {
it("stops a live connector recorded by a pre-claim state file (no claimId)", async () => {
const { spawn } = await import("node:child_process");
const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import(
"node:fs"
);
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const previousDataDir = process.env.CLINE_DATA_DIR;
const dataDir = mkdtempSync(join(tmpdir(), "slack-legacy-state-"));
process.env.CLINE_DATA_DIR = dataDir;
const child = spawn(
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ stdio: "ignore" },
);
try {
const stateDir = join(dataDir, "connectors", "slack");
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, "mybot.json"),
JSON.stringify({
userName: "mybot",
connectionMode: "socket",
pid: child.pid,
// Unreachable on purpose: session cleanup falls back to
// local storage inside the isolated data dir.
rpcAddress: "ws://127.0.0.1:1/hub",
startedAt: new Date(0).toISOString(),
}),
"utf8",
);
const io = { writeln: () => {}, writeErr: () => {} };
const result = await slackConnector.stopInstance?.("mybot", io);
expect(result?.stoppedProcesses).toBe(1);
} finally {
child.kill("SIGKILL");
if (previousDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = previousDataDir;
}
rmSync(dataDir, { recursive: true, force: true });
}
});
});
+117 -77
View File
@@ -28,7 +28,7 @@ import {
enqueueThreadTurn,
startConnectorWebhookServer,
} from "../chat-runtime";
import { isProcessRunning } from "../common";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
import {
type ActiveConnectorTurn,
handleConnectorUserTurn,
@@ -56,6 +56,7 @@ import {
loadThreadState,
persistMergedThreadState,
readBindings,
resolveThreadTurnQueueKey,
writeBindings,
} from "../thread-bindings";
import type {
@@ -64,19 +65,13 @@ import type {
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
getConnectorFirstContactMessage,
getConnectorSystemPrompt,
getConnectorSystemRules,
} from "./prompts";
import { getConnectorSystemPrompt, getConnectorSystemRules } from "./prompts";
const SLACK_SYSTEM_RULES = getConnectorSystemRules(
"Slack",
"You can respond to user messages in threads and DMs, and you can use tools according to user's requests and your capabilities.",
);
const SLACK_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
type SlackThreadState = ConnectorThreadState & {
teamId?: string;
};
@@ -501,62 +496,68 @@ class SlackConnector extends ConnectorBase<
}
protected override createCommand(): Command {
return super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Slack bot username label")
.option(
"--bot-token <token>",
"Slack bot token for single-workspace mode",
)
.option("--signing-secret <secret>", "Slack signing secret")
.option("--app-token <token>", "Slack app-level token for socket mode")
.option("--client-id <id>", "Slack OAuth client id")
.option("--client-secret <secret>", "Slack OAuth client secret")
.option(
"--encryption-key <key>",
"Base64 32-byte key for encrypted installations",
)
.option(
"--installation-key-prefix <prefix>",
"Override stored installation key prefix",
)
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--enable-tools", "Enable tools for Slack sessions")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option(
"--base-url <url>",
"Public base URL for webhooks and OAuth callback",
)
.addHelpText(
"after",
[
"",
"Environment:",
" SLACK_BOT_TOKEN Single-workspace bot token",
" SLACK_SIGNING_SECRET Slack signing secret",
" SLACK_APP_TOKEN App-level token for socket mode",
" SLACK_CLIENT_ID OAuth client id",
" SLACK_CLIENT_SECRET OAuth client secret",
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
].join("\n"),
);
return (
super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "Slack bot username label")
.option(
"--bot-token <token>",
"Slack bot token for single-workspace mode",
)
.option("--signing-secret <secret>", "Slack signing secret")
.option("--app-token <token>", "Slack app-level token for socket mode")
.option("--client-id <id>", "Slack OAuth client id")
.option("--client-secret <secret>", "Slack OAuth client secret")
.option(
"--encryption-key <key>",
"Base64 32-byte key for encrypted installations",
)
.option(
"--installation-key-prefix <prefix>",
"Override stored installation key prefix",
)
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for Slack sessions")
// Retained so existing invocations and persisted autostart arguments
// keep parsing; tools are on unless --no-tools is passed.
.option("--enable-tools", "Enable tools (default)")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() ||
resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option(
"--base-url <url>",
"Public base URL for webhooks and OAuth callback",
)
.addHelpText(
"after",
[
"",
"Environment:",
" SLACK_BOT_TOKEN Single-workspace bot token",
" SLACK_SIGNING_SECRET Slack signing secret",
" SLACK_APP_TOKEN App-level token for socket mode",
" SLACK_CLIENT_ID OAuth client id",
" SLACK_CLIENT_SECRET OAuth client secret",
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
].join("\n"),
)
);
}
protected override readOptions(command: Command): ConnectSlackOptions {
@@ -577,6 +578,7 @@ class SlackConnector extends ConnectorBase<
mode?: string;
interactive?: boolean;
enableTools?: boolean;
tools?: boolean;
rpcAddress?: string;
hookCommand?: string;
port?: string;
@@ -643,7 +645,7 @@ class SlackConnector extends ConnectorBase<
systemPrompt: opts.system,
mode: this.parseMode(opts.mode),
interactive: Boolean(opts.interactive),
enableTools: Boolean(opts.enableTools),
enableTools: opts.tools !== false,
rpcAddress:
opts.rpcAddress?.trim() ||
process.env.CLINE_RPC_ADDRESS?.trim() ||
@@ -682,6 +684,9 @@ class SlackConnector extends ConnectorBase<
Boolean(
value &&
typeof value === "object" &&
// claimId is optional: state files written by older CLI
// versions predate claiming and must stay manageable
// (already-running detection, status, stop).
typeof (value as SlackConnectorState).pid === "number" &&
typeof (value as SlackConnectorState).userName === "string",
),
@@ -733,6 +738,12 @@ class SlackConnector extends ConnectorBase<
);
}
protected override instanceIdFromOptions(
options: ConnectSlackOptions,
): string | undefined {
return options.userName;
}
protected override async runWithOptions(
options: ConnectSlackOptions,
rawArgs: string[],
@@ -743,14 +754,18 @@ class SlackConnector extends ConnectorBase<
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const stateStorePath = this.resolveStateStorePath(options.userName);
const staleState = this.removeStaleState(
statePath,
(path) => this.readConnectorState(path),
(state) => state.pid,
);
const existingState = this.readConnectorState(statePath);
const staleState =
existingState && !isProcessRunning(existingState.pid)
? existingState
: undefined;
if (staleState) {
clearBindingSessionIds<SlackThreadState>(bindingsPath);
}
const formatAlreadyRunning = (state: SlackConnectorState) =>
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}`;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
@@ -759,10 +774,7 @@ class SlackConnector extends ConnectorBase<
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}`,
formatAlreadyRunningMessage: formatAlreadyRunning,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
@@ -773,6 +785,34 @@ class SlackConnector extends ConnectorBase<
return backgroundExitCode;
}
// Foreground / detached-child path: exclusively claim the instance before
// opening Slack socket-mode so a second process cannot share the token.
const startedAt = new Date().toISOString();
const claim = this.claimConnectorInstance({
statePath,
createState: (claimId) => ({
claimId,
userName: options.userName,
connectionMode: options.connectionMode,
pid: process.pid,
rpcAddress: "pending",
startedAt,
...(options.connectionMode === "webhook"
? { port: options.port, baseUrl: options.baseUrl }
: {}),
}),
readState: (path) => this.readConnectorState(path),
getPid: (state) => state.pid,
});
if (!claim.claimed) {
io.writeln(
claim.running
? formatAlreadyRunning(claim.running)
: `[slack] connector already running for user=${options.userName}`,
);
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
component: "slack-connect",
@@ -860,6 +900,7 @@ class SlackConnector extends ConnectorBase<
});
await client.connect();
this.writeConnectorState(statePath, {
claimId: claim.claimId,
userName: options.userName,
connectionMode: options.connectionMode,
pid: process.pid,
@@ -867,7 +908,7 @@ class SlackConnector extends ConnectorBase<
...(options.connectionMode === "webhook"
? { port: options.port, baseUrl: options.baseUrl }
: {}),
startedAt: new Date().toISOString(),
startedAt,
});
let stopping = false;
@@ -892,7 +933,7 @@ class SlackConnector extends ConnectorBase<
bindingsPath,
startRequest,
);
const queueKey = thread.id;
const queueKey = resolveThreadTurnQueueKey(thread);
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
@@ -919,7 +960,6 @@ class SlackConnector extends ConnectorBase<
hookCommand: options.hookCommand,
systemRules: SLACK_SYSTEM_RULES,
errorLabel: "Slack",
firstContactMessage: SLACK_FIRST_CONTACT_MESSAGE,
userInstructionService,
chatCommandHost,
activeTurns,
@@ -4,6 +4,7 @@ import { join } from "node:path";
import type { ConnectTelegramOptions } from "@cline/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
import { handleConnectorUserTurn } from "../connector-host";
import { __test__, telegramConnector } from "./telegram";
const mocks = vi.hoisted(() => ({
@@ -468,3 +469,322 @@ describe("telegram binding lookup", () => {
expect(result).toBeUndefined();
});
});
type SlashTestState = Record<string, unknown>;
function createSlashTestThread(input: {
id: string;
channelId: string;
isDM: boolean;
initialState?: SlashTestState;
}) {
let state: SlashTestState = { ...(input.initialState ?? {}) };
const posts: unknown[] = [];
let subscribed = false;
const thread = {
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
get state() {
return Promise.resolve(state);
},
async setState(nextState: SlashTestState) {
state = { ...nextState };
},
async subscribe() {
subscribed = true;
},
async post(message: unknown) {
posts.push(message);
const sentMessage = {
edit: async (nextMessage: unknown) => {
posts.push(nextMessage);
return sentMessage;
},
delete: async () => undefined,
};
return sentMessage;
},
async startTyping() {},
toJSON() {
return {
id: input.id,
channelId: input.channelId,
isDM: input.isDM,
state,
};
},
};
return {
thread,
posts,
getState: () => state,
isSubscribed: () => subscribed,
};
}
function slashTestStartRequest() {
return {
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
systemPrompt: "system",
provider: "cline",
model: "test-model",
mode: "act",
};
}
describe("telegram slash command delivery", () => {
it("receives bot_command updates intercepted by the telegram chat library", async () => {
// The Telegram Bot API tags any leading-slash message with a
// `bot_command` entity, and @chat-adapter/telegram diverts those
// updates away from the mention/subscribed-message handlers. This
// pins the delivery contract: an intercepted update must still reach
// the connector turn handler through the slash-command path.
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ ok: true, result: {} }), {
status: 200,
}),
),
);
const { createTelegramAdapter } = await import("@chat-adapter/telegram");
const { Chat, ConsoleLogger } = await import("chat");
const { InMemoryStateAdapter } = await import("../stores/memory-state");
const telegram = createTelegramAdapter({
mode: "polling",
botToken: "123456:TEST-TOKEN",
userName: "test_bot",
logger: new ConsoleLogger("error", "telegram-slash-test"),
});
const bot = new Chat({
userName: "test_bot",
adapters: { telegram },
state: new InMemoryStateAdapter(),
logger: new ConsoleLogger("error", "telegram-slash-test"),
});
// bot.initialize() assigns this reference before polling starts; set
// it directly to avoid the real getMe/long-polling network calls.
(telegram as unknown as { chat: unknown }).chat = bot;
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const turns: Array<{ threadId: string; isDM: boolean; text: string }> = [];
bot.onSlashCommand(
__test__.createTelegramSlashCommandHandler({
bot,
bindingsPath: join(dir, "threads.json"),
baseStartRequest: slashTestStartRequest() as never,
handleTurn: async (thread, text) => {
turns.push({ threadId: thread.id, isDM: thread.isDM, text });
},
}) as never,
);
const completions: Promise<unknown>[] = [];
(
telegram as unknown as {
processUpdate: (update: unknown, options?: unknown) => void;
}
).processUpdate(
{
update_id: 1,
message: {
message_id: 42,
date: Math.floor(Date.now() / 1000),
chat: { id: 555, type: "private" },
from: {
id: 999,
is_bot: false,
first_name: "Alice",
username: "alice",
},
text: "/clear",
entities: [{ type: "bot_command", offset: 0, length: 6 }],
},
},
{
waitUntil: (task: Promise<unknown>) =>
completions.push(task.catch(() => undefined)),
},
);
await Promise.all(completions);
await vi.waitFor(() => {
expect(turns).toHaveLength(1);
});
expect(turns[0]).toEqual({
threadId: "telegram:555",
isDM: true,
text: "/clear",
});
});
it("routes intercepted slash commands into the connector turn handler", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, isSubscribed, getState } = createSlashTestThread({
id: "telegram:12345",
channelId: "telegram:12345",
isDM: true,
});
const botThread = vi.fn(() => thread);
const handleTurn = vi.fn(async () => undefined);
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: botThread } as never,
bindingsPath,
baseStartRequest: slashTestStartRequest() as never,
handleTurn: handleTurn as never,
});
await handler({
channel: { id: "telegram:12345" },
command: "/clear",
text: "",
raw: {
message_id: 7,
chat: { id: 12345, type: "private" },
from: { id: 999, username: "alice", first_name: "Alice" },
text: "/clear",
entities: [{ type: "bot_command", offset: 0, length: 6 }],
},
});
expect(botThread).toHaveBeenCalledWith("telegram:12345");
expect(isSubscribed()).toBe(true);
expect(handleTurn).toHaveBeenCalledWith(thread, "/clear");
expect(getState().participantKey).toBe("telegram:id:999");
});
it("preserves group-chat bot addressing in the forwarded command text", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const { thread } = createSlashTestThread({
id: "telegram:-100200",
channelId: "telegram:-100200",
isDM: false,
});
const handleTurn = vi.fn(async () => undefined);
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: () => thread } as never,
bindingsPath: join(dir, "threads.json"),
baseStartRequest: slashTestStartRequest() as never,
handleTurn: handleTurn as never,
});
// The chat adapter strips "@test_bot" into command targeting before
// invoking slash handlers; the raw message text keeps it so the
// connector host can enforce group addressing rules.
await handler({
channel: { id: "telegram:-100200" },
command: "/tools",
text: "on",
raw: {
message_id: 8,
chat: { id: -100200, type: "supergroup" },
from: { id: 999, username: "alice" },
text: "/tools@test_bot on",
entities: [{ type: "bot_command", offset: 0, length: 15 }],
},
});
expect(handleTurn).toHaveBeenCalledWith(thread, "/tools@test_bot on");
});
it("falls back to the parsed command when the raw payload has no text", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const { thread } = createSlashTestThread({
id: "telegram:12345",
channelId: "telegram:12345",
isDM: true,
});
const handleTurn = vi.fn(async () => undefined);
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: () => thread } as never,
bindingsPath: join(dir, "threads.json"),
baseStartRequest: slashTestStartRequest() as never,
handleTurn: handleTurn as never,
});
await handler({
channel: { id: "telegram:12345" },
command: "/cwd",
text: "/tmp",
raw: undefined,
});
expect(handleTurn).toHaveBeenCalledWith(thread, "/cwd /tmp");
});
it("delivers intercepted slash commands to the chat command host", async () => {
const dir = mkdtempSync(join(tmpdir(), "cline-telegram-slash-"));
tempDataDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createSlashTestThread({
id: "telegram:777",
channelId: "telegram:777",
isDM: true,
initialState: {
sessionId: "session-1",
participantKey: "telegram:id:999",
participantLabel: "alice",
welcomeSentAt: "2026-03-17T00:00:00.000Z",
},
});
const stopRuntimeSession = vi.fn(async () => undefined);
const deleteSession = vi.fn(async () => undefined);
const baseStartRequest = slashTestStartRequest();
const handler = __test__.createTelegramSlashCommandHandler({
bot: { thread: () => thread } as never,
bindingsPath,
baseStartRequest: baseStartRequest as never,
handleTurn: (async (
turnThread: typeof thread,
text: string,
): Promise<void> => {
await handleConnectorUserTurn({
thread: turnThread as never,
text,
client: { stopRuntimeSession, deleteSession } as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "test_bot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Telegram",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
});
}) as never,
});
await handler({
channel: { id: "telegram:777" },
command: "/clear",
text: "",
raw: {
message_id: 9,
chat: { id: 777, type: "private" },
from: { id: 999, username: "alice" },
text: "/clear",
entities: [{ type: "bot_command", offset: 0, length: 6 }],
},
});
expect(deleteSession).toHaveBeenCalledWith("session-1", true);
expect(posts).toContainEqual({ raw: "Started a fresh session." });
});
});
+119 -42
View File
@@ -47,6 +47,7 @@ import {
loadThreadState,
persistMergedThreadState,
readBindings,
resolveThreadTurnQueueKey,
} from "../thread-bindings";
import type {
ConnectCommandDefinition,
@@ -266,6 +267,55 @@ async function persistTelegramThreadContext(input: {
);
}
type TelegramSlashCommandEvent = {
channel: { id: string };
command: string;
text: string;
raw: unknown;
};
/**
* The Telegram chat adapter intercepts any message whose leading entity is a
* `bot_command` and delivers it to slash-command handlers instead of the
* normal mention/subscribed-message handlers. Without a registered handler
* the command is consumed and dropped, so connector commands like /clear
* never reach the chat command host. Rebuild the originating chat thread and
* forward the original message text (preserving any `@bot` addressing used
* in group chats) into the same turn pipeline as regular messages.
*/
function createTelegramSlashCommandHandler(input: {
bot: Pick<Chat, "thread">;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
handleTurn: (
thread: Thread<TelegramThreadState>,
text: string,
) => Promise<void>;
}): (event: TelegramSlashCommandEvent) => Promise<void> {
return async (event) => {
const raw = asRecord(event.raw);
const commandText =
readString(raw?.text) ??
readString(raw?.caption) ??
[event.command.trim(), event.text.trim()].filter(Boolean).join(" ");
if (!commandText) {
return;
}
const thread = input.bot.thread(
event.channel.id,
) as Thread<TelegramThreadState>;
await thread.subscribe();
await persistTelegramThreadContext({
thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
rawMessage: event.raw,
errorLabel: "Telegram",
});
await input.handleTurn(thread, commandText);
};
}
async function deliverScheduledResult(input: {
bot: Chat;
client: HubSessionClient;
@@ -417,47 +467,53 @@ class TelegramConnector extends ConnectorBase<
}
protected override createCommand(): Command {
return super
.createCommand()
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
.option(
"-m, --bot-username <name>",
"Telegram bot username; fetched from token if omitted",
)
.option("-k, --bot-token <token>", "Telegram bot token")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for Telegram sessions")
.option(
"--allowed-user-id <id>",
"Only allow this Telegram user ID to use the bot",
)
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
)
.addHelpText(
"after",
[
"",
"Notes:",
" - Without -i, the connector is launched in the background.",
" - Tools are enabled by default for Telegram sessions.",
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
" - Bot username is discovered from the Telegram bot token when omitted.",
" - Provider/model default to the CLI's last-used provider settings.",
].join("\n"),
);
return (
super
.createCommand()
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
.option(
"-m, --bot-username <name>",
"Telegram bot username; fetched from token if omitted",
)
.option("-k, --bot-token <token>", "Telegram bot token")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for Telegram sessions")
// Retained so existing invocations and persisted autostart arguments
// keep parsing; tools are on unless --no-tools is passed.
.option("--enable-tools", "Enable tools (default)")
.option(
"--allowed-user-id <id>",
"Only allow this Telegram user ID to use the bot",
)
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() ||
resolveDefaultCliRpcAddress(),
)
.addHelpText(
"after",
[
"",
"Notes:",
" - Without -i, the connector is launched in the background.",
" - Tools are enabled by default for Telegram sessions.",
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
" - Bot username is discovered from the Telegram bot token when omitted.",
" - Provider/model default to the CLI's last-used provider settings.",
].join("\n"),
)
);
}
protected override readOptions(command: Command): ConnectTelegramOptions {
@@ -631,6 +687,17 @@ class TelegramConnector extends ConnectorBase<
}
}
/**
* Only knowable up front when `--bot-username` was supplied; otherwise the
* username is resolved from Telegram's API during startup, and the caller
* has to start this connector locally instead of through the hub.
*/
protected override instanceIdFromOptions(
options: ConnectTelegramOptions,
): string | undefined {
return options.botUsername;
}
protected override async runWithOptions(
inputOptions: ConnectTelegramOptions,
rawArgs: string[],
@@ -816,7 +883,7 @@ class TelegramConnector extends ConnectorBase<
thread: Thread<TelegramThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey = resolveThreadTurnQueueKey(thread);
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
@@ -1007,6 +1074,15 @@ class TelegramConnector extends ConnectorBase<
await handleTurn(thread, message.text);
});
bot.onSlashCommand(
createTelegramSlashCommandHandler({
bot,
bindingsPath,
baseStartRequest: startRequest,
handleTurn,
}),
);
await bot.initialize();
const stopTaskUpdateStream =
startConnectorTaskUpdateRelay<TelegramThreadState>({
@@ -1151,6 +1227,7 @@ export const telegramConnector: ConnectCommandDefinition =
new TelegramConnector();
export const __test__ = {
createTelegramSlashCommandHandler,
fetchTelegramBotUsername,
readTelegramBotId,
resolveTelegramBotUsername,
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { discordConnector } from "./discord";
import { gchatConnector } from "./gchat";
import { linearConnector } from "./linear";
import { slackConnector } from "./slack";
import { telegramConnector } from "./telegram";
import { whatsappConnector } from "./whatsapp";
/**
* Every connector runs with tools enabled unless the operator opts out, so this
* has to hold for all of them at once rather than per adapter the whole point
* is that there is no adapter where the default is different.
*/
const connectors: Array<{
name: string;
connector: unknown;
/** Minimal arguments that parse for this adapter. */
baseArgs: string[];
}> = [
{
name: "slack",
connector: slackConnector,
baseArgs: ["--user-name", "bot"],
},
{
name: "discord",
connector: discordConnector,
baseArgs: ["--application-id", "app-1", "--bot-token", "token"],
},
{
name: "linear",
connector: linearConnector,
baseArgs: [
"--user-name",
"bot",
"--api-key",
"key",
"--webhook-secret",
"secret",
],
},
{
name: "gchat",
connector: gchatConnector,
baseArgs: ["--user-name", "bot"],
},
{
name: "whatsapp",
connector: whatsappConnector,
baseArgs: ["--user-name", "bot", "--phone-number-id", "123"],
},
{
name: "telegram",
connector: telegramConnector,
baseArgs: ["--bot-token", "123:token"],
},
];
function parse(
connector: unknown,
rawArgs: string[],
): { enableTools: boolean } {
return (
connector as {
parseArgs(rawArgs: string[]): { enableTools: boolean };
}
).parseArgs(rawArgs);
}
describe("connector tools default", () => {
for (const { name, connector, baseArgs } of connectors) {
it(`${name}: enables tools when nothing is passed`, () => {
expect(parse(connector, baseArgs).enableTools).toBe(true);
});
it(`${name}: disables tools with --no-tools`, () => {
expect(parse(connector, [...baseArgs, "--no-tools"]).enableTools).toBe(
false,
);
});
it(`${name}: an explicit --no-tools beats --enable-tools`, () => {
// Ambiguous input resolves to the safer answer.
expect(
parse(connector, [...baseArgs, "--enable-tools", "--no-tools"])
.enableTools,
).toBe(false);
});
}
it("keeps accepting --enable-tools so existing invocations still parse", () => {
// Persisted autostart arguments and deployed scripts carry this flag; it is
// redundant now but must not become an unknown-option error.
for (const { connector, baseArgs } of connectors) {
expect(
parse(connector, [...baseArgs, "--enable-tools"]).enableTools,
).toBe(true);
}
});
});
+60 -43
View File
@@ -51,6 +51,7 @@ import {
loadThreadState,
persistMergedThreadState,
readBindings,
resolveThreadTurnQueueKey,
} from "../thread-bindings";
import type {
ConnectCommandDefinition,
@@ -273,47 +274,53 @@ class WhatsAppConnector extends ConnectorBase<
}
protected override createCommand(): Command {
return super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "WhatsApp bot username label")
.option("--phone-number-id <id>", "WhatsApp Business phone number id")
.option("--access-token <token>", "Meta access token")
.option("--app-secret <secret>", "Meta app secret")
.option("--verify-token <token>", "Webhook verify token")
.option("--api-version <version>", "Graph API version", "v21.0")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--enable-tools", "Enable tools for WhatsApp sessions")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() || resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option("--base-url <url>", "Public base URL for webhook configuration")
.addHelpText(
"after",
[
"",
"Environment:",
" WHATSAPP_ACCESS_TOKEN Meta access token",
" WHATSAPP_APP_SECRET Meta app secret",
" WHATSAPP_PHONE_NUMBER_ID WhatsApp Business phone number id",
" WHATSAPP_VERIFY_TOKEN Webhook verification token",
" WHATSAPP_BOT_USERNAME Bot username label",
].join("\n"),
);
return (
super
.createCommand()
.usage("--base-url <PUBLIC_BASE_URL> [options]")
.option("--user-name <name>", "WhatsApp bot username label")
.option("--phone-number-id <id>", "WhatsApp Business phone number id")
.option("--access-token <token>", "Meta access token")
.option("--app-secret <secret>", "Meta app secret")
.option("--verify-token <token>", "Webhook verify token")
.option("--api-version <version>", "Graph API version", "v21.0")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
.option("--api-key <key>", "Provider API key override")
.option("--system <prompt>", "System prompt override")
.option("--cwd <path>", "Workspace / cwd for runtime")
.option("--mode <act|plan>", "Agent mode", "act")
.option("-i, --interactive", "Keep connector in foreground")
.option("--no-tools", "Disable tools for WhatsApp sessions")
// Retained so existing invocations and persisted autostart arguments
// keep parsing; tools are on unless --no-tools is passed.
.option("--enable-tools", "Enable tools (default)")
.option(
"--hook-command <command>",
"Run a shell command for connector events",
)
.option(
"--rpc-address <host:port>",
"RPC address",
process.env.CLINE_RPC_ADDRESS?.trim() ||
resolveDefaultCliRpcAddress(),
)
.option("--host <host>", "Webhook listen host")
.option("--port <port>", "Webhook listen port")
.option("--base-url <url>", "Public base URL for webhook configuration")
.addHelpText(
"after",
[
"",
"Environment:",
" WHATSAPP_ACCESS_TOKEN Meta access token",
" WHATSAPP_APP_SECRET Meta app secret",
" WHATSAPP_PHONE_NUMBER_ID WhatsApp Business phone number id",
" WHATSAPP_VERIFY_TOKEN Webhook verification token",
" WHATSAPP_BOT_USERNAME Bot username label",
].join("\n"),
)
);
}
protected override readOptions(command: Command): ConnectWhatsAppOptions {
@@ -332,6 +339,7 @@ class WhatsAppConnector extends ConnectorBase<
mode?: string;
interactive?: boolean;
enableTools?: boolean;
tools?: boolean;
rpcAddress?: string;
hookCommand?: string;
port?: string;
@@ -364,7 +372,7 @@ class WhatsAppConnector extends ConnectorBase<
systemPrompt: opts.system,
mode: this.parseMode(opts.mode),
interactive: Boolean(opts.interactive),
enableTools: Boolean(opts.enableTools),
enableTools: opts.tools !== false,
rpcAddress:
opts.rpcAddress?.trim() ||
process.env.CLINE_RPC_ADDRESS?.trim() ||
@@ -455,6 +463,15 @@ class WhatsAppConnector extends ConnectorBase<
);
}
protected override instanceIdFromOptions(
options: ConnectWhatsAppOptions,
): string | undefined {
return resolveInstanceKey({
phoneNumberId: options.phoneNumberId,
userName: options.userName,
});
}
protected override async runWithOptions(
options: ConnectWhatsAppOptions,
rawArgs: string[],
@@ -608,7 +625,7 @@ class WhatsAppConnector extends ConnectorBase<
thread: Thread<WhatsAppThreadState>,
text: string,
) => {
const queueKey = thread.id;
const queueKey = resolveThreadTurnQueueKey(thread);
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, queueKey, work);
const runTurn = async () => {
+24 -2
View File
@@ -116,7 +116,29 @@ describe("ConnectorBase background launch", () => {
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: child exited before becoming ready",
expect.stringContaining(
"launch failed: child exited before becoming ready",
),
);
});
it("points at the child log so a startup failure is diagnosable", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
mocks.isProcessRunning.mockReturnValue(false);
await new TestConnector().runBackground(io);
const [message] = vi.mocked(io.writeErr).mock.calls[0] ?? [];
expect(message).toContain("logs/connectors/test/test-connector.log");
expect(mocks.spawnDetachedConnector).toHaveBeenCalledWith(
["connect", "test"],
["--token", "secret"],
"CLINE_TEST_CONNECT_CHILD",
expect.objectContaining({
logPath: expect.stringContaining(
"logs/connectors/test/test-connector.log",
),
}),
);
});
@@ -129,7 +151,7 @@ describe("ConnectorBase background launch", () => {
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: timed out after 0ms",
expect.stringContaining("launch failed: timed out after 0ms"),
);
});
+154 -5
View File
@@ -1,14 +1,24 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import {
closeSync,
existsSync,
openSync,
readdirSync,
readSync,
statSync,
} from "node:fs";
import { basename, join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
import { isSupervisedConnectorProcess } from "@cline/shared";
import { Command, CommanderError } from "commander";
import {
CONNECT_ALREADY_RUNNING_EXIT_CODE,
isProcessRunning,
readJsonFile,
removeFile,
resolveConnectorDebugLogPath,
spawnDetachedConnector,
terminateProcess,
tryClaimConnectorStateFile,
writeJsonFile,
} from "./common";
import type {
@@ -21,6 +31,65 @@ import type {
const SHOW_HELP_ERROR = "__SHOW_HELP__";
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
const CONNECTOR_STARTUP_POLL_MS = 100;
const CHILD_LOG_TAIL_BYTES = 8_192;
const CHILD_LOG_TAIL_LINES = 3;
const ESC = String.fromCharCode(27);
const BEL = String.fromCharCode(7);
const ANSI_SEQUENCE_PATTERN = new RegExp(
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
"g",
);
function stripAnsiCodes(text: string): string {
return text.replace(ANSI_SEQUENCE_PATTERN, "");
}
/**
* Surface why a detached connector child died. The child's own error is the
* useful part; the parent only knows that it exited, so quote the tail of the
* child's log and point at the file for the rest.
*/
function formatChildLogHint(logPath: string): string {
const suffix = ` See ${logPath} for details.`;
let handle: number | undefined;
try {
const { size } = statSync(logPath);
if (size === 0) {
return suffix;
}
const length = Math.min(size, CHILD_LOG_TAIL_BYTES);
const buffer = Buffer.alloc(length);
handle = openSync(logPath, "r");
const read = readSync(handle, buffer, 0, length, size - length);
const lines = buffer
.subarray(0, read)
.toString("utf8")
// A partial first line is likely when starting mid-file.
.split("\n")
.slice(size > length ? 1 : 0)
.map((line) => stripAnsiCodes(line).trim())
.filter((line) => line.length > 0)
.slice(-CHILD_LOG_TAIL_LINES);
if (lines.length === 0) {
return suffix;
}
return ` Last output from the child:\n${lines
.map((line) => ` ${line}`)
.join("\n")}\n${suffix.trimStart()}`;
} catch {
// The log is best-effort: a missing or unreadable file must never turn a
// startup failure into a crash.
return suffix;
} finally {
if (handle !== undefined) {
try {
closeSync(handle);
} catch {
// Nothing actionable if the descriptor is already gone.
}
}
}
}
export abstract class ConnectorBase<Options, State>
implements ConnectCommandDefinition
@@ -87,6 +156,31 @@ export abstract class ConnectorBase<Options, State>
return this.runWithOptions(options, rawArgs, io, context);
}
/**
* The instance id `rawArgs` would run as, when that is knowable from the
* arguments alone.
*
* The hub keys supervision by (channel, instanceId), so it needs the id
* before anything is spawned. Adapters that can only determine it with a side
* effect Telegram resolves its bot username from the API when the flag is
* omitted return undefined, and the caller falls back to starting the
* connector locally.
*/
resolveInstanceId(rawArgs: string[]): string | undefined {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch {
return undefined;
}
const instanceId = this.instanceIdFromOptions(options);
return instanceId?.trim() ? instanceId.trim() : undefined;
}
protected instanceIdFromOptions(_options: Options): string | undefined {
return undefined;
}
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
@@ -170,6 +264,49 @@ export abstract class ConnectorBase<Options, State>
return undefined;
}
/**
* Exclusively claim the connector state path for this process before
* connecting to Slack/Discord/etc. Prevents two foreground (`-i`) or
* racing detached launches from both opening socket-mode with the same
* bot token.
*/
protected claimConnectorInstance(input: {
statePath: string;
createState: (claimId: string) => State & { claimId: string; pid: number };
readState: (path: string) => State | undefined;
getPid: (state: State) => number;
}): { claimed: true; claimId: string } | { claimed: false; running?: State } {
const existing = input.readState(input.statePath);
if (existing && isProcessRunning(input.getPid(existing))) {
return { claimed: false, running: existing };
}
const claim = tryClaimConnectorStateFile(
input.statePath,
input.createState,
);
if (!claim) {
const raced = input.readState(input.statePath);
return {
claimed: false,
...(raced ? { running: raced } : {}),
};
}
return { claimed: true, claimId: claim.claimId };
}
/**
* Where a detached child's stdout/stderr is captured. Without this the
* child is spawned with stdio "ignore", so a child that dies during startup
* takes its only diagnostic with it and the parent can report nothing but
* "child exited before becoming ready".
*/
protected resolveDetachedLogPath(statePath: string): string {
return resolveConnectorDebugLogPath(
this.name,
basename(statePath, ".json") || this.name,
);
}
protected async maybeRunInBackground(input: {
rawArgs: string[];
io: ConnectIo;
@@ -184,7 +321,13 @@ export abstract class ConnectorBase<Options, State>
launchFailureMessage: string;
startupTimeoutMs?: number;
}): Promise<number | undefined> {
if (input.interactive || process.env[input.childEnvVar] === "1") {
if (
input.interactive ||
process.env[input.childEnvVar] === "1" ||
// A supervised connector is the process the hub is tracking, so it must
// run the adapter here instead of handing off to a detached child.
isSupervisedConnectorProcess()
) {
return undefined;
}
const runningState = input.readState(input.statePath);
@@ -192,10 +335,16 @@ export abstract class ConnectorBase<Options, State>
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
const logPath = this.resolveDetachedLogPath(input.statePath);
const pid = spawnDetachedConnector(
["connect", this.name],
input.rawArgs,
input.childEnvVar,
{
logPath,
component: `${this.name}-connect`,
metadata: { statePath: input.statePath },
},
);
if (!pid) {
input.io.writeErr(input.launchFailureMessage);
@@ -212,7 +361,7 @@ export abstract class ConnectorBase<Options, State>
}
if (!isProcessRunning(pid)) {
input.io.writeErr(
`${input.launchFailureMessage}: child exited before becoming ready`,
`${input.launchFailureMessage}: child exited before becoming ready.${formatChildLogHint(logPath)}`,
);
return 1;
}
@@ -222,7 +371,7 @@ export abstract class ConnectorBase<Options, State>
}
await terminateProcess(pid);
input.io.writeErr(
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms.${formatChildLogHint(logPath)}`,
);
return 1;
}
+344 -1
View File
@@ -1,4 +1,6 @@
import { dirname, resolve } from "node:path";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
@@ -189,3 +191,344 @@ describe("readSessionReplyText", () => {
).resolves.toBe(2);
});
});
describe("tryClaimConnectorStateFile", () => {
it("claims an empty path and rejects a second live claim", async () => {
const { mkdtempSync, readFileSync, rmSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const { tryClaimConnectorStateFile } = await import("./common");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
try {
const first = tryClaimConnectorStateFile(
statePath,
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
{
isRunning: () => true,
getStartToken: (pid) => `process-${pid}`,
},
);
expect(first).toBeDefined();
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as {
claimId: string;
pid: number;
};
expect(parsed.claimId).toBe(first?.claimId);
expect(parsed.pid).toBe(process.pid);
const second = tryClaimConnectorStateFile(
statePath,
(claimId) => ({
claimId,
pid: process.pid + 1,
userName: "bot",
}),
{
isRunning: () => true,
getStartToken: (pid) => `process-${pid}`,
},
);
expect(second).toBeUndefined();
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("replaces a dead-pid claim", async () => {
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const { tryClaimConnectorStateFile } = await import("./common");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
writeFileSync(
statePath,
JSON.stringify({ pid: 1, userName: "stale" }),
"utf8",
);
try {
const claimed = tryClaimConnectorStateFile(
statePath,
(claimId) => ({ claimId, pid: process.pid, userName: "bot" }),
{
isRunning: (pid) => pid === process.pid,
getStartToken: (pid) => `process-${pid}`,
},
);
expect(claimed).toBeDefined();
expect(JSON.parse(readFileSync(statePath, "utf8")).pid).toBe(process.pid);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("allows only one contender to replace the same stale generation", async () => {
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const firstPayload = `${JSON.stringify({
claimId: "first",
pid: 2,
userName: "bot",
})}
`;
const secondPayload = `${JSON.stringify({
claimId: "second",
pid: 3,
userName: "bot",
})}
`;
writeFileSync(statePath, stalePayload, "utf8");
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
firstPayload,
{
isRunning: () => false,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(true);
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
secondPayload,
{
isRunning: () => false,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(false);
expect(readFileSync(statePath, "utf8")).toBe(firstPayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("recovers when a stale-generation guard owner exits before replacement", async () => {
const { createHash } = await import("node:crypto");
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const replacementPayload = `${JSON.stringify({
claimId: "replacement",
pid: 2,
userName: "bot",
})}
`;
const generation = createHash("sha256").update(stalePayload).digest("hex");
const orphanedGuardPath = `${statePath}.${generation}.claim`;
writeFileSync(statePath, stalePayload, "utf8");
writeFileSync(
orphanedGuardPath,
`${JSON.stringify(
{
claimId: "orphaned",
pid: 3,
processStartToken: "process-3",
},
null,
2,
)}
`,
"utf8",
);
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
replacementPayload,
{
isRunning: () => false,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(true);
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not succeed a live stale-generation guard owner", async () => {
const { createHash } = await import("node:crypto");
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const replacementPayload = `${JSON.stringify({
claimId: "replacement",
pid: 2,
userName: "bot",
})}
`;
const generation = createHash("sha256").update(stalePayload).digest("hex");
writeFileSync(statePath, stalePayload, "utf8");
writeFileSync(
`${statePath}.${generation}.claim`,
`${JSON.stringify(
{
claimId: "live",
pid: 3,
processStartToken: "process-3",
},
null,
2,
)}
`,
"utf8",
);
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
replacementPayload,
{
isRunning: (pid) => pid === 3,
getStartToken: (pid) => `process-${pid}`,
},
),
).toBe(false);
expect(readFileSync(statePath, "utf8")).toBe(stalePayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("recovers when an orphaned guard pid belongs to a different process", async () => {
const { createHash } = await import("node:crypto");
const { mkdtempSync, writeFileSync, readFileSync, rmSync } = await import(
"node:fs"
);
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const dir = mkdtempSync(join(tmpdir(), "connector-claim-"));
const statePath = join(dir, "instance.json");
const stalePayload = `${JSON.stringify({
claimId: "stale",
pid: 1,
userName: "stale",
})}
`;
const replacementPayload = `${JSON.stringify({
claimId: "replacement",
pid: 2,
userName: "bot",
})}
`;
const generation = createHash("sha256").update(stalePayload).digest("hex");
writeFileSync(statePath, stalePayload, "utf8");
writeFileSync(
`${statePath}.${generation}.claim`,
`${JSON.stringify(
{
claimId: "orphaned",
pid: 3,
processStartToken: "original-process-3",
},
null,
2,
)}
`,
"utf8",
);
try {
expect(
__test__.tryReplaceStaleConnectorStateFile(
statePath,
stalePayload,
replacementPayload,
{
isRunning: (pid) => pid === 3,
getStartToken: (pid) =>
pid === 3 ? "reused-process-3" : `process-${pid}`,
},
),
).toBe(true);
expect(readFileSync(statePath, "utf8")).toBe(replacementPayload);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("detached connector log rotation", () => {
it("keeps one generation once the log grows past the cap", () => {
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
const logPath = join(dir, "cline-slack.log");
writeFileSync(logPath, "x".repeat(__test__.DETACHED_LOG_MAX_BYTES + 1));
__test__.rotateOversizedLog(logPath);
expect(existsSync(logPath)).toBe(false);
expect(readFileSync(`${logPath}.1`, "utf8").length).toBe(
__test__.DETACHED_LOG_MAX_BYTES + 1,
);
});
it("leaves a small log in place so restarts keep their history", () => {
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
const logPath = join(dir, "cline-slack.log");
writeFileSync(logPath, "recent failure");
__test__.rotateOversizedLog(logPath);
expect(readFileSync(logPath, "utf8")).toBe("recent failure");
expect(existsSync(`${logPath}.1`)).toBe(false);
});
it("does nothing when there is no log yet", () => {
const dir = mkdtempSync(join(tmpdir(), "cline-connector-log-"));
expect(() =>
__test__.rotateOversizedLog(join(dir, "missing.log")),
).not.toThrow();
});
});
+290 -1
View File
@@ -1,10 +1,14 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
closeSync,
existsSync,
linkSync,
openSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
@@ -28,6 +32,9 @@ export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
*/
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
/** Rotate a detached connector log once it passes this size. */
const DETACHED_LOG_MAX_BYTES = 8 * 1024 * 1024;
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
return rawArgs.includes(flag);
}
@@ -73,6 +80,70 @@ export function isProcessRunning(pid: number): boolean {
}
}
type ProcessProbe = {
isRunning: (pid: number) => boolean;
getStartToken: (pid: number) => string | undefined;
};
function getProcessStartToken(pid: number): string | undefined {
if (!Number.isInteger(pid) || pid <= 0) {
return undefined;
}
try {
if (process.platform === "linux") {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
if (commandEnd < 0) {
return undefined;
}
// Fields after the command name begin at field 3 (state), so field
// 22 (starttime) is index 19.
const startTime = stat
.slice(commandEnd + 1)
.trim()
.split(/\s+/)[19];
const bootId = readFileSync(
"/proc/sys/kernel/random/boot_id",
"utf8",
).trim();
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
}
const result =
process.platform === "win32"
? spawnSync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
},
)
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
encoding: "utf8",
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
const startTime = result.status === 0 ? result.stdout.trim() : "";
return startTime ? `${process.platform}:${startTime}` : undefined;
} catch {
return undefined;
}
}
const defaultProcessProbe: ProcessProbe = {
isRunning: isProcessRunning,
getStartToken: getProcessStartToken,
};
export async function terminateProcess(pid: number): Promise<boolean> {
if (!isProcessRunning(pid)) {
return false;
@@ -164,12 +235,30 @@ export function resolveConnectorDebugLogPath(
);
}
/**
* Connectors are long-lived and restart often, so an append-only log would grow
* without bound on a host that runs them for weeks. Keep one previous
* generation and start fresh once the current one gets large.
*/
function rotateOversizedLog(path: string): void {
try {
if (statSync(path).size < DETACHED_LOG_MAX_BYTES) {
return;
}
rmSync(`${path}.1`, { force: true });
renameSync(path, `${path}.1`);
} catch {
// No log yet, or it cannot be rotated: appending is still fine.
}
}
function tryOpenDetachedLogFd(path: string | undefined): number | undefined {
if (!path?.trim()) {
return undefined;
}
try {
ensureParentDir(path);
rotateOversizedLog(path);
return openSync(path, "a");
} catch {
return undefined;
@@ -269,6 +358,9 @@ export const __test__ = {
buildDetachedConnectorArgs,
buildDetachedConnectorCommand,
buildDetachedConnectorEnv,
tryReplaceStaleConnectorStateFile,
rotateOversizedLog,
DETACHED_LOG_MAX_BYTES,
};
export function readJsonFile<T>(path: string, fallback: T): T {
@@ -289,6 +381,203 @@ export function writeJsonFile(path: string, value: unknown): void {
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
}
/**
* Atomically claim a connector state path for this process.
*
* Uses O_EXCL so two concurrent `cline connect` launches cannot both observe
* "no running instance" and both proceed. Returns undefined when another live
* connector already owns the path (or a concurrent claim won the race).
*/
export function tryClaimConnectorStateFile(
statePath: string,
createState: (
claimId: string,
) => { claimId: string; pid: number } & Record<string, unknown>,
processProbe: ProcessProbe = defaultProcessProbe,
): { claimId: string } | undefined {
ensureParentDir(statePath);
const claimId = randomUUID();
const state = createState(claimId);
const payload = `${JSON.stringify(state, null, 2)}
`;
if (tryCreateConnectorStateFile(statePath, payload)) {
return { claimId };
}
let observedPayload: string;
try {
observedPayload = readFileSync(statePath, "utf8");
} catch {
return undefined;
}
try {
const existing = JSON.parse(observedPayload) as { pid?: unknown };
const existingPid =
typeof existing.pid === "number" ? existing.pid : undefined;
if (existingPid !== undefined && processProbe.isRunning(existingPid)) {
return undefined;
}
} catch (error) {
if (!(error instanceof SyntaxError)) {
return undefined;
}
}
return tryReplaceStaleConnectorStateFile(
statePath,
observedPayload,
payload,
processProbe,
)
? { claimId }
: undefined;
}
function tryCreateConnectorStateFile(
statePath: string,
payload: string,
): boolean {
let fd: number;
try {
fd = openSync(statePath, "wx");
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code)
: undefined;
if (code === "EEXIST") {
return false;
}
throw error;
}
try {
writeFileSync(fd, payload, "utf8");
} finally {
closeSync(fd);
}
return true;
}
/**
* Replaces exactly the stale generation that the caller observed.
*
* Each contender atomically links its ownership record into a guard keyed by
* the observed generation. A live guard owner blocks replacement. If an owner
* dies in the critical section, contenders append a successor guard rather
* than deleting the existing one, so stale recovery remains crash-safe.
*/
function tryReplaceStaleConnectorStateFile(
statePath: string,
observedPayload: string,
replacementPayload: string,
processProbe: ProcessProbe = defaultProcessProbe,
): boolean {
let replacement: { claimId?: unknown; pid?: unknown };
try {
replacement = JSON.parse(replacementPayload) as {
claimId?: unknown;
pid?: unknown;
};
} catch {
return false;
}
if (
typeof replacement.claimId !== "string" ||
typeof replacement.pid !== "number"
) {
return false;
}
const generation = createHash("sha256").update(observedPayload).digest("hex");
const ownerPayload = `${JSON.stringify(
{
claimId: replacement.claimId,
pid: replacement.pid,
processStartToken: processProbe.getStartToken(replacement.pid),
},
null,
2,
)}
`;
const candidatePath = `${statePath}.${replacement.claimId}.candidate`;
if (!tryCreateConnectorStateFile(candidatePath, ownerPayload)) {
return false;
}
const guardPaths: string[] = [];
let acquiredGuard = false;
try {
let guardPath = `${statePath}.${generation}.claim`;
while (true) {
guardPaths.push(guardPath);
try {
linkSync(candidatePath, guardPath);
acquiredGuard = true;
break;
} catch (error) {
const code =
error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code)
: undefined;
if (code !== "EEXIST") {
throw error;
}
}
let guardPayload: string;
try {
guardPayload = readFileSync(guardPath, "utf8");
} catch {
return false;
}
try {
const guardOwner = JSON.parse(guardPayload) as {
pid?: unknown;
processStartToken?: unknown;
};
if (
typeof guardOwner.pid === "number" &&
processProbe.isRunning(guardOwner.pid)
) {
const runningStartToken = processProbe.getStartToken(guardOwner.pid);
if (
typeof guardOwner.processStartToken !== "string" ||
runningStartToken === undefined ||
runningStartToken === guardOwner.processStartToken
) {
return false;
}
}
} catch {
// Invalid ownership metadata cannot identify a live owner.
}
const successor = createHash("sha256")
.update(guardPath)
.update("\0")
.update(guardPayload)
.digest("hex");
guardPath = `${statePath}.${generation}.${successor}.claim`;
}
if (readFileSync(statePath, "utf8") !== observedPayload) {
return false;
}
rmSync(statePath);
return tryCreateConnectorStateFile(statePath, replacementPayload);
} catch {
return false;
} finally {
rmSync(candidatePath, { force: true });
if (acquiredGuard) {
for (const guardPath of guardPaths) {
rmSync(guardPath, { force: true });
}
}
}
}
export function removeFile(path: string): void {
try {
rmSync(path, { force: true });
+131 -2
View File
@@ -178,6 +178,52 @@ describe("handleConnectorUserTurn", () => {
}
});
it("posts no greeting when the adapter configures none", async () => {
// Slack deliberately configures no first-contact message: the greeting is
// gated on per-thread state, so a restart or a cleared history replayed it
// on the user's next message.
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
participantKey: "slack:user:alice",
participantLabel: "alice",
});
await handleConnectorUserTurn({
thread: thread as never,
client: {} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "cline-slack",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
text: "/whereami",
});
expect(
posts.filter((message) => messageText(message).includes("Connected")),
).toEqual([]);
// The turn itself still answers.
expect(messageText(posts.at(-1))).toContain(
"participantKey=slack:user:alice",
);
});
it("sends a first-contact message only once per persisted thread state", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -644,6 +690,81 @@ describe("handleConnectorUserTurn", () => {
).toBe(false);
});
it("recovers when the bound session is wedged on a run that never drained", async () => {
// Cline Mom's failure: the thread pointed at a session whose runtime still
// had a run in flight, so every message came back as "SessionRuntime.shutdown
// called while a run is in progress" instead of answering.
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
sessionId: "wedged-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("recovered reply");
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "fresh-session",
});
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
if (sessionId === "wedged-session") {
// Crossing the hub's JSON boundary strips the error class, so only the
// message survives — which is exactly what the connector sees.
throw new Error(
"SessionRuntime.shutdown called while a run is in progress (agentId=agent_123)",
);
}
return {
result: {
text: "recovered reply",
finishReason: "stop",
iterations: 1,
},
};
});
await handleConnectorUserTurn({
thread: thread as never,
text: "are you there?",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "slack",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Slack",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
});
expect(
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
).toEqual(["wedged-session", "fresh-session"]);
// The stale mapping is replaced, so the thread is not wedged next time.
expect(getState().sessionId).toBe("fresh-session");
expect(messageText(posts.at(-1))).toBe("recovered reply");
expect(
posts.some((message) =>
messageText(message).includes("run is in progress"),
),
).toBe(false);
});
it("does not retry forever when the replacement session is also missing", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -1777,7 +1898,11 @@ describe("handleConnectorUserTurn", () => {
}),
{ timeoutMs: null },
);
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
// Handing the follow-up to the running session is silent: no acknowledgement
// line is added to the thread.
expect(
posts.some((message) => messageText(message).includes("Steering")),
).toBe(false);
});
it("steers when the same session is active under a different turn key", async () => {
@@ -1829,7 +1954,11 @@ describe("handleConnectorUserTurn", () => {
}),
{ timeoutMs: null },
);
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
// Handing the follow-up to the running session is silent: no acknowledgement
// line is added to the thread.
expect(
posts.some((message) => messageText(message).includes("Steering")),
).toBe(false);
});
it("starts a normal turn when the active session is in a different thread", async () => {
+10 -9
View File
@@ -6,7 +6,7 @@ import type {
HubSessionClient,
UserInstructionConfigService,
} from "@cline/core";
import { isSessionNotFoundError } from "@cline/core";
import { isUnusableSessionError } from "@cline/core";
import type { SentMessage, Thread } from "chat";
import type { CliLoggerAdapter } from "../logging/adapter";
import { buildUserInputMessage, resolveSystemPrompt } from "../runtime/prompt";
@@ -975,10 +975,12 @@ export async function handleConnectorUserTurn<
{ timeoutMs: null },
);
} catch (error) {
if (!isSessionNotFoundError(error)) {
if (!isUnusableSessionError(error)) {
throw error;
}
// The tracked turn points at a session the hub no longer knows about.
// The tracked turn points at a session that can no longer serve it —
// the hub does not know it, or its runtime is stuck on a run that never
// drained.
// Remove only the entry we attempted to steer, then route recovery
// through the normal per-thread queue. Concurrent messages that saw
// the same stale turn will line up behind this one instead of creating
@@ -1002,11 +1004,10 @@ export async function handleConnectorUserTurn<
);
return;
}
await postConnectorText(
input.thread,
input.transport,
"Steering current task.",
);
// No acknowledgement: the follow-up is handed to the running session and its
// effect shows up in the answer. Announcing it added a line to every thread
// and overstated what happens, since the prompt is queued for the session
// rather than injected into the loop already running.
return;
}
@@ -1104,7 +1105,7 @@ async function runConnectorRuntimeTurnWithRecovery<
});
break;
} catch (error) {
if (!allowStaleSessionRetry || !isSessionNotFoundError(error)) {
if (!allowStaleSessionRetry || !isUnusableSessionError(error)) {
throw error;
}
allowStaleSessionRetry = false;
@@ -62,7 +62,10 @@ vi.mock("../commands/auth", async () => {
};
});
import { buildConnectorStartRequest } from "./session-runtime";
import {
buildConnectorStartRequest,
isReusableConnectorSession,
} from "./session-runtime";
describe("buildConnectorStartRequest", () => {
beforeEach(() => {
@@ -161,3 +164,33 @@ describe("buildConnectorStartRequest", () => {
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
describe("isReusableConnectorSession", () => {
it("rejects missing and terminal sessions", () => {
expect(isReusableConnectorSession(undefined)).toBe(false);
expect(isReusableConnectorSession({ sessionId: "" })).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "completed" }),
).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "failed" }),
).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "aborted" }),
).toBe(false);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "cancelled" }),
).toBe(false);
});
it("accepts live and status-omitted sessions", () => {
expect(
isReusableConnectorSession({ sessionId: "s1", status: "running" }),
).toBe(true);
expect(
isReusableConnectorSession({ sessionId: "s1", status: "idle" }),
).toBe(true);
expect(isReusableConnectorSession({ sessionId: "s1" })).toBe(true);
});
});
+30 -2
View File
@@ -137,6 +137,29 @@ export function buildThreadStartRequest<TState extends ConnectorThreadState>(
};
}
/** Terminal hub statuses are not reusable for a new connector turn. */
const TERMINAL_HUB_SESSION_STATUSES = new Set([
"completed",
"failed",
"aborted",
"cancelled",
]);
export function isReusableConnectorSession(
session: { sessionId?: string; status?: string } | undefined | null,
): boolean {
if (!session?.sessionId?.trim()) {
return false;
}
const status = session.status?.trim().toLowerCase();
if (!status) {
// Older hubs omit status; treat presence as reusable and let send-time
// session_not_found recovery handle true zombies.
return true;
}
return !TERMINAL_HUB_SESSION_STATUSES.has(status);
}
export async function getOrCreateSessionId<
TState extends ConnectorThreadState,
>(input: {
@@ -162,7 +185,7 @@ export async function getOrCreateSessionId<
const existing = threadState.sessionId?.trim();
if (existing) {
const existingSession = await input.client.getSession(existing);
if (existingSession) {
if (isReusableConnectorSession(existingSession)) {
await persistMergedThreadState(
input.thread,
input.bindingsPath,
@@ -204,12 +227,17 @@ export async function getOrCreateSessionId<
input.errorLabel,
);
input.logger.core.log(
"Connector thread session missing; starting a new session",
existingSession
? "Connector thread session is terminal; starting a new session"
: "Connector thread session missing; starting a new session",
{
severity: "warn",
transport: input.transport,
threadId: input.thread.id,
sessionId: existing,
...(existingSession?.status
? { status: existingSession.status }
: {}),
},
);
}
@@ -153,6 +153,26 @@ export function writeBindings<TState extends ConnectorThreadState>(
writeJsonFile(path, bindings);
}
/**
* Key under which turns for `thread` must be serialised.
*
* This has to follow the same identity rule as {@link findBindingForThread},
* because whatever shares a session has to share a queue. A DM reuses one
* binding and therefore one runtime session for every message in the
* channel, so keying the queue by thread id would let two messages in the same
* DM run against that one session concurrently. That surfaces as
* "SessionRuntime.shutdown called while a run is in progress", or as two
* conversations interleaved in one session's history.
*
* Channel threads each own their binding, so they keep their own key and go on
* running independently of one another.
*/
export function resolveThreadTurnQueueKey(
thread: Pick<ConnectorBindingThreadIdentity, "id" | "channelId" | "isDM">,
): string {
return thread.isDM ? `dm:${thread.channelId}` : thread.id;
}
export function findBindingForThread<TState extends ConnectorThreadState>(
bindings: ConnectorBindingStore<TState>,
thread: ConnectorBindingThreadIdentity,
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { enqueueThreadTurn } from "./chat-runtime";
import { resolveThreadTurnQueueKey } from "./thread-bindings";
describe("resolveThreadTurnQueueKey", () => {
it("gives every channel thread its own key", () => {
// Channel threads each own a binding and a session, so they run in parallel.
const first = resolveThreadTurnQueueKey({
id: "slack:C1:1111.1",
channelId: "slack:C1",
isDM: false,
});
const second = resolveThreadTurnQueueKey({
id: "slack:C1:2222.2",
channelId: "slack:C1",
isDM: false,
});
expect(first).not.toBe(second);
expect(first).toBe("slack:C1:1111.1");
});
it("collapses every message in one DM onto a single key", () => {
// findBindingForThread reuses one binding for a whole DM channel, so those
// messages share a session and must not run concurrently.
const first = resolveThreadTurnQueueKey({
id: "slack:D1:1111.1",
channelId: "slack:D1",
isDM: true,
});
const second = resolveThreadTurnQueueKey({
id: "slack:D1:2222.2",
channelId: "slack:D1",
isDM: true,
});
expect(first).toBe(second);
});
it("keeps separate DM channels separate", () => {
expect(
resolveThreadTurnQueueKey({
id: "slack:D1:1111.1",
channelId: "slack:D1",
isDM: true,
}),
).not.toBe(
resolveThreadTurnQueueKey({
id: "slack:D2:1111.1",
channelId: "slack:D2",
isDM: true,
}),
);
});
});
describe("thread turn scheduling", () => {
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
it("runs two messages in the same DM one after the other", async () => {
const queues = new Map<string, Promise<void>>();
const dm = { id: "slack:D1:1.1", channelId: "slack:D1", isDM: true };
const later = { id: "slack:D1:2.2", channelId: "slack:D1", isDM: true };
const order: string[] = [];
const first = deferred();
const firstTurn = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(dm),
async () => {
order.push("first:start");
await first.promise;
order.push("first:end");
},
);
const secondTurn = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(later),
async () => {
order.push("second:start");
},
);
// The second message must not touch the shared session until the first
// message's run has finished.
await new Promise((resolve) => setImmediate(resolve));
expect(order).toEqual(["first:start"]);
first.resolve();
await Promise.all([firstTurn, secondTurn]);
expect(order).toEqual(["first:start", "first:end", "second:start"]);
});
it("runs two channel threads at the same time", async () => {
const queues = new Map<string, Promise<void>>();
const threadA = { id: "slack:C1:1.1", channelId: "slack:C1", isDM: false };
const threadB = { id: "slack:C1:2.2", channelId: "slack:C1", isDM: false };
const order: string[] = [];
const blocked = deferred();
const turnA = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(threadA),
async () => {
order.push("a:start");
await blocked.promise;
order.push("a:end");
},
);
const turnB = enqueueThreadTurn(
queues,
resolveThreadTurnQueueKey(threadB),
async () => {
order.push("b:start");
},
);
// B answers while A is still working: separate threads, separate sessions.
await new Promise((resolve) => setImmediate(resolve));
expect(order).toEqual(["a:start", "b:start"]);
blocked.resolve();
await Promise.all([turnA, turnB]);
expect(order).toEqual(["a:start", "b:start", "a:end"]);
});
});
+6
View File
@@ -24,6 +24,12 @@ export interface ConnectCommandDefinition {
): Promise<number>;
validate(args: string[], io: ConnectIo): Promise<number>;
showHelp(io: ConnectIo): void;
/**
* Instance id `args` would run as, when it is knowable without side effects.
* The hub keys connector supervision by (channel, instanceId) and needs it
* before spawning; undefined sends the caller to the local start path.
*/
resolveInstanceId?(args: string[]): string | undefined;
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
}
+10 -2
View File
@@ -2,9 +2,10 @@
import { isMainThread } from "node:worker_threads";
import {
claimHubDaemonProcess,
claimSupervisedConnectorProcess,
disposeAll,
initVcr,
isHubDaemonProcess,
setConnectorCliLaunchSpec,
} from "@cline/shared";
import { logCliProcessError } from "./logging/errors";
@@ -22,11 +23,18 @@ initVcr(process.env.CLINE_VCR);
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (isHubDaemonProcess()) {
} else if (claimHubDaemonProcess()) {
// Claim rather than read: the sentinel is consumed here so the processes a
// daemon-hosted session spawns do not inherit it and try to become daemons.
// 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 {
// Same reasoning as the daemon sentinel above: consume the supervised-connector
// marker so the processes an agent session spawns cannot inherit it and mistake
// themselves for the connector the hub is tracking.
claimSupervisedConnectorProcess();
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
if (cliLaunchSpec) {
setConnectorCliLaunchSpec({
+63
View File
@@ -66,6 +66,7 @@ const dashboardMocks = vi.hoisted(() => ({
}));
const connectMocks = vi.hoisted(() => ({
formatAdapterList: vi.fn(() => ""),
runCleanupConnectorInstance: vi.fn(async () => 0),
runConnectAdapter: vi.fn(async () => 0),
runRestartConnector: vi.fn(async () => 0),
runStopAllConnectors: vi.fn(async () => 0),
@@ -396,6 +397,68 @@ describe("runCli lightweight command dispatch", () => {
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
});
it("routes a supervised cleanup to one connector instance", async () => {
connectMocks.runCleanupConnectorInstance.mockClear();
connectMocks.runConnectAdapter.mockClear();
process.argv = [
"bun",
"src/index.ts",
"connect",
"--cleanup-instance",
"cline-slack",
"slack",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runCleanupConnectorInstance).toHaveBeenCalledWith(
"slack",
"cline-slack",
expect.any(Object),
);
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
});
it("rejects combining cleanup with another connect mode", async () => {
connectMocks.runCleanupConnectorInstance.mockClear();
connectMocks.runStopConnector.mockClear();
process.argv = [
"bun",
"src/index.ts",
"connect",
"--cleanup-instance",
"cline-slack",
"--stop",
"slack",
];
const { runCli } = await import("./main");
await runCli();
expect(process.exitCode).toBe(1);
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
});
it("requires a channel for a supervised cleanup", async () => {
connectMocks.runCleanupConnectorInstance.mockClear();
process.argv = [
"bun",
"src/index.ts",
"connect",
"--cleanup-instance",
"x",
];
const { runCli } = await import("./main");
await runCli();
expect(process.exitCode).toBe(1);
expect(connectMocks.runCleanupConnectorInstance).not.toHaveBeenCalled();
});
it("routes a targeted connector restart to one instance", async () => {
process.argv = [
"bun",
+47 -3
View File
@@ -384,6 +384,10 @@ export async function runCli(): Promise<void> {
"--restart-instance <id>",
"Restart one connector instance (used by daemon recovery)",
)
.option(
"--cleanup-instance <id>",
"Reap one dead connector instance, preserving autostart (used by hub supervision)",
)
.allowUnknownOption()
.passThroughOptions()
.addHelpText(
@@ -393,15 +397,34 @@ export async function runCli(): Promise<void> {
.action(async (adapter: string | undefined) => {
const {
formatAdapterList,
runCleanupConnectorInstance,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
runStopConnector,
} = await import("./commands/connect");
const opts = connectCmd.opts();
if (opts.stop && (opts.restart || opts.restartInstance)) {
io.writeErr("connect accepts only one of --stop or --restart");
const exclusiveModes = [
opts.stop,
opts.restart || opts.restartInstance,
opts.cleanupInstance,
].filter(Boolean).length;
if (exclusiveModes > 1) {
io.writeErr(
"connect accepts only one of --stop, --restart or --cleanup-instance",
);
ctx.exitCode = 1;
} else if (opts.cleanupInstance) {
if (!adapter) {
io.writeErr("connect --cleanup-instance requires a channel");
ctx.exitCode = 1;
} else {
ctx.exitCode = await runCleanupConnectorInstance(
adapter,
opts.cleanupInstance,
io,
);
}
} else if (opts.stop) {
if (adapter) {
ctx.exitCode = await runStopConnector(adapter, io);
@@ -482,6 +505,24 @@ export async function runCli(): Promise<void> {
io,
});
});
const mcpUninstallCmd = mcpCmd
.command("uninstall")
.alias("remove")
.alias("rm")
.description("Uninstall an MCP server by name")
.argument("<name>", "MCP server name")
.option("--json", "Output as JSON")
.action(async (name: string) => {
const opts = mcpUninstallCmd.opts<{
json?: boolean;
}>();
const { runMcpUninstallCommand } = await import("./commands/mcp");
ctx.exitCode = await runMcpUninstallCommand({
name,
json: opts.json === true || program.opts().json === true,
io,
});
});
const createDoctorRuntimeCommand = async () => {
const { createDoctorCommand } = await import("./commands/doctor");
@@ -775,7 +816,10 @@ export async function runCli(): Promise<void> {
// Enters the Agent Client Protocol stdio transport and never falls through.
if (args.acpMode) {
const { runAcpMode } = await import("./acp/index");
await runAcpMode();
// Only an explicit `--auto-approve true` (or `--yolo`) enables
// auto-approval in ACP mode; We do not respect the default to
// avoid accidental auto-approval in ACP mode.
await runAcpMode({ autoApproveTools: args.autoApproveOverride === true });
return;
}
@@ -1,89 +0,0 @@
// ---------------------------------------------------------------------------
// Page-object helpers for the /settings view.
// ---------------------------------------------------------------------------
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { expectVisible } from "../terminal.js";
const TAB_ORDER = [
"API",
"Auto-approve",
"Features",
"Account",
"Other",
] as const;
export type SettingsTab = (typeof TAB_ORDER)[number];
/**
* Navigate to a specific settings tab by pressing Right from the API tab (index 0).
* Waits for each tab's content to appear before pressing the next key, making
* navigation deterministic regardless of machine speed.
*/
export async function goToSettingsTab(
terminal: Terminal,
tab: SettingsTab,
): Promise<void> {
const targetIndex = TAB_ORDER.indexOf(tab);
for (let i = 0; i < targetIndex; i++) {
terminal.keyRight();
// Wait for the next tab's content to appear before pressing again
await assertTabContent(terminal, TAB_ORDER[i + 1]);
}
}
/** Assert the API tab content is visible */
export async function assertApiTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, ["Provider:", "Model ID:"]);
}
/** Assert the Auto-approve tab content is visible */
export async function assertAutoApproveTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, [
"Read project files",
"Execute safe commands",
"Edit project files",
]);
}
/** Assert the Features tab content is visible */
export async function assertFeaturesTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, [
"Subagents",
"Web tools",
"Double-check completion",
]);
}
/** Assert the Account tab content is visible */
export async function assertAccountTab(terminal: Terminal): Promise<void> {
// The account tab shows sign-in options when not authenticated to Cline
await expectVisible(terminal, /sign in|sign out/i);
}
/** Assert the Other tab content is visible */
export async function assertOtherTab(terminal: Terminal): Promise<void> {
await expectVisible(terminal, ["Preferred language:", "Cline v"]);
}
/**
* Assert the content for a given tab is visible.
* Used internally by goToSettingsTab to confirm navigation landed correctly.
*/
export async function assertTabContent(
terminal: Terminal,
tab: SettingsTab,
): Promise<void> {
switch (tab) {
case "API":
return assertApiTab(terminal);
case "Auto-approve":
return assertAutoApproveTab(terminal);
case "Features":
return assertFeaturesTab(terminal);
case "Account":
return assertAccountTab(terminal);
case "Other":
return assertOtherTab(terminal);
}
}
@@ -17,6 +17,7 @@ export type LocalSlashCommandName =
| "plugins"
| "account"
| "model"
| "theme"
| "compact"
| "skills"
| "fork"
@@ -62,6 +63,10 @@ const TUI_LOCAL_COMMANDS: Array<{
name: "model",
description: "Switch model or provider",
},
{
name: "theme",
description: "Change color theme",
},
{
name: "account",
description: "View Cline account",
@@ -112,6 +117,7 @@ const TUI_LOCAL_COMMANDS: Array<{
const SYSTEM_COMMAND_ORDER = [
"settings",
"model",
"theme",
"account",
"mcp",
"plugins",
@@ -3,8 +3,7 @@ import type {
AutocompleteMode,
AutocompleteOption,
} from "../hooks/use-autocomplete";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
const MAX_ROWS = 7;
export const DROPDOWN_MAX_HEIGHT = MAX_ROWS + 2;
@@ -19,7 +18,9 @@ export interface AutocompleteDropdownProps {
}
export function AutocompleteDropdown(props: AutocompleteDropdownProps) {
const { mode, options, selected, onSelect, accent = palette.act } = props;
const theme = useTheme();
const { mode, options, selected, onSelect } = props;
const accent = props.accent ?? theme.accents.act;
const { width: termWidth } = useTerminalDimensions();
if (!mode || options.length === 0) return null;
@@ -122,8 +123,8 @@ function OptionRow(props: {
accent: string;
onSelect: (option: AutocompleteOption) => void;
}) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const theme = useTheme();
const defaultFg = theme.defaultForeground;
const { opt, isSelected, rowBudget, mode, accent, onSelect } = props;
if (opt.isHeader) {
@@ -172,12 +173,12 @@ function OptionRow(props: {
onMouseDown={() => onSelect(opt)}
>
<text wrapMode="none">
<span fg={isSelected ? palette.textOnSelection : "gray"}>{prefix}</span>
<span fg={isSelected ? palette.textOnSelection : defaultFg}>
<span fg={isSelected ? theme.textOnSelection : "gray"}>{prefix}</span>
<span fg={isSelected ? theme.textOnSelection : defaultFg}>
{displayName}
</span>
{descText ? (
<span fg={isSelected ? palette.textOnSelection : "gray"}>
<span fg={isSelected ? theme.textOnSelection : "gray"}>
{" ".repeat(descGap)}
{descText}
</span>
+72 -53
View File
@@ -21,14 +21,8 @@ import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
} from "../cline-account";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getUserMessageBackground,
palette,
type TerminalTheme,
} from "../palette";
import { getUserMessageBackground } from "../palette";
import type { ResolvedTheme } from "../themes";
import type { ChatEntry } from "../types";
import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
@@ -218,7 +212,7 @@ function ToolCallView(props: {
toolName: string;
inputSummary: string;
rawInput?: unknown;
accent?: string;
accent: string;
defaultFg?: string;
streaming: boolean;
result?: {
@@ -227,14 +221,8 @@ function ToolCallView(props: {
error?: string;
};
}) {
const {
toolName,
inputSummary,
streaming,
result,
accent = palette.act,
defaultFg,
} = props;
const { toolName, inputSummary, streaming, result, accent, defaultFg } =
props;
const failed = result?.error != null;
const warningFailure = isWarningToolError(result?.error);
const params = formatToolParams(toolName, props.rawInput, inputSummary);
@@ -279,7 +267,11 @@ function ToolCallView(props: {
);
}
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
function ClineCreditsClinePassErrorView(props: {
defaultFg?: string;
theme: ResolvedTheme;
}) {
const linkColor = props.theme.accents.act;
const subscriptionUrl = getCliSubscriptionUrl();
return (
<box flexDirection="row">
@@ -301,7 +293,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
/>
<box flexDirection="row">
<text fg="gray">Purchase Credits: </text>
<text fg={palette.act} selectable>
<text fg={linkColor} selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
@@ -309,7 +301,7 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
</box>
<box flexDirection="row">
<text fg="gray">Purchase ClinePass: </text>
<text fg={palette.act} selectable>
<text fg={linkColor} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
@@ -324,18 +316,26 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
function ClineCreditsErrorView(props: {
defaultFg?: string;
theme: ResolvedTheme;
}) {
return (
<ClineCreditsClinePassErrorView
defaultFg={props.defaultFg}
theme={props.theme}
/>
);
}
function ClinePassSubscriptionErrorView(props: {
defaultFg?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const subscriptionUrl = getCliSubscriptionUrl();
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
const planAccent = getModeAccent("plan", props.terminalTheme);
const planAccent = props.theme.accents.plan;
useEffect(() => {
if (!props.loadIndividualSubscriptionPlans) {
@@ -387,13 +387,13 @@ function ClinePassSubscriptionErrorView(props: {
)}
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg={palette.act} selectable>
<text fg={props.theme.accents.act} selectable>
<a href={subscriptionUrl}>Open subscription page</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">URL: </text>
<text fg={palette.act} selectable>
<text fg={props.theme.accents.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
@@ -404,9 +404,9 @@ function ClinePassSubscriptionErrorView(props: {
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const planAccent = getModeAccent("plan", props.terminalTheme);
const planAccent = props.theme.accents.plan;
return (
<box flexDirection="row">
@@ -464,21 +464,22 @@ function CompactionDividerRow(props: {
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
const accent = props.theme.accents.act;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<text fg={accent} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={accent}
paddingX={1}
>
<text fg="red">ClinePass limit reached</text>
<text fg={props.theme.accents.error}>ClinePass limit reached</text>
<text fg={props.defaultFg} selectable content={detail} />
<text
fg={props.defaultFg}
@@ -491,7 +492,7 @@ function ClinePassLimitErrorView(props: {
<code
content="--provider cline"
filetype="bash"
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
syntaxStyle={getSyntaxStyle(props.theme)}
selectable
/>
<text fg={props.defaultFg} selectable content="." />
@@ -504,20 +505,24 @@ function ClinePassLimitErrorView(props: {
function ClineFreeModelLimitErrorView(props: {
message: string;
defaultFg?: string;
theme: ResolvedTheme;
}) {
const resetTime = extractClineFreeModelLimitResetTime(props.message);
const accent = props.theme.accents.act;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<text fg={accent} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={accent}
paddingX={1}
>
<text fg="red">Daily free model limit reached</text>
<text fg={props.theme.accents.error}>
Daily free model limit reached
</text>
<text
fg={props.defaultFg}
selectable
@@ -538,18 +543,22 @@ function ClineFreeModelLimitErrorView(props: {
);
}
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
function ClineFreePromotionEndedErrorView(props: {
defaultFg?: string;
theme: ResolvedTheme;
}) {
const accent = props.theme.accents.act;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<text fg={accent} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={accent}
paddingX={1}
>
<text fg="red">Free model promotion ended</text>
<text fg={props.theme.accents.error}>Free model promotion ended</text>
<text
fg={props.defaultFg}
selectable
@@ -572,12 +581,12 @@ export function ChatEntryView(props: {
/** Mode the entry was produced in (resolved with the current-mode fallback). */
mode?: SyntaxAccentMode;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
theme: ResolvedTheme;
}) {
const { entry, accent = palette.act, mode = "act", terminalTheme } = props;
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const userMsgBg = getUserMessageBackground(terminalBg);
const { entry, mode = "act", theme } = props;
const accent = props.accent ?? theme.accents.act;
const defaultFg = theme.defaultForeground;
const userMsgBg = getUserMessageBackground(theme.background);
switch (entry.kind) {
case "user":
@@ -633,7 +642,7 @@ export function ChatEntryView(props: {
<box flexGrow={1}>
<markdown
content={content}
syntaxStyle={getSyntaxStyle(terminalTheme, mode)}
syntaxStyle={getSyntaxStyle(theme, mode)}
streaming={entry.streaming}
fg={defaultFg}
/>
@@ -660,13 +669,13 @@ export function ChatEntryView(props: {
case "error":
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
return <ClineCreditsErrorView defaultFg={defaultFg} theme={theme} />;
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
terminalTheme={terminalTheme}
theme={theme}
/>
);
}
@@ -677,7 +686,7 @@ export function ChatEntryView(props: {
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
theme={theme}
/>
);
}
@@ -686,7 +695,7 @@ export function ChatEntryView(props: {
<ClinePassLimitErrorView
message={entry.text}
defaultFg={defaultFg}
terminalTheme={terminalTheme}
theme={theme}
/>
);
}
@@ -695,16 +704,26 @@ export function ChatEntryView(props: {
<ClineFreeModelLimitErrorView
defaultFg={defaultFg}
message={entry.text}
theme={theme}
/>
);
}
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
return (
<ClineFreePromotionEndedErrorView
defaultFg={defaultFg}
theme={theme}
/>
);
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
<text fg="red" selectable content={`Error: ${entry.text}`} />
<text fg={theme.accents.error} content="* " />
<text
fg={theme.accents.error}
selectable
content={`Error: ${entry.text}`}
/>
</box>
);
@@ -9,8 +9,8 @@ import {
useRef,
} from "react";
import type { TranscriptCommand } from "../hooks/transcript-keybinds";
import { useTerminalTheme } from "../hooks/use-terminal-background";
import { getModeAccent } from "../palette";
import { useTheme } from "../hooks/use-theme";
import { getThemeModeAccent } from "../themes";
import type { ChatEntry } from "../types";
import { ChatEntryView } from "./chat-entry";
@@ -31,8 +31,8 @@ export const ChatMessageList = forwardRef<
>(function ChatMessageList(props, ref) {
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
const lastEntry = props.entries.at(-1);
const terminalTheme = useTerminalTheme();
const accent = getModeAccent(props.uiMode ?? "act", terminalTheme);
const theme = useTheme();
const accent = getThemeModeAccent(theme, props.uiMode ?? "act");
const userSubmissionScrollKey =
lastEntry?.kind === "user_submitted" ? props.entries.length : 0;
@@ -103,12 +103,12 @@ export const ChatMessageList = forwardRef<
<ChatEntryView
key={key}
entry={entry}
accent={getModeAccent(entryMode, terminalTheme)}
accent={getThemeModeAccent(theme, entryMode)}
mode={entryMode === "plan" ? "plan" : "act"}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
theme={theme}
/>
);
})}
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
| "settings"
| "change-model"
| "change-provider"
| "theme"
| "account"
| "mcp"
| "plugins"
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
description: "Switch provider and configure credentials",
keywords: ["provider", "api key", "account", "auth"],
},
{
action: "theme",
label: "Change Theme",
shortcut: "Opt+T",
description: "Pick a color theme for the TUI",
keywords: ["theme", "colors", "dark", "light", "appearance"],
},
{
action: "mcp",
label: "Manage MCP Servers",
@@ -127,6 +127,12 @@ const HELP_ROWS: HelpRow[] = [
key: "/settings",
desc: "Open interactive config browser",
},
{
kind: "entry",
id: "c-theme",
key: "/theme",
desc: "Change color theme",
},
{
kind: "entry",
id: "c-mcp",
@@ -0,0 +1,140 @@
import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useEffect, useRef, useState } from "react";
import { useThemeController } from "../../hooks/use-theme";
import { palette } from "../../palette";
import { getThemeSwatchColors, THEMES } from "../../themes";
const SWATCH_BLOCK = "\u25a0";
export function ThemePickerContent(props: ChoiceContext<string>) {
const { resolve, dismiss, dialogId } = props;
const { height } = useTerminalDimensions();
const controller = useThemeController();
const [selected, setSelected] = useState(() => {
const index = THEMES.findIndex(
(theme) => theme.id === controller.selectedThemeId,
);
return index >= 0 ? index : 0;
});
const selectedRef = useRef(selected);
selectedRef.current = selected;
const controllerRef = useRef(controller);
controllerRef.current = controller;
// Live preview: moving the selection repaints the whole TUI with the
// highlighted theme so users see exactly what they would get.
useEffect(() => {
const theme = THEMES[selected];
if (theme) {
controllerRef.current.previewThemeId(theme.id);
}
}, [selected]);
// Clear any dangling preview when the dialog closes without a confirm
// (escape, backdrop click, dialog replaced). setThemeId already clears the
// preview on confirm, so this is a no-op in that path.
useEffect(() => {
return () => {
controllerRef.current.previewThemeId(null);
};
}, []);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return" || key.name === "enter" || key.name === "tab") {
const theme = THEMES[selectedRef.current];
if (theme) {
controllerRef.current.setThemeId(theme.id);
resolve(theme.id);
}
return;
}
if (key.name === "up") {
setSelected((index) => (index <= 0 ? THEMES.length - 1 : index - 1));
return;
}
if (key.name === "down") {
setSelected((index) => (index >= THEMES.length - 1 ? 0 : index + 1));
}
}, dialogId);
const maxVisible = Math.max(3, height - 10);
const start = Math.max(
0,
Math.min(
selected - Math.floor(maxVisible / 2),
Math.max(0, THEMES.length - maxVisible),
),
);
const visibleThemes = THEMES.slice(start, start + maxVisible);
// Selection prefix (2 cells) + longest label + separating gap.
const labelWidth = Math.max(...THEMES.map((theme) => theme.label.length)) + 4;
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg="white">
<strong>Theme</strong>
</text>
<text fg="gray">esc</text>
</box>
<box flexDirection="column">
{visibleThemes.map((theme, i) => {
const absoluteIndex = start + i;
const isSelected = absoluteIndex === selected;
const isCurrent = theme.id === controller.selectedThemeId;
const swatches = getThemeSwatchColors(theme);
return (
<box
key={theme.id}
flexDirection="row"
backgroundColor={isSelected ? palette.selection : undefined}
onMouseDown={() => {
setSelected(absoluteIndex);
controllerRef.current.setThemeId(theme.id);
resolve(theme.id);
}}
height={1}
>
<text
fg={isSelected ? palette.textOnSelection : "white"}
width={labelWidth}
flexShrink={0}
>
{isSelected ? "\u276f " : " "}
{theme.label}
</text>
<text flexShrink={0}>
{swatches.map((color, swatchIndex) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-size color strip
key={swatchIndex}
fg={color}
>
{SWATCH_BLOCK}
</span>
))}
</text>
<text fg={isSelected ? palette.textOnSelection : "gray"}>
{" "}
{theme.description}
{isCurrent ? " (current)" : ""}
</text>
</box>
);
})}
</box>
<text fg="gray">
<em>{"\u2191/\u2193 preview, Enter to apply, Esc to cancel"}</em>
</text>
</box>
);
}
@@ -1,7 +1,7 @@
import type { ScrollBoxRenderable } from "@opentui/core";
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
import type { RuntimeToolInteraction } from "../types";
import { formatApprovalParams } from "./dialogs/tool-approval";
@@ -152,7 +152,7 @@ function Shell(
gap={1}
>
<box flexDirection="row" gap={1}>
<text fg={palette.act}>{props.title}</text>
<text fg={props.accent}>{props.title}</text>
</box>
{props.children}
</box>
@@ -165,17 +165,18 @@ function ChoiceButton(props: {
selectedFg?: string;
onPress: () => void;
}) {
const theme = useTheme();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
<box
paddingX={1}
backgroundColor={props.selected ? palette.selection : undefined}
backgroundColor={props.selected ? theme.selection : undefined}
onMouseDown={props.onPress}
>
<text
fg={
props.selected
? (props.selectedFg ?? palette.textOnSelection)
? (props.selectedFg ?? theme.textOnSelection)
: undefined
}
>
@@ -190,6 +191,7 @@ function ToolApprovalResponse(
interaction: Extract<RuntimeToolInteraction, { kind: "tool_approval" }>;
},
) {
const theme = useTheme();
const [selected, setSelected] = useState<"approve" | "deny">("approve");
const selectedRef = useRef(selected);
selectedRef.current = selected;
@@ -231,7 +233,7 @@ function ToolApprovalResponse(
inputForeground={props.inputForeground}
>
<box flexDirection="column" gap={1}>
<text fg="yellow">Approve tool call?</text>
<text fg={theme.accents.plan}>Approve tool call?</text>
<text fg={props.accent} selectable>
{request.toolName}
</text>
@@ -263,6 +265,7 @@ function AskQuestionResponse(
},
) {
const { interaction } = props;
const theme = useTheme();
const { height, width } = useTerminalDimensions();
const [selected, setSelected] = useState(0);
const [customValue, setCustomValue] = useState("");
@@ -439,13 +442,11 @@ function AskQuestionResponse(
gap={1}
flexShrink={0}
width="100%"
backgroundColor={
optionSelected ? palette.selection : undefined
}
backgroundColor={optionSelected ? theme.selection : undefined}
onMouseDown={() => resolveAnswer(option)}
>
<text
fg={optionSelected ? palette.textOnSelection : "gray"}
fg={optionSelected ? theme.textOnSelection : "gray"}
flexShrink={0}
>
{optionSelected ? ">" : " "}
@@ -453,7 +454,7 @@ function AskQuestionResponse(
<text
fg={
optionSelected
? palette.textOnSelection
? theme.textOnSelection
: props.inputForeground
}
flexGrow={1}
@@ -472,17 +473,17 @@ function AskQuestionResponse(
gap={1}
flexShrink={0}
width="100%"
backgroundColor={isTyping ? palette.selection : undefined}
backgroundColor={isTyping ? theme.selection : undefined}
onMouseDown={() => selectIndex(customIndex)}
>
<text
fg={isTyping ? palette.textOnSelection : "gray"}
fg={isTyping ? theme.textOnSelection : "gray"}
flexShrink={0}
>
{isTyping ? ">" : " "}
</text>
{isTyping ? (
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
<text fg={theme.textOnSelection} flexGrow={1} flexShrink={1}>
{customText}
</text>
) : (
+17 -12
View File
@@ -1,7 +1,7 @@
import "opentui-spinner/react";
import { useEffect, useState } from "react";
import { useSession } from "../contexts/session-context";
import { palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
import type { QueuedPromptItem } from "../types";
function truncatePrompt(prompt: string): string {
@@ -20,6 +20,7 @@ export function QueuedPrompts(props: {
onEditConfirm: (id: string, prompt: string) => void;
}) {
const session = useSession();
const theme = useTheme();
if (props.items.length === 0) return null;
const selected = props.selectedId
@@ -42,7 +43,7 @@ export function QueuedPrompts(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor={selected ? palette.selection : "gray"}
borderColor={selected ? theme.selection : "gray"}
paddingX={1}
>
<text fg="gray">
@@ -75,6 +76,7 @@ function QueuedPromptRow(props: {
onEditConfirm: (prompt: string) => void;
}) {
const { item, selected, editing } = props;
const theme = useTheme();
const [editValue, setEditValue] = useState(item.prompt);
useEffect(() => {
@@ -88,15 +90,15 @@ function QueuedPromptRow(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={selected ? palette.selection : undefined}
backgroundColor={selected ? theme.selection : undefined}
>
{item.steer && !editing ? (
<spinner
name="dots"
color={selected ? palette.textOnSelection : "gray"}
color={selected ? theme.textOnSelection : "gray"}
/>
) : (
<text fg={selected ? palette.textOnSelection : "gray"} flexShrink={0}>
<text fg={selected ? theme.textOnSelection : "gray"} flexShrink={0}>
{selected ? "" : " "}
</text>
)}
@@ -106,21 +108,24 @@ function QueuedPromptRow(props: {
onInput={setEditValue}
onSubmit={() => props.onEditConfirm(editValue)}
placeholder="Edit message..."
backgroundColor={palette.selection}
focusedBackgroundColor={palette.selection}
textColor={palette.textOnSelection}
cursorColor={palette.textOnSelection}
placeholderColor={palette.textOnSelection}
backgroundColor={theme.selection}
focusedBackgroundColor={theme.selection}
textColor={theme.textOnSelection}
cursorColor={theme.textOnSelection}
placeholderColor={theme.textOnSelection}
focused
flexGrow={1}
/>
) : (
<text fg={selected ? palette.textOnSelection : undefined} flexGrow={1}>
<text
fg={selected ? theme.textOnSelection : theme.defaultForeground}
flexGrow={1}
>
{truncatePrompt(item.prompt)}
</text>
)}
{!editing && item.attachmentCount > 0 && (
<text fg={selected ? palette.textOnSelection : "gray"} flexShrink={0}>
<text fg={selected ? theme.textOnSelection : "gray"} flexShrink={0}>
{attachmentLabel(item.attachmentCount)}
</text>
)}
+10 -13
View File
@@ -1,6 +1,5 @@
import { useMemo, useState } from "react";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
export interface SearchableItem {
key: string;
@@ -229,8 +228,8 @@ export function SearchableList(props: {
emptyText?: string;
borderColor?: string;
}) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const theme = useTheme();
const defaultFg = theme.defaultForeground;
const {
items,
selected,
@@ -288,23 +287,23 @@ export function SearchableList(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
backgroundColor={isSel ? theme.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
overflow="hidden"
height={1}
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
fg={isSel ? theme.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text fg={isSel ? palette.textOnSelection : defaultFg}>
<text fg={isSel ? theme.textOnSelection : defaultFg}>
{item.label}
</text>
{item.detail && (
<text
fg={isSel ? palette.textOnSelection : "gray"}
fg={isSel ? theme.textOnSelection : "gray"}
flexShrink={1}
>
{item.detail}
@@ -313,9 +312,7 @@ export function SearchableList(props: {
{item.tag && (
<text
fg={
isSel
? palette.textOnSelection
: (item.tagColor ?? "gray")
isSel ? theme.textOnSelection : (item.tagColor ?? "gray")
}
flexShrink={0}
>
@@ -326,8 +323,8 @@ export function SearchableList(props: {
<text
fg={
isSel
? palette.textOnSelection
: (item.rightLabelColor ?? palette.success)
? theme.textOnSelection
: (item.rightLabelColor ?? theme.accents.success)
}
flexShrink={0}
>
+6 -15
View File
@@ -4,15 +4,7 @@ import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
} from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getSuccessColor,
} from "../palette";
import { useTheme } from "../hooks/use-theme";
import { HOME_VIEW_MAX_WIDTH } from "../types";
export function createContextBar(
@@ -163,13 +155,12 @@ export function StatusBar(props: StatusBarProps) {
} = props;
const { width } = useTerminalDimensions();
const terminalBg = useTerminalBackground();
const terminalTheme = useTerminalTheme();
const defaultFg = getDefaultForeground(terminalBg);
const theme = useTheme();
const defaultFg = theme.defaultForeground;
const contextBarFilledFg = resolveContextBarFilledForeground(defaultFg);
const actAccent = getModeAccent("act", terminalTheme);
const planAccent = getModeAccent("plan", terminalTheme);
const successColor = getSuccessColor(terminalTheme);
const actAccent = theme.accents.act;
const planAccent = theme.accents.plan;
const successColor = theme.accents.success;
const hasMaxInputTokens =
typeof maxInputTokens === "number" &&
Number.isFinite(maxInputTokens) &&
+7 -7
View File
@@ -1,5 +1,5 @@
import { useTerminalDimensions } from "@opentui/react";
import { palette } from "../palette";
import { useTheme } from "../hooks/use-theme";
export type ToastVariant = "info" | "success" | "error";
@@ -8,19 +8,19 @@ export type ToastState = {
variant: ToastVariant;
};
const variantColor: Record<ToastVariant, string> = {
info: palette.selection,
success: palette.success,
error: palette.error,
};
export function Toast(props: { toast: ToastState | null }) {
const { width } = useTerminalDimensions();
const theme = useTheme();
if (!props.toast) {
return null;
}
const variantColor: Record<ToastVariant, string> = {
info: theme.accents.act,
success: theme.accents.success,
error: theme.accents.error,
};
const availableWidth = Math.max(1, width - 4);
const maxWidth = Math.min(44, availableWidth);
const right = width < 32 ? 0 : 2;
+15 -12
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTerminalTheme } from "../hooks/use-terminal-background";
import { diffPalettes, palette, type TerminalTheme } from "../palette";
import { useTheme } from "../hooks/use-theme";
import type { ResolvedTheme } from "../themes";
import { makeUnifiedDiff } from "../utils/diff";
import { getSyntaxStyle } from "../utils/syntax-style";
import { getToolErrorPresentation } from "../utils/tool-errors";
@@ -45,7 +45,7 @@ function isEditTool(toolName: string): boolean {
);
}
function BashOutput(props: { fullText: string; theme: TerminalTheme }) {
function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
const [expanded, setExpanded] = useState(false);
const { fullText } = props;
const trimmed = fullText.trimEnd();
@@ -115,18 +115,19 @@ function DiffStats(props: {
added: number;
removed: number;
language?: string;
theme: ResolvedTheme;
}) {
const { added, removed, language } = props;
const { added, removed, language, theme } = props;
return (
<text fg="gray">
{RESULT}{" "}
{removed > 0 ? (
<>
<span fg={palette.success}>+{added}</span>{" "}
<span fg="red">-{removed}</span> lines
<span fg={theme.accents.success}>+{added}</span>{" "}
<span fg={theme.accents.error}>-{removed}</span> lines
</>
) : (
<span fg={palette.success}>+{added} lines (new)</span>
<span fg={theme.accents.success}>+{added} lines (new)</span>
)}
{language ? ` | ${language}` : ""}
</text>
@@ -136,7 +137,7 @@ function DiffStats(props: {
function EditOutput(props: {
rawInput?: unknown;
outputSummary: string;
theme: TerminalTheme;
theme: ResolvedTheme;
}) {
const [expanded, setExpanded] = useState(true);
const editorInfo = parseEditorInput(props.rawInput);
@@ -156,7 +157,7 @@ function EditOutput(props: {
const language = detectLanguage(editorInfo.path);
const addedLines = newText.split("\n").length;
const removedLines = oldText ? oldText.split("\n").length : 0;
const diffPalette = diffPalettes[props.theme];
const diffPalette = props.theme.diff;
return (
<box
@@ -168,6 +169,7 @@ function EditOutput(props: {
added={addedLines}
removed={removedLines}
language={language}
theme={props.theme}
/>
{expanded && (
<box marginLeft={2} marginTop={1} marginBottom={1}>
@@ -194,7 +196,7 @@ function EditOutput(props: {
function ApplyPatchOutput(props: {
rawInput?: unknown;
outputSummary: string;
theme: TerminalTheme;
theme: ResolvedTheme;
}) {
const [expanded, setExpanded] = useState(true);
const info = parseApplyPatchInput(props.rawInput);
@@ -211,7 +213,7 @@ function ApplyPatchOutput(props: {
const fileLabel = info.files.map((f) => shortenPath(f, 40)).join(", ");
const language = detectLanguage(info.files[0] ?? "");
const diffPalette = diffPalettes[props.theme];
const diffPalette = props.theme.diff;
return (
<box
@@ -223,6 +225,7 @@ function ApplyPatchOutput(props: {
added={info.additions}
removed={info.deletions}
language={fileLabel}
theme={props.theme}
/>
{expanded && (
<box marginLeft={2} marginTop={1} marginBottom={1}>
@@ -292,7 +295,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
export function ToolOutput(props: ToolOutputProps) {
const { toolName, outputSummary, rawOutput, rawInput, error } = props;
const terminalTheme = useTerminalTheme();
const terminalTheme = useTheme();
const [errorExpanded, setErrorExpanded] = useState(false);
if (error) {
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from "react";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import { getDefaultForeground } from "../palette";
import { useTheme } from "../hooks/use-theme";
import { RobotAnimation } from "./robot-animation";
export function useMouseTracker() {
@@ -18,8 +17,7 @@ export function useMouseTracker() {
}
export function TrackedRobot(props: { cursorX?: number; cursorY?: number }) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const defaultFg = useTheme().defaultForeground;
return (
<box width="100%" flexShrink={1} overflow="hidden">
<RobotAnimation
@@ -9,6 +9,7 @@ export interface LocalSlashCommandActionInput {
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
openThemePicker: () => void;
invocation?: LocalSlashCommandInvocation;
runCompact: () => void;
runFork: () => void;
@@ -46,6 +47,10 @@ export function runLocalSlashCommandAction(
input.openModelSelector();
return true;
}
if (normalized === "theme") {
input.openThemePicker();
return true;
}
if (normalized === "compact") {
// Autocomplete can invoke local commands while a turn is running. Keep
// /compact handled, but do not let it take ownership of the active turn's
+75
View File
@@ -0,0 +1,75 @@
import { readTuiThemeGlobally, setTuiThemeGlobally } from "@cline/core";
import { useRenderer } from "@opentui/react";
import {
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { AUTO_THEME_ID, normalizeThemeId, resolveTheme } from "../themes";
import { TerminalColorsContext, ThemeContext } from "./use-theme";
/**
* Resolves the theme to boot with: CLINE_THEME env override first, then the
* persisted setting, falling back to terminal auto-detection.
*/
export function getInitialThemeId(): string {
const fromEnv = process.env.CLINE_THEME?.trim();
if (fromEnv) {
return normalizeThemeId(fromEnv);
}
try {
return normalizeThemeId(readTuiThemeGlobally());
} catch {
return AUTO_THEME_ID;
}
}
export function ThemeProvider(props: {
initialThemeId?: string;
children: ReactNode;
}) {
const detected = useContext(TerminalColorsContext);
const renderer = useRenderer();
const [selectedThemeId, setSelectedThemeId] = useState(() =>
normalizeThemeId(props.initialThemeId ?? getInitialThemeId()),
);
const [previewId, setPreviewId] = useState<string | null>(null);
const activeThemeId = previewId ?? selectedThemeId;
const theme = useMemo(
() => resolveTheme(activeThemeId, detected),
[activeThemeId, detected],
);
useEffect(() => {
if (renderer.isDestroyed) {
return;
}
renderer.setBackgroundColor(theme.appBackground ?? "transparent");
}, [renderer, theme.appBackground]);
const setThemeId = useCallback((id: string) => {
const normalized = normalizeThemeId(id);
setSelectedThemeId(normalized);
setPreviewId(null);
try {
setTuiThemeGlobally(normalized);
} catch {
// Persisting is best-effort; the in-session theme still applies.
}
}, []);
const previewThemeId = useCallback((id: string | null) => {
setPreviewId(id === null ? null : normalizeThemeId(id));
}, []);
const value = useMemo(
() => ({ theme, selectedThemeId, setThemeId, previewThemeId }),
[theme, selectedThemeId, setThemeId, previewThemeId],
);
return <ThemeContext value={value}>{props.children}</ThemeContext>;
}
+18 -6
View File
@@ -216,6 +216,20 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
finalizeDanglingCompactionEntry("cancelled");
break;
case "error":
// Recoverable errors are in-run notices (the MistakeTracker
// emits one for every recorded mistake, e.g. a plan-mode
// guard-blocked command) — the run continues, so the footer
// must keep reflecting the active turn instead of flipping
// to idle mid-run. Surface them only in verbose mode.
if (event.recoverable) {
if (verbose) {
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error, { modelId }),
});
}
break;
}
setIsRunning(false);
setIsStreaming(false);
closeInlineStream();
@@ -223,12 +237,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
finalizeDanglingCompactionEntry("failed");
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error, { modelId }),
});
}
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error, { modelId }),
});
break;
case "notice":
if (event.displayRole === "status") {
@@ -45,6 +45,7 @@ export function useConfigPanel(opts: {
) => Promise<InteractiveConfigData | undefined>;
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
openThemePicker: (options?: { refocus?: boolean }) => Promise<void>;
refocusTextarea: () => void;
}) {
const emptyConfigData = useMemo(
@@ -118,6 +119,8 @@ export function useConfigPanel(opts: {
});
} else if (action.kind === "open-model") {
await opts.openModelSelector({ onCancel: () => {} });
} else if (action.kind === "open-theme") {
await opts.openThemePicker({ refocus: false });
} else if (action.kind === "toggle-item") {
await opts.onToggleConfigItem?.(action.item);
} else if (action.kind === "delete-item") {
@@ -15,6 +15,7 @@ function makeActions(
openMcpManager: vi.fn(async () => false),
openModelSelector: vi.fn(),
openSkills: vi.fn(),
openThemePicker: vi.fn(),
runCompact: vi.fn(),
runFork: vi.fn(),
runUndo: vi.fn(async () => {}),
@@ -23,6 +23,7 @@ export function useLocalCommandActions(input: {
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
openThemePicker: () => void;
refocusTextarea: () => void;
setAppView: (view: AppView) => void;
onClearConversation: () => Promise<void>;
@@ -45,6 +46,7 @@ export function useLocalCommandActions(input: {
openMcpManager,
openModelSelector,
openSkills,
openThemePicker,
refocusTextarea,
setAppView,
onClearConversation,
@@ -227,6 +229,7 @@ export function useLocalCommandActions(input: {
openMcpManager,
openModelSelector,
openSkills,
openThemePicker,
runCompact,
runFork,
runUndo: onUndo,
@@ -247,6 +250,7 @@ export function useLocalCommandActions(input: {
openHistory,
openModelSelector,
openSkills,
openThemePicker,
runCompact,
runFork,
session.isRunning,
@@ -1,25 +0,0 @@
import { createContext, useContext } from "react";
import { getTerminalTheme, type TerminalTheme } from "../palette";
export interface TerminalColors {
background: string | null;
foreground: string | null;
}
export const TerminalColorsContext = createContext<TerminalColors>({
background: null,
foreground: null,
});
export function useTerminalBackground(): string | null {
return useContext(TerminalColorsContext).background;
}
export function useTerminalForeground(): string | null {
return useContext(TerminalColorsContext).foreground;
}
export function useTerminalTheme(): TerminalTheme {
const { background, foreground } = useContext(TerminalColorsContext);
return getTerminalTheme(background, foreground);
}
+58
View File
@@ -0,0 +1,58 @@
import { createContext, useContext } from "react";
import type { TerminalTheme } from "../palette";
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
export interface TerminalColors {
background: string | null;
foreground: string | null;
}
export const TerminalColorsContext = createContext<TerminalColors>({
background: null,
foreground: null,
});
export interface ThemeController {
theme: ResolvedTheme;
/** The persisted selection (previews do not change this). */
selectedThemeId: string;
/** Select and persist a theme. */
setThemeId: (id: string) => void;
/** Temporarily render a theme (live preview); null reverts to selection. */
previewThemeId: (id: string | null) => void;
}
/** Provided by ThemeProvider (see theme-provider.tsx). */
export const ThemeContext = createContext<ThemeController | null>(null);
export function useThemeController(): ThemeController {
const controller = useContext(ThemeContext);
if (!controller) {
throw new Error("useThemeController must be used within ThemeProvider");
}
return controller;
}
export function useTheme(): ResolvedTheme {
const controller = useContext(ThemeContext);
const detected = useContext(TerminalColorsContext);
// Fall back to auto resolution so components render sensibly when mounted
// without a ThemeProvider (e.g. in isolated tests).
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
}
/**
* Background that adaptive colors (input field, user bubbles, rules) derive
* from: the theme's painted background when set, else the detected one.
*/
export function useTerminalBackground(): string | null {
return useTheme().background;
}
export function useTerminalForeground(): string | null {
return useContext(TerminalColorsContext).foreground;
}
export function useTerminalTheme(): TerminalTheme {
return useTheme().variant;
}
+14
View File
@@ -1,7 +1,9 @@
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { getInitialThemeId } from "./hooks/theme-provider";
import { Root } from "./root";
import { installTuiStdioCapture } from "./stdio-capture";
import { resolveTheme } from "./themes";
import type { TuiProps } from "./types";
export type { TuiProps } from "./types";
@@ -22,6 +24,17 @@ export async function renderOpenTui(
const terminalBackground = detectedPalette?.defaultBackground ?? null;
const terminalForeground = detectedPalette?.defaultForeground ?? null;
// Paint the selected theme's background before the first frame so themed
// sessions don't flash the terminal's own background on startup.
const initialThemeId = getInitialThemeId();
const initialTheme = resolveTheme(initialThemeId, {
background: terminalBackground,
foreground: terminalForeground,
});
if (initialTheme.appBackground) {
renderer.setBackgroundColor(initialTheme.appBackground);
}
let root: ReturnType<typeof createRoot>;
try {
root = createRoot(renderer);
@@ -30,6 +43,7 @@ export async function renderOpenTui(
{...props}
terminalBackground={terminalBackground}
terminalForeground={terminalForeground}
initialThemeId={initialThemeId}
/>,
);
} catch (error) {
+3 -3
View File
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
import { getMcpDescription } from "./interactive-config";
describe("getMcpDescription", () => {
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
it("discloses the default initialize timeout for unconfigured stdio servers", () => {
expect(
getMcpDescription({
name: "local",
transport: { type: "stdio", command: "node" },
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
});
it("shows one configured timeout when it also applies to initialize", () => {
@@ -40,6 +40,6 @@ describe("getMcpDescription", () => {
transport: { type: "stdio", command: "node" },
timeoutSeconds: Number.NaN,
}),
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
});
});
+4 -37
View File
@@ -1,7 +1,6 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import {
basename,
dirname,
extname,
isAbsolute,
join,
@@ -10,7 +9,9 @@ import {
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
discoverPluginModulePaths,
getPluginDisplayName,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginToolsWithDiagnostics,
@@ -183,7 +184,7 @@ export function getMcpDescription(registration: McpServerRegistration): string {
const timeoutDescription =
registration.transport.type === "stdio" &&
!isMcpTimeoutConfigured(registration.timeoutSeconds)
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
? `request timeout ${timeoutSeconds}s, initialize timeout ${DEFAULT_MCP_CONNECT_TIMEOUT_MS / 1000}s`
: `timeout ${timeoutSeconds}s`;
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
}
@@ -243,40 +244,6 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
return [...agentsById.values()];
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
};
return typeof packageJson.name === "string" && packageJson.name.trim()
? packageJson.name.trim()
: undefined;
} catch {
return undefined;
}
}
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
if (packageName) {
return packageName;
}
break;
}
const parent = resolve(current, "..");
if (parent === current) {
break;
}
current = parent;
}
return basename(filePath, extname(filePath));
}
function isPathWithin(parentPath: string, childPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
+7 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getModeAccent, getSuccessColor, getTerminalTheme } from "./palette";
import { getTerminalTheme, themePalette } from "./palette";
describe("getTerminalTheme", () => {
it("detects light terminals from the default background", () => {
@@ -24,14 +24,14 @@ describe("getTerminalTheme", () => {
describe("theme-aware palette helpers", () => {
it("uses the brand accent colors for dark terminals", () => {
expect(getModeAccent("act", "dark")).toBe("#79b8ff");
expect(getModeAccent("plan", "dark")).toBe("#ffea7f");
expect(getSuccessColor("dark")).toBe("#99e89b");
expect(themePalette.dark.act).toBe("#79b8ff");
expect(themePalette.dark.plan).toBe("#ffea7f");
expect(themePalette.dark.success).toBe("#99e89b");
});
it("uses darker accents on light terminals", () => {
expect(getModeAccent("act", "light")).toBe("#0f72cb");
expect(getModeAccent("plan", "light")).toBe("#867100");
expect(getSuccessColor("light")).toBe("#116329");
expect(themePalette.light.act).toBe("#0f72cb");
expect(themePalette.light.plan).toBe("#867100");
expect(themePalette.light.success).toBe("#116329");
});
});
+2 -11
View File
@@ -46,17 +46,6 @@ export const diffPalettes = {
},
} as const;
export function getModeAccent(
mode: string,
theme: TerminalTheme = "dark",
): string {
return mode === "plan" ? themePalette[theme].plan : themePalette[theme].act;
}
export function getSuccessColor(theme: TerminalTheme = "dark"): string {
return themePalette[theme].success;
}
// Input field adaptive color system
//
// The input field background needs to be visibly distinct from the terminal
@@ -208,6 +197,8 @@ export function getModeInputPlaceholder(
);
}
export { hexToOklab, oklabToHex };
function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
+63 -18
View File
@@ -11,8 +11,11 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
import type { RepoStatus } from "../utils/repo-status";
import { readRepoStatus } from "../utils/repo-status";
import {
isSameRepoStatus,
type RepoStatus,
readRepoStatus,
} from "../utils/repo-status";
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
import type { TranscriptScrollHandle } from "./components/chat-message-list";
import {
@@ -36,9 +39,11 @@ import {
SKILLS_MARKETPLACE_URL,
SkillsPickerContent,
} from "./components/dialogs/skills-picker";
import { ThemePickerContent } from "./components/dialogs/theme-picker";
import { Toast, type ToastState, type ToastVariant } from "./components/toast";
import { EventBridgeProvider } from "./contexts/event-bridge-context";
import { SessionProvider, useSession } from "./contexts/session-context";
import { ThemeProvider } from "./hooks/theme-provider";
import { useAccountDialog } from "./hooks/use-account-dialog";
import { useAgentEventHandlers } from "./hooks/use-agent-events";
import { useAutocomplete } from "./hooks/use-autocomplete";
@@ -51,8 +56,8 @@ import { useQueuedPrompts } from "./hooks/use-queued-prompts";
import { useRootKeyboard } from "./hooks/use-root-keyboard";
import { useRuntimeDialogBridge } from "./hooks/use-runtime-dialog-bridge";
import { useSlashCommands } from "./hooks/use-slash-commands";
import { TerminalColorsContext } from "./hooks/use-terminal-background";
import { useTerminalTitle } from "./hooks/use-terminal-title";
import { TerminalColorsContext } from "./hooks/use-theme";
import type { AppView, TuiProps, TuiStartupTarget } from "./types";
import { hydrateSessionMessages } from "./utils/hydrate-messages";
import { isProviderConfigured } from "./utils/provider-configured";
@@ -135,12 +140,31 @@ function App(props: TuiProps) {
skillCommands,
});
const repoStatusInFlightRef = useRef(false);
const refreshRepoStatus = useCallback(() => {
// Skip if the previous read is still running (slow git on huge repos)
// so poll ticks never stack subprocesses or apply stale results.
if (repoStatusInFlightRef.current) return;
repoStatusInFlightRef.current = true;
readRepoStatus(props.config.cwd)
.then(setRepoStatus)
.catch(() => {});
.then((next) =>
// Keep the previous object when nothing changed so poll ticks
// don't re-render the app.
setRepoStatus((prev) => (isSameRepoStatus(prev, next) ? prev : next)),
)
.catch(() => {})
.finally(() => {
repoStatusInFlightRef.current = false;
});
}, [props.config.cwd]);
// Poll so branch switches made outside the CLI (another terminal, an
// editor) show up without requiring an agent turn.
useEffect(() => {
const interval = setInterval(refreshRepoStatus, 5_000);
return () => clearInterval(interval);
}, [refreshRepoStatus]);
const refocusTextareaRef = useRef<() => void>(() => {});
const populateInputRef = useRef<(value: string) => void>(() => {});
const insertSkillCommandRef = useRef<
@@ -203,6 +227,22 @@ function App(props: TuiProps) {
onSessionRestart: props.onSessionRestart,
refocusTextarea: () => refocusTextareaRef.current(),
});
const openThemePicker = useCallback(
async (options?: { refocus?: boolean }) => {
await dialog.choice<string>({
size: "large",
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
<ThemePickerContent {...ctx} />
),
});
if (options?.refocus !== false) {
refocusTextareaRef.current();
}
},
[dialog, termHeight],
);
const propsOnToggleConfigItem = props.onToggleConfigItem;
const onToggleConfigItem = useMemo<TuiProps["onToggleConfigItem"]>(() => {
if (!propsOnToggleConfigItem) {
@@ -244,6 +284,7 @@ function App(props: TuiProps) {
onDeleteConfigItem,
openModelSelector,
openMcpManager,
openThemePicker,
refocusTextarea: () => refocusTextareaRef.current(),
});
@@ -592,6 +633,7 @@ function App(props: TuiProps) {
openMcpManager,
openModelSelector,
openSkills,
openThemePicker,
refocusTextarea: () => refocusTextareaRef.current(),
setAppView,
onClearConversation: clearConversation,
@@ -914,6 +956,7 @@ export function Root(
props: TuiProps & {
terminalBackground?: string | null;
terminalForeground?: string | null;
initialThemeId?: string;
},
) {
const initialEntries = useMemo(
@@ -937,19 +980,21 @@ export function Root(
);
return (
<TerminalColorsContext value={terminalColors}>
<DialogProvider size="medium">
<SessionProvider
config={props.config}
initialEntries={initialEntries}
initialUsage={initialUsage}
onRunningChange={props.onRunningChange}
onAutoApproveChange={props.onAutoApproveChange}
onCompactionModeChange={props.onCompactionModeChange}
onExit={props.onExit}
>
<App {...props} />
</SessionProvider>
</DialogProvider>
<ThemeProvider initialThemeId={props.initialThemeId}>
<DialogProvider size="medium">
<SessionProvider
config={props.config}
initialEntries={initialEntries}
initialUsage={initialUsage}
onRunningChange={props.onRunningChange}
onAutoApproveChange={props.onAutoApproveChange}
onCompactionModeChange={props.onCompactionModeChange}
onExit={props.onExit}
>
<App {...props} />
</SessionProvider>
</DialogProvider>
</ThemeProvider>
</TerminalColorsContext>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { diffPalettes, themePalette } from "./palette";
import {
AUTO_THEME_ID,
getDialogAccents,
getThemeDefinition,
getThemeModeAccent,
getThemeSwatchColors,
normalizeThemeId,
resolveTheme,
THEMES,
} from "./themes";
const noDetection = { background: null, foreground: null };
describe("theme registry", () => {
it("has unique ids and auto first", () => {
const ids = THEMES.map((theme) => theme.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids[0]).toBe(AUTO_THEME_ID);
});
it("gives every non-auto theme an explicit background and foreground", () => {
for (const theme of THEMES) {
if (theme.id === AUTO_THEME_ID) {
expect(theme.background).toBeNull();
expect(theme.foreground).toBeNull();
} else {
expect(theme.background).toMatch(/^#[0-9a-f]{6}$/i);
expect(theme.foreground).toMatch(/^#[0-9a-f]{6}$/i);
}
}
});
it("provides four swatch colors per theme", () => {
for (const theme of THEMES) {
expect(getThemeSwatchColors(theme)).toHaveLength(4);
}
});
});
describe("normalizeThemeId", () => {
it("accepts known ids case-insensitively", () => {
expect(normalizeThemeId("tokyo-night")).toBe("tokyo-night");
expect(normalizeThemeId(" Dracula ")).toBe("dracula");
});
it("falls back to auto for unknown or missing ids", () => {
expect(normalizeThemeId("not-a-theme")).toBe(AUTO_THEME_ID);
expect(normalizeThemeId(undefined)).toBe(AUTO_THEME_ID);
expect(normalizeThemeId(null)).toBe(AUTO_THEME_ID);
expect(normalizeThemeId("")).toBe(AUTO_THEME_ID);
});
});
describe("resolveTheme", () => {
it("auto adapts to the detected terminal and keeps its background", () => {
const dark = resolveTheme(AUTO_THEME_ID, noDetection);
expect(dark.variant).toBe("dark");
expect(dark.appBackground).toBeNull();
expect(dark.background).toBeNull();
expect(dark.defaultForeground).toBeUndefined();
expect(dark.accents.act).toBe(themePalette.dark.act);
expect(dark.diff).toEqual(diffPalettes.dark);
const light = resolveTheme(AUTO_THEME_ID, {
background: "#ffffff",
foreground: null,
});
expect(light.variant).toBe("light");
expect(light.appBackground).toBeNull();
expect(light.background).toBe("#ffffff");
expect(light.defaultForeground).toBe("#1a1a1a");
expect(light.accents.act).toBe(themePalette.light.act);
expect(light.diff).toEqual(diffPalettes.light);
});
it("forced dark and light themes override detection", () => {
const forcedDark = resolveTheme("dark", {
background: "#ffffff",
foreground: "#1a1a1a",
});
expect(forcedDark.variant).toBe("dark");
expect(forcedDark.appBackground).toBe("#14161b");
expect(forcedDark.background).toBe("#14161b");
expect(forcedDark.accents.act).toBe(themePalette.dark.act);
const forcedLight = resolveTheme("light", noDetection);
expect(forcedLight.variant).toBe("light");
expect(forcedLight.appBackground).toBe("#ffffff");
expect(forcedLight.defaultForeground).toBe("#1a1a1a");
expect(forcedLight.accents.act).toBe(themePalette.light.act);
});
it("named themes carry their own accents, syntax, and derived diff", () => {
const tokyo = resolveTheme("tokyo-night", noDetection);
expect(tokyo.variant).toBe("dark");
expect(tokyo.appBackground).toBe("#1a1b26");
expect(tokyo.defaultForeground).toBe("#c0caf5");
expect(tokyo.accents.act).toBe("#7aa2f7");
expect(tokyo.syntax.keyword).toBe("#bb9af7");
expect(tokyo.diff.addedSignColor).toBe("#9ece6a");
expect(tokyo.diff.removedSignColor).toBe("#f7768e");
// Derived diff backgrounds are tints of the theme background, not the
// stock dark diff palette.
expect(tokyo.diff.addedBg).not.toBe(diffPalettes.dark.addedBg);
expect(tokyo.diff.addedBg).toMatch(/^#[0-9a-f]{6}$/i);
});
it("falls back to auto for unknown ids", () => {
const resolved = resolveTheme("bogus", noDetection);
expect(resolved.id).toBe(AUTO_THEME_ID);
});
it("resolves every registered theme without missing colors", () => {
for (const definition of THEMES) {
const resolved = resolveTheme(definition.id, noDetection);
expect(resolved.accents.act).toBeTruthy();
expect(resolved.accents.plan).toBeTruthy();
expect(resolved.accents.success).toBeTruthy();
expect(resolved.accents.error).toBeTruthy();
for (const value of Object.values(resolved.diff)) {
expect(value).toBeTruthy();
}
expect(resolved.syntax.keyword).toBeTruthy();
expect(resolved.syntax.comment).toBeTruthy();
}
});
it("derives a readable selection pair per theme", () => {
// Dark-theme accents are light, so selected text flips to black.
const dark = resolveTheme(AUTO_THEME_ID, noDetection);
expect(dark.selection).toBe(dark.accents.act);
expect(dark.textOnSelection).toBe("#000000");
// Light-theme accents are darkened for contrast, so text flips to white.
const light = resolveTheme("light", noDetection);
expect(light.selection).toBe(light.accents.act);
expect(light.textOnSelection).toBe("#ffffff");
for (const definition of THEMES) {
const resolved = resolveTheme(definition.id, noDetection);
expect(["#000000", "#ffffff"]).toContain(resolved.textOnSelection);
}
});
});
describe("theme helpers", () => {
it("getThemeModeAccent picks the accent by mode", () => {
const theme = resolveTheme("nord", noDetection);
expect(getThemeModeAccent(theme, "act")).toBe("#88c0d0");
expect(getThemeModeAccent(theme, "plan")).toBe("#ebcb8b");
});
it("getDialogAccents falls back to dark accents for light themes", () => {
const solarizedLight = resolveTheme("solarized-light", noDetection);
expect(getDialogAccents(solarizedLight).act).toBe(themePalette.dark.act);
const dracula = resolveTheme("dracula", noDetection);
expect(getDialogAccents(dracula).act).toBe("#bd93f9");
});
it("getThemeDefinition finds registered themes", () => {
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
expect(getThemeDefinition("missing")).toBeUndefined();
});
});
+575
View File
@@ -0,0 +1,575 @@
import {
diffPalettes,
getDefaultForeground,
getTerminalTheme,
hexToOklab,
oklabToHex,
type TerminalTheme,
themePalette,
} from "./palette";
// User-selectable color themes for the TUI.
//
// Three kinds of built-in themes exist:
// - "auto" adapts to the terminal: it detects light/dark from the terminal's
// reported background and keeps that background untouched (the pre-theme
// behavior, and still the default).
// - "dark" / "light" force the corresponding Cline palette and paint a
// matching background, for terminals whose reported colors are missing or
// wrong (see cline/cline#12872).
// - Named themes (Tokyo Night, Gruvbox, ...) paint their canonical
// background and bring their own accent + syntax palettes.
export interface ThemeAccents {
act: string;
plan: string;
success: string;
error: string;
}
export interface ThemeDiffPalette {
addedBg: string;
removedBg: string;
addedLineNumberBg: string;
removedLineNumberBg: string;
addedSignColor: string;
removedSignColor: string;
lineNumberFg: string;
}
export interface ThemeSyntaxColors {
keyword: string;
operator: string;
type: string;
functionName: string;
variable: string;
string: string;
number: string;
comment: string;
punctuation: string;
property: string;
constant: string;
tag: string;
attribute: string;
escape: string;
markdownCode: string;
markdownMuted: string;
markdownItalic: string;
markdownDefault?: string;
}
// Dark syntax colors are a pastel family harmonized with the brand accents
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
// part of the same palette instead of a bolted-on editor theme.
export const baseSyntaxColors: Record<TerminalTheme, ThemeSyntaxColors> = {
dark: {
keyword: "#d7a0e3",
operator: "#9bbbdd",
type: "#dfca7d",
functionName: themePalette.dark.act,
variable: "#ee939b",
string: "#99e89b",
number: "#f0ad7f",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#ee939b",
constant: "#f0ad7f",
tag: "#ee939b",
attribute: "#f0ad7f",
escape: "#9bbbdd",
markdownCode: "#99e89b",
markdownMuted: "#808080",
markdownItalic: "#dfca7d",
},
light: {
keyword: "#cf222e",
operator: "#0550ae",
type: "#953800",
functionName: "#8250df",
variable: "#953800",
string: "#0a3069",
number: "#0550ae",
comment: "#6e7781",
punctuation: "#57606a",
property: "#0550ae",
constant: "#0550ae",
tag: "#116329",
attribute: "#0550ae",
escape: "#0550ae",
markdownCode: "#116329",
markdownMuted: "#6e7781",
markdownItalic: "#8250df",
markdownDefault: "#1a1a1a",
},
};
const baseAccents: Record<TerminalTheme, ThemeAccents> = {
dark: { ...themePalette.dark, error: "#ef4444" },
light: { ...themePalette.light, error: "#b42318" },
};
export interface ThemeDefinition {
id: string;
label: string;
description: string;
/** "auto" resolves to the detected terminal variant at runtime. */
variant: TerminalTheme | "auto";
/** Painted over the whole terminal; null keeps the terminal's background. */
background: string | null;
/** Default text color; null keeps the variant default. */
foreground: string | null;
accents?: Partial<ThemeAccents>;
syntax?: Partial<ThemeSyntaxColors>;
diff?: Partial<ThemeDiffPalette>;
}
export interface ResolvedTheme {
id: string;
label: string;
variant: TerminalTheme;
/** Explicit background painted over the terminal, or null to keep it. */
appBackground: string | null;
/** Background all adaptive colors derive from (theme's, else detected). */
background: string | null;
/** Default text color; undefined keeps the renderer default (white). */
defaultForeground: string | undefined;
/** Background for selected rows/buttons on the main themed surface. */
selection: string;
/** Text color readable on top of `selection`. */
textOnSelection: string;
accents: ThemeAccents;
diff: ThemeDiffPalette;
syntax: ThemeSyntaxColors;
}
export const AUTO_THEME_ID = "auto";
export const THEMES: readonly ThemeDefinition[] = [
{
id: AUTO_THEME_ID,
label: "Auto",
description: "Adapts to your terminal's colors",
variant: "auto",
background: null,
foreground: null,
},
{
id: "dark",
label: "Cline Dark",
description: "Cline's accents on deep charcoal",
variant: "dark",
background: "#14161b",
foreground: "#e8eaed",
},
{
id: "light",
label: "Cline Light",
description: "Crisp white, high-contrast accents",
variant: "light",
background: "#ffffff",
foreground: "#1a1a1a",
},
{
id: "tokyo-night",
label: "Tokyo Night",
description: "Moody blues and neon city glow",
variant: "dark",
background: "#1a1b26",
foreground: "#c0caf5",
accents: {
act: "#7aa2f7",
plan: "#e0af68",
success: "#9ece6a",
error: "#f7768e",
},
syntax: {
keyword: "#bb9af7",
operator: "#89ddff",
type: "#2ac3de",
functionName: "#7aa2f7",
variable: "#c0caf5",
string: "#9ece6a",
number: "#ff9e64",
comment: "#565f89",
punctuation: "#a9b1d6",
property: "#73daca",
constant: "#ff9e64",
tag: "#f7768e",
attribute: "#bb9af7",
escape: "#89ddff",
markdownCode: "#9ece6a",
markdownMuted: "#565f89",
markdownItalic: "#e0af68",
markdownDefault: "#c0caf5",
},
},
{
id: "gruvbox-dark",
label: "Gruvbox Dark",
description: "Retro warmth, earthy and amber",
variant: "dark",
background: "#282828",
foreground: "#ebdbb2",
accents: {
act: "#83a598",
plan: "#fabd2f",
success: "#b8bb26",
error: "#fb4934",
},
syntax: {
keyword: "#fb4934",
operator: "#fe8019",
type: "#fabd2f",
functionName: "#b8bb26",
variable: "#83a598",
string: "#b8bb26",
number: "#d3869b",
comment: "#928374",
punctuation: "#ebdbb2",
property: "#83a598",
constant: "#d3869b",
tag: "#8ec07c",
attribute: "#fabd2f",
escape: "#fe8019",
markdownCode: "#b8bb26",
markdownMuted: "#928374",
markdownItalic: "#fabd2f",
markdownDefault: "#ebdbb2",
},
},
{
id: "nord",
label: "Nord",
description: "Cool arctic blues and frosted teals",
variant: "dark",
background: "#2e3440",
foreground: "#d8dee9",
accents: {
act: "#88c0d0",
plan: "#ebcb8b",
success: "#a3be8c",
error: "#bf616a",
},
syntax: {
keyword: "#81a1c1",
operator: "#81a1c1",
type: "#8fbcbb",
functionName: "#88c0d0",
variable: "#d8dee9",
string: "#a3be8c",
number: "#b48ead",
comment: "#616e88",
punctuation: "#eceff4",
property: "#8fbcbb",
constant: "#b48ead",
tag: "#81a1c1",
attribute: "#8fbcbb",
escape: "#ebcb8b",
markdownCode: "#a3be8c",
markdownMuted: "#616e88",
markdownItalic: "#ebcb8b",
markdownDefault: "#d8dee9",
},
},
{
id: "dracula",
label: "Dracula",
description: "Vivid color on a dark violet night",
variant: "dark",
background: "#282a36",
foreground: "#f8f8f2",
accents: {
act: "#bd93f9",
plan: "#f1fa8c",
success: "#50fa7b",
error: "#ff5555",
},
syntax: {
keyword: "#ff79c6",
operator: "#ff79c6",
type: "#8be9fd",
functionName: "#50fa7b",
variable: "#f8f8f2",
string: "#f1fa8c",
number: "#bd93f9",
comment: "#6272a4",
punctuation: "#f8f8f2",
property: "#8be9fd",
constant: "#bd93f9",
tag: "#ff79c6",
attribute: "#50fa7b",
escape: "#ff79c6",
markdownCode: "#f1fa8c",
markdownMuted: "#6272a4",
markdownItalic: "#ffb86c",
markdownDefault: "#f8f8f2",
},
},
{
id: "catppuccin-mocha",
label: "Catppuccin Mocha",
description: "Soothing pastels on warm mocha",
variant: "dark",
background: "#1e1e2e",
foreground: "#cdd6f4",
accents: {
act: "#89b4fa",
plan: "#f9e2af",
success: "#a6e3a1",
error: "#f38ba8",
},
syntax: {
keyword: "#cba6f7",
operator: "#89dceb",
type: "#f9e2af",
functionName: "#89b4fa",
variable: "#cdd6f4",
string: "#a6e3a1",
number: "#fab387",
comment: "#6c7086",
punctuation: "#9399b2",
property: "#94e2d5",
constant: "#fab387",
tag: "#f38ba8",
attribute: "#f9e2af",
escape: "#f5c2e7",
markdownCode: "#a6e3a1",
markdownMuted: "#6c7086",
markdownItalic: "#f9e2af",
markdownDefault: "#cdd6f4",
},
},
{
id: "one-dark",
label: "One Dark",
description: "Atom's balanced, easygoing dark",
variant: "dark",
background: "#282c34",
foreground: "#abb2bf",
accents: {
act: "#61afef",
plan: "#e5c07b",
success: "#98c379",
error: "#e06c75",
},
syntax: {
keyword: "#c678dd",
operator: "#56b6c2",
type: "#e5c07b",
functionName: "#61afef",
variable: "#e06c75",
string: "#98c379",
number: "#d19a66",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#e06c75",
constant: "#d19a66",
tag: "#e06c75",
attribute: "#d19a66",
escape: "#56b6c2",
markdownCode: "#98c379",
markdownMuted: "#5c6370",
markdownItalic: "#e5c07b",
markdownDefault: "#abb2bf",
},
},
{
id: "solarized-dark",
label: "Solarized Dark",
description: "Low-glare teal depths, easy on eyes",
variant: "dark",
background: "#002b36",
foreground: "#93a1a1",
accents: {
act: "#268bd2",
plan: "#b58900",
success: "#859900",
error: "#dc322f",
},
syntax: {
keyword: "#859900",
operator: "#93a1a1",
type: "#b58900",
functionName: "#268bd2",
variable: "#268bd2",
string: "#2aa198",
number: "#d33682",
comment: "#586e75",
punctuation: "#93a1a1",
property: "#268bd2",
constant: "#cb4b16",
tag: "#268bd2",
attribute: "#93a1a1",
escape: "#cb4b16",
markdownCode: "#2aa198",
markdownMuted: "#586e75",
markdownItalic: "#6c71c4",
markdownDefault: "#93a1a1",
},
},
{
id: "solarized-light",
label: "Solarized Light",
description: "Warm parchment with muted accents",
variant: "light",
background: "#fdf6e3",
foreground: "#657b83",
accents: {
act: "#268bd2",
plan: "#b58900",
success: "#859900",
error: "#dc322f",
},
syntax: {
keyword: "#859900",
operator: "#657b83",
type: "#b58900",
functionName: "#268bd2",
variable: "#268bd2",
string: "#2aa198",
number: "#d33682",
comment: "#93a1a1",
punctuation: "#657b83",
property: "#268bd2",
constant: "#cb4b16",
tag: "#268bd2",
attribute: "#586e75",
escape: "#cb4b16",
markdownCode: "#2aa198",
markdownMuted: "#93a1a1",
markdownItalic: "#6c71c4",
markdownDefault: "#657b83",
},
},
] as const;
const THEMES_BY_ID = new Map(THEMES.map((theme) => [theme.id, theme]));
export function getThemeDefinition(id: string): ThemeDefinition | undefined {
return THEMES_BY_ID.get(id);
}
export function normalizeThemeId(id: string | undefined | null): string {
const trimmed = id?.trim().toLowerCase();
return trimmed && THEMES_BY_ID.has(trimmed) ? trimmed : AUTO_THEME_ID;
}
// Below this WCAG relative luminance, white text has the higher contrast
// ratio against the background; above it, black does. Derived from
// (L + 0.05)^2 = 1.05 * 0.05.
const WHITE_TEXT_LUMINANCE_CUTOFF = 0.179;
function relativeLuminance(hex: string): number {
const channel = (offset: number) => {
const c = parseInt(hex.slice(offset, offset + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
}
function mixHex(base: string, tint: string, amount: number): string {
const a = hexToOklab(base);
const b = hexToOklab(tint);
return oklabToHex(
a.L + (b.L - a.L) * amount,
a.a + (b.a - a.a) * amount,
a.b + (b.b - a.b) * amount,
);
}
// Diff rows tint the theme background toward the theme's own green/red so
// diffs feel native to every theme instead of reusing one fixed palette.
function deriveDiffPalette(
background: string,
foreground: string,
accents: ThemeAccents,
): ThemeDiffPalette {
return {
addedBg: mixHex(background, accents.success, 0.22),
removedBg: mixHex(background, accents.error, 0.22),
addedLineNumberBg: mixHex(background, accents.success, 0.3),
removedLineNumberBg: mixHex(background, accents.error, 0.3),
addedSignColor: accents.success,
removedSignColor: accents.error,
lineNumberFg: mixHex(foreground, background, 0.4),
};
}
export interface DetectedTerminalColors {
background: string | null;
foreground: string | null;
}
export function resolveTheme(
id: string,
detected: DetectedTerminalColors,
): ResolvedTheme {
const definition =
getThemeDefinition(normalizeThemeId(id)) ?? (THEMES[0] as ThemeDefinition);
const variant: TerminalTheme =
definition.variant === "auto"
? getTerminalTheme(detected.background, detected.foreground)
: definition.variant;
const appBackground = definition.background;
const background = appBackground ?? detected.background;
const accents: ThemeAccents = {
...baseAccents[variant],
...definition.accents,
};
const syntax: ThemeSyntaxColors = {
...baseSyntaxColors[variant],
...(definition.foreground
? { markdownDefault: definition.foreground }
: {}),
...definition.syntax,
};
const diff: ThemeDiffPalette = {
...(appBackground && definition.foreground
? deriveDiffPalette(appBackground, definition.foreground, accents)
: diffPalettes[variant]),
...definition.diff,
};
// Selected rows highlight with the act accent; the text on top flips
// between black and white, picking whichever has the higher WCAG
// contrast ratio against the accent.
const selection = accents.act;
const textOnSelection =
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
? "#000000"
: "#ffffff";
return {
id: definition.id,
label: definition.label,
variant,
appBackground,
background,
defaultForeground:
definition.foreground ?? getDefaultForeground(background),
selection,
textOnSelection,
accents,
diff,
syntax,
};
}
export function getThemeModeAccent(theme: ResolvedTheme, mode: string): string {
return mode === "plan" ? theme.accents.plan : theme.accents.act;
}
/**
* Dialog surfaces are always dark, so light-variant themes (whose accents are
* darkened for contrast on light backgrounds) fall back to the dark accent
* set for readable dialog content.
*/
export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
}
/** Small color strip rendered next to each entry in the theme picker. */
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
const variant = definition.variant === "auto" ? "dark" : definition.variant;
const accents = { ...baseAccents[variant], ...definition.accents };
return [accents.act, accents.plan, accents.success, accents.error];
}
@@ -135,6 +135,35 @@ describe("hydrateSessionMessages", () => {
]);
});
// Regression test for https://github.com/cline/cline/issues/13036:
// persisted sessions with malformed tool inputs must stay resumable.
it("hydrates tool calls with malformed inputs without throwing", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "run_commands",
input: { command: null },
},
],
},
] as Message[];
expect(hydrateSessionMessages(messages)).toEqual([
{
kind: "tool_call",
toolName: "run_commands",
inputSummary: "",
rawInput: { command: null },
streaming: false,
mode: undefined,
},
]);
});
it("leaves mode undefined for transcripts without user_input wrappers", () => {
const messages = [
{ role: "user", content: "plain old message" },
+39 -9
View File
@@ -39,13 +39,25 @@ vi.mock("@opentui/core", () => ({
SyntaxStyle: MockSyntaxStyle,
}));
import { resolveTheme } from "../themes";
const darkTheme = resolveTheme("auto", { background: null, foreground: null });
const lightTheme = resolveTheme("auto", {
background: "#ffffff",
foreground: null,
});
const tokyoNight = resolveTheme("tokyo-night", {
background: null,
foreground: null,
});
describe("getSyntaxStyle", () => {
it("keeps dark markdown prose on the terminal default foreground", () => {
expect(getSyntaxStyle("dark").getStyle("default")).toBeUndefined();
expect(getSyntaxStyle(darkTheme).getStyle("default")).toBeUndefined();
});
it("uses a dark default foreground for light markdown content", () => {
const style = getSyntaxStyle("light").getStyle("default");
const style = getSyntaxStyle(lightTheme).getStyle("default");
expect(style?.fg?.toInts()).toEqual([26, 26, 26, 255]);
});
@@ -53,29 +65,47 @@ describe("getSyntaxStyle", () => {
it("tints markdown accents by mode", () => {
// act #79b8ff vs plan #ffea7f (dark theme accents)
expect(
getSyntaxStyle("dark", "act").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(darkTheme, "act").getStyle("markup.heading")?.fg?.toInts(),
).toEqual([0x79, 0xb8, 0xff, 255]);
expect(
getSyntaxStyle("dark", "plan").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(darkTheme, "plan")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0xff, 0xea, 0x7f, 255]);
expect(
getSyntaxStyle("dark", "plan").getStyle("markup.link")?.fg?.toInts(),
getSyntaxStyle(darkTheme, "plan").getStyle("markup.link")?.fg?.toInts(),
).toEqual([0xff, 0xea, 0x7f, 255]);
});
it("tints light-theme markdown accents by mode", () => {
// act #0f72cb vs plan #867100 (light theme accents)
expect(
getSyntaxStyle("light", "act").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(lightTheme, "act")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0x0f, 0x72, 0xcb, 255]);
expect(
getSyntaxStyle("light", "plan").getStyle("markup.heading")?.fg?.toInts(),
getSyntaxStyle(lightTheme, "plan")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0x86, 0x71, 0x00, 255]);
});
it("keeps code token colors constant across modes", () => {
expect(getSyntaxStyle("dark", "plan").getStyle("keyword")).toEqual(
getSyntaxStyle("dark", "act").getStyle("keyword"),
expect(getSyntaxStyle(darkTheme, "plan").getStyle("keyword")).toEqual(
getSyntaxStyle(darkTheme, "act").getStyle("keyword"),
);
});
it("uses named theme syntax palettes and accents", () => {
// Tokyo Night keyword #bb9af7, act accent #7aa2f7.
expect(
getSyntaxStyle(tokyoNight, "act").getStyle("keyword")?.fg?.toInts(),
).toEqual([0xbb, 0x9a, 0xf7, 255]);
expect(
getSyntaxStyle(tokyoNight, "act")
.getStyle("markup.heading")
?.fg?.toInts(),
).toEqual([0x7a, 0xa2, 0xf7, 255]);
});
});
+10 -73
View File
@@ -1,5 +1,5 @@
import { RGBA, type StyleDefinition, SyntaxStyle } from "@opentui/core";
import { type TerminalTheme, themePalette } from "../palette";
import type { ResolvedTheme } from "../themes";
// Markdown's prominent elements (headings, bold, list markers, links) take
// the accent of the mode the content was produced in, so assistant output
@@ -8,73 +8,6 @@ export type SyntaxAccentMode = "act" | "plan";
const instances = new Map<string, SyntaxStyle>();
interface SyntaxColors {
keyword: string;
operator: string;
type: string;
functionName: string;
variable: string;
string: string;
number: string;
comment: string;
punctuation: string;
property: string;
constant: string;
tag: string;
attribute: string;
escape: string;
markdownCode: string;
markdownMuted: string;
markdownItalic: string;
markdownDefault?: string;
}
// Dark syntax colors are a pastel family harmonized with the brand accents
// (act #79b8ff, plan #ffea7f, success #99e89b): every hue sits near the same
// OKLCH lightness/chroma weight (~L 0.78, C 0.11) so code blocks feel like
// part of the same palette instead of a bolted-on editor theme.
const syntaxColors: Record<TerminalTheme, SyntaxColors> = {
dark: {
keyword: "#d7a0e3",
operator: "#9bbbdd",
type: "#dfca7d",
functionName: themePalette.dark.act,
variable: "#ee939b",
string: "#99e89b",
number: "#f0ad7f",
comment: "#5c6370",
punctuation: "#abb2bf",
property: "#ee939b",
constant: "#f0ad7f",
tag: "#ee939b",
attribute: "#f0ad7f",
escape: "#9bbbdd",
markdownCode: "#99e89b",
markdownMuted: "#808080",
markdownItalic: "#dfca7d",
},
light: {
keyword: "#cf222e",
operator: "#0550ae",
type: "#953800",
functionName: "#8250df",
variable: "#953800",
string: "#0a3069",
number: "#0550ae",
comment: "#6e7781",
punctuation: "#57606a",
property: "#0550ae",
constant: "#0550ae",
tag: "#116329",
attribute: "#0550ae",
escape: "#0550ae",
markdownCode: "#116329",
markdownMuted: "#6e7781",
markdownItalic: "#8250df",
markdownDefault: "#1a1a1a",
},
};
function color(hex: string): RGBA {
return RGBA.fromHex(hex);
}
@@ -92,11 +25,13 @@ function italic(hex: string): StyleDefinition {
}
function buildSyntaxStyle(
theme: TerminalTheme,
theme: ResolvedTheme,
mode: SyntaxAccentMode,
): SyntaxStyle {
const colors = syntaxColors[theme];
const accent = color(themePalette[theme][mode]);
const colors = theme.syntax;
const accent = color(
mode === "plan" ? theme.accents.plan : theme.accents.act,
);
const markdownHeading = accent;
const markdownCode = color(colors.markdownCode);
const markdownMuted = color(colors.markdownMuted);
@@ -150,10 +85,12 @@ function buildSyntaxStyle(
}
export function getSyntaxStyle(
theme: TerminalTheme = "dark",
theme: ResolvedTheme,
mode: SyntaxAccentMode = "act",
): SyntaxStyle {
const key = `${theme}:${mode}`;
// The auto theme resolves to a different palette per variant, so the
// variant participates in the cache key alongside the theme id.
const key = `${theme.id}:${theme.variant}:${mode}`;
let style = instances.get(key);
if (!style) {
style = buildSyntaxStyle(theme, mode);
+66 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
import {
buildReadFilesKeys,
extractFullOutputText,
parseReadFilesInput,
} from "./tool-parsing";
describe("buildReadFilesKeys", () => {
it("produces unique keys when the same path is read twice", () => {
@@ -32,3 +36,64 @@ describe("buildReadFilesKeys", () => {
expect(buildReadFilesKeys([])).toEqual([]);
});
});
describe("extractFullOutputText", () => {
it("extracts text with real newlines from the MCP CallToolResult shape", () => {
const raw = {
content: [
{ type: "text", text: "# Memory\n\nline one" },
{ type: "text", text: "line two" },
],
};
expect(extractFullOutputText(raw)).toBe("# Memory\n\nline one\nline two");
});
it("keeps binary payloads behind placeholders in mixed MCP content", () => {
const raw = {
content: [
{ type: "text", text: "before" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
{
type: "resource",
resource: { uri: "file:///a.md", blob: "d29ybGQ=" },
},
{ type: "resource_link", uri: "file:///b.md", name: "b.md" },
{ type: "text", text: "after" },
],
};
expect(extractFullOutputText(raw)).toBe(
"before\n[image: image/png]\naGVsbG8=\n[resource: file:///a.md]\nd29ybGQ=\n[resource_link: file:///b.md]\nafter",
);
});
it("chunks base64 payloads into 76-char lines so collapse stays compact", () => {
const raw = {
content: [
{ type: "image", data: "A".repeat(160), mimeType: "image/png" },
],
};
expect(extractFullOutputText(raw)?.split("\n")).toEqual([
"[image: image/png]",
"A".repeat(76),
"A".repeat(76),
"A".repeat(8),
]);
});
it("extracts embedded resource text from MCP content", () => {
const raw = {
content: [
{
type: "resource",
resource: { uri: "file:///memory.md", text: "resource body\nline 2" },
},
],
};
expect(extractFullOutputText(raw)).toBe("resource body\nline 2");
});
it("falls back to pretty JSON for objects without text content", () => {
const raw = { structuredContent: { ok: true } };
expect(extractFullOutputText(raw)).toBe(JSON.stringify(raw, null, 2));
});
});
+51
View File
@@ -185,6 +185,12 @@ export function parseSpawnAgentInput(
return { task: input.task };
}
// Base64 payloads are one giant line; chunk to MIME width so GenericOutput's
// line-based collapse stays compact and expand shows the full data.
function chunkBase64(data: string): string {
return data.match(/.{1,76}/g)?.join("\n") ?? data;
}
export function extractFullOutputText(raw: unknown): string | undefined {
if (raw === null || raw === undefined) return undefined;
if (typeof raw === "string") return raw;
@@ -213,6 +219,51 @@ export function extractFullOutputText(raw: unknown): string | undefined {
}
if (typeof raw === "object") {
// MCP tools return {content: [{type: "text", text}, ...]}. Extract the
// text so multi-line results keep real newlines instead of being
// JSON-escaped into one giant line that floods the terminal (#13038).
// Non-text blocks keep their identifying metadata plus their base64
// payloads so mixed results are not silently truncated.
const content = (raw as { content?: unknown }).content;
if (Array.isArray(content)) {
const parts = content
.map((part) => {
if (!isRecord(part)) return "";
if (part.type === "text" && typeof part.text === "string") {
return part.text;
}
if (part.type === "resource" && isRecord(part.resource)) {
if (typeof part.resource.text === "string") {
return part.resource.text;
}
if (typeof part.resource.blob === "string" && part.resource.blob) {
const label =
typeof part.resource.uri === "string"
? `[resource: ${part.resource.uri}]`
: "[resource]";
return `${label}\n${chunkBase64(part.resource.blob)}`;
}
if (typeof part.resource.uri === "string") {
return `[resource: ${part.resource.uri}]`;
}
}
if (part.type === "resource_link" && typeof part.uri === "string") {
return `[resource_link: ${part.uri}]`;
}
if (
(part.type === "image" || part.type === "audio") &&
typeof part.mimeType === "string"
) {
if (typeof part.data === "string" && part.data) {
return `[${part.type}: ${part.mimeType}]\n${chunkBase64(part.data)}`;
}
return `[${part.type}: ${part.mimeType}]`;
}
return typeof part.type === "string" ? `[${part.type}]` : "";
})
.filter(Boolean);
if (parts.length > 0) return parts.join("\n");
}
try {
return JSON.stringify(raw, null, 2);
} catch {
+5 -8
View File
@@ -15,17 +15,14 @@ import {
StatusBar,
} from "../components/status-bar";
import { useSession } from "../contexts/session-context";
import {
useTerminalBackground,
useTerminalTheme,
} from "../hooks/use-terminal-background";
import { useTheme } from "../hooks/use-theme";
import {
getInputRuleColor,
getModeAccent,
getModeInputBackground,
getModeInputForeground,
getModeInputPlaceholder,
} from "../palette";
import { getThemeModeAccent } from "../themes";
import type {
QueuedPromptItem,
RuntimeToolInteraction,
@@ -73,9 +70,9 @@ export function ChatView(props: {
repoStatus,
} = props;
const session = useSession();
const terminalBg = useTerminalBackground();
const terminalTheme = useTerminalTheme();
const accent = getModeAccent(session.uiMode, terminalTheme);
const theme = useTheme();
const terminalBg = theme.background;
const accent = getThemeModeAccent(theme, session.uiMode);
const inputBackground = getModeInputBackground(session.uiMode, terminalBg);
const inputRuleColor = getInputRuleColor(terminalBg);
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
@@ -8,6 +8,7 @@ import { isToggleableInteractiveConfigItem } from "../../tui/interactive-config"
export type ConfigAction =
| { kind: "open-provider" }
| { kind: "open-model" }
| { kind: "open-theme" }
| { kind: "toggle-item"; item: InteractiveConfigItem }
| { kind: "delete-item"; item: InteractiveConfigItem }
| {
+16 -2
View File
@@ -16,7 +16,9 @@ import {
import type { CliCompactionMode, Config } from "../../utils/types";
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
import { resolveModelDisplayName } from "../components/status-bar";
import { getModeAccent, palette } from "../palette";
import { useThemeController } from "../hooks/use-theme";
import { palette } from "../palette";
import { getDialogAccents, getThemeDefinition } from "../themes";
import {
type ConfigAction,
canDeleteConfigFooterRow,
@@ -406,6 +408,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [togglingItemId, setTogglingItemId] = useState<string | null>(null);
const [toggleError, setToggleError] = useState<string | undefined>();
const [navPos, setNavPos] = useState(0);
const themeController = useThemeController();
const dialogAccents = getDialogAccents(themeController.theme);
const currentThemeLabel =
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
const displayName = resolveModelDisplayName(config);
@@ -459,6 +465,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
r.push({ kind: "provider" });
r.push({ kind: "model" });
r.push({ kind: "toggle", id: "mode", label: "Mode" });
r.push({ kind: "toggle", id: "theme", label: "Theme" });
r.push({ kind: "toggle", id: "compaction", label: "Compaction" });
r.push({
kind: "toggle",
@@ -601,6 +608,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
setMode(mode === "plan" ? "act" : "plan");
props.onToggleMode();
break;
case "theme":
resolve({ kind: "open-theme" });
break;
case "auto-approve":
setAutoApprove(!autoApprove);
props.onToggleAutoApprove();
@@ -813,7 +823,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
let valueColor: string;
if (row.id === "mode") {
value = mode === "plan" ? "Plan" : "Act";
valueColor = getModeAccent(mode);
valueColor =
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
} else if (row.id === "theme") {
value = currentThemeLabel;
valueColor = dialogAccents.act;
} else if (row.id === "auto-approve") {
value = autoApprove ? "● on" : "○ off";
valueColor = autoApprove ? palette.success : "gray";
+6 -10
View File
@@ -13,17 +13,13 @@ import {
} from "../components/status-bar";
import { TrackedRobot, useMouseTracker } from "../components/tracked-robot";
import { useSession } from "../contexts/session-context";
import { useTheme } from "../hooks/use-theme";
import {
useTerminalBackground,
useTerminalTheme,
} from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getInputRuleColor,
getModeAccent,
getModeInputForeground,
getModeInputPlaceholder,
} from "../palette";
import { getThemeModeAccent } from "../themes";
import { HOME_VIEW_MAX_WIDTH, type TuiProps } from "../types";
export function HomeView(props: {
@@ -65,10 +61,10 @@ export function HomeView(props: {
visualRow: number;
} | null>(null);
const terminalBg = useTerminalBackground();
const terminalTheme = useTerminalTheme();
const defaultFg = getDefaultForeground(terminalBg);
const accent = getModeAccent(session.uiMode, terminalTheme);
const theme = useTheme();
const terminalBg = theme.background;
const defaultFg = theme.defaultForeground;
const accent = getThemeModeAccent(theme, session.uiMode);
const inputRuleColor = getInputRuleColor(terminalBg);
const inputForeground = getModeInputForeground(session.uiMode, terminalBg);
const inputPlaceholder = getModeInputPlaceholder(session.uiMode, terminalBg);
@@ -37,7 +37,7 @@ import {
type SearchableItem,
useSearchableList,
} from "../../components/searchable-list";
import { palette } from "../../palette";
import { useTheme } from "../../hooks/use-theme";
import {
getDefaultAwsRegion,
type ProviderConfigValues,
@@ -82,6 +82,7 @@ export interface OnboardingControllerProps {
export function useOnboardingController(props: OnboardingControllerProps) {
const { onComplete } = props;
const theme = useTheme();
const providerSettingsManager = useMemo(
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
[props.providerSettingsManager],
@@ -147,9 +148,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
: undefined,
searchText: `${p.name} ${p.id}`,
rightLabel: p.hasAuth ? "\u25cf" : undefined,
rightLabelColor: palette.success,
rightLabelColor: theme.accents.success,
})),
[providers],
[providers, theme.accents.success],
);
const providerList = useSearchableList(providerItems);
+59 -37
View File
@@ -19,11 +19,8 @@ import {
TrackedRobot,
type useMouseTracker,
} from "../../components/tracked-robot";
import {
useTerminalBackground,
useTerminalTheme,
} from "../../hooks/use-terminal-background";
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
import { useTheme } from "../../hooks/use-theme";
import { getInputRuleColor, getUserMessageBackground } from "../../palette";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
@@ -35,8 +32,24 @@ import {
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
function useDefaultFg(): string | undefined {
const terminalBg = useTerminalBackground();
return getDefaultForeground(terminalBg);
return useTheme().defaultForeground;
}
/**
* Theme-derived colors for the onboarding surface. The subtle border/detail
* tones used to be fixed dark grays (#333333 / #555555), which disappear on
* light or tinted theme backgrounds; they now lift from the theme background.
*/
function useOnboardingColors() {
const theme = useTheme();
return {
accent: theme.accents.act,
success: theme.accents.success,
selection: theme.selection,
textOnSelection: theme.textOnSelection,
subtleBorder: getUserMessageBackground(theme.background),
mutedDetail: getInputRuleColor(theme.background),
};
}
function getClinePassSubscriptionOptionId(index: number): string {
@@ -81,6 +94,7 @@ function OnboardingFrame({
}
export function OnboardingDoneScreen(props: { mouse: MouseTrackerState }) {
const colors = useOnboardingColors();
return (
<box
flexDirection="column"
@@ -90,7 +104,7 @@ export function OnboardingDoneScreen(props: { mouse: MouseTrackerState }) {
alignItems="center"
onMouseMove={props.mouse.onMouseMove}
>
<text fg={palette.success}>{"\u2714"} You're all set!</text>
<text fg={colors.success}>{"\u2714"} You're all set!</text>
</box>
);
}
@@ -106,6 +120,7 @@ export function OnboardingOAuthPendingScreen(props: {
oauthProvider: string;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<OnboardingFrame
compact={props.compact}
@@ -117,7 +132,7 @@ export function OnboardingOAuthPendingScreen(props: {
{!props.authError && (
<box flexDirection="row" gap={1} justifyContent="center">
<spinner name="dots" color={palette.act} />
<spinner name="dots" color={colors.accent} />
<text fg="gray">{props.authStatus}</text>
</box>
)}
@@ -134,13 +149,13 @@ export function OnboardingOAuthPendingScreen(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor="#333333"
borderColor={colors.subtleBorder}
paddingX={2}
paddingY={1}
width={props.contentWidth}
>
<text fg="gray">If the browser didn't open:</text>
<text fg={palette.act} marginTop={1} selectable>
<text fg={colors.accent} marginTop={1} selectable>
<a href={props.authUrl}>{props.authUrl}</a>
</text>
</box>
@@ -165,6 +180,7 @@ export function OnboardingDeviceCodeScreen(props: {
mouse: MouseTrackerState;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<OnboardingFrame
compact={props.compact}
@@ -176,7 +192,7 @@ export function OnboardingDeviceCodeScreen(props: {
{!props.deviceUserCode && !props.deviceError && (
<box flexDirection="row" gap={1} justifyContent="center">
<spinner name="dots" color={palette.act} />
<spinner name="dots" color={colors.accent} />
<text fg="gray">{props.deviceStatus}</text>
</box>
)}
@@ -193,7 +209,7 @@ export function OnboardingDeviceCodeScreen(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
borderColor={colors.accent}
paddingX={2}
paddingY={1}
width={props.contentWidth}
@@ -207,7 +223,7 @@ export function OnboardingDeviceCodeScreen(props: {
<text fg="gray" marginTop={1}>
Visit this URL and enter the code above:
</text>
<text fg={palette.act} selectable>
<text fg={colors.accent} selectable>
<a href={props.deviceVerifyUrl}>{props.deviceVerifyUrl}</a>
</text>
</box>
@@ -215,7 +231,7 @@ export function OnboardingDeviceCodeScreen(props: {
{props.deviceUserCode && !props.deviceError && (
<box flexDirection="row" gap={1} justifyContent="center">
<spinner name="dots" color={palette.act} />
<spinner name="dots" color={colors.accent} />
<text fg="gray">Waiting for sign-in...</text>
</box>
)}
@@ -276,6 +292,7 @@ export function OnboardingProviderConfigScreen(props: {
onSubmit: () => void;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
const visibleFields = FIELD_ORDER.filter(
(key) => props.fields[key] !== undefined,
);
@@ -314,7 +331,7 @@ export function OnboardingProviderConfigScreen(props: {
<box
border
borderStyle="rounded"
borderColor={isFocused ? palette.act : "gray"}
borderColor={isFocused ? colors.accent : "gray"}
paddingX={1}
>
<input
@@ -354,6 +371,7 @@ export function OnboardingCodexCliScreen(props: {
status?: CodexCliStatus;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
const installedStatus =
props.status?.installed === true ? props.status : undefined;
return (
@@ -374,7 +392,7 @@ export function OnboardingCodexCliScreen(props: {
{installedStatus && (
<box flexDirection="column" gap={1} alignItems="center">
<text fg={palette.success}>{"\u25cf"} Codex CLI installed</text>
<text fg={colors.success}>{"\u25cf"} Codex CLI installed</text>
<text fg="gray">{installedStatus.version}</text>
</box>
)}
@@ -384,7 +402,7 @@ export function OnboardingCodexCliScreen(props: {
<text fg="yellow">Codex CLI was not found</text>
<text fg="gray">{props.status.reason}</text>
<text fg="gray">Install Codex CLI from:</text>
<text fg={palette.act} selectable>
<text fg={colors.accent} selectable>
{CODEX_CLI_INSTALL_URL}
</text>
</box>
@@ -494,8 +512,8 @@ export function OnboardingClinePassSubscriptionScreen(props: {
subscriptionUrl: string;
}) {
const defaultFg = useDefaultFg();
const terminalTheme = useTerminalTheme();
const planAccent = getModeAccent("plan", terminalTheme);
const planAccent = useTheme().accents.plan;
const colors = useOnboardingColors();
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
const isLoading = props.status === "loading";
const isSubscribed = props.status === "subscribed";
@@ -527,7 +545,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
flexDirection="column"
border
borderStyle="rounded"
borderColor={isSubscribed ? palette.success : planAccent}
borderColor={isSubscribed ? colors.success : planAccent}
paddingX={1}
paddingY={1}
height={bodyHeight}
@@ -544,7 +562,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
>
<box flexDirection="column" width="100%" flexShrink={0}>
<text
fg={isSubscribed ? palette.success : planAccent}
fg={isSubscribed ? colors.success : planAccent}
flexShrink={0}
>
{isSubscribed
@@ -622,19 +640,19 @@ export function OnboardingClinePassSubscriptionScreen(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
backgroundColor={isSel ? colors.selection : undefined}
height={1}
flexShrink={0}
overflow="hidden"
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
fg={isSel ? colors.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
fg={isSel ? colors.textOnSelection : defaultFg}
flexShrink={0}
>
{option.label}
@@ -656,7 +674,7 @@ export function OnboardingClinePassSubscriptionScreen(props: {
<text fg="gray" flexShrink={0}>
If the browser button does not work:
</text>
<text fg={palette.act} selectable flexShrink={0}>
<text fg={colors.accent} selectable flexShrink={0}>
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
</text>
</box>
@@ -786,6 +804,7 @@ export function OnboardingThinkingLevelScreen(props: {
thinkingSelected: number;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<OnboardingFrame
compact={props.compact}
@@ -808,19 +827,16 @@ export function OnboardingThinkingLevelScreen(props: {
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
backgroundColor={isSel ? colors.selection : undefined}
height={1}
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
<text fg={isSel ? colors.textOnSelection : "gray"} flexShrink={0}>
{isSel ? "\u276f" : " "}
</text>
<text fg={isSel ? palette.textOnSelection : defaultFg}>
<text fg={isSel ? colors.textOnSelection : defaultFg}>
{level.label}
</text>
<text fg={isSel ? palette.textOnSelection : "gray"}>
<text fg={isSel ? colors.textOnSelection : "gray"}>
{level.desc}
</text>
</box>
@@ -842,6 +858,7 @@ export function OnboardingMainMenuScreen(props: {
mouse: MouseTrackerState;
}) {
const defaultFg = useDefaultFg();
const colors = useOnboardingColors();
return (
<box
flexDirection="column"
@@ -884,20 +901,25 @@ export function OnboardingMainMenuScreen(props: {
flexDirection="row"
border
borderStyle="rounded"
borderColor={isSel ? palette.act : "#333333"}
borderColor={isSel ? colors.accent : colors.subtleBorder}
paddingX={1}
gap={1}
alignItems="center"
>
<text fg={isSel ? palette.act : "#555555"} flexShrink={0}>
<text
fg={isSel ? colors.accent : colors.mutedDetail}
flexShrink={0}
>
{option.icon}
</text>
<box flexDirection="column" flexGrow={1}>
<text fg={isSel ? defaultFg : "gray"}>{option.label}</text>
<text fg={isSel ? "gray" : "#555555"}>{option.detail}</text>
<text fg={isSel ? "gray" : colors.mutedDetail}>
{option.detail}
</text>
</box>
{isSel && (
<text fg={palette.act} flexShrink={0}>
<text fg={colors.accent} flexShrink={0}>
{"\u2192"}
</text>
)}
+68
View File
@@ -10,6 +10,7 @@ import {
isCliHookPayload,
normalizeAutoApproveArgs,
parseArgs,
truncate,
} from "./helpers";
type EnvSnapshot = {
@@ -374,6 +375,73 @@ describe("format helpers", () => {
).toBe("first (+2 more)");
expect(formatToolOutput(null)).toBe("");
});
// Regression tests for https://github.com/cline/cline/issues/13036:
// malformed tool inputs crossing the model/tool boundary must never
// throw from display-only formatters.
it("does not crash on run_commands with a null command", () => {
expect(formatToolInput("run_commands", { command: null })).toBe("");
});
it("does not crash on run_commands with a non-string command", () => {
expect(formatToolInput("run_commands", { command: { nested: true } })).toBe(
'{"nested":true}',
);
expect(formatToolInput("run_commands", { commands: { command: 42 } })).toBe(
"42",
);
});
it("keeps valid empty-string args in structured command summaries", () => {
expect(
formatToolInput("run_commands", {
commands: [{ command: "grep", args: ["", "pattern", "file.txt"] }],
}),
).toBe("grep pattern file.txt");
expect(
formatToolInput("run_commands", {
commands: [{ command: "git", args: [null, "status", undefined] }],
}),
).toBe("git status");
});
it("skips null entries in run_commands command arrays", () => {
expect(
formatToolInput("run_commands", { commands: [null, "echo hi"] }),
).toBe("echo hi");
expect(formatToolInput("run_commands", [undefined, "echo hi"])).toBe(
"echo hi",
);
});
it("does not crash on fetch_web_content with malformed requests", () => {
expect(
formatToolInput("fetch_web_content", {
requests: [null, { url: "https://example.com" }, { url: 42 }, "raw"],
}),
).toBe("https://example.com, 42");
});
it("falls back to an empty summary for unserializable inputs", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatToolInput("unknown_tool", circular)).toBe("");
expect(formatToolOutput(circular)).toBe("");
expect(
formatToolInput("unknown_tool", {
toJSON() {
throw new Error("boom");
},
}),
).toBe("");
});
it("truncates non-string values without throwing", () => {
expect(truncate(null, 10)).toBe("");
expect(truncate(undefined, 10)).toBe("");
expect(truncate(42, 10)).toBe("42");
expect(truncate({ nested: true }, 60)).toBe('{"nested":true}');
});
});
describe("hook payload validation and audit logging", () => {
+58 -14
View File
@@ -53,8 +53,38 @@ export function resolveWorkspaceRoot(cwd: string): string {
return cwd;
}
export function truncate(str: string, maxLen: number): string {
const oneLine = str.replace(/\n/g, " ").trim();
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
/**
* Normalizes an untrusted runtime value into a display string without ever
* throwing. Tool inputs/outputs cross the model/tool boundary, so they may
* not match their TypeScript annotations (e.g. `{ command: null }`).
*/
export function toDisplayString(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return safeJsonStringify(value);
}
try {
return String(value);
} catch {
return "";
}
}
export function truncate(value: unknown, maxLen: number): string {
const oneLine = toDisplayString(value).replace(/\n/g, " ").trim();
if (oneLine.length <= maxLen) {
return oneLine;
}
@@ -66,14 +96,21 @@ export function formatStructuredCommand(cmd: unknown): string {
return cmd;
}
if (cmd && typeof cmd === "object" && "command" in cmd) {
const structured = cmd as { command: string; args?: unknown };
const args = Array.isArray(structured.args) ? structured.args : [];
const structured = cmd as { command?: unknown; args?: unknown };
const command = toDisplayString(structured.command);
// Drop only nullish entries: they carry no display value, while an
// empty string is a valid argv entry that must stay in the summary.
const args = Array.isArray(structured.args)
? structured.args
.filter((arg) => arg !== null && arg !== undefined)
.map(toDisplayString)
: [];
if (args.length === 0) {
return structured.command;
return command;
}
return `${structured.command} ${args.join(" ")}`;
return `${command} ${args.join(" ")}`;
}
return String(cmd);
return toDisplayString(cmd);
}
function summarizeRunCommandsInput(input: unknown): string {
@@ -82,14 +119,17 @@ function summarizeRunCommandsInput(input: unknown): string {
}
if (Array.isArray(input)) {
return input.map(formatStructuredCommand).join("; ");
return input.map(formatStructuredCommand).filter(Boolean).join("; ");
}
if (input && typeof input === "object") {
const obj = input as Record<string, unknown>;
if (obj.commands !== undefined) {
if (Array.isArray(obj.commands)) {
return obj.commands.map(formatStructuredCommand).join("; ");
return obj.commands
.map(formatStructuredCommand)
.filter(Boolean)
.join("; ");
}
return formatStructuredCommand(obj.commands);
}
@@ -157,7 +197,11 @@ export function formatToolInput(toolName: string, input: unknown): string {
if (Array.isArray(obj.requests)) {
return truncate(
obj.requests
.map((r) => r.url)
.map((r) =>
r && typeof r === "object" && "url" in r
? toDisplayString((r as { url?: unknown }).url)
: "",
)
.filter(Boolean)
.join(", "),
120,
@@ -286,7 +330,7 @@ export function formatToolInput(toolName: string, input: unknown): string {
return "list";
}
return truncate(JSON.stringify(input), 60);
return truncate(input, 60);
}
export function formatToolOutput(output: unknown): string {
@@ -330,10 +374,10 @@ export function formatToolOutput(output: unknown): string {
)
.filter(Boolean)
.join(" ") || "Successfully read image"
: String(result ?? "");
: toDisplayString(result);
return truncate(resultStr, 80);
}
return truncate(JSON.stringify(item), 80);
return truncate(item, 80);
})
.filter((s) => s.length > 0);
@@ -346,7 +390,7 @@ export function formatToolOutput(output: unknown): string {
return `${results[0]} (+${results.length - 1} more)`;
}
return truncate(JSON.stringify(output), 100);
return truncate(output, 100);
}
function isRecord(value: unknown): value is Record<string, unknown> {
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { isSameRepoStatus } from "./repo-status";
describe("isSameRepoStatus", () => {
it("treats identical statuses as equal", () => {
expect(
isSameRepoStatus(
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 3 } },
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 3 } },
),
).toBe(true);
expect(
isSameRepoStatus(
{ branch: null, diffStats: null },
{ branch: null, diffStats: null },
),
).toBe(true);
});
it("detects branch and diff changes", () => {
expect(
isSameRepoStatus(
{ branch: "main", diffStats: null },
{ branch: "feature", diffStats: null },
),
).toBe(false);
expect(
isSameRepoStatus(
{ branch: "main", diffStats: null },
{ branch: "main", diffStats: { files: 1, additions: 0, deletions: 0 } },
),
).toBe(false);
expect(
isSameRepoStatus(
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 3 } },
{ branch: "main", diffStats: { files: 1, additions: 2, deletions: 4 } },
),
).toBe(false);
});
});
+12
View File
@@ -14,6 +14,18 @@ export interface RepoStatus {
diffStats: GitDiffStats | null;
}
export function isSameRepoStatus(a: RepoStatus, b: RepoStatus): boolean {
if (a.branch !== b.branch) return false;
if (a.diffStats === null || b.diffStats === null) {
return a.diffStats === b.diffStats;
}
return (
a.diffStats.files === b.diffStats.files &&
a.diffStats.additions === b.diffStats.additions &&
a.diffStats.deletions === b.diffStats.deletions
);
}
export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
const [branchResult, diffResult] = await Promise.allSettled([
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
+33 -1
View File
@@ -8,6 +8,7 @@ import {
type McpServerEntry,
type McpTransport,
removeServer,
setServerOAuthClient,
toggleServer,
updateServer,
} from "./settings";
@@ -49,6 +50,7 @@ type RemoteAuthMode = "none" | "headers" | "oauth";
interface UrlServerConfig {
transport: McpTransport;
authMode: RemoteAuthMode;
oauthClient?: { clientId: string; clientSecret?: string };
}
export interface McpAddDefaults {
@@ -196,7 +198,29 @@ async function collectUrlTransport(
});
if (isCancel(authMode)) return null;
if (authMode === "oauth" || authMode === "none") {
if (authMode === "oauth") {
const clientId = await p.text({
message: "OAuth client ID (leave empty for dynamic registration)",
});
if (isCancel(clientId)) return null;
const normalizedClientId = (clientId as string).trim();
let clientSecret: string | undefined;
if (normalizedClientId) {
const secret = await p.password({
message: "OAuth client secret (leave empty for public clients)",
});
if (isCancel(secret)) return null;
clientSecret = (secret as string).trim() || undefined;
}
return {
transport: { type, url: (url as string).trim() },
authMode,
oauthClient: normalizedClientId
? { clientId: normalizedClientId, clientSecret }
: undefined,
};
}
if (authMode === "none") {
return {
transport: { type, url: (url as string).trim() },
authMode,
@@ -270,6 +294,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
let oauthClient: UrlServerConfig["oauthClient"];
if (type === "stdio") {
transport = await collectStdioTransport(defaults?.command);
} else {
@@ -279,6 +304,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
);
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
oauthClient = config?.oauthClient;
}
if (!transport) return;
@@ -286,6 +312,8 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
addServer(serverName, transport);
if (authMode !== "oauth") {
clearServerOAuth(serverName);
} else {
setServerOAuthClient(serverName, oauthClient);
}
p.log.success(`Added "${serverName}" to ${getSettingsPath()}`);
if (authMode === "oauth") {
@@ -374,18 +402,22 @@ async function actionEdit(): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
let oauthClient: UrlServerConfig["oauthClient"];
if (type === "stdio") {
transport = await collectStdioTransport();
} else {
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
oauthClient = config?.oauthClient;
}
if (!transport) return;
updateServer(name, transport);
if (type === "stdio" || authMode !== "oauth") {
clearServerOAuth(name);
} else {
setServerOAuthClient(name, oauthClient);
}
p.log.success(`Updated "${name}"`);
if (authMode === "oauth") {

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