Compare commits

...

211 Commits

Author SHA1 Message Date
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
Bee 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
Bee 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 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
Tomás Barreiro 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 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 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 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 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
Bee 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 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 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 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 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 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 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 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
Bee 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
Bee 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
Bee 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
Bee 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
Bee 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 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 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
Deach 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
oab24413gmai 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 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
oab24413gmai 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
James Arlen 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 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 Cooney 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
Octopus 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 Minh 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
Saoud Rizwan 6173bad65e chore(sdk): release v0.0.69 2026-08-01 21:11:32 -07:00
Saoud Rizwan cf710d76bf feat(llms): retry empty Ollama responses at the model boundary (#12845)
Local backends (Ollama especially) intermittently return a turn that
finishes normally but carries no text, reasoning, or tool call. In the
SDK runtime an empty assistant turn is a hard failure ("Model returned
empty response"), so one flaky generation kills the whole task.

Adds a LanguageModelV3 middleware that retries the stream only when a
turn produced genuinely nothing, wired as the outermost middleware on
the Ollama vendor. A tool-call-only turn counts as content and is never
retried; non-empty turns stream through live with no added latency; and
turns that error or hit the token limit are passed through unchanged.

This is the streaming-safe slice of ai-sdk-ollama's reliability story:
its own reliability layer lives in doGenerate and owns the tool loop
(executes tools and force-synthesizes text), which is incompatible with
Cline running its own loop over doStream.
2026-08-01 19:34:54 -07:00
Saoud Rizwan 9b31692fa0 fix(migration): fall back to the default Cline model for unknown legacy model ids (#12846)
* fix(migration): fall back to the default Cline model for unknown legacy model ids

Some migrated users ended up making Cline provider requests with a model
id the new extension doesn't have because the legacy migration carried
their stored model id over verbatim and never applied a default.

Two small fixes in the provider settings migration:

- Drop a legacy Cline model id the catalog doesn't know so the entry
  falls back to the default model instead of carrying the unknown id
  into inference requests.
- getDefaultModelForProvider only accepted defaults present in the
  generated model block; Cline's generated block holds a few free models
  while its declared default (anthropic/claude-sonnet-5) lives in the
  collection catalog, so the fallback previously landed on an arbitrary
  free model instead of the default.

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

* fix(migration): validate legacy Cline models against the full runtime catalog

The known-model check used the curated Cline collection plus the tiny
generated cline block, but the runtime Cline catalog is OpenRouter-backed
and also resolves Vercel AI Gateway alias ids. Legacy users on
runtime-served ids outside the curated collection (e.g. the z-ai/glm-5
family) would have been wrongly defaulted. Suffixed variant ids like
...:1m still fall back to the default Cline model.

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

* fix(migration): validate Cline models against the canonicalized runtime catalog

Greptile review: the raw generated-catalog checks accepted alias ids
(e.g. OpenRouter's z-ai/...) that buildClineModels canonicalizes away
(to zai/...), persisting models absent from the exposed runtime catalog.

Validate against the collection model list (which the runtime catalog
mirrors exactly) and fold alias spellings onto their canonical ids via
the shared VERCEL_OPENROUTER_MODEL_ID_ALIAS_RULES, so legacy z-ai users
keep their model under the canonical id instead of being defaulted.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 19:34:03 -07:00
Saoud Rizwan d3e32500ae fix(ollama): raise the response-start timeout default to 5 minutes so model cold loads don't error (#12839)
* fix(llms): retry Ollama response-start timeouts through the AI SDK retry loop

The pre-SDK handler wrapped Ollama chat calls in withRetry({ retryAllErrors:
true }), which silently rode out model cold loads: Ollama holds /api/chat open
while loading and only sends response headers once the model is ready, so the
first attempt of a large model routinely times out at 30s and a later retry
lands on the loaded model. The SDK path lost that behavior twice over: the
response-start timeout rejected with a plain Error (the AI SDK only retries
APICallError with isRetryable), and ai-sdk-ollama wraps every doStream failure
in its own OllamaError, hiding even a correctly-typed error from the retry
predicate. Net effect: one attempt, a surfaced timeout error, and no automatic
recovery - a regression vs the legacy extension for local models that load
slower than the timeout (cline/cline#12829).

Fix: withOllamaResponseTimeout now rejects with APICallError(isRetryable:
true) when its own timer fired (upstream aborts still propagate untouched),
and a restoreOllamaApiCallErrorMiddleware unwraps the buried APICallError from
OllamaError cause chains so streamText's built-in retry (2 retries with
backoff, ~96s of cold-load coverage) engages.

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

* style: biome format

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

* rework: raise Ollama response-start default to 5 minutes instead of retrying

Replaces the APICallError/retry-middleware approach: the 30s guillotine was
the actual root problem (Ollama sends response headers only after the model
cold-loads; killing a healthy request forces error/retry churn), so give the
response-start budget the same order of generosity other AI SDK-based agents
use (opencode: no default header timeout for custom providers, 5 minutes for
its only default) and delete the retry machinery. Unreachable servers still
fail instantly at the connection level, users can still cancel from the UI,
and an explicit requestTimeoutMs is still honored.

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

* revert Ollama timeout description copy, keep the new default values

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

* fix: forward Ollama request timeout and context window to standalone handlers

Greptile review catch on #12839: buildSdkProviderConfig never carried
requestTimeoutMs, so handlers built via buildApiHandler (commit message
generation) ignored an explicit user timeout — pre-existing, but material now
that the fallback default is 5 minutes. Reuse the session factory's
resolveOllamaProviderConfig so the standalone path honors the configured
timeout and the user's context window (num_ctx) instead of Ollama's 4096
default, keeping the two paths on one source of truth.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 19:19:48 -07:00
Saoud Rizwan b0cb179a06 fix(settings): persist base URL and API key edits made before provider config loads (#12840)
OpenAI Compatible and LiteLLM gated their base URL onChange (and API key
writes via canWrite) on the async provider config having loaded. Text typed
in that window hit a no-op onChange after the debounce cleared the
pending-edit flag, so the late initialValue resync wiped it and nothing was
saved. write() never needed loaded config, and useProviderConfig's request
sequencing already drops the stale initial read, so the guards are removed.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 18:26:09 -07:00
Saoud Rizwan f57c7944ec fix(vscode): keep plan/act input border in sync with actual textarea focus (#12841)
Sending a message (Enter or send button) cleared the isTextAreaFocused
flag without blurring the textarea. Since the DOM element stayed focused,
onFocus never re-fired (programmatic .focus() on an already-focused
element is a no-op), so the mode-colored outline stayed hidden until a
real blur/refocus cycle - which is why toggling Plan/Act mode brought it
back. Stop clearing the flag on send; blur is already handled by the
onBlur handler.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 18:02:59 -07:00
Saoud Rizwan e450cbf5dd fix(vscode): settle pending tool approval when a message edit replaces the session (#12836)
Editing a previous message while a tool approval prompt was pending left the
old session's approval promise parked forever: the superseded run stayed
suspended awaiting an answer that could never come, and the stale resolver
kept intercepting later ask responses. Clear pending interactions before
starting the replacement session, exactly like cancelTask / clearTask /
task-switch / mode-change already do.

Ref #12827

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 18:02:01 -07:00
Saoud Rizwan 830ef5d288 Fix AskSage custom API URL being ignored at inference time (#12843)
The runtime baseUrlMap in resolveBaseUrl lacked the asksage ->
asksageApiUrl mapping (present in store.ts, effective-config.ts, and the
legacy migration), so a custom AskSage API URL saved in legacy state was
never read and requests fell through to the builtin default
https://api.asksage.ai/server.

Also write the URL through the SDK provider-config store in
AskSageProvider.tsx (mirroring AnthropicProvider) so providers.json
stays in sync for CLI/desktop hosts; the store mirrors baseUrl back to
the legacy asksageApiUrl state key, keeping the /get-models fetch and
legacy readers working.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 18:00:58 -07:00
Saoud Rizwan 723ee3b610 fix(settings): persist Qwen/Moonshot API line to providers.json (#12837)
Convert the Qwen and Moonshot regional API line dropdowns from
legacy-state-only writes to useProviderConfig().write({ apiLine }),
matching the Z AI pattern. The host store mirrors the write back to the
legacy qwenApiLine/moonshotApiLine state keys, so a single write keeps
providers.json (read by the CLI and desktop app) and the legacy
StateManager (read by the VS Code session factory) in sync.

Adds store tests pinning the dual-write mirroring and webview component
tests for the dropdowns' write and display behavior.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 18:00:16 -07:00
cline-cloud[bot] 75e1cc7a6f fix(settings): restore custom URL toggle after clear failure (#12838)
* fix(settings): restore custom URL toggle after clear failure

* fix(settings): cancel pending URL edit before clear

---------

Co-authored-by: Cline <noreply@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-08-01 17:55:54 -07:00
Sufiyan Khan 96f8fbf671 fix(terminal): complete commands on shell execution end (#12658) 2026-08-01 17:26:42 -07:00
Tran Binh Minh 211cc035bd fix(vscode): include untracked files in commit message generation (#12069)
* fix(vscode): include untracked files in commit message generation

getGitDiff only ran git diff --staged and git diff HEAD, neither of which reports untracked files, so an add-only working tree failed with 'No changes in workspace for commit message'. Gather untracked files and diff each against /dev/null via execFile (argv, no shell) so add-only trees work and special-char filenames are safe.

Closes #12060

* fix(vscode): include untracked files alongside tracked changes

Address review: append untracked-file diffs in the non-staged path instead of gating on an empty diff, so a mix of edited tracked files and new untracked files includes both. Re-throw git exit codes other than 1 (files differ) so real errors aren't swallowed. Use a named, non-runnable label for the output header. Adds a mixed tracked+untracked test.

Refs #12060

---------

Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-08-01 17:14:54 -07:00
Dominic Cooney 1e9e3c7a3e fix(cli): restore the formatDisplayUserInput import in root.tsx (#12844)
#12831 removed the import while rewriting checkpoint restore, and #12830
landed on top of it adding a usage that assumed the import was still
there. apps/cli typecheck has failed on main since, which fails the
sdk-test Quality Checks job on every PR touching sdk/**.

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-08-01 16:53:50 -07:00
Saoud Rizwan e543c692c7 fix(settings): persist custom base URL checkbox state and stop keystroke loss in URL fields (#12834)
* fix(settings): persist and display custom base URL checkbox, stop keystroke loss in URL fields

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

* fix(settings): drop stale provider-config responses and re-read after failed writes

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

* fix(settings): skip write-failure recovery read when a newer write is in flight

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-01 13:15:07 -07:00
Saoud Rizwan e34e794624 fix(cli): show plain text when prefilling a restored message (#12830)
After a checkpoint restore (/undo or Esc Esc), the rewound user message is
dropped into the input box to edit and re-send. It was prefilled from the
raw stored text, which the runtime wraps in a <user_input mode="...">
envelope, so the input showed '<user_input mode="act">...</user_input>'
instead of what the user typed. Prefill the display form via
formatDisplayUserInput (already used for the picker preview and imported in
this file), which strips the envelope and preserves slash-command display
form.
2026-08-01 10:01:16 -07:00
Saoud Rizwan 8d078f59bd fix(checkpoints): create reliably, full workspace rewind on restore, repair CLI /undo (#12831)
* fix(core): create checkpoints reliably across hosts, restarts, and compaction

#12691 moved checkpoint run-boundary detection into a beforeRun hook that
recorded snapshot.messages.length, assuming the run's user prompt is
appended afterwards. SessionRuntime (VS Code + CLI) instead seeds the
prompt into initialMessages and calls run(""), so the beforeRun delta is
always empty and no checkpoints were ever created in either surface.

Gate checkpoint creation on two signals instead of the fragile in-memory
delta alone:
- introducedUserRun: the beforeRun delta contains a new user turn. Covers
  hosts that pass the prompt as run input and refreshes the entry on
  edit-and-regenerate.
- alreadyCheckpointed: the run count already exists in the DURABLE session
  checkpoint history. Covers the seeded-prompt path and, unlike an
  in-memory counter, still holds after a process restart.
Skip only when neither applies (a continuation/resumption re-running an
already-checkpointed run), so a reopened session can't overwrite a good
pre-run snapshot with the mutated workspace. Run numbering uses the
span-aware countUserRunMessages so it survives compaction folding turns
into one summary message.

Adds regression tests for the seeded-prompt creation, the reopen-without-
new-turn overwrite case, and the first-turn-after-compaction case.

* fix(cli): number /undo checkpoints span-aware so restore can map them

The interactive /undo picker counted every role="user" message when
assigning run numbers to checkpoints. Tool-result messages also carry
role "user", so any turn that used tools got an inflated run number; the
picker then handed that number to the core, whose span-aware
findUserRunMessage could not map it and aborted with 'Could not find user
message for run N'. Restore was effectively unusable whenever the agent
called a tool.

Count runs with the core's getUserRunSpan (tool results contribute 0, a
compaction summary spans the turns it folded) so the picker's run numbers
match what the core records and resolves. Extracted the item-building into
a pure buildCheckpointPickerItems helper with unit coverage for the
tool-result and compaction cases.

* fix(core): capture untracked files in checkpoints as a third parent

Checkpoint creation used plain `git stash create`, which cannot include
untracked files (no -u support). Restore therefore had no way to bring back
a file Cline created during a task, so a full rewind was impossible.

Synthesize a stash-shaped snapshot commit that also records untracked,
non-ignored files as a third parent - exactly like
`git stash create --include-untracked` - without touching the working tree,
the real index, or the stash list: list `ls-files --others
--exclude-standard`, stage into a temp GIT_INDEX_FILE, write-tree +
commit-tree to get the untracked parent, then rebuild the stash commit with
that extra parent. When the tracked worktree is clean but untracked files
exist, synthesize the stash from HEAD so they are still captured instead of
falling back to a bare HEAD-commit checkpoint. Fully clean worktrees still
use the HEAD-commit fallback.

* fix(core): full workspace rewind on restore for snapshot checkpoints

Restore now rewinds untracked files generation-aware:
- If the checkpoint carries an untracked third parent (a snapshot from
  createWorktreeStashCommit), do a full rewind: reset tracked to the base,
  `git clean -fd` to drop files created after the checkpoint (and clear the
  worktree so `stash apply` cannot hit an "already exists" conflict), then
  `git stash apply`, which restores each captured untracked file to its
  checkpoint-time content from the third parent. `git clean -fd` (no -x)
  leaves .gitignored paths - build output, node_modules, .env - alone. This
  is safe because everything removed is either recreated from ^3 or postdates
  the checkpoint, and the pre-restore recovery snapshot (stash push
  --include-untracked) can roll the whole operation back.
- If the checkpoint has no third parent (legacy 2-parent stashes and
  HEAD-commit fallbacks from before capture existed), keep the conservative
  behavior: never touch untracked files, since nothing can reconstruct them.

This makes 'Reset Code' / '/undo' a true rewind: a file Cline created in an
early turn and ruined later comes back to the early-turn version.
2026-08-01 10:00:33 -07:00
cline-cloud[bot] 94f897f559 fix(telemetry): preserve provider error details (#12824)
Co-authored-by: Cline Cloud Agent <cline-cloud-agent@users.noreply.github.com>
2026-08-01 10:06:02 +02:00
Saoud Rizwan 4b529f5f81 fix(telemetry): stop counting tool use mistake notices as provider API errors (and stop double-counting them) (#12820)
* fix(telemetry): single classified emitter for provider API errors, gated on terminal failures

* chore: remove explanatory comment block from agent-events.ts

* feat(telemetry): stamp terminal=true on SDK provider failure events

* chore: remove dead notice api_error capture (no producer emits that reason)

* refactor(telemetry): rename provider-failure 'terminal' flag to 'fatal' (terminal is the shell in Cline)

* refactor(telemetry): drop the fatal flag - only user-surfaced failures are reported on both bundles
2026-07-31 23:53:58 -07:00
Tomás Barreiro 978155814e Fix Text rendering when restarting ACP sessions (#12823) 2026-07-31 23:48:41 -07:00
Saoud Rizwan 055210a2bf fix(cli): don't let the ClinePass promo dialog trap users whose terminal drops Esc (#12819)
* fix(cli): don't let the ClinePass promo dialog trap users whose terminal drops Esc

The promo dialog could only be dismissed with Escape, and Esc is the
least reliable key across terminals: it arrives as a bare \x1b that
needs timeout disambiguation, and Bun's Windows console input layer is
known to swallow it (Windows PowerShell users reported being unable to
dismiss the dialog at all). Worse, the 'shown' marker was only written
when the dialog closed, so a user who force-quit saw the promo again on
every launch.

- Any key other than Enter now dismisses the dialog (Enter still opens
  the subscription page)
- The shown marker is persisted when the dialog is displayed, not when
  it is dismissed, so a force-quit never loops the promo
- Add a tuistory e2e test covering marker timing and any-key dismissal

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

* fix(cli): let any key cancel the OAuth waiting screen

Like the ClinePass promo, the OAuth wait screen was dismissible only
with Esc (plus K for the API-key fallback when offered) while blocking
on a browser flow that may never complete — a trap on terminals that
drop Esc. Any key other than K now cancels the pending auth attempt;
K still switches to manual API key entry when available.

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

* fix(cli): don't let a modifier keypress dismiss link-bearing dialogs

The ClinePass promo and OAuth wait screens both render a URL the user
opens by holding Cmd/Ctrl and clicking. With 'any key closes', that
modifier keystroke could tear the dialog out from under the click. Add
a shared isAnyKeyDismiss() guard so only unmodified keys dismiss; keys
held with ctrl/meta/super/hyper (and bare modifier presses) are ignored.
Enter still opens the promo and K still opens manual API key entry.

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

* revert(cli): persist promo shown-marker on dismiss again

Now that any key dismisses the promo, users can reliably close it, so
there's no need to write the shown-marker eagerly on display. Restore
persisting it in the dialog's finally() and update the e2e assertion.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-31 23:44:29 -07:00
Saoud Rizwan 8e68b14673 chore(sdk): release v0.0.68 2026-07-31 22:15:38 -07:00
Saoud Rizwan 4061dda034 fix(cli): close remaining open-package gaps in browser URL opening (#12822)
Follow-up to #12782, which replaced the open package with openUrlInBrowser
but missed two call sites and dropped some platform handling the package
provided:

- Migrate the two remaining open users (skills marketplace open in
  tui/root.tsx and ACP OAuth in acp/auth.ts) to openUrlInBrowser; the
  listenerless-child crash fixed by #12782 was still reachable there.
- Treat containers running on a WSL2 kernel (Docker Desktop for Windows,
  devcontainers) as plain Linux: /proc/version says microsoft but there is
  no Windows interop, so use xdg-open instead of powershell.exe (matches
  the is-inside-container check open@10 performed).
- Try opener candidates in order: on WSL, powershell.exe on PATH, then the
  absolute /mnt/c/... path (covers appendWindowsPath=false), then xdg-open
  (sandboxed WSL with WSLg); on win32, the %SystemRoot% absolute PowerShell
  path first (what open@10 used), then PATH lookup.
- Convert Linux file paths to \\wsl$ UNC paths via wslpath before handing
  them to Start-Process, so 'cline doctor log' works on WSL.
- Remove the now-unused open dependency from apps/cli.
2026-07-31 22:07:36 -07:00
Dominic Cooney b93a8cd442 fix(vscode): run Store PowerShell profiles correctly (#12802)
* fix(vscode): run Store PowerShell profiles correctly

* test(vscode): cover Store PowerShell background execution

* fix(core): handle shell stdin write failures

* test(windows): verify legacy PowerShell execution
2026-07-31 22:03:10 -07:00
Saoud Rizwan 72561771a7 docs: move ACP editor integration to a dedicated Usage page (#12821) 2026-07-31 21:26:14 -07:00
Mikołaj Kondratek 123477dcd9 chore: remove dead host-side capture methods for core-owned telemetry events (#12818)
captureDiffEditFailure and captureWorkspaceInitError have no callers: SDK core
is the sole emitter of task.diff_edit_failed and workspace.init_error. Keeping
callable host-side capture APIs for core-owned events is how the
task.provider_api_error double-emission happened — a future host caller would
silently double-count these events with no type error or failing test. Also
drops the two event-name constants, which were only referenced by the removed
methods.
2026-07-31 21:03:37 -07:00
Saoud Rizwan 2ac20c647c docs: add Editor Integration (ACP) section to CLI overview (#12808)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-31 20:52:37 -07:00
Saoud Rizwan 316efc4521 Resolve recommended-model display names once in the SDK feed (#12806)
* feat(core): resolve display-ready names in fetchClineRecommendedModels

* refactor(cli,vscode): render recommended-model names from the enriched feed

* fix(core): resolve catalog names through vercel/openrouter id aliases

* fix(core): share one timeout budget across the feed and catalog lookups

Greptile flagged that resolveDisplayNames started a fresh timeoutMs window
after the recommendation request finished, so a slow endpoint plus a cold
or hung catalog could keep the picker loading for ~2x the timeout. The
catalog race now gets only the budget remaining from a single deadline;
an already-cached catalog still applies on an exhausted budget because
its promise resolves ahead of the zero-delay timer.
2026-07-31 20:49:59 -07:00
Saoud Rizwan b0ee2cc80a ci(vscode): publish combined stable VSIX to Open VSX and harden ab-package gates (#12805)
* ci(vscode): publish combined stable VSIX to Open VSX and harden ab-package gates

* ci(vscode): address greptile review — env-routed version input, bookkeeping survives Open VSX failure
2026-07-31 20:09:09 -07:00
Cline Test a063317218 fix(connectors): strip the Slack bot mention from incoming messages (#12780)
* fix(cli): strip the Slack bot mention from incoming connector messages

Slack delivers an at-mention of the app as `<@U0B8E8H3U1F> hi`, and the chat
SDK deliberately leaves the bot's own mention unresolved so mention detection
keeps working - flattening it to `@U0B8E8H3U1F hi`. The connector forwarded
that verbatim, so the agent saw the raw bot id at the front of every
mention-triggered turn.

Strip the leading self-mention in onNewMention/onSubscribedMessage before the
approval-reply check and handleTurn, resolving the bot id from the adapter
(request-scoped in multi-workspace mode) with a fallback to the event envelope
authorizations. Mentions of other users and inline mentions are preserved, and
a bare mention is left as-is so the turn is not dropped as empty input.

* fix(cli): only strip a complete Slack bot mention, not an id prefix

The `<@ID>` and `<@ID|name>` alternatives in stripSlackBotMention are
terminated by `>`, but the SDK-flattened bare `@ID` alternative had no
trailing boundary, so it also matched the start of a longer id. With bot id
`U123`, a message addressed to a different user - `@U1234 help` - was
rewritten to `4 help`, corrupting both the approval-reply check and the text
handed to the agent.

Require the flattened alternative to be followed by a non-id character with a
`(?![A-Za-z0-9])` lookahead, so it only matches a complete Slack id. A plain
`\b` cannot express this, because Slack ids end in word characters and `\b`
still matches between `U123` and `4`.

Existing behaviour is unchanged: angle-bracket and flattened self-mentions are
still stripped, repeated leading mentions still collapse, trailing `[\s,:]`
separators are still consumed, other users' and inline mentions are preserved,
and a bare mention is still left untouched so the turn is not dropped as empty.

Adds regression tests for the prefix collision, which fail against the previous
regex and pass with this one.

---------

Co-authored-by: cline-test-bot <cline-test-bot@users.noreply.github.com>
2026-07-31 19:31:04 -07:00
Tomás Barreiro 6d10f363b3 Add CLinePass as a provider on ACP (#12793)
* Add CLinePass as a provider on ACP

* Resolve default model id
2026-08-01 03:28:37 +02:00
Mikołaj Kondratek ab0bc93182 fix: surface upstream provider error from gateway-forwarded stream failures (#12800)
* fix: surface upstream provider error from gateway-forwarded stream failures

Vercel AI Gateway streams upstream rejections (e.g. Alibaba Qwen context-
length errors) wrapped in its own parse failure: the top-level message is
just 'Stream error occurred' and the cause is an internal ZodError, while
the real rejection is JSON-encoded in value.error_message. Unwrap it so
users see 'This model's maximum context length is 40960 tokens...' instead
of a raw Zod issue dump.

Also fall back to JSON.stringify for opaque object errors so the UI never
renders '[object Object]'.

* refactor(llms): use shared safe-JSON helpers and a named type guard in extractErrorMessage
2026-07-31 18:08:32 -07:00
Saoud Rizwan 22ded04bc4 Resolve display names for Cline free models in the CLI and extension model pickers (#12801)
* fix(llms): resolve OpenRouter display names for all Cline free models

* fix(vscode): resolve featured model card display names from the provider catalog

* fix(vscode): fall back to endpoint-provided names on featured model cards
2026-07-31 17:25:16 -07:00
Saoud Rizwan edaab58716 Add tuistory-based TUI e2e harness for the CLI (#12796)
* Add tuistory-based TUI e2e harness for the CLI

Evaluates https://github.com/remorses/tuistory as a Playwright-style
driver for the interactive TUI. Adds:

- tuistory devDependency in apps/cli
- test:e2e:tuistory script + vitest.tuistory.e2e.config.ts
- src/cli.tuistory.e2e.test.ts: ports the script(1)-based interactive
  smoke tests to reactive waitForText/screen-state assertions against a
  real PTY + Ghostty terminal emulator (5 tests, ~11s, no fixed sleeps)
- DEVELOPMENT.md docs for the vitest suite and the tuistory session CLI
  agents can use to manually drive the TUI headlessly

* Add tuistory agent skill (.cline/skills, symlinked to .claude/.agents)

Teaches coding agents to drive the Cline TUI headlessly via tuistory
sessions (launch with isolated env, reactive wait, snapshot/screenshot,
observe-act-observe loop) and to write launchTerminal()-based e2e tests,
closing the loop for cloud agents testing apps/cli.
2026-07-31 17:14:22 -07:00
Tomás Barreiro 0ec999b31a Remove CLI Promo Code (#12797)
* Remove CLI Promo Code

* fix import

* fix tests

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-31 16:52:58 -07:00
Octopus 3845be53b3 fix(llms): preserve video input capability (#12787)
Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
2026-07-31 16:39:50 -07:00
Saoud Rizwan 8014a05088 Revert "fix(cli): don't crash when no browser opener binary exists (#12782)" (#12799)
This reverts commit c7850423f1.
2026-07-31 15:58:03 -07:00
Saoud Rizwan c7850423f1 fix(cli): don't crash when no browser opener binary exists (#12782)
* fix(cli): don't crash on browser-open failure when no opener binary exists

open() with { wait: false } resolves to the detached child process before
the opener binary is known to exist. On hosts without one (e.g. xdg-open
on headless Linux), the failure arrives as an async 'error' event on the
listenerless child, escalating to an uncaughtException that kills the CLI
— bypassing every try/catch and .catch() at the call sites. Hitting
"Sign in with Cline" from the welcome screen reliably crashed the TUI in
containers.

Route all browser opens through a shared openUrlInBrowser() helper that
attaches the error listener and reports failure via its returned promise,
so flows fall back to their existing "visit the URL below" messaging.

* fix(cli): attach opener error listeners in the same tick as spawn

Greptile's review caught that the helper attached its listeners only after
awaiting open()'s promise. Empirically that window is safe under Node 22
(the listener wins) but real under Bun — the runtime the compiled CLI
ships on — where the missing-binary ENOENT 'error' event fires before the
microtask queue drains, reproducing the exact crash this helper exists to
prevent.

macOS and non-WSL Linux now spawn their opener (open / xdg-open) directly
with listeners attached in the same synchronous tick, which both runtimes
guarantee can never miss the event. Windows and WSL keep delegating to the
open package for its shell quoting and interop routing; their openers
(cmd/powershell) always exist, so the post-await path cannot hit ENOENT.
The regression test now emits the error on nextTick — before microtasks —
which fails against the previous implementation.

* fix(cli): drop the open package — same-tick opener spawn on every platform

The win32/WSL delegate path still attached listeners after awaiting
open()'s promise, leaving a narrow uncaught-error window under Bun for
emittable spawn failures (e.g. AV-blocked EPERM). Spawn the opener
directly everywhere instead: open on macOS, xdg-open on Linux, and
powershell -EncodedCommand on Windows/WSL — the base64-encoded
Start-Process command sidesteps cmd/PowerShell quoting of URLs entirely,
so nothing is ever shell-interpolated.
2026-07-31 14:29:14 -07:00
Saoud Rizwan ed5f3031b0 fix(cli): silence dialog-container 'not a child of __root__' warning on exit (#12795)
On CLI exit, renderer.destroy() runs root.destroyRecursively() before
React flushes the DialogProvider's passive unmount cleanup, so the
dialog container is already detached when the cleanup calls
renderer.root.remove(container), triggering OpenTUI's 'Renderable with
id dialog-container is not a child of __root__, skipping remove'
warning. Drop the explicit remove from the patched @opentui-ui/dialog
provider cleanup (react + solid): Renderable.destroy() already detaches
from its parent when attached and no-ops when already destroyed.
2026-07-31 14:23:17 -07:00
Tomás Barreiro 3f38bd516f Add Organization selector to ACP (#12774)
* Add Organization selector to ACP

* linter
2026-07-31 12:01:46 -07:00
Tomás Barreiro be8c16b0ae Fix ACP session resolution (#12756)
* Fix ACP session resolution

* Use the selected provider/model

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-31 10:09:46 -07:00
Sufiyan Khan 0074b7222f fix(vscode): remove attachments from edited messages (#12578) 2026-07-31 10:05:30 -07:00
Mikołaj Kondratek ce81bc6855 fix(core): omit workspace hint for filesystem root paths (#12778)
basename("/") is an empty string, which WorkspaceInfoSchema rejects
(hint is z.string().min(1).optional()), so upsertWorkspaceInfo threw a
ZodError for any session rooted at the filesystem root — e.g. the
desktop app launched from the Dock with cwd "/" — and commands never
ran. Omit the hint instead of storing an empty string.
2026-07-31 17:50:46 +02:00
Bee e0803124b2 fix(cli): restart hub via installed wrapper after update (#12755)
Launch the hub through CLINE_WRAPPER_PATH after Unix self-updates so npm 12 does not reuse a deleted cached executable. Preserve the in-process fallback for Windows and development builds, and add coverage for success and failure paths.

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-31 01:01:22 -07:00
Saoud Rizwan 644e841737 chore(vscode): bump to 4.1.2 for release 2026-07-30 22:02:40 -07:00
Saoud Rizwan 5c6753e6d7 Show Legacy/Next extension variant in the settings About page (#12777) 2026-07-30 21:57:04 -07:00
Saoud Rizwan fdcc5367dc chore(vscode): bump to 4.1.1 for release 2026-07-30 21:08:32 -07:00
Dominic Cooney 901fdbc5cb Remove vestigial MCP server-key machinery from McpHub (#12773)
The uid/mcpServerKeys registry existed to encode server names into
native tool-call function names and decode them back at dispatch.
That encode/decode path was removed with the extension host
(c4c126bee): tool names are now built by the SDK's deterministic
defaultMcpToolNameTransform and execution closes over the server
name directly, so getMcpServerByKey has no callers and the keys are
write-only state. Delete the registry, the uid field, and the
deleteServerKey callback plumbing.

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-30 21:05:58 -07:00
Saoud Rizwan 69e3149f5f ci(vscode): add tag, GitHub Release, and Slack bookkeeping to combined publish workflow 2026-07-30 20:59:08 -07:00
Saoud Rizwan 3a3d0c1bc3 chore(vscode): bump to 4.1.0 and backport legacy 4.0.x changelog to main 2026-07-30 20:59:08 -07:00
Tomás Barreiro 0746ea72bf Improve ACP agent errors (#12766)
* handle finish reasons

* describe agent error

* use SDK functions

* Add isLikelyAuthError to the check

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-30 19:12:38 -07:00
Sebastien Tardif f0d5ede555 fix: replace flaky setTimeout waits with drainForTesting in BannerService tests (#10530)
BannerService tests still used 10ms sleeps for background fetch completion.
On slow CI runners that races mocha timeouts. drainForTesting() already
exists and awaits the in-flight fetch promise deterministically.

Rebased onto monorepo main (apps/vscode path).

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-07-30 18:24:00 -07:00
Saoud Rizwan ed821a6456 chore(cli): release v3.0.48 2026-07-30 18:17:43 -07:00
Saoud Rizwan f36b59c9cb chore(sdk): release v0.0.67 2026-07-30 18:03:17 -07:00
Saoud Rizwan 311959e757 ci(vscode): add test gate to combined A/B publish + publish-extension skill (#12764)
* ci(vscode): gate the combined A/B package workflow on both bundles' test suites

* docs(skills): add publish-extension skill for VS Code extension releases

* ci(vscode): pin tested revision for next bundle and refuse publishing untested next-refs

* ci(vscode): pin legacy bundle to the revision its test gate ran against
2026-07-30 17:54:29 -07:00
Saoud Rizwan f21e6faf1c fix(vscode): show migrated model in settings instead of hardcoded default (#12768)
* fix(vscode): show migrated model in settings instead of hardcoded default

After the SDK provider migration a user who never explicitly picked a model
(i.e. took the legacy default) ends up with the model recorded in
providers.json but not in the mode-specific globalState fields the settings
picker reads. The OpenRouter picker and its info card then fell back to the
hardcoded openRouterDefaultModelId (claude-sonnet-4.5) and its pricing, while
the extension actually ran the migrated model (claude-sonnet-5).

- resolveModelInfo: when no model id is requested, honor the provider store's
  committed selection (which reads providers.json when the state field is
  empty) before substituting a catalog default.
- OpenRouterModelPicker: source the displayed model id/info from the
  authoritative resolver as the fallback when the mode fields are empty,
  instead of the hardcoded constant. Committed-field users are unaffected.

* fix(vscode): guard picker model info against resolver default substitution

Review hardening: the resolver substitutes its provider default for ids it
cannot resolve, so only trust its info when it answered for the id actually
displayed. Prefer the live catalog entry for the displayed id (synchronous
once fetched, which also removes the transient placeholder while the resolver
is in flight), and never render another model's metadata under the displayed
model's name. Also document why the act-then-plan readSelection order in the
empty-id branch cannot misattribute a mode-specific selection.
2026-07-30 17:51:21 -07:00
Saoud Rizwan 1cf19304f4 docs: restore 'open Cline in right sidebar' guide as section of IDE usage page (#12771) 2026-07-30 17:43:58 -07:00
Saoud Rizwan 09aec528d0 Fix task export button: resolve the SDK session folder reliably (#12772)
* Restore task export to markdown and show download button in all builds

* Render untyped tool outputs and object tool inputs as JSON in task export

* Open the task's SDK session folder from the export button and show it in all builds

* Keep the task header session-folder button dev-only
2026-07-30 17:34:29 -07:00
Saoud Rizwan 4ec2b68c7c Fix queued prompt row alignment and auto-scroll when queueing a message (#12767)
* Fix queued prompt row alignment and auto-scroll on queue

Center the dot, badges, and cancel button on the first text line of each queued prompt row (the X previously sat ~3px below the text), and re-pin the chat view to the bottom when a prompt is queued so the queue banner doesn't cover the end of the conversation.

* Don't treat task switches as queue growth for auto-scroll

Guard the queued-prompt auto-scroll effect on the displayed task's ts: switching to a task that already has queued prompts grows the count without a send from this webview, and should not hijack the newly opened conversation's scroll position.
2026-07-30 17:33:19 -07:00
Saoud Rizwan 56fd6bb1ce Fix hidden plan/act mode-switch prompts reappearing when resuming a task from history (#12769)
* fix(vscode): hide synthetic mode-switch and resumption prompts when rehydrating chat from history

* chore: add changeset
2026-07-30 17:04:32 -07:00
Tomás Barreiro 49c7a89882 Inject device_id into tracking events (#12708)
* Inject  into tracking events

* fix sandbox resolution
2026-07-31 01:13:17 +02:00
Bee b47851791c feat(desktop): support message editing & checkpoints (#12691)
* feat(desktop): support message editing & checkpoints

Fork sessions before a selected user run, trim checkpoint history, and restore prior messages so prompts can be edited safely. Update the chat UI and tool activity panels to support the editing flow and preserve horizontal scrolling for long content.

* fix(desktop): restore checkpoints when editing messages

* fix(core): infer kindless checkpoint types

* fix(core): preserve checkpoint run numbering

* fix(desktop): make message edit restores transactional

* fix(desktop): make checkpoint restores workspace-atomic
2026-07-30 15:50:33 -07:00
Saoud Rizwan c0a966c46a Restyle compact-task confirmation as a bordered card with even spacing (#12759)
The confirmation that appears when clicking the compact button in the
task header was a bare unstyled row with a stray bottom margin (my-2)
that stacked on the header card's own bottom padding, leaving a dead
gap under the buttons. It is now a distinct bordered card (editor
background against the header's toolbar surface) with a title, a short
description of what compacting does, and right-aligned Cancel/Compact
buttons, with symmetric spacing above and below.

Also drops the ContextWindow wrapper's bottom margin (my-1.5 -> mt-1.5)
so the row's bottom spacing matches the header padding, and adds a
ContextWindow Storybook story that mirrors the expanded TaskHeader
surface so the confirmation can be previewed in isolation.
2026-07-30 15:42:30 -07:00
Saoud Rizwan cf3f3e08eb fix: don't block provider switch UI on ClinePass account switch (#12758)
Selecting ClinePass in settings awaited a network round-trip (PUT
/active-account + possible token refresh) before postStateToWebview,
so the settings panel stayed on the previous provider until the
request finished. Make the personal-account switch fire-and-forget:
it was already best-effort, and auth state changes propagate to the
webview separately once it completes.

Also convert the helper's test to bun:test so it actually runs (the
mocha version was excluded by both the bun unit runner and the
vscode-test glob) and fix its stale null-vs-undefined assertion from
the SDK migration.
2026-07-30 15:42:06 -07:00
Ara 7712e44468 fix(vscode): show catalog-driven reasoning effort selector for xAI, Z AI, and Moonshot (#12754)
The xAI, Z AI, and Moonshot settings components were never wired to the
catalog's reasoning capability: xAI only offered a legacy low/high
checkbox hardcoded to grok-3-mini model ids, and Z AI / Moonshot had no
reasoning control at all, even though models.dev marks grok-4.5, glm-5,
kimi-k2-thinking, etc. as reasoning models. Every catalog-driven
provider (GenericProviderSettings, OpenRouter/Vercel/Requesty pickers)
already gates ReasoningEffortSelector on supportsReasoning.

Render the shared ReasoningEffortSelector in these three components when
the selected model's catalog info advertises reasoning, persisting the
choice to the provider config the same way GenericProviderSettings does.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-30 14:39:48 -07:00
Saoud Rizwan f77f584a32 fix(vscode): stop queued-prompt turns from getting stuck on Thinking (#12751)
* fix(vscode): stop queued-prompt turns from getting stuck on Thinking

When the SDK drains a queued prompt at the end of a turn, the new turn's
pending_prompt_submitted bookkeeping (isRunning=true, phase=streaming) always
runs before the previous turn's send promise unwinds in fireAndForgetSend.
That .then then unconditionally called setRunning(false), so the queued turn
ran with isRunning=false and its own turn-complete was mistaken for a
cancelled-turn straggler - the phase never left "streaming" and the chat
showed an endless Thinking indicator.

Track a monotonic turn epoch on SdkSessionLifecycle: immediate sends and
drained queue prompts bump it, and both the send-settled callbacks and the
event coordinator's turn-end handling skip their bookkeeping when a newer
turn has started since (covers the symmetric interleaving where the done
handler resumes after the drain and would clobber the queued turn's
streaming phase).

* Simplify: preserve only an actual cancel phase in the turn-complete straggler guard

Replaces the turn-epoch machinery with the minimal fix: the straggler
guard's intent is to preserve the cancel-set "resumable" phase, so key it
on the phase itself instead of the isRunning proxy. When the SDK drains a
queued prompt at turn end, the previous turn's send promise settles after
the queued turn already started and flips isRunning back to false
mid-turn; with the old guard the queued turn's real completion was then
mistaken for a cancel straggler and the phase stayed stuck on
"streaming" (endless Thinking). Checking for "resumable" lets that
completion resolve the terminal phase normally while cancel behavior is
unchanged.
2026-07-30 14:35:33 -07:00
Saoud Rizwan 2771760305 fix(vscode): stop thinking loader flickering around mid-turn tool calls (#12750)
The anti-flash grace period (added to stop the loader flashing at turn
end) also fired mid-turn, causing a visible hide/show/hide flicker right
before a tool row appeared:

- When a reasoning tail finalized while the turn kept streaming, the
  reasoning shimmer collapsed, the loader stayed hidden for the 500ms
  grace, popped in, then hid again when the tool row landed. Reasoning
  never ends a turn, so skip the grace for reasoning tails and hand the
  shimmer straight to the loader.
- When the loader was already visible below a streaming tool group, the
  group tail finalizing blinked it off for the grace period. The grace
  now only delays hidden -> visible transitions, never hides an
  already-visible loader.
2026-07-30 14:32:15 -07:00
Saoud Rizwan 16d0d04573 Show user message immediately when sending to a task opened from history (#12753)
* Show user message immediately when sending to a history-resumed task

Sending a message to a task opened from history routed through the
resume_task/resume_completed_task askResponse branch, which forced the
Thinking loader but never set the optimistic user_feedback bubble. The
extension only echoes the user's message after the full SDK session
resume completes, so the chat showed a Thinking indicator with no user
message until the (slow) resume finished.

Pass showPendingMessage on the resume branch like the other
non-streaming follow-up paths, so the user's message appears in the
chat immediately. The optimistic bubble reconciles with the extension's
say:user_feedback echo once the resume completes (identical raw text).

* Add changeset
2026-07-30 14:28:18 -07:00
Saoud Rizwan 3590b425eb fix(ci): de-flake Windows bun unit tests (hook PowerShell bridge + runner retry) (#12752) 2026-07-30 14:21:05 -07:00
Bee 12404f0a4b fix(core): add a plugin telemetry bridge (#12741)
* fix(core): add a plugin telemetry bridge

* fix(core): address plugin telemetry bridge review feedback

- Sanitization fallback now covers the whole executeTool IPC payload:
  `input` can be rewritten by beforeTool hooks or programmatic callers,
  so a non-serializable input degrades gracefully like the context does.
- The sandbox only offers ctx.telemetry when the host actually has a
  telemetry service (new PluginSandboxOptions.telemetryAvailable, derived
  from options.telemetry in the config loader), so feature-detecting
  ctx.telemetry means "someone is listening" in both execution modes.

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

* fix(core): plugin telemetry review round 2 — timer leak and setup-time fallback

- SubprocessSandbox.call: a synchronous child.send() throw (cyclic payload)
  left the pending timeout timer armed; it later fired and shut the sandbox
  down, killing unrelated in-flight calls. Cancel the pending entry and
  reject with the original error so serialization failures stay classifiable.
- plugin_telemetry events emitted during plugin setup() arrive before the
  session is registered, so the session-config lookup missed and setup-time
  telemetry was silently dropped. Route through a fallback telemetry service
  (extensionContext/local config/host default), mirroring handlePluginLog's
  fallback logger.

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

* fix(core): classify BigInt IPC serialization errors for the sandbox fallback

Bun ("cannot serialize BigInt") and Node ("Do not know how to serialize a
BigInt") raise messages that did not match the cyclic/circular predicate, so
a bigint smuggled into tool input or context by a hook or programmatic caller
rethrew instead of retrying with the JSON-safe clone — which already drops
bigint leaves.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:44:25 -07:00
Saoud Rizwan 6cb43f0309 fix(core): make compaction sidecar persistence reliable (#12747)
Auto-compaction state was silently rejected on every save ("Skipped
stale session compaction state"), forcing a full re-compaction — an
extra summarizer LLM call — on every turn past the trigger, and a
resume-time identity churn could leave a dead sidecar permanently
blocking replacements.

Three changes:

1. Stop hashing volatile transport identity. The source-prefix hash no
   longer includes message id/ts, which the codec regenerates on every
   wire/storage round-trip (a store's just-appended user turn has none
   yet; consolidated parallel tool results are re-split with minted ids
   on resume). The fingerprint now covers role, content, and durable
   metadata. Hash seed bumped to v2; v1 sidecars fail projection once
   and are replaced by the next compaction.

2. Validate persists against the exact source messages the state was
   computed over. createCompactionStateAwarePrepareTurn passes
   context.messages to saveState, and the local runtime host threads
   them into persistActiveSessionCompactionState instead of falling
   back to the conversation store's mid-turn shape.

3. Scope the count-based stale-write guard to states that still
   project. An unprojectable current state no longer blocks a
   newer-timestamped replacement, so invalidated sidecars self-heal
   instead of deadlocking the session.

All three regression tests fail on main and pass with this change.
2026-07-30 13:05:47 -07:00
Saoud Rizwan 3058563f37 fix(vscode): mark onboarding complete only after OAuth succeeds (#12744)
* fix(vscode): mark onboarding complete only after OAuth succeeds

The onboarding webview marked welcomeViewCompleted immediately after the
sign-in URL opened (accountLoginClicked resolves at URL-open time), so
Free/Frontier/ClinePass signups landed in chat signed out when the user
abandoned or failed browser auth, and the flag persisted across reloads.

Restore the classic extension behavior: the host (SdkAuthService) now
sets welcomeViewCompleted after the OAuth token exchange succeeds, in
createAuthRequest, handleAuthCallback, and the E2E mock login. The
webview persists the model selection up front, stays on the 'Almost
there!' step until auth completes, and fires the 'completed' funnel
event via a pending-intent module once clineUser arrives (mirroring the
pendingClinePassSubscribe pattern). This also fixes the legacy
WelcomeView fallback, whose 'Get Started for Free' never completed
onboarding after login.

* refactor(vscode): slim the onboarding-completion fix to its essentials

Drop the pending-telemetry module and App hook (the 'completed' funnel
event keeps its existing main-branch semantics, firing when the flow is
initiated, so no telemetry change in this PR), restore finishOnboarding
to its original shape with just a markCompleted parameter, and reduce
the host helper to a single setGlobalState call.
2026-07-30 12:40:44 -07:00
Saoud Rizwan 39de7479b8 Fix delayed and stale Plan/Act mode switches (#12732)
* fix(vscode): make plan act switches responsive

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

* fix(vscode): recognize completed plans with trailing usage

* fix(vscode): continue reopened completed plans

* fix(vscode): always publish state after mode rebuild

* fix(vscode): roll mode back when session replacement is refused

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-30 12:34:29 -07:00
Saoud Rizwan 86c2600a8a Show the thinking indicator immediately when starting a turn (#12746)
* Post streaming turn state to webview before session startup

The webview only learns the turn phase through full state posts, and the
first post after initTask happened only after startNewSession settled —
so the chat mounted with a stale idle TurnState and the thinking
indicator popped in noticeably late. Ship a state post right after the
initial task message is emitted, in parallel with session startup.

* Show thinking indicator optimistically on new-task submit

Capture the TurnState seq at the moment the newTask RPC is sent and
force the in-list Thinking loader row until a fresher TurnState arrives
(any phase), so the indicator renders together with the task message
instead of waiting for the streaming TurnState to round-trip. Rolled
back if the RPC fails; legacy (no turnState) hands off to the existing
tail heuristic once the task message lands.

* Paint the initial Thinking loader without waiting for Virtuoso

Frame-by-frame measurement showed the loader decision was true on the
chat view's first paint, but the synthetic in-list row still appeared
~150-200ms later: a cold-mounting virtualized list needs several frames
to measure and paint its first item. When the list has no visible rows
yet (new task just submitted), render the waiting row as a plain
element over the (empty) list instead; once any real row exists the
warm list takes over with the in-list row as before.

* Show thinking indicator immediately for follow-up messages too

Follow-ups had the same delay as new tasks: SdkController.askResponse
moves the phase to streaming but never posted state, so the webview
kept the stale terminal phase (hiding the loader) until the new turn's
first session event posted state. Post right after the phase change,
and generalize the webview's optimistic marker from new-task-only to
any turn-starting send (askResponse outside a streaming phase), with a
guard that never shows the loader while a content row is actively
streaming. Renames pendingNewTaskSeq to pendingTurnStartSeq.

* fix(vscode): render thinking loader synchronously
2026-07-30 12:28:40 -07:00
Saoud Rizwan f4230e475b fix(vscode): /compact UX — clear input, wrap divider, always update context header (#12735)
* fix(vscode): clear chat input immediately when /compact is submitted

* fix(vscode): let the compaction divider label wrap at narrow widths

* fix(vscode): update context-window header even when compaction grows the context

* chore: add changeset for /compact UX fixes

* docs(vscode): align getLastApiReqTotalTokens return doc with unclamped rescale
2026-07-30 12:13:33 -07:00
Saoud Rizwan 078abcd055 fix(ci): build shared package before the ui-publish desktop chat test
The desktop chat integration test renders components from @cline/ui, but
it also pulls @cline/shared/browser through the desktop app's own
message-content module. That subpath resolves to dist output no step in
this job produced, so the suite failed to collect.

Build @cline/shared before the test, and install the full workspace: the
two-package filter did not provide enough of the tree for that build.
2026-07-30 12:02:42 -07:00
Saoud Rizwan afc1229ab0 fix(ui): declare bun and node type dependencies
The ui-publish workflow installs only the @cline/ui and @cline/code
workspaces, so the root devDependencies that previously supplied the
'bun' and 'node' type roots were absent and tsc failed with TS2688.
Declare them on the package that requires them in its tsconfig types.

Also refreshes the stale @cline/code version recorded in bun.lock.
2026-07-30 12:02:42 -07:00
Saoud Rizwan 892837d352 Enable Auto Compact by default in VS Code (#12739)
* feat(vscode): enable Auto Compact by default

The SDK-based extension has no fallback context management: with auto
compact off, hitting the model's context window fails the request with a
provider error and retrying keeps failing (the legacy extension truncated
the oldest half of the conversation in this situation). The CLI already
defaults compaction on (agentic); align the extension with it.

* chore: add changeset for Auto Compact default-on
2026-07-30 12:02:02 -07:00
Ara 078d9f63f0 fix(models): tolerate null contextWindow/maxTokens in SDK catalog shapes (#12743)
* fix(models): tolerate null contextWindow/maxTokens in SDK catalog shapes

Live LiteLLM proxies report unknown model limits as explicit nulls in
/model/info (e.g. max_tokens: null). adaptSdkModelInfo only tolerated
undefined, so a single such model failed the entire catalog refresh and
left the model picker empty. Treat null like a missing value (matching
the existing pricing handling) and fall back to the safe defaults.

* Update apps/vscode/src/sdk/model-catalog/shape-adapter.ts

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* Update apps/vscode/src/sdk/model-catalog/shape-adapter.ts

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* fix(models): restore missing limit fallbacks

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-07-30 20:19:56 +02:00
Sufiyan Khan fba27b3c81 fix(vscode): preserve draft when toggling from act to plan (#12241)
* fix(vscode): preserve draft when toggling from act to plan

* fix(vscode): simplify mode toggle draft handling
2026-07-30 11:17:57 -07:00
Saoud Rizwan 6549bdbacc Show edited file after the diff preview closes (legacy parity) (#12731)
* Show edited file after diff preview closes, matching legacy behavior

* Add changeset

* Reveal destination after apply patch moves

* Skip reveal for superseded previews and aborted edits
2026-07-30 11:17:43 -07:00
Bee 55ccbb7d51 fix(cli): remove rendering multiple views in one Bun process (#10936)
* fix(cli): open history in the existing TUI

* refactor(cli): clarify history TUI startup target

* feat(cli): add history actions to TUI

* fix(cli): avoid empty session when resuming history

* fix(cli): fail history delete without session id

* fix(cli): dispatch resume hook from history picker
2026-07-30 11:16:51 -07:00
Tran Binh Minh f039b1419f fix(vscode): show per-file diff for multi-file apply_patch (#12086)
* fix(vscode): show per-file diff for multi-file apply_patch

apply_patch edits to multiple files rendered the entire multi-file patch in every per-file diff row. Split the patch into one tool message per file at content_end (mirroring the read_files split) so each row shows only that file's changes.

Closes #9904

* fix(vscode): address review on multi-file apply_patch split

Import the canonical PATCH_MARKERS from @cline/core instead of the local AP_MARKERS duplicate and export it through the core barrel. The cross-world import barrier the old comment claimed does not exist - apps/vscode already imports runtime values from @cline/core.

Route the apply_patch branch in sdkToolToClineSayTool through getApplyPatchString so the streaming and finalized rows derive their content from one source.

Handle the bare-string apply_patch input. ApplyPatchInputUnionSchema accepts { input: string } | string; a bare two-file patch made getApplyPatchString return undefined, so both content_start and content_end produced one empty-path row instead of the per-file split. Return the raw string when the field lookup finds nothing, with a start/end reconciliation test.

Refs #9904

---------

Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-07-30 11:13:45 -07:00
Saoud Rizwan 97c558ffe8 chore(ui): release v0.2.0-next.1 2026-07-30 11:02:02 -07:00
Saoud Rizwan 8f6f1652e1 Improve slash command description contrast on selection (#12742)
* fix slash command hover text contrast

* remove slash menu regression test
2026-07-30 10:40:54 -07:00
cline-cloud[bot] 26ee3abf82 fix(core): stabilize Windows SDK tests (#12722)
* fix(core): stabilize Windows SDK tests

* fix(core): handle late subprocess stdin errors

* test(core): verify shell process cleanup

* test(core): budget Windows PowerShell hook

---------

Co-authored-by: Cline Bot <bot@cline.bot>
Co-authored-by: Dominic Cooney <dominic.cooney@cline.bot>
2026-07-30 19:01:15 +02:00
cline-cloud[bot] d2b674bb9d fix(vscode): restore macOS E2E launch (#12726)
Co-authored-by: Dominic Cooney <dominic.cooney@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-30 03:04:21 -07:00
Saoud Rizwan 2ce4facd9d fix(vscode): show thinking immediately after submit (#12733)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-30 02:59:38 -07:00
Saoud Rizwan ac4724166d Disable feature tips by default in the VS Code extension (#12730)
* Disable feature tips by default in VS Code extension

* Add changeset for feature tips default change
2026-07-30 00:58:42 -07:00
Cline Test 2131bbba0c fix(connectors): recover Slack thread mapping when session is gone (#12727)
* fix(connectors): recover Slack thread mapping when session is gone

A connector thread binding can outlive its runtime session (hub restart,
session abort, retention cleanup). When that happened the thread stayed
pinned to a dead session id and every subsequent turn failed with
`session_not_found`, so the bot replied "Slack bridge error: session not
found" forever with no way to recover short of editing threads.json.

Drop the stale binding and replay the turn once against a brand new
session. Both the normal turn path and the steering path are covered.

Adds forgetThreadSession() to session-runtime and 3 regression tests.

* fix(connectors): serialize stale session recovery

---------

Co-authored-by: cline-test-bot <cline-test-bot@users.noreply.github.com>
Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-29 23:57:53 -07:00
Saoud Rizwan 792738eeed fix(vscode): auto-proceed long-running terminal commands (#12712)
* fix(vscode): auto-proceed long-running terminal commands

* chore(vscode): raise terminal auto-proceed timeout to 60s

* chore(vscode): raise terminal auto-proceed timeout to 300s
2026-07-29 23:30:52 -07:00
Saoud Rizwan 0ac7d0bdb5 remove Enable R1 messages format option from OpenAI Compatible provider (#12729) 2026-07-29 23:28:02 -07:00
Saoud Rizwan 4fa42771a7 Consolidate per-provider model refresh handlers onto the SDK catalog (#12716)
* Consolidate per-provider model refresh handlers into the SDK

The VS Code extension resolved model catalogs from two sources: the SDK
catalog (models.dev-backed) used by resolveModelInfo/task header, and
host-side refresh handlers (refreshOpenRouterModels & co.) used by the
settings pickers. This dual-source split produced inconsistencies like
ENG-2345.

SDK (@cline/core):
- New rich live model sources (live-model-sources.ts) ported from the
  extension handlers: OpenRouter (pricing incl. cache read/write,
  descriptions, image support, thinking config, tiers/global-endpoint
  metadata, curated overrides, stealth models), Vercel AI Gateway, and
  Hugging Face. Keyed by generated catalog key so cline shares
  OpenRouter's live data.
- mergeKnownModels layers rich live entries field-wise on top of the
  curated catalog (live fields win, curated fields fill gaps) instead of
  the modelsSourceUrl replace semantics.
- New Groq and Requesty private fetchers (API-key gated); Baseten
  private fetcher now parses live pricing and reasoning support and is
  enriched from the curated catalog.

Extension (apps/vscode):
- refreshOpenRouterModels/Groq/Baseten/VercelAiGateway/HuggingFace/
  Hicap/Requesty are now thin delegates over the SDK provider catalog;
  all bespoke fetch/parse/disk-cache code is deleted.
- shape-adapter maps the SDK's thinkingConfig, temperature,
  global-endpoint capability, and metadata tiers onto the extension
  ModelInfo.
- Removed the now-unused StateManager models cache, per-provider disk
  cache files, and the dead readOpenRouterModels stub.

Fixes ENG-2381.

* Simplify: rely on the SDK's models.dev catalog, no rich live sources

Drop the ported per-provider live fetchers and curated overrides
(live-model-sources.ts) and all SDK merge changes. The extension now does
exactly what the CLI does: refresh handlers resolve through
resolveProviderConfig, which serves the models.dev-backed catalog
(bundled + runtime live refresh) plus the SDK's pre-existing
authenticated fetchers (Baseten/Hicap/LiteLLM/Poolside). No hardcoded
model info or per-model pricing workarounds remain anywhere.

Also reverts the shape-adapter additions since no SDK catalog source
populates thinkingConfig/temperature/metadata tiers today.

* Replace thinking-budget sliders with catalog-driven reasoning effort selection

Match the CLI's UX: every reasoning-capable model (SDK catalog
'reasoning' capability -> supportsReasoning) gets the Reasoning Effort
selector (none/low/medium/high/xhigh); the legacy 'Enable thinking' +
budget-tokens slider is removed everywhere, along with the hardcoded
per-provider thinking-model id lists (Anthropic, Claude Code, Bedrock,
Qwen) and claude/grok model-id heuristics in the OpenRouter, Vercel,
and Requesty pickers.

Effort changes now dual-write the provider-config reasoning settings
({enabled, effort}) that the session factory actually consumes - the
budget slider wrote legacy plan/act thinkingBudgetTokens state that
sessions already ignored. The utility request path
(buildSdkProviderConfig) drops its budget preference and forwards
effort only; the SDK translates effort into each provider's wire
format (including budget-token mapping where required).

* Gate picker reasoning-effort UI on live catalog entries

The OpenRouter/Vercel/Requesty pickers read the committed legacy
model-info snapshot, which provider-config writes can clear when a
resolution lands on a fallback source - selecting an effort made the
selector disappear. Gate on the live catalog map (with snapshot
fallback) instead; Requesty gates on the catalog only, since its
safe-default fallback over-reports reasoning support.

* Address review: honor legacy thinking budgets, dedupe refresh handlers

- Persisted thinking budgets are honored again (greptile P1 / review
  request): normalizeProviderReasoningSettings maps a stored
  reasoning.budgetTokens (written by older versions or the SDK's
  legacy-state migration) onto the effort scale and treats it as
  thinking-on, and buildSdkProviderConfig derives an effort from the
  legacy plan/act budget fields when no explicit effort exists. An
  explicit 'none' still wins. Shared mapping lives in
  reasoningEffortFromThinkingBudget with low/medium/high buckets.
- Extract resolveProviderModelsRecord into providerCatalogShared and
  collapse the seven refresh handlers onto it.
- Document the explicit OCA decision: its reasoning control is the
  API-driven effort dropdown; the removed budget slider wrote state no
  OCA request path consumed.

* Harden OpenRouter picker reasoning gate against placeholder metadata

Gate on the raw committed model-info snapshot instead of the hook's
default-info fallback, so a selected id that is absent from the catalog
can never inherit reasoning support from placeholder metadata (the
fallback carries no supportsReasoning today, but reading the raw field
removes the latent dependency).
2026-07-29 23:27:05 -07:00
Saoud Rizwan c91cd4be59 fix(vscode): clear pending approvals on task switch (#12705)
* fix(vscode): clear pending approvals on task switch

* feat(vscode): show compact slash command

* docs(vscode): explain task approval cleanup

* fix(vscode): settle pending questions on cleanup

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-29 22:15:12 -07:00
Saoud Rizwan 851ee033bc Fix checkpoint restores across session resumes (#12713)
* fix checkpoints across session resumes

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

* style checkpoints mapping helper

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-29 22:08:37 -07:00
Saoud Rizwan 192e6ffab2 fix(vscode): interrupt running terminal command when a task is cancelled (#12696)
Cancelling a task previously only detached Cline's listeners from an
in-flight foreground command (process.continue()); the spawned process
kept running in the user's terminal after cancellation.

Send Ctrl+C (ETX) to the terminal before detaching so the shell delivers
SIGINT to the foreground process group, actually stopping the command.
The terminal is left open for reuse, and cancellation still succeeds even
if the interrupt write throws (e.g. terminal already disposed).
2026-07-29 21:45:43 -07:00
Saoud Rizwan 6b668fefdc fix(core): carry legacy OpenAI-compatible model-info overrides into seeded models.json (#12697)
The legacy-provider migration seeded the openai-compatible models.json
entry with hardcoded defaults (contextWindow 128k, no pricing/temperature/
maxTokens/R1 flag). Because later override migrations skip models that
already exist in models.json, the user's legacy planMode/actModeOpenAiModelInfo
overrides were silently discarded on first upgrade: context window,
max output tokens, input/output prices, temperature, supportsImages=false,
and isR1FormatRequired all reset to defaults.

Seed the entry from the mode-appropriate legacy model-info snapshot
instead, treating legacy sentinels (maxTokens -1, temperature 0,
prices 0) as unset.
2026-07-29 21:31:55 -07:00
Saoud Rizwan 8224ad1634 feat(llms): default OpenRouter provider to anthropic/claude-sonnet-5 (#12707)
Matches the legacy extension's OpenRouter default (openRouterDefaultModelId),
so users migrating from the legacy build without an explicitly selected model
keep the same default model instead of being silently moved to
anthropic/claude-sonnet-4.6.
2026-07-29 17:50:57 -07:00
Saoud Rizwan 96e8f51436 fix(core): don't abort config scan when .clinerules is a legacy single file (#12702)
A legacy single-file .clinerules at the workspace root made the config
watcher's scans of .clinerules/skills and .clinerules/workflows throw
ENOTDIR, which aborted the entire user-instruction refresh: workspace
rules, global rules, and the Skills view all silently failed to load.

Treat ENOTDIR like ENOENT in isIgnorableDirectoryError so a file in a
directory position simply yields no candidates. The .clinerules file
itself is still picked up by the file branch of discoverRulesLikeFiles.
2026-07-29 17:48:23 -07:00
Saoud Rizwan 5e5c4475bc fix(vscode): surface VS Code LM as a host provider (#12711)
* fix(vscode): register VS Code LM provider in catalog

* fix(vscode): use empty selector as vscode-lm catalog default model
2026-07-29 17:43:10 -07:00
Saoud Rizwan 66228fb30b Hide Plugins tab in extension Customize view (#12720) 2026-07-29 17:40:11 -07:00
Saoud Rizwan 7b8798c996 Fix built-in slash commands on the SDK runtime: /newtask aliases /compact, port /deep-planning expansion, hide /newrule and /reportbug (#12721)
* fix(vscode): hide /newrule and /deep-planning until their prompt expansions are ported to the SDK runtime

* feat(vscode): port the /newtask context handoff to the SDK runtime

Expand /newtask into explicit new_task-tool instructions in
SdkController.resolveSlashCommands (ported from legacy
newTaskToolResponse), register a custom new_task AgentTool that captures
the model-generated context summary and completes the run, and emit the
ask:"new_task" message on turn completion so the existing webview
"Start New Task with Context" button (which preloads a fresh task with
the ask text) becomes reachable again. Set the turn phase to
awaiting_followup when emitting the ask, since the completesRun
termination path skips the translator's usual end-of-turn status
handling.

* fix(vscode): hide /reportbug until its prompt expansion is ported to the SDK runtime

Also drop the feature tip promoting /reportbug so the UI doesn't
advertise a command that no longer autocompletes.

* Revert "feat(vscode): port the /newtask context handoff to the SDK runtime"

This reverts commit d9ad153aec.

* feat(vscode): make /newtask an alias of /compact

Condensing achieves /newtask's goal (continue working with a fresh,
summarized context window) without the legacy new_task tool, so the
webview intercepts /newtask alongside /compact and /smol and runs the
condense RPC. Menu description updated to match.

* feat(vscode): port the /deep-planning prompt expansion to the SDK runtime

Expand /deep-planning into the legacy generic-variant instructions
(silent investigation, targeted questions, implementation_plan.md) in
SdkController.resolveSlashCommands, ahead of workflow/skill expansion.
Legacy's STEP 4 created an implementation task via the new_task tool,
which doesn't exist on the SDK runtime; the ported prompt instead has
the agent present the plan and wait for explicit user confirmation.
Re-adds /deep-planning to the slash menu.

* refactor(vscode): simplify the /deep-planning expansion

Drop the custom regex/expander and shell-specific research-command
blocks: the builtin is now a plain AvailableRuntimeCommand appended to
the discovered workflow/skill commands, so the existing
expandSlashCommands machinery handles matching and replacement. The
prompt keeps the four-step protocol and implementation_plan.md
structure with a generic investigation paragraph instead of embedded
OS-specific commands.
2026-07-29 17:36:33 -07:00
John Choi dfd22bf79e refactor(ui): extract desktop approval card (#12693)
* refactor(ui): extract desktop approval card

* fix(ui): preserve approval card parity

* refactor(ui): keep approval labels fixed
2026-07-29 17:22:22 -07:00
John Choi 307707fd00 refactor(ui): extract desktop search combobox (#12663)
* refactor(ui): extract desktop search combobox

* fix(ui): preserve search selector visual parity

* fix(ui): preserve search combobox parity

* fix(ui): preserve combobox adoption parity

* test(desktop): reflect combobox accessible names

* fix(ui): disable open combobox options
2026-07-29 16:25:50 -07:00
Dominic Cooney 2a47c6ca08 fix(mcp): honor per-server timeout (seconds) across all clients (#12546)
* fix(mcp): honor per-server timeout (seconds) across all clients

The per-server timeout field in cline_mcp_settings.json was only read
by the VSCode extension's tools/call path. Everywhere else used
hardcoded constants: the SDK client timed out all requests at 5s and
initialize at 1.5s, and the extension's metadata requests (tools/list,
resources/*, prompts/*) timed out at 5s. Slow servers failed despite a
configured timeout (#7635, #12344).

Resolve the timeout once per client and apply it to every request:

- @cline/shared exports the default (60s) and bounds (1s-3600s) plus a
  resolver that clamps out-of-range values, so a milliseconds/seconds
  mix-up can no longer become hours.
- The SDK config loader parses timeout into
  McpServerRegistration.timeoutSeconds; StdioMcpClient and
  SdkUrlMcpClient apply it to initialize, tools/list, and tools/call.
  Unconfigured servers keep the fast 1.5s initialize probe so startup
  is no slower than before; a configured timeout raises that budget
  for slow-starting servers.
- The extension routes every request (including metadata) through one
  resolver and drops the hardcoded 5s DEFAULT_REQUEST_TIMEOUT_MS.
- createMcpTools derives the agent tool timeoutMs from the same value,
  keeping the wrapper and request timeouts in agreement.
- Timeout errors now name the bound and the field to increase; the
  VSCode server row and the CLI server list show the effective timeout
  and how to change it.

* fix(mcp): harden timeout lifecycle handling

* fix(mcp): address timeout review feedback

* fix(mcp): bound initialization and reconnect

* fix(mcp): keep timeout snapshots consistent

* fix(mcp): use standard stdio framing

* fix(mcp): bound legacy stdio fallback

* fix(mcp): honor timeout in framed fallback

* test(vscode): use SDK Vitest runner

* fix(mcp): fetch server capabilities in parallel

The four post-connect metadata requests (tools/list, resources/list,
resources/templates/list, prompts/list) ran sequentially, so a server
that hangs after initialize blocked connectToServer for four timeout
bounds. The MCP client correlates concurrent requests by JSON-RPC id
and the stdio transport writes each message atomically, so the fetches
now run in parallel and the worst case is one bound.

Also delete McpHub.readResource and McpHub.getPrompt and their response
types: nothing calls them since the SDK migration removed the
access_mcp_resource tool and prompt expansion.

* fix(mcp): keep failed servers and both framing errors visible

When both stdio framing attempts fail differently during initialize,
name each attempt's error instead of discarding the Content-Length
fallback's diagnostics. When they fail identically (both timed out),
rethrow the newline error unchanged so the timeout hint is the whole
message.

When connectToServer fails before the connection is registered (e.g.
the transport fails to start), register a disconnected entry carrying
the error so the server stays visible in the list instead of silently
disappearing, and notify the webview so the row leaves the connecting
state.

* fix(mcp): reject tool calls on connections without a client

A failed (re)connect registers a disconnected entry with a null client
so the server stays visible in the list. A tool wrapper captured by an
active session can still target that server; callTool now rejects it
with a controlled error naming the server and its last connection
error, instead of dereferencing the null client and throwing a
TypeError.
2026-07-29 16:25:23 -07:00
Edoardo Busano 704e953b69 fix(shared): keep valid OTEL headers when one entry is malformed (#12260)
parseKeyPairsIntoRecord wrapped the whole forEach in one try/catch, so a single entry that broke decodeURIComponent (e.g. a stray % in OTEL_EXPORTER_OTLP_HEADERS) aborted the loop and silently dropped every remaining header. Move the try/catch inside the loop to skip only the malformed entry. Adds regression tests.
2026-07-30 00:47:44 +02:00
John Choi c5661f8835 refactor(ui): extract desktop quick actions (#12664)
* refactor(ui): extract desktop quick actions

* fix(ui): preserve quick action visual parity

* style(ui): format quick actions import

* style(ui): format packaged component files
2026-07-29 14:52:05 -07:00
Ara 60c5a24eef Route reasoning controls from models.dev across providers (#12542)
* refactor(reasoning): route model controls from models.dev

Preserve typed reasoning capabilities from the model catalog, normalize requests once against advertised effort, budget, and toggle controls, and keep provider adapters focused on wire encoding. CLI presentation changes are intentionally deferred to a follow-up.

* fix(llms): encode catalog reasoning controls per provider

* fix(llms): clamp reasoning defaults and budgets

* refactor(shared): narrow reasoning exports

* refactor(llms): colocate reasoning controls

* fix(llms): handle mandatory Claude reasoning modes

* fix(llms): omit impossible Anthropic thinking

* fix(llms): reject impossible Anthropic thinking
2026-07-29 23:43:59 +02:00
John Choi 3f9ed573db refactor(ui): extract desktop aurora (#12665)
* refactor(ui): extract desktop aurora

* docs(ui): preserve aurora constraints

* docs(ui): document aurora container contract

* style(ui): format package file list
2026-07-29 14:30:56 -07:00
Bee ac432c87f0 fix(desktop): prevent long-running chat turns from timing out (#12671)
* fix(desktop): disable timeout for chat send commands

Add per-invocation timeout options to the desktop client and disable the deadline for long-running chat send requests. Extract the shared command response type and verify send commands use the timeout override.

* fix(desktop): clean up failed websocket sends
2026-07-29 23:30:28 +02:00
Etisha Garg 95b841a2dd docs: add screenshots for finding free models (#12690) 2026-07-29 12:50:43 -07:00
Saoud Rizwan 7d63376d98 Revert "Revert "docs: add Cline free models page (#12183)" (#12185)" (#12186)
This reverts commit ed3107f9ec.

Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-07-29 07:41:43 -07:00
Sufiyan Khan 912c467818 fix(vscode): cancel Cline task on signout (#12657) 2026-07-29 00:41:33 -07:00
Saoud Rizwan c39c6d4479 feat(vscode): enable Cline Pass unconditionally, removing the ext-cline-pass feature flag (#12677) 2026-07-29 00:23:32 -07:00
Saoud Rizwan 071e5451b1 fix(webview): use theme-colored dropdowns for native select elements (#12676) 2026-07-28 23:55:28 -07:00
Saoud Rizwan 159961b3a3 Fix useDebouncedInput firing onChange on mount, which could wipe stored API keys (#12675)
useDebouncedInput scheduled its debounced onChange on mount and on every
external initialValue resync, not just user edits. Settings fields mount
with a placeholder value while their backing provider config is still
loading asynchronously, so the mount-fire echoed that placeholder back
to the backend ~100ms later.

For DebouncedTextField-backed secret fields (e.g. the OpenRouter API key,
which renders a masked value derived from the async readProviderConfig
response), losing that race meant writing apiKey: "" — silently deleting
the stored key from both providers.json and the legacy secrets store,
and leaving the field rendering empty despite a previously persisted key.
Non-secret fields similarly re-saved stale placeholder values on every
mount.

Gate the debounced save on an actual user edit: only values set through
the returned setter fire onChange; mount and external resyncs never do.
2026-07-28 22:39:13 -07:00
Saoud Rizwan 4a3f1ce310 fix(openai-compatible): carry custom model metadata across model-id changes (ENG-2341) (#12628)
* fix(openai-compatible): keep user model metadata when only the model id changes

Changing the OpenAI Compatible model id committed the new id without
overrides, so an id unknown to the catalog resolved to safe defaults
(inputPrice/outputPrice 0, supportsPromptCache false) and paid requests
billed as $0.0000. The legacy extension kept this user-authored metadata
in a single id-independent blob, so custom prices survived id edits.

Recommit the currently displayed overrides under the new id when the
model id changes, and let edits made while that commit is round-tripping
target the pending id instead of the stale read-back id.

Fixes ENG-2341

* fix(openai-compatible): scope pending selection state per mode

Review follow-up: the pending-override accumulator and pending-commit
counter were shared across Plan and Act. Changing the Act model id,
switching to Plan while that commit was round-tripping, then editing an
override committed the Plan edit under the pending Act model id (and the
shared pending count blocked Plan's reseed at the mode boundary).

Record the mode alongside the pending selection and only trust it for
edits in the same mode, keep per-mode pending counts so a mode switch
reseeds from that mode's committed state, and cover the deferred-commit
mode-switch scenario with a component test.

* fix(openai-compatible): give each mode its own pending-selection accumulator

Review follow-up: tagging the single shared accumulator with a mode still
lost state on a mode round trip. With an Act commit pending, visiting
Plan reseeded the shared slot to Plan; returning to Act could not reseed
(Act's read-back was still in flight), so the next Act edit merged onto
an empty set and silently dropped the pending prices/context/capabilities.

Keep one accumulator slot per mode so a round trip through the other
mode never disturbs a mode's pending state, and cover the scenario with
a deferred-commit round-trip test.
2026-07-28 22:30:19 -07:00
Saoud Rizwan 10a658a767 fix: report OpenRouter Anthropic models' full 1m context window consistently (#12629)
The OpenRouter model picker (refreshOpenRouterModels) still applied the
legacy 200k context-window restriction to Anthropic Claude models, while
the task header and auto-compaction resolve model info through the SDK
catalog, which reports the full 1m extended context window. The same
model showed Context: 200K in the picker and 1.0m in the task header.

Per the current product direction the 200k restriction (and its :1m
opt-in variants) is dropped entirely — everyone gets the 1m context
window. Remove the artificial clamps from refreshOpenRouterModels
(keeping the prompt-cache pricing overrides) and update the
openRouterDefaultModelInfo fallback to match, so the picker, the task
header, and compaction thresholds all agree on 1m.

Closes ENG-2345.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 22:04:55 -07:00
Dominic Cooney 9d63bfcb31 fix(vscode): restore foreground terminal default (#12672)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-28 22:01:31 -07:00
Saoud Rizwan daa32ee138 fix(core): migrate legacy API keys for all secret-backed providers (ENG-2337) (#12626)
* fix(core): migrate legacy API keys for all secret-backed providers

collectCandidateProviderIds only nominated 11 provider ids while
buildLegacyProviderSettings can copy keys for 34, so stored keys for the
other 25 providers (deepseek, mistral, xai, groq, ...) were silently
dropped during migration unless the provider was the active plan/act
provider. Add the missing candidate checks so any stored key makes its
provider a migration candidate.

Also pick the legacy mode per candidate: a split plan/act config applied
the single globalState.mode to every provider, so the non-current mode's
configured model was replaced by the catalog default.

Migration re-runs on manager construction and never overwrites existing
entries, so users who already ran the buggy migration get dropped keys
backfilled from the still-present legacy secrets.json on next launch.

Fixes ENG-2337

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

* fix(core): normalize legacy provider-id aliases during migration

Address review: the mode selection and model fallback compared raw
legacy provider ids, so a declared alias (togetherai -> together,
sap-ai-core -> sapaicore) in globalState would miss its canonical
secret-derived candidate, read the wrong mode, and could write duplicate
alias/canonical entries. Route candidate collection, mode comparison,
and the generic model fallback through the existing normalizeProviderId
boundary. resolveMigratedProviderId now delegates to normalizeProviderId
(identical for the openai -> openai-compatible case it already handled).

Legacy ApiProvider never actually stored alias forms, so this is
hardening for hand-edited state rather than a live regression.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 21:59:39 -07:00
Saoud Rizwan a51fa646bb fix(vscode): reconcile the two provider state stores (ENG-2332) (#12640)
* fix(vscode): reconcile the two provider state stores (ENG-2332)

- createStorageContext now honors CLINE_DATA_DIR with the same priority as
  the SDK's resolveClineDataDir and the legacy reader's resolveDataDir
  (explicit option > CLINE_DATA_DIR > CLINE_DIR/data > ~/.cline/data), so
  globalState.json/secrets.json live in the same data dir as providers.json
  and legacy task state instead of silently splitting across directories.
- Add setLastUsedProvider and call it on active provider switches
  (SdkProviderChangeCoordinator) and when a session resolves its provider
  from StateManager (buildSessionConfig), so providers.json's
  lastUsedProvider no longer goes stale across provider switches.
- Trim env vars in legacy-state-reader's resolveDataDir to match the SDK's
  resolution exactly.

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

* refactor: trim ENG-2332 fix to the minimal change set

Revert the cosmetic legacy-state-reader trim, restore the original CLINE_DIR
line in createStorageContext, and tighten comments. No behavior change to
the two core fixes.

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

* refactor: drop lastUsedProvider sync, keep only the data-dir alignment fix

Scope ENG-2332 to the root-cause fix: createStorageContext honoring
CLINE_DATA_DIR like the SDK resolvers. The providers.json lastUsedProvider
staleness is deferred to a follow-up.

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

* fix: trim CLINE_DATA_DIR in resolveDataDir to match createStorageContext

Addresses Greptile P1: a whitespace-padded CLINE_DATA_DIR was trimmed by
createStorageContext but used verbatim by the legacy reader, which could
resolve the two stores to different directories again.

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

* chore: retrigger CI (windows e2e flake in chat.test.ts)

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

* refactor: share one data-dir resolver between storage context and legacy reader

Per review feedback: extract resolveDataDirFromEnv in storage-context.ts and
have legacy-state-reader's resolveDataDir delegate to it, so the two stores
structurally cannot drift apart again.

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

* fix: trim CLINE_DIR in the shared data-dir resolver to match the SDK

The SDK's resolveClineDir trims CLINE_DIR; a whitespace-padded value would
otherwise still resolve VS Code state and providers.json to different
directories.

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

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 21:36:02 -07:00
John Choi 5213070f67 refactor(ui): extract desktop hero heading (#12609)
* refactor(ui): extract desktop hero heading

* test(ui): preserve hero heading constraints

* docs(ui): trim hero heading comments
2026-07-28 19:48:32 -07:00
Saoud Rizwan 7d7cddb9d9 chore(desktop): release v0.0.7 2026-07-28 18:33:51 -07:00
Saoud Rizwan 55e169f1f8 feat(vscode): working-directory badge in task header for out-of-workspace tasks (#12637)
* feat(vscode): show working-directory badge when a task runs outside the open workspace

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

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

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

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

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

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

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

* chore: add changeset for workflow fixes

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

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

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

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

* chore: update changeset for workflow deprecation notice

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

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

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

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

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

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

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

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

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

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

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

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

* fix(vscode): serialize workflow toggle refreshes

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

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

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

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

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

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

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

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

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

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

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

Address review feedback:

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

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

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

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

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

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

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

---------

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

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

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

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

* Remove attempt_completion tool and strip completion box headers

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

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

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

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

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

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

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

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

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

* Treat missing session records as unknown outcome in history retag

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* Quote empty MCP arguments for cmd.exe

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-28 16:10:27 -07:00
Tomás Barreiro 76c30b1c60 Update ClineFreeModelLimitError wording (#12666) 2026-07-29 01:03:44 +02:00
758 changed files with 99117 additions and 24564 deletions
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-extension
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/tuistory
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: restore workflow support regressions — expand `/workflow.md` slash commands (the legacy filename spelling the autocomplete inserts) and mid-message commands, honor workflow enable/disable toggles during expansion, refresh the slash menu's workflow list on webview launch, and bring back the Workflows management tab in the rules modal (now last in the tab list, with a deprecation notice pointing to Skills)
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix hidden plan/act mode-switch and task-resumption prompts reappearing as user messages when a task is reopened from history
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Enable Auto Compact by default so long chats automatically compress conversation history instead of failing at the model context limit. It can be disabled in Settings → Features → "Auto Compact".
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix /compact UX: clear the chat input as soon as the command is submitted, wrap the compaction divider row at narrow sidebar widths, and update the context-window header even when compacting a small conversation grows the estimated context
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Disable feature tips by default; they can be enabled in Settings → Features → "Feature Tips"
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Show the edited file in a regular editor tab after the diff preview closes, restoring the legacy post-edit behavior
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Show the user's message in chat immediately when sending to a task opened from history, instead of only a thinking indicator until the session resume finishes
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/publish-extension
+1
View File
@@ -0,0 +1 @@
../../.cline/skills/tuistory
+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.
+182
View File
@@ -0,0 +1,182 @@
---
name: publish-extension
description: Use when releasing the Cline VS Code extension — stable (currently the combined legacy+next A/B VSIX via ext-vscode-ab-package), nightly (ext-vscode-publish-nightly), or a legacy-branch hotfix (ext-vscode-publish-legacy). Guides version selection, changelog, PostHog rollout-flag coordination, workflow dispatch, environment approvals, tagging, and post-publish verification, plus the eventual cutover to publishing the SDK extension standalone.
---
# VS Code Extension Release
Use this skill when the user asks to release, publish, or ship the VS Code extension — stable, nightly, or a legacy hotfix — or to dial the rollout, or to cut over to the SDK extension permanently.
> Working directory: repo root. All workflows are dispatched from `main` (GitHub requires the workflow file on the default branch; each workflow checks out the refs it actually builds).
## The current era: combined A/B rollout
We are mid-migration from the legacy (npm, pre-SDK) extension to the next (SDK-based, bun) extension. Until the cutover is complete, **the stable and nightly listings ship a combined VSIX**: a small loader + two complete extensions (`next/` built from `main`, `legacy/` built from the `legacy-extension` branch). The loader picks one per window based on the PostHog flag `ext-sdk-bundle-rollout`. Deep-dive docs: `apps/vscode-rollout/README.md` (authoritative) and PR #12253 (design + runbook comments).
Endgame (see "Cutover" at the bottom): once the next bundle is trusted at 100%, stable goes back to a plain build of `main` via `ext-vscode-publish-stable.yml` and all the legacy/rollout machinery is retired.
### The listings and the workflows
| Channel | Marketplace ID | Workflow | Trigger | Version |
|---|---|---|---|---|
| Stable (combined) | `saoudrizwan.claude-dev` | `ext-vscode-ab-package.yml` | dispatch only; `publish` input defaults false | manual input (semver, e.g. `4.1.0`) |
| Nightly (combined) | `saoudrizwan.cline-nightly` | `ext-vscode-publish-nightly.yml` | cron 12:00 UTC + dispatch | auto `<major>.<minor>.<unix-ts>` from main's `apps/vscode/package.json` |
| Legacy hotfix (standalone) | `saoudrizwan.claude-dev` | `ext-vscode-publish-legacy.yml` | dispatch | from `apps/vscode/package.json` on `legacy-extension` |
| Stable standalone (post-cutover) | `saoudrizwan.claude-dev` | `ext-vscode-publish-stable.yml` | dispatch | from `apps/vscode/package.json` on `main` |
All three publish paths gate on tests before publishing: nightly and ab-package run the reusable bun suite (`ext-vscode-test.yml`, tests `main`) — ab-package additionally runs the legacy branch's npm suite — and the legacy workflow inlines the npm suite. Environment gates: stable paths use `publish``Publish` environment (required reviewers approve in the Actions UI); nightly uses `PublishNightly` (branch policy only, no reviewers — a reviewer requirement would block the cron).
## Golden rules (read before any release)
1. **One listing, one version line.** `claude-dev` is published from multiple workflows/branches. Every stable publish must use a version **strictly above the highest version ever published to the listing from any branch** — marketplace versions are monotonic and cannot be unpublished (supersede, never delete). Check what's live first:
```bash
curl -s -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
-d '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])"
```
`ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version.
2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader):
```bash
node -e '
const KEY = process.argv[1]; // phc_... extracted from a shipped VSIX loader
(async () => {
let t = 0, n = 200;
for (let i = 0; i < n; i += 20) {
const rs = await Promise.all(Array.from({length: 20}, (_, j) =>
fetch("https://data.cline.bot/decide?v=3", { method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({api_key: KEY, distinct_id: `probe-${i+j}-${Math.random()}`})
}).then(r => r.json())));
for (const r of rs) if ((r.featureFlags||{})["ext-sdk-bundle-rollout"] === true) t++;
}
console.log(`~${(100*t/n).toFixed(1)}% (${t}/${n})`);
})()' "$KEY"
```
Flag changes are made in the PostHog UI (Cline project). **0% is the kill switch** — the flag is two-way; there is no separate killswitch flag. Dialing down demotes machines back to legacy on their next window reload.
3. **Ask before pushing** commits or tags. Environment approvals are the maintainer's to give.
4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## [<version>]`.
5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel <id>`) before re-dispatching.
## Stable release (combined A/B VSIX) — the current stable path
### Pre-flight
```bash
# 1. What's live, and what version comes next (must exceed it — rule 1)
# 2. Flag percentage (rule 2) — decide where it should be for this release
# 3. Legacy tip = what the non-promoted cohort will run; confirm it's the shipped hotfix line
git fetch origin main legacy-extension
git log --oneline -3 origin/legacy-extension
# 4. Cheap local rehearsal of the most likely build failure: the union manifest
# hard-fails if views/viewsContainers/configuration diverged between branches.
git show origin/main:apps/vscode/package.json > /tmp/next.json
git show origin/legacy-extension:apps/vscode/package.json > /tmp/legacy.json
node apps/vscode-rollout/scripts/gen-manifest.mjs --next /tmp/next.json --legacy /tmp/legacy.json --version <VERSION>
# Expected warnings only: engines union (takes newer) + walkthrough copy drift.
```
Release prep on `main` (PR, not direct push):
- Add `## [<VERSION>]` entry at the top of root `CHANGELOG.md`.
- Bump `apps/vscode/package.json` to `<VERSION>` so the repo reflects the published line. Side effect: nightly versions become `<major>.<minor>.<unix-ts>` of the new base — harmless (separate listing, still monotonic).
### Dispatch
```bash
gh workflow run ext-vscode-ab-package.yml --ref main \
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
# publish=false builds an installable .vsix artifact without publishing and
# needs NO environment approval — the ungated build job uploads the artifact
# and the run completes.
gh run list --workflow=ext-vscode-ab-package.yml --limit 1
```
Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on:
```bash
gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments
```
### Post-publish
1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX:
```bash
curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])"
```
2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback:
```bash
git tag v<VERSION> <main-sha-built> # ask before pushing
git push origin v<VERSION>
gh release create v<VERSION> --title "v<VERSION>" --notes "<changelog section>" <path-to.vsix>
```
A real publish also **hard-fails early** if root `CHANGELOG.md` on the built main revision doesn't start with `## [<VERSION>]` — the release prep PR must be merged before dispatching.
3. Thorough artifact check (`gh run download <run-id>`): union `package.json` is `saoudrizwan.claude-dev@<VERSION>`, `next/package.json` and `legacy/package.json` carry the SAME version, `grep -c 'phc_' extension/extension.js` ≥ 1 (loader key inlined), no leftover `process.env.TELEMETRY_SERVICE_API_KEY` / `process.env.CLINE_ROLLOUT_VARIANT` literals in either bundle's dist (leftovers = a build ran without its env and telemetry is silently dead).
4. Monitor: `extension.rollout.bundle_activated` in `otel.otel_logs` filtered to `extension_version = '<VERSION>'` (stable cohort is cleanly separable — nightly versions are timestamps). Watch the next/legacy ratio and the crash-fallback rate; Metabase dashboards 17 (rollout + task error rate) and 19 (error deep dive). `extension.rollout.loader_decision` (incl. `double_failure`) is PostHog-only, not in ClickHouse.
5. Dial the flag per the rollout plan (e.g. 0% at publish → 1% → up), verifying each change with the probe from rule 2. Announce demotions ahead of time — dialing down also demotes nightly dogfooders unless they set `"cline-nightly.rollout.bundleOverride": "next"`.
### Known caveats of this path
- **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%.
- A red run can still mean a successful publish on paths that tag (see Gotchas).
## Nightly release
Happens automatically (cron 12:00 UTC). Manual cut:
```bash
gh workflow run ext-vscode-publish-nightly.yml --ref main # real publish
gh workflow run ext-vscode-publish-nightly.yml --ref main -f dry-run=true # artifact only
gh run watch <run-id> --exit-status --interval 60
```
No changelog/version prep — the version is computed. Verify with the marketplace query against `saoudrizwan.cline-nightly`.
**Red run ≠ failed publish**: the final tag-push step fails whenever main's HEAD touches `.github/workflows/**` (default token cannot create such refs). If "Published" appears in the logs, the release went out; push the `nightly-main-<UTC ts>-<sha12>` tag manually with user credentials.
## Legacy hotfix release (and emergency full rollback)
For shipping a fix on the `legacy-extension` branch — or as the **structural rollback** from a bad combined stable VSIX: a standalone legacy publish at a higher version supersedes the combined VSIX entirely (loader and all) for every user. (For "next bundle misbehaving" you don't need this — dial the flag to 0% instead.)
```bash
# On legacy-extension: commit the fix, bump apps/vscode/package.json ABOVE the
# highest version ever published to the listing (rule 1 — including combined
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main \
-f release-type=release -f branch=legacy-extension
```
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
## Cutover: retiring the A/B machinery (the endgame)
When the next bundle has held at 100% long enough to trust:
1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first.
2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow).
3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v<version>` itself, creates the GitHub release, publishes Marketplace + Open VSX.
4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal.
5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive).
6. Update this skill: delete the combined-era sections and keep the standalone flow.
## Gotchas index
- `inputs.*` are empty strings on `schedule` events — preserve `|| 'default'` fallbacks when editing the nightly workflow.
- `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this).
- Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings.
- Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface.
- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group.
- Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `<prefix>.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data.
+107
View File
@@ -0,0 +1,107 @@
---
name: tuistory
description: |
Drive and test terminal apps (especially the Cline CLI TUI in apps/cli) through tuistory — named background PTY sessions that agents can read, wait on, snapshot, screenshot, and type into. Like Playwright/tmux for terminals, with reactive waiting instead of blind `sleep`.
Use this skill when you need to:
- Manually test or reproduce bugs in the interactive Cline TUI (`bun run cli -i`) from a headless environment
- Run a dev server or any long-lived/interactive process in the background without hanging your tool call
- Write or extend Playwright-style e2e tests for the TUI (`bun run test:e2e:tuistory` in apps/cli)
- Capture text snapshots or styled PNG screenshots of a TUI screen as evidence
---
# tuistory
[tuistory](https://github.com/remorses/tuistory) wraps any terminal command in a named background PTY session backed by a Ghostty terminal emulator. Agents interact with the session via short CLI calls that return instantly; humans can `tuistory attach` to the same session to watch or intervene. No real terminal or display (`DISPLAY`) is needed — it works fully headless, which makes it the preferred way for cloud agents to exercise the Cline TUI.
It is installed as a devDependency of `@cline/cli`, so the pinned binary resolves when you run from `apps/cli`:
```bash
cd apps/cli
bunx tuistory --help # source of truth for commands, options, and syntax
```
For full upstream docs: `curl -s https://raw.githubusercontent.com/remorses/tuistory/refs/heads/main/README.md`
## Driving the Cline TUI headlessly
Launch the TUI in an isolated environment so you don't touch real user config (`~/.cline`):
```bash
cd apps/cli
DATA_DIR=$(mktemp -d) && HOME_DIR=$(mktemp -d)
bunx tuistory -s cline --cols 120 --rows 36 \
--env HOME=$HOME_DIR --env CLINE_DATA_DIR=$DATA_DIR \
--env CLINE_DISABLE_CLINE_PASS_NOTICE=1 --env CLINE_TELEMETRY_DISABLED=1 \
-- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
```
The dummy `-k test-key` renders the full chat UI; only an actual agent turn would fail. For recorded LLM turns, use the VCR cassettes described in `apps/cli/src/tests/helpers/env.ts` (`CLINE_VCR=playback` + `CLINE_VCR_CASSETTE`). Real turns need a provider credential (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`).
Then use an **observe → act → observe** loop:
```bash
# Wait reactively for the chat view — never use sleep
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
# Act, then always observe the resulting screen state
bunx tuistory -s cline type "/settings"
bunx tuistory -s cline snapshot --trim
bunx tuistory -s cline press enter
bunx tuistory -s cline snapshot --trim
# Styled PNG of the current screen (prints the file path) — good for artifacts
bunx tuistory -s cline screenshot
# Full raw output stream (snapshot shows only the visible screen)
bunx tuistory read -s cline --all
# Tear down a session YOU started (double Ctrl+C exits the TUI cleanly)
bunx tuistory -s cline press ctrl c
bunx tuistory -s cline press ctrl c
bunx tuistory -s cline close
```
## Background processes (instead of tmux)
```bash
bunx tuistory -s my-server -- bun run dev:sidecar # returns immediately
bunx tuistory -s my-server wait "/listening|ready/i" --timeout 30000
bunx tuistory read -s my-server # new output since last read
bunx tuistory -s my-server restart # after code changes
```
## Key rules
- **Options before `--`, command after.** Everything after the first `--` is passed verbatim to the child: `tuistory -s name --cols 150 -- bun src/index.ts` is correct.
- **Snapshot after every action.** TUIs are stateful; dialogs and errors can render over the view you expect. `snapshot` reflects what the user actually sees (occluded text does not count), unlike grepping the raw stream.
- **Wait, never sleep.** `wait "text"` / `wait "/regex/i"` (case-sensitive by default) reacts as fast as the terminal updates; `wait-idle` when you don't know what to expect. Always pass `--timeout`.
- **Keys land instantly.** Unlike sleep-based scripts, a queued second keypress can leak into the next view (e.g. one Enter both accepts a slash completion and submits it).
- **Never close a session you didn't start.** Sessions are shared with humans (`tuistory attach -s name`) and other agents. Default to leaving sessions running; use `read`/`wait`/`snapshot` to inspect without disrupting.
- `--cols`/`--rows` affect TUI layout (assertions are width-sensitive); `--pixel-ratio 2` gives sharper screenshots.
## Writing e2e tests with the library API
`apps/cli/src/cli.tuistory.e2e.test.ts` (run: `bun run test:e2e:tuistory`) is the reference. The programmatic API runs in-process — no daemon:
```ts
import { launchTerminal } from "tuistory";
const session = await launchTerminal({
command: "bun",
args: ["src/index.ts", "--provider", "anthropic", "-k", "test-key"],
cwd: cliRoot,
env: isolatedEnv, // see createCliEnv() in the reference test
cols: 120,
rows: 36,
waitForDataTimeout: 30_000, // CLI cold start compiles a large TS graph
});
await session.waitForText("What can I do for you?", { timeout: 30_000 });
const screen = await session.text({ trimEnd: true }); // emulated screen state
await session.type("/settings");
await session.press("enter");
session.close(); // always close in test teardown
```
Screen-state assertions can check that stale UI is *gone* (`expect(screen).not.toContain(...)`), which stream-grepping harnesses cannot. `session.text({ only: { bold: true } })` filters by style; `session.read()` returns the raw stream since the last read.
+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
+354 -8
View File
@@ -5,6 +5,11 @@ name: ext-vscode-ab-package
# `legacy/` from the legacy-extension branch. Cohort selection happens at
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
# and the rollout runbook.
#
# Job layout: cheap input gates (preflight) and the two bundle test suites run
# ungated; the build job packages the VSIX with no environment attached, so
# publish=false rehearsals complete without any approval; only the publish job
# — Marketplace + Open VSX + bookkeeping — waits on the `publish` environment.
on:
workflow_dispatch:
@@ -24,7 +29,7 @@ on:
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
@@ -37,22 +42,187 @@ concurrency:
cancel-in-progress: false
jobs:
package:
name: Build combined (legacy + next) VSIX
# Input gates that need no checkout: fail in seconds — before the test
# suites, the ~20-minute build, and the environment approval — instead of
# at publish time.
preflight:
name: Validate inputs
runs-on: ubuntu-latest
environment: publish
steps:
# The input reaches the shell ONLY via env here (never inline
# expression interpolation, which is evaluated before bash runs and
# would allow script injection from the dispatch form). Because
# every later job `needs` preflight, passing this regex is what
# makes the plain-string `${{ inputs.version }}` interpolations
# downstream safe.
- name: Validate version format
env:
VERSION: ${{ github.event.inputs.version }}
run: |
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be plain X.Y.Z with no leading 'v' and no suffix (got '$VERSION')."
echo "It is stamped verbatim into the union manifest and both bundle manifests."
exit 1
fi
echo "Version format ok: $VERSION"
# The reusable bun suite tests the dispatch revision (main), so
# publishing any other next-ref would ship an untested bundle.
# Build-only runs (publish=false) may still use arbitrary next-refs
# for artifact rehearsals.
- name: Refuse to publish an untested next-ref
if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }}
run: |
echo "Error: publish=true requires next-ref=main — the test gate only covers main."
exit 1
# Marketplace versions are monotonic and cannot be unpublished:
# every publish must exceed the highest version ever published to
# the claude-dev listing FROM ANY BRANCH (combined stable or legacy
# hotfix). The publish job re-checks right before publishing — the
# environment-approval wait can last days and a legacy hotfix can
# land in between. Keep both copies of this check in sync.
- name: Verify version exceeds the live Marketplace version
if: ${{ github.event.inputs.publish == 'true' }}
env:
VERSION: ${{ github.event.inputs.version }}
run: |
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
if [[ -z "$LIVE" ]]; then
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
exit 1
fi
node -e '
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
for (let i = 0; i < 3; i++) {
if (next[i] > live[i]) process.exit(0);
if (next[i] < live[i]) break;
}
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
process.exit(1);
' "$VERSION" "$LIVE"
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
# Gate the build/publish on BOTH bundles' own test suites, mirroring the two
# standalone publish paths (nightly gates on the bun suite via the same
# reusable workflow; the legacy publish inlines the npm suite).
#
# Caveat (shared with the nightly workflow): the reusable bun suite tests the
# DISPATCH revision — main's tip at dispatch, since this workflow is only
# dispatched from main — not `next-ref`. The build job therefore pins the
# default next-ref checkout to that same revision (tested == built) and
# preflight refuses publish=true for any other next-ref; build-only artifact
# runs may still build untested refs.
test-next:
name: Test next (SDK) bundle
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/ext-vscode-test.yml
# The legacy branch is the npm codebase, so the bun-based reusable workflow
# cannot test it. Inlined npm steps, kept in sync with the `test` job in
# ext-vscode-publish-legacy.yml (same suite, different ref input name).
test-legacy:
name: Test legacy bundle
runs-on: ubuntu-latest
# The tested revision, exported so the build job builds EXACTLY what
# this suite ran against. legacy-ref is a mutable branch name and the
# build job starts later — re-resolving the name there could pick up
# commits this gate never saw.
outputs:
tested-sha: ${{ steps.rev.outputs.sha }}
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
- name: Record tested revision
id: rev
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (lint + typecheck)
run: npm run ci:check-all
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
- name: Unit Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:unit
- name: Extension Integration Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: xvfb-run -a npm run test:coverage
- name: Webview Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
build:
name: Build combined (legacy + next) VSIX
needs: [preflight, test-next, test-legacy]
runs-on: ubuntu-latest
steps:
# For the default next-ref (main), pin the checkout to the exact
# revision the test-next gate ran against: a moving branch name could
# otherwise drift past the tested commit during the test phase.
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
ref: ${{ github.event.inputs.next-ref == 'main' && github.sha || github.event.inputs.next-ref }}
path: next-src
lfs: true
# Fail fast (before the ~20-min build) if a real publish is missing
# its changelog entry — same contract the standalone publish
# workflows enforce. Build-only rehearsals are exempt.
- name: Verify changelog entry
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: next-src
run: |
EXPECTED_HEADING="## [${{ github.event.inputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing (found '$FIRST_HEADING')."
exit 1
fi
echo "Found changelog entry for ${{ github.event.inputs.version }}"
# Pin to the revision test-legacy actually tested (see that job's
# outputs comment) — never re-resolve the mutable branch name here.
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
ref: ${{ needs.test-legacy.outputs.tested-sha }}
path: legacy-src
lfs: true
@@ -65,9 +235,12 @@ jobs:
with:
node-version: 22
# --frozen-lockfile so the built bundle resolves the exact
# dependency set the test-next gate ran against (the reusable suite
# installs frozen too) — a bare install could silently re-resolve.
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
@@ -76,6 +249,17 @@ jobs:
working-directory: next-src
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
working-directory: next-src/apps/vscode
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# Stamp the combined version into each bundle's package.json AFTER
# install and BEFORE its build: the About tab and telemetry
# extension_version read the bundle's own manifest, so without this
@@ -192,14 +376,176 @@ jobs:
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
publish:
name: Publish to Marketplace and Open VSX
needs: build
if: ${{ github.event.inputs.publish == 'true' }}
runs-on: ubuntu-latest
environment: publish
# contents: write is required by the post-publish bookkeeping (tag +
# GitHub Release), mirroring the standalone publish workflows.
permissions:
contents: write
steps:
# The built next revision: preflight refused publish=true for any
# next-ref other than main, and the build job pinned main to the
# dispatch SHA — so github.sha IS the published commit. Used for the
# changelog, the release tag, and the previous-tag lookup.
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Download VSIX artifact
uses: actions/download-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Re-check monotonicity at the last moment: the environment-approval
# wait can last days, and a legacy hotfix published in the meantime
# would otherwise be silently superseded by this older code line.
# Keep in sync with the preflight copy of this check.
- name: Re-verify version exceeds the live Marketplace version
env:
VERSION: ${{ github.event.inputs.version }}
run: |
LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \
-H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \
--data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \
| node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})')
if [[ -z "$LIVE" ]]; then
echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev."
exit 1
fi
node -e '
const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number));
for (let i = 0; i < 3; i++) {
if (next[i] > live[i]) process.exit(0);
if (next[i] < live[i]) break;
}
console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`);
process.exit(1);
' "$VERSION" "$LIVE"
echo "Version ok: $VERSION exceeds live Marketplace version $LIVE"
# Both PATs are verified BEFORE the first irreversible publish so a
# missing Open VSX token can't strand us half-published. The two
# registries are separate steps: if Open VSX fails after the
# Marketplace accepted the VSIX, the run goes red (so the operator
# notices Open VSX lagged) but the bookkeeping below still runs —
# it is keyed off the Marketplace outcome, which is what "shipped"
# means for this listing.
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
id: publish_marketplace
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish to Open VSX."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Publish to Open VSX
working-directory: staging
env:
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: npx ovsx publish --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" --pat "$OVSX_PAT"
# ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ----
# Mirrors the standalone publish workflows. Every step here is
# continue-on-error, and gated on the MARKETPLACE outcome rather
# than plain step ordering: the Marketplace publish already
# happened, so bookkeeping must still run when only the Open VSX
# step failed, and a red run after a successful publish is exactly
# the confusion the nightly workflow taught us to avoid (tag pushes
# fail whenever the built commit touches .github/workflows/** — no
# grantable permission fixes that; push the tag manually in that
# case, see the publish-extension skill).
- name: Extract changelog entry
id: changelog
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
run: |
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
{
echo "content<<CHANGELOG_EOF"
echo "$CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve previous release tag
id: prev_tag
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
run: |
# ls-remote needs no local tag objects; take the highest v* tag
# below the one being released.
PREV=$(git ls-remote --tags origin 'v*' \
| awk -F/ '{print $NF}' | grep -v '\^{}' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vx "v${{ github.event.inputs.version }}" \
| sort -V | tail -1)
echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT"
- name: Create and push release tag
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
run: |
TAG="v${{ github.event.inputs.version }}"
git tag "$TAG" HEAD
git push origin "refs/tags/$TAG"
echo "Pushed $TAG at $(git rev-parse HEAD)"
- name: Create GitHub Release
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ github.event.inputs.version }}
files: staging/claude-dev-${{ github.event.inputs.version }}.vsix
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
continue-on-error: true
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline v${{ github.event.inputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline v${{ github.event.inputs.version }}*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...v${{ github.event.inputs.version }}"
+6 -2
View File
@@ -44,7 +44,7 @@ jobs:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
run: bun install --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
@@ -58,6 +58,11 @@ jobs:
- name: Build UI package
run: bun -F @cline/ui build
# The desktop chat test imports @cline/shared/browser, which resolves to
# dist output that nothing else in this job builds.
- name: Build shared package
run: bun -F @cline/shared build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
@@ -97,7 +102,6 @@ jobs:
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
+188
View File
@@ -1,5 +1,193 @@
# Changelog
## [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
- Show which extension variant is active — "Legacy" or "Next" — next to the version in the settings About page, in both bundles of the combined rollout package.
## [4.1.1]
### Changed
- Remove vestigial MCP server-key machinery from McpHub — native MCP tool calls now route by server name instead of a random in-memory uid, so routing survives restarts and server list changes.
## [4.1.0]
### Changed
- Convert the stable extension to a combined A/B package: one VSIX containing both the current (legacy) extension and the new SDK-based extension, plus a loader that activates exactly one per window via a staged remote rollout. For nearly all users nothing changes — the loader activates the same extension as 4.0.12; a small percentage (starting at 1%) is gradually opted into the SDK-based extension. If the new extension fails to activate, the loader falls back to the current one in the same window. Settings and credentials are shared between the two.
## [4.0.12]
### Added
- Add support for free Cline models, shown as "(free)" in the model picker, with a dedicated error card that includes the reset time when the free limit is reached.
### Fixed
- Keep Claude Code responses that were already streamed when the CLI exits with a max-turns error, instead of discarding a valid response.
## [4.0.11]
### Added
- Add Claude Opus 5 across the Anthropic, Claude Code, Bedrock, Vertex, Cline, and OpenRouter providers, including 1M context window variants.
- Add Moonshot Kimi K3 support.
- Include the host plugin version in telemetry events.
### Fixed
- Correct pricing for the Claude Opus 1M context variants, which overstated costs for requests above 200k tokens.
- Enable native tool calling for Kimi K3 models, fixing empty responses.
## [4.0.10]
### Added
- Add telemetry to track when Cline reaches the consecutive mistake limit.
## [4.0.9]
### Added
- Add GPT-5.6 ChatGPT subscription models.
### Changed
- Soften and shorten the message shown when Cline hits the consecutive mistake limit.
### Fixed
- Handle cumulative usage snapshots from OpenAI-compatible providers so token counts are no longer over-reported.
- Load skills from files saved as UTF-8 with a byte-order mark (BOM).
## [4.0.8]
### Added
- Add more models to the GCP Vertex provider, plus a free-form entry option in the model dropdown for specifying custom Vertex models.
## [4.0.7]
### Added
- Add a ClinePass limit-reached error with a one-click option to switch to Cline usage-based billing.
- Allow selecting Cline free models on the ClinePass provider, organized into Subscribed and Free tabs with model descriptions.
### Changed
- Refine ClinePass onboarding and provider settings copy, and open the "learn more" link via the in-app URL handler.
- Remove the Cline model picker recommendation copy.
### Removed
- Remove all references to GLM 5.1.
## [4.0.6]
### Fixed
- Generalize the model capability warning so it applies more broadly.
## [4.0.5]
### Added
- Add support for Claude Sonnet 5 across the Anthropic, Bedrock, Vertex, Claude Code, SAP AI Core, OpenRouter, and Vercel AI Gateway providers, including model picker and recommended-model updates.
## [4.0.4]
### Changed
- Fully remove the ClinePass feature flag so ClinePass is available everywhere in the UI — onboarding, settings, the welcome promo banner, and the credit-limit "Switch to ClinePass" action.
## [4.0.3]
### Changed
- Enable the ClinePass provider for all users by removing the feature-flag gate that previously fell back to the standard Cline provider.
## [4.0.2]
### Added
- Add reasoning effort support (including `xhigh`) for DeepSeek thinking models.
- Improve the ClinePass provider experience with clearer reasoning controls and model selection.
### Fixed
- Show reasoning effort controls for ClinePass models and align ClinePass model resolution with the rest of the provider.
- Prefer canonical Cline Z.ai model ids and polish ClinePass and Z.ai model metadata.
- Fix environment variable replacement in the webview.
- Default focus chain settings in webview state so the toggle reflects the correct value on load.
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [4.0.0]
### 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.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
- Connector threads (Slack, Discord, Telegram, Linear, Google Chat, WhatsApp) now recover when the session they were bound to is gone — the stale binding is dropped and the turn replays against a new session, instead of failing with "session not found" until `threads.json` is edited by hand
- `cline --help` now reports the real default `--config` and `--data-dir` paths
- The per-server `timeout` in `cline_mcp_settings.json` is now honored for `initialize`, `tools/list`, and `tools/call`, so slow MCP servers no longer fail against a hardcoded 5s limit (from SDK v0.0.67)
- Reasoning controls are now routed from the models.dev catalog across providers, with clamped budgets and correct per-provider encoding (from SDK v0.0.67)
- OpenRouter now defaults to `anthropic/claude-sonnet-5` (from SDK v0.0.67)
- Fixed the China and international endpoint toggles being ignored for Qwen, Moonshot, and Z AI (from SDK v0.0.67)
- Legacy API keys are now migrated for every secret-backed provider (from SDK v0.0.67)
- Legacy OpenAI Compatible model-info overrides now survive into the seeded `models.json` (from SDK v0.0.67)
- Fixed auto-compaction state being rejected as stale, which added a redundant summarizer call on every turn past the compaction trigger (from SDK v0.0.67)
- Fixed checkpoint restores across session resumes (from SDK v0.0.67)
- Tool calls that pass line numbers as strings (`insert_line`, `read_files` bounds) are now accepted instead of erroring (from SDK v0.0.67)
- A legacy single-file `.clinerules` no longer aborts the config scan (from SDK v0.0.67)
- Plugins can now emit telemetry through `ctx.telemetry` (from SDK v0.0.67)
## 3.0.47
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
+31
View File
@@ -339,6 +339,9 @@ bun run test:e2e:interactive
# TUI-specific E2E tests (uses @microsoft/tui-test)
bun run test:e2e:cli:tui
# TUI E2E tests driven through tuistory (PTY + Ghostty terminal emulator)
bun run test:e2e:tuistory
# Type checking
bun run typecheck
@@ -364,6 +367,34 @@ bun run dev -- --interactive --config /tmp/cline-test
Or set `CLINE_FORCE_ONBOARDING=1` to force the onboarding view regardless of existing config.
### Manually testing the TUI (agents / headless environments)
[tuistory](https://github.com/remorses/tuistory) is installed as a devDependency. It wraps the TUI in a named background PTY session that can be scripted from a plain shell — no real terminal or display needed. This is the preferred way for AI agents (or anyone in a headless environment) to poke at the interactive TUI:
```bash
cd apps/cli
# Launch the TUI in a background session
bunx tuistory -s cline --cols 120 --rows 36 -- bun src/index.ts --provider anthropic -m claude-sonnet-4-6 -k test-key
# Wait reactively for the chat view (no sleep guessing)
bunx tuistory -s cline wait "What can I do for you?" --timeout 30000
# Interact and inspect
bunx tuistory -s cline type "/settings"
bunx tuistory -s cline press enter
bunx tuistory -s cline snapshot --trim # current screen as text
bunx tuistory -s cline screenshot # current screen as a styled PNG
# A human can watch/drive the same session from another terminal
tuistory attach -s cline
# Tear down
bunx tuistory -s cline close
```
The same engine powers the `test:e2e:tuistory` vitest suite (`src/cli.tuistory.e2e.test.ts`), which uses the programmatic `launchTerminal()` API for assertions against the emulated screen.
### Adding a new TUI component
1. Create a `.tsx` file in `src/tui/components/`
+1 -1
View File
@@ -260,7 +260,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline/data` (enables sandbox mode automatically) |
| `--auto-approve [true\|false]` | Set tool auto-approval for all tools |
| `--kanban` | Run the external `kanban` app |
| `-y, --yolo` | Skip tool approval prompts, enable `submit_and_exit`, and disable spawn/team tools by default |
+5 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.47",
"version": "3.0.51",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -62,6 +62,7 @@
"test:unit": "vitest run --config vitest.config.ts",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:interactive": "vitest run --config vitest.interactive.e2e.config.ts",
"test:e2e:tuistory": "vitest run --config vitest.tuistory.e2e.config.ts",
"test:watch": "vitest --config vitest.config.ts",
"test:e2e:cli:tui": "cd src/tests && tui-test",
"link": "bun unlink && bun link"
@@ -99,8 +100,9 @@
"@cline/core": "workspace:*",
"@cline/shared": "workspace:*",
"@microsoft/tui-test": "^0.0.2",
"@types/bun": "^1.3.10",
"@types/react": "19.2.14",
"vitest": "^4.0.18",
"@types/bun": "^1.3.10"
"tuistory": "^0.10.1",
"vitest": "^4.0.18"
}
}
+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 "$@"
+314 -36
View File
@@ -7,6 +7,8 @@ import type {
ContentBlock,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
@@ -28,11 +30,12 @@ import {
ProviderSettingsManager,
SessionSource,
} from "@cline/core";
import type { Message } from "@cline/shared";
import { isLikelyAuthError, type Message } from "@cline/shared";
import { getPersistedProviderApiKey } from "../commands/auth";
import { resolveSystemPrompt } from "../runtime/prompt";
import { subscribeToAgentEvents } from "../runtime/session-events";
import { createCliCore } from "../session/session";
import { isClineOrgIndividualInferenceSubscriptionErrorMessage } from "../utils/cline-pass-errors";
import { getCliBuildInfo } from "../utils/common";
import { randomSessionId, resolveWorkspaceRoot } from "../utils/helpers";
import type { Config } from "../utils/types";
@@ -43,8 +46,24 @@ import {
authenticateAcpProvider,
isAcpAuthMethodId,
} from "./auth";
import { requestAcpToolApproval } from "./permissions";
import {
AUTO_APPROVE_CONFIG_ID,
buildAutoApproveConfigOption,
parseAutoApproveValue,
} from "./auto-approve";
import {
buildOrganizationConfigOption,
fetchClineOrganizations,
getAcpOrgSubscriptionMessage,
ORGANIZATION_CONFIG_ID,
PERSONAL_ACCOUNT_VALUE,
switchClineOrganization,
usesClineAccount,
} from "./organizations";
import { requestAcpToolApproval } from "./permissions";
import { replaySessionHistory } from "./session-load";
import {
describeAgentError,
forwardAgentEvent,
sendConfigOptionUpdate,
sendCurrentModeUpdate,
@@ -61,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. */
@@ -69,6 +90,15 @@ interface SessionState {
abortController?: AbortController;
/** Unsubscribe function for the agent event listener. */
unsubscribe?: () => void;
/**
* Most recent unrecoverable agent error for the in-flight turn.
*
* The runtime reports fatal failures (bad credentials, subscription
* restrictions, provider outages) as an `error` event and still resolves
* `send()` normally, so the message has to be stashed here for `prompt()` to
* turn into an error response.
*/
fatalError?: Error;
/** Messages to inject into the next session manager for conversation continuity. */
pendingInitialMessages?: Message[];
}
@@ -77,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> {
@@ -109,7 +144,7 @@ export class AcpAgent implements Agent {
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
isSessionReady() {
// Require authentication unless an API key is provided via env var.
if (!this.authResult && !process.env.CLINE_API_KEY) {
// Check for valid persisted credentials from a previous session
@@ -119,18 +154,46 @@ export class AcpAgent implements Agent {
if (!this.authResult) {
throw RequestError.authRequired(
undefined,
"Call authenticate before creating a session",
"Call authenticate before starting a session",
);
}
}
}
availableModes() {
return [
{
id: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
id: "act",
name: "Act",
description: "Make changes to the codebase",
},
];
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
this.isSessionReady();
const sessionId = randomSessionId();
const defaultMode = "act";
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const defaultModelId =
process.env.CLINE_MODEL ?? "anthropic/claude-sonnet-4.6";
const providerModels = await Llms.getModelsForProvider(providerId);
// Model ids are provider-scoped, so the default must come from the
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
// nothing to `cline`, and vice versa.
const defaultModelId = await resolveDefaultModelId(
providerId,
process.env.CLINE_MODEL,
providerModels,
);
this.sessions.set(sessionId, {
id: sessionId,
@@ -139,9 +202,9 @@ export class AcpAgent implements Agent {
currentMode: defaultMode,
currentProviderId: providerId,
currentModelId: defaultModelId,
autoApproveTools: this.defaultAutoApproveTools,
});
const providerModels = await Llms.getModelsForProvider(providerId);
const availableModels = Object.entries(providerModels).map(
([modelId, info]) => ({
modelId,
@@ -150,22 +213,13 @@ export class AcpAgent implements Agent {
}),
);
const organizationOption =
await this.getOrganizationConfigOption(providerId);
return {
sessionId,
modes: {
availableModes: [
{
id: "plan",
name: "Plan",
description:
"Explore the codebase and plan changes without modifying files",
},
{
id: "act",
name: "Act",
description: "Make changes to the codebase",
},
],
availableModes: this.availableModes(),
currentModeId: defaultMode,
},
models: {
@@ -176,10 +230,87 @@ export class AcpAgent implements Agent {
await buildProviderConfigOption(providerId),
buildModelConfigOption(defaultModelId, providerModels),
buildModeConfigOption(defaultMode),
buildAutoApproveConfigOption(this.defaultAutoApproveTools),
...(organizationOption ? [organizationOption] : []),
],
};
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
this.isSessionReady();
let session = this.sessions.get(params.sessionId);
let messages: Message[];
if (session?.sessionManager && session.activeSessionId) {
// The session is still live in this connection — replay its current
// conversation without restarting anything.
messages =
(await session.sessionManager.readMessages(session.activeSessionId)) ??
[];
} else {
if (!session) {
// Provider/model are not persisted per session — a session
// loaded on a fresh connection starts from the same defaults
// as a new session, with the model resolved against the
// provider's own catalog just like newSession.
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(providerId);
session = {
id: params.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers,
currentMode: "act",
currentProviderId: providerId,
currentModelId: await resolveDefaultModelId(
providerId,
process.env.CLINE_MODEL,
providerModels,
),
autoApproveTools: this.defaultAutoApproveTools,
};
this.sessions.set(params.sessionId, session);
}
try {
messages =
(await this.ensureSessionManager(session, params.sessionId, {
resume: true,
})) ?? [];
} catch (error) {
this.sessions.delete(params.sessionId);
throw error;
}
}
// The ACP spec requires the full conversation to be replayed via
// session/update notifications before this request resolves.
await replaySessionHistory(this.conn, params.sessionId, messages);
const providerModels = await Llms.getModelsForProvider(
session.currentProviderId,
);
const availableModels = Object.entries(providerModels).map(
([availableModelId, info]) => ({
modelId: availableModelId,
name: info.name ?? availableModelId,
description: info.description,
}),
);
return {
modes: {
availableModes: this.availableModes(),
currentModeId: session.currentMode,
},
models: {
availableModels,
currentModelId: session.currentModelId,
},
configOptions: await buildAllConfigOptions(session),
};
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
@@ -193,6 +324,7 @@ export class AcpAgent implements Agent {
const abortController = new AbortController();
session.abortController = abortController;
session.fatalError = undefined;
// If cancel() was already called before prompt() started, bail early.
if (abortController.signal.aborted) {
@@ -242,6 +374,17 @@ export class AcpAgent implements Agent {
updatedAt: new Date().toISOString(),
});
// A cancelled turn always reports `cancelled`: the ACP spec
// requires agents to convert abort failures into the cancelled stop reason
// so clients don't show cancellations as errors.
if (stopReason !== "cancelled") {
const fatalError = session.fatalError;
session.fatalError = undefined;
if (fatalError) {
throw toAcpPromptError(fatalError);
}
}
return { stopReason };
}
@@ -326,16 +469,37 @@ export class AcpAgent implements Agent {
// creates a fresh one with the new provider on the next prompt().
await this.teardownSessionManager(session);
// If current model doesn't exist in new provider, reset to first available
// Re-resolve the model against the new provider's catalog: keep the
// current one when it's offered there too, otherwise fall back to the
// provider's declared default rather than whichever model happens to
// be listed first (for cline-pass that is an unrelated free model).
const providerModels = await Llms.getModelsForProvider(value);
const modelIds = Object.keys(providerModels);
const fallbackModelId = modelIds[0];
if (
!modelIds.includes(session.currentModelId) &&
fallbackModelId !== undefined
) {
session.currentModelId = fallbackModelId;
session.currentModelId = await resolveDefaultModelId(
value,
session.currentModelId,
providerModels,
);
break;
}
case ORGANIZATION_CONFIG_ID: {
try {
await switchClineOrganization({
apiKey: this.accountApiKey,
providerSettingsManager: this.providerSettingsManager,
organizationId: value === PERSONAL_ACCOUNT_VALUE ? null : value,
});
} catch (error) {
const message = describeAgentError(error);
throw RequestError.internalError(
{ message },
`Failed to switch account: ${message}`,
);
}
// Restart the backend session so subsequent turns run under the
// newly selected account.
await this.teardownSessionManager(session);
break;
}
@@ -362,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,
@@ -370,6 +546,12 @@ export class AcpAgent implements Agent {
}
const configOptions = await buildAllConfigOptions(session);
const organizationOption = await this.getOrganizationConfigOption(
session.currentProviderId,
);
if (organizationOption) {
configOptions.push(organizationOption);
}
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
return { configOptions };
}
@@ -410,6 +592,25 @@ export class AcpAgent implements Agent {
this.sessions.clear();
}
private get accountApiKey(): string {
return process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
}
private async getOrganizationConfigOption(
providerId: string,
): Promise<SessionConfigOption | undefined> {
if (!usesClineAccount(providerId)) {
return undefined;
}
const organizations = await fetchClineOrganizations({
apiKey: this.accountApiKey,
providerSettingsManager: this.providerSettingsManager,
});
return organizations
? buildOrganizationConfigOption(organizations)
: undefined;
}
/**
* Attempt to restore authentication from persisted provider settings.
*
@@ -467,13 +668,17 @@ export class AcpAgent implements Agent {
* Lazily create and start the session manager for this ACP session.
* After the first call the manager persists across prompt() calls so that
* conversation history is maintained.
*
* With `resume: true` the persisted conversation for `acpSessionId` is read
* back through the session manager.
*/
private async ensureSessionManager(
session: SessionState,
acpSessionId: string,
): Promise<void> {
options?: { resume?: boolean },
): Promise<Message[] | undefined> {
if (session.sessionManager) {
return;
return undefined;
}
const config = await this.buildConfig(session);
@@ -482,31 +687,61 @@ 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,
});
let initialMessages: Message[] | undefined;
if (options?.resume) {
initialMessages = await sessionManager
.readMessages(acpSessionId)
.catch(() => undefined);
if (!initialMessages || initialMessages.length === 0) {
await sessionManager
.dispose("acp_load_session_not_found")
.catch(() => {});
throw RequestError.resourceNotFound(acpSessionId);
}
} else {
initialMessages = session.pendingInitialMessages;
session.pendingInitialMessages = undefined;
}
session.unsubscribe = subscribeToAgentEvents(
sessionManager,
(event: AgentEvent) => {
// Remember unrecoverable failures so prompt() can fail the turn.
if (event.type === "error" && !event.recoverable) {
session.fatalError =
event.error instanceof Error
? event.error
: new Error(describeAgentError(event.error));
}
forwardAgentEvent(this.conn, acpSessionId, event);
},
);
const initialMessages = session.pendingInitialMessages;
session.pendingInitialMessages = undefined;
const started = await sessionManager.start({
source: SessionSource.CLI,
config,
// Persist the core session under the ACP session id so that
// session/load can find the conversation by the id the client holds.
config: {
...config,
modelId: session.currentModelId,
sessionId: acpSessionId,
},
interactive: true,
initialMessages,
});
session.sessionManager = sessionManager;
session.activeSessionId = started.sessionId;
return initialMessages;
}
private async buildConfig(session: SessionState): Promise<Config> {
@@ -560,6 +795,48 @@ export class AcpAgent implements Agent {
}
}
async function resolveDefaultModelId(
providerId: string,
preferredModelId: string | undefined,
providerModels: Record<string, unknown>,
): Promise<string> {
const modelIds = Object.keys(providerModels);
const preferred = preferredModelId?.trim();
if (preferred && modelIds.includes(preferred)) {
return preferred;
}
const providerDefault = (await Llms.getProvider(providerId))?.defaultModelId;
if (providerDefault && modelIds.includes(providerDefault)) {
return providerDefault;
}
return modelIds[0] ?? "";
}
/**
* Convert a fatal agent error into a JSON-RPC error for the prompt response.
*
* Credential/subscription problems map to `auth_required` (-32000) so clients
* can offer a re-auth affordance rather than just printing text; everything
* else is an internal error.
*
* Classification goes through the shared CLI helpers, which check the error's
* type *and* its name/message. That matters because the runtime re-wraps errors
* as it forwards them across the event boundary, so `instanceof` alone fails on
* the object ACP actually receives.
*/
function toAcpPromptError(error: Error): RequestError {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
const message = getAcpOrgSubscriptionMessage();
return RequestError.internalError({ message }, message);
}
const message = describeAgentError(error);
const isAuthProblem = isLikelyAuthError(error);
return isAuthProblem
? RequestError.authRequired({ message }, message)
: RequestError.internalError({ message }, message);
}
async function buildProviderConfigOption(
currentProviderId: string,
): Promise<SessionConfigOption> {
@@ -636,6 +913,7 @@ async function buildAllConfigOptions(
providerOption,
buildModelConfigOption(session.currentModelId, providerModels),
buildModeConfigOption(session.currentMode),
buildAutoApproveConfigOption(session.autoApproveTools),
];
}
+5 -1
View File
@@ -5,9 +5,13 @@ import { writeDiagnostic } from "../utils/output";
/**
* Supported ACP OAuth provider IDs.
*
* This list doubles as the set of selectable providers (see
* `setSessionConfigOption`)
*/
export const ACP_AUTH_METHODS = [
{ id: "cline", name: "Sign in with Cline" },
{ id: "cline-pass", name: "Sign in with ClinePass" },
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
] as const;
@@ -30,7 +34,7 @@ async function performOAuthLogin(input: {
providerSettingsManager: ProviderSettingsManager;
}): Promise<string> {
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
[import("@cline/core"), import("open")],
[import("@cline/core"), import("../utils/open")],
);
const callbacks = createOAuthClientCallbacks({
+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
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
buildOrganizationConfigOption,
PERSONAL_ACCOUNT_VALUE,
} from "./organizations";
describe("buildOrganizationConfigOption", () => {
const organizations = [
{
active: false,
memberId: "m-1",
name: "Acme Corp",
organizationId: "org-1",
roles: ["member" as const],
},
{
active: true,
memberId: "m-2",
name: "Cline Bot Inc",
organizationId: "org-2",
roles: ["admin" as const],
},
];
it("lists Personal first plus every organization", () => {
const option = buildOrganizationConfigOption({
organizations,
activeOrganizationId: "org-2",
});
expect(option.id).toBe("organization");
if (option.type !== "select") {
throw new Error(`expected a select option, got ${option.type}`);
}
expect(option.currentValue).toBe("org-2");
expect(option.options).toEqual([
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
{ value: "org-1", name: "Acme Corp" },
{ value: "org-2", name: "Cline Bot Inc" },
]);
});
it("selects Personal when no organization is active", () => {
const option = buildOrganizationConfigOption({
organizations,
activeOrganizationId: null,
});
expect(option.currentValue).toBe(PERSONAL_ACCOUNT_VALUE);
});
});
+154
View File
@@ -0,0 +1,154 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import {
type ClineAccountOrganization,
ClineAccountService,
getPersistedProviderApiKey,
type ProviderSettingsManager,
RuntimeOAuthTokenManager,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
export const PERSONAL_ACCOUNT_VALUE = "personal";
export const ORGANIZATION_CONFIG_ID = "organization";
export function usesClineAccount(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export interface AcpOrganizationState {
organizations: ClineAccountOrganization[];
/** Active organization id, or null when the personal account is active. */
activeOrganizationId: string | null;
}
interface ClineAccountInput {
apiKey: string;
providerSettingsManager: ProviderSettingsManager;
}
// Cline access tokens expire between runs, so account requests resolve
// through the refresh-aware OAuth manager. A single shared instance keeps
// refreshes single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let oauthTokenManager: RuntimeOAuthTokenManager | undefined;
function createAccountService(input: ClineAccountInput): ClineAccountService {
const { providerSettingsManager } = input;
const settings = providerSettingsManager.getProviderSettings("cline");
return new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => {
try {
oauthTokenManager ??= new RuntimeOAuthTokenManager({
providerSettingsManager,
});
const resolution = await oauthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch {
// Fall back to the persisted token; the account request surfaces
// the auth failure to the caller.
}
return (
getPersistedProviderApiKey(
"cline",
providerSettingsManager.getProviderSettings("cline"),
) ||
input.apiKey ||
undefined
);
},
});
}
export async function fetchClineOrganizations(
input: ClineAccountInput,
): Promise<AcpOrganizationState | undefined> {
try {
const service = createAccountService(input);
const organizations = await service.fetchUserOrganizations();
if (organizations.length === 0) {
return undefined;
}
return {
organizations,
activeOrganizationId:
organizations.find((org) => org.active)?.organizationId ?? null,
};
} catch {
return undefined;
}
}
export function buildOrganizationConfigOption(
state: AcpOrganizationState,
): SessionConfigOption {
return {
type: "select",
id: ORGANIZATION_CONFIG_ID,
name: "Account",
description:
"The Cline account usage is billed to — your personal account or an organization",
category: "account",
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
options: [
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
...state.organizations.map((org) => ({
value: org.organizationId,
name: org.name,
})),
],
};
}
export async function switchClineOrganization(
input: ClineAccountInput & { organizationId: string | null },
): Promise<void> {
const service = createAccountService(input);
await service.switchAccount(input.organizationId);
await persistActiveOrganization(input.providerSettingsManager, service);
}
// Re-persist the active organization so headless runs and the hub daemon
// attribute telemetry to the right account. Best-effort: the switch itself
// already succeeded server-side.
async function persistActiveOrganization(
manager: ProviderSettingsManager,
service: ClineAccountService,
): Promise<void> {
try {
const organizations = await service.fetchUserOrganizations();
const active = organizations.find((org) => org.active) ?? null;
const persisted = manager.getProviderSettings("cline");
if (!persisted) {
return;
}
manager.saveProviderSettings(
{
...persisted,
auth: {
...persisted.auth,
organizationId: active?.organizationId,
organizationName: active?.name,
memberId: active?.memberId,
},
},
{ setLastUsed: false },
);
} catch {
// Ignore; see above.
}
}
export function getAcpOrgSubscriptionMessage(): string {
return [
"Organization accounts cannot use ClinePass subscriptions.",
'Switch the "Account" session option to Personal to keep using ClinePass,',
'or switch the "Provider" option to Cline to bill your organization.',
].join(" ");
}
+261
View File
@@ -0,0 +1,261 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
import {
replaySessionHistory,
translateHistoricalMessage,
} from "./session-load";
describe("translateHistoricalMessage", () => {
it("maps string content to a message chunk for the right role", () => {
expect(translateHistoricalMessage({ role: "user", content: "hi" })).toEqual(
[
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "hi" },
},
],
);
expect(
translateHistoricalMessage({ role: "assistant", content: "hello" }),
).toEqual([
{
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello" },
},
]);
});
it("strips the <user_input> wrapper from replayed user text", () => {
// Persisted user messages keep their runtime-generated wrapper. Replaying
// it verbatim leaked markup to the client, which rendered the unknown
// element as bare text (a one-word prompt showed up as just its content
// with the wrapper swallowed).
expect(
translateHistoricalMessage({
role: "user",
content: '<user_input mode="act">s</user_input>',
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "s" },
},
]);
expect(
translateHistoricalMessage({
role: "user",
content: [
{
type: "text",
text: '<user_input mode="plan">lets do it</user_input>',
},
],
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "lets do it" },
},
]);
});
it("strips mode notices and formats slash commands for display", () => {
expect(
translateHistoricalMessage({
role: "user",
content:
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "are you okay?" },
},
]);
expect(
translateHistoricalMessage({
role: "user",
content:
'<user_command slash="team">spawn a team of agents for the following task: inspect rpc startup</user_command>',
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "/team inspect rpc startup" },
},
]);
});
it("does not replay the synthetic act-mode continuation prompt", () => {
expect(
translateHistoricalMessage({
role: "user",
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
}),
).toEqual([]);
});
it("leaves assistant text untouched", () => {
// Only user text carries the wrapper; agent output must replay verbatim.
expect(
translateHistoricalMessage({
role: "assistant",
content: 'Use <user_input mode="act"> to wrap prompts.',
}),
).toEqual([
{
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: 'Use <user_input mode="act"> to wrap prompts.',
},
},
]);
});
it("skips empty text and unknown blocks", () => {
expect(
translateHistoricalMessage({
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "redacted_thinking", data: "xxx" },
],
}),
).toEqual([]);
});
it("maps thinking blocks to agent_thought_chunk", () => {
expect(
translateHistoricalMessage({
role: "assistant",
content: [{ type: "thinking", thinking: "pondering" }],
}),
).toEqual([
{
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "pondering" },
},
]);
});
it("maps tool_use to a pending tool_call", () => {
const updates = translateHistoricalMessage({
role: "assistant",
content: [
{
type: "tool_use",
id: "call-1",
name: "read_files",
input: { file_paths: ["a.ts"] },
},
],
});
expect(updates).toHaveLength(1);
expect(updates[0]).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "call-1",
kind: "read",
status: "pending",
rawInput: { file_paths: ["a.ts"] },
});
});
it("maps tool_result to a tool_call_update with flattened output", () => {
expect(
translateHistoricalMessage({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call-1",
name: "read_files",
content: [
{ type: "text", text: "line one" },
{ type: "image", data: "abc", mediaType: "image/png" },
],
},
],
}),
).toEqual([
{
sessionUpdate: "tool_call_update",
toolCallId: "call-1",
status: "completed",
rawOutput: "line one\n[image]",
},
]);
});
it("marks errored tool results as failed", () => {
const [update] = translateHistoricalMessage({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call-2",
name: "run_commands",
content: "boom",
is_error: true,
},
],
});
expect(update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "call-2",
status: "failed",
rawOutput: "boom",
});
});
it("maps image blocks to image content chunks", () => {
expect(
translateHistoricalMessage({
role: "user",
content: [{ type: "image", data: "abc", mediaType: "image/png" }],
}),
).toEqual([
{
sessionUpdate: "user_message_chunk",
content: { type: "image", data: "abc", mimeType: "image/png" },
},
]);
});
});
describe("replaySessionHistory", () => {
it("sends one awaited notification per update, in order", async () => {
const sent: unknown[] = [];
const conn = {
sessionUpdate: vi.fn(async (notification: unknown) => {
sent.push(notification);
}),
} as unknown as AgentSideConnection;
await replaySessionHistory(conn, "sess-1", [
{ role: "user", content: "question" },
{ role: "assistant", content: "answer" },
]);
expect(sent).toEqual([
{
sessionId: "sess-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "question" },
},
},
{
sessionId: "sess-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "answer" },
},
},
]);
});
});
+141
View File
@@ -0,0 +1,141 @@
import type {
AgentSideConnection,
SessionUpdate,
} from "@agentclientprotocol/sdk";
import {
type ContentBlock,
formatDisplayUserInput,
type Message,
type ToolResultContent,
} from "@cline/shared";
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
* The act-mode continuation prompt is runtime-generated, not typed by the
* user, so it must not replay as a user turn. Mirrors the TUI transcript
* hydration filter in tui/utils/hydrate-messages.ts.
*/
function isSyntheticUserText(text: string): boolean {
return text === ACT_MODE_CONTINUATION_PROMPT;
}
/**
* Replay a persisted conversation to the client as session/update
* notifications. Used by `session/load` — the ACP spec requires the entire
* conversation to be replayed before the load request resolves, so each
* notification is awaited.
*/
export async function replaySessionHistory(
conn: AgentSideConnection,
sessionId: string,
messages: Message[],
): Promise<void> {
for (const message of messages) {
for (const update of translateHistoricalMessage(message)) {
await conn.sessionUpdate({ sessionId, update });
}
}
}
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
const blocks: ContentBlock[] =
typeof message.content === "string"
? [{ type: "text", text: message.content }]
: message.content;
const updates: SessionUpdate[] = [];
for (const block of blocks) {
switch (block.type) {
case "text": {
if (!block.text) break;
if (message.role !== "user") {
updates.push({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: block.text },
});
break;
}
// Display boundary: persisted user text keeps its runtime-generated
// <user_input mode="..."> wrapper and <mode_notice> elements (they are
// the durable record of the mode each turn was sent in). Replaying them
// verbatim leaks markup to the client, which renders the unknown
// element as bare text — so `s` shows up as `s` with the wrapper
// swallowed. Strip them the same way every other surface does.
const text = formatDisplayUserInput(block.text);
if (!text || isSyntheticUserText(text)) break;
updates.push({
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
});
break;
}
case "thinking": {
if (!block.thinking) break;
updates.push({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: block.thinking },
});
break;
}
case "image": {
const content = {
type: "image" as const,
data: block.data,
mimeType: block.mediaType,
};
updates.push(
message.role === "user"
? { sessionUpdate: "user_message_chunk", content }
: { sessionUpdate: "agent_message_chunk", content },
);
break;
}
case "tool_use": {
updates.push({
sessionUpdate: "tool_call",
toolCallId: block.id,
title: buildToolTitle(block.name, block.input),
kind: mapToolKind(block.name),
status: "pending",
rawInput: block.input,
});
break;
}
case "tool_result": {
updates.push({
sessionUpdate: "tool_call_update",
toolCallId: block.tool_use_id,
status: block.is_error ? "failed" : "completed",
rawOutput: flattenToolResultContent(block.content),
});
break;
}
default:
break;
}
}
return updates;
}
function flattenToolResultContent(
content: ToolResultContent["content"],
): string {
if (typeof content === "string") {
return content;
}
return content
.map((part) => {
switch (part.type) {
case "text":
return part.text;
case "file":
return part.content;
default:
return "[image]";
}
})
.join("\n");
}
+6
View File
@@ -4,6 +4,7 @@ import type {
SessionUpdate,
} from "@agentclientprotocol/sdk";
import type { AgentEvent } from "@cline/core";
import { getErrorMessage } from "@cline/shared";
import { buildToolTitle, mapToolKind } from "./tool-utils";
/**
@@ -81,6 +82,11 @@ function translateContentStart(
}
}
export function describeAgentError(error: unknown): string {
const message = getErrorMessage(error).trim();
return message || "The agent reported an unknown error.";
}
function translateContentEnd(
event: AgentEvent & { type: "content_end" },
): SessionUpdate[] {
+1 -1
View File
@@ -220,7 +220,7 @@ describe("cli interactive e2e", () => {
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
"seed history session",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
+244
View File
@@ -0,0 +1,244 @@
// ---------------------------------------------------------------------------
// Proof-of-concept: driving the interactive TUI with tuistory
// (https://github.com/remorses/tuistory) instead of `script` + timed printf.
//
// Compare with `cli.interactive.e2e.test.ts`, which pipes keystrokes through
// the Unix `script` utility on a fixed sleep schedule and greps the raw
// output dump. Here each test launches the CLI in a real PTY backed by a
// Ghostty terminal emulator, waits reactively for screen content
// (`waitForText` resolves as soon as the text renders), and asserts against
// the emulated screen state rather than the raw byte stream.
//
// Run with: bun run test:e2e:tuistory
// ---------------------------------------------------------------------------
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { launchTerminal, type Session } from "tuistory";
import { afterEach, describe, expect, it } from "vitest";
const cliRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(cliRoot, "src", "index.ts");
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
const LAUNCH_TIMEOUT_MS = 30_000;
const UI_TIMEOUT_MS = 15_000;
const tempDirs: string[] = [];
const sessions: Session[] = [];
function createCliEnv(
overrides: Record<string, string | undefined> = {},
): Record<string, string | undefined> {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-data-"));
const sessionDir = mkdtempSync(
path.join(os.tmpdir(), "cli-tuistory-sessions-"),
);
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-tuistory-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
CLINE_TELEMETRY_DISABLED: "1",
CLINE_NO_AUTO_UPDATE: "1",
// Without this, the ClinePass promo dialog renders over the chat view.
// The stream-grepping interactive suite doesn't notice the overlay, but
// tuistory's screen snapshot reflects what the user actually sees.
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
// The parent vitest process sets CI/VITEST; clear them so the spawned
// CLI renders as a real interactive terminal.
CI: undefined,
VITEST: undefined,
...overrides,
};
}
async function launchCli(
extraArgs: string[] = [],
env: Record<string, string | undefined> = createCliEnv(),
): Promise<Session> {
const session = await launchTerminal({
command: bunExec,
args: [
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
...extraArgs,
],
cwd: cliRoot,
env,
cols: 120,
rows: 36,
// The CLI compiles a large TS graph on cold start; don't gate launch
// on the default 5s first-data timeout.
waitForDataTimeout: LAUNCH_TIMEOUT_MS,
});
sessions.push(session);
return session;
}
/** Wait for the chat view to be fully rendered. */
async function waitForChatView(session: Session): Promise<void> {
await session.waitForText("What can I do for you?", {
timeout: LAUNCH_TIMEOUT_MS,
});
}
describe("cli tuistory e2e", () => {
afterEach(async () => {
for (const session of sessions.splice(0)) {
try {
// Double Ctrl+C exits the TUI cleanly (first press shows the
// "press again to exit" hint) before the PTY is torn down.
await session.press(["ctrl", "c"]);
await session.press(["ctrl", "c"]);
await session.waitIdle({ timeout: 3_000 });
} catch {
// Session may already be dead; close() below still cleans up.
}
session.close();
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("shows the interactive chat view on launch", async () => {
const session = await launchCli();
await waitForChatView(session);
const screen = await session.text({ trimEnd: true });
expect(screen).toContain("What can I do for you?");
expect(screen).toContain("○ Plan ● Act (Tab)");
expect(screen).toContain("Auto-approve all enabled (Shift+Tab)");
});
it("toggles plan/act mode with Tab", async () => {
const session = await launchCli();
await waitForChatView(session);
expect(await session.text()).toContain("○ Plan ● Act (Tab)");
await session.press("tab");
// Reactive wait: resolves as soon as the toggled indicator renders.
await session.waitForText("● Plan ○ Act (Tab)", {
timeout: UI_TIMEOUT_MS,
});
// Unlike stream-grepping, the emulated screen reflects current state:
// the old indicator is gone, not just buried in scrollback.
const screen = await session.text();
expect(screen).toContain("● Plan ○ Act (Tab)");
expect(screen).not.toContain("○ Plan ● Act (Tab)");
});
it("toggles auto-approve-all with Shift+Tab", async () => {
const session = await launchCli();
await waitForChatView(session);
expect(await session.text()).toContain(
"Auto-approve all enabled (Shift+Tab)",
);
await session.press(["shift", "tab"]);
await session.waitForText("Auto-approve all disabled (Shift+Tab)", {
timeout: UI_TIMEOUT_MS,
});
const screen = await session.text();
expect(screen).not.toContain("Auto-approve all enabled (Shift+Tab)");
});
it("opens /settings, navigates tabs, and closes with Escape", async () => {
const session = await launchCli();
await waitForChatView(session);
await session.type("/settings");
// Slash menu completion for the settings command.
await session.waitForText("Modify agent configuration", {
timeout: UI_TIMEOUT_MS,
});
// A single Enter accepts the highlighted completion and submits it.
// (The `script`-based suite pressed Enter twice with 250ms sleeps; with
// reactive key delivery the second Enter would leak into the settings
// view and activate the focused row.)
await session.press("enter");
await session.waitForText("←/→ switch tabs", { timeout: UI_TIMEOUT_MS });
const settingsScreen = await session.text();
expect(settingsScreen).toContain("Settings");
expect(settingsScreen).toContain("▸ Provider");
// Switch from the General tab to the MCP tab; the body swaps from the
// provider/model rows to MCP content.
await session.press("right");
await session.text({
waitFor: (text) => !text.includes("Compaction"),
timeout: UI_TIMEOUT_MS,
});
await session.press("escape");
await session.waitForText("Use / for slash commands", {
timeout: UI_TIMEOUT_MS,
});
expect(await session.text()).not.toContain("←/→ switch tabs");
});
it("launches config view directly with `cline config`", async () => {
const session = await launchCli(["config"]);
await session.waitForText("←/→ switch tabs", {
timeout: LAUNCH_TIMEOUT_MS,
});
const screen = await session.text();
expect(screen).toContain("Settings");
expect(screen).toContain("▸ Provider");
});
it("dismisses the ClinePass promo with any key and marks it as shown", async () => {
// Re-enable the promo dialog that the shared env suppresses.
const env = createCliEnv({ CLINE_DISABLE_CLINE_PASS_NOTICE: undefined });
const dataDir = env.CLINE_DATA_DIR as string;
const session = await launchCli([], env);
await session.waitForText("Try ClinePass", { timeout: LAUNCH_TIMEOUT_MS });
await session.waitForText("Press Enter to open, any other key to close", {
timeout: UI_TIMEOUT_MS,
});
// Any key other than Enter dismisses the dialog (Esc is unreliable in
// some terminals, notably on Windows).
await session.type("x");
await session.text({
waitFor: (text) => !text.includes("Try ClinePass"),
timeout: UI_TIMEOUT_MS,
});
const screen = await session.text();
expect(screen).toContain("What can I do for you?");
expect(screen).not.toContain("Open ClinePass");
// The "shown" marker is persisted once the dialog is dismissed so the
// promo doesn't reappear on the next launch.
const markerPath = path.join(dataDir, "settings", "cli-notices.json");
await session.waitIdle({ timeout: UI_TIMEOUT_MS });
expect(existsSync(markerPath)).toBe(true);
expect(readFileSync(markerPath, "utf8")).toContain(
'"cline-cli-cline-pass-intro": true',
);
});
});
+1 -1
View File
@@ -10,9 +10,9 @@ import {
saveProviderOAuthCredentials,
} from "@cline/core";
import { Command } from "commander";
import open from "open";
import React from "react";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import open from "../utils/open";
import {
getPersistedProviderApiKey,
isOAuthProvider,
+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,
+1 -1
View File
@@ -2,8 +2,8 @@ import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { configureSandboxEnvironment } from "../utils/helpers";
import open from "../utils/open";
import { c } from "../utils/output";
export interface DashboardServerHandle {
+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",
);
});
});
+132 -3
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,14 +16,16 @@ import {
type ActiveConnectorRecord,
formatUptime,
resolveClineBuildEnv,
type SupervisedConnectorRecord,
} from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import { getCliBuildInfo } from "../utils/common";
import 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) {
@@ -0,0 +1,94 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
const historyMocks = vi.hoisted(() => ({
runHistoryDelete: vi.fn(async () => 0),
runHistoryExport: vi.fn(async () => 0),
runHistoryList: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
vi.mock("./history", () => historyMocks);
import { registerHistoryCommand } from "./history-command";
function createHarness(isInteractiveTTY: boolean) {
const program = new Command()
.exitOverride()
.option("--json", "Output as JSON");
program.configureOutput({
writeOut: vi.fn(),
writeErr: vi.fn(),
});
const io = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
const setExitCode = vi.fn();
const setStartupTarget = vi.fn();
registerHistoryCommand({
program,
io,
setExitCode,
setStartupTarget,
isInteractiveTTY: () => isInteractiveTTY,
});
return { program, io, setExitCode, setStartupTarget };
}
describe("registerHistoryCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens the in-app history picker for an interactive text terminal", async () => {
const { program, setExitCode, setStartupTarget } = createHarness(true);
await program.parseAsync(["history"], { from: "user" });
expect(setStartupTarget).toHaveBeenCalledOnce();
expect(setStartupTarget).toHaveBeenCalledWith("history");
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
expect(setExitCode).not.toHaveBeenCalled();
});
it("keeps explicit JSON output non-interactive even when a TTY is attached", async () => {
const { program, io, setExitCode, setStartupTarget } = createHarness(true);
await program.parseAsync(["history", "--json"], { from: "user" });
expect(setStartupTarget).not.toHaveBeenCalled();
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
limit: 50,
outputMode: "json",
io,
});
expect(setExitCode).toHaveBeenCalledWith(0);
});
it("prints text history when no interactive terminal is attached", async () => {
const { program, io, setExitCode, setStartupTarget } = createHarness(false);
await program.parseAsync(["history", "--limit", "12"], { from: "user" });
expect(setStartupTarget).not.toHaveBeenCalled();
expect(historyMocks.runHistoryList).toHaveBeenCalledWith({
limit: 12,
outputMode: "text",
io,
});
expect(setExitCode).toHaveBeenCalledWith(0);
});
it("returns an error when delete is missing --session-id", async () => {
const { program, io, setExitCode } = createHarness(false);
await program.parseAsync(["history", "delete"], { from: "user" });
expect(io.writeErr).toHaveBeenCalledWith(
"history delete requires --session-id <id>",
);
expect(historyMocks.runHistoryDelete).not.toHaveBeenCalled();
expect(setExitCode).toHaveBeenCalledWith(1);
});
});
+117
View File
@@ -0,0 +1,117 @@
import type { Command } from "commander";
import type { TuiStartupTarget } from "../tui/types";
import type { CliOutputMode } from "../utils/types";
import {
runHistoryDelete,
runHistoryExport,
runHistoryList,
runHistoryUpdate,
} from "./history";
type HistoryCommandIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
};
type RegisterHistoryCommandOptions = {
program: Command;
io: HistoryCommandIo;
setExitCode: (code: number) => void;
setStartupTarget: (target: TuiStartupTarget) => void;
isInteractiveTTY?: () => boolean;
};
function resolveHistoryOutputMode(
program: Command,
historyCmd: Command,
): CliOutputMode {
return program.opts().json || historyCmd.opts().json ? "json" : "text";
}
export function registerHistoryCommand({
program,
io,
setExitCode,
setStartupTarget,
isInteractiveTTY = () =>
process.stdin.isTTY === true && process.stdout.isTTY === true,
}: RegisterHistoryCommandOptions): void {
const historyCmd = program
.command("history")
.alias("h")
.description("List session history or manage saved sessions")
.option("--json", "Output as JSON")
.option("--limit <count>", "Maximum number of sessions to show", "50")
.option("--page <number>", "Page number for paginated results")
.option("--config <dir>", "configuration directory")
.action(async () => {
const opts = historyCmd.opts();
const limit = Number.parseInt(opts.limit, 10);
const outputMode = resolveHistoryOutputMode(program, historyCmd);
if (outputMode === "text" && isInteractiveTTY()) {
setStartupTarget("history");
return;
}
setExitCode(
await runHistoryList({
limit,
outputMode,
io,
}),
);
});
const historyDeleteCmd = historyCmd
.command("delete")
.description("Delete a session from history")
.option("--session-id <id>", "Session ID to delete")
.action(async () => {
const opts = historyDeleteCmd.opts();
if (!opts.sessionId) {
io.writeErr("history delete requires --session-id <id>");
setExitCode(1);
return;
}
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(await runHistoryDelete(opts.sessionId, outputMode, io));
});
const historyUpdateCmd = historyCmd
.command("update")
.description("Update a session in history")
.option("--metadata <json>", "Metadata as JSON string")
.option("--prompt <text>", "New prompt text")
.option("--session-id <id>", "Session ID to update")
.option("--title <text>", "New title")
.action(async () => {
const opts = historyUpdateCmd.opts();
if (!opts.sessionId) {
io.writeErr("history update requires --session-id <id>");
setExitCode(1);
return;
}
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(
await runHistoryUpdate(
opts.sessionId,
opts.prompt,
opts.title,
opts.metadata,
outputMode,
io,
),
);
});
const historyExportCmd = historyCmd
.command("export <sessionId>")
.description("Export a session as a standalone HTML file")
.option("-o, --output <path>", "Output HTML file path")
.action(async (sessionId: string) => {
const opts = historyExportCmd.opts();
const outputMode = resolveHistoryOutputMode(program, historyCmd);
setExitCode(
await runHistoryExport(sessionId, opts.output, outputMode, io),
);
});
}
+44 -10
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SessionHistoryRecord } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { exportHistorySession } from "../session/history-export";
import {
formatCheckpointDetail,
formatHistoryListLine,
@@ -16,18 +17,12 @@ vi.mock("../session/session", () => ({
readSessionMessagesArtifact: vi.fn(),
}));
vi.mock("../tui/history-standalone", () => ({
renderHistoryStandalone: vi.fn(async () => 0),
}));
import { listSessions, readSessionMessagesArtifact } from "../session/session";
import { renderHistoryStandalone } from "../tui/history-standalone";
const mockedReadSessionMessagesArtifact = vi.mocked(
readSessionMessagesArtifact,
);
const mockedListSessions = vi.mocked(listSessions);
const mockedRenderHistoryStandalone = vi.mocked(renderHistoryStandalone);
function createHistoryRow(
overrides: Partial<SessionHistoryRecord> = {},
@@ -200,8 +195,11 @@ describe("runHistoryList", () => {
vi.clearAllMocks();
});
it("hydrates interactive history rows so titles can be inferred from messages", async () => {
const row = createHistoryRow({ prompt: undefined, metadata: undefined });
it("requests hydrated text history rows so titles can come from messages", async () => {
const row = createHistoryRow({
prompt: undefined,
metadata: { title: "hydrated title", totalCost: 0.25 },
});
mockedListSessions.mockResolvedValue([row]);
const io = {
writeln: vi.fn(),
@@ -218,8 +216,8 @@ describe("runHistoryList", () => {
expect(mockedListSessions).toHaveBeenCalledWith(25, {
hydrate: true,
});
expect(mockedRenderHistoryStandalone).toHaveBeenCalledWith(
expect.objectContaining({ rows: [row] }),
expect(io.writeln).toHaveBeenCalledWith(
expect.stringContaining("hydrated title"),
);
});
@@ -313,6 +311,42 @@ describe("runHistoryExport", () => {
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
});
it("writes structured JSON from a persisted messages artifact", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const artifact = {
version: 1,
updated_at: "2026-04-22T17:42:10.123Z",
sessionId: "sess_1",
systemPrompt: "Be helpful",
messages: [
{
id: "m1",
role: "user",
content: [{ type: "text", text: "hello" }],
},
{
id: "m2",
role: "assistant",
content: [{ type: "text", text: "world" }],
},
],
} satisfies NonNullable<
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
>;
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
const targetPath = await exportHistorySession({
sessionId: "sess_1",
format: "json",
outputDirectory: tempDir,
});
expect(targetPath).toBe(join(tempDir, "sess_1.json"));
await expect(
readFile(targetPath, "utf8").then((contents) => JSON.parse(contents)),
).resolves.toEqual(artifact);
});
it("exports run_commands history with structured command objects", async () => {
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
const outputPath = join(tempDir, "export.html");
+14 -46
View File
@@ -1,13 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { generateConversationHTML } from "../session/export";
import {
deleteSession,
listSessions,
readSessionMessagesArtifact,
updateSession,
} from "../session/session";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import { exportHistorySession } from "../session/history-export";
import { deleteSession, listSessions, updateSession } from "../session/session";
import { formatHistoryListLine } from "../utils/history-format";
import { writeln } from "../utils/output";
import type { CliOutputMode } from "../utils/types";
@@ -22,22 +15,6 @@ type HistoryIo = {
writeErr: (text: string) => void;
};
async function exportHistorySession(
sessionId: string,
outputPath?: string,
): Promise<string> {
const data = await readSessionMessagesArtifact(sessionId);
if (!data) {
throw new Error(`Session ${sessionId} not found or has no messages.json`);
}
const targetPath = resolve(outputPath?.trim() || `${sessionId}.html`);
const html = generateConversationHTML(data, sessionId);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, html, "utf8");
return targetPath;
}
async function runHistoryDelete(
sessionId: string | undefined,
outputMode: CliOutputMode,
@@ -136,7 +113,11 @@ async function runHistoryExport(
}
try {
const targetPath = await exportHistorySession(sessionId, outputPath);
const targetPath = await exportHistorySession({
sessionId,
format: "html",
outputPath,
});
if (outputMode === "json") {
process.stdout.write(
@@ -161,7 +142,7 @@ export async function runHistoryList(input: {
outputMode: CliOutputMode;
workspaceRoot?: string;
io?: HistoryIo;
}): Promise<number | string> {
}): Promise<number> {
const io = input.io ?? {
writeln,
writeErr: (text: string) => process.stderr.write(`${text}\n`),
@@ -186,23 +167,10 @@ export async function runHistoryList(input: {
return 0;
}
disableOpenTuiGraphicsProbe();
const { renderHistoryStandalone } = await import("../tui/history-standalone");
return await renderHistoryStandalone({
rows,
refreshRows: async () =>
await listSessions(limit, {
workspaceRoot: input.workspaceRoot,
hydrate: false,
}),
onExport: async (sessionId: string) =>
await exportHistorySession(sessionId, undefined),
});
for (const row of rows) {
io.writeln(formatHistoryListLine(row));
}
return 0;
}
export {
exportHistorySession,
runHistoryDelete,
runHistoryExport,
runHistoryUpdate,
};
export { runHistoryDelete, runHistoryExport, runHistoryUpdate };
+61
View File
@@ -0,0 +1,61 @@
import { relative, sep } from "node:path";
import {
resolveClineDataDir,
resolveClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createProgram } from "./program";
/** Render an absolute path under `home` the way help text does: `~/...`. */
function tildePath(absolutePath: string, home: string): string {
return `~/${relative(home, absolutePath).split(sep).join("/")}`;
}
describe("root option help text", () => {
const FAKE_HOME = "/home/cline-help-test";
const savedEnv: Record<string, string | undefined> = {};
beforeAll(() => {
// Pin the resolver inputs so the defaults below are the true defaults
// (no CLINE_DIR/CLINE_DATA_DIR overrides, known home directory).
for (const key of ["CLINE_DIR", "CLINE_DATA_DIR"]) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
setHomeDir(FAKE_HOME);
});
afterAll(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("reports the actual resolver defaults for --config and --data-dir", () => {
// A wide help width keeps each option description on one line so the
// full default text can be matched.
const help = createProgram()
.configureHelp({ helpWidth: 500 })
.helpInformation();
const configDefault = tildePath(resolveClineDir(), FAKE_HOME);
const dataDirDefault = tildePath(resolveClineDataDir(), FAKE_HOME);
// Sanity-check the resolvers themselves so the assertions below can't
// silently drift along with a resolver regression.
expect(configDefault).toBe("~/.cline");
expect(dataDirDefault).toBe("~/.cline/data");
expect(help).toContain(
`Configuration directory (default: ${configDefault})`,
);
expect(help).toContain(
`Use isolated local state at this directory path (default: ${dataDirDefault})`,
);
});
});
+2 -5
View File
@@ -64,13 +64,10 @@ export function addRootOptions(cmd: Command): Command {
"--acp",
"Run in Agent Client Protocol (ACP) mode for editor integration",
)
.option(
"--config <path>",
"Configuration directory (default: ~/.cline/data/settings)",
)
.option("--config <path>", "Configuration directory (default: ~/.cline)")
.option(
"--data-dir <path>",
"Use isolated local state at this directory path (default: ~/.cline)",
"Use isolated local state at this directory path (default: ~/.cline/data)",
)
.option(
"--hooks-dir <path>",
+93
View File
@@ -1,10 +1,31 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
mockEnsureCliHubServer: vi.fn(),
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawn: mockSpawn,
};
});
vi.mock("../utils/hub-runtime", () => ({
ensureCliHubServer: mockEnsureCliHubServer,
}));
import {
autoUpdateOnStartup,
checkForUpdates,
ensureCliHubServerAfterUpdate,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
@@ -21,6 +42,14 @@ const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createChildProcessThatCloses(exitCode: number): ChildProcess {
const child = new EventEmitter();
queueMicrotask(() => {
child.emit("close", exitCode);
});
return child as ChildProcess;
}
function createFile(path: string): string {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, "");
@@ -236,6 +265,70 @@ describe("hub restart owner selection", () => {
});
});
describe("post-update hub launch", () => {
afterEach(() => {
mockEnsureCliHubServer.mockReset();
mockSpawn.mockReset();
});
it("uses the freshly installed wrapper instead of the current executable", async () => {
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
const env = {
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
CLINE_NO_AUTO_UPDATE: "0",
};
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
expect(mockSpawn).toHaveBeenCalledWith(
"/opt/cline/lib/node_modules/cline/bin/cline",
["hub", "ensure"],
{
cwd: "/workspace/project",
env: {
...env,
CLINE_NO_AUTO_UPDATE: "1",
},
stdio: "ignore",
windowsHide: true,
},
);
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
});
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
await ensureCliHubServerAfterUpdate(
"C:\\workspace\\project",
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
"win32",
);
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
"C:\\workspace\\project",
);
expect(mockSpawn).not.toHaveBeenCalled();
});
it("surfaces a failure from the freshly installed CLI", async () => {
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
await expect(
ensureCliHubServerAfterUpdate(
"/workspace/project",
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
"linux",
),
).rejects.toThrow(
"freshly installed Cline failed to start the hub (exit code 1)",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
+45 -1
View File
@@ -237,6 +237,50 @@ async function runKanbanUpdate(
return waitForProcessExit(updateProcess);
}
/**
* Start the hub through the freshly installed CLI after a self-update.
*
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
* remove that cached executable while replacing the package and then block the
* postinstall script that recreates it. The current process keeps running from
* the unlinked executable, but process.execPath is no longer spawnable. Going
* back through the wrapper makes it resolve the newly installed platform
* binary instead.
*
* Windows does not create the bin/.cline cache, and development builds do not
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
* ensure path.
*/
export async function ensureCliHubServerAfterUpdate(
workspaceRoot: string,
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<void> {
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
if (!wrapperPath || platform === "win32") {
await ensureCliHubServer(workspaceRoot);
return;
}
const child = spawn(wrapperPath, ["hub", "ensure"], {
cwd: workspaceRoot,
env: {
...env,
// The fresh CLI only exists to start the hub. Do not let it launch
// another background update check while this update is finishing.
CLINE_NO_AUTO_UPDATE: "1",
},
stdio: "ignore",
windowsHide: true,
});
const exitCode = await waitForProcessExit(child);
if (exitCode !== 0) {
throw new Error(
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
);
}
}
function formatUpdateSummaryTargets(targets: string[]): string {
if (targets.length === 0) {
return "";
@@ -342,7 +386,7 @@ async function restartHubServerIfRunning(): Promise<void> {
// Re-ensure a fresh hub instance is spawned.
try {
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
await ensureCliHubServerAfterUpdate(process.cwd());
writeln(`${c.green}${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
} catch (err) {
writeErr(
+78 -59
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,9 @@ 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 () => {
try {
await handleConnectorUserTurn({
@@ -1152,6 +1172,7 @@ class DiscordConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
resolveMuteTarget: ({ target }) => resolveDiscordMuteTarget(target),
createEmptyRuntimeReplyResolver:
@@ -1291,9 +1312,7 @@ class DiscordConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+62 -47
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,9 @@ 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 () => {
try {
await handleConnectorUserTurn({
@@ -637,6 +653,7 @@ class GoogleChatConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -698,9 +715,7 @@ class GoogleChatConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+62 -47
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,9 @@ 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 () => {
try {
await handleConnectorUserTurn({
@@ -662,6 +678,7 @@ class LinearConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -723,9 +740,7 @@ class LinearConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
@@ -338,6 +338,88 @@ describe("slack binding lookup", () => {
expect(calls).toEqual(["get:T123", "token:xoxb-team-token", "work"]);
});
it("strips the leading bot mention from Slack message text", () => {
expect(
__test__.stripSlackBotMention("@U0B8E8H3U1F hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention("<@U0B8E8H3U1F> hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention("<@U0B8E8H3U1F|cline> hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention(" @U0B8E8H3U1F: hi", "U0B8E8H3U1F"),
).toBe("hi");
expect(
__test__.stripSlackBotMention(
"@U0B8E8H3U1F @U0B8E8H3U1F hi",
"U0B8E8H3U1F",
),
).toBe("hi");
});
it("keeps Slack text that does not start with the bot mention", () => {
expect(
__test__.stripSlackBotMention("hi @U0B8E8H3U1F", "U0B8E8H3U1F"),
).toBe("hi @U0B8E8H3U1F");
expect(__test__.stripSlackBotMention("@U999999 hi", "U0B8E8H3U1F")).toBe(
"@U999999 hi",
);
expect(__test__.stripSlackBotMention("@cline hi", "U0B8E8H3U1F")).toBe(
"@cline hi",
);
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F hi", undefined)).toBe(
"@U0B8E8H3U1F hi",
);
});
it("keeps mentions of other Slack users whose id starts with the bot id", () => {
expect(__test__.stripSlackBotMention("@U1234 help", "U123")).toBe(
"@U1234 help",
);
expect(__test__.stripSlackBotMention("@U123 hi", "U123")).toBe("hi");
expect(__test__.stripSlackBotMention("<@U1234> help", "U123")).toBe(
"<@U1234> help",
);
expect(__test__.stripSlackBotMention("<@U1234|other> help", "U123")).toBe(
"<@U1234|other> help",
);
expect(
__test__.stripSlackBotMention("@U0B8E8H3U1FX hi", "U0B8E8H3U1F"),
).toBe("@U0B8E8H3U1FX hi");
expect(
__test__.stripSlackBotMention(
"@U0B8E8H3U1F @U0B8E8H3U1FX hi",
"U0B8E8H3U1F",
),
).toBe("@U0B8E8H3U1FX hi");
});
it("keeps a bare Slack bot mention so the turn is not dropped", () => {
expect(__test__.stripSlackBotMention("@U0B8E8H3U1F", "U0B8E8H3U1F")).toBe(
"@U0B8E8H3U1F",
);
expect(
__test__.stripSlackBotMention("<@U0B8E8H3U1F> ", "U0B8E8H3U1F"),
).toBe("<@U0B8E8H3U1F> ");
});
it("resolves the Slack bot user id from the adapter or event authorizations", () => {
expect(__test__.resolveSlackBotUserId({ botUserId: "U0B8E8H3U1F" })).toBe(
"U0B8E8H3U1F",
);
expect(
__test__.resolveSlackBotUserId(
{ botUserId: undefined },
{ authorizations: [{ user_id: "U0B8E8H3U1F" }] },
),
).toBe("U0B8E8H3U1F");
expect(
__test__.resolveSlackBotUserId({ botUserId: undefined }, { text: "hi" }),
).toBeUndefined();
});
it("detects Slack invalid_thread_ts errors", () => {
expect(
__test__.isSlackInvalidThreadTsError(
@@ -351,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 });
}
});
});
+189 -84
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;
};
@@ -206,6 +201,60 @@ function extractSlackChannelFromId(id: string): string | undefined {
return parts[0] === "slack" ? readString(parts[1]) : undefined;
}
/**
* Slack delivers `@cline hi` as `<@U0B8E8H3U1F> hi`, and the chat SDK
* deliberately leaves the bot's own mention unresolved (so mention detection
* keeps working), flattening it to `@U0B8E8H3U1F hi`. Strip that leading
* self-mention so the agent receives `hi`.
*
* Only leading mentions of the bot itself are removed; mentions of other users
* (already resolved to `@display-name`) and inline mentions are preserved so
* the agent still sees who was addressed. A bare mention with no other content
* is left untouched so the turn still reaches the agent instead of being
* dropped as empty input.
*/
function stripSlackBotMention(
text: string,
botUserId: string | undefined,
): string {
const botId = botUserId?.trim();
if (!botId || !text) {
return text;
}
const escapedBotId = botId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Matches `<@U123>`, `<@U123|name>` and the SDK-flattened `@U123` form,
// repeated when a user mentions the bot more than once up front.
//
// The angle-bracket forms are delimited by `>`, but the flattened form has no
// closing delimiter, so it needs an explicit boundary. Without one, `@U123`
// also matches the start of a longer id belonging to someone else, turning
// `@U1234 help` into `4 help`. Slack ids are uppercase alphanumeric, so a
// complete mention is one that is not followed by another id character.
// `\b` cannot express this: ids end in word characters, so `@U123\b` still
// matches inside `@U1234`.
const leadingMention = new RegExp(
`^(?:\\s*(?:<@${escapedBotId}(?:\\|[^<>]*)?>|@${escapedBotId}(?![A-Za-z0-9]))[\\s,:]*)+`,
);
const stripped = text.replace(leadingMention, "");
return stripped.trim() ? stripped.trimStart() : text;
}
/**
* The adapter exposes the authenticated bot user id (request-scoped in
* multi-workspace mode). When it is not yet known, fall back to the id Slack
* reports as the authorized app user on the event envelope.
*/
function resolveSlackBotUserId(
slack: Pick<SlackAdapter, "botUserId">,
rawMessage?: unknown,
): string | undefined {
const raw = asRecord(rawMessage);
return (
readString(slack.botUserId) ??
readString(firstRecord(raw?.authorizations)?.user_id)
);
}
function resolveSlackChannelMentionThread(
thread: Thread<SlackThreadState>,
message: Message,
@@ -447,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 {
@@ -523,6 +578,7 @@ class SlackConnector extends ConnectorBase<
mode?: string;
interactive?: boolean;
enableTools?: boolean;
tools?: boolean;
rpcAddress?: string;
hookCommand?: string;
port?: string;
@@ -589,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() ||
@@ -628,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",
),
@@ -679,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[],
@@ -689,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,
@@ -705,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:
@@ -719,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",
@@ -806,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,
@@ -813,7 +908,7 @@ class SlackConnector extends ConnectorBase<
...(options.connectionMode === "webhook"
? { port: options.port, baseUrl: options.baseUrl }
: {}),
startedAt: new Date().toISOString(),
startedAt,
});
let stopping = false;
@@ -838,7 +933,9 @@ 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 () => {
try {
await withSlackTeamBotToken({
@@ -863,10 +960,10 @@ class SlackConnector extends ConnectorBase<
hookCommand: options.hookCommand,
systemRules: SLACK_SYSTEM_RULES,
errorLabel: "Slack",
firstContactMessage: SLACK_FIRST_CONTACT_MESSAGE,
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (
currentThread,
@@ -951,9 +1048,7 @@ class SlackConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
@@ -966,10 +1061,14 @@ class SlackConnector extends ConnectorBase<
rawMessage: message.raw,
errorLabel: "Slack",
});
const text = stripSlackBotMention(
message.text,
resolveSlackBotUserId(slack, message.raw),
);
if (
await maybeHandleConnectorApprovalReply({
thread: mentionThread,
text: message.text,
text,
client,
clientId,
pendingApprovals,
@@ -978,7 +1077,7 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(mentionThread, message.text);
await handleTurn(mentionThread, text);
});
bot.onSubscribedMessage(async (thread, message) => {
@@ -989,10 +1088,14 @@ class SlackConnector extends ConnectorBase<
rawMessage: message.raw,
errorLabel: "Slack",
});
const text = stripSlackBotMention(
message.text,
resolveSlackBotUserId(slack, message.raw),
);
if (
await maybeHandleConnectorApprovalReply({
thread,
text: message.text,
text,
client,
clientId,
pendingApprovals,
@@ -1001,7 +1104,7 @@ class SlackConnector extends ConnectorBase<
) {
return;
}
await handleTurn(thread, message.text);
await handleTurn(thread, text);
});
bot.onSlashCommand(async (event) => {
@@ -1214,7 +1317,9 @@ export const __test__ = {
buildSlackParticipantKey,
resolveSlackParticipant,
normalizeSlackMessageEventChannelType,
resolveSlackBotUserId,
resolveSlackChannelMentionThread,
stripSlackBotMention,
withSlackTeamBotToken,
isSlackInvalidThreadTsError,
findBindingForThread: (
@@ -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." });
});
});
+123 -45
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,9 @@ 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 () => {
try {
await handleConnectorUserTurn({
@@ -841,6 +910,7 @@ class TelegramConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
forceDisableTools: !options.enableTools,
postFinalReply: async ({
@@ -952,9 +1022,7 @@ class TelegramConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
@@ -1006,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>({
@@ -1150,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);
}
});
});
+64 -46
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,9 @@ 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 () => {
try {
await handleConnectorUserTurn({
@@ -633,6 +652,7 @@ class WhatsAppConnector extends ConnectorBase<
userInstructionService,
chatCommandHost,
activeTurns,
enqueueTurn,
turnKey: queueKey,
getSessionMetadata: (currentThread, _clientId, currentState) => ({
userName: options.userName,
@@ -709,9 +729,7 @@ class WhatsAppConnector extends ConnectorBase<
await runTurn();
return;
}
await enqueueThreadTurn(threadQueues, queueKey, async () => {
await runTurn();
});
await enqueueTurn(runTurn);
};
bot.onNewMention(async (thread, message) => {
+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 });
+482 -10
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { enqueueThreadTurn } from "./chat-runtime";
import { handleConnectorUserTurn } from "./connector-host";
vi.mock("./hooks", () => ({
@@ -43,7 +44,22 @@ function createThread(initialState: TestState = {}, isDM = true) {
state = { ...nextState };
},
async post(message: unknown) {
posts.push(message);
// Real thread implementations drain async-iterable replies; the
// connector streams runtime output straight into post() for
// transports without a custom final-reply hook.
if (
message &&
typeof message === "object" &&
Symbol.asyncIterator in message
) {
let streamed = "";
for await (const chunk of message as AsyncIterable<string>) {
streamed += chunk;
}
posts.push(streamed);
} else {
posts.push(message);
}
const sentMessage = {
edit: async (nextMessage: unknown) => {
posts.push(nextMessage);
@@ -99,13 +115,21 @@ function createRuntimeClient(
);
const abortRuntimeSession = vi.fn(async () => undefined);
const deleteSession = vi.fn(async () => undefined);
const sendRuntimeSession = vi.fn(async () => ({
result: {
text: responseText,
finishReason: "stop",
iterations: 1,
},
}));
const sendRuntimeSession = vi.fn(
async (
_sessionId: string,
_request?: unknown,
_options?: unknown,
): Promise<{
result?: { text: string; finishReason: string; iterations: number };
}> => ({
result: {
text: responseText,
finishReason: "stop",
iterations: 1,
},
}),
);
const readMessages = vi.fn(async () => messages);
return {
client: {
@@ -138,6 +162,10 @@ function messageText(message: unknown): string {
return String(message);
}
async function runTurnImmediately(work: () => Promise<void>): Promise<void> {
await work();
}
describe("handleConnectorUserTurn", () => {
const tempDirs: string[] = [];
@@ -150,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);
@@ -542,6 +616,391 @@ describe("handleConnectorUserTurn", () => {
});
});
it("recovers from a stale thread session mapping by starting a new session", async () => {
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: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("recovered reply");
// The hub still reports the persisted session row, so the connector reuses
// the stale mapping...
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
// ...but sending input to the dead session fails with session_not_found
// until a fresh session id is used.
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "fresh-session",
});
runtime.sendRuntimeSession.mockImplementation(async (sessionId: string) => {
if (sessionId === "dead-session") {
throw Object.assign(new Error("session not found: dead-session"), {
code: "session_not_found",
});
}
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(["dead-session", "fresh-session"]);
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
expect(getState().sessionId).toBe("fresh-session");
expect(messageText(posts.at(-1))).toBe("recovered reply");
expect(
posts.some((message) =>
messageText(message).includes("session not found"),
),
).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);
const bindingsPath = join(dir, "threads.json");
const { thread } = createThread({
enableTools: false,
autoApproveTools: false,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
sessionId: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("never delivered");
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "also-dead-session",
});
runtime.sendRuntimeSession.mockImplementation(async () => {
throw Object.assign(new Error("session not found"), {
code: "session_not_found",
});
});
await expect(
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",
}),
).rejects.toThrow(/session not found/);
expect(runtime.sendRuntimeSession).toHaveBeenCalledTimes(2);
});
it("starts a new session when steering an active turn hits a dead session", async () => {
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: "dead-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 === "dead-session") {
throw Object.assign(new Error("session not found: dead-session"), {
code: "session_not_found",
});
}
return {
result: {
text: "recovered reply",
finishReason: "stop",
iterations: 1,
},
};
});
const activeTurns = new Map([
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
]);
await handleConnectorUserTurn({
thread: thread as never,
text: "actually do this instead",
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",
activeTurns: activeTurns as never,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
expect(getState().sessionId).toBe("fresh-session");
expect(messageText(posts.at(-1))).toBe("recovered reply");
expect(
posts.some((message) =>
messageText(message).includes("Steering current task."),
),
).toBe(false);
});
it("serializes concurrent recovery from the same stale active turn", async () => {
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: "dead-session",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("unused");
runtime.getSession.mockImplementation(async (sessionId: string) => ({
sessionId,
}));
runtime.startRuntimeSession.mockResolvedValue({
sessionId: "fresh-session",
});
let releaseStaleSteers = () => {};
const bothStaleSteersStarted = new Promise<void>((resolve) => {
releaseStaleSteers = resolve;
});
let staleSteerCount = 0;
runtime.sendRuntimeSession.mockImplementation(
async (sessionId: string, request?: unknown) => {
if (sessionId === "dead-session") {
staleSteerCount += 1;
if (staleSteerCount === 2) {
releaseStaleSteers();
}
await bothStaleSteersStarted;
throw Object.assign(new Error("session not found: dead-session"), {
code: "session_not_found",
});
}
const prompt =
request && typeof request === "object" && "prompt" in request
? String((request as { prompt?: unknown }).prompt)
: "";
return {
result: {
text: `recovered: ${prompt}`,
finishReason: "stop",
iterations: 1,
},
};
},
);
const activeTurns = new Map([
["thread-1", { sessionId: "dead-session", threadId: "thread-1" }],
]);
const threadQueues = new Map<string, Promise<void>>();
const enqueueTurn = (work: () => Promise<void>) =>
enqueueThreadTurn(threadQueues, "thread-1", work);
const commonInput = {
thread: thread as never,
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",
activeTurns,
enqueueTurn,
turnKey: "thread-1",
};
await Promise.all([
handleConnectorUserTurn({
...commonInput,
text: "first recovery message",
}),
handleConnectorUserTurn({
...commonInput,
text: "second recovery message",
}),
]);
expect(runtime.startRuntimeSession).toHaveBeenCalledTimes(1);
expect(
runtime.sendRuntimeSession.mock.calls.map((call) => call[0]),
).toEqual([
"dead-session",
"dead-session",
"fresh-session",
"fresh-session",
]);
expect(getState().sessionId).toBe("fresh-session");
expect(activeTurns.size).toBe(0);
expect(posts.map(messageText)).toEqual([
"recovered: first recovery message",
"recovered: second recovery message",
]);
});
it("creates schedules with forced-disabled runtime options", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
@@ -977,6 +1436,7 @@ describe("handleConnectorUserTurn", () => {
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "current-participant",
});
@@ -1225,6 +1685,7 @@ describe("handleConnectorUserTurn", () => {
reusedLogMessage: "reused",
startedLogMessage: "started",
activeTurns,
enqueueTurn: runTurnImmediately,
resolveMuteTarget: () => ({
participantKey: "discord:user:bob",
participantLabel: "<@bob>",
@@ -1426,6 +1887,7 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
});
expect(runtime.startRuntimeSession).not.toHaveBeenCalled();
@@ -1436,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 () => {
@@ -1476,6 +1942,7 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
@@ -1487,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 () => {
@@ -1526,6 +1997,7 @@ describe("handleConnectorUserTurn", () => {
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
enqueueTurn: runTurnImmediately,
turnKey: "thread-1",
});
+248 -59
View File
@@ -6,6 +6,7 @@ import type {
HubSessionClient,
UserInstructionConfigService,
} 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";
@@ -28,6 +29,7 @@ import {
import {
buildThreadStartRequest,
clearSession,
forgetThreadSession,
getOrCreateSessionId,
} from "./session-runtime";
import {
@@ -51,6 +53,20 @@ export type ActiveConnectorTurn = {
participantKey?: string;
};
type ConnectorTurnQueue = (work: () => Promise<void>) => Promise<void>;
type ConnectorTurnCoordination =
| {
activeTurns: Map<string, ActiveConnectorTurn>;
enqueueTurn: ConnectorTurnQueue;
turnKey?: string;
}
| {
activeTurns?: undefined;
enqueueTurn?: undefined;
turnKey?: string;
};
type EmptyRuntimeReplyResolver = () => Promise<string | undefined>;
type EmptyRuntimeReplyResolverFactory = (input: {
@@ -135,6 +151,45 @@ async function postConnectorRuntimeReply<TState extends ConnectorThreadState>(
await postConnectorText(thread, transport, text);
}
/**
* Clears a thread's stale session mapping after the hub reported the mapped
* session no longer exists, so the next turn starts a fresh session instead of
* failing forever against a dead session id.
*/
async function forgetStaleThreadSession<
TState extends ConnectorThreadState,
>(input: {
thread: Thread<TState>;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
errorLabel: string;
logger: CliLoggerAdapter;
transport: string;
sessionId: string;
}): Promise<boolean> {
const forgotten = await forgetThreadSession({
thread: input.thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
expectedSessionId: input.sessionId,
});
if (!forgotten) {
return false;
}
input.logger.core.log(
"Connector thread session no longer exists; starting a new session",
{
severity: "warn",
transport: input.transport,
threadId: input.thread.id,
channelId: input.thread.channelId,
sessionId: input.sessionId,
},
);
return true;
}
function applyForcedToolDisable<TState extends ConnectorThreadState>(
state: TState,
forceDisableTools: boolean | undefined,
@@ -200,9 +255,7 @@ function formatMuteTargetList(targets: ConnectorMuteTarget[]): string {
return targets.map(formatMuteTargetLabel).join(", ");
}
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: {
type ConnectorUserTurnInput<TState extends ConnectorThreadState> = {
thread: Thread<TState>;
text: string;
runtimeText?: string;
@@ -231,8 +284,6 @@ export async function handleConnectorUserTurn<
firstContactMessage?: string | ((currentState: TState) => string | undefined);
chatCommandHost?: ChatCommandHost;
userInstructionService?: UserInstructionConfigService;
activeTurns?: Map<string, ActiveConnectorTurn>;
turnKey?: string;
resolveMuteTarget?: (input: {
target: string;
thread: Thread<TState>;
@@ -276,7 +327,11 @@ export async function handleConnectorUserTurn<
threadId: string;
error: Error;
}) => Promise<void>;
}): Promise<void> {
} & ConnectorTurnCoordination;
export async function handleConnectorUserTurn<
TState extends ConnectorThreadState,
>(input: ConnectorUserTurnInput<TState>): Promise<void> {
const resolvedInput = input.text.trim();
if (!resolvedInput) {
return;
@@ -892,56 +947,107 @@ export async function handleConnectorUserTurn<
input.baseStartRequest,
effectiveCurrentState,
);
const activeTurn =
input.activeTurns?.get(turnKey) ??
(input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.values()).find(
(turn) =>
const keyedActiveTurn = input.activeTurns?.get(turnKey);
const activeTurnEntry = keyedActiveTurn
? ([turnKey, keyedActiveTurn] as const)
: input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.entries()).find(
([, turn]) =>
turn.sessionId === currentState.sessionId?.trim() &&
turn.threadId === input.thread.id,
)
: undefined);
if (activeTurn?.sessionId?.trim()) {
: undefined;
if (activeTurnEntry?.[1].sessionId?.trim()) {
const [activeTurnKey, activeTurn] = activeTurnEntry;
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
);
await input.client.sendRuntimeSession(
activeTurn.sessionId,
{
config: startRequest,
prompt,
attachments: buildAttachments({ userImages, userFiles }),
delivery: "steer",
},
{ timeoutMs: null },
);
await postConnectorText(
input.thread,
input.transport,
"Steering current task.",
);
try {
await input.client.sendRuntimeSession(
activeTurn.sessionId,
{
config: startRequest,
prompt,
attachments: buildAttachments({ userImages, userFiles }),
delivery: "steer",
},
{ timeoutMs: null },
);
} catch (error) {
if (!isUnusableSessionError(error)) {
throw error;
}
// 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
// independent replacement sessions.
if (input.activeTurns?.get(activeTurnKey) === activeTurn) {
input.activeTurns.delete(activeTurnKey);
}
const enqueueTurn = input.enqueueTurn;
if (!enqueueTurn) {
throw new Error(
"Active connector turns require a per-thread turn queue",
);
}
await enqueueTurn(() =>
runConnectorRuntimeTurnWithRecovery({
input,
runtimeInput,
turnKey,
staleSessionId: activeTurn.sessionId,
}),
);
return;
}
// 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;
}
const sessionId = await getOrCreateSessionId({
thread: input.thread,
client: input.client,
startRequest,
logger: input.logger,
clientId: input.clientId,
transport: input.transport,
bindingsPath: input.bindingsPath,
errorLabel: input.errorLabel,
hookCommand: input.hookCommand,
hookBotUserName: input.botUserName,
sessionMetadata: input.getSessionMetadata(
input.thread,
input.clientId,
currentState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
await runConnectorRuntimeTurnWithRecovery({
input,
runtimeInput,
turnKey,
currentState,
});
}
/**
* Runs a queued connector turn, replacing a stale session mapping at most once
* before replaying the user's input.
*/
async function runConnectorRuntimeTurnWithRecovery<
TState extends ConnectorThreadState,
>(params: {
input: ConnectorUserTurnInput<TState>;
runtimeInput: string;
turnKey: string;
currentState?: TState;
staleSessionId?: string;
}): Promise<void> {
const { input, runtimeInput, turnKey } = params;
const currentState =
params.currentState ??
(await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
));
const effectiveCurrentState = applyForcedToolDisable(
currentState,
input.forceDisableTools,
);
const startRequest = buildThreadStartRequest(
input.baseStartRequest,
effectiveCurrentState,
);
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
@@ -951,16 +1057,107 @@ export async function handleConnectorUserTurn<
prompt,
attachments: buildAttachments({ userImages, userFiles }),
};
if (params.staleSessionId) {
await forgetStaleThreadSession({
thread: input.thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
logger: input.logger,
transport: input.transport,
sessionId: params.staleSessionId,
});
}
// A thread binding can outlive its runtime session (hub restart, session
// deletion, retention cleanup). When that happens the turn fails with
// `session_not_found`; drop the stale mapping and replay the turn once
// against a brand new session instead of wedging the thread forever.
const resolveSessionId = () =>
getOrCreateSessionId({
thread: input.thread,
client: input.client,
startRequest,
logger: input.logger,
clientId: input.clientId,
transport: input.transport,
bindingsPath: input.bindingsPath,
errorLabel: input.errorLabel,
hookCommand: input.hookCommand,
hookBotUserName: input.botUserName,
sessionMetadata: input.getSessionMetadata(
input.thread,
input.clientId,
currentState,
),
reusedLogMessage: input.reusedLogMessage,
startedLogMessage: input.startedLogMessage,
});
let sessionId = await resolveSessionId();
let allowStaleSessionRetry = params.staleSessionId === undefined;
for (;;) {
try {
await runConnectorRuntimeTurn({
input,
sessionId,
request,
currentState,
turnKey,
});
break;
} catch (error) {
if (!allowStaleSessionRetry || !isUnusableSessionError(error)) {
throw error;
}
allowStaleSessionRetry = false;
await forgetStaleThreadSession({
thread: input.thread,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
errorLabel: input.errorLabel,
logger: input.logger,
transport: input.transport,
sessionId,
});
sessionId = await resolveSessionId();
}
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...currentState,
sessionId,
},
input.errorLabel,
);
}
/**
* Runs a single connector turn against an already-resolved session and streams
* the reply back into the thread.
*/
async function runConnectorRuntimeTurn<
TState extends ConnectorThreadState,
>(params: {
input: ConnectorUserTurnInput<TState>;
sessionId: string;
request: ChatRunTurnRequest;
currentState: TState;
turnKey: string;
}): Promise<void> {
const { input, sessionId, request, currentState, turnKey } = params;
const resolveFallbackText = await input.createEmptyRuntimeReplyResolver?.({
client: input.client,
sessionId,
});
input.activeTurns?.set(turnKey, {
const activeTurn: ActiveConnectorTurn = {
sessionId,
threadId: input.thread.id,
participantKey: currentState.participantKey,
});
};
input.activeTurns?.set(turnKey, activeTurn);
await input.thread.startTyping();
let toolStatusMessage: SentMessage | undefined;
const postFinalReply = input.postFinalReply
@@ -1025,21 +1222,13 @@ export async function handleConnectorUserTurn<
);
} finally {
input.pendingApprovals.delete(input.thread.id);
input.activeTurns?.delete(turnKey);
if (input.activeTurns?.get(turnKey) === activeTurn) {
input.activeTurns.delete(turnKey);
}
if (toolStatusMessage) {
await toolStatusMessage.delete().catch(() => undefined);
}
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...currentState,
sessionId,
},
input.errorLabel,
);
}
export async function maybeHandleConnectorApprovalReply<
@@ -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);
});
});
+67 -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 }
: {}),
},
);
}
@@ -283,6 +311,43 @@ export async function getOrCreateSessionId<
return sessionId;
}
/**
* Drops a thread's session mapping without touching the runtime session, but
* only when it still points at the session the caller observed as stale.
*
* Used when the hub reports the mapped session no longer exists: the thread
* binding may have been recovered concurrently, so a newer session id must
* never be cleared by an older failure.
*/
export async function forgetThreadSession<
TState extends ConnectorThreadState,
>(input: {
thread: Thread<TState>;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
errorLabel: string;
expectedSessionId: string;
}): Promise<boolean> {
const threadState = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
);
if (threadState.sessionId?.trim() !== input.expectedSessionId.trim()) {
return false;
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...threadState,
sessionId: undefined,
},
input.errorLabel,
);
return true;
}
export async function clearSession<TState extends ConnectorThreadState>(input: {
thread: Thread<TState>;
client: HubSessionClient;
@@ -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({
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import { resolveMigrationNoticeKeyAction } from "./notice-dialog";
vi.mock("@opentui-ui/dialog/react", () => ({
useDialogKeyboard: () => undefined,
}));
describe("resolveMigrationNoticeKeyAction", () => {
it("opens the subscription page on Enter", () => {
expect(resolveMigrationNoticeKeyAction({ name: "return" })).toBe("open");
expect(resolveMigrationNoticeKeyAction({ name: "enter" })).toBe("open");
});
it("dismisses on Escape", () => {
expect(resolveMigrationNoticeKeyAction({ name: "escape" })).toBe("dismiss");
});
it("dismisses on any other unmodified key so users are never stuck behind the promo", () => {
for (const name of ["q", "x", "space", "tab", "backspace", "up"]) {
expect(resolveMigrationNoticeKeyAction({ name })).toBe("dismiss");
}
});
it("ignores modifier-held keys so holding Cmd/Ctrl to click the link never dismisses", () => {
expect(resolveMigrationNoticeKeyAction({ name: "c", ctrl: true })).toBe(
"ignore",
);
expect(resolveMigrationNoticeKeyAction({ name: "x", meta: true })).toBe(
"ignore",
);
expect(resolveMigrationNoticeKeyAction({ name: "x", super: true })).toBe(
"ignore",
);
// A bare modifier press (empty name) is ignored, not a dismiss.
expect(resolveMigrationNoticeKeyAction({ name: "" })).toBe("ignore");
});
});
@@ -1,12 +1,34 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import open from "open";
import { useCallback, useMemo, useState } from "react";
import { palette } from "../tui/palette";
import {
type DialogDismissKey,
isAnyKeyDismiss,
} from "../tui/utils/dialog-keys";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
import open from "../utils/open";
import type { CliMigrationNotice } from "./notice";
/**
* Enter opens the subscription page; any other (unmodified) key dismisses the
* dialog; modifier-held keys are ignored.
*
* The dialog used to be dismissible only with Esc, but Esc is the least
* reliable key across terminals (it arrives as a bare `\x1b` that needs
* timeout disambiguation, and Windows console input layers are known to
* swallow it), which left users stuck behind the promo with no way out.
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
* subscription link never dismisses the dialog mid-click.
*/
export function resolveMigrationNoticeKeyAction(
key: DialogDismissKey,
): "open" | "dismiss" | "ignore" {
if (!isAnyKeyDismiss(key)) return "ignore";
return key.name === "return" || key.name === "enter" ? "open" : "dismiss";
}
export function MigrationNoticeContent(
props: ChoiceContext<boolean> & {
notice: CliMigrationNotice;
@@ -30,13 +52,13 @@ export function MigrationNoticeContent(
}, [subscriptionUrl]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
resolve(true);
const action = resolveMigrationNoticeKeyAction(key);
if (action === "ignore") return;
if (action === "open") {
openSubscriptionPage();
return;
}
if (key.name === "return" || key.name === "enter") {
openSubscriptionPage();
}
resolve(true);
}, dialogId);
return (
@@ -48,7 +70,7 @@ export function MigrationNoticeContent(
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
<text selectable>Try it now with a limited-time promo for $1.99.</text>
<text selectable>Try it now with a limited-time promo for $4.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
@@ -61,7 +83,9 @@ export function MigrationNoticeContent(
</box>
</box>
{status && <text fg={palette.muted}>{status}</text>}
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
<text fg={palette.muted}>
Press Enter to open, any other key to close
</text>
</box>
);
}
+83 -68
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),
@@ -96,16 +97,11 @@ const worktreeMocks = vi.hoisted(() => ({
createTaskWorktree: vi.fn(),
}));
const historyMocks = vi.hoisted(() => ({
runHistoryList: vi.fn<() => Promise<number | string>>(async () => 0),
runHistoryList: vi.fn<() => Promise<number>>(async () => 0),
runHistoryDelete: vi.fn(async () => 0),
runHistoryExport: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
const historyResumeMocks = vi.hoisted(() => ({
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
async () => undefined,
),
}));
const loggingMocks = vi.hoisted(() => ({
createCliLoggerAdapter: vi.fn(() => ({
core: {
@@ -212,7 +208,6 @@ vi.mock("./commands/connect", () => connectMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
vi.mock("./utils/history-resume", () => historyResumeMocks);
vi.mock("./logging/adapter", () => loggingMocks);
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
vi.mock("./utils/telemetry", () => telemetryMocks);
@@ -241,8 +236,6 @@ describe("runCli lightweight command dispatch", () => {
historyMocks.runHistoryExport.mockResolvedValue(0);
historyMocks.runHistoryUpdate.mockReset();
historyMocks.runHistoryUpdate.mockResolvedValue(0);
historyResumeMocks.spawnHistoryResume.mockReset();
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
sessionMocks.getSessionRow.mockReset();
sessionMocks.getSessionRow.mockResolvedValue({
sessionId: "sess_123",
@@ -324,6 +317,10 @@ describe("runCli lightweight command dispatch", () => {
value: true,
configurable: true,
});
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
});
afterEach(() => {
@@ -400,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",
@@ -480,7 +539,7 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(process.exitCode).toBe(1);
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
@@ -617,10 +676,6 @@ describe("runCli lightweight command dispatch", () => {
});
it("creates a worktree for default interactive mode", async () => {
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts", "--worktree"];
const { runCli } = await import("./main");
@@ -723,7 +778,7 @@ describe("runCli lightweight command dispatch", () => {
expect.anything(),
undefined,
expect.objectContaining({
initialView: undefined,
startupTarget: undefined,
}),
);
});
@@ -734,10 +789,6 @@ describe("runCli lightweight command dispatch", () => {
title: "Try ClinePass",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
@@ -767,10 +818,6 @@ describe("runCli lightweight command dispatch", () => {
provider: "cline-pass",
model: "cline-pass/test-model",
});
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
@@ -875,7 +922,7 @@ describe("runCli lightweight command dispatch", () => {
undefined,
expect.objectContaining({
initialPrompt: "sup",
initialView: undefined,
startupTarget: undefined,
}),
);
});
@@ -1115,65 +1162,33 @@ describe("runCli lightweight command dispatch", () => {
expect.anything(),
"sess_123",
expect.objectContaining({
initialView: "chat",
startupTarget: "chat",
}),
);
});
it("resumes a history-picked session in a child process", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess_from_history",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it("propagates the child exit code when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("forces chat view when the history-picker child cannot launch", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
it("opens history inside the interactive TUI for the history picker", async () => {
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
});
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyMocks.runHistoryList).not.toHaveBeenCalled();
expect(
migrationNoticeMocks.getClineCliMigrationNotice,
).not.toHaveBeenCalled();
expect(runtimeMocks.runInteractive).toHaveBeenCalledTimes(1);
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
"sess_from_history",
undefined,
expect.objectContaining({
initialPrompt: undefined,
initialView: "chat",
startupTarget: "history",
}),
);
});
+68 -153
View File
@@ -5,6 +5,7 @@ import type { ToolPolicy } from "@cline/core";
import { registerDisposable } from "@cline/shared";
import type { Command } from "commander";
import { registerHistoryCommand } from "./commands/history-command";
import {
CommanderError,
commanderToParsedArgs,
@@ -15,6 +16,7 @@ import {
getPreferredKanbanInstaller,
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import type { TuiStartupTarget } from "./tui/types";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
@@ -136,11 +138,19 @@ function writePromptArgError(args: string[]): void {
);
}
function startupTargetTakesPrecedenceOverMigrationNotice(
target: TuiStartupTarget | undefined,
): boolean {
return target === "config" || target === "history";
}
export async function runCli(): Promise<void> {
installStreamErrorGuards();
autoUpdateOnStartup();
const cliArgs = process.argv.slice(2);
const isFullTTY =
process.stdin.isTTY === true && process.stdout.isTTY === true;
const configDir = resolveConfigDirArg(cliArgs);
const { setClineDir, setHomeDir } = await import("@cline/shared/storage");
if (configDir) {
@@ -154,11 +164,13 @@ export async function runCli(): Promise<void> {
// `--config <dir>` rather than the default home/config location.
captureCliExtensionActivated();
let launchConfigView = false;
const normalizedArgs = normalizeAutoApproveArgs(cliArgs);
// Subcommand routing via Commander
const ctx: { exitCode?: number; resumeSessionId?: string } = {};
const ctx: {
exitCode?: number;
startupTarget?: TuiStartupTarget;
} = {};
const io = { writeln, writeErr };
const program = createProgram();
// Re-enable built-in help/version output for the routing program
@@ -249,7 +261,7 @@ export async function runCli(): Promise<void> {
ctx.exitCode = code;
},
() => {
launchConfigView = true;
ctx.startupTarget = "config";
},
);
return configCmd;
@@ -372,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(
@@ -381,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);
@@ -416,7 +451,7 @@ export async function runCli(): Promise<void> {
connectCmd.args.slice(1),
io,
);
} else if (process.stdin.isTTY && process.stdout.isTTY) {
} else if (isFullTTY) {
ctx.exitCode = await runConnectWizard();
} else {
writeln(`\nAdapters:\n${formatAdapterList()}`);
@@ -428,7 +463,7 @@ export async function runCli(): Promise<void> {
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
if (process.stdin.isTTY && process.stdout.isTTY) {
if (isFullTTY) {
ctx.exitCode = await runMcpWizard();
} else {
writeln(
@@ -493,107 +528,17 @@ export async function runCli(): Promise<void> {
await doctorCmd.parseAsync(cmd.args, { from: "user" });
});
const historyCmd = program
.command("history")
.alias("h")
.description("List session history or manage saved sessions")
.option("--json", "Output as JSON")
.option("--limit <count>", "Maximum number of sessions to show", "50")
.option("--page <number>", "Page number for paginated results")
.option("--config <dir>", "configuration directory")
.action(async () => {
const opts = historyCmd.opts();
const limit = Number.parseInt(opts.limit, 10);
const outputMode =
program.opts().json || opts.json
? ("json" as const)
: ("text" as const);
const { runHistoryList } = await import("./commands/history");
const result = await runHistoryList({
limit,
outputMode,
io,
});
if (typeof result === "string") {
ctx.resumeSessionId = result;
// JSON listing should never return a session id; if it does, still exit here so
// we never fall through to agent bootstrap (which can block on stdin in CI).
if (outputMode === "json") {
ctx.exitCode = 0;
}
} else {
// Always set exit code for numeric results so `ctx.exitCode` is never left
// undefined (that would fall through and load the full CLI runtime).
ctx.exitCode = result ?? 0;
}
});
const historyDeleteCmd = historyCmd
.command("delete")
.description("Delete a session from history")
.option("--session-id <id>", "Session ID to delete")
.action(async () => {
const opts = historyDeleteCmd.opts();
if (!opts.sessionId) {
writeErr("history delete requires --session-id <id>");
ctx.exitCode = 0;
return;
}
const outputMode =
program.opts().json || historyCmd.opts().json
? ("json" as const)
: ("text" as const);
const { runHistoryDelete } = await import("./commands/history");
ctx.exitCode = await runHistoryDelete(opts.sessionId, outputMode, io);
});
const historyUpdateCmd = historyCmd
.command("update")
.description("Update a session in history")
.option("--metadata <json>", "Metadata as JSON string")
.option("--prompt <text>", "New prompt text")
.option("--session-id <id>", "Session ID to update")
.option("--title <text>", "New title")
.action(async () => {
const opts = historyUpdateCmd.opts();
if (!opts.sessionId) {
writeErr("history update requires --session-id <id>");
ctx.exitCode = 1;
return;
}
const outputMode =
program.opts().json || historyCmd.opts().json
? ("json" as const)
: ("text" as const);
const { runHistoryUpdate } = await import("./commands/history");
ctx.exitCode = await runHistoryUpdate(
opts.sessionId,
opts.prompt,
opts.title,
opts.metadata,
outputMode,
io,
);
});
const historyExportCmd = historyCmd
.command("export <sessionId>")
.description("Export a session as a standalone HTML file")
.option("-o, --output <path>", "Output HTML file path")
.action(async (sessionId: string) => {
const opts = historyExportCmd.opts();
const outputMode =
program.opts().json || historyCmd.opts().json
? ("json" as const)
: ("text" as const);
const { runHistoryExport } = await import("./commands/history");
ctx.exitCode = await runHistoryExport(
sessionId,
opts.output,
outputMode,
io,
);
});
registerHistoryCommand({
program,
io,
setExitCode: (code) => {
ctx.exitCode = code;
},
setStartupTarget: (target) => {
ctx.startupTarget = target;
},
isInteractiveTTY: () => isFullTTY,
});
program
.command("hook")
@@ -625,11 +570,7 @@ export async function runCli(): Promise<void> {
.allowExcessArguments()
.passThroughOptions()
.action(async (_opts: unknown, cmd: Command) => {
if (
cmd.args.length === 0 &&
process.stdin.isTTY &&
process.stdout.isTTY
) {
if (cmd.args.length === 0 && isFullTTY) {
ctx.exitCode = await runScheduleWizard();
return;
}
@@ -777,30 +718,8 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
// The history picker already created (and tore down) an OpenTUI renderer
// in this process; starting the interactive TUI here would create a
// second one, which can crash natively during teardown. Resume in a
// fresh `cline --id <session-id>` child process instead.
const { spawnHistoryResume } = await import("./utils/history-resume");
const childExitCode = await spawnHistoryResume({
sessionId: resumeSessionId,
normalizedArgs,
remainingArgs: program.args,
configDir,
});
if (childExitCode !== undefined) {
process.exitCode = childExitCode;
return;
}
args = {
...args,
interactive: true,
prompt: undefined,
};
}
let startupTarget = ctx.startupTarget;
let resumeSessionId: string | undefined;
if (args.id !== undefined) {
const sessionId = args.id.trim();
if (!sessionId) {
@@ -809,16 +728,12 @@ export async function runCli(): Promise<void> {
return;
}
resumeSessionId = sessionId;
startupTarget = "chat";
process.env.CLINE_HOOK_AGENT_RESUME = "1";
args = {
...args,
interactive: true,
prompt: undefined,
};
} else {
delete process.env.CLINE_HOOK_AGENT_RESUME;
}
if (launchConfigView) {
if (startupTarget) {
args = {
...args,
interactive: true,
@@ -883,7 +798,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;
}
@@ -892,7 +810,7 @@ export async function runCli(): Promise<void> {
!args.prompt &&
!resumeSessionId &&
!stdinHasPipedInput() &&
(!process.stdin.isTTY || !process.stdout.isTTY)
!isFullTTY
) {
writeErr("--worktree without a prompt requires an interactive terminal.");
process.exitCode = 1;
@@ -1239,12 +1157,6 @@ export async function runCli(): Promise<void> {
return;
}
const runInteractive = await loadInteractiveRuntimeModule();
let initialView: "chat" | "config" | undefined;
if (launchConfigView) {
initialView = "config";
} else if (resumeSessionId) {
initialView = "chat";
}
const initialClineProviderSettings =
provider === "cline" ? selectedProviderSettings : undefined;
let initialNotice:
@@ -1255,7 +1167,10 @@ export async function runCli(): Promise<void> {
notice: import("./kanban-migration/notice").CliMigrationNotice,
) => void)
| undefined;
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
if (
!startupTargetTakesPrecedenceOverMigrationNotice(startupTarget) &&
isFullTTY
) {
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
await import("./kanban-migration/notice");
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
@@ -1271,7 +1186,7 @@ export async function runCli(): Promise<void> {
initialPrompt: args.prompt,
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
clineProviderSettings: initialClineProviderSettings,
initialView,
startupTarget,
initialNotice,
onInitialNoticeShown: markInitialNoticeShown,
});
@@ -1012,9 +1012,9 @@ Review with the bundled skill.`,
const linear = data.mcp.find((item) => item.name === "linear");
const docs = data.mcp.find((item) => item.name === "docs");
expect(linear?.description).toBe("streamableHttp, oauth error");
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
expect(linear?.loadError).toBe("OAuth authorization failed");
expect(docs?.description).toBe("sse, oauth authorized");
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
expect(docs?.loadError).toBeUndefined();
});
+2 -3
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
const sessionManagerMocks = vi.hoisted(() => ({
start: vi.fn(),
@@ -39,10 +40,8 @@ const sessionEventsMocks = vi.hoisted(() => ({
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
+89 -1
View File
@@ -1,10 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
import {
applyInteractiveModelChange,
assertHistorySessionIsDeletable,
resolveReasoningForModelChange,
resumeInteractiveSession,
} from "./run-interactive";
describe("assertHistorySessionIsDeletable", () => {
it("rejects deleting the active interactive session", () => {
expect(() => assertHistorySessionIsDeletable("sess_1", "sess_1")).toThrow(
"Cannot delete the active session",
);
});
it("allows deleting another or pre-startup session", () => {
expect(() =>
assertHistorySessionIsDeletable("sess_1", "sess_2"),
).not.toThrow();
expect(() => assertHistorySessionIsDeletable("sess_1", "")).not.toThrow();
});
});
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
expect(
@@ -108,3 +125,74 @@ describe("applyInteractiveModelChange", () => {
);
});
});
describe("resumeInteractiveSession", () => {
const originalAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
afterEach(() => {
if (originalAgentResume === undefined) {
delete process.env.CLINE_HOOK_AGENT_RESUME;
} else {
process.env.CLINE_HOOK_AGENT_RESUME = originalAgentResume;
}
});
it("starts the selected session directly without ensuring an empty session first", async () => {
const messages = [
{ id: "message-1", role: "user" as const, content: "hello" },
];
const ensureReady = vi.fn(async () => {});
const resumeSession = vi.fn(async () => {
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
return messages;
});
const getAccumulatedUsage = vi.fn(async () => ({
inputTokens: 12,
outputTokens: 3,
totalCost: 0.42,
}));
const sessionRuntime = {
ensureReady,
resumeSession,
getAccumulatedUsage,
};
const result = await resumeInteractiveSession(
sessionRuntime,
"session-selected",
);
expect(ensureReady).not.toHaveBeenCalled();
expect(resumeSession).toHaveBeenCalledOnce();
expect(resumeSession).toHaveBeenCalledWith("session-selected");
expect(getAccumulatedUsage).toHaveBeenCalledWith({
inputTokens: 0,
outputTokens: 0,
});
expect(result).toMatchObject({
messages,
totalCost: 0.42,
});
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
});
it("restores the hook state when the selected session cannot resume", async () => {
delete process.env.CLINE_HOOK_AGENT_RESUME;
const resumeSession = vi.fn(async () => {
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBe("1");
throw new Error("resume failed");
});
await expect(
resumeInteractiveSession(
{
resumeSession,
getAccumulatedUsage: vi.fn(),
},
"session-missing",
),
).rejects.toThrow("resume failed");
expect(process.env.CLINE_HOOK_AGENT_RESUME).toBeUndefined();
});
});
+65 -16
View File
@@ -10,6 +10,8 @@ import {
import { formatModeSwitchNotice } from "@cline/shared";
import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import { exportHistorySession } from "../session/history-export";
import { deleteSession } from "../session/session";
import {
loadClineAccountSnapshot,
loadIndividualSubscriptionPlans,
@@ -26,7 +28,7 @@ import {
resolveClineWelcomeLine,
} from "../tui/interactive-welcome";
import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem } from "../tui/types";
import type { QueuedPromptItem, TuiStartupTarget } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import {
@@ -73,6 +75,17 @@ type ModelChangeReasoningConfig = {
reasoningEffort?: Config["reasoningEffort"];
};
export function assertHistorySessionIsDeletable(
sessionId: string,
activeSessionId: string,
): void {
if (activeSessionId && sessionId === activeSessionId) {
throw new Error(
"Cannot delete the active session. Start or resume another session first.",
);
}
}
export function resolveReasoningForModelChange(
config: ModelChangeReasoningConfig,
existing: Pick<ProviderSettings, "reasoning">,
@@ -130,6 +143,37 @@ export async function applyInteractiveModelChange(input: {
});
}
export async function resumeInteractiveSession(
sessionRuntime: Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
"resumeSession" | "getAccumulatedUsage"
>,
sessionId: string,
) {
const previousAgentResume = process.env.CLINE_HOOK_AGENT_RESUME;
process.env.CLINE_HOOK_AGENT_RESUME = "1";
let messages: Awaited<ReturnType<typeof sessionRuntime.resumeSession>>;
try {
messages = await sessionRuntime.resumeSession(sessionId);
} catch (error) {
if (previousAgentResume === undefined) {
delete process.env.CLINE_HOOK_AGENT_RESUME;
} else {
process.env.CLINE_HOOK_AGENT_RESUME = previousAgentResume;
}
throw error;
}
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
});
return {
messages,
totalCost: usage.totalCost,
currentContextSize: getCurrentContextSize(messages),
};
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -137,7 +181,7 @@ export async function runInteractive(
options?: {
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
initialView?: "chat" | "config";
startupTarget?: TuiStartupTarget;
initialPrompt?: string;
initialNotice?: CliMigrationNotice;
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
@@ -470,7 +514,7 @@ export async function runInteractive(
tuiApp = await renderOpenTui({
config,
initialView: options?.initialView,
startupTarget: options?.startupTarget,
initialPrompt: options?.initialPrompt,
initialNotice: options?.initialNotice,
onInitialNoticeShown: options?.onInitialNoticeShown,
@@ -764,18 +808,23 @@ export async function runInteractive(
});
await sessionRuntime.restartWithCurrentMessages();
},
onResumeSession: async (sessionId: string) => {
await sessionRuntime.ensureReady();
const messages = await sessionRuntime.resumeSession(sessionId);
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
});
return {
messages,
totalCost: usage.totalCost,
currentContextSize: getCurrentContextSize(messages),
};
// resumeSession initializes the manager and starts the selected session
// directly. Ensuring a session first would mint an empty history entry
// when the TUI was launched through `cline history`.
onResumeSession: async (sessionId: string) =>
await resumeInteractiveSession(sessionRuntime, sessionId),
onExportHistorySession: async (sessionId, format) =>
await exportHistorySession({
sessionId,
format,
outputDirectory: config.cwd,
}),
onDeleteHistorySession: async (sessionId) => {
assertHistorySessionIsDeletable(
sessionId,
sessionRuntime.getActiveSessionId(),
);
return (await deleteSession(sessionId)).deleted;
},
onCompact: async () => {
await sessionRuntime.ensureReady();
@@ -804,7 +853,7 @@ export async function runInteractive(
},
});
if (!loadDeferredInitialMessages) {
if (!loadDeferredInitialMessages && options?.startupTarget !== "history") {
setTimeout(() => {
void sessionRuntime.ensureReady().catch((error) => {
if (sessionRuntime.isShutdownRequested() || startupErrorReported) {
+34
View File
@@ -0,0 +1,34 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { generateConversationHTML } from "./export";
import { readSessionMessagesArtifact } from "./session";
export type HistoryExportFormat = "html" | "json";
export async function exportHistorySession(input: {
sessionId: string;
format: HistoryExportFormat;
outputPath?: string;
outputDirectory?: string;
}): Promise<string> {
const { sessionId, format, outputPath, outputDirectory } = input;
const data = await readSessionMessagesArtifact(sessionId);
if (!data) {
throw new Error(`Session ${sessionId} not found or has no messages.json`);
}
const targetPath = outputPath?.trim()
? resolve(outputPath)
: resolve(
outputDirectory?.trim() || process.cwd(),
`${sessionId}.${format}`,
);
const contents =
format === "html"
? generateConversationHTML(data, sessionId)
: `${JSON.stringify(data, null, 2)}\n`;
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, contents, "utf8");
return targetPath;
}
@@ -0,0 +1,85 @@
import type { CheckpointEntry } from "@cline/core";
import type { Message } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
function userPrompt(text: string): Message {
return { role: "user", content: [{ type: "text", text }] } as Message;
}
function toolResult(): Message {
return {
role: "user",
content: [{ type: "tool_result", tool_use_id: "t", content: "ok" }],
} as unknown as Message;
}
function assistant(text: string): Message {
return { role: "assistant", content: [{ type: "text", text }] } as Message;
}
const history: CheckpointEntry[] = [
{ ref: "ref1", createdAt: 1, runCount: 1, kind: "commit" },
{ ref: "ref2", createdAt: 2, runCount: 2, kind: "stash" },
];
describe("buildCheckpointPickerItems", () => {
it("numbers runs span-aware so tool-result messages don't inflate the count", () => {
// A transcript with tool-result messages (role "user") between prompts,
// exactly the shape that made the old raw-role counting emit run 5 for
// the second prompt and abort restore.
const messages: Message[] = [
userPrompt("first request"),
assistant("working"),
toolResult(),
assistant("working more"),
toolResult(),
userPrompt("second request"),
assistant("done"),
toolResult(),
];
const items = buildCheckpointPickerItems(messages, history);
expect(items.map((item) => item.runCount)).toEqual([1, 2]);
expect(items.map((item) => item.text)).toEqual([
"first request",
"second request",
]);
});
it("maps each real user turn to the nearest checkpoint at or before it", () => {
const messages: Message[] = [
userPrompt("first request"),
toolResult(),
userPrompt("second request"),
];
const items = buildCheckpointPickerItems(messages, [
{ ref: "only", createdAt: 1, runCount: 1, kind: "commit" },
]);
// Run 2 has no exact checkpoint; it falls back to the run-1 entry.
expect(items).toEqual([
expect.objectContaining({ runCount: 1, text: "first request" }),
expect.objectContaining({ runCount: 2, text: "second request" }),
]);
});
it("counts a compaction summary as spanning the runs it folded", () => {
const compaction = {
role: "user",
content: [{ type: "text", text: "Compacted context" }],
metadata: { kind: "compaction", userRunSpan: 2 },
} as unknown as Message;
const messages: Message[] = [compaction, userPrompt("third request")];
const items = buildCheckpointPickerItems(messages, [
{ ref: "r3", createdAt: 3, runCount: 3, kind: "stash" },
]);
expect(items).toEqual([
expect.objectContaining({ runCount: 3, text: "third request" }),
]);
});
});
@@ -0,0 +1,90 @@
import type { CheckpointEntry } from "@cline/core";
import { getUserRunSpan } from "@cline/core";
import type { Message } from "@cline/shared";
import { formatDisplayUserInput, truncateStr } from "@cline/shared";
import type { CheckpointPickerItem } from "./components/dialogs/checkpoint-picker";
/** Highest checkpoint recorded at or before `runCount`. */
function checkpointForRun(
checkpointHistory: readonly CheckpointEntry[],
runCount: number,
): CheckpointEntry | undefined {
return checkpointHistory.reduce<CheckpointEntry | undefined>(
(best, checkpoint) => {
if (checkpoint.runCount > runCount) {
return best;
}
if (!best || checkpoint.runCount > best.runCount) {
return checkpoint;
}
return best;
},
undefined,
);
}
function extractText(content: Message["content"]): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.filter(
(b): b is { type: "text"; text: string } =>
typeof b === "object" &&
b !== null &&
"type" in b &&
(b as { type?: unknown }).type === "text" &&
"text" in b &&
typeof (b as { text?: unknown }).text === "string",
)
.map((b) => b.text)
.join(" ");
}
/**
* Builds the `/undo` checkpoint picker rows from the raw conversation and the
* recorded checkpoint history.
*
* The run count MUST advance with `getUserRunSpan`, exactly as the core does
* when it numbers checkpoints and later resolves them. Tool-result messages
* carry role "user" but contribute 0, and a compaction summary spans the turns
* it folded. Counting raw "user" messages overcounts, so the picker would hand
* restore a run number the core cannot map surfacing as
* "Could not find user message for run N" and aborting the restore.
*/
export function buildCheckpointPickerItems(
rawMessages: readonly Message[],
checkpointHistory: readonly CheckpointEntry[],
): CheckpointPickerItem[] {
const items: CheckpointPickerItem[] = [];
let userRunCount = 0;
for (const msg of rawMessages) {
const span = getUserRunSpan(msg);
if (span < 1) {
continue;
}
userRunCount += span;
const checkpoint = checkpointForRun(checkpointHistory, userRunCount);
if (!checkpoint) {
continue;
}
const text = extractText(msg.content);
const preview = truncateStr(
formatDisplayUserInput(text).replace(/\s+/g, " "),
60,
);
if (!preview) {
continue;
}
items.push({
runCount: userRunCount,
text: preview,
fullText: text,
createdAt: checkpoint.createdAt,
});
}
return items;
}
@@ -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",
@@ -4,6 +4,10 @@ import {
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
import {
type DialogDismissKey,
isAnyKeyDismiss,
} from "../../utils/dialog-keys";
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
@@ -45,6 +49,26 @@ export function saveManualProviderApiKey(
}
}
/**
* Key handling for the OAuth waiting screens: `K` switches to manual API key
* entry when that fallback is available; any other (unmodified) key cancels
* the pending auth attempt; modifier-held keys are ignored.
*
* Like the ClinePass promo dialog, this screen must never depend on Esc
* alone: it is non-interactive, it may be waiting on a browser flow that
* never completes, and Esc is the least reliably delivered key across
* terminals (notably on Windows, where console input layers can swallow it).
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
* auth/verification link never cancels the flow mid-click.
*/
export function resolveOAuthWaitKeyAction(
key: DialogDismissKey,
allowApiKeyFallback: boolean | undefined,
): "use_api_key" | "cancel" | "ignore" {
if (!isAnyKeyDismiss(key)) return "ignore";
return allowApiKeyFallback && key.name === "k" ? "use_api_key" : "cancel";
}
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
@@ -53,6 +77,8 @@ export function buildClinePassSubscriptionPageUrl(
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
url.searchParams.set("code", CLI_PROMO_CODE);
if (CLI_PROMO_CODE) {
url.searchParams.set("code", CLI_PROMO_CODE);
}
return url.toString();
}
@@ -9,22 +9,61 @@ import {
} from "../../../utils/provider-auth";
import {
buildClinePassSubscriptionPageUrl,
resolveOAuthWaitKeyAction,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
describe("resolveOAuthWaitKeyAction", () => {
it("switches to manual API key entry on K when the fallback is available", () => {
expect(resolveOAuthWaitKeyAction({ name: "k" }, true)).toBe("use_api_key");
});
it("cancels on K when the fallback is not available", () => {
expect(resolveOAuthWaitKeyAction({ name: "k" }, false)).toBe("cancel");
});
it("cancels on any other unmodified key so users are never stuck waiting on a browser flow", () => {
for (const name of ["escape", "q", "return", "space", "up", "x"]) {
expect(resolveOAuthWaitKeyAction({ name }, true)).toBe("cancel");
expect(resolveOAuthWaitKeyAction({ name }, false)).toBe("cancel");
}
});
it("ignores modifier-held keys so holding Cmd/Ctrl to click the auth link never cancels", () => {
expect(resolveOAuthWaitKeyAction({ name: "k", ctrl: true }, true)).toBe(
"ignore",
);
expect(resolveOAuthWaitKeyAction({ name: "c", ctrl: true }, false)).toBe(
"ignore",
);
expect(resolveOAuthWaitKeyAction({ name: "x", meta: true }, true)).toBe(
"ignore",
);
expect(resolveOAuthWaitKeyAction({ name: "x", super: true }, false)).toBe(
"ignore",
);
// A bare modifier press (empty name) is ignored, not a cancel.
expect(resolveOAuthWaitKeyAction({ name: "" }, true)).toBe("ignore");
});
});
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
);
expect(
buildClinePassSubscriptionPageUrl(undefined).startsWith(
"https://app.cline.bot/dashboard/subscription?personal=true",
),
).toBe(true);
});
it("keeps the configured app base URL", () => {
expect(
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
).toBe(
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
);
buildClinePassSubscriptionPageUrl(
"https://staging-app.cline.bot",
).startsWith(
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
),
).toBe(true);
});
});
@@ -13,7 +13,6 @@ import {
import { getClineEnvironmentConfig } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import open from "open";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CODEX_CLI_INSTALL_URL,
@@ -21,6 +20,7 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import open from "../../../utils/open";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import {
@@ -39,6 +39,7 @@ import {
} from "../searchable-list";
import {
buildClinePassSubscriptionPageUrl,
resolveOAuthWaitKeyAction,
saveManualProviderApiKey,
} from "./provider-picker-helpers";
@@ -877,21 +878,20 @@ export function OAuthLoginContent(
}, []);
useDialogKeyboard((key) => {
if (key.name === "escape") {
cancelAuthAttempt();
dismiss();
const action = resolveOAuthWaitKeyAction(key, allowApiKeyFallback);
if (action === "ignore") return;
cancelAuthAttempt();
if (action === "use_api_key") {
resolve("use_api_key");
return;
}
if (key.name === "k" && allowApiKeyFallback) {
cancelAuthAttempt();
resolve("use_api_key");
}
dismiss();
}, dialogId);
const escapeHint = allowApiKeyFallback
? "K to enter an API key instead, Esc to cancel"
: "Esc to cancel";
const escapeHintColor = allowApiKeyFallback ? "white" : "gray";
const cancelHint = allowApiKeyFallback
? "K to enter an API key instead, any other key to cancel"
: "Press any key to cancel";
const cancelHintColor = allowApiKeyFallback ? "white" : "gray";
if (mode === "device") {
return (
@@ -919,8 +919,8 @@ export function OAuthLoginContent(
{deviceError && <text fg="red">{deviceError}</text>}
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
<text fg={cancelHintColor}>
<em>{cancelHint}</em>
</text>
</box>
);
@@ -942,8 +942,8 @@ export function OAuthLoginContent(
{error && <text fg="red">{error}</text>}
<text fg={escapeHintColor}>
<em>{escapeHint}</em>
<text fg={cancelHintColor}>
<em>{cancelHint}</em>
</text>
</box>
);
@@ -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>
) : (
@@ -93,14 +93,3 @@ export function freeTierDescriptionFor(
);
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
}
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
// disambiguate them from their paid twins. Inside the sectioned pickers the
// Free header already says it, so the markers are redundant — but keep them in
// flat lists (e.g. browse-all), where both variants appear side by side.
export function stripFreeMarker(displayName: string): string {
return displayName
.replace(/\s*\(free\)\s*$/i, "")
.replace(/:free$/i, "")
.trim();
}
@@ -3,7 +3,6 @@ import {
buildFeaturedModelEntries,
CLINE_PASS_FREE_SECTION_DESCRIPTION,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
@@ -86,13 +85,4 @@ describe("cline model picker entries", () => {
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
).toBe(undefined);
});
it("strips redundant free markers from display names", () => {
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
"Trinity Large Preview",
);
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
});
});
@@ -12,7 +12,6 @@ import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
export {
@@ -23,7 +22,6 @@ export {
type ClineModelPickerItem,
type ClineModelPickerTier,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
function tagColor(tag: string): string {
@@ -32,24 +30,6 @@ function tagColor(tag: string): string {
return palette.act;
}
function resolveDisplayName(
modelId: string,
knownModels?: Record<string, unknown>,
): string {
if (knownModels) {
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return stripFreeMarker(hit.name);
}
}
const fallback = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function useClineRecommendedModels() {
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
const [loading, setLoading] = useState(true);
@@ -75,10 +55,9 @@ export function ClineModelPicker(props: {
entries: ClineModelPickerEntry[];
selected: number;
loading?: boolean;
knownModels?: Record<string, unknown>;
currentModelId?: string;
}) {
const { entries, selected, loading, knownModels, currentModelId } = props;
const { entries, selected, loading, currentModelId } = props;
if (loading) {
return (
@@ -122,7 +101,8 @@ export function ClineModelPicker(props: {
}
const tags = entry.model.tags;
const name = resolveDisplayName(entry.model.id, knownModels);
// Names arrive display-ready from fetchClineRecommendedModels
const name = entry.model.name || entry.model.id;
const isCurrent = currentModelId === entry.model.id;
rows.push(
<box
@@ -7,7 +7,6 @@ import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-picker";
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
import { ProviderRow } from "./provider-row";
@@ -25,29 +24,10 @@ function tagColor(tag: string): string {
return palette.act;
}
function resolveDisplayName(
modelId: string,
knownModels?: Record<string, unknown>,
): string {
if (knownModels) {
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return stripFreeMarker(hit.name);
}
}
const fallback = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function ClineModelSelectorContent(
props: ChoiceContext<string> & {
currentModel: string;
currentProviderName: string;
knownModels?: Record<string, unknown>;
entries: ClineModelPickerEntry[];
},
) {
@@ -57,7 +37,6 @@ export function ClineModelSelectorContent(
dialogId,
currentModel,
currentProviderName,
knownModels,
entries,
} = props;
const [selected, setSelected] = useState(0);
@@ -95,7 +74,8 @@ export function ClineModelSelectorContent(
rows.push({
key: entry.model.id,
kind: "model",
label: resolveDisplayName(entry.model.id, knownModels),
// Names arrive display-ready from fetchClineRecommendedModels
label: entry.model.name || entry.model.id,
tags: entry.model.tags,
isCurrent: currentModel === entry.model.id,
entryIndex: i,
@@ -112,7 +92,7 @@ export function ClineModelSelectorContent(
}
}
return rows;
}, [entries, knownModels, currentModel]);
}, [entries, currentModel]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
@@ -238,7 +218,6 @@ export function ClineModelSelectorDialogContent(
props: ChoiceContext<string> & {
currentModel: string;
currentProviderName: string;
knownModels?: Record<string, unknown>;
loadEntries: () => Promise<ClineModelPickerEntry[]>;
},
) {

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