Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 396032cd3b chore(cli): release v3.0.45 2026-07-18 21:03:37 -07:00
Saoud Rizwan f33ab3a872 chore(sdk): release v0.0.65 2026-07-18 20:46:28 -07:00
Saoud Rizwan 2ca8364ffc docs: add Kimi K3 to ClinePass model list and reference pricing (#12393) 2026-07-18 20:35:48 -07:00
Saoud Rizwan 2ef81be703 feat(llms): make Claude Code and Codex provider packages optional peers (#12379)
ai-sdk-provider-claude-code and ai-sdk-provider-codex-cli were hard
dependencies of @cline/llms, so every npm install of the cline CLI
pulled their native binaries (~250MB claude-agent-sdk platform binary,
~105MB @openai/codex) even for users who never select those providers.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Address shell resolution review feedback

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

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

* Apply terminal profile changes at the model-request boundary

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

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

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

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

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

* Harden shell profile path resolution edge cases

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

* ci(ui): add standalone npm publishing

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

* refactor(ui): simplify package validation

* refactor(ui): tighten package and release contracts

* docs(ui): remove duplicate install guidance

* ci(ui): make publishing workflow manual-only
2026-07-17 18:22:57 -07:00
Bee d1837366c0 chore(llms): update model catalog (#12366)
Update model catalog with bun run build:models
Version updated to 1784318695007
2026-07-17 13:28:59 -07:00
Saoud Rizwan c380daf4a3 docs(ui): add adoption primer (#12367) 2026-07-17 13:21:15 -07:00
Bee c564045d81 chore(cli): includes version numbers in hub status output (#12358)
Includes version numbers in hub status output and doctor command to make debugging with user easier.
2026-07-17 05:37:54 +02:00
Saoud Rizwan 9a5e1751b2 chore(cli): release v3.0.44 2026-07-16 18:38:54 -07:00
Saoud Rizwan 131e25e1a1 chore(sdk): release v0.0.64 2026-07-16 18:14:58 -07:00
Saoud Rizwan a7ff007af9 chore(cli): release v3.0.43 2026-07-16 17:52:46 -07:00
ef27f45080 fix: max output token handling (#12031)
* fix: max output token handling

* shared

* max reasoning budgetTokens

* fix unit test

* fix: address review feedback on max output token handling

- OpenRouter effort branch sends only reasoning.effort (OpenRouter rejects
  effort combined with reasoning.max_tokens)
- OpenAI Responses forwards explicit caller maxTokens for API-key usage;
  ChatGPT OAuth and synthesized gateway defaults are still omitted
- Gateway lifts the synthesized default output cap above explicit Anthropic
  reasoning budgets so max_tokens > thinking.budget_tokens holds

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

* fix: address second-round review feedback on max output token handling

- Replace the gateway-only requestedMaxTokens field with a defaultedMaxTokens
  flag set when the gateway synthesizes a cap, so explicit maxTokens from
  direct provider callers is forwarded by default (greptile P1)
- Check the parsed hostname instead of a URL substring when detecting the
  ChatGPT OAuth backend (CodeQL)
- Drop the empty else-if branch in toAiSdkMessages in favor of an explicit
  emptiedByDroppedReasoning condition (greptile P2; biome rejects the
  suggested bare continue)
- Dedupe isPositiveFiniteNumber by exporting it from gateway.ts (greptile P2)

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

* refactor: extract isPositiveFiniteNumber into providers/utils.ts

Move the shared helper to its own module as suggested in review instead
of exporting it from gateway.ts.

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

* chore: remove unrelated VS Code changes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 17:50:22 -07:00
Dominic Cooney e3c6d51072 fix: recognize frontmatter with a leading UTF-8 BOM (#12277)
* fix(vscode): recognize SKILL.md frontmatter with a leading UTF-8 BOM

SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's 'UTF-8 with BOM' encoding) were silently skipped and not recognized as skills, because gray-matter/regex-style frontmatter parsers require '---' at byte offset 0 and never accounted for the leading BOM byte sequence Node's utf-8 decoder does not strip.

Fixes the shared parseYamlFrontmatter() helper (used by skills, rules, workflows, and remote skill entries in the VS Code extension) and every duplicated ad-hoc frontmatter regex across the SDK/CLI/hub/desktop-app/example-plugin code paths to strip a leading BOM before matching.

Adds regression tests exercising the exact reported scenario (BOM-prefixed SKILL.md silently missing name/description) in frontmatter.test.ts, skills.test.ts, skill-frontmatter-toggle.test.ts, user-instruction-config-loader.test.ts, and configured-agent-config.test.ts.

Fixes https://github.com/cline/cline/issues/12151

* refactor(shared): centralize UTF-8 BOM stripping

* refactor(shared): add UTF-8 file readers

* docs: guide UTF-8 configuration reads
2026-07-17 09:37:20 +09:00
Saoud Rizwan 48bac25548 chore(sdk): regenerate lockfile for v0.0.63 2026-07-16 17:13:44 -07:00
Saoud Rizwan 37f5f104f3 chore(sdk): release v0.0.63 2026-07-16 16:47:40 -07:00
Saoud Rizwan 3577b52404 feat(core): emit mistake-limit telemetry from the session runtime (#12355)
Moves the task.mistake_limit_reached capture (#12354) from the VS Code
SdkController wrapper into @cline/core so every host (CLI, VS Code,
hub daemon) emits it via its session telemetry service.

The MistakeTracker gains an onLimitTelemetry hook fired exactly once
per limit hit, before the limit decision is resolved — including when
no onConsecutiveMistakeLimitReached callback is configured (the
default-stop path, which the extension-side capture missed). The
orchestrator wires the hook to captureMistakeLimitReached using its
reserved telemetry field, reading sessionId/modelId/providerId at fire
time so mid-session connection updates are reflected.

The now-redundant extension wrapper and TelemetryService method are
removed to avoid double-counting in VS Code.
2026-07-16 16:39:50 -07:00
1843bc8ed0 fix(vscode): persist selected account organization (#12345)
* fix(vscode): persist selected account organization

* fix(vscode): ignore stale organization responses

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:08:07 -07:00
fead00ec57 fix(vscode): avoid duplicate OpenAI provider settings (#12346)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:07:48 -07:00
238107d21c fix(vscode): preview auto-approved apply patches (#12349)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-16 16:05:24 -07:00
Saoud Rizwan 2063a661bd feat(vscode): capture telemetry when consecutive mistake limit is reached (#12354) 2026-07-16 15:58:21 -07:00
Dominic Cooney ec02d5862e Fix debug harness: run under node, drop ws dependency. (#12319)
The harness rotted after the npm-to-bun migration: the 'ws' package it imported is no longer in the dependency tree, and Playwright's _electron.launch() times out under bun (the debugee Electron starts but Playwright never finishes attaching; the same launch attaches in under a second under node). Use the runtime's built-in WebSocket for the CDP client and document that the harness must be run with node.
2026-07-17 07:43:32 +09:00
Mikołaj Kondratek 8452084842 fix: auto-discover OS trust anchors in the CLI wrapper (#11498)
* fix: auto-discover OS trust anchors in the CLI wrapper

The 3.x CLI ships as a Bun-compiled binary. Bun does not read the OS
trust store unless NODE_USE_SYSTEM_CA is set, and even with the flag its
Windows enumeration covers only the `Root` store, not `CA`/Intermediate
(verified empirically across the CLINE-2353 Windows repro rounds). So a
corporate MITM root is not trusted out of the box and inference fails
with "unable to get local issuer certificate". The pre-3.0 (Node) CLI
had no app-level CA handling either; users only succeeded by setting
NODE_EXTRA_CA_CERTS manually. The reporter's ask: have it just work
without the env var.

This follows the CLINE-2353 SDK fetch-threading change. That made the
inference client honor a host-provided proxy/CA-aware fetch, but on the
CLI Bun's global fetch is already proxy-aware and a fetch function
cannot cross the hub-daemon process boundary, so the CLI's missing piece
is trust material, not the fetch. Env vars do inherit across spawns.

The npm `bin/cline` wrapper runs on Node (not Bun), so it can read the
full OS store via tls.getCACertificates("system") (Node >= 22, no flag
required) — including the Windows `CA` store Bun skips — and hand the
certs to the Bun child via NODE_EXTRA_CA_CERTS, which both runtimes
honor. This mirrors the JetBrains plugin's configureCertificates(),
replacing "harvest from the IDE trust store" with "harvest from the OS".

The merge logic lives in a dependency-free, injectable-module CommonJS
helper (bin/ca-certs.cjs) so it is unit-testable and ships verbatim in
the generated wrapper package (publish copies bin/ wholesale). A
user-set NODE_EXTRA_CA_CERTS is merged ahead of the system certs; a
self-reference to the managed bundle is detected to avoid re-appending
every launch; when no system certs are available the user's setting is
left untouched. Writes are atomic (temp + rename) and owner-only.

Adds ca-certs.test.ts (13 cases) covering harvest filtering, user-bundle
PEM/DER/missing handling, newline-separated merge, managed-path
self-reference, and the no-system-certs no-op.

* fix: harden CLI auto-CA harvesting (review follow-ups)

Follow-ups from the CLINE-2353 review of the CLI auto-CA wrapper.

- H1: a legacy NODE_EXTRA_CA_CERTS set to an OS-path-delimited list
  ("a.pem;b.pem", the CLINE-2324 footgun Node never split) was stat'd as
  one file, failed, and silently dropped the user's certs. readUserCerts
  now tries the whole value as one file first, then splits on the OS path
  delimiter and reads each existing PEM, merging them all.
- M1: skip the rewrite when the managed bundle is already current, instead
  of re-harvesting and rewriting on every launch (mirrors the JetBrains
  hash-and-skip). configureNodeExtraCaCerts now returns a typed outcome
  (unchanged | written | write-failed-reused | write-failed |
  no-system-certs) with cert counts.
- M2: tolerate rename-over-existing failures (Windows EPERM/EBUSY when a
  concurrent child holds the file open) by removing the target and
  retrying, then falling back to a previously-written bundle. Combined
  with M1 the steady state no longer rewrites at all.
- M3: the wrapper prints a one-line diagnostic under CLINE_DEBUG=1
  (cert counts + managed path, or a warning when no OS certs were found
  or the write failed). Runs once per startup.
- M4: corrected the now-stale CLI guidance in shared/net.ts (the CLI no
  longer requires users to set NODE_EXTRA_CA_CERTS manually).
- L1: documented the auto-trust behavior, the managed ~/.cline bundle,
  the merge-not-replace override semantics, and CLINE_DEBUG in the CLI
  README.
- L4: trimmed the helper's file header; DI is still injectable for tests.

ca-certs.test.ts grows to 20 cases: adds readUserCerts (single path,
delimited split, missing-segment skip, managed-bundle exclusion, empty),
the unchanged/second-run skip, and a write-failure outcome via an
fs that throws.

* fix: address CLI auto-CA review issues (temp cleanup, cert count, test)

- writeBundle now hoists the temp path so the outer catch removes a
  partially-written temp file (e.g. ENOSPC / ACL failure mid-write).
  Previously only the inner double-rename failure cleaned up, so repeated
  disk-full/permission failures left a stale .tmp per launch in ~/.cline.
  The inner Windows-rename fallback now lets its failure fall through to
  the single cleanup path instead of duplicating rmSync.
- userCertCount now counts individual certificates (via countCerts, which
  tallies BEGIN CERTIFICATE markers) rather than the number of PEM files,
  so a user bundle with N intermediates reports N and is comparable to
  systemCertCount. countCerts is exported for testing.
- Adds tests for the write-failed-reused branch (stale bundle reused when
  the rewrite fails but the old file is still readable) and for countCerts
  (one file holding two certs reports 2).

* fix: warn when the CLI wrapper's Node cannot read the OS trust store

tls.getCACertificates("system") needs Node >= 22.15; on older hosts the
auto-CA harvest silently did nothing, which is indistinguishable from a
broken corporate proxy. Distinguish the missing-API case as its own
outcome (api-unavailable) and print a non-debug warning when the user
has no NODE_EXTRA_CA_CERTS of their own. Found in round-5 Windows
validation (wrapper under Node 22.1.0).

* fix: copy only certificate blocks into the managed CA bundle

Combined cert+key PEMs (nginx/haproxy-style server.pem) passed the
old contains-a-certificate check, so a user NODE_EXTRA_CA_CERTS
pointing at one duplicated the private key into the managed bundle,
where it outlives rotation of the original and gets no permission
tightening on Windows. Extract complete BEGIN/END CERTIFICATE blocks
instead; files with none are treated as not PEM, and certificates-only
files pass through byte-identical so the unchanged-skip stays stable.
Raised in PR review.

* fix: show the old-Node trust warning once per Node version

The api-unavailable warning printed on every CLI invocation, turning
an actionable nudge into stderr noise for users pinned to an old Node.
Stamp the warning per Node version under the cline dir: it shows once,
re-arms when the Node version changes, and a bookkeeping failure never
suppresses the diagnostic. Raised in PR review.
2026-07-16 10:14:15 -07:00
Dominic Cooney a41129a5db fix(vscode): restore 'Proceed While Running' for foreground terminal commands (#12320)
* First cut of 'proceed while running' for foreground tasks.

* Address review: flush partial line on detach; cap log before write; freeze partial output at detach.

* fix(vscode): cap detached command log replay
2026-07-15 22:46:37 -07:00
Saoud Rizwan 1ea34be611 chore(cli): release v3.0.42 2026-07-15 20:03:31 -07:00
Saoud Rizwan e72bc3cd14 chore(sdk): release v0.0.62 2026-07-15 19:46:58 -07:00
9c907af826 Send the Feature Flag Event when rolling out (#12325)
* Send the Feature Flag Event when rolling out

* Update apps/vscode-rollout/scripts/smoke-loader.mjs

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-07-15 19:39:37 -07:00
Saoud Rizwan e8d3d82522 fix(core): omit telemetry from hub tool contexts (#12326) 2026-07-15 19:33:12 -07:00
7f9d2e96d9 fix(ollama): restore native API routing so context window and timeout settings work (#12286)
The 4.0.0 SDK migration routed Ollama through the generic OpenAI-compatible
vendor (/v1/chat/completions), which cannot express Ollama's options.num_ctx.
Every model loaded at Ollama's 4096-token server default, truncating Cline's
prompt and breaking most features (CLINE-2603, CLINE-2566, CLINE-2572).

- Add a native Ollama vendor backed by ai-sdk-ollama (wraps the official
  ollama client); num_ctx derives from the resolved gateway model's
  contextWindow at the adapter boundary, defaulting to 32768
- Persist the Model Context Window setting in providers.json via the
  pre-existing provider-neutral contextWindow field (legacy
  ollamaApiOptionsCtxNum state key kept as read fallback / write mirror),
  and surface it as the selected model's contextWindow so the chat
  indicator, compaction budgets, and num_ctx all agree
- Project ProviderConfig.maxInputTokens (where ProviderSettings.contextWindow
  lands) onto the selected gateway model in both gateway builders so
  CLI/Core hosts honor the configured value too
- Stop falling back to the bundled Ollama-Cloud catalog when /api/tags is
  empty; local-model-source providers keep the user's committed model
  instead of silently selecting a cloud model (nemotron)
- Wire Request Timeout (ms) with the legacy semantics (response must start
  within requestTimeoutMs || 30000; streaming never cut off mid-generation)
- Settings UI: gate the context-window field until provider config loads,
  skip unchanged writes, drop the custom prompt checkbox

Fixes CLINE-2603, CLINE-2566, CLINE-2572

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-15 17:55:29 -07:00
Saoud Rizwan d618f8073a fix(vscode-rollout): align bundle versions and harden combined publish workflows (#12321)
* fix(vscode-rollout): align bundle versions in the stable AB workflow

Found by Max in local testing: the union manifest's version (what the
Marketplace and auto-update see) is the stitch input, but each bundle's
About tab and telemetry extension_version read that bundle's OWN
package.json — so the stable combined VSIX reported three different
versions (dispatch input / main's 4.0.0 / legacy's 4.0.8) depending on
where you looked. The nightly channel doesn't have this problem
(nightlify.mjs stamps one version into everything); this gives the stable
channel the identity-preserving equivalent: scripts/set-version.mjs stamps
the dispatch version into each checkout after install, before its build.

Also fixes a latent ab-package bug while restructuring the steps: the
next-bundle build never ran build:sdk, so the @cline/* workspace deps had
no dist and esbuild would fail on a fresh CI checkout (the workflow has
never run end-to-end — the publish environment gate blocked pre-merge
dispatches). Split install/build:sdk/align/build into separate steps,
mirroring the nightly workflow.

* fix(vscode-rollout): assert bundle sub-manifest versions in identity guardrails

Greptile round on #12321: the stable guardrail didn't assert version at
all. Went one further than the suggestion — both workflows' guardrails now
also assert each bundle sub-manifest's version (and name, for nightly)
matches the expected version, which is the check that actually regression-
guards the set-version.mjs/nightlify.mjs stamping (About tab + telemetry
extension_version read the sub-manifests, not the union). Expected version
routed through env rather than interpolated into the script body. Adds the
conventional paired test for set-version.mjs.

* fix(vscode-rollout): don't fail the nightly run when the tag push is rejected

First real combined publish (run 29454994164) published to both registries
successfully but the run went red at the last step: the default
GITHUB_TOKEN cannot create a ref whose commit modifies workflow files, and
HEAD was the #12253 squash merge which rewrote this very workflow. There
is no workflows permission grantable to the token, so this recurs any
night HEAD touched .github/workflows. The tag is bookkeeping — mark the
step continue-on-error so a successful publish isn't reported as a
failure. (Today's missing tag was pushed manually.)
2026-07-15 17:39:44 -07:00
Saoud Rizwan eb21ba583c fix(ci): restrict nightly publishing to main (#12322) 2026-07-15 15:35:09 -07:00
f29c25395c feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout (#12253)
* feat(vscode-rollout): A/B loader and packaging for staged SDK extension rollout

Ship one marketplace VSIX containing a tiny loader plus two complete
extension bundles: next/ (SDK-based apps/vscode from main) and legacy/
(the legacy-extension branch). The loader picks one bundle per window
from a PostHog-flag-driven, sticky, one-way cohort assignment, activates
it with a Proxy-scoped ExtensionContext so each bundle resolves its
resources from its own subdirectory, and falls back to legacy (with
partial-registration cleanup and version pinning) if the next bundle
crashes during activation.

Includes the union-manifest generator with per-cohort when-clause
gating, the VSIX stitcher, a node-level loader smoke test, and the
ext-vscode-ab-package workflow that builds both refs and packages
(optionally publishes) the combined VSIX.

* fix(vscode-rollout): address rollout review feedback

* feat(vscode-rollout): versioned kill-switch, user-setting override, launch-cadence telemetry

Review follow-ups from #12253:

- Kill-switch is now scoped by version instead of boolean: the PostHog flag's
  payload carries {"maxKilledVersion": "x.y.z"} and the loader demotes only
  combined VSIXes <= that version, so killing a broken release never blocks
  the release that fixes it. Arming with no payload still demotes everything,
  and the old boolean memento format is normalized on read.

- cline.rollout.bundleOverride user setting (auto | next | legacy) as a
  manual escape hatch editable straight from settings.json: beats flags and
  the kill-switch in both directions, applies on window reload, reported as
  'override' on the activation event. Injected into the union manifest by
  gen-manifest so neither bundle has to know about it.

- parseRolloutFlags hardens flag typing: only a literal boolean true promotes
  (multivariate variants, numbers, junk fail safe), kill payloads are parsed
  defensively from /decide's JSON-string encoding.

- Activation events now carry ms_since_last_activation so the real window-
  reload cadence bounds how fast the rollout percentage gets dialed up.

- Walkthrough manifest invariant relaxed from byte-equality to structural
  equality (ids/media/completionEvents): the branches already diverge on one
  MCP step description, and since walkthrough markdown at the VSIX root comes
  from next regardless, hard-failing on copy tweaks bricked the release
  pipeline while protecting nothing. Copy divergence now warns and ships
  next's text.

* feat(vscode-rollout): identity-aware namespace, authoritative activation telemetry, nightly indicator

- Derive the setting section and sdkBundle context key from the packaged
  manifest name (cline.* for stable claude-dev, cline-nightly.* for the
  nightly identity, whose packaging rewrites the whole ID namespace);
  gen-manifest derives the same prefix for gates and the injected
  bundleOverride setting.
- Call the activated bundle's reportRolloutActivation export (merged on
  both branches) with attempted/actual/fallback — the authoritative
  extension.rollout.bundle_activated event, attributed via the bundle's
  variant-built telemetry. On crash fallback the LEGACY bundle reports it.
- Rename the loader's direct PostHog event to
  extension.rollout.loader_decision: it collided byte-for-byte with the
  bundles' event name under a different schema. It keeps the loader-side
  metadata (override, launch cadence, loader_version, extension_name) and
  gains double_failure for the both-bundles-dead case.
- Fix duplicate activation events on crash fallback: the recursive legacy
  activation no longer emits a second, contradictory fallback:false event.
- Nightly-only status bar indicator (Cline: Next / Cline: Legacy) so
  dogfooders can see which bundle a window is running.
- Union diverged engines to the newer requirement instead of hard-failing:
  main's VS Code engine (^1.101.0) has legitimately moved ahead of
  legacy-extension's (^1.84.0), which bricked every combined build.
- Smoke scenarios for all of the above.

* feat(vscode-rollout): publish the nightly as the combined A/B VSIX

Convert ext-vscode-publish-nightly.yml (cron + dispatch) from the
standalone SDK build to the combined loader + next + legacy package,
published as saoudrizwan.cline-nightly at <major>.<minor>.<unix-seconds>:

- scripts/nightlify.mjs reproduces publish-nightly.mjs's identity mutation
  (claude-dev -> cline-nightly, "cline. -> "cline-nightly., displayName,
  activity bar title) with the version as an explicit argument so ONE
  version reaches both bundle manifests and the union manifest. Runs after
  dependency install and before each bundle build.
- Both bundle builds get CLINE_ROLLOUT_VARIANT (next/legacy) in the nightly
  AND stable workflows — without it the merged rollout telemetry
  (extension_variant common prop + the authoritative bundle_activated
  capture) silently no-ops.
- dry-run dispatch input builds and uploads the installable .vsix without
  publishing or tagging; publish/tag steps are additionally gated to main,
  so the PR branch can be dispatched for pre-merge verification.
- Identity guardrails before packaging: nightly workflow asserts
  cline-nightly, the stable ab-package workflow asserts claude-dev.
- The nightly tag now records the legacy bundle sha in its message.
- README: nightly channel section (identity mapping, the two telemetry
  events and their owners, dry-run verification), and a note that the
  PostHog flags govern nightly only until the stable combined VSIX ships.

The single-bundle publish-nightly.mjs path remains for manual
feature-branch pre-release publishes; CI no longer invokes it.

* chore(vscode-rollout): harden nightly workflow gating

- Restore a job-level branch allowlist on the publish job (main + the
  rehearsal branch). Advisory defense-in-depth: the enforced gate is the
  PublishNightly environment's deployment-branch policy in repo settings,
  which must list the same branches; a dispatched branch runs its own copy
  of this file.
- Route the legacy-ref dispatch input through env instead of interpolating
  it into the run script body (script-injection hygiene; dispatch already
  requires write access).

* add otel vars to rollout build (#12316)

- Extension will not emit otel metrics to otel without these vars, so
adding those into the slow-rollout build workflow

Co-authored-by: Max Paulus 🥪 <max@cline.bot>

* fix(vscode-rollout): pass OTel env to the nightly legacy bundle build

Legacy's esbuild inlines OTEL_* at build time and its standalone publish
workflow passes them, so the combined nightly's legacy bundle was being
built with the OTel logs/metrics pipeline dead. Companion to #12316,
which fixes the same gap in ext-vscode-ab-package.yml (both bundles
there).

* feat(vscode-rollout): make the rollout two-way, remove the kill-switch

The one-way cohort + versioned kill-switch existed to avoid demoting users
whose SDK-bundle tasks aren't listed by legacy and whose rotated creds may
need a re-login. Decision: those are acceptable, temporary UX costs on an
emergency-only path — not worth a second flag and permanent mechanism
complexity (payload parsing, version scoping, killed-up-to cache format).

Now there is ONE knob: each background refresh caches exactly what
ext-sdk-bundle-rollout says for the next window. Dialing the percentage
down demotes; 0% pulls everyone back to legacy on their next reload.
Fail-safe direction preserved: only a literal boolean true promotes —
variant strings / numbers / a deleted flag all resolve to legacy; malformed
/decide responses leave the cache untouched. Local crash pinning (next
threw -> pin this version to legacy on this machine) is unchanged and
independent of the flag.

Removes KILLSWITCH_FLAG/KILLSWITCH_STATE_KEY/isVersionKilled/
normalizeKilledUpTo/compareVersions/nextCachedBundle; parseRolloutFlags
becomes parseRolloutAssignment returning the bundle to cache. Smoke
scenarios replaced with two-way promote/demote coverage.

---------

Co-authored-by: Max <maxpaulus43@gmail.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 15:07:45 -07:00
MaxandMax Paulus 🥪 84c9b587a6 refactor(vscode): resolve model metadata host-side (#12130)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-15 13:37:02 -07:00
Saoud Rizwan 6dca234d8e chore(cli): release v3.0.41 2026-07-15 11:02:17 -07:00
Renee Huang 9217eacbbd fix: update broken ACP editor integrations redirect to CLI reference (#12312)
* fix: update broken ACP editor integrations redirect to point to CLI reference

* feat: add ACP Editor Integrations page under CLI section

- Create cli/acp-editor-integrations.mdx with ACP overview, supported editors, quick start, and usage guide
- Add page to CLI navigation group in docs.json
- Restore redirect from /cline-cli/acp-editor-integrations to /cli/acp-editor-integrations (page now exists)

* Revert "feat: add ACP Editor Integrations page under CLI section"

This reverts commit 2728b9c2ad.
2026-07-15 10:57:59 -07:00
222 changed files with 15201 additions and 1428 deletions
+158
View File
@@ -0,0 +1,158 @@
---
name: publish-ui
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
---
# Publish UI
Release `@cline/ui` independently from the Cline SDK runtime packages.
## Release contract
- Version source: `sdk/packages/ui/package.json`.
- Workflow: `.github/workflows/ui-publish.yml`.
- The package keeps `internal: true` only to stay out of the SDK's shared
version/publish scripts. It is still a public npm package because
`private: false` and `publishConfig.access: public` control npm publication.
- `latest` is the production channel. `next` is an opt-in preview channel.
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
version intended for `latest` under the preview tag because npm versions
cannot be republished.
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
- The publish job and npm trust relationship use the protected `Publish`
environment.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
## Normal release
1. Inspect the branch, current version, npm state, and UI changes.
```sh
git status --short --branch
node -p "require('./sdk/packages/ui/package.json').version"
npm view @cline/ui dist-tags versions --json
git log --oneline --no-merges -- \
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
.github/workflows/ui-publish.yml
```
2. Ask for the npm channel and version together. For `latest`, ask for patch,
minor, major, or an explicit version. For `next`, require an explicit
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
not run the SDK version command.
3. Validate the release candidate.
```sh
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
bun -F @cline/ui typecheck
bun -F @cline/ui test
bun -F @cline/ui test:package
bun -F @cline/ui build-storybook
bun -F @cline/code test:chat-ui
```
The packed-package test installs the tarball with Bun/React 19 and with
npm/Node/React 18.
Inspect `bun pm pack --dry-run` when the exported file set changed.
4. Commit the version bump separately from feature work. Ask before pushing.
```sh
git add sdk/packages/ui/package.json bun.lock
git commit -m "chore(ui): release vX.Y.Z"
git push origin HEAD
```
5. After the release commit reaches `main`, restate the selected npm tag and ask
for explicit publish approval. Then trigger and watch the standalone
workflow:
```sh
run_url=$(gh workflow run ui-publish.yml --ref main \
-f npm_tag=latest \
-f confirm_publish=publish)
test -n "$run_url"
run_id=${run_url##*/}
gh run watch "$run_id" --exit-status
```
Use `npm_tag=next` only for a deliberate preview. Do not report success until
the workflow succeeds and npm shows the exact version under the selected tag.
```sh
npm view @cline/ui dist-tags versions --json
```
## One-time npm bootstrap
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
package to exist before its GitHub trusted publisher can be configured.
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
reviewed `main` checkout. Verify authentication, account 2FA, and write
access to the `@cline` npm organization. The `npm trust` command in step 4
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
itself enforces npm 11.5.1 or newer.
```sh
npm --version
npm whoami
npm view @cline/ui version
```
If npm is older than 11.15, ask before upgrading with
`npm install -g npm@^11.15.0`.
2. Run the normal release validation in step 3 above. Then build, pack, test,
and inspect the exact initial tarball. Record the absolute archive path
printed by the final command.
```sh
bun -F @cline/ui build
pack_dir=$(mktemp -d)
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$tarball"
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
tar -tzf "$tarball"
printf 'Bootstrap archive: %s\n' "$tarball"
```
3. Ask for explicit approval, then publish the initial version publicly under
`latest`:
```sh
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
```
4. Ask separately before configuring the standalone workflow as the trusted
publisher:
```sh
npm trust github @cline/ui \
--repo cline/cline \
--file ui-publish.yml \
--env Publish \
--allow-publish
```
5. Verify both package state and trust. Every later release uses the workflow;
do not add a long-lived npm token.
```sh
npm view @cline/ui dist-tags versions --json
npm trust list @cline/ui
```
## Final report
Report the version and npm tag, release commit, whether anything was pushed,
workflow URL or bootstrap result, npm verification, and tests/builds run. If
the package still returns `E404`, state that bootstrap remains required.
@@ -0,0 +1,4 @@
interface:
display_name: "Publish UI"
short_description: "Prepare and publish the Cline UI package"
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
+3 -2
View File
@@ -8,8 +8,9 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
# Build extension first if needed (protos + esbuild):
bun run protos && IS_DEV=true bun esbuild.mjs
# Launch (skip-build if already built):
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
# Launch (skip-build if already built). Run with node, NOT bun — Playwright's
# Electron launch times out under bun:
node src/dev/debug-harness/server.ts --skip-build --auto-launch
# In another terminal:
curl localhost:19229/api -d '{"method":"status"}'
+1
View File
@@ -16,6 +16,7 @@ This file is the secret sauce for working effectively in this codebase. It captu
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
- Additional instructions about making requests: @.clinerules/network.md
+205
View File
@@ -0,0 +1,205 @@
name: ext-vscode-ab-package
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
# `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.
on:
workflow_dispatch:
inputs:
version:
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
required: true
type: string
next-ref:
description: "Ref to build the next (SDK) bundle from"
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
package:
name: Build combined (legacy + next) VSIX
runs-on: ubuntu-latest
environment: publish
steps:
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.next-ref }}
path: next-src
lfs: true
- name: Checkout legacy source
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.legacy-ref }}
path: legacy-src
lfs: true
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install next workspace dependencies
working-directory: next-src
run: bun install
# @cline/* are local workspace symlinks to source packages; apps/vscode's
# `package` script does NOT build them, so without this the esbuild step
# fails on a fresh checkout. (The nightly workflow already does this.)
- name: Build SDK packages
working-directory: next-src
run: bun run build:sdk
# 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
# the VSIX reports three different versions depending on where you
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
- name: Align next bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# Match the stable publish workflow's OpenTelemetry production defaults.
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 }}
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Align legacy bundle version
working-directory: next-src/apps/vscode-rollout
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Match the stable publish workflow's OpenTelemetry production defaults.
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 }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ github.event.inputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# This workflow publishes the STABLE identity. If nightlify ever leaks
# into this path the union manifest would ship under the wrong name.
# The bundle sub-manifest checks guard the set-version.mjs stamping:
# the About tab and telemetry extension_version read those files.
- name: Assert stable manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ github.event.inputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
'
- name: Package VSIX
working-directory: staging
run: |
npm install -g @vscode/vsce
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: claude-dev-${{ github.event.inputs.version }}
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
if-no-files-found: error
- name: Publish to Marketplace
if: ${{ github.event.inputs.publish == 'true' }}
working-directory: staging
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish."
exit 1
fi
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
+211 -47
View File
@@ -1,17 +1,40 @@
name: ext-vscode-publish-nightly
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
# loader plus two complete extension bundles — `next/` from this ref's
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
# Cohort selection happens at runtime via PostHog flags; see
# apps/vscode-rollout/README.md for the design and rollout runbook.
#
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
# (manual dispatch, publishes claude-dev). Shared logic lives in
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
# workflows stay thin. The single-bundle nightly path this replaced
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
description: "Ref to build the legacy bundle from"
required: false
default: "legacy-extension"
type: string
dry-run:
description: "Build and upload the .vsix artifact without publishing or tagging"
required: false
default: false
type: boolean
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
# Prevent concurrent publish runs on the same branch: the version is generated
# from a seconds-resolution timestamp, so parallel runs on the same ref can
# collide on the same version and cause publish failures or inconsistent tagging.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
@@ -20,7 +43,7 @@ permissions: {}
jobs:
test:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
if: github.repository == 'cline/cline'
permissions:
contents: read
pull-requests: read
@@ -30,60 +53,79 @@ jobs:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
name: Publish Cline (Nightly) Combined Extension
# Defense in depth: only protected main may enter the publishing environment.
# This `if` is advisory because a dispatched branch runs its own copy of this
# file; the enforced gate is the PublishNightly environment's deployment-branch
# policy, which must also allow only main.
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
- name: Checkout next (SDK) source
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: next-src
lfs: true
persist-credentials: false
- name: Show build source
working-directory: ${{ github.workspace }}
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
persist-credentials: false
- name: Show build sources
env:
# Routed through env rather than interpolated into the script body so
# a crafted dispatch input can't inject shell (hygiene: dispatchers
# need write access anyway, but keep the pattern clean).
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
run: |
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
echo "next: $(git -C next-src rev-parse HEAD)"
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
# Node is required beyond install: the rollout scripts run under node and
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's dependency detection fail.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
# ONE version for the next bundle, the legacy bundle, and the union
# manifest: gen-manifest hard-fails if the bundle identities diverge.
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
# from next's base version, so it keeps outranking earlier nightlies.
- name: Compute nightly version
id: version
run: |
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Combined nightly version: $VERSION (base $BASE)"
- name: Install next workspace dependencies
working-directory: next-src
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
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
@@ -93,20 +135,24 @@ jobs:
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
# its build (runtime command/config IDs derive from the manifest) and
# AFTER dependency install (workspace self-links key off the original
# package name).
- name: Nightlify next bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Publish Nightly Extension
- name: Build next bundle
working-directory: next-src/apps/vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# Inlined by esbuild: attributes every telemetry event with
# extension_variant and unlocks the bundle's authoritative
# extension.rollout.bundle_activated capture. Rollout builds only.
CLINE_ROLLOUT_VARIANT: next
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
@@ -114,12 +160,129 @@ jobs:
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 }}
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
run: bun run package
- name: Install legacy dependencies
working-directory: legacy-src
run: |
npm --prefix apps/vscode install --include=optional
npm --prefix apps/vscode/webview-ui install --include=optional
- name: Nightlify legacy bundle manifest
working-directory: next-src/apps/vscode-rollout
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
- name: Build legacy bundle
working-directory: legacy-src/apps/vscode
env:
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ROLLOUT_VARIANT: legacy
# Legacy's esbuild inlines these too (its own publish workflow passes
# them) — omitting them here would ship the legacy bundle with the
# OTel pipeline dead, unlike what legacy users get today.
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 }}
run: npm run package
- name: Build loader and run rollout tests
working-directory: next-src/apps/vscode-rollout
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
run: |
bun run typecheck
bun run test
bun run build:production
- name: Stitch combined VSIX staging
working-directory: next-src/apps/vscode-rollout
run: |
node scripts/stitch.mjs \
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
--loader dist/extension.js \
--version "${{ steps.version.outputs.version }}" \
--out "$GITHUB_WORKSPACE/staging"
- name: Smoke-test loader against staging
working-directory: next-src/apps/vscode-rollout
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
# The nightly identity must have fully propagated (nightlify -> both
# bundle manifests -> union manifest) or we'd publish over the stable
# extension ID. The bundle sub-manifest checks guard the version
# stamping: the About tab and telemetry extension_version read those.
- name: Assert nightly manifest identity
working-directory: staging
env:
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const assert = require("node:assert");
const expected = process.env.EXPECTED_VERSION;
const pkg = require("./package.json");
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
for (const bundle of ["next", "legacy"]) {
const sub = require(`./${bundle}/package.json`);
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
}
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
'
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Package VSIX
working-directory: staging
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
# both standalone bundle workflows. No SendGrid credential is intentionally
# supplied here; inspect the reported artifact before widening the exemption.
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v4
with:
name: cline-nightly-${{ steps.version.outputs.version }}
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
# The job is main-only; step-level dry-run gating still permits a build-only
# rehearsal without publishing or tagging.
- name: Publish to VS Code Marketplace and Open VSX
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
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
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
if [[ -n "$OVSX_PAT" ]]; then
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
else
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
fi
- name: Tag published commit
working-directory: ${{ github.workspace }}
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
# whose commit modifies workflow files (no workflows permission exists
# for it), so this step fails whenever HEAD touched .github/workflows.
# The publish already succeeded by this point — don't mark the run red;
# push the tag manually with user credentials when it matters.
continue-on-error: true
working-directory: next-src
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -127,10 +290,11 @@ jobs:
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
+145
View File
@@ -0,0 +1,145 @@
name: ui-publish
on:
workflow_dispatch:
inputs:
npm_tag:
description: "npm distribution tag"
required: true
type: choice
options:
- next
- latest
default: next
confirm_publish:
description: 'Type "publish" to publish @cline/ui to npm'
required: true
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
quality:
name: UI quality and package checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
- name: Install dependencies
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
- name: Typecheck UI
run: bun -F @cline/ui typecheck
- name: Test UI
run: bun -F @cline/ui test
- name: Build Storybook
run: bun -F @cline/ui build-storybook
- name: Build UI package
run: bun -F @cline/ui build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
- name: Pack publish artifact
id: pack
shell: bash
run: |
set -euo pipefail
pack_dir="$RUNNER_TEMP/ui-npm-pack"
mkdir -p "$pack_dir"
cd sdk/packages/ui
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
test -n "$archive"
echo "archive=$archive" >> "$GITHUB_OUTPUT"
- name: Test packed package
env:
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
- name: Upload publish artifact
uses: actions/upload-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
if-no-files-found: error
retention-days: 7
publish:
name: Publish @cline/ui
if: >-
github.event_name == 'workflow_dispatch' &&
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
runs-on: ubuntu-latest
environment: Publish
permissions:
contents: read
id-token: write
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Download publish artifact
uses: actions/download-artifact@v4
with:
name: ui-npm-package
path: ${{ runner.temp }}/ui-npm-pack
- name: Verify publish tooling
shell: bash
run: |
set -euo pipefail
npm_version=$(npm --version)
echo "npm ${npm_version}"
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
- name: Publish package
shell: bash
env:
NPM_CONFIG_PROVENANCE: "true"
NPM_TAG: ${{ inputs.npm_tag }}
run: |
set -euo pipefail
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
if [ -z "$archive" ]; then
echo "UI package archive was not downloaded"
exit 1
fi
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
exit 1
fi
npm publish "$archive" --tag "$NPM_TAG" --access public
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
+32
View File
@@ -1,5 +1,37 @@
# Cline CLI Changelog
## 3.0.45
- Smaller install: the Claude Code and Codex providers are now optional and loaded on demand, cutting `npm i -g cline` from ~640MB to ~285MB (from SDK v0.0.65)
- Kimi K3 is now available as a ClinePass model (from SDK v0.0.65)
- Runs now retry once after refreshing expired OAuth credentials (from SDK v0.0.65)
- Team runs: the spawn tool is no longer exposed to teammates, and errored teammate runs now report as failed instead of completed (from SDK v0.0.65)
- Hub status output now includes version numbers
- Updated the bundled model catalog (from SDK v0.0.65)
## 3.0.44
- Improved max output token handling across providers (gateway routing, OpenAI vendor, and reasoning models) (from SDK v0.0.64)
- Frontmatter and configuration files that start with a UTF-8 byte order mark (e.g. saved by Windows editors) now parse correctly (from SDK v0.0.64)
## 3.0.43
- The CLI now automatically trusts your operating system's certificate store, so it works behind corporate proxies and TLS-inspecting firewalls without manually setting `NODE_EXTRA_CA_CERTS` (fixes "unable to get local issuer certificate" errors, including Windows intermediate CA stores)
## 3.0.42
- Fixed Ollama native API routing so context window and timeout settings work again
## 3.0.41
- Compaction now shows progress status in the TUI
- Model IDs are now suggested from OpenAI-compatible endpoints when configuring a provider
- Workspace git info (branch/remote) is now persisted and refreshed across sessions
- Compaction no longer runs during an active turn
- Fixed a crash when the terminal title was updated during TUI teardown
- The API key fallback hint is now highlighted for better visibility
- Benign git states are no longer reported as workspace initialization errors
## 3.0.40
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
+15
View File
@@ -346,9 +346,24 @@ Desktop-integrated approval mode is also supported via env wiring (`CLINE_TOOL_A
- `CLINE_LOG_LEVEL` - Runtime log level (`trace|debug|info|warn|error|fatal|silent`, default `info`)
- `CLINE_LOG_PATH` - Runtime log file path (default `<CLINE_DATA_DIR>/logs/cline.log`)
- `CLINE_LOG_NAME` - Logger name embedded in runtime log records
- `CLINE_DEBUG` - Set to `1`/`true` to print wrapper diagnostics (e.g. the CA bundle summary)
`--key` takes precedence over environment variables.
## Certificate trust
The CLI automatically trusts your operating system's certificate store, so it
works behind corporate TLS-inspecting proxies and with self-signed/internal
endpoints without any setup. On launch the `cline` wrapper harvests the OS trust
anchors and writes them to `~/.cline/cli-node-extra-ca-certs.pem`, then points
the runtime's `NODE_EXTRA_CA_CERTS` at that bundle. The file is regenerated when
it changes and is safe to delete (it is rebuilt on the next run).
If you set `NODE_EXTRA_CA_CERTS` yourself, your certificates are **merged** into
that bundle alongside the system store rather than replacing it. Run with
`CLINE_DEBUG=1` to see how many OS and user CAs were loaded and where the bundle
was written.
## Contributing
See [DEVELOPMENT.md](./DEVELOPMENT.md) for local development setup, monorepo structure, and TUI architecture. See [DISTRIBUTION.md](./DISTRIBUTION.md) for how the CLI is packaged and distributed.
+281
View File
@@ -0,0 +1,281 @@
// Auto-discovery of OS trust anchors for the Cline CLI.
//
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
//
// Dependency-free CommonJS with injectable modules so it is unit-testable and
// ships verbatim in the published wrapper package.
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
const CERT_BLOCK =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
/**
* Returns only the complete certificate blocks from PEM text, or null when
* there are none. User files may also hold private keys (combined cert+key
* PEMs) or other sections, which must never be copied into the managed
* bundle. Files that contain nothing but certificates pass through verbatim
* so unchanged bundles keep hash-skipping the rewrite.
*/
function sanitizePem(text) {
const blocks = text.match(CERT_BLOCK) ?? [];
if (blocks.length === 0) {
return null;
}
const rest = text.replace(CERT_BLOCK, "");
if (/^\s*$/.test(rest)) {
return text;
}
return `${blocks.join("\n")}\n`;
}
/**
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
* tls.getCACertificates("system") requires Node >= 22.
*/
function harvestSystemCerts(tlsModule) {
try {
const tls = tlsModule || require("node:tls");
if (typeof tls.getCACertificates !== "function") {
return [];
}
const certs = tls.getCACertificates("system");
if (!Array.isArray(certs)) {
return [];
}
return certs.filter(
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
);
} catch {
return [];
}
}
/**
* Returns the file's certificate blocks as PEM text, or null when missing,
* unreadable, or holding no complete certificate block.
*/
function readUserBundle(fsModule, userPath) {
if (!userPath) {
return null;
}
try {
const fs = fsModule || require("node:fs");
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
if (!stat || !stat.isFile()) {
return null;
}
// Binary DER would not have loaded in the runtime either; require PEM.
return sanitizePem(fs.readFileSync(userPath, "utf8"));
} catch {
return null;
}
}
/**
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
* value as a single file, but some users set an OS-path-delimited list; the
* whole value is tried as one file first, then split.
* The managed bundle is excluded so reading it back never re-appends its certs.
*/
function readUserCerts(fsModule, pathModule, value, managedPath) {
if (!value) {
return [];
}
const fs = fsModule || require("node:fs");
const path = pathModule || require("node:path");
const candidates = [];
const whole = readUserBundle(fs, value);
if (whole) {
candidates.push({ filePath: value, pem: whole });
} else if (value.includes(path.delimiter)) {
for (const segment of value.split(path.delimiter)) {
const trimmed = segment.trim();
if (!trimmed) {
continue;
}
const pem = readUserBundle(fs, trimmed);
if (pem) {
candidates.push({ filePath: trimmed, pem });
}
}
}
const pems = [];
for (const candidate of candidates) {
const isManaged =
managedPath &&
path.resolve(candidate.filePath) === path.resolve(managedPath);
if (!isManaged) {
pems.push(candidate.pem);
}
}
return pems;
}
/**
* Concatenates the user PEMs (if any) and the system certificates into one
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
* markers cannot fuse into one invalid line.
*/
function buildBundle({ systemCerts, userPems }) {
const parts = [...(userPems ?? []), ...systemCerts];
return parts
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
.join("");
}
/** Counts individual PEM certificates across the given bundle strings. */
function countCerts(pems) {
let count = 0;
for (const pem of pems) {
count += pem.split(PEM_MARKER).length - 1;
}
return count;
}
function readFileIfExists(fs, filePath) {
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveClineDir(env, os, path) {
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
}
/**
* True when the api-unavailable warning should print. Stamped per Node version
* in the cline dir so the nudge shows once rather than on every command; a
* version change (upgrade that still falls short, or downgrade) re-arms it.
* When the stamp cannot be read or written, warn — bookkeeping failures must
* never suppress a real diagnostic.
*/
function shouldWarnApiUnavailable(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const version = deps.nodeVersion || process.versions.node;
const dir = resolveClineDir(env, os, path);
const stamp = path.join(dir, `.ca-api-warned-${version}`);
try {
if (fs.existsSync(stamp)) {
return false;
}
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(stamp, "", { mode: 0o600 });
return true;
} catch {
return true;
}
}
/** Atomically writes [content] to [target]; returns true on success. */
function writeBundle(fs, dir, target, content) {
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
try {
fs.mkdirSync(dir, { recursive: true });
// Owner read/write: the bundle holds public CA material, not secrets,
// but there is no reason to make it world-writable.
fs.writeFileSync(tmp, content, { mode: 0o600 });
try {
fs.renameSync(tmp, target);
} catch {
// Windows can reject rename over a file a concurrent child holds open.
fs.rmSync(target, { force: true });
fs.renameSync(tmp, target);
}
return true;
} catch {
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
try {
fs.rmSync(tmp, { force: true });
} catch {
// Ignore: best-effort cleanup.
}
return false;
}
}
/**
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
* in place. Returns an outcome the caller can log; `action` is one of
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
* "no-system-certs" | "api-unavailable".
*/
function configureNodeExtraCaCerts(env, deps = {}) {
const fs = deps.fs || require("node:fs");
const os = deps.os || require("node:os");
const path = deps.path || require("node:path");
const tls = deps.tls || require("node:tls");
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
// harvest cannot run at all, which the caller should surface to the user.
if (typeof tls.getCACertificates !== "function") {
return {
action: "api-unavailable",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const systemCerts = harvestSystemCerts(tls);
if (systemCerts.length === 0) {
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
// and let the runtime fall back to its bundled CAs.
return {
action: "no-system-certs",
path: null,
systemCertCount: 0,
userCertCount: 0,
};
}
const managedDir = resolveClineDir(env, os, path);
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
const userPems = readUserCerts(fs, path, userValue, managedPath);
const bundle = buildBundle({ systemCerts, userPems });
const base = {
path: managedPath,
systemCertCount: systemCerts.length,
userCertCount: countCerts(userPems),
};
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
// and the concurrent-rename race in the steady state.
if (readFileIfExists(fs, managedPath) === bundle) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "unchanged" };
}
if (writeBundle(fs, managedDir, managedPath, bundle)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "written" };
}
// Write failed: fall back to a previously-written bundle if one exists.
if (readFileIfExists(fs, managedPath)) {
env.NODE_EXTRA_CA_CERTS = managedPath;
return { ...base, action: "write-failed-reused" };
}
return { ...base, path: null, action: "write-failed" };
}
module.exports = {
harvestSystemCerts,
sanitizePem,
readUserBundle,
readUserCerts,
buildBundle,
countCerts,
configureNodeExtraCaCerts,
shouldWarnApiUnavailable,
};
+42
View File
@@ -23,6 +23,48 @@ const childEnv = {
CLINE_WRAPPER_PATH: scriptPath,
};
// Auto-discover OS trust anchors and pass them to the Bun child via
// NODE_EXTRA_CA_CERTS. The Bun runtime does not read the OS store on its own,
// so corporate/self-signed CAs would otherwise fail. This wrapper runs on
// Node, which can read the full store here.
try {
const caCerts = require("./ca-certs.cjs");
const outcome = caCerts.configureNodeExtraCaCerts(childEnv);
const debug =
process.env.CLINE_DEBUG === "1" || process.env.CLINE_DEBUG === "true";
// Not debug-gated: on old Nodes the harvest silently doing nothing is
// indistinguishable from a broken corporate proxy. Stamped per Node
// version so the nudge shows once, not on every command.
if (
outcome &&
outcome.action === "api-unavailable" &&
!childEnv.NODE_EXTRA_CA_CERTS &&
caCerts.shouldWarnApiUnavailable(childEnv)
) {
console.warn(
`[cline] Node ${process.versions.node} cannot read the OS trust store (needs >= 22.15); ` +
"corporate or self-signed CAs may fail TLS. Upgrade Node or set NODE_EXTRA_CA_CERTS.",
);
}
if (debug && outcome) {
if (outcome.action === "no-system-certs") {
console.warn(
"[cline] No OS trust anchors found; relying on the runtime's bundled CAs.",
);
} else if (outcome.action === "write-failed") {
console.warn(
"[cline] Could not write the managed CA bundle; relying on the runtime's bundled CAs.",
);
} else {
console.warn(
`[cline] Trust: ${outcome.systemCertCount} OS + ${outcome.userCertCount} user CAs (${outcome.action}) -> ${outcome.path}`,
);
}
}
} catch {
// Best effort: fall back to the runtime's default trust on any failure.
}
function run(target) {
const result = childProcess.spawnSync(target, process.argv.slice(2), {
stdio: "inherit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.40",
"version": "3.0.45",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+367
View File
@@ -0,0 +1,367 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The helper ships as CommonJS in the published wrapper package, so it is
// loaded via require rather than an ESM import.
const caCerts = require("../../bin/ca-certs.cjs") as {
harvestSystemCerts: (tls?: unknown) => string[];
readUserBundle: (fs: unknown, p: string | null) => string | null;
readUserCerts: (
fs: unknown,
path: unknown,
value: string | null,
managedPath: string | null,
) => string[];
buildBundle: (input: {
systemCerts: string[];
userPems?: string[];
}) => string;
countCerts: (pems: string[]) => number;
configureNodeExtraCaCerts: (
env: Record<string, string>,
deps?: { tls?: unknown; fs?: unknown },
) => {
action: string;
path: string | null;
systemCertCount: number;
userCertCount: number;
};
shouldWarnApiUnavailable: (
env: Record<string, string>,
deps?: { fs?: unknown; nodeVersion?: string },
) => boolean;
};
const fs = require("node:fs");
const path = require("node:path");
const certSystem =
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
function fakeTls(certs: unknown) {
return { getCACertificates: () => certs };
}
describe("ca-certs", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("harvestSystemCerts", () => {
it("returns only PEM strings from the system store", () => {
expect(
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
).toEqual([certSystem]);
});
it("returns [] when getCACertificates is unavailable", () => {
expect(caCerts.harvestSystemCerts({})).toEqual([]);
});
it("returns [] when getCACertificates throws", () => {
expect(
caCerts.harvestSystemCerts({
getCACertificates: () => {
throw new Error("nope");
},
}),
).toEqual([]);
});
});
describe("readUserBundle", () => {
it("returns PEM contents for a PEM file", () => {
const p = join(dir, "user.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
});
it("returns null for a non-PEM (DER) file", () => {
const p = join(dir, "user.der");
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
it("returns null for a missing file and for null path", () => {
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
expect(caCerts.readUserBundle(fs, null)).toBeNull();
});
it("strips non-certificate sections such as private keys", () => {
// Combined cert+key files (nginx/haproxy style) are common; the key
// must never reach the managed bundle.
const p = join(dir, "combined.pem");
writeFileSync(
p,
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
);
const out = caCerts.readUserBundle(fs, p);
expect(out).toContain("USER");
expect(out).not.toContain("PRIVATE KEY");
expect(out).not.toContain("SECRET");
});
it("keeps certificates-only files verbatim", () => {
// Byte-identical passthrough keeps the unchanged-skip hash stable.
const p = join(dir, "clean.pem");
writeFileSync(p, `${certUser}\n${certSystem}`);
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
});
it("returns null for a BEGIN marker without a complete block", () => {
const p = join(dir, "truncated.pem");
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
expect(caCerts.readUserBundle(fs, p)).toBeNull();
});
});
describe("readUserCerts", () => {
it("reads a single PEM file path", () => {
const p = join(dir, "corp.pem");
writeFileSync(p, certUser);
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
});
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
const a = join(dir, "a.pem");
const b = join(dir, "b.pem");
writeFileSync(a, certUser);
writeFileSync(b, certSystem);
expect(
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
).toEqual([certUser, certSystem]);
});
it("skips missing segments in a delimited value", () => {
const a = join(dir, "a.pem");
writeFileSync(a, certUser);
const value = [a, join(dir, "missing.pem")].join(delimiter);
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
});
it("excludes the managed bundle from user certs", () => {
const managed = join(dir, "cli-node-extra-ca-certs.pem");
writeFileSync(managed, certUser);
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
});
it("returns [] for empty value", () => {
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
});
});
describe("buildBundle", () => {
it("merges user PEMs before system certs", () => {
expect(
caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
}),
).toBe(`${certUser}\n${certSystem}`);
});
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
// certUser has no trailing newline, so this proves the boundary fix.
const merged = caCerts.buildBundle({
systemCerts: [certSystem],
userPems: [certUser],
});
expect(merged).not.toContain(
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
);
});
it("handles no user PEMs", () => {
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
certSystem,
);
});
});
describe("configureNodeExtraCaCerts", () => {
it("writes a managed bundle and points the env var at it", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.action).toBe("written");
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
});
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
const userPath = join(dir, "corp.pem");
writeFileSync(userPath, certUser);
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: userPath,
};
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
});
expect(out.userCertCount).toBe(1);
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
expect(written).toContain("USER");
expect(written).toContain("SYSTEM");
});
it("reports unchanged and skips rewrite on the second run", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("written");
expect(
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
.action,
).toBe("unchanged");
});
it("does not re-append when the user already points at the managed bundle", () => {
const env: Record<string, string> = { CLINE_DIR: dir };
const first = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
const env2: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: first,
};
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
expect(written.match(/SYSTEM/g)?.length).toBe(1);
});
it("no-ops when no system certs are available", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
expect(out.action).toBe("no-system-certs");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports api-unavailable on Nodes without getCACertificates", () => {
const env: Record<string, string> = {
CLINE_DIR: dir,
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
};
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
expect(out.action).toBe("api-unavailable");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
});
it("reports write-failed when the bundle cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
fs: failingFs,
});
expect(out.action).toBe("write-failed");
expect(out.path).toBeNull();
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
});
it("reuses a stale bundle when the rewrite fails", () => {
// First run writes the bundle normally.
const env: Record<string, string> = { CLINE_DIR: dir };
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
tls: fakeTls([certSystem]),
}).path as string;
// Second run: writes fail, but the stale bundle is still readable.
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env2: Record<string, string> = { CLINE_DIR: dir };
const out = caCerts.configureNodeExtraCaCerts(env2, {
// A different system cert forces a rewrite attempt (not "unchanged").
tls: fakeTls([certUser]),
fs: failingFs,
});
expect(out.action).toBe("write-failed-reused");
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
});
});
describe("countCerts", () => {
it("counts individual certificates, not files", () => {
// One file holding two certs must report 2, not 1.
const twoInOne = `${certUser}\n${certSystem}`;
expect(caCerts.countCerts([twoInOne])).toBe(2);
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
expect(caCerts.countCerts([])).toBe(0);
});
});
describe("shouldWarnApiUnavailable", () => {
it("warns once per Node version, then stays quiet", () => {
const env = { CLINE_DIR: dir };
const deps = { nodeVersion: "22.1.0" };
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
});
it("re-arms when the Node version changes", () => {
const env = { CLINE_DIR: dir };
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
).toBe(true);
expect(
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
).toBe(false);
});
it("still warns when the stamp cannot be written", () => {
const realFs = require("node:fs");
const failingFs = {
...realFs,
mkdirSync: () => {
throw new Error("EACCES");
},
writeFileSync: () => {
throw new Error("EACCES");
},
};
const env = { CLINE_DIR: dir };
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
// Bookkeeping failure must never suppress the diagnostic.
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
});
});
});
+3 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { basename, extname, join } from "node:path";
import {
@@ -15,6 +15,7 @@ import {
type SkillConfig,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { Command } from "commander";
import { getToolCatalog } from "../runtime/tools";
import { loadInteractiveConfigData } from "../tui/interactive-config";
@@ -209,7 +210,7 @@ async function runAgentsConfigCommand(
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
+35
View File
@@ -9,6 +9,7 @@ import {
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { version as cliVersion } from "../../package.json";
import { getCliBuildInfo } from "../utils/common";
const {
@@ -174,6 +175,40 @@ describe("runDoctorCommand", () => {
);
});
it("reports CLI and running hub Core versions", async () => {
const cwd = "/workspace";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.63",
});
mockProbeHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
port: 25463,
pid: 50174,
coreVersion: "0.0.64",
});
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
const output: string[] = [];
const code = await runDoctorCommand(
{ cwd, json: true },
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
);
expect(code).toBe(0);
expect(JSON.parse(output[0] || "")).toMatchObject({
cliVersion,
coreVersion: "0.0.64",
});
});
it("doctor --fix clears wedged hub startup artifacts when no server is actually running", async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "doctor-hub-fix-"));
tempDirs.push(cwd);
+7
View File
@@ -14,6 +14,7 @@ import {
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
@@ -49,6 +50,8 @@ type SpawnedProcessRecord = {
type DoctorStatus = {
cwd: string;
cliVersion: string;
coreVersion?: string;
hubUrl?: string;
hubHealthy: boolean;
hubPid?: number;
@@ -337,6 +340,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
];
return {
cwd,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
hubUrl: current?.url,
hubHealthy: !!health?.url,
hubPid: current?.pid,
@@ -419,6 +424,8 @@ export async function runDoctorCommand(
io.writeln(JSON.stringify(before));
return 0;
}
writeln(`cli version ${c.dim}${before.cliVersion}${c.reset}`);
writeln(`core version ${c.dim}${before.coreVersion ?? "n/a"}${c.reset}`);
writeln(`hub url ${c.dim}${before.hubUrl ?? "none"}${c.reset}`);
writeln(
`hub healthy ${c.dim}${before.hubHealthy ? "yes" : "no"}${before.hubPid ? ` (pid=${before.hubPid})` : ""}${c.reset}`,
+4
View File
@@ -34,6 +34,7 @@ vi.mock("@cline/core", () => ({
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { version as cliVersion } from "../../package.json";
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
@@ -63,6 +64,7 @@ describe("createHubCommand", () => {
port: 25463,
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
coreVersion: "0.0.62",
});
const output: string[] = [];
@@ -88,6 +90,8 @@ describe("createHubCommand", () => {
pid: 50174,
startedAt: "2026-01-01T00:00:00.000Z",
uptime: "1m 5s",
cliVersion,
coreVersion: "0.0.62",
});
});
+3
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
writeln: (text?: string) => void;
@@ -134,6 +135,8 @@ export function createHubCommand(
pid: health?.pid,
startedAt: health?.startedAt,
uptime,
cliVersion,
coreVersion: health?.coreVersion ?? discovery?.coreVersion,
}),
);
}),
+2 -1
View File
@@ -27,6 +27,7 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -195,7 +196,7 @@ function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -18,6 +18,7 @@ import {
resolvePluginConfigSearchPaths,
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
} from "@cline/core";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import { readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
@@ -114,7 +115,7 @@ export async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
+6
View File
@@ -3,9 +3,12 @@
"version": "0.0.1",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
"predev:web": "bun run build:ui",
"dev:web": "next dev webview -p 3125 --turbo",
"dev:sidecar": "bun run sidecar/index.ts",
"dev": "tauri dev",
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
@@ -16,7 +19,10 @@
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
"start": "next start webview",
"pretypecheck": "bun run build:ui",
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
"pretest:chat-ui": "bun run build:ui",
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
@@ -50,6 +50,7 @@ import {
updateMcpSettingsFileSync,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -558,7 +559,7 @@ async function listUserInstructionConfigs(
const ext = extname(entry.name).toLowerCase();
if (ext !== ".yml" && ext !== ".yaml") continue;
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const raw = readFileSyncStrippingUtf8Bom(filePath);
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const fm = fmMatch?.[1] ?? "";
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
@@ -3,6 +3,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@cline/ui/theme/index.css";
@import "@cline/ui/components/agent-chat.css";
@source "../../node_modules/streamdown/dist";
@@ -1,12 +1,27 @@
"use client";
import {
Message as AgentMessage,
Conversation,
ConversationContent,
ConversationScrollButton,
ConversationViewport,
MessageAction,
MessageActions,
MessageContent,
Reasoning,
ReasoningContent,
ReasoningTrigger,
ToolActivity,
ToolActivityCode,
ToolActivityContent,
ToolActivityDetails,
ToolActivityTrigger,
} from "@cline/ui/components/agent-chat";
import {
AlertCircle,
Bot,
BrainIcon,
Check,
ChevronDown,
ChevronRight,
Clock3,
Copy,
FileEdit,
@@ -20,15 +35,7 @@ import {
SquareTerminalIcon,
UndoIcon,
} from "lucide-react";
import {
memo,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { memo, useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
@@ -86,11 +93,9 @@ type AskQuestionRequestItem = {
};
const IS_DEBUG = process.env.NODE_ENV === "test";
const STICKY_BOTTOM_THRESHOLD_PX = 24;
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
function ChatMessagesImpl({
sessionId: _sessionId,
sessionId,
status,
chatTransportState = "connecting",
isSessionSwitching = false,
@@ -105,9 +110,6 @@ function ChatMessagesImpl({
onRestoreCheckpoint,
onForkSession,
}: ChatMessagesProps) {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const scrollContentRef = useRef<HTMLDivElement | null>(null);
const shouldStickToBottomRef = useRef(true);
const hasMessages = messages.length > 0;
const lastErrorMessage = [...messages]
.reverse()
@@ -115,7 +117,6 @@ function ChatMessagesImpl({
const shouldShowErrorBanner =
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [toolApprovalActions, setToolApprovalActions] = useState<
Record<string, "approving" | "rejecting">
>({});
@@ -140,23 +141,6 @@ function ChatMessagesImpl({
const showIdleDetails =
!hasMessages && !isSessionSwitching && !showSwitchTransition;
const getViewport = useCallback(() => {
return scrollAreaRef.current;
}, []);
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = "smooth") => {
const viewport = getViewport();
if (!viewport) {
return;
}
shouldStickToBottomRef.current = true;
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
setShowScrollToBottom((prev) => (prev ? false : prev));
},
[getViewport],
);
useEffect(() => {
if (!isSessionSwitching) {
setShowSwitchTransition((prev) => (prev ? false : prev));
@@ -170,50 +154,6 @@ function ChatMessagesImpl({
};
}, [isSessionSwitching]);
useEffect(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
const updateScrollToBottomVisibility = () => {
const distanceFromBottom =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
shouldStickToBottomRef.current =
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
const shouldShow =
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
setShowScrollToBottom((prev) =>
prev === shouldShow ? prev : shouldShow,
);
};
updateScrollToBottomVisibility();
viewport.addEventListener("scroll", updateScrollToBottomVisibility);
return () => {
viewport.removeEventListener("scroll", updateScrollToBottomVisibility);
};
}, [getViewport]);
useLayoutEffect(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scrollToBottom("auto");
}, [scrollToBottom]);
useEffect(() => {
const content = scrollContentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
const resizeObserver = new ResizeObserver(() => {
if (shouldStickToBottomRef.current) scrollToBottom("auto");
});
resizeObserver.observe(content);
return () => resizeObserver.disconnect();
}, [scrollToBottom]);
useEffect(() => {
const activeRequestIds = new Set(
pendingToolApprovals.map((item) => item.requestId),
@@ -377,17 +317,19 @@ function ChatMessagesImpl({
);
return (
<div className="relative h-full min-h-0 min-w-0">
<div
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
ref={scrollAreaRef}
<Conversation
className="h-full min-h-0 min-w-0"
key={sessionId ?? "new-chat"}
>
<ConversationViewport
aria-label="Agent conversation"
className="h-full min-h-0 min-w-0"
>
<div
<ConversationContent
className={cn(
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
showIdleDetails ? "p-0" : "px-6 py-6",
)}
ref={scrollContentRef}
>
{showIdleDetails ? null : (
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
@@ -497,21 +439,10 @@ function ChatMessagesImpl({
{error}
</div>
) : null}
</div>
</div>
{showScrollToBottom ? (
<Button
className="absolute bottom-4 right-4 z-20 size-9 rounded-full shadow-sm"
onClick={() => scrollToBottom("smooth")}
size="icon"
type="button"
variant="secondary"
>
<ChevronDown className="size-4" />
<span className="sr-only">Scroll to bottom</span>
</Button>
) : null}
</div>
</ConversationContent>
</ConversationViewport>
<ConversationScrollButton />
</Conversation>
);
}
@@ -746,8 +677,6 @@ function MessageBubble({
isUser && Boolean(onCopyRawText || checkpoint);
const keepUserActionsVisible = restorePending || Boolean(restoreError);
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
const hiddenActionButtonsClassName =
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
if (message.role === "tool") {
return <ToolMessageBlock message={message} />;
@@ -760,159 +689,102 @@ function MessageBubble({
const reasoningContent = message.reasoning?.trim() || "";
return (
<div
className={cn(
"flex min-w-0",
isUser ? "justify-end" : "w-full justify-start",
)}
>
<div
className={cn(
"group max-w-full min-w-0 wrap-break-word text-sm",
isUser && "flex max-w-[85%] flex-col items-end gap-1 md:max-w-[50%]",
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
!isUser && !isError && "text-foreground",
isError &&
"bg-destructive/10 border border-destructive/40 text-destructive",
)}
>
<div
className={cn(
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
isUser && "rounded-sm bg-card p-2 text-foreground/80",
)}
>
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
streaming={isStreaming}
/>
) : null}
<AgentMessage from={message.role}>
<MessageContent className="space-y-2 wrap-break-word">
{reasoningContent || message.reasoningRedacted ? (
<ReasoningBlock
content={reasoningContent}
redacted={message.reasoningRedacted === true}
streaming={isStreaming}
/>
) : null}
<div className="my-1 min-w-0 max-w-full wrap-break-word">
<MemoizedMarkdown
content={displayContent || " "}
streaming={isStreaming && message.role === "assistant"}
/>
</div>
<div className="my-1 min-w-0 max-w-full wrap-break-word">
<MemoizedMarkdown
content={displayContent || " "}
streaming={isStreaming && message.role === "assistant"}
/>
</div>
{shouldRenderUserActions ? (
<div className="space-y-1">
<div className="flex h-6 items-center justify-end">
<div
className={cn(
"flex items-center justify-end gap-2",
keepUserActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
</MessageContent>
{shouldRenderUserActions ? (
<>
<MessageActions visible={keepUserActionsVisible}>
{onCopyRawText ? (
<MessageAction
label={wasCopied ? "Copied user message" : "Copy user message"}
onClick={onCopyRawText}
title={wasCopied ? "Copied" : "Copy message"}
>
{onCopyRawText ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label={
wasCopied ? "Copied user message" : "Copy user message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy message"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
) : null}
{checkpoint ? (
<Button
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
aria-label="Restore checkpoint"
disabled={restoreDisabled || restorePending}
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
size="sm"
title="Restore checkpoint"
type="button"
variant="ghost"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UndoIcon className="h-3.5 w-3.5" />
)}
</Button>
) : null}
</div>
</div>
{restoreError ? (
<div className="text-right text-xs text-destructive">
{restoreError}
</div>
{wasCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</MessageAction>
) : null}
</div>
) : null}
{shouldRenderAssistantActions ? (
<div className="flex h-6 items-center hidden">
<div
className={cn(
"flex items-center gap-0",
keepAssistantActionsVisible
? "pointer-events-auto opacity-100"
: hiddenActionButtonsClassName,
)}
>
{onCopyRawText ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
size="sm"
title={wasCopied ? "Copied" : "Copy raw assistant output"}
type="button"
variant="ghost"
>
{wasCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
) : null}
{onForkSession ? (
<Button
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
aria-label="Fork session"
disabled={forkPending}
onClick={onForkSession}
size="sm"
title="Fork session - copy full message history into a new session"
type="button"
variant="ghost"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SplitIcon className="h-3 w-3" />
)}
</Button>
) : null}
{forkError ? (
<span className="text-[11px] text-destructive">
{forkError}
</span>
) : null}
{checkpoint ? (
<MessageAction
disabled={restoreDisabled || restorePending}
label="Restore checkpoint"
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
title="Restore checkpoint"
>
{restorePending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UndoIcon className="h-3.5 w-3.5" />
)}
</MessageAction>
) : null}
</MessageActions>
{restoreError ? (
<div className="text-right text-xs text-destructive">
{restoreError}
</div>
</div>
) : null}
</div>
</div>
) : null}
</>
) : null}
{shouldRenderAssistantActions ? (
<MessageActions visible={keepAssistantActionsVisible}>
{onCopyRawText ? (
<MessageAction
label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
title={wasCopied ? "Copied" : "Copy raw assistant output"}
>
{wasCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</MessageAction>
) : null}
{onForkSession ? (
<MessageAction
disabled={forkPending}
label="Fork session"
onClick={onForkSession}
title="Fork session - copy full message history into a new session"
>
{forkPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SplitIcon className="h-3 w-3" />
)}
</MessageAction>
) : null}
{forkError ? (
<span className="text-[11px] text-destructive">{forkError}</span>
) : null}
</MessageActions>
) : null}
</AgentMessage>
);
}
@@ -925,48 +797,18 @@ function ReasoningBlock({
redacted: boolean;
streaming?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const panelId = useId();
const displayContent = content || (redacted ? "[redacted]" : "");
if (!displayContent) {
return null;
}
return (
<div className="my-2">
<Button
aria-controls={panelId}
aria-expanded={expanded}
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-foreground"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
<BrainIcon aria-hidden="true" className="size-4" />
<span>{streaming ? "Thinking" : "Thought process"}</span>
<span
aria-live="polite"
className="text-xs font-normal text-muted-foreground"
>
{streaming ? "In progress" : "Complete"}
</span>
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</span>
</Button>
{expanded ? (
<div
className="mt-1.5 min-w-0 max-w-full rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground"
id={panelId}
>
<MemoizedMarkdown content={displayContent} streaming={streaming} />
</div>
) : null}
</div>
<Reasoning isStreaming={streaming}>
<ReasoningTrigger />
<ReasoningContent>
<MemoizedMarkdown content={displayContent} streaming={streaming} />
</ReasoningContent>
</Reasoning>
);
}
@@ -1347,8 +1189,6 @@ function buildToolSummaryFromMeta(
}
function ToolMessageBlock({ message }: { message: ChatMessage }) {
const [expanded, setExpanded] = useState(false);
const panelId = useId();
const payload = parseToolPayload(message.content);
const toolName = message.meta?.toolName || payload?.toolName || "tool";
const hookEventName = message.meta?.hookEventName;
@@ -1380,94 +1220,50 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
const hasExpandedSections =
details.length > 0 || Boolean(inputPreview || resultPreview);
const summaryContent = (
<>
{payload?.isError ? (
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-4" />
)}
<span className="min-w-0 wrap-break-word">{summary.label}</span>
{summary.diff ? (
<span className="shrink-0 font-mono text-xs">
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
<span className="text-destructive">-{summary.diff.deletions}</span>
</span>
) : null}
</>
);
return (
<div className="my-2 flex w-full min-w-0 justify-start">
<div
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
>
{hasExpandedSections ? (
<Button
aria-controls={panelId}
aria-expanded={expanded}
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-primary/80"
onClick={() => setExpanded((current) => !current)}
type="button"
variant="ghost"
>
{summaryContent}
<span className="shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</span>
</Button>
) : (
<div className="flex max-w-full items-center justify-start gap-2 py-1 text-left text-sm font-medium text-primary">
{summaryContent}
</div>
)}
{expanded ? (
<div
className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground"
id={panelId}
>
{hasExpandedSections ? (
<div className="space-y-1">
{details.map((detail) => (
<div
className="wrap-break-word"
key={`${message.id}_${detail}`}
>
{detail}
</div>
))}
</div>
) : null}
{inputPreview ? (
<div className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
Input
</div>
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
{inputPreview}
</pre>
</div>
) : null}
{resultPreview ? (
payload?.isError ? (
<div className="mt-1">
<span className="text-destructive">{resultPreview}</span>
</div>
) : (
<div className="space-y-1">
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
{resultPreview}
</pre>
</div>
)
) : null}
<ToolActivity expandable={hasExpandedSections}>
<ToolActivityTrigger
additions={summary.diff?.additions}
deletions={summary.diff?.deletions}
icon={
payload?.isError ? (
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-4" />
)
}
label={summary.label}
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
/>
<ToolActivityContent>
{details.length > 0 ? (
<ToolActivityDetails>
{details.map((detail) => (
<div key={`${message.id}_${detail}`}>{detail}</div>
))}
</ToolActivityDetails>
) : null}
{inputPreview ? (
<div className="space-y-1">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
Input
</div>
<ToolActivityCode className="text-sm">
{inputPreview}
</ToolActivityCode>
</div>
) : null}
</div>
</div>
{resultPreview ? (
payload?.isError ? (
<div className="mt-1 text-destructive">{resultPreview}</div>
) : (
<ToolActivityCode className="max-h-64 text-sm">
{resultPreview}
</ToolActivityCode>
)
) : null}
</ToolActivityContent>
</ToolActivity>
);
}
+202
View File
@@ -0,0 +1,202 @@
# vscode-rollout — A/B loader for the SDK extension rollout
The VS Code Marketplace has no staged rollouts: publishing a version updates
every user. This package lets us ship the **SDK-based extension** (main's
`apps/vscode`, "next") to a percentage of users while everyone else keeps
running the **legacy extension** (the `legacy-extension` branch), inside a
single published VSIX.
## How it works
The published VSIX contains a ~40 KB loader as its entrypoint and two complete,
independently built extension bundles:
```
extension.js ← loader (this package)
package.json ← UNION of both bundles' manifests (generated, see below)
assets/, walkthrough/ ← manifest-referenced resources (VSIX-root-relative)
next/ ← SDK extension (dist/, webview-ui/build/, assets/)
legacy/ ← legacy extension (dist/, webview-ui/build/, assets/, codicons)
```
Per window, the loader:
1. Reads the cached cohort assignment from its own `globalState` keys —
synchronously, never from the network.
2. Sets the `cline.sdkBundle` context key (gates cohort-specific menu items /
palette entries in the union manifest).
3. `require()`s exactly one bundle and calls its `activate()` with a
Proxy-wrapped `ExtensionContext` whose `extensionUri` / `extensionPath` /
`asAbsolutePath` point into that bundle's subdirectory — so each bundle
resolves its own webview build and assets without knowing it was relocated.
Storage properties pass through untouched: both bundles share the same
`~/.cline/data` + VS Code storage they used as standalone extensions.
4. After the selected bundle activates, evaluates the PostHog flags in the
background and caches the assignment **for the next window**. Flag changes
never flip a live window. A crash fallback skips this refresh so it cannot
overwrite the legacy pin.
If the next bundle throws during activation, the loader disposes whatever it
half-registered, pins this VSIX version back to legacy
(`cline.rollout.nextActivationFailedVersion`), reports a `fallback` telemetry
event, and activates legacy — a crashed rollout self-heals without a
marketplace re-publish. A new version gets to try next again.
## Cohort rules
- **Two-way, one knob.** `ext-sdk-bundle-rollout` (percentage flag) is the
entire remote control surface: each background refresh caches exactly what
the flag says for the machine's next window. Dialing the percentage up
promotes; dialing it down demotes on the next reload — the emergency lever
is simply "set the rollout to 0%". Known demotion costs (accepted): tasks
created on the SDK bundle are stored as SDK sessions the legacy bundle
doesn't list (they reappear on re-promotion — nothing is deleted), and
credentials rotated on next may require a re-login on legacy.
- **The flag must stay a boolean flag.** The loader only promotes on a
literal `true` from `/decide` — a multivariate variant, number, or anything
else fails safe to legacy (see `parseRolloutAssignment` + tests). Don't
convert it to multivariate.
- The flag is evaluated against the same PostHog distinct id the extension's
telemetry uses (machine id, mirroring `src/services/logging/distinctId.ts`),
so cohort membership is correlatable with telemetry. Flag evaluation is
always on (matching `FeatureFlagsService`); the loader's own
`extension.rollout.loader_decision` event respects the user's telemetry
opt-out and VS Code's global telemetry switch.
- **Manual overrides, in either direction.** The `cline.rollout.bundleOverride`
user setting (`"auto" | "next" | "legacy"`, editable straight from
settings.json) forces a bundle for anyone — users in a pinch, or us
debugging — beating the remote assignment both ways. Applies on window
reload. `CLINE_BUNDLE_OVERRIDE=next|legacy` (env var) does the same for
local dev and e2e and beats even the setting. Both are reported as
`override` on the loader event so overridden machines don't pollute
cohort comparisons.
- **Crash pinning is local, not remote.** If the next bundle throws during
activation, the loader falls back to legacy in the same window and pins
that VSIX version on this machine (`cline.rollout.nextActivationFailedVersion`);
a new release gets to try next again. This safety net is independent of the
flag.
## The union manifest
`package.json` contributions are static — VS Code reads them before any code
runs — so the shipped manifest must serve both cohorts. `scripts/gen-manifest.mjs`
regenerates it at stitch time from both branches' real manifests:
- Contributions declared by both bundles pass through untouched.
- Menu entries / keybindings declared by only one get `when` AND-ed with
`cline.sdkBundle` / `!cline.sdkBundle`, so a cohort never sees a button its
bundle didn't register (and shared buttons that moved position don't render
twice).
- Commands exclusive to one bundle are hidden from the other cohort's command
palette.
- `views` / `viewsContainers` / `configuration` / `walkthroughs`
**must be identical** in both manifests — they can't be safely gated at
runtime, so divergence fails the build. Keep these static contributions in
sync between the branches. `engines` may diverge: the union takes the newer
requirement (which necessarily satisfies the older one).
Because the manifest is regenerated from both branches on every build,
contribution drift between the branches can't ship silently — it either merges
cleanly or the stitch fails.
## Building locally
```bash
# 1. build both bundles (their own toolchains)
cd apps/vscode && bun run package # next
cd <legacy worktree>/apps/vscode && npm run package # legacy (npm ci first)
# 2. build the loader + stitch + package
cd apps/vscode-rollout
bun run build # dev build; CI uses build:production with the PostHog key
node scripts/stitch.mjs \
--next ../vscode --legacy <legacy worktree>/apps/vscode \
--loader dist/extension.js --version 4.1.0 --out /tmp/cline-ab-staging
node scripts/smoke-loader.mjs /tmp/cline-ab-staging # loader behavior smoke
cd /tmp/cline-ab-staging && vsce package --no-dependencies --allow-package-secrets sendgrid
```
The narrowly scoped `sendgrid` scanner exemption mirrors the existing next and
legacy packaging workflows. This workflow supplies only the existing PostHog
project-key inputs; it does not declare a SendGrid credential. Identify the
exact matching string in production staging output before changing or
broadening the exemption.
Local builds have no `TELEMETRY_SERVICE_API_KEY`, so the loader skips PostHog
entirely and everyone stays on legacy unless `CLINE_BUNDLE_OVERRIDE` is set.
CI: the `ext-vscode-ab-package` workflow (manual dispatch) builds both refs,
stitches, smoke-tests, uploads the `.vsix` artifact, and optionally publishes.
## Nightly channel
The daily `ext-vscode-publish-nightly` workflow (cron + manual dispatch)
publishes this same combined package as **`saoudrizwan.cline-nightly`**. Before
each bundle builds, `scripts/nightlify.mjs` rewrites its manifest to the
nightly identity — the same mutation the standalone nightly always applied
(`apps/vscode/scripts/publish-nightly.mjs` on both branches is the source of
truth), so nightly can be installed alongside stable:
| | stable | nightly |
|---|---|---|
| manifest `name` | `claude-dev` | `cline-nightly` |
| contribution IDs / context key / settings | `cline.*` | `cline-nightly.*` |
| version | operator-supplied (4.1.0+) | `<major>.<minor>.<unix-seconds>` |
The loader derives the namespace from its own `packageJSON.name` at runtime
(`idPrefix` in `src/cohort.ts`), and gen-manifest derives it from the next
manifest's name — no build flags involved. Nightly builds also show a
status-bar indicator (`Cline: Next` / `Cline: Legacy`); stable builds never do.
Dispatching the nightly workflow from `main` with `dry-run` builds and uploads
the installable `.vsix` without publishing or tagging. The publish job is
intentionally restricted to `main` by both the workflow and the
`PublishNightly` environment's deployment-branch policy.
### Telemetry events
- **`extension.rollout.bundle_activated`** (authoritative, captured by the
activated bundle's own telemetry via its `reportRolloutActivation` export;
requires the bundle to be built with `CLINE_ROLLOUT_VARIANT`): attempted vs
actual bundle, fallback flag, error details on fallback. Every other event
from a rollout build carries `extension_variant` as a common property.
- **`extension.rollout.loader_decision`** (loader-owned, direct capture): the
loader-side metadata the bundle event can't know — override source, launch
cadence, loader version, `extension_name` (nightly vs stable) — and the only
signal when BOTH bundles fail (`double_failure: true`).
## Rollout runbook
Until the stable combined VSIX ships, the flag governs **nightly installs
only** — dialing it is safe for production users and is the lever for moving
nightly dogfooders onto next.
1. Create `ext-sdk-bundle-rollout` in PostHog **before** the first publish: a
plain boolean release flag with a percentage rollout, starting at **0%**.
(There is deliberately no kill-switch flag — the assignment is two-way, so
0% *is* the kill switch.)
2. Publish the combined VSIX (version above every previously published one).
With the rollout at 0% this release is behaviorally identical to legacy for
everyone — it only validates the loader plumbing in the wild. Watch
`extension.rollout.bundle_activated` and `extension.rollout.loader_decision`.
3. Dial `ext-sdk-bundle-rollout` up: 1% → 5% → 25% → 100%. Assignments apply on
each machine's next window reload after its flag refresh, so propagation
speed is bounded by how often people reload windows — watch the
`ms_since_last_activation` distribution on loader events to see real
uptake lag before deciding the next step, and compare cohorts by the
`bundle` property.
4. Emergencies: dial the percentage **down** (0% pulls everyone back to legacy
on their next reload). Demoted machines keep settings and creds; tasks
created on the SDK bundle reappear when re-promoted. Ship the fix as a
higher version, then dial back up. Machines whose next bundle *crashed*
are additionally version-pinned to legacy locally, independent of the flag.
5. When next reaches 100% and soaks, retire the loader: publish a plain SDK
extension build and delete this package.
## Version numbering
The combined VSIX owns the marketplace version line and must always exceed the
last version published from either branch (legacy stable was 4.0.x → start at
4.1.0). The bundles' own `package.json` versions ride along inside their
subdirectories for provenance; the loader reports the combined version as
`loader_version`.
+27
View File
@@ -0,0 +1,27 @@
import esbuild from "esbuild";
const production = process.argv.includes("--production");
// Same build-time secret injection scheme as apps/vscode/esbuild.mjs: CI
// provides TELEMETRY_SERVICE_API_KEY; local builds leave it undefined and the
// loader skips all PostHog calls (everyone stays on legacy).
const define = {};
if (process.env.TELEMETRY_SERVICE_API_KEY) {
define["process.env.TELEMETRY_SERVICE_API_KEY"] = JSON.stringify(
process.env.TELEMETRY_SERVICE_API_KEY,
);
}
await esbuild.build({
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
platform: "node",
format: "cjs",
target: "node18",
external: ["vscode"],
minify: production,
sourcemap: !production,
define,
logLevel: "info",
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@cline/vscode-rollout",
"private": true,
"version": "0.1.0",
"description": "Loader + packaging tooling for the staged (A/B) rollout of the SDK-based VS Code extension alongside the legacy extension",
"type": "module",
"scripts": {
"build": "node esbuild.mjs",
"build:production": "node esbuild.mjs --production",
"typecheck": "tsc --noEmit",
"test": "bun test src scripts",
"stitch": "node scripts/stitch.mjs"
},
"devDependencies": {
"@types/node": "20.x",
"@types/vscode": "1.84.0",
"esbuild": "^0.25.0",
"node-machine-id": "^1.1.12",
"typescript": "^5.4.5"
}
}
@@ -0,0 +1,437 @@
/**
* Generate the combined VSIX's package.json as the UNION of the two bundles'
* manifests, regenerated from both branches' actual package.json files at
* stitch time so contribution drift between branches can't ship silently.
*
* Rules:
* - Identity/top-level fields come from the next (main) manifest.
* - `main` points at the loader; `version` comes from the release input.
* - commands / menus / keybindings / activationEvents / icons are unioned.
* Menu entries and keybindings present in only ONE manifest get their
* `when` clause AND-ed with the `<prefix>.sdkBundle` context key (set by the
* loader before activation; prefix follows the manifest identity — see
* src/cohort.ts idPrefix), so a cohort never sees a button whose handler
* its bundle doesn't register — and shared buttons that moved position
* don't show up twice. Commands exclusive to one bundle are likewise hidden
* from the other cohort's command palette.
* - views / viewsContainers / configuration / engines MUST be
* identical in both manifests — they can't be safely gated at runtime, so
* divergence is a hard error.
*
* Usage: node gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]
*/
import { deepStrictEqual } from "node:assert";
import { readFileSync, writeFileSync } from "node:fs";
export function generateManifest(nextPkg, legacyPkg, version) {
for (const field of ["name", "publisher", "main"]) {
if (nextPkg[field] !== legacyPkg[field]) {
throw new Error(
`manifest field "${field}" differs: ${nextPkg[field]} vs ${legacyPkg[field]}`,
);
}
}
for (const field of ["views", "viewsContainers", "configuration"]) {
try {
deepStrictEqual(
nextPkg.contributes?.[field],
legacyPkg.contributes?.[field],
);
} catch {
throw new Error(
`contributes.${field} diverged between bundles — it cannot be gated at runtime; reconcile the branches`,
);
}
}
const engines = unionEngines(nextPkg.engines, legacyPkg.engines);
assertWalkthroughsCompatible(
nextPkg.contributes?.walkthroughs,
legacyPkg.contributes?.walkthroughs,
);
// The nightly packaging rewrites the whole `cline.*` ID namespace to
// `cline-nightly.*` (scripts/nightlify.mjs), so the context key and the
// injected setting must follow the manifest's identity. Keep in sync with
// idPrefix/bundleContextKey/settingSection in src/cohort.ts.
const prefix = nextPkg.name === "cline-nightly" ? "cline-nightly" : "cline";
const nextGate = `${prefix}.sdkBundle`;
const legacyGate = `!${nextGate}`;
const nc = nextPkg.contributes ?? {};
const lc = legacyPkg.contributes ?? {};
const menus = unionMenus(nc.menus, lc.menus, nextGate, legacyGate);
hideExclusiveCommandsFromPalette(
menus,
nc.commands,
lc.commands,
nextGate,
legacyGate,
);
const manifest = {
name: nextPkg.name,
displayName: nextPkg.displayName,
description: nextPkg.description,
version,
icon: nextPkg.icon,
engines,
author: nextPkg.author,
license: nextPkg.license,
publisher: nextPkg.publisher,
repository: nextPkg.repository,
homepage: nextPkg.homepage,
categories: nextPkg.categories,
keywords: nextPkg.keywords,
activationEvents: unionPrimitive(
nextPkg.activationEvents,
legacyPkg.activationEvents,
),
main: "./extension.js",
contributes: {
viewsContainers: nc.viewsContainers,
views: nc.views,
commands: unionBy(
[...(nc.commands ?? []), ...(lc.commands ?? [])],
(c) => c.command,
),
keybindings: unionGated(
nc.keybindings,
lc.keybindings,
nextGate,
legacyGate,
),
menus,
icons: unionIcons(nc.icons, lc.icons),
configuration: injectLoaderConfiguration(nc.configuration, prefix),
walkthroughs: nc.walkthroughs,
},
scripts: {},
};
assertSuperset(manifest, nextPkg, "next", nextGate);
assertSuperset(manifest, legacyPkg, "legacy", legacyGate);
return manifest;
}
/**
* The loader's own user-visible escape hatch, keyed by the manifest identity.
* Neither bundle knows about it; only the loader reads it (src/cohort.ts
* settingSection/SETTING_BUNDLE_OVERRIDE — keep the key and values in sync).
* Injected after the configuration-equality invariant so it can't mask real
* drift between the bundles.
*/
function loaderSettings(prefix) {
return {
[`${prefix}.rollout.bundleOverride`]: {
type: "string",
enum: ["auto", "next", "legacy"],
enumDescriptions: [
"Follow the remote rollout assignment.",
"Force the new (SDK-based) extension.",
"Force the previous (legacy) extension.",
],
default: "auto",
scope: "application",
markdownDescription:
"Manual override for Cline's staged extension rollout. `next` forces the new (SDK-based) extension, `legacy` forces the previous one, `auto` follows the remote rollout assignment. Takes effect on window reload.",
},
};
}
function injectLoaderConfiguration(configuration, prefix) {
const properties = { ...(configuration?.properties ?? {}) };
for (const [key, schema] of Object.entries(loaderSettings(prefix))) {
if (properties[key]) {
throw new Error(
`bundle manifests must not declare loader-owned setting ${key}`,
);
}
properties[key] = schema;
}
return { title: "Cline", ...(configuration ?? {}), properties };
}
/**
* Walkthroughs can't be gated per cohort, and their markdown at the VSIX root
* always comes from the next checkout — so requiring byte-identical manifests
* here would brick releases over copy tweaks while protecting nothing. Only
* STRUCTURE must match (walkthrough/step ids, media paths, completion events —
* the parts code and the manifest reference); when titles/descriptions
* diverge, next's copy ships for everyone and the build says so.
*/
function assertWalkthroughsCompatible(next = [], legacy = []) {
const structure = (walkthroughs) =>
walkthroughs.map((walkthrough) => ({
id: walkthrough.id,
steps: (walkthrough.steps ?? []).map((step) => ({
id: step.id,
media: step.media,
completionEvents: step.completionEvents,
when: step.when,
})),
}));
try {
deepStrictEqual(structure(next), structure(legacy));
} catch {
throw new Error(
"contributes.walkthroughs diverged structurally (ids/media/completionEvents) — reconcile the branches",
);
}
try {
deepStrictEqual(next, legacy);
} catch {
console.warn(
"warning: walkthrough titles/descriptions differ between bundles; shipping next's copy for both cohorts",
);
}
}
/**
* Engines can safely diverge in ONE direction: the union requires whichever
* bundle needs the NEWER host, which necessarily satisfies the other bundle's
* older requirement too. (main routinely bumps the VS Code engine ahead of the
* legacy branch — an equality assertion here would brick every combined build
* over that.) Non-caret/complex ranges we can't compare fail hard rather than
* guessing.
*/
function unionEngines(nextEngines = {}, legacyEngines = {}) {
const union = {};
for (const key of new Set([
...Object.keys(nextEngines),
...Object.keys(legacyEngines),
])) {
const a = nextEngines[key];
const b = legacyEngines[key];
if (a === undefined || b === undefined || a === b) {
union[key] = a ?? b;
continue;
}
const minimum = (range) => {
const match = /^\^(\d+(?:\.\d+)*)$/.exec(range);
return match?.[1];
};
const [minA, minB] = [minimum(a), minimum(b)];
if (!minA || !minB) {
throw new Error(
`engines.${key} diverged with uncomparable ranges: ${a} vs ${b}`,
);
}
union[key] = compareDotted(minA, minB) >= 0 ? a : b;
console.warn(
`warning: engines.${key} differs between bundles (next ${a}, legacy ${b}); union requires ${union[key]}`,
);
}
return union;
}
/** Compare dotted numeric versions. */
function compareDotted(a, b) {
const pa = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
const pb = b.split(".").map((part) => Number.parseInt(part, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function unionPrimitive(a = [], b = []) {
return [...new Set([...a, ...b])];
}
/** Union keeping first occurrence per key (next wins on shared ids). */
function unionBy(items, keyFn) {
const seen = new Map();
for (const item of items) {
const key = keyFn(item);
if (!seen.has(key)) {
seen.set(key, item);
}
}
return [...seen.values()];
}
function sortKeysDeep(value) {
if (Array.isArray(value)) {
return value.map(sortKeysDeep);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortKeysDeep(value[key])]),
);
}
return value;
}
function stableJson(value) {
return JSON.stringify(sortKeysDeep(value));
}
function gateWhen(entry, gate) {
return { ...entry, when: entry.when ? `(${entry.when}) && ${gate}` : gate };
}
/**
* Union two entry lists (menu entries or keybindings): entries declared by
* both bundles pass through untouched; entries declared by only one get their
* `when` AND-ed with that bundle's cohort gate.
*/
function unionGated(
nextEntries = [],
legacyEntries = [],
nextGate,
legacyGate,
) {
const nextSet = new Set(nextEntries.map(stableJson));
const legacySet = new Set(legacyEntries.map(stableJson));
const entries = [];
for (const entry of nextEntries) {
entries.push(
legacySet.has(stableJson(entry)) ? entry : gateWhen(entry, nextGate),
);
}
for (const entry of legacyEntries) {
if (!nextSet.has(stableJson(entry))) {
entries.push(gateWhen(entry, legacyGate));
}
}
return entries;
}
function unionMenus(a = {}, b = {}, nextGate, legacyGate) {
const menus = {};
for (const location of new Set([...Object.keys(a), ...Object.keys(b)])) {
menus[location] = unionGated(
a[location],
b[location],
nextGate,
legacyGate,
);
}
return menus;
}
/**
* A command declared by only one bundle would surface in the other cohort's
* command palette with no registered handler ("command not found" on run).
* Hide it there unless that bundle's own manifest already constrains it.
*/
function hideExclusiveCommandsFromPalette(
menus,
nextCommands = [],
legacyCommands = [],
nextGate,
legacyGate,
) {
const nextIds = new Set(nextCommands.map((c) => c.command));
const legacyIds = new Set(legacyCommands.map((c) => c.command));
const palette = menus.commandPalette ?? (menus.commandPalette = []);
const alreadyListed = new Set(palette.map((e) => e.command));
for (const id of nextIds) {
if (!legacyIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: nextGate });
}
}
for (const id of legacyIds) {
if (!nextIds.has(id) && !alreadyListed.has(id)) {
palette.push({ command: id, when: legacyGate });
}
}
}
function unionIcons(a = {}, b = {}) {
const icons = { ...b, ...a };
for (const id of Object.keys(icons)) {
if (a[id] && b[id] && JSON.stringify(a[id]) !== JSON.stringify(b[id])) {
throw new Error(
`contributes.icons["${id}"] diverged between bundles — icon fonts resolve from the VSIX root`,
);
}
}
return icons;
}
/**
* Every command/keybinding/menu entry/activation event a bundle declares must
* survive the union, either verbatim or with its `when` AND-ed with that
* bundle's cohort gate.
*/
function assertSuperset(manifest, sourcePkg, label, gate) {
const missing = [];
const commandIds = new Set(
manifest.contributes.commands.map((c) => c.command),
);
for (const cmd of sourcePkg.contributes?.commands ?? []) {
if (!commandIds.has(cmd.command)) {
missing.push(`command ${cmd.command}`);
}
}
for (const event of sourcePkg.activationEvents ?? []) {
if (!manifest.activationEvents.includes(event)) {
missing.push(`activationEvent ${event}`);
}
}
const presentOrGated = (unionEntries, entry) => {
const set = new Set((unionEntries ?? []).map(stableJson));
return (
set.has(stableJson(entry)) || set.has(stableJson(gateWhen(entry, gate)))
);
};
for (const kb of sourcePkg.contributes?.keybindings ?? []) {
if (!presentOrGated(manifest.contributes.keybindings, kb)) {
missing.push(`keybinding ${kb.command}`);
}
}
for (const [location, entries] of Object.entries(
sourcePkg.contributes?.menus ?? {},
)) {
for (const entry of entries) {
if (!presentOrGated(manifest.contributes.menus[location], entry)) {
missing.push(
`menu ${location}: ${entry.command ?? JSON.stringify(entry)}`,
);
}
}
}
if (missing.length > 0) {
throw new Error(
`union manifest is missing ${label} contributions:\n ${missing.join("\n ")}`,
);
}
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { next, legacy, version, out } = parseArgs(process.argv);
if (!next || !legacy || !version) {
console.error(
"usage: gen-manifest.mjs --next <pkg.json> --legacy <pkg.json> --version <x.y.z> [--out <file>]",
);
process.exit(1);
}
const manifest = generateManifest(
JSON.parse(readFileSync(next, "utf8")),
JSON.parse(readFileSync(legacy, "utf8")),
version,
);
const json = `${JSON.stringify(manifest, null, "\t")}\n`;
if (out) {
writeFileSync(out, json);
console.log(`wrote ${out}`);
} else {
process.stdout.write(json);
}
}
@@ -0,0 +1,382 @@
import { describe, expect, it } from "bun:test";
import { generateManifest } from "./gen-manifest.mjs";
const shared = {
name: "claude-dev",
publisher: "saoudrizwan",
main: "./dist/extension.js",
engines: { vscode: "^1.84.0" },
displayName: "Cline",
};
function pkg(overrides) {
return {
...shared,
activationEvents: ["onStartupFinished"],
contributes: {
viewsContainers: {
activitybar: [{ id: "c", title: "Cline", icon: "assets/icon.svg" }],
},
views: { c: [{ type: "webview", id: "claude-dev.SidebarProvider" }] },
commands: [],
keybindings: [],
menus: {},
icons: {},
...overrides.contributes,
},
...Object.fromEntries(
Object.entries(overrides).filter(([k]) => k !== "contributes"),
),
};
}
describe("generateManifest", () => {
it("unions commands, menus, keybindings and activation events", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.a", title: "A" },
{ command: "cline.shared", title: "S" },
],
menus: { "view/title": [{ command: "cline.a", when: "x" }] },
},
});
const legacy = pkg({
activationEvents: ["onStartupFinished", "workspaceContains:evals.env"],
contributes: {
commands: [
{ command: "cline.b", title: "B" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [{ command: "cline.b", when: "y" }],
"comments/commentThread/title": [{ command: "cline.b" }],
},
keybindings: [{ command: "cline.b", key: "ctrl+k" }],
},
});
const manifest = generateManifest(next, legacy, "4.1.0");
expect(manifest.version).toBe("4.1.0");
expect(manifest.main).toBe("./extension.js");
expect(manifest.contributes.commands.map((c) => c.command).sort()).toEqual([
"cline.a",
"cline.b",
"cline.shared",
]);
expect(manifest.contributes.menus["view/title"]).toHaveLength(2);
expect(
manifest.contributes.menus["comments/commentThread/title"],
).toHaveLength(1);
expect(manifest.contributes.keybindings).toHaveLength(1);
expect(manifest.activationEvents).toContain("workspaceContains:evals.env");
});
it("gates cohort-exclusive menu entries and keybindings on the context key", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.a", title: "A" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [
{ command: "cline.a", when: "x" },
{ command: "cline.shared", when: "v" },
],
},
},
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.b", title: "B" },
{ command: "cline.shared", title: "S" },
],
menus: {
"view/title": [
{ command: "cline.b", when: "y" },
{ command: "cline.shared", when: "v" },
],
},
keybindings: [{ command: "cline.b", key: "ctrl+k", when: "focus" }],
},
});
const manifest = generateManifest(next, legacy, "4.1.0");
const viewTitle = manifest.contributes.menus["view/title"];
expect(viewTitle.find((e) => e.command === "cline.a").when).toBe(
"(x) && cline.sdkBundle",
);
expect(viewTitle.find((e) => e.command === "cline.b").when).toBe(
"(y) && !cline.sdkBundle",
);
expect(viewTitle.find((e) => e.command === "cline.shared").when).toBe("v");
expect(manifest.contributes.keybindings[0].when).toBe(
"(focus) && !cline.sdkBundle",
);
});
it("hides cohort-exclusive commands from the other cohort's palette", () => {
const next = pkg({
contributes: {
commands: [
{ command: "cline.nextOnly", title: "N" },
{ command: "cline.shared", title: "S" },
],
},
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.legacyOnly", title: "L" },
{ command: "cline.shared", title: "S" },
],
},
});
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
.commandPalette;
expect(palette).toContainEqual({
command: "cline.nextOnly",
when: "cline.sdkBundle",
});
expect(palette).toContainEqual({
command: "cline.legacyOnly",
when: "!cline.sdkBundle",
});
expect(palette.find((e) => e.command === "cline.shared")).toBeUndefined();
});
it("leaves commands alone when a bundle already declares a palette entry for them", () => {
const next = pkg({
contributes: { commands: [{ command: "cline.shared", title: "S" }] },
});
const legacy = pkg({
contributes: {
commands: [
{ command: "cline.hidden", title: "H" },
{ command: "cline.shared", title: "S" },
],
menus: { commandPalette: [{ command: "cline.hidden", when: "false" }] },
},
});
const palette = generateManifest(next, legacy, "4.1.0").contributes.menus
.commandPalette;
expect(palette.filter((e) => e.command === "cline.hidden")).toEqual([
{ command: "cline.hidden", when: "(false) && !cline.sdkBundle" },
]);
});
it("dedupes structurally identical menu entries", () => {
const entry = { command: "cline.a", when: "view == cline" };
const next = pkg({
contributes: {
commands: [{ command: "cline.a", title: "A" }],
menus: { "view/title": [entry] },
},
});
const legacy = pkg({
contributes: {
commands: [{ command: "cline.a", title: "A" }],
menus: { "view/title": [{ ...entry }] },
},
});
expect(
generateManifest(next, legacy, "1.0.0").contributes.menus["view/title"],
).toHaveLength(1);
});
it("rejects diverged views/viewsContainers", () => {
const next = pkg({});
const legacy = pkg({
contributes: { views: { c: [{ type: "webview", id: "other" }] } },
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/views/);
});
it("rejects structurally diverged walkthroughs", () => {
const walkthrough = (stepId, media) => ({
contributes: {
walkthroughs: [
{
id: "ClineWalkthrough",
title: "Meet Cline",
steps: [{ id: stepId, title: "Start here", media }],
},
],
},
});
expect(() =>
generateManifest(
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
pkg(walkthrough("hello", { markdown: "walkthrough/step1.md" })),
"1.0.0",
),
).toThrow(/walkthroughs diverged structurally/);
expect(() =>
generateManifest(
pkg(walkthrough("welcome", { markdown: "walkthrough/step1.md" })),
pkg(walkthrough("welcome", { markdown: "walkthrough/other.md" })),
"1.0.0",
),
).toThrow(/walkthroughs diverged structurally/);
});
it("tolerates copy-only walkthrough divergence, shipping next's text", () => {
const walkthrough = (description) => ({
contributes: {
walkthroughs: [
{
id: "ClineWalkthrough",
title: "Meet Cline",
steps: [
{
id: "welcome",
title: "Start here",
description,
media: { markdown: "walkthrough/step1.md" },
},
],
},
],
},
});
const manifest = generateManifest(
pkg(walkthrough("Connect via MCP.")),
pkg(walkthrough("Discover the MCP Marketplace.")),
"1.0.0",
);
expect(manifest.contributes.walkthroughs[0].steps[0].description).toBe(
"Connect via MCP.",
);
});
it("rejects diverged configuration", () => {
const next = pkg({
contributes: {
configuration: {
title: "Cline",
properties: {
"cline.enabled": { type: "boolean", default: false },
},
},
},
});
const legacy = pkg({
contributes: {
configuration: {
title: "Cline",
properties: {
"cline.enabled": { type: "boolean", default: 0 },
},
},
},
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(
/contributes\.configuration diverged/,
);
});
it("injects the loader-owned bundleOverride setting into the union", () => {
const manifest = generateManifest(pkg({}), pkg({}), "4.1.0");
const prop =
manifest.contributes.configuration.properties[
"cline.rollout.bundleOverride"
];
expect(prop).toBeDefined();
expect(prop.enum).toEqual(["auto", "next", "legacy"]);
expect(prop.default).toBe("auto");
expect(prop.scope).toBe("application");
});
it("rejects bundles that declare the loader-owned setting themselves", () => {
const withClash = {
contributes: {
configuration: {
title: "Cline",
properties: { "cline.rollout.bundleOverride": { type: "string" } },
},
},
};
expect(() =>
generateManifest(pkg(withClash), pkg(withClash), "4.1.0"),
).toThrow(/loader-owned setting/);
});
it("derives gates and the injected setting from the nightly identity", () => {
const nightly = (overrides) => ({
...pkg(overrides),
name: "cline-nightly",
displayName: "Cline (Nightly)",
});
const next = nightly({
contributes: {
commands: [{ command: "cline-nightly.nextOnly", title: "N" }],
menus: {
"view/title": [{ command: "cline-nightly.nextOnly", when: "x" }],
},
},
});
const legacy = nightly({
contributes: {
commands: [{ command: "cline-nightly.legacyOnly", title: "L" }],
keybindings: [{ command: "cline-nightly.legacyOnly", key: "ctrl+k" }],
},
});
const manifest = generateManifest(next, legacy, "4.0.1752600000");
expect(manifest.name).toBe("cline-nightly");
expect(manifest.contributes.menus["view/title"][0].when).toBe(
"(x) && cline-nightly.sdkBundle",
);
expect(manifest.contributes.keybindings[0].when).toBe(
"!cline-nightly.sdkBundle",
);
expect(manifest.contributes.menus.commandPalette).toContainEqual({
command: "cline-nightly.legacyOnly",
when: "!cline-nightly.sdkBundle",
});
const properties = manifest.contributes.configuration.properties;
expect(properties["cline-nightly.rollout.bundleOverride"]).toBeDefined();
expect(properties["cline.rollout.bundleOverride"]).toBeUndefined();
});
it("unions diverged engines to the newer requirement (either direction)", () => {
const olderLegacy = { ...pkg({}), engines: { vscode: "^1.74.0" } };
expect(generateManifest(pkg({}), olderLegacy, "1.0.0").engines).toEqual({
vscode: "^1.84.0",
});
const newerLegacy = { ...pkg({}), engines: { vscode: "^1.101.0" } };
expect(generateManifest(pkg({}), newerLegacy, "1.0.0").engines).toEqual({
vscode: "^1.101.0",
});
});
it("rejects diverged engines it cannot compare", () => {
const legacy = { ...pkg({}), engines: { vscode: ">=1.84.0 <2.0.0" } };
expect(() => generateManifest(pkg({}), legacy, "1.0.0")).toThrow(
/uncomparable/,
);
});
it("rejects conflicting icon definitions", () => {
const next = pkg({
contributes: {
icons: {
"cline-logo": {
description: "d",
default: { fontPath: "a.woff", fontCharacter: "\\E900" },
},
},
},
});
const legacy = pkg({
contributes: {
icons: {
"cline-logo": {
description: "d",
default: { fontPath: "b.woff", fontCharacter: "\\E900" },
},
},
},
});
expect(() => generateManifest(next, legacy, "1.0.0")).toThrow(/icons/);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* Rewrite an apps/vscode package.json to the nightly identity, in place.
*
* Reproduces updatePackageJson() from apps/vscode/scripts/publish-nightly.mjs
* (the same script exists on BOTH main and legacy-extension — those copies are
* the source of truth for the mutation; if they change, change this too):
* - textual rewrites: "claude-dev" -> "cline-nightly" everywhere, and every
* `"cline.` ID prefix -> `"cline-nightly.` (commands, settings, view IDs,
* when-clauses that START with the key — mid-string references like
* `config.cline.x` are NOT rewritten, same as the standalone nightly)
* - name / displayName / activity bar title / version
*
* Differences from publish-nightly.mjs, on purpose:
* - the version is an explicit ARGUMENT, not computed here: the combined
* VSIX applies ONE version to the next bundle, the legacy bundle, and the
* union manifest, so gen-manifest's identity-equality assertions hold.
* - no backup/restore, README swapping, or workspace-self-link reconciling:
* this runs against a disposable CI checkout, BEFORE the bundle build and
* never followed by vsce in that checkout (vsce only runs in the stitched
* staging dir with --no-dependencies).
*
* Run it AFTER dependency install (the workspace self-link resolution keys off
* the original package name) and BEFORE the bundle's package build.
*
* Usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export const NIGHTLY_NAME = "cline-nightly";
export const NIGHTLY_DISPLAY_NAME = "Cline (Nightly)";
export function nightlifyPackageJson(rawContent, version) {
if (!version) {
throw new Error("version is required");
}
const content = rawContent
.replaceAll("claude-dev", NIGHTLY_NAME)
.replaceAll('"cline.', `"${NIGHTLY_NAME}.`);
const pkg = JSON.parse(content);
pkg.name = NIGHTLY_NAME;
pkg.displayName = NIGHTLY_DISPLAY_NAME;
pkg.version = version;
// publish-nightly.mjs assigns `.title` on the activitybar value directly,
// which is a silent no-op on the real manifest (activitybar is an ARRAY —
// JSON.stringify drops non-index properties). Retitle the actual entries.
const activitybar = pkg.contributes?.viewsContainers?.activitybar;
for (const container of Array.isArray(activitybar) ? activitybar : []) {
container.title = NIGHTLY_DISPLAY_NAME;
}
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
const { dir, version } = parseArgs(process.argv);
if (!dir || !version) {
console.error(
"usage: node nightlify.mjs --dir <apps/vscode checkout> --version <x.y.ts>",
);
process.exit(1);
}
const packageJsonPath = path.join(dir, "package.json");
const before = readFileSync(packageJsonPath, "utf8");
const beforeName = JSON.parse(before).name;
writeFileSync(packageJsonPath, nightlifyPackageJson(before, version));
console.log(
`nightlified ${packageJsonPath}: ${beforeName} -> ${NIGHTLY_NAME}@${version}`,
);
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test";
import { nightlifyPackageJson } from "./nightlify.mjs";
const fixture = {
name: "claude-dev",
displayName: "Cline",
publisher: "saoudrizwan",
version: "4.0.0",
main: "./dist/extension.js",
contributes: {
viewsContainers: {
activitybar: [
{
id: "claude-dev-ActivityBar",
title: "Cline",
icon: "assets/icon.svg",
},
],
},
views: {
"claude-dev-ActivityBar": [
{ type: "webview", id: "claude-dev.SidebarProvider" },
],
},
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
keybindings: [{ command: "cline.addToChat", key: "ctrl+'" }],
menus: {
"view/title": [
{
command: "cline.plusButtonClicked",
when: "view == claude-dev.SidebarProvider",
},
// Mid-string references are NOT rewritten — a known limitation
// shared with the standalone nightly's publish-nightly.mjs.
{ command: "cline.addToChat", when: "config.cline.enableExtras" },
],
},
configuration: {
title: "Cline",
properties: { "cline.enableExtras": { type: "boolean" } },
},
},
};
describe("nightlifyPackageJson", () => {
const pkg = JSON.parse(
nightlifyPackageJson(JSON.stringify(fixture, null, "\t"), "4.0.1752600000"),
);
it("sets the nightly identity and the supplied version", () => {
expect(pkg.name).toBe("cline-nightly");
expect(pkg.displayName).toBe("Cline (Nightly)");
expect(pkg.version).toBe("4.0.1752600000");
expect(pkg.publisher).toBe("saoudrizwan");
});
it("rewrites claude-dev IDs and the cline.* namespace", () => {
expect(pkg.contributes.viewsContainers.activitybar[0].id).toBe(
"cline-nightly-ActivityBar",
);
expect(pkg.contributes.viewsContainers.activitybar[0].title).toBe(
"Cline (Nightly)",
);
expect(Object.keys(pkg.contributes.views)).toEqual([
"cline-nightly-ActivityBar",
]);
expect(pkg.contributes.views["cline-nightly-ActivityBar"][0].id).toBe(
"cline-nightly.SidebarProvider",
);
expect(pkg.contributes.commands[0].command).toBe(
"cline-nightly.plusButtonClicked",
);
expect(pkg.contributes.keybindings[0].command).toBe(
"cline-nightly.addToChat",
);
expect(Object.keys(pkg.contributes.configuration.properties)).toEqual([
"cline-nightly.enableExtras",
]);
});
it("rewrites when-clauses that start with a rewritten ID, but not mid-string references", () => {
const [gated, midString] = pkg.contributes.menus["view/title"];
expect(gated.when).toBe("view == cline-nightly.SidebarProvider");
// Documented limitation: `config.cline.` does not match the `"cline.`
// pattern, so it survives unrewritten (matches publish-nightly.mjs).
expect(midString.when).toBe("config.cline.enableExtras");
});
it("requires a version", () => {
expect(() => nightlifyPackageJson("{}", undefined)).toThrow(/version/);
});
});
@@ -0,0 +1,55 @@
/**
* Stamp the combined VSIX's version into a bundle checkout's package.json,
* in place, BEFORE that bundle builds.
*
* Why: the union manifest's version (what the Marketplace and auto-update
* see) is supplied at stitch time, but each bundle's runtime reads its OWN
* package.json — the About tab and every telemetry event's extension_version
* come from there. Without this stamp the stable combined VSIX would report
* three different versions (union input, main's base version, legacy's base
* version) depending on where you look, which turns user bug reports into
* archaeology. The nightly path gets the same alignment via nightlify.mjs
* (which also rewrites identity); this script is the identity-preserving
* version-only equivalent for the stable channel.
*
* Usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>
*/
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
export function setPackageVersion(rawContent, version) {
if (!version) {
throw new Error("version is required");
}
const pkg = JSON.parse(rawContent);
pkg.version = version;
return `${JSON.stringify(pkg, null, "\t")}\n`;
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
const { dir, version } = parseArgs(process.argv);
if (!dir || !version) {
console.error(
"usage: node set-version.mjs --dir <apps/vscode checkout> --version <x.y.z>",
);
process.exit(1);
}
const packageJsonPath = path.join(dir, "package.json");
const before = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
writeFileSync(
packageJsonPath,
setPackageVersion(readFileSync(packageJsonPath, "utf8"), version),
);
console.log(`set ${packageJsonPath} version: ${before} -> ${version}`);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "bun:test";
import { setPackageVersion } from "./set-version.mjs";
const fixture = JSON.stringify(
{
name: "claude-dev",
displayName: "Cline",
publisher: "saoudrizwan",
version: "4.0.0",
contributes: {
commands: [{ command: "cline.plusButtonClicked", title: "New Task" }],
},
},
null,
"\t",
);
describe("setPackageVersion", () => {
it("stamps the version and touches nothing else", () => {
const pkg = JSON.parse(setPackageVersion(fixture, "4.1.0"));
expect(pkg.version).toBe("4.1.0");
expect(pkg.name).toBe("claude-dev");
expect(pkg.displayName).toBe("Cline");
expect(pkg.publisher).toBe("saoudrizwan");
expect(pkg.contributes.commands[0].command).toBe(
"cline.plusButtonClicked",
);
});
it("requires a version", () => {
expect(() => setPackageVersion(fixture, undefined)).toThrow(/version/);
expect(() => setPackageVersion(fixture, "")).toThrow(/version/);
});
});
@@ -0,0 +1,671 @@
/**
* Node-level smoke test for the built loader against a staged VSIX directory.
* No real VS Code: `vscode` is stubbed just enough for the loader itself, and
* the staging dir's next/legacy bundles are swapped for tiny recorders. Verifies
* the loader's end-to-end behavior in a real require() environment:
* 1. default (no cached cohort) -> activates legacy
* 2. cached cohort "next" -> activates next, scoped context paths
* 3. the flag refresh caches a TWO-WAY assignment for the next window
* (rollout on promotes, rollout off demotes a cached "next")
* 4. CLINE_BUNDLE_OVERRIDE / the cline.rollout.bundleOverride setting
* force a bundle in either direction
* 5. next activation throws -> disposes partial registrations, falls
* back to legacy, pins version, and
* skips the cohort refresh
* 6. the activated bundle's reportRolloutActivation export receives the
* authoritative attempted/actual/fallback record (and its absence is
* tolerated); the loader's own loader_decision capture fires exactly
* once per window
* 7. the nightly identity (manifest name cline-nightly) switches the
* setting section + context key namespace and shows the status bar
* bundle indicator
* 8. both bundles throwing surfaces the failure and captures a
* double_failure loader event
*
* Usage: node smoke-loader.mjs <staging-dir>
* Copies the staging dir to a temp sandbox; the input is never modified.
*/
import assert from "node:assert";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import Module from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
const staging = process.argv[2];
if (!staging) {
console.error("usage: node smoke-loader.mjs <staging-dir>");
process.exit(1);
}
// ---- vscode API stub (only what the loader touches) -------------------------
const executedCommands = [];
const statusBarItems = [];
function makeVscodeStub(
sandbox,
settings = {},
{ telemetryEnabled = false } = {},
) {
return {
Uri: {
file: (fsPath) => ({ fsPath, path: fsPath, scheme: "file" }),
joinPath: (base, ...segments) => {
const fsPath = path.join(base.fsPath, ...segments);
return { fsPath, path: fsPath, scheme: "file" };
},
},
commands: {
executeCommand: async (command, ...args) => {
executedCommands.push([command, ...args]);
},
},
workspace: {
getConfiguration: (section) => ({
get: (key) => settings[`${section}.${key}`],
}),
},
window: {
createStatusBarItem: () => {
const item = {
text: "",
tooltip: "",
shown: false,
show() {
this.shown = true;
},
dispose() {},
};
statusBarItems.push(item);
return item;
},
},
StatusBarAlignment: { Left: 1, Right: 2 },
env: { machineId: "smoke-machine", isTelemetryEnabled: telemetryEnabled },
version: "0.0.0-smoke",
_sandbox: sandbox,
};
}
function makeContext(sandbox, globalStateSeed = {}, packageJSON = {}) {
const state = new Map(Object.entries(globalStateSeed));
return {
extensionUri: { fsPath: sandbox, path: sandbox, scheme: "file" },
extensionPath: sandbox,
extension: { packageJSON: { version: "4.1.0-smoke", ...packageJSON } },
subscriptions: [],
globalState: {
get: (key) => state.get(key),
update: async (key, value) => void state.set(key, value),
_dump: () => Object.fromEntries(state),
},
asAbsolutePath: (rel) => path.join(sandbox, rel),
};
}
/** PostHog /capture/ POSTs recorded by a scenario's fetch stub, parsed. */
function captureCalls(fetchCalls) {
return fetchCalls
.filter(([url]) => String(url).includes("/capture/"))
.map(([, init]) => JSON.parse(init.body));
}
function captureEvents(fetchCalls, event) {
return captureCalls(fetchCalls).filter((capture) => capture.event === event);
}
function loaderDecisionCaptures(fetchCalls) {
return captureEvents(fetchCalls, "extension.rollout.loader_decision");
}
function featureFlagCalledCaptures(fetchCalls) {
return captureEvents(fetchCalls, "$feature_flag_called");
}
function decideCalls(fetchCalls) {
return fetchCalls.filter(([url]) => String(url).includes("/decide"));
}
function flagResponse(flags = { rollout: false }) {
return {
ok: true,
json: async () => ({
featureFlags: {
"ext-sdk-bundle-rollout": flags.rollout,
},
}),
};
}
function makeFlagFetch(flags) {
const calls = [];
const fetch = async (...args) => {
calls.push(args);
return flagResponse(flags);
};
return { calls, fetch };
}
function makeDeferredFlagFetch(flags) {
const calls = [];
let resolveResponse;
let markStarted;
const started = new Promise((resolve) => {
markStarted = resolve;
});
const fetch = (...args) => {
calls.push(args);
markStarted();
return new Promise((resolve) => {
resolveResponse = () => resolve(flagResponse(flags));
});
};
return {
calls,
fetch,
started,
resolve: () => resolveResponse?.(),
};
}
async function waitFor(predicate, message, timeoutMs = 500) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) {
assert.fail(message);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
async function flushAsyncWork() {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
// ---- sandbox setup -----------------------------------------------------------
function makeSandbox({
nextThrows = false,
legacyThrows = false,
omitReportExport = false,
} = {}) {
const sandbox = mkdtempSync(path.join(tmpdir(), "cline-ab-smoke-"));
cpSync(
path.join(staging, "extension.js"),
path.join(sandbox, "extension.js"),
);
for (const bundle of ["next", "legacy"]) {
mkdirSync(path.join(sandbox, bundle, "dist"), { recursive: true });
const throws =
(bundle === "next" && nextThrows) ||
(bundle === "legacy" && legacyThrows);
const throwLine = throws
? `await global.__smoke.beforeNextFailure?.();\n\t\tctx.subscriptions.push({ dispose() { global.__smoke.disposed.push("${bundle}") } });\n\t\tthrow new Error("smoke: ${bundle} activation exploded");`
: "";
// Mirrors the reportRolloutActivation export both real bundles gained in
// their rollout-telemetry PRs; recorded so scenarios can assert the
// authoritative attempted/actual/fallback record.
const reportExport = omitReportExport
? ""
: `exports.reportRolloutActivation = async (input) => { global.__smoke.reports.push({ reporter: "${bundle}", attemptedBundle: input.attemptedBundle, actualBundle: input.actualBundle, fallback: input.fallback, hasError: input.error !== undefined }); };`;
writeFileSync(
path.join(sandbox, bundle, "dist", "extension.js"),
`exports.activate = async (ctx) => {
${throwLine}
global.__smoke.activated.push({ bundle: "${bundle}", extensionPath: ctx.extensionPath, asAbs: ctx.asAbsolutePath("webview-ui/build") });
return { bundle: "${bundle}" };
};
exports.deactivate = () => { global.__smoke.deactivated.push("${bundle}"); };
${reportExport}`,
);
}
mkdirSync(path.join(sandbox, "data"), { recursive: true });
writeFileSync(
path.join(sandbox, "data", "globalState.json"),
JSON.stringify({ "cline.generatedMachineId": "smoke-machine" }),
);
return sandbox;
}
async function runScenario(
name,
{
seed = {},
env = {},
settings = {},
nextThrows = false,
legacyThrows = false,
omitReportExport = false,
telemetryEnabled = false,
contextPackageJSON = {},
expectFailure = false,
fetchController = makeFlagFetch(),
beforeNextFailure,
expectRefresh = true,
},
checks,
afterDeactivateChecks = async () => {},
) {
const sandbox = makeSandbox({ nextThrows, legacyThrows, omitReportExport });
global.__smoke = {
activated: [],
deactivated: [],
disposed: [],
reports: [],
beforeNextFailure,
};
executedCommands.length = 0;
statusBarItems.length = 0;
const previousEnv = {};
const scenarioEnv = {
CLINE_DIR: sandbox,
// A dev build leaves this lookup dynamic; production builds inline the
// real PostHog key. Either way, the smoke must exercise refreshCohort.
TELEMETRY_SERVICE_API_KEY: "smoke-posthog-project-key",
...env,
};
for (const [key, value] of Object.entries(scenarioEnv)) {
previousEnv[key] = process.env[key];
process.env[key] = value;
}
const originalFetch = global.fetch;
global.fetch = fetchController.fetch;
const originalResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...rest) {
if (request === "vscode") {
return "vscode";
}
return originalResolve.call(this, request, ...rest);
};
require.cache.vscode = {
id: "vscode",
filename: "vscode",
loaded: true,
exports: makeVscodeStub(sandbox, settings, { telemetryEnabled }),
};
try {
const loaderPath = path.join(sandbox, "extension.js");
delete require.cache[loaderPath];
const loader = require(loaderPath);
const context = makeContext(sandbox, seed, contextPackageJSON);
let api;
let activationError;
try {
api = await loader.activate(context);
} catch (error) {
activationError = error;
}
if (expectFailure) {
assert.ok(activationError, `${name} should have failed to activate`);
} else if (activationError) {
throw activationError;
}
if (expectRefresh) {
await waitFor(
() => decideCalls(fetchController.calls).length > 0,
`${name} did not refresh its cohort after activation`,
);
await flushAsyncWork();
assert.equal(
decideCalls(fetchController.calls).length,
1,
`${name} should refresh its cohort exactly once`,
);
}
await checks({
context,
api,
activationError,
sandbox,
fetchCalls: fetchController.calls,
});
await loader.deactivate();
await afterDeactivateChecks({ context, api, sandbox });
console.log(`PASS ${name}`);
} finally {
Module._resolveFilename = originalResolve;
delete require.cache.vscode;
if (originalFetch === undefined) {
delete global.fetch;
} else {
global.fetch = originalFetch;
}
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
rmSync(sandbox, { recursive: true, force: true });
}
}
const require = Module.createRequire(import.meta.url);
await runScenario("default cohort -> legacy", {}, async ({ api, sandbox }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.equal(
global.__smoke.activated[0].extensionPath,
path.join(sandbox, "legacy"),
);
assert.deepEqual(executedCommands[0], [
"setContext",
"cline.sdkBundle",
false,
]);
assert.deepEqual(global.__smoke.deactivated, []);
// The activated bundle received the authoritative activation record.
assert.deepEqual(global.__smoke.reports, [
{
reporter: "legacy",
attemptedBundle: "legacy",
actualBundle: "legacy",
fallback: false,
hasError: false,
},
]);
// Stable identity: no nightly status bar indicator.
assert.equal(statusBarItems.length, 0);
});
await runScenario(
"cached next -> next with scoped paths",
{ seed: { "cline.rollout.bundle": "next" } },
async ({ api, sandbox }) => {
assert.deepEqual(api, { bundle: "next" });
const activation = global.__smoke.activated[0];
assert.equal(activation.extensionPath, path.join(sandbox, "next"));
assert.equal(
activation.asAbs,
path.join(sandbox, "next", "webview-ui", "build"),
);
assert.deepEqual(executedCommands[0], [
"setContext",
"cline.sdkBundle",
true,
]);
assert.deepEqual(global.__smoke.reports, [
{
reporter: "next",
attemptedBundle: "next",
actualBundle: "next",
fallback: false,
hasError: false,
},
]);
},
);
await runScenario(
"rollout flag on promotes for the NEXT window only",
{
fetchController: makeFlagFetch({ rollout: true }),
},
async ({ context, api, fetchCalls }) => {
// This window already decided legacy from the (empty) cache; the refresh
// promotes the NEXT window.
assert.deepEqual(api, { bundle: "legacy" });
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "next");
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
assert.equal(featureFlagCalled.properties.$feature_flag_response, true);
},
);
await runScenario(
"rollout flag off demotes a cached next for the NEXT window (two-way)",
{
seed: { "cline.rollout.bundle": "next" },
fetchController: makeFlagFetch({ rollout: false }),
},
async ({ context, api, fetchCalls }) => {
// This window already ran next; dialing the flag down moves the machine
// back to legacy on its next reload.
assert.deepEqual(api, { bundle: "next" });
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
const [featureFlagCalled] = featureFlagCalledCaptures(fetchCalls);
assert.ok(featureFlagCalled, "rollout refresh must emit the PostHog feature-flag exposure event");
assert.equal(featureFlagCalled.event, "$feature_flag_called");
assert.equal(featureFlagCalled.properties.$feature_flag, "ext-sdk-bundle-rollout");
assert.equal(featureFlagCalled.properties.$feature_flag_response, false);
},
);
await runScenario(
"env override forces next",
{ env: { CLINE_BUNDLE_OVERRIDE: "next" } },
async ({ api }) => {
assert.deepEqual(api, { bundle: "next" });
},
);
await runScenario(
"user setting overrides to legacy despite cached next",
{
seed: { "cline.rollout.bundle": "next" },
settings: { "cline.rollout.bundleOverride": "legacy" },
},
async ({ api }) => {
assert.deepEqual(api, { bundle: "legacy" });
},
);
await runScenario(
"user setting overrides to next despite a cached legacy assignment",
{
seed: { "cline.rollout.bundle": "legacy" },
settings: { "cline.rollout.bundleOverride": "next" },
},
async ({ api }) => {
assert.deepEqual(api, { bundle: "next" });
},
);
const failedNextRefresh = makeDeferredFlagFetch({ rollout: true });
await runScenario(
"next activation failure falls back to legacy",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
fetchController: failedNextRefresh,
expectRefresh: false,
beforeNextFailure: () =>
Promise.race([
failedNextRefresh.started,
new Promise((resolve) => setTimeout(resolve, 100)),
]),
},
async ({ context, api, fetchCalls }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.deepEqual(
global.__smoke.disposed,
["next"],
"partial registrations disposed",
);
const state = context.globalState._dump();
assert.equal(state["cline.rollout.bundle"], "legacy");
assert.equal(
state["cline.rollout.nextActivationFailedVersion"],
"4.1.0-smoke",
);
assert.equal(
context.subscriptions.length,
0,
"failed bundle's subscriptions removed",
);
// setContext flipped back for the legacy UI
assert.deepEqual(executedCommands.at(-1), [
"setContext",
"cline.sdkBundle",
false,
]);
// The LEGACY bundle (the one whose telemetry pipeline is alive) received
// the authoritative fallback record; the dead next bundle reported nothing.
assert.deepEqual(global.__smoke.reports, [
{
reporter: "legacy",
attemptedBundle: "next",
actualBundle: "legacy",
fallback: true,
hasError: true,
},
]);
// Keep the fetch stub installed long enough for an incorrectly delayed
// refresh to reach the network boundary before asserting its absence.
await new Promise((resolve) => setTimeout(resolve, 100));
// Settle a refresh if the loader incorrectly launched one. With the old
// ordering it would now promote COHORT_STATE_KEY back to next.
if (decideCalls(fetchCalls).length > 0) {
failedNextRefresh.resolve();
await flushAsyncWork();
}
assert.equal(
decideCalls(fetchCalls).length,
0,
"crash fallback must not refresh the failed cohort",
);
assert.equal(context.globalState._dump()["cline.rollout.bundle"], "legacy");
},
);
await runScenario(
"loader_decision capture carries the loader-side metadata",
{
env: { CLINE_BUNDLE_OVERRIDE: "next" },
telemetryEnabled: true,
contextPackageJSON: { name: "claude-dev" },
},
async ({ fetchCalls }) => {
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length > 0,
"loader_decision capture never reached the network",
);
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(captures.length, 1);
const [capture] = captures;
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.bundle, "next");
assert.equal(capture.properties.attempted_bundle, "next");
assert.equal(capture.properties.fallback, false);
assert.equal(capture.properties.override, "env");
assert.equal(capture.properties.loader_version, "4.1.0-smoke");
assert.equal(capture.properties.extension_name, "claude-dev");
},
);
await runScenario(
"crash fallback captures exactly one loader_decision event",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
telemetryEnabled: true,
expectRefresh: false,
},
async ({ fetchCalls }) => {
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length > 0,
"fallback loader_decision capture never reached the network",
);
// Give an incorrect second capture (the pre-fix fallback:false event from
// the recursive legacy success) time to reach the network before counting.
await new Promise((resolve) => setTimeout(resolve, 100));
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(
captures.length,
1,
"fallback must emit exactly ONE loader event (regression: duplicate fallback:false event)",
);
const [capture] = captures;
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.bundle, "legacy");
assert.equal(capture.properties.attempted_bundle, "next");
assert.equal(capture.properties.fallback, true);
assert.match(capture.properties.error_message, /next activation exploded/);
},
);
await runScenario(
"a bundle without the reportRolloutActivation export still activates",
{ omitReportExport: true },
async ({ api }) => {
assert.deepEqual(api, { bundle: "legacy" });
assert.deepEqual(global.__smoke.reports, []);
},
);
await runScenario(
"nightly identity: namespaced setting + context key, status bar indicator",
{
contextPackageJSON: { name: "cline-nightly" },
settings: { "cline-nightly.rollout.bundleOverride": "next" },
},
async ({ api, context }) => {
assert.deepEqual(api, { bundle: "next" });
assert.deepEqual(executedCommands[0], [
"setContext",
"cline-nightly.sdkBundle",
true,
]);
assert.equal(statusBarItems.length, 1);
const [item] = statusBarItems;
assert.equal(item.shown, true);
assert.equal(item.text, "Cline: Next");
assert.match(item.tooltip, /bundleOverride setting/);
assert.ok(
context.subscriptions.includes(item),
"indicator must be disposed with the extension",
);
},
);
await runScenario(
"double failure: both bundles throw, loader reports and rethrows",
{
seed: { "cline.rollout.bundle": "next" },
nextThrows: true,
legacyThrows: true,
telemetryEnabled: true,
expectFailure: true,
expectRefresh: false,
},
async ({ activationError, fetchCalls }) => {
assert.match(String(activationError), /legacy activation exploded/);
assert.deepEqual(
global.__smoke.reports,
[],
"no bundle survived to report the authoritative event",
);
await waitFor(
() => loaderDecisionCaptures(fetchCalls).length >= 2,
"double failure should capture the fallback AND the double_failure events",
);
const captures = loaderDecisionCaptures(fetchCalls);
assert.equal(captures.length, 2);
for (const capture of captures) {
assert.equal(capture.event, "extension.rollout.loader_decision");
assert.equal(capture.properties.fallback, true);
}
const doubleFailure = captures.find(
(c) => c.properties.double_failure === true,
);
assert.ok(doubleFailure, "one capture must be flagged double_failure");
assert.equal(doubleFailure.properties.attempted_bundle, "next");
assert.match(
doubleFailure.properties.error_message,
/legacy activation exploded/,
);
},
);
await runScenario(
"deactivate delegates to active bundle",
{},
async () => {},
async () => {
assert.deepEqual(global.__smoke.deactivated, ["legacy"]);
},
);
console.log("\nall loader smoke scenarios passed");
+180
View File
@@ -0,0 +1,180 @@
/**
* Assemble the combined (loader + next + legacy) VSIX staging directory.
*
* Layout produced:
* <out>/
* extension.js loader bundle (this package's dist/extension.js)
* package.json union manifest (gen-manifest.mjs)
* README.md next's marketplace README
* LICENSE, CHANGELOG.md, assets/, walkthrough/ from next (manifest-referenced, VSIX-root-relative)
* next/ SDK extension payload (dist/, webview-ui/build/, assets/, package.json)
* legacy/ legacy extension payload (dist/, webview-ui/build/, assets/,
* node_modules/@vscode/codicons/dist/, package.json)
*
* Each bundle resolves its own resources under its subdirectory because the
* loader hands it an ExtensionContext whose extensionUri/extensionPath point
* there (see src/scoped-context.ts). Manifest-referenced resources (icons,
* walkthrough media, codicon font declared in contributes.icons) resolve from
* the VSIX root, where the stitcher places next's copies.
*
* Usage:
* node stitch.mjs --next <apps/vscode dir, built> --legacy <apps/vscode dir, built> \
* --loader <dist/extension.js> --version <x.y.z> --out <staging dir>
*/
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { generateManifest } from "./gen-manifest.mjs";
// Legacy's webview loads codicon.css straight from node_modules (see its
// WebviewProvider); next bundles the font into its webview build but its own
// .vscodeignore still re-includes the codicons dist, so mirror that here.
const BUNDLE_PAYLOAD = {
next: [
"dist",
"webview-ui/build",
"assets",
"package.json",
"node_modules/@vscode/codicons/dist",
],
legacy: [
"dist",
"webview-ui/build",
"assets",
"package.json",
"node_modules/@vscode/codicons/dist",
],
};
/** VSIX-root files, all taken from the next checkout (manifest fields come from next too). */
const ROOT_PAYLOAD = ["LICENSE", "CHANGELOG.md", "assets", "walkthrough"];
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 2) {
args[argv[i].replace(/^--/, "")] = argv[i + 1];
}
return args;
}
function copyInto(sourceRoot, relPaths, destRoot, { optional = [] } = {}) {
for (const rel of relPaths) {
const source = path.join(sourceRoot, rel);
if (!existsSync(source)) {
if (optional.includes(rel)) {
console.warn(` skip (missing, optional): ${rel}`);
continue;
}
throw new Error(
`required payload missing: ${source} — did the bundle build run?`,
);
}
cpSync(source, path.join(destRoot, rel), {
recursive: true,
dereference: true,
});
console.log(` + ${rel}`);
}
}
export function stitch({ next, legacy, loader, version, out }) {
for (const [name, value] of Object.entries({
next,
legacy,
loader,
version,
out,
})) {
if (!value) {
throw new Error(`--${name} is required`);
}
}
// Refuse to stage from an unbuilt tree early, with a clear message.
for (const [name, root] of [
["next", next],
["legacy", legacy],
]) {
if (!existsSync(path.join(root, "dist", "extension.js"))) {
throw new Error(
`${name} bundle not built: ${root}/dist/extension.js missing`,
);
}
if (
!existsSync(path.join(root, "webview-ui", "build", "assets", "index.js"))
) {
throw new Error(
`${name} webview not built: ${root}/webview-ui/build/assets/index.js missing`,
);
}
}
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
console.log("root payload (from next):");
copyInto(next, ROOT_PAYLOAD, out, {
optional: ["CHANGELOG.md", "walkthrough"],
});
cpSync(loader, path.join(out, "extension.js"));
console.log(" + extension.js (loader)");
const readme = path.join(next, "README.marketplace.md");
cpSync(
existsSync(readme) ? readme : path.join(next, "README.md"),
path.join(out, "README.md"),
);
console.log(" + README.md");
for (const [bundle, payload] of Object.entries(BUNDLE_PAYLOAD)) {
const sourceRoot = bundle === "next" ? next : legacy;
console.log(`${bundle} payload:`);
copyInto(sourceRoot, payload, path.join(out, bundle), {
optional: ["walkthrough"],
});
}
const manifest = generateManifest(
JSON.parse(readFileSync(path.join(next, "package.json"), "utf8")),
JSON.parse(readFileSync(path.join(legacy, "package.json"), "utf8")),
version,
);
writeFileSync(
path.join(out, "package.json"),
`${JSON.stringify(manifest, null, "\t")}\n`,
);
console.log(" + package.json (union manifest)");
// vsce packages everything in the staging dir; only strip sourcemaps and
// junk. The codicons files under legacy/node_modules must survive, so no
// blanket node_modules ignore here — staging only ever contains what this
// script copied.
writeFileSync(
path.join(out, ".vscodeignore"),
["**/*.map", "**/.DS_Store", ""].join("\n"),
);
console.log(`\nstaged ${out} (version ${version})`);
// Keep the scanner exemption category-scoped to match the standalone bundle
// workflows; see the README for its scope and verification notes.
console.log(
`package it with:\n cd ${out} && vsce package --no-dependencies --allow-package-secrets sendgrid`,
);
}
if (
process.argv[1] &&
import.meta.url === `file://${path.resolve(process.argv[1])}`
) {
try {
stitch(parseArgs(process.argv));
} catch (error) {
console.error(`stitch failed: ${error.message}`);
process.exit(1);
}
}
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import {
bundleContextKey,
decideBundle,
decisionOverrideSource,
idPrefix,
parseRolloutAssignment,
ROLLOUT_FLAG,
settingSection,
} from "./cohort";
const base = {
envOverride: undefined,
settingOverride: undefined,
cached: undefined,
previousFailure: false,
};
describe("decideBundle", () => {
it("defaults to legacy with no cached assignment", () => {
expect(decideBundle(base)).toBe("legacy");
});
it("uses the cached assignment", () => {
expect(decideBundle({ ...base, cached: "next" })).toBe("next");
expect(decideBundle({ ...base, cached: "legacy" })).toBe("legacy");
});
it("treats unknown cached values as legacy", () => {
expect(decideBundle({ ...base, cached: "garbage" })).toBe("legacy");
});
it("a previous activation failure on this version forces legacy", () => {
expect(
decideBundle({ ...base, cached: "next", previousFailure: true }),
).toBe("legacy");
});
it("env override beats everything, including a previous failure", () => {
expect(
decideBundle({ ...base, envOverride: "next", previousFailure: true }),
).toBe("next");
expect(
decideBundle({ ...base, envOverride: "legacy", cached: "next" }),
).toBe("legacy");
});
it("user setting overrides in both directions", () => {
expect(
decideBundle({ ...base, settingOverride: "next", previousFailure: true }),
).toBe("next");
expect(
decideBundle({ ...base, settingOverride: "legacy", cached: "next" }),
).toBe("legacy");
});
it("env override beats the user setting", () => {
expect(
decideBundle({ ...base, envOverride: "legacy", settingOverride: "next" }),
).toBe("legacy");
});
it("ignores invalid and 'auto' overrides", () => {
expect(decideBundle({ ...base, envOverride: "beta", cached: "next" })).toBe(
"next",
);
expect(
decideBundle({ ...base, settingOverride: "auto", cached: "next" }),
).toBe("next");
});
});
describe("decisionOverrideSource", () => {
it("reports which override was active", () => {
expect(decisionOverrideSource(base)).toBeUndefined();
expect(decisionOverrideSource({ ...base, settingOverride: "next" })).toBe(
"setting",
);
expect(
decisionOverrideSource({
...base,
envOverride: "legacy",
settingOverride: "next",
}),
).toBe("env");
expect(
decisionOverrideSource({ ...base, settingOverride: "auto" }),
).toBeUndefined();
});
});
describe("parseRolloutAssignment", () => {
it("promotes only on a literal boolean true", () => {
expect(
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: true } }),
).toBe("next");
});
it("is two-way: anything else resolves to legacy (fail-safe)", () => {
// false = dialed out of the cohort; the rest = mis-configured flag.
for (const value of ["test", "control", 1, 0.5, {}, false, undefined]) {
expect(
parseRolloutAssignment({ featureFlags: { [ROLLOUT_FLAG]: value } }),
).toBe("legacy");
}
// Flag deleted / not created yet: nobody promoted.
expect(parseRolloutAssignment({ featureFlags: {} })).toBe("legacy");
});
it("returns undefined for malformed responses (cache left untouched)", () => {
expect(parseRolloutAssignment(undefined)).toBeUndefined();
expect(parseRolloutAssignment({})).toBeUndefined();
expect(parseRolloutAssignment({ featureFlags: null })).toBeUndefined();
});
});
describe("identity prefix", () => {
it("maps the nightly manifest name to the cline-nightly namespace", () => {
expect(idPrefix("cline-nightly")).toBe("cline-nightly");
});
it("maps everything else (stable claude-dev, unknown, missing) to cline", () => {
expect(idPrefix("claude-dev")).toBe("cline");
expect(idPrefix("some-fork")).toBe("cline");
expect(idPrefix(undefined)).toBe("cline");
});
it("derives the setting section and context key from the prefix", () => {
expect(settingSection("cline")).toBe("cline.rollout");
expect(settingSection("cline-nightly")).toBe("cline-nightly.rollout");
expect(bundleContextKey("cline")).toBe("cline.sdkBundle");
expect(bundleContextKey("cline-nightly")).toBe("cline-nightly.sdkBundle");
});
});
+134
View File
@@ -0,0 +1,134 @@
export type Bundle = "next" | "legacy";
/**
* The combined VSIX ships under two identities: the stable extension
* (manifest name "claude-dev", contribution IDs under "cline.*") and the
* nightly (name "cline-nightly", IDs under "cline-nightly.*" — the nightly
* packaging rewrites every `"cline.` prefix in the manifest, see
* scripts/nightlify.mjs and apps/vscode/scripts/publish-nightly.mjs). Anything
* the loader reads from or feeds back into the manifest namespace — the
* bundleOverride setting and the sdkBundle context key — must use the prefix
* matching the installed identity. scripts/gen-manifest.mjs derives the same
* prefix when generating the union manifest; keep them in sync.
*/
export const NIGHTLY_EXTENSION_NAME = "cline-nightly";
export type IdPrefix = "cline" | "cline-nightly";
export function idPrefix(extensionName: string | undefined): IdPrefix {
return extensionName === NIGHTLY_EXTENSION_NAME ? "cline-nightly" : "cline";
}
/** Settings section holding the bundleOverride escape hatch. */
export function settingSection(prefix: IdPrefix): string {
return `${prefix}.rollout`;
}
/** Context key gating per-cohort menus/keybindings in the union manifest. */
export function bundleContextKey(prefix: IdPrefix): string {
return `${prefix}.sdkBundle`;
}
/**
* Loader-owned VS Code memento keys. Never touched by either bundle. These
* deliberately stay un-prefixed by identity: globalState is already scoped to
* the extension ID, so a stable and a nightly install can never collide.
*/
export const COHORT_STATE_KEY = "cline.rollout.bundle";
/** Version of the combined VSIX whose `next` bundle failed to activate, if any. */
export const FAILED_VERSION_STATE_KEY =
"cline.rollout.nextActivationFailedVersion";
/** Epoch ms of the previous loader activation, for launch-cadence telemetry. */
export const LAST_ACTIVATION_STATE_KEY = "cline.rollout.lastActivationAt";
/**
* PostHog rollout flag (created in the Cline PostHog project). Must be a
* plain BOOLEAN release flag with a percentage rollout.
*
* The assignment is TWO-WAY: each background refresh caches exactly what the
* flag says (true => next, anything else => legacy) for the next window, so
* dialing the percentage down moves machines back to legacy on their next
* reload — the single emergency lever is "set the rollout to 0%". Demoted
* machines keep their settings/creds (the state files round-trip), but tasks
* created on the SDK bundle aren't visible in legacy's history until
* re-promoted, and tokens rotated on next may require re-auth on legacy.
*/
export const ROLLOUT_FLAG = "ext-sdk-bundle-rollout";
/** Env var for local dev / e2e to force a bundle. Beats everything. */
export const BUNDLE_OVERRIDE_ENV = "CLINE_BUNDLE_OVERRIDE";
/**
* User-visible escape hatch: `<prefix>.rollout.bundleOverride` in VS Code
* settings ("auto" | "next" | "legacy") — see settingSection() for the
* identity-dependent section name. Editable from settings.json without
* touching mementos, beats the remote flag in either direction, applies on
* window reload. Injected into the union manifest by gen-manifest.mjs — keep
* the schema there in sync with these constants.
*/
export const SETTING_BUNDLE_OVERRIDE = "bundleOverride";
function asBundle(value: unknown): Bundle | undefined {
return value === "next" || value === "legacy" ? value : undefined;
}
export interface CohortInputs {
/** CLINE_BUNDLE_OVERRIDE, if set. */
envOverride: string | undefined;
/** The <prefix>.rollout.bundleOverride user setting ("auto" = no override). */
settingOverride: string | undefined;
/** Cached assignment from the previous window's background flag refresh. */
cached: string | undefined;
/** The next bundle failed to activate on this VSIX version before. */
previousFailure: boolean;
}
/**
* Decide which bundle to activate for this window. Must be synchronous and
* never block on the network: it only consumes state cached by the previous
* window's background refresh, so a percentage change applies on the next
* window reload, mirroring how VS Code's own experiments behave.
*/
export function decideBundle(inputs: CohortInputs): Bundle {
const forced =
asBundle(inputs.envOverride) ?? asBundle(inputs.settingOverride);
if (forced) {
return forced;
}
if (inputs.previousFailure) {
return "legacy";
}
return inputs.cached === "next" ? "next" : "legacy";
}
/** Which override produced the decision, if any — reported on the activation event. */
export function decisionOverrideSource(
inputs: Pick<CohortInputs, "envOverride" | "settingOverride">,
): "env" | "setting" | undefined {
if (asBundle(inputs.envOverride)) {
return "env";
}
if (asBundle(inputs.settingOverride)) {
return "setting";
}
return undefined;
}
/**
* Parse a PostHog /decide (v3) response into the assignment to cache for the
* next window, or undefined when the response is malformed (leave the cached
* assignment untouched — sticky on transient failures).
*
* Deliberately strict so a mis-configured flag fails SAFE toward legacy: only
* boolean `true` promotes. A multivariate variant string, a number, a payload,
* or a missing/deleted flag all resolve to legacy — the flag must stay a plain
* boolean release flag with a percentage rollout.
*/
export function parseRolloutAssignment(response: unknown): Bundle | undefined {
const flags = (
response as { featureFlags?: Record<string, unknown> } | undefined
)?.featureFlags;
if (!flags || typeof flags !== "object") {
return undefined;
}
return flags[ROLLOUT_FLAG] === true ? "next" : "legacy";
}
+259
View File
@@ -0,0 +1,259 @@
import { createRequire } from "node:module";
import path from "node:path";
import * as vscode from "vscode";
import {
BUNDLE_OVERRIDE_ENV,
type Bundle,
bundleContextKey,
COHORT_STATE_KEY,
decideBundle,
decisionOverrideSource,
FAILED_VERSION_STATE_KEY,
type IdPrefix,
idPrefix,
LAST_ACTIVATION_STATE_KEY,
SETTING_BUNDLE_OVERRIDE,
settingSection,
} from "./cohort";
import { refreshCohort, reportLoaderDecision } from "./rollout";
import { scopedContext } from "./scoped-context";
/**
* Cline rollout loader.
*
* The VSIX ships two complete, independently built extension bundles:
* next/ — the SDK-based extension (built from main's apps/vscode)
* legacy/ — the pre-SDK extension (built from the legacy-extension branch)
*
* This entrypoint picks exactly one per window — from state cached by the
* previous window's background flag refresh, never from a blocking network
* call — activates it with a context whose install-root paths point into its
* subdirectory, and delegates everything else to it. If the next bundle throws
* during activation, the loader disposes whatever it half-registered, pins
* this VSIX version back to legacy, and activates legacy instead.
*/
// Resolved at runtime relative to the installed VSIX root; must stay opaque to
// esbuild so the bundles aren't inlined into the loader.
const requireFromVsixRoot = createRequire(__filename);
interface BundleModule {
activate(context: vscode.ExtensionContext): Promise<unknown> | unknown;
deactivate?(): Promise<void> | void;
/**
* Exported by both bundles' entrypoints (see rollout-metadata.ts on each
* branch): captures the AUTHORITATIVE `extension.rollout.bundle_activated`
* event through the bundle's own variant-attributed telemetry pipeline.
* Optional so the loader keeps working against a bundle built before the
* export existed.
*/
reportRolloutActivation?(input: {
attemptedBundle: Bundle;
actualBundle: Bundle;
fallback: boolean;
error?: unknown;
}): Promise<void>;
}
let activeBundle: { module: BundleModule; name: Bundle } | undefined;
interface ActivationMeta {
msSinceLastActivation?: number;
override?: "env" | "setting";
}
/** Set when the original decision crashed and this activation is the fallback. */
interface FallbackFrom {
attempted: Bundle;
error: unknown;
}
export async function activate(context: vscode.ExtensionContext) {
const loaderVersion: string =
context.extension.packageJSON?.version ?? "unknown";
const prefix = idPrefix(context.extension.packageJSON?.name);
// Launch-cadence telemetry: how stale the previous activation is bounds how
// fast a percentage change can actually reach users' windows.
const lastActivationAt = context.globalState.get<number>(
LAST_ACTIVATION_STATE_KEY,
);
const now = Date.now();
void context.globalState.update(LAST_ACTIVATION_STATE_KEY, now);
const overrides = {
envOverride: process.env[BUNDLE_OVERRIDE_ENV],
settingOverride: vscode.workspace
.getConfiguration(settingSection(prefix))
.get<string>(SETTING_BUNDLE_OVERRIDE),
};
const bundle = decideBundle({
...overrides,
cached: context.globalState.get<string>(COHORT_STATE_KEY),
previousFailure:
context.globalState.get<string>(FAILED_VERSION_STATE_KEY) ===
loaderVersion,
});
const meta: ActivationMeta = {
msSinceLastActivation:
typeof lastActivationAt === "number" && lastActivationAt <= now
? now - lastActivationAt
: undefined,
override: decisionOverrideSource(overrides),
};
return activateBundle(context, prefix, bundle, loaderVersion, meta, true);
}
async function activateBundle(
context: vscode.ExtensionContext,
prefix: IdPrefix,
bundle: Bundle,
loaderVersion: string,
meta: ActivationMeta,
refreshAssignmentOnSuccess: boolean,
fallbackFrom?: FallbackFrom,
): Promise<unknown> {
// Menus/keybindings gated per cohort in package.json key off this.
await vscode.commands.executeCommand(
"setContext",
bundleContextKey(prefix),
bundle === "next",
);
const subscriptionsBefore = context.subscriptions.length;
try {
const module = requireFromVsixRoot(
path.join(__dirname, bundle, "dist", "extension.js"),
) as BundleModule;
const api = await module.activate(scopedContext(context, bundle));
activeBundle = { module, name: bundle };
// Cache the next window's assignment only after the originally selected
// bundle activates. A crash fallback must not start a refresh that could
// promote the cohort back to next after the handler pins it to legacy.
if (refreshAssignmentOnSuccess) {
void refreshCohort(context).catch(() => {});
}
// Authoritative activation event, captured by the bundle's own telemetry
// (built with CLINE_ROLLOUT_VARIANT). On fallback this runs in the legacy
// bundle — next's pipeline is the thing that just crashed.
if (typeof module.reportRolloutActivation === "function") {
void module
.reportRolloutActivation({
attemptedBundle: fallbackFrom?.attempted ?? bundle,
actualBundle: bundle,
fallback: fallbackFrom !== undefined,
error: fallbackFrom?.error,
})
.catch(() => {});
}
// The loader's own decision event fires once per window: the fallback
// path already reported (fallback: true) from the catch block below.
if (!fallbackFrom) {
void reportLoaderDecision(context, bundle, {
...meta,
fallback: false,
}).catch(() => {});
}
showNightlyBundleIndicator(context, prefix, bundle, meta, fallbackFrom);
return api;
} catch (error) {
if (bundle === "legacy") {
// Nothing left to fall back to; let VS Code surface the failure. When
// this was already the crash fallback, no bundle telemetry pipeline is
// alive — the loader's direct event is the only record.
void reportLoaderDecision(context, "legacy", {
...meta,
attemptedBundle: fallbackFrom?.attempted ?? "legacy",
fallback: fallbackFrom !== undefined,
doubleFailure: fallbackFrom !== undefined,
errorMessage: formatActivationError(error),
}).catch(() => {});
throw error;
}
console.error(
"[cline-rollout] next bundle failed to activate, falling back to legacy:",
error,
);
disposeSubscriptionsAddedAfter(context, subscriptionsBefore);
// Pin this VSIX version to legacy so we don't crash-loop every window.
// A new release (new version string) gets to try next again.
await context.globalState.update(FAILED_VERSION_STATE_KEY, loaderVersion);
await context.globalState.update(COHORT_STATE_KEY, "legacy");
void reportLoaderDecision(context, "legacy", {
...meta,
attemptedBundle: "next",
fallback: true,
errorMessage: formatActivationError(error),
}).catch(() => {});
return activateBundle(
context,
prefix,
"legacy",
loaderVersion,
meta,
false,
{ attempted: "next", error },
);
}
}
function formatActivationError(error: unknown): string {
return error instanceof Error
? `${error.message}\n${error.stack ?? ""}`.slice(0, 2000)
: String(error);
}
/**
* Nightly-only visible indicator of which bundle this window is running.
* The stable combined VSIX (and any ordinary build) never shows it: the
* prefix is derived from the packaged manifest name. Best-effort — the
* indicator must never take down an otherwise successful activation.
*/
function showNightlyBundleIndicator(
context: vscode.ExtensionContext,
prefix: IdPrefix,
bundle: Bundle,
meta: ActivationMeta,
fallbackFrom: FallbackFrom | undefined,
) {
if (prefix !== "cline-nightly") {
return;
}
try {
const item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Right,
-1000,
);
item.text = bundle === "next" ? "Cline: Next" : "Cline: Legacy";
const detail = fallbackFrom
? "crash fallback from the next bundle"
: meta.override
? `forced by ${meta.override === "env" ? `the ${BUNDLE_OVERRIDE_ENV} env var` : "the bundleOverride setting"}`
: "rollout assignment";
item.tooltip = `Cline nightly A/B rollout: running the ${bundle === "next" ? "next (SDK)" : "legacy"} bundle (${detail}).`;
item.show();
context.subscriptions.push(item);
} catch (error) {
console.warn("[cline-rollout] could not show bundle indicator:", error);
}
}
/** Dispose anything a failed activation managed to register before it threw. */
function disposeSubscriptionsAddedAfter(
context: vscode.ExtensionContext,
startIndex: number,
) {
const added = context.subscriptions.splice(startIndex);
for (const disposable of added) {
try {
disposable.dispose();
} catch {
// best effort — a broken disposable must not block the fallback
}
}
}
export async function deactivate() {
return activeBundle?.module.deactivate?.();
}
+223
View File
@@ -0,0 +1,223 @@
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { machineId } from "node-machine-id";
import * as vscode from "vscode";
import {
type Bundle,
COHORT_STATE_KEY,
parseRolloutAssignment,
ROLLOUT_FLAG,
} from "./cohort";
/**
* Same PostHog project + reverse proxy the extension's telemetry uses.
* The API key is injected at build time by CI (see esbuild.mjs), matching how
* apps/vscode injects TELEMETRY_SERVICE_API_KEY. Local builds without the key
* skip all network calls, so the loader defaults everyone to legacy.
*/
const POSTHOG_HOST = "https://data.cline.bot";
const POSTHOG_API_KEY = process.env.TELEMETRY_SERVICE_API_KEY;
const FETCH_TIMEOUT_MS = 10_000;
const FEATURE_FLAG_CALLED_EVENT = "$feature_flag_called";
/**
* Mirror the distinct-id derivation in apps/vscode
* (src/services/logging/distinctId.ts) so PostHog evaluates the rollout flag
* against the same id the bundles report telemetry with — otherwise cohort
* membership can't be correlated with cohort behavior in dashboards.
* Falls back to vscode.env.machineId rather than generating + persisting a new
* id: the loader must never write to the shared ~/.cline state files.
*/
async function getDistinctId(): Promise<string> {
const generated = await readSharedGlobalStateKey("cline.generatedMachineId");
if (typeof generated === "string" && generated.length > 0) {
return generated;
}
try {
const id = await machineId();
if (id) {
return id;
}
} catch {
// fall through
}
return vscode.env.machineId;
}
/** Read one key from the file-backed global state both bundles share. */
async function readSharedGlobalStateKey(key: string): Promise<unknown> {
try {
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline");
const raw = await readFile(
path.join(clineDir, "data", "globalState.json"),
"utf8",
);
const state = JSON.parse(raw);
return state?.[key];
} catch {
return undefined;
}
}
async function postJson(
url: string,
body: object,
): Promise<Response | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
async function fetchAssignment(
distinctId: string,
): Promise<Bundle | undefined> {
if (!POSTHOG_API_KEY) {
return undefined;
}
const response = await postJson(`${POSTHOG_HOST}/decide?v=3`, {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId,
});
if (!response?.ok) {
return undefined;
}
try {
const decideResponse = await response.json();
const assignment = parseRolloutAssignment(decideResponse);
if (!assignment) {
return undefined;
}
// Mirror FeatureFlagsService/PostHog SDK exposure tracking for this
// loader-owned flag evaluation. This event is intentionally not gated by
// telemetry opt-out: feature-flag evaluation remains enabled so PostHog can
// correctly attribute rollout cohorts, while loader_decision below still
// respects user/host telemetry settings.
void reportFeatureFlagCalled(
distinctId,
getRolloutFlagResponse(decideResponse),
).catch(() => {});
return assignment;
} catch {
return undefined;
}
}
function getRolloutFlagResponse(response: unknown): unknown {
const flags = (
response as { featureFlags?: Record<string, unknown> } | undefined
)?.featureFlags;
return flags && typeof flags === "object" ? flags[ROLLOUT_FLAG] : undefined;
}
async function reportFeatureFlagCalled(
distinctId: string,
flagResponse: unknown,
): Promise<void> {
if (!POSTHOG_API_KEY) {
return;
}
await postJson(`${POSTHOG_HOST}/capture/`, {
api_key: POSTHOG_API_KEY,
event: FEATURE_FLAG_CALLED_EVENT,
distinct_id: distinctId,
properties: {
$feature_flag: ROLLOUT_FLAG,
$feature_flag_response: flagResponse,
},
});
}
/**
* Background refresh: evaluate the rollout flag and cache exactly what it
* says for the NEXT window (two-way: dialing the percentage down demotes on
* the next reload). Never affects the bundle already activated in this
* window, and failures leave the cached assignment untouched (sticky on
* transient errors only).
*/
export async function refreshCohort(
context: vscode.ExtensionContext,
): Promise<void> {
const distinctId = await getDistinctId();
const assignment = await fetchAssignment(distinctId);
if (!assignment) {
return;
}
await context.globalState.update(COHORT_STATE_KEY, assignment);
}
/**
* The loader's own decision event. Distinct from the AUTHORITATIVE
* `extension.rollout.bundle_activated` event, which the activated bundle
* itself captures through its variant-attributed telemetry pipeline (the
* loader triggers it via the bundle's reportRolloutActivation export — see
* src/extension.ts). This event carries the loader-side metadata that event
* can't (override source, launch cadence, loader version) and is the only
* signal left when BOTH bundles fail to activate.
*/
export const LOADER_DECISION_EVENT = "extension.rollout.loader_decision";
/**
* Report the loader's bundle decision (and whether it was a crash fallback).
* Feature-flag evaluation is always allowed (matching the extension's
* FeatureFlagsService), but event capture respects the user's telemetry
* opt-out and VS Code's global telemetry setting.
*/
export async function reportLoaderDecision(
context: vscode.ExtensionContext,
bundle: Bundle,
options: {
fallback: boolean;
/** Bundle the loader originally decided on; differs from `bundle` on fallback. */
attemptedBundle?: Bundle;
/** Both bundles threw — nothing activated, and no bundle telemetry exists. */
doubleFailure?: boolean;
errorMessage?: string;
/** Time since the previous loader activation on this machine, if known. */
msSinceLastActivation?: number;
/** Whether an env var or user setting forced this bundle. */
override?: "env" | "setting";
},
): Promise<void> {
if (!POSTHOG_API_KEY) {
return;
}
const telemetrySetting = await readSharedGlobalStateKey("telemetrySetting");
if (telemetrySetting === "disabled" || !vscode.env.isTelemetryEnabled) {
return;
}
const distinctId = await getDistinctId();
await postJson(`${POSTHOG_HOST}/capture/`, {
api_key: POSTHOG_API_KEY,
event: LOADER_DECISION_EVENT,
distinct_id: distinctId,
properties: {
bundle,
attempted_bundle: options.attemptedBundle ?? bundle,
fallback: options.fallback,
double_failure: options.doubleFailure,
error_message: options.errorMessage,
// Launch-cadence distribution: how long promotions take to reach real
// windows tells us how fast the rollout percentage can safely be dialed.
ms_since_last_activation: options.msSinceLastActivation,
override: options.override,
loader_version: context.extension.packageJSON?.version,
// Separates nightly traffic from the (future) stable combined VSIX.
extension_name: context.extension.packageJSON?.name,
vscode_version: vscode.version,
},
});
}
+56
View File
@@ -0,0 +1,56 @@
import path from "node:path";
import * as vscode from "vscode";
import type { Bundle } from "./cohort";
/**
* Wrap the real ExtensionContext so a bundle living under `<vsix root>/<sub>/`
* resolves extension-root-relative resources (webview-ui build, walkthrough
* assets, bundled codicons, ...) from its own subtree, without either codebase
* knowing it was relocated.
*
* Only install-root properties are redirected. Storage-related properties
* (globalState, workspaceState, secrets, globalStorageUri, storageUri, logUri)
* intentionally pass through untouched: both bundles must keep sharing the
* exact storage the standalone extension used, so user state survives cohort
* changes and VSIX upgrades.
*/
export function scopedContext(
context: vscode.ExtensionContext,
sub: Bundle,
): vscode.ExtensionContext {
const extensionUri = vscode.Uri.joinPath(context.extensionUri, sub);
const extensionPath = extensionUri.fsPath;
const scopedExtension = new Proxy(context.extension, {
get(target, prop, _receiver) {
if (prop === "extensionUri") {
return extensionUri;
}
if (prop === "extensionPath") {
return extensionPath;
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
const overrides = new Map<PropertyKey, unknown>([
["extensionUri", extensionUri],
["extensionPath", extensionPath],
[
"asAbsolutePath",
(relativePath: string) => path.join(extensionPath, relativePath),
],
["extension", scopedExtension],
]);
return new Proxy(context, {
get(target, prop, _receiver) {
if (overrides.has(prop)) {
return overrides.get(prop);
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as vscode.ExtensionContext;
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node", "vscode"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+46 -2
View File
@@ -63,7 +63,7 @@ service ModelsService {
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
// Writes provider configuration fields and returns redacted effective configuration
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
// Commits a mode-specific model selection atomically with its model metadata
// Commits a mode-specific model ID with optional user-authored metadata overrides
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
}
@@ -122,6 +122,36 @@ message OpenRouterModelInfo {
optional ApiFormat api_format = 16;
}
// User-authored per-model metadata stored in models.json.
//
// Semantics:
// - `capabilities` accepts only the SDK ModelCapability values (e.g.
// "images", "tools", "prompt-cache", "reasoning", "files"); unknown
// strings are silently dropped by the host. The array is additive over
// the base metadata; the explicit supports_* booleans win when both are
// present.
// - `is_r1_format_required` is a legacy alias that forces the R1 chat
// format only when true; `api_format` is canonical.
// - Invalid numbers (non-positive token limits, negative prices or
// temperature, non-finite values) are silently discarded, not rejected.
message ModelOverrides {
optional string name = 1;
optional int64 max_tokens = 2;
optional int64 context_window = 3;
optional int64 max_input_tokens = 4;
repeated string capabilities = 5;
optional bool supports_vision = 6;
optional bool supports_attachments = 7;
optional bool supports_reasoning = 8;
optional double input_price = 9;
optional double output_price = 10;
optional double cache_reads_price = 11;
optional double cache_writes_price = 12;
optional double temperature = 13;
optional ApiFormat api_format = 14;
optional bool is_r1_format_required = 15;
}
// Shared response message for model information
message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
@@ -222,12 +252,16 @@ message ProviderConfigResponse {
optional CommittedModelSelection act_selection = 11;
optional AwsProviderConfig aws = 12;
optional GcpProviderConfig gcp = 13;
// Provider-level context window (providers.json `contextWindow`). Used by
// bring-your-own-model providers (e.g. Ollama, where it maps to num_ctx).
optional int32 context_window = 14;
}
message CommittedModelSelection {
string provider_id = 1;
string model_id = 2;
OpenRouterModelInfo model_info = 3;
optional ModelOverrides overrides = 4;
}
message ProviderReasoningPatch {
@@ -249,6 +283,8 @@ message WriteProviderConfigPatch {
optional bool clear_headers = 10;
optional AwsProviderConfig aws = 11;
optional GcpProviderConfig gcp = 12;
// Provider-level context window; 0 clears the setting.
optional int32 context_window = 13;
}
message WriteProviderConfigRequest {
@@ -257,10 +293,18 @@ message WriteProviderConfigRequest {
}
message CommitModelSelectionRequest {
// Field 4 carried `OpenRouterModelInfo model_info` in earlier releases.
// Reusing the number with a different message type mis-decodes on version
// skew, so the retired field stays reserved.
reserved 4;
reserved "model_info";
string provider_id = 1;
string mode = 2;
string model_id = 3;
OpenRouterModelInfo model_info = 4;
// Tri-state: ABSENT leaves the model's stored overrides unchanged, an
// explicitly EMPTY message clears them, and a populated message replaces
// them wholesale (no per-field merge).
optional ModelOverrides overrides = 5;
}
message ClineRecommendedModel {
+3
View File
@@ -16,6 +16,9 @@ service TaskService {
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Detaches the running foreground terminal command ("Proceed While Running"):
// the agent receives the partial output and a log file path for the rest.
rpc proceedWhileRunningCommand(EmptyRequest) returns (Empty);
// Clears the current task
rpc clearTask(EmptyRequest) returns (Empty);
// Gets the total size of all tasks
@@ -56,4 +56,16 @@ describe("parseYamlFrontmatter", () => {
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
expect(result.body.trim()).to.equal("Content")
})
// Regression test for https://github.com/cline/cline/issues/12151
// A leading UTF-8 BOM (e.g. saved by Windows Notepad's "UTF-8 with BOM" encoding) must not
// prevent frontmatter from being recognized.
it("parses frontmatter correctly when the content has a leading UTF-8 BOM", () => {
const input = `\uFEFF---\nname: my-skill\ndescription: A test skill\n---\n# my-skill\nThis is a test skill.`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ name: "my-skill", description: "A test skill" })
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
})
})
@@ -139,6 +139,34 @@ Instructions here`)
expect(skills[0].source).to.equal("global")
})
// Regression test for https://github.com/cline/cline/issues/12151:
// SKILL.md files saved with a UTF-8 BOM (e.g. by Windows Notepad's "UTF-8 with BOM"
// encoding) were silently skipped because the frontmatter regex required "---" at the
// very start of the file and never accounted for the leading \uFEFF byte sequence.
it("should discover skills whose SKILL.md starts with a UTF-8 BOM", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`\uFEFF---
name: my-skill
description: A test skill
---
# my-skill
This is a test skill.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
expect(skills[0].description).to.equal("A test skill")
expect(skills[0].source).to.equal("global")
})
it("should discover skills from project .clinerules/skills directory", async () => {
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const skillDir = path.join(projectSkillsDir, "explaining-code")
@@ -1,3 +1,4 @@
import { stripUtf8Bom } from "@cline/shared"
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
@@ -35,11 +36,16 @@ export type FrontmatterParseResult = {
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
// Strip a leading UTF-8 BOM (e.g. added by Windows Notepad's "UTF-8 with BOM" encoding),
// which Node's `utf-8` decoding does not strip on its own. Without this the frontmatter
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
const normalizedMarkdown = stripUtf8Bom(markdown)
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
const match = normalizedMarkdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
@@ -48,6 +54,6 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
}
}
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { computeConfigFingerprint } from "@/sdk/model-catalog/fingerprint"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import { ApiFormat, ModelOverrides } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
type TestStateManager = {
@@ -153,6 +153,22 @@ describe("provider model catalog handlers", () => {
baseUrl: "https://api.example.com/v1",
auth: { accessToken: "SECRET_SENTINEL_ACCESS", refreshToken: "SECRET_SENTINEL_REFRESH", accountId: "acct-1" },
})
vi.mocked(store.readSelection).mockImplementation((_providerId, mode) =>
mode === "act"
? {
providerId,
modelId: "custom-model",
overrides: {
capabilities: ["tools", "custom-capability"],
inputPrice: 1.25,
supportsVision: false,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
modelInfo: { name: "Custom model", contextWindow: 64_000, supportsPromptCache: false },
}
: undefined,
)
const controller = makeController(store, makeCatalog())
const response = await readProviderConfig(controller, { value: "cline" })
@@ -165,10 +181,24 @@ describe("provider model catalog handlers", () => {
hasRefreshToken: true,
accountId: "acct-1",
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(response.actSelection).toMatchObject({
providerId: "cline",
modelId: "custom-model",
modelInfo: { name: "Custom model", contextWindow: 64_000 },
overrides: {
capabilities: ["tools", "custom-capability"],
inputPrice: 1.25,
supportsVision: false,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_API_KEY")
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_ACCESS")
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_REFRESH")
})
it("writeProviderConfig writes a patch and returns redacted updated config", async () => {
it("writeProviderConfig writes a patch and returns a redacted response", async () => {
const { writeProviderConfig } = await import("../writeProviderConfig")
const providerId = parseProviderId("ollama")
const updatedConfig: EffectiveProviderConfig = {
@@ -188,8 +218,10 @@ describe("provider model catalog handlers", () => {
apiKey: "SECRET_SENTINEL_OLLAMA",
baseUrl: "http://localhost:11434/v1",
})
expect(response.apiKeyLength).toBe("SECRET_SENTINEL_OLLAMA".length)
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL")
expect(response).toMatchObject({
apiKeyLength: "SECRET_SENTINEL_OLLAMA".length,
})
expect(JSON.stringify(response)).not.toContain("SECRET_SENTINEL_OLLAMA")
})
it("writeProviderConfig can explicitly clear headers", async () => {
@@ -210,7 +242,7 @@ describe("provider model catalog handlers", () => {
expect(store.write).toHaveBeenCalledWith(providerId, { headers: {} })
})
it("commitModelSelection validates mode and commits the full selection envelope", async () => {
it("commitModelSelection validates mode and commits model settings", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
@@ -224,22 +256,20 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
overrides: ModelOverrides.create({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
modelInfo: expect.objectContaining({
overrides: expect.objectContaining({
name: "DeepSeek V4 Flash",
contextWindow: 456,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_CHAT,
capabilities: ["prompt-cache"],
}),
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
@@ -249,6 +279,49 @@ describe("provider model catalog handlers", () => {
expect(stateManager.flushPendingState).toHaveBeenCalledTimes(1)
})
// The overrides field is tri-state: absent preserves the model's stored
// overrides, an explicitly empty message clears them, and a populated
// message replaces them. The two boundary cases are pinned here because
// the webview relies on both (see useProviderConfig.test.ts).
it("commitModelSelection maps an ABSENT overrides field to undefined (preserve stored overrides)", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
overrides: undefined,
})
})
it("commitModelSelection maps an EMPTY overrides message to an empty object (clear stored overrides)", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
await commitModelSelection(controller, {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
overrides: ModelOverrides.create({}),
})
expect(store.commitSelection).toHaveBeenCalledWith(providerId, "act", {
providerId,
modelId: "deepseek-v4-flash",
overrides: {},
})
})
it("commitModelSelection reports provider changes when config is initialized", async () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
@@ -268,10 +341,7 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "act",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({
name: "DeepSeek V4 Flash",
apiFormat: ApiFormat.OPENAI_CHAT,
}),
overrides: ModelOverrides.create({ name: "DeepSeek V4 Flash" }),
})
expect(handleApiConfigurationChanged).toHaveBeenCalledWith({}, { actModeApiProvider: "deepseek" })
@@ -289,7 +359,7 @@ describe("provider model catalog handlers", () => {
providerId: "deepseek",
mode: "invalid",
modelId: "deepseek-v4-flash",
modelInfo: OpenRouterModelInfo.create({ supportsPromptCache: true }),
overrides: ModelOverrides.create({ capabilities: ["prompt-cache"] }),
}),
).rejects.toThrow('mode must be "plan" or "act"')
expect(store.commitSelection).not.toHaveBeenCalled()
@@ -75,7 +75,6 @@ describe("provider model catalog backend smoke", () => {
providerId: "deepseek",
mode: "act",
modelId,
modelInfo,
}),
)
@@ -3,12 +3,14 @@ import type {
EffectiveProviderConfig,
Mode,
ModelSelection,
ModelSelectionOverrides,
ProviderCatalog,
ProviderConfigPatch,
ProviderConfigStore,
ProviderId,
ProviderListing,
ProviderModelsResult,
ResolvedModelSelection,
} from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import {
@@ -17,13 +19,15 @@ import {
CommitModelSelectionRequest,
CommittedModelSelection,
GcpProviderConfig,
ModelOverrides as ModelOverridesProto,
OpenRouterModelInfo,
ProviderConfigResponse,
ProviderListing as ProviderListingProto,
ProviderModelsResponse,
WriteProviderConfigPatch,
} from "@/shared/proto/cline/models"
import { fromProtobufModelInfo, toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import { fromProtobufModelOverrides, toProtobufModelOverrides } from "@/shared/proto-conversions/models/modelOverrides"
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import type { GlobalStateAndSettings } from "@/shared/storage/state-keys"
export interface ProviderCatalogController {
@@ -94,7 +98,11 @@ function toProtobufModels(models: ReadonlyMap<string, ModelInfo>): Record<string
return result
}
function toCommittedModelSelectionProto(selection: ModelSelection | undefined): CommittedModelSelection | undefined {
function toModelOverridesProto(overrides: ModelSelectionOverrides | undefined): ModelOverridesProto | undefined {
return overrides ? toProtobufModelOverrides(overrides) : undefined
}
function toCommittedModelSelectionProto(selection: ResolvedModelSelection | undefined): CommittedModelSelection | undefined {
if (!selection) {
return undefined
}
@@ -102,6 +110,7 @@ function toCommittedModelSelectionProto(selection: ModelSelection | undefined):
providerId: selection.providerId,
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
overrides: toModelOverridesProto(selection.overrides),
})
}
@@ -199,6 +208,7 @@ export function toRedactedProviderConfigResponse(
actSelection: toCommittedModelSelectionProto(store?.readSelection(config.providerId, "act")),
aws: toRedactedAwsProviderConfigProto(config.aws),
gcp: toRedactedGcpProviderConfigProto(config.gcp),
contextWindow: config.contextWindow,
})
}
@@ -218,6 +228,10 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
...(protoPatch.apiLine !== undefined ? { apiLine: protoPatch.apiLine } : {}),
...(protoPatch.aws !== undefined ? { aws: toAwsProviderConfigPatch(protoPatch) } : {}),
...(protoPatch.gcp !== undefined ? { gcp: toGcpProviderConfigPatch(protoPatch) } : {}),
// A zero context window over the wire means "clear the setting".
...(protoPatch.contextWindow !== undefined
? { contextWindow: protoPatch.contextWindow > 0 ? protoPatch.contextWindow : null }
: {}),
...(protoPatch.accessToken !== undefined || protoPatch.refreshToken !== undefined || protoPatch.accountId !== undefined
? {
auth: {
@@ -241,17 +255,18 @@ export function toProviderConfigPatch(protoPatch: WriteProviderConfigPatch | und
}
}
function toSelectionOverrides(overrides: ModelOverridesProto | undefined): ModelSelectionOverrides | undefined {
return fromProtobufModelOverrides(overrides)
}
export function toModelSelection(request: CommitModelSelectionRequest, providerId: ProviderId): ModelSelection {
const modelId = request.modelId.trim()
if (!modelId) {
throw new Error("model_id is required")
}
if (!request.modelInfo) {
throw new Error("model_info is required")
}
return {
providerId,
modelId,
modelInfo: fromProtobufModelInfo(request.modelInfo),
overrides: toSelectionOverrides(request.overrides),
}
}
@@ -12,10 +12,11 @@ import { type ProviderCatalogController, parseProviderIdRequest } from "./provid
* Resolution order:
*
* 1. Committed selection — the user's most-recently-chosen plan/act
* selection in the provider config store. This is the source of
* truth for dynamic-list providers (openrouter, openai-compatible,
* ollama, lmstudio, requesty, litellm, …) where the picker writes
* the live `ModelInfo` into the selection when the user commits.
* model ID resolved against SDK catalog metadata, the picker's state
* snapshot, and stored overrides by the provider config store. A
* selection whose metadata is pure fallback fabrication (no catalog or
* state base, no overrides) is deferred behind the catalog steps below
* and only returned as a last resort.
*
* 2. Catalog peek — a non-fetching look-up of the catalog cache for
* the provider's current effective config fingerprint. Hits when
@@ -41,23 +42,24 @@ export async function resolveModelInfo(
const requestedModelId = request.modelId?.trim() || ""
const store = controller.getProviderConfigStore()
// A committed selection whose metadata is pure fallback fabrication (no
// catalog/state base and no user overrides) must not shadow the live
// catalog below; it is kept only as a last resort before "unknown".
let fallbackSelection: ReturnType<typeof store.readSelection>
if (requestedModelId) {
const actSelection = store.readSelection(providerId, "act")
if (actSelection?.modelId === requestedModelId) {
for (const mode of ["act", "plan"] as const) {
const selection = store.readSelection(providerId, mode)
if (selection?.modelId !== requestedModelId) {
continue
}
if (selection.modelInfoSource === "fallback" && !selection.overrides) {
fallbackSelection ??= selection
continue
}
return ResolveModelInfoResponse.create({
providerId,
modelId: actSelection.modelId,
modelInfo: toProtobufModelInfo(actSelection.modelInfo),
source: "committed-selection",
})
}
const planSelection = store.readSelection(providerId, "plan")
if (planSelection?.modelId === requestedModelId) {
return ResolveModelInfoResponse.create({
providerId,
modelId: planSelection.modelId,
modelInfo: toProtobufModelInfo(planSelection.modelInfo),
modelId: selection.modelId,
modelInfo: toProtobufModelInfo(selection.modelInfo),
source: "committed-selection",
})
}
@@ -74,7 +76,9 @@ export async function resolveModelInfo(
const cached = catalog.peekModels(providerId)
if (cached?.ok) {
const hit = pickFromCatalog(cached, requestedModelId, allowCustomModelIds)
if (hit) {
// A default-model substitution answers a question about a different
// model; the committed selection, even fallback-grade, is closer.
if (hit && (hit.matchedRequested || !fallbackSelection)) {
return ResolveModelInfoResponse.create({
providerId,
modelId: hit.modelId,
@@ -90,7 +94,7 @@ export async function resolveModelInfo(
const resolved = await catalog.resolveModels(providerId).catch(() => undefined)
if (resolved?.ok) {
const hit = pickFromCatalog(resolved, requestedModelId, allowCustomModelIds)
if (hit) {
if (hit && (hit.matchedRequested || !fallbackSelection)) {
return ResolveModelInfoResponse.create({
providerId,
modelId: hit.modelId,
@@ -100,6 +104,15 @@ export async function resolveModelInfo(
}
}
if (fallbackSelection) {
return ResolveModelInfoResponse.create({
providerId,
modelId: fallbackSelection.modelId,
modelInfo: toProtobufModelInfo(fallbackSelection.modelInfo),
source: "committed-selection",
})
}
return ResolveModelInfoResponse.create({
providerId,
modelId: requestedModelId,
@@ -25,6 +25,7 @@ export async function getStateToPostToWebview(controller: {
mcpHub?: any
backgroundCommandRunning?: boolean
backgroundCommandTaskId?: string
foregroundCommandRunning?: boolean
workspaceManager?: any
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
}): Promise<ExtensionState> {
@@ -157,6 +158,7 @@ export async function getStateToPostToWebview(controller: {
favoritedModelIds,
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
backgroundCommandTaskId: controller.backgroundCommandTaskId,
foregroundCommandRunning: controller.foregroundCommandRunning ?? false,
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
primaryRootIndex: controller.workspaceManager?.getPrimaryIndex?.() ?? 0,
isMultiRootWorkspace: (controller.workspaceManager?.getRoots?.()?.length ?? 0) > 1,
@@ -239,7 +239,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("defaultTerminalProfile", request.defaultTerminalProfile)
// Update the live terminal manager so new terminals use the new profile.
// Existing terminals are left open — they're keyed by effective shell
// and reused when compatible, or skipped when not.
// and reused when compatible, or skipped when not. No session rebuild
// is needed: the run_commands tool re-reads the profile each time a
// model request is built, so the description and execution both pick
// up the new shell at the next request boundary.
controller.terminalManager?.setDefaultTerminalProfile(request.defaultTerminalProfile)
}
@@ -192,7 +192,10 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
controller.stateManager.setGlobalState("defaultTerminalProfile", defaultTerminalProfile)
// Update the live terminal manager so new terminals use the new profile.
// Existing terminals are left open — they're keyed by effective shell
// and reused when compatible, or skipped when not.
// and reused when compatible, or skipped when not. No session rebuild
// is needed: the run_commands tool re-reads the profile each time a
// model request is built, so the description and execution both pick
// up the new shell at the next request boundary.
controller.terminalManager?.setDefaultTerminalProfile(defaultTerminalProfile)
}
}
@@ -0,0 +1,15 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* "Proceed While Running": detach the in-flight foreground terminal command(s)
* so the agent turn continues with the partial output while the commands keep
* running in the user's terminal, streaming further output to a log file.
*/
export async function proceedWhileRunningCommand(controller: Controller, _request: EmptyRequest): Promise<Empty> {
const controllerWithProceed = controller as Controller & {
proceedWhileRunningCommand: () => Promise<void>
}
await controllerWithProceed.proceedWhileRunningCommand()
return Empty.create()
}
+5 -4
View File
@@ -15,8 +15,9 @@ Designed to be driven from an agentic loop via `curl` commands.
## Quick Start
```bash
# Terminal 1: Start the debug harness server
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 1: Start the debug harness server.
# Run with node, NOT bun — Playwright's Electron launch times out under bun.
node src/dev/debug-harness/server.ts --auto-launch --skip-build
# Terminal 2: Interact via curl
curl localhost:19229/api -d '{"method":"status"}'
@@ -27,7 +28,7 @@ curl localhost:19229/api -d '{"method":"ui.screenshot"}'
## Server Options
```
bun src/dev/debug-harness/server.ts [options]
node src/dev/debug-harness/server.ts [options]
Options:
--skip-build Skip building extension/webview (use existing dist/)
@@ -42,7 +43,7 @@ Options:
```bash
# This builds protos, extension (unminified+sourcemaps), webview (unminified+sourcemaps),
# downloads VSCode, launches it, and connects CDP to the extension host.
bun src/dev/debug-harness/server.ts --auto-launch
node src/dev/debug-harness/server.ts --auto-launch
```
## Data Isolation
+15 -9
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env node
/**
* Debug Harness Server
@@ -10,7 +10,12 @@
* - UI automation (click, type, screenshot) via Playwright
*
* Usage:
* bun src/dev/debug-harness/server.ts [options]
* node src/dev/debug-harness/server.ts [options]
*
* Run with node, not bun: Playwright's _electron.launch() never finishes
* attaching to the debugee under bun (the Electron process starts, but the
* launch times out), while the same launch works under node. Node >= 22.6
* runs this file directly via type stripping.
*
* Options:
* --skip-build Skip building extension/webview
@@ -39,7 +44,6 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { downloadAndUnzipVSCode, SilentReporter } from "@vscode/test-electron"
import { _electron, type CDPSession, type ElectronApplication, type Frame, type Page } from "playwright"
import WebSocket from "ws"
const __script_dir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url))
@@ -201,19 +205,21 @@ class CdpClient {
async connect(wsUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
// The runtime's built-in WebSocket (browser-style events), so the
// harness has no dependency on the `ws` package.
const ws = new WebSocket(wsUrl)
ws.on("open", () => {
ws.addEventListener("open", () => {
this.ws = ws
resolve()
})
ws.on("error", (e: Error) => {
if (!this.ws) reject(e)
ws.addEventListener("error", () => {
if (!this.ws) reject(new Error(`WebSocket connection failed: ${wsUrl}`))
})
ws.on("close", () => {
ws.addEventListener("close", () => {
this.ws = null
})
ws.on("message", (raw: WebSocket.Data) => {
const msg = JSON.parse(raw.toString())
ws.addEventListener("message", (event: MessageEvent) => {
const msg = JSON.parse(typeof event.data === "string" ? event.data : Buffer.from(event.data).toString())
if (msg.id !== undefined) {
const p = this.pending.get(msg.id)
if (p) {
@@ -262,10 +262,15 @@ export class VscodeTerminalManager {
return mergePromise(process, promise)
}
async getOrCreateTerminal(cwd: string): Promise<ITerminalInfo> {
/**
* @param profileId Terminal profile to create/match the terminal with.
* Defaults to the current setting; callers that captured the profile
* earlier (e.g. when the model request was built) pass it here so a
* settings change does not switch shells under an in-flight tool call.
*/
async getOrCreateTerminal(cwd: string, profileId: string = this.defaultTerminalProfile): Promise<ITerminalInfo> {
const terminals = TerminalRegistry.getAllTerminals()
const expectedShellPath =
this.defaultTerminalProfile !== "default" ? getShellForProfile(this.defaultTerminalProfile) : undefined
const expectedShellPath = profileId !== "default" ? getShellForProfile(profileId) : undefined
// Resolve effective shell for comparison (so "default" and "zsh" match on macOS)
const effectiveExpected = VscodeTerminalManager.effectiveShellPath(expectedShellPath)
@@ -694,4 +694,41 @@ describe("TerminalProcess (Integration Tests)", () => {
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
processAny.buffer.should.equal("")
})
it("detach emits continue but keeps line listeners attached and listening", () => {
const processAny = process as any
const continueEvents: number[] = []
const lines: string[] = []
process.on("continue", () => continueEvents.push(1))
process.on("line", (line) => lines.push(line))
process.detach()
continueEvents.length.should.equal(1)
// Unlike continue(), detach must not stop listening or drop 'line'
// listeners: output after detach still reaches subscribers (this is
// what streams the rest of a detached command to the log file).
processAny.isListening.should.be.true()
processAny.emitIfEol("after detach\n")
lines.should.containEql("after detach")
})
it("detach flushes a buffered partial line before emitting continue", () => {
const processAny = process as any
const events: string[] = []
process.on("continue", () => events.push("continue"))
process.on("line", (line) => events.push(`line:${line}`))
// A chunk with no trailing newline stays in the internal buffer.
processAny.emitIfEol("partial output")
processAny.buffer.should.equal("partial output")
process.detach()
// The partial line must reach listeners before 'continue' resolves the
// awaited promise; otherwise it is missing from the partial output and
// from the log's initial flush.
events.should.eql(["line:partial output", "continue"])
processAny.buffer.should.equal("")
})
})
@@ -16,8 +16,8 @@ import {
TRUNCATE_KEEP_LINES,
} from "@/integrations/terminal/constants"
import type { ITerminalProcess, TerminalCompletionDetails, TerminalProcessEvents } from "@/integrations/terminal/types"
import { Logger } from "@/shared/services/Logger"
import type { MarkerlessCompletionCause } from "@/services/telemetry/TelemetryService"
import { Logger } from "@/shared/services/Logger"
import { Osc633EventType, Osc633Parser } from "./osc633Parser"
import { classifyShellPrompt, getLastLine } from "./shellPromptHeuristics"
@@ -522,6 +522,23 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
this.emit("continue")
}
/**
* Resolve the awaited promise while the command keeps running ("Proceed
* While Running"). Listeners stay attached and 'line' events keep
* flowing unlike continue() so callers can stream the remaining
* output until the command actually completes. Because 'completed' is
* only emitted by the read loop when the command genuinely ends, the
* terminal stays busy and is not eligible for reuse until then.
*/
detach() {
// Flush any partial line (no trailing newline yet) so it reaches
// listeners before the awaited promise resolves; otherwise it would be
// dropped from both the partial output and the log capture if the
// command exits without further newline-terminated output.
this.emitRemainingBufferIfListening()
this.emit("continue")
}
/**
* Get output that hasn't been retrieved yet.
* Truncates if output is too large to prevent context window overflow.
@@ -63,10 +63,17 @@ export interface ITerminalProcess extends EventEmitter<TerminalProcessEvents> {
/**
* Continue execution without waiting for completion.
* Stops event emission and resolves the promise.
* This is called when user clicks "Proceed While Running".
*/
continue(): void
/**
* Resolve the awaited promise while the command keeps running ("Proceed
* While Running"). Unlike continue(), output listeners stay attached and
* 'line'/'completed' events keep flowing, so callers can stream the rest
* of the output (e.g. to a log file) until the command completes.
*/
detach(): void
/**
* Get output that hasn't been retrieved yet.
* @returns The unretrieved output
+25
View File
@@ -68,6 +68,7 @@ import {
import { SdkCompactionCoordinator } from "./sdk-compaction-coordinator"
import { SdkDiffEditCoordinator } from "./sdk-diff-edit-coordinator"
import { SdkFollowupCoordinator } from "./sdk-followup-coordinator"
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
import { SdkMessageCoordinator, type SessionEventListener } from "./sdk-message-coordinator"
@@ -204,6 +205,15 @@ export class Controller {
// standalone (JetBrains/CLI) host run commands through the SDK's built-in tool.
private _terminalManager?: VscodeTerminalManager
// Registry of in-flight foreground (VS Code terminal) command executions.
// Owned here — not by the session — so it survives session rebuilds, which
// recreate the tool set. Drives the "Proceed While Running" button.
private readonly foregroundCommands = new SdkForegroundCommandCoordinator({
onRunningChanged: () => {
void this.postStateToWebview()
},
})
// Private state kept for stub compatibility
private backgroundCommandRunning = false
private backgroundCommandTaskId?: string
@@ -330,6 +340,7 @@ export class Controller {
},
onDidBecomeIdle: () => this.handleSessionBecameIdle(),
getRemoteConfigIntegration: () => this.remoteConfigCoreIntegration,
foregroundCommands: this.foregroundCommands,
getTerminalManager: () => {
// Guarded by getEffectiveTerminalExecutionMode() at the read sites
// (vscode-session-host.ts, sdk-terminal-execution-mode-coordinator.ts):
@@ -1174,6 +1185,19 @@ export class Controller {
stubWarn("cancelBackgroundCommand")
}
/**
* "Proceed While Running": detach every in-flight foreground terminal
* command. Each pending run_commands call returns its partial output plus
* the log file path the remaining output is redirected to, and the agent
* turn continues while the commands keep running in their terminals.
*/
async proceedWhileRunningCommand(): Promise<void> {
const detached = this.foregroundCommands.proceedWhileRunning()
if (detached === 0) {
Logger.warn("[SdkController] proceedWhileRunningCommand: No foreground command is running")
}
}
async cancelQueuedPrompt(promptId: string): Promise<void> {
const trimmedPromptId = promptId.trim()
if (!trimmedPromptId) {
@@ -1868,6 +1892,7 @@ export class Controller {
mcpHub: this.mcpHub,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
foregroundCommandRunning: this.foregroundCommands.isRunning,
})
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
.map(sessionHistoryRecordToHistoryItem)
@@ -2,6 +2,9 @@ import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import type { CoreSessionConfig } from "@cline/core"
import * as LlmsModels from "@cline/llms"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
buildResumeSessionInput,
@@ -15,9 +18,12 @@ import {
resolveApiKey,
updateHistoryItem,
} from "./cline-session-factory"
import { parseProviderId } from "./model-catalog/provider-id"
import { createProviderConfigStore } from "./model-catalog/store"
const mocks = vi.hoisted(() => {
const providerSettingsManager = {
getFilePath: vi.fn(() => path.join(tempDir, "settings", "providers.json")),
getLastUsedProviderSettings: vi.fn(() => undefined),
getProviderSettings: vi.fn((_providerId?: string) => undefined),
saveProviderSettings: vi.fn(),
@@ -39,6 +45,9 @@ const mocks = vi.hoisted(() => {
}
return undefined
}),
setGlobalStateBatch: vi.fn(),
setGlobalState: vi.fn(),
setSecret: vi.fn(),
},
}
})
@@ -72,11 +81,14 @@ vi.mock("@shared/services/Logger", () => ({
let tempDir: string
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH
const previousDataDir = process.env.CLINE_DATA_DIR
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-session-factory-"))
process.env.CLINE_DATA_DIR = tempDir
process.env.CLINE_GLOBAL_SETTINGS_PATH = path.join(tempDir, "global-settings.json")
vi.clearAllMocks()
LlmsModels.resetRegistry()
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
@@ -88,12 +100,14 @@ beforeEach(() => {
}
return undefined
})
mocks.providerSettingsManager.getFilePath.mockReturnValue(path.join(tempDir, "settings", "providers.json"))
mocks.providerSettingsManager.getLastUsedProviderSettings.mockReturnValue(undefined)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
})
afterEach(() => {
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath
process.env.CLINE_DATA_DIR = previousDataDir
fs.rmSync(tempDir, { recursive: true, force: true })
})
@@ -135,6 +149,11 @@ describe("getDefaultModelIdForProvider", () => {
expect(getDefaultModelIdForProvider("unknown-provider")).toBeUndefined()
})
it("returns no default for local-model-source providers so a cloud-catalog model is never silently selected", () => {
expect(getDefaultModelIdForProvider("ollama")).toBeUndefined()
expect(getDefaultModelIdForProvider("lmstudio")).toBeUndefined()
})
it("resolves the OpenAI Compatible default through the extension's openai alias", () => {
// The extension stores the OpenAI Compatible provider as "openai" while
// the SDK catalog keys it as "openai-compatible". toSdkProviderId bridges
@@ -230,9 +249,11 @@ describe("normalizeSdkBaseUrl", () => {
expect(normalizeSdkBaseUrl("openai-compatible", " ")).toBeUndefined()
})
it("uses provider catalog defaults to add the SDK endpoint path when the user supplies only an origin", () => {
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434/v1")
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/v1")
it("passes Ollama origins through unchanged (the native-API vendor appends /api itself)", () => {
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434")).toBe("http://localhost:11434")
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/")).toBe("http://localhost:11434/")
// Legacy 4.0.x configs may carry the OpenAI-compat /v1 suffix; it is
// preserved here and rewritten to /api by the vendor.
expect(normalizeSdkBaseUrl("ollama", "http://localhost:11434/v1")).toBe("http://localhost:11434/v1")
})
@@ -429,6 +450,40 @@ describe("buildSessionConfig", () => {
expect(config.providerConfig).not.toHaveProperty("apiKey")
})
it("preserves rich SDK catalog entries without extension-side replacement", async () => {
const expectedModel = structuredClone((await LlmsModels.getModelsForProvider("anthropic"))["claude-sonnet-4-6"])
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "anthropic",
actModeApiModelId: "claude-sonnet-4-6",
apiKey: "anthropic-key",
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["claude-sonnet-4-6"]
expect(knownModel).toEqual(expectedModel)
expect(knownModel.capabilities).toEqual(
expect.arrayContaining(["images", "files", "tools", "reasoning", "structured_output", "temperature", "prompt-cache"]),
)
expect(knownModel.pricing).toEqual(expectedModel.pricing)
expect(knownModel.releaseDate).toBe(expectedModel.releaseDate)
expect(knownModel.family).toBe(expectedModel.family)
})
it("keeps session creation non-fatal when known-model lookup fails", async () => {
const lookupError = new Error("registry unavailable")
const getModelsSpy = vi.spyOn(LlmsModels, "getModelsForProvider").mockRejectedValueOnce(lookupError)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(config.providerConfig).not.toHaveProperty("knownModels")
expect(Logger.warn).toHaveBeenCalledWith(
"[SessionFactory] Failed to resolve known models for provider=anthropic:",
lookupError,
)
getModelsSpy.mockRestore()
})
it("passes OpenAI Compatible max output tokens as an explicit request limit", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
@@ -451,11 +506,87 @@ describe("buildSessionConfig", () => {
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect(config.knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeUndefined()
expect((config.providerConfig as any).knownModels).toBeDefined()
expect((config.providerConfig as any).maxOutputTokens).toBeUndefined()
expect((config as any).maxTokensPerTurn).toBe(4_096)
})
it("uses OpenAI Compatible overrides from models.json for runtime request settings", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
actModeOpenAiModelInfo: { supportsPromptCache: false },
} as any)
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
providerId: parseProviderId("openai"),
modelId: "custom-reasoner",
overrides: {
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 15_000,
maxTokens: 1_234,
capabilities: ["images", "reasoning", "streaming", "tools"],
supportsVision: false,
supportsAttachments: true,
supportsReasoning: false,
temperature: 0,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.5,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
expect(config.providerId).toBe("openai-compatible")
expect(config.modelId).toBe("custom-reasoner")
expect((config as any).maxTokensPerTurn).toBe(1_234)
expect((config as any).temperature).toBe(0)
expect(knownModel).toMatchObject({
id: "custom-reasoner",
name: "Custom Reasoner",
contextWindow: 16_000,
maxInputTokens: 15_000,
maxTokens: 1_234,
capabilities: ["streaming", "tools", "files"],
apiFormat: "openai-responses",
temperature: 0,
pricing: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.5 },
})
})
it("keeps -1 OpenAI Compatible values out of request settings and fallback knownModels", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openai",
actModeOpenAiModelId: "custom-reasoner",
openAiApiKey: "openai-compatible-key",
openAiBaseUrl: "https://openai-compatible.example/v1",
actModeOpenAiModelInfo: { supportsPromptCache: false },
} as any)
createProviderConfigStore().commitSelection(parseProviderId("openai"), "act", {
providerId: parseProviderId("openai"),
modelId: "custom-reasoner",
overrides: {
name: "Custom Reasoner",
maxTokens: -1,
temperature: -1,
},
})
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect((config as any).maxTokensPerTurn).toBeUndefined()
expect((config as any).temperature).toBeUndefined()
const knownModel = (config.providerConfig as any).knownModels["custom-reasoner"]
expect(knownModel).not.toHaveProperty("maxTokens")
expect(knownModel).not.toHaveProperty("temperature", -1)
})
it("passes OCA reasoning effort from legacy mode settings to SDK sessions", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "oca",
+177 -6
View File
@@ -17,7 +17,13 @@ import {
resolveProviderApiKeyFromSettings,
type StartSessionResult,
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import type { ModelInfo as SdkModelInfo } from "@cline/llms"
import {
getGeneratedModelsForProvider,
getModelsForProvider,
MODEL_COLLECTIONS_BY_PROVIDER_ID,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
} from "@cline/llms"
import { buildClineSystemPrompt } from "@cline/shared"
import type { ApiConfiguration } from "@shared/api"
import { ClineClient } from "@shared/cline"
@@ -37,7 +43,11 @@ import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
import { buildAgentHooks } from "./hooks-adapter"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
import type { ResolvedModelSelection } from "./model-catalog/contracts"
import { nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-catalog/model-values"
import { parseProviderId } from "./model-catalog/provider-id"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { createProviderConfigStore, resolveRuntimeModelSelection } from "./model-catalog/store"
import { getProviderSettingsManager } from "./provider-migration"
import { buildSapProviderConfig, type SapProviderConfig } from "./sap-config"
import type { SdkSessionHost } from "./session-host"
@@ -209,8 +219,73 @@ function resolveOcaReasoningConfig(mode: Mode, apiConfig: ApiConfiguration | und
function resolveOpenAiCompatibleMaxTokens(config: ApiConfiguration | undefined, mode: Mode): number | undefined {
const modelInfo = mode === "plan" ? config?.planModeOpenAiModelInfo : config?.actModeOpenAiModelInfo
const maxTokens = modelInfo?.maxTokens
return typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined
return positiveFiniteNumber(modelInfo?.maxTokens)
}
function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
const modelInfo = selection.modelInfo
const capabilities = new Set<NonNullable<SdkModelInfo["capabilities"]>[number]>(
(selection.overrides?.capabilities ?? []) as NonNullable<SdkModelInfo["capabilities"]>,
)
const setCapability = (capability: NonNullable<SdkModelInfo["capabilities"]>[number], enabled: boolean): void => {
if (enabled) capabilities.add(capability)
else capabilities.delete(capability)
}
if (modelInfo.supportsImages !== undefined) setCapability("images", modelInfo.supportsImages)
setCapability("prompt-cache", modelInfo.supportsPromptCache)
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
const maxTokens = positiveFiniteNumber(modelInfo.maxTokens)
const contextWindow = positiveFiniteNumber(modelInfo.contextWindow)
const maxInputTokens = positiveFiniteNumber(selection.overrides?.maxInputTokens)
const temperature = nonNegativeFiniteNumber(modelInfo.temperature)
const inputPrice = nonNegativeFiniteNumber(modelInfo.inputPrice)
const outputPrice = nonNegativeFiniteNumber(modelInfo.outputPrice)
const cacheRead = nonNegativeFiniteNumber(modelInfo.cacheReadsPrice)
const cacheWrite = nonNegativeFiniteNumber(modelInfo.cacheWritesPrice)
const apiFormat = toSdkApiFormat(modelInfo.apiFormat)
const hasPricing =
inputPrice !== undefined || outputPrice !== undefined || cacheRead !== undefined || cacheWrite !== undefined
return {
id: selection.modelId,
name: modelInfo.name ?? selection.modelId,
...(maxTokens !== undefined ? { maxTokens } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
...(capabilities.size > 0 ? { capabilities: [...capabilities] } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(hasPricing
? {
pricing: {
...(inputPrice !== undefined ? { input: inputPrice } : {}),
...(outputPrice !== undefined ? { output: outputPrice } : {}),
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheWrite !== undefined ? { cacheWrite } : {}),
},
}
: {}),
}
}
function resolveCommittedRuntimeModel(
providerId: string,
mode: Mode,
modelId: string | undefined,
): ResolvedModelSelection | undefined {
if (!modelId) {
return undefined
}
try {
const parsedProviderId = parseProviderId(providerId)
const selection = createProviderConfigStore().readSelection(parsedProviderId, mode)
return selection?.modelId === modelId ? selection : resolveRuntimeModelSelection(parsedProviderId, modelId)
} catch (error) {
Logger.warn(`[SessionFactory] Failed to resolve committed model settings for provider=${providerId}:`, error)
return undefined
}
}
// ---------------------------------------------------------------------------
@@ -303,8 +378,21 @@ const PROVIDER_MODEL_ID_MAP: Record<string, { plan: keyof ApiConfiguration; act:
const DEFAULT_PROVIDER_ID = "cline"
/**
* Providers whose model list comes from a live local endpoint (Ollama's
* `/api/tags`, LM Studio's `/v1/models`). Their installed models are the only
* meaningful catalog; a bundled-catalog default would silently select a model
* the user never installed (e.g. an Ollama Cloud nemotron model).
*/
function providerHasLocalModelSource(providerId: string): boolean {
return Boolean(MODEL_COLLECTIONS_BY_PROVIDER_ID[toSdkProviderId(providerId)]?.provider.modelsSourceUrl)
}
export function getDefaultModelIdForProvider(providerId: string): string | undefined {
const sdkProviderId = toSdkProviderId(providerId)
if (providerHasLocalModelSource(providerId)) {
return undefined
}
const collection = MODEL_COLLECTIONS_BY_PROVIDER_ID[sdkProviderId]
if (!collection) {
return undefined
@@ -479,6 +567,42 @@ export function resolveVertexProviderConfig(config: ApiConfiguration): Pick<Prov
}
}
type OllamaProviderConfig = {
modelInfo?: { id: string; name: string; contextWindow: number }
timeoutMs?: number
}
/**
* Resolve the user's "Model Context Window" setting for Ollama and surface it
* as the selected model's `contextWindow`. The gateway carries it on the
* resolved model definition, and the Ollama vendor maps it onto the wire as
* `options.num_ctx` without it Ollama loads every model with its 4096-token
* server default. Keeping it on the model also means context management
* budgets against the window Ollama actually applies (Ollama truncates the
* prompt to `num_ctx` server-side).
*/
export function resolveOllamaProviderConfig(config: ApiConfiguration, modelId: string | undefined): OllamaProviderConfig {
// providers.json (`contextWindow`) is the source of truth; the legacy
// StateManager string is a migration fallback (the config store mirrors
// writes to both).
let settingsContextWindow: number | undefined
try {
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
settingsContextWindow = typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
} catch {
Logger.warn("[SessionFactory] Failed to read Ollama settings from providers.json")
}
const raw = config.ollamaApiOptionsCtxNum?.trim()
const parsed = raw ? Number(raw) : Number.NaN
const legacyContextWindow = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined
const contextWindow = settingsContextWindow ?? legacyContextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW
const timeoutMs = config.requestTimeoutMs
return {
...(typeof timeoutMs === "number" && timeoutMs > 0 ? { timeoutMs } : {}),
...(modelId ? { modelInfo: { id: modelId, name: modelId, contextWindow } } : {}),
}
}
export function resolveBaseUrl(providerId: string, config: ApiConfiguration): string | undefined {
const baseUrlMap: Record<string, keyof ApiConfiguration> = {
anthropic: "anthropicBaseUrl",
@@ -536,6 +660,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
let bedrockProviderConfig: BedrockProviderConfig | undefined
let vertexProviderConfig: Pick<ProviderSettings, "gcp" | "region"> | undefined
let sapProviderConfig: SapProviderConfig | undefined
let ollamaProviderConfig: ReturnType<typeof resolveOllamaProviderConfig> | undefined
try {
const stateManager = StateManager.get()
@@ -571,6 +696,10 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
baseUrl = sapProviderConfig.baseUrl
}
if (providerId === "ollama") {
ollamaProviderConfig = resolveOllamaProviderConfig(apiConfig, modelId)
}
Logger.log(
`[SessionFactory] Resolved from StateManager: provider=${providerId}, model=${modelId}, hasApiKey=${!!apiKey}`,
)
@@ -606,12 +735,31 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// Final defaults. Keep this aligned with the provider catalog so the UI and
// session factory share one source of truth for default models.
providerId = providerId ?? DEFAULT_PROVIDER_ID
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
if (!modelId && providerHasLocalModelSource(providerId)) {
// Local-model-source providers: the committed selection lives in
// providers.json when the legacy state slot is empty (e.g. configs
// created through the SDK settings store). Never fall through to a
// catalog default — an empty model id surfaces an explicit "select a
// model" state instead of silently running a model the user never chose.
try {
modelId = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))?.model?.trim()
} catch {
Logger.warn(`[SessionFactory] Failed to read ${providerId} model from providers.json`)
}
modelId = modelId || ""
} else {
modelId = modelId ?? getDefaultModelIdForProvider(providerId) ?? getDefaultModelIdForProvider(DEFAULT_PROVIDER_ID) ?? ""
}
if (!apiKey && apiConfig) {
apiKey = resolveApiKey(providerId, apiConfig)
}
apiKey = apiKey ?? ""
const maxTokensPerTurn = providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined
const committedRuntimeModel = resolveCommittedRuntimeModel(providerId, mode, modelId)
const overriddenMaxTokens = committedRuntimeModel?.overrides?.maxTokens
const maxTokensPerTurn =
positiveFiniteNumber(overriddenMaxTokens) ??
(providerId === "openai" ? resolveOpenAiCompatibleMaxTokens(apiConfig, mode) : undefined)
const temperature = nonNegativeFiniteNumber(committedRuntimeModel?.overrides?.temperature)
const reasoningConfig =
providerId === "oca"
? (resolveOcaReasoningConfig(mode, apiConfig) ?? resolveProviderReasoningConfig(providerId))
@@ -662,13 +810,34 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
const sdkProviderId = toSdkProviderId(providerId)
const hostIdentity = await resolveHostIdentity()
const isMultiRoot = await resolveIsMultiRootWorkspace()
let knownModels: Awaited<ReturnType<typeof getModelsForProvider>> | undefined
try {
// Constructing the settings manager loads providers.json and models.json into
// the @cline/llms registry. Reading models from that registry ensures custom
// model overrides are included in the inference provider config, not just in
// the webview/display path.
getProviderSettingsManager(resolveDataDir())
knownModels = await getModelsForProvider(sdkProviderId)
// Only inject host-resolved metadata that carries real information
// (catalog/state base or user overrides). Pure fallback fabrications
// must not reach the runtime; the SDK's own resolution handles those.
const isPureFallbackModel = committedRuntimeModel?.modelInfoSource === "fallback" && !committedRuntimeModel.overrides
if (committedRuntimeModel && !isPureFallbackModel && !knownModels?.[modelId]) {
knownModels = {
...(knownModels ?? {}),
[modelId]: toSdkModelInfo(committedRuntimeModel),
}
}
} catch (error) {
Logger.warn(`[SessionFactory] Failed to resolve known models for provider=${sdkProviderId}:`, error)
}
// Always pass a providerConfig so the proxy/CA-aware fetch reaches the SDK
// gateway; without it the agent loop uses bare global fetch and corporate
// proxy/self-signed CA setups fail on JetBrains and CLI. Cloud providers
// additionally need structured options (region/project/auth/SAP OAuth), which core
// reads from providerConfig in createAgentModelFromConfig.
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig
const cloudProviderConfig = bedrockProviderConfig ?? vertexProviderConfig ?? sapProviderConfig ?? ollamaProviderConfig
// Spread the cloud config first so the explicit fields below — notably the
// proxy/CA-aware fetch — can never be clobbered if those types gain matching keys.
const providerConfig = {
@@ -677,6 +846,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
modelId,
...(apiKey ? { apiKey } : {}),
...(baseUrl !== undefined ? { baseUrl } : {}),
...(knownModels && Object.keys(knownModels).length > 0 ? { knownModels } : {}),
fetch,
}
@@ -707,6 +877,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
mode: mode === "plan" ? "plan" : "act",
...reasoningConfig,
...(maxTokensPerTurn !== undefined ? { maxTokensPerTurn } : {}),
...(temperature !== undefined ? { temperature } : {}),
maxIterations: undefined,
logger: sdkLogger,
extensionContext: {
@@ -3,10 +3,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
import type {
EffectiveProviderConfig,
Fingerprint,
ModelSelection,
ProviderConfigChange,
ProviderConfigReader,
ProviderModelsResult,
ResolvedModelSelection,
} from "./contracts"
import { computeConfigFingerprint } from "./fingerprint"
import { parseProviderId } from "./provider-id"
@@ -95,7 +95,7 @@ function record(
}
}
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ModelSelection): TestReader {
function makeReader(initialConfig: EffectiveProviderConfig, selection?: ResolvedModelSelection): TestReader {
let config = initialConfig
const listeners = new Set<(event: ProviderConfigChange) => void>()
return {
@@ -241,7 +241,7 @@ describe("ProviderCatalog Phase 3.2 resolveModels happy path", () => {
})
const providerId = parseProviderId("openrouter")
const config: EffectiveProviderConfig = { providerId, apiKey: "secret", baseUrl: "https://provider.example.com" }
const selection: ModelSelection = { providerId, modelId: "selected", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "selected", modelInfo }
const reader = makeReader(config, selection)
const catalog = createProviderCatalog(reader)
@@ -507,7 +507,7 @@ describe("ProviderCatalog Phase 3.4 store-driven invalidation", () => {
const providerId = parseProviderId("openrouter")
const reader = makeReader({ providerId, apiKey: "same" })
const catalog = createProviderCatalog(reader)
const selection: ModelSelection = { providerId, modelId: "different", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "different", modelInfo }
const first = await catalog.resolveModels(providerId)
reader.emit({ kind: "selection", providerId, mode: "act", selection })
@@ -692,7 +692,7 @@ describe("ProviderCatalog Phase 3.6 subscribe", () => {
const reader = makeReader({ providerId, baseUrl: "http://localhost:11434/v1" })
const catalog = createProviderCatalog(reader)
const listener = vi.fn()
const selection: ModelSelection = { providerId, modelId: "custom:latest", modelInfo }
const selection: ResolvedModelSelection = { providerId, modelId: "custom:latest", modelInfo }
catalog.subscribe(providerId, listener)
reader.emit({ kind: "selection", providerId, mode: "act", selection })
+68 -17
View File
@@ -68,8 +68,8 @@ export type Fingerprint = string & { readonly [FingerprintBrand]: void }
* - Two reads with no intervening write return structurally equal values.
* - Consumers must not mutate. The shape is `Readonly`.
*
* Mode-dependent selection (modelId, modelInfo) is *not* part of this
* type. Use `ProviderConfigStore.readSelection(providerId, mode)`.
* Mode-dependent selection is *not* part of this type. Use
* `ProviderConfigStore.readSelection(providerId, mode)`.
*/
export interface AwsProviderConfig {
readonly accessKey?: string
@@ -98,6 +98,12 @@ export interface EffectiveProviderConfig {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
/**
* Provider-level context window (providers.json `contextWindow`).
* Provider-neutral: for Ollama it maps to `options.num_ctx` at the
* vendor boundary.
*/
readonly contextWindow?: number
/**
* OAuth-style auth bundle (e.g. cline provider's WorkOS token).
* Compatible with `apiKey`; some providers populate both.
@@ -119,9 +125,9 @@ export interface EffectiveProviderConfig {
* A patch describing a field-level write to `ProviderConfigStore`.
*
* Invariant: `ProviderConfigPatch` cannot describe a model selection. The
* type does not contain `modelId` or `modelInfo`. To write a selection,
* use `commitSelection`, which is a structurally distinct method on the
* store.
* type does not contain `modelId` or per-model overrides. To write a
* selection, use `commitSelection`, which is a structurally distinct method
* on the store.
*
* Empty patches are allowed and are no-ops. A field present with value
* `null` means "clear this field"; an absent field means "leave unchanged."
@@ -140,6 +146,7 @@ export interface ProviderConfigPatch {
readonly region?: string | null
readonly aws?: AwsProviderConfig | null
readonly gcp?: GcpProviderConfig | null
readonly contextWindow?: number | null
readonly auth?: {
readonly accessToken?: string
readonly refreshToken?: string
@@ -153,22 +160,65 @@ export interface ProviderConfigPatch {
// Model selection
// ---------------------------------------------------------------------------
/** Per-model metadata overrides authored by the user for custom models. */
export interface ModelSelectionOverrides {
readonly name?: string
readonly maxTokens?: number
readonly contextWindow?: number
readonly maxInputTokens?: number
readonly capabilities?: readonly string[]
readonly supportsVision?: boolean
readonly supportsAttachments?: boolean
readonly supportsReasoning?: boolean
readonly inputPrice?: number
readonly outputPrice?: number
readonly cacheReadsPrice?: number
readonly cacheWritesPrice?: number
readonly temperature?: number
readonly apiFormat?: ModelInfo["apiFormat"]
readonly isR1FormatRequired?: boolean
}
/**
* A user's committed model selection. The triple is atomic by type: every
* write of `modelId` carries its `modelInfo` envelope, and vice versa.
* A user's committed model selection. The committed write stores only the
* selected model id plus optional user-authored overrides. `ModelInfo` is
* derived by the host from the SDK catalog, then layered with `models.json`
* overrides, then per-provider safe fallback metadata.
*
* Invariants:
* - `modelInfo` was either taken from a `ProviderCatalog.resolveModels`
* result, or constructed from per-provider safe defaults when the user
* entered a custom id manually. Either way it represents the picker's
* best knowledge at the moment of commit. The runtime uses it verbatim.
* - The stored selection wins over later SDK catalog changes. Refresh
* does not retroactively change committed selections.
* - The webview does not commit a `ModelInfo` snapshot.
* - Catalog metadata updates may affect the resolved `ModelInfo` for an
* existing selection unless the user has explicitly overridden the field.
*/
export interface ModelSelection {
readonly providerId: ProviderId
readonly modelId: string
readonly overrides?: ModelSelectionOverrides
}
/** A committed selection as read back by consumers that need display/runtime metadata. */
export interface ResolvedModelSelection extends ModelSelection {
readonly modelInfo: ModelInfo
/**
* Where the base `modelInfo` came from, before overrides were applied:
*
* - "catalog" SDK catalog metadata (generated snapshot or registry).
* - "state" the mode-specific `*ModeModelInfo` snapshot persisted by
* the picker at selection time. Authoritative for dynamic-list providers
* (openrouter, litellm, requesty, ) whose models are not in the static
* catalog.
* - "fallback" provider-safe defaults fabricated because nothing better
* was available. Consumers should treat a "fallback" resolution without
* overrides as weak data and prefer live catalog lookups over it.
*/
readonly modelInfoSource?: "catalog" | "state" | "fallback"
/**
* The base metadata `modelInfo` was resolved from, before overrides were
* applied. Persisted (rather than the resolved value) into the legacy
* state snapshot so that deleting an override cannot resurrect it from a
* snapshot it was previously baked into.
*/
readonly baseModelInfo?: ModelInfo
}
// ---------------------------------------------------------------------------
@@ -190,7 +240,7 @@ export type ProviderConfigChange =
readonly kind: "selection"
readonly providerId: ProviderId
readonly mode: Mode
readonly selection: ModelSelection
readonly selection: ResolvedModelSelection
}
export type ProviderConfigChangeListener = (event: ProviderConfigChange) => void
@@ -317,7 +367,7 @@ export interface ProviderModelsEvent {
*/
export interface ProviderConfigReader {
read(providerId: ProviderId): EffectiveProviderConfig
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined
subscribe(listener: ProviderConfigChangeListener): Disposable
}
@@ -351,8 +401,9 @@ export interface ProviderConfigStore extends ProviderConfigReader {
write(providerId: ProviderId, patch: ProviderConfigPatch): EffectiveProviderConfig
/**
* Commit a model selection atomically with its info envelope. The only
* entry point that writes `{providerId, modelId, modelInfo}` triples.
* Commit a model ID atomically with optional user-authored overrides.
* Supplying overrides replaces that model's stored override entry; omitting
* them leaves the existing entry unchanged.
*
* I2: refresh handlers do not have access to this method by type, since
* `ProviderCatalog` holds only a `ProviderConfigReader`.
@@ -54,7 +54,27 @@ describe("buildEffectiveProviderConfig", () => {
providerId: parseProviderId("ollama"),
apiKey: "provider-ollama-key",
baseUrl: "http://state-ollama:11434",
extras: { ollamaApiOptionsCtxNum: "8192" },
// The legacy state string surfaces as the provider-neutral
// contextWindow when providers.json has none.
contextWindow: 8192,
})
})
it("prefers the providers.json contextWindow over the legacy Ollama state key", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
ollama: {
provider: "ollama",
contextWindow: 65536,
},
})
mocks.setApiConfiguration({
ollamaApiOptionsCtxNum: "8192",
})
expect(buildEffectiveProviderConfig(parseProviderId("ollama"))).toEqual({
providerId: parseProviderId("ollama"),
contextWindow: 65536,
})
})
@@ -18,6 +18,7 @@ type ProviderSettingsLike = {
readonly region?: string
readonly aws?: AwsProviderConfig
readonly gcp?: GcpProviderConfig
readonly contextWindow?: number
readonly auth?: AuthConfig
readonly extras?: ExtrasConfig
}
@@ -102,7 +103,6 @@ const headerFields: Partial<Record<string, keyof ApiConfiguration>> = {
}
const extrasFields: Partial<Record<string, Partial<Record<string, keyof ApiConfiguration>>>> = {
ollama: { ollamaApiOptionsCtxNum: "ollamaApiOptionsCtxNum" },
lmstudio: { lmStudioMaxTokens: "lmStudioMaxTokens" },
litellm: { liteLlmUsePromptCache: "liteLlmUsePromptCache" },
openrouter: { openRouterProviderSorting: "openRouterProviderSorting" },
@@ -157,6 +157,14 @@ function readBoolean(record: Record<string, unknown>, key: string): boolean | un
return typeof value === "boolean" ? value : undefined
}
function readPositiveInteger(value: unknown): number | undefined {
const parsed = typeof value === "string" ? Number(value) : value
if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) {
return Math.floor(parsed)
}
return undefined
}
function readGcp(record: Record<string, unknown>): GcpProviderConfig | undefined {
const gcp = record.gcp
if (!isPlainRecord(gcp)) {
@@ -206,6 +214,7 @@ function readProviderSettings(providerId: ProviderId): ConfigParts {
region: readString(settings, "region"),
aws: readAws(settings),
gcp: readGcp(settings),
contextWindow: readPositiveInteger(settings.contextWindow),
auth: readAuth(settings),
extras: isPlainRecord(settings.extras) ? settings.extras : undefined,
} satisfies ProviderSettingsLike
@@ -298,6 +307,16 @@ function readStateAws(provider: string, config: ApiConfiguration): AwsProviderCo
return Object.values(aws).some((value) => value !== undefined) ? aws : undefined
}
function readStateContextWindow(provider: string, config: ApiConfiguration): number | undefined {
// Only Ollama has a legacy context-window state key; other providers keep
// theirs in providers.json exclusively.
if (provider !== "ollama") {
return undefined
}
return readPositiveInteger(config.ollamaApiOptionsCtxNum)
}
function readStateConfig(providerId: ProviderId, config: ApiConfiguration): ConfigParts {
const provider = providerId.toString()
return {
@@ -308,6 +327,7 @@ function readStateConfig(providerId: ProviderId, config: ApiConfiguration): Conf
region: readStringFromConfig(config, regionFields[provider]),
aws: readStateAws(provider, config),
gcp: readStateGcp(provider, config),
contextWindow: readStateContextWindow(provider, config),
auth: readStateAuth(provider, config),
extras: readStateExtras(provider, config),
}
@@ -372,6 +392,10 @@ export function buildEffectiveProviderConfig(providerId: ProviderId): EffectiveP
// fields as a fallback for old installs, but let providers.json win when both exist.
assignIfDefined(merged, "aws", mergeAws(stateConfig.aws, providerSettings.aws))
assignIfDefined(merged, "gcp", mergeGcp(stateConfig.gcp, providerSettings.gcp))
// providers.json is the source of truth for the context window; the legacy
// Ollama StateManager key is a migration fallback (the store mirrors writes
// to both).
assignIfDefined(merged, "contextWindow", providerSettings.contextWindow ?? stateConfig.contextWindow)
assignIfDefined(merged, "auth", stateConfig.auth ?? providerSettings.auth)
assignIfDefined(merged, "extras", mergeExtras(providerSettings.extras, stateConfig.extras))
@@ -170,6 +170,9 @@ export function computeConfigFingerprint(providerId: ProviderId, config: Effecti
region: config.region ?? null,
aws: sanitizeAws(config.aws),
gcp: sanitizeGcp(config.gcp),
// The context window is not a secret; include it raw so changes
// invalidate cached model lists.
contextWindow: config.contextWindow ?? null,
extras: sanitizeExtras(config.extras),
auth: {
accountId: config.auth?.accountId ?? null,
@@ -1,7 +1,8 @@
/**
* Applies the `ModelInfo` fields the extension owns locally, on top of
* an adapted SDK `ModelInfo`. Today this is just Vertex's
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`).
* an adapted SDK `ModelInfo`. Today this is Vertex's
* `supportsGlobalEndpoint` allowlist (see `./vertex-global-endpoint.ts`)
* and Ollama's effective context window.
*
* Both the model-list resolution path (`resolveSdkModels`) and the
* single-model lookup path (`resolveModelInfo`) pass adapted
@@ -10,13 +11,52 @@
* flags upstream, the override and this file can be removed together.
*/
import { OLLAMA_DEFAULT_CONTEXT_WINDOW } from "@cline/llms"
import type { ModelInfo } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { ProviderId } from "./contracts"
import { vertexModelSupportsGlobalEndpoint } from "./vertex-global-endpoint"
/**
* The context window Ollama actually applies is the requested `num_ctx`,
* not the model's native maximum Ollama truncates the prompt to it
* server-side. Surface the user's "Model Context Window" setting (or the
* request default) instead of catalog/safe-default values so the chat
* indicator and context management match reality.
*/
function resolveOllamaContextWindow(): number {
// providers.json (`contextWindow`) is the source of truth; the legacy
// StateManager string is a migration fallback (the config store mirrors
// writes to both).
try {
const value = getProviderSettingsManager().getProviderSettings("ollama")?.contextWindow
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
} catch {
// providers.json unavailable — fall through to the legacy state key.
}
try {
const raw = StateManager.get().getApiConfiguration().ollamaApiOptionsCtxNum?.trim()
if (raw) {
const value = Number(raw)
if (Number.isFinite(value) && value > 0) {
return Math.floor(value)
}
}
} catch {
// StateManager unavailable (e.g. tests) — fall through to the default.
}
return OLLAMA_DEFAULT_CONTEXT_WINDOW
}
export function applyHostModelInfoOverrides(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): ModelInfo {
if (providerId === "vertex" && vertexModelSupportsGlobalEndpoint(providerId, modelId)) {
return { ...modelInfo, supportsGlobalEndpoint: true }
}
if (providerId === "ollama") {
return { ...modelInfo, contextWindow: resolveOllamaContextWindow() }
}
return modelInfo
}
@@ -0,0 +1,45 @@
import type { ModelInfo } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
/** SDK string spelling of an API format (matches @cline/shared ApiFormatSchema). */
export type SdkApiFormatString = "r1" | "openai-responses" | "default"
export function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
export function positiveFiniteNumber(value: unknown): number | undefined {
const number = finiteNumber(value)
return number !== undefined && number > 0 ? number : undefined
}
export function nonNegativeFiniteNumber(value: unknown): number | undefined {
const number = finiteNumber(value)
return number !== undefined && number >= 0 ? number : undefined
}
export function toSdkApiFormat(apiFormat: ModelInfo["apiFormat"]): SdkApiFormatString | undefined {
switch (apiFormat) {
case ApiFormat.R1_CHAT:
return "r1"
case ApiFormat.OPENAI_RESPONSES:
return "openai-responses"
case ApiFormat.OPENAI_CHAT:
return "default"
default:
return undefined
}
}
export function fromSdkApiFormat(apiFormat: string | undefined): ModelInfo["apiFormat"] | undefined {
switch (apiFormat) {
case "r1":
return ApiFormat.R1_CHAT
case "openai-responses":
return ApiFormat.OPENAI_RESPONSES
case "default":
return ApiFormat.OPENAI_CHAT
default:
return undefined
}
}
+572 -29
View File
@@ -1,4 +1,6 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import { syncStoredProviderRegistration } from "@cline/core"
import { type ApiConfiguration, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { ProviderConfigChange } from "./contracts"
import { parseProviderId } from "./provider-id"
@@ -7,6 +9,11 @@ const mocks = vi.hoisted(() => {
type MockApiConfiguration = ApiConfiguration & { planActSeparateModelsSetting?: boolean }
let apiConfiguration: MockApiConfiguration = {}
let providerSettingsById: Record<string, Record<string, unknown>> = {}
let generatedModelsByProvider: Record<string, Record<string, ModelInfo>> = {}
let modelsFile: { version: 1; providers: Record<string, { models?: Record<string, Record<string, unknown>> }> } = {
version: 1,
providers: {},
}
const saveProviderSettings = vi.fn((settings: Record<string, unknown>, _options?: { setLastUsed?: boolean }) => {
const provider = settings.provider
if (typeof provider !== "string") {
@@ -20,6 +27,8 @@ const mocks = vi.hoisted(() => {
reset(): void {
apiConfiguration = {}
providerSettingsById = {}
generatedModelsByProvider = {}
modelsFile = { version: 1, providers: {} }
saveProviderSettings.mockClear()
},
setApiConfiguration(value: MockApiConfiguration): void {
@@ -28,6 +37,12 @@ const mocks = vi.hoisted(() => {
setProviderSettings(value: Record<string, Record<string, unknown>>): void {
providerSettingsById = { ...value }
},
setGeneratedModels(providerId: string, models: Record<string, ModelInfo>): void {
generatedModelsByProvider = { ...generatedModelsByProvider, [providerId]: models }
},
getGeneratedModels(providerId: string): Record<string, ModelInfo> {
return generatedModelsByProvider[providerId] ?? {}
},
getSavedProviderSettings(providerId: string): Record<string, unknown> | undefined {
return providerSettingsById[providerId]
},
@@ -37,6 +52,12 @@ const mocks = vi.hoisted(() => {
getSaveProviderSettingsMock(): typeof saveProviderSettings {
return saveProviderSettings
},
getModelsFile() {
return modelsFile
},
setModelsFile(value: typeof modelsFile): void {
modelsFile = value
},
getStateManager() {
return {
getApiConfiguration: () => ({ ...apiConfiguration }),
@@ -69,11 +90,24 @@ vi.mock("../provider-migration", () => ({
getProviderSettingsManager: mocks.getProviderSettingsManager,
}))
vi.mock("@cline/core", () => ({
syncStoredProviderRegistration: vi.fn(),
readModelsFileSync: vi.fn(() => mocks.getModelsFile()),
resolveModelsRegistryPath: vi.fn(() => "/tmp/models.json"),
writeModelsFileSync: vi.fn((_filePath: string, state: ReturnType<typeof mocks.getModelsFile>) => mocks.setModelsFile(state)),
}))
vi.mock("@cline/llms", () => ({
getGeneratedModelsForProvider: vi.fn((providerId: string) => mocks.getGeneratedModels(providerId)),
MODEL_COLLECTIONS_BY_PROVIDER_ID: {},
}))
const modelInfoA: ModelInfo = {
name: "Model A",
contextWindow: 128_000,
maxTokens: 8_192,
supportsPromptCache: true,
apiFormat: ApiFormat.OPENAI_RESPONSES,
}
const modelInfoB: ModelInfo = {
@@ -83,9 +117,41 @@ const modelInfoB: ModelInfo = {
supportsPromptCache: false,
}
function selectionFromModelInfo(providerId: ReturnType<typeof parseProviderId>, modelId: string, modelInfo: ModelInfo) {
const capabilities: string[] = []
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
if (modelInfo.supportsImages) capabilities.push("images")
if (modelInfo.supportsReasoning) capabilities.push("reasoning")
return {
providerId,
modelId,
overrides: {
name: modelInfo.name,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
...(modelInfo.apiFormat !== undefined ? { apiFormat: modelInfo.apiFormat } : {}),
...(capabilities.length > 0 ? { capabilities } : {}),
},
}
}
function expectResolvedSelection(
actual: unknown,
selection: ReturnType<typeof selectionFromModelInfo>,
modelInfo: ModelInfo,
): void {
expect(actual).toMatchObject({
providerId: selection.providerId,
modelId: selection.modelId,
overrides: selection.overrides,
modelInfo,
})
}
describe("createProviderConfigStore", () => {
beforeEach(() => {
mocks.reset()
vi.clearAllMocks()
vi.resetModules()
})
@@ -121,22 +187,29 @@ describe("createProviderConfigStore", () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "anthropic/claude-sonnet-4", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "anthropic/claude-sonnet-4", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getModelsFile().providers.openrouter?.models?.["anthropic/claude-sonnet-4"]).toMatchObject({
name: "Model A",
contextWindow: 128_000,
maxTokens: 8_192,
apiFormat: "openai-responses",
capabilities: ["prompt-cache"],
})
})
it("round-trips generic provider selections using the in-process modelInfo envelope", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("deepseek")
const selection = { providerId, modelId: "deepseek-v4-pro", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "deepseek-v4-pro", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
})
it("hydrates a generic provider selection from providers.json after reload", async () => {
@@ -148,6 +221,8 @@ describe("createProviderConfigStore", () => {
expect(store.readSelection(providerId, "act")).toEqual({
providerId,
modelId: "manual-zai-model",
modelInfoSource: "fallback",
baseModelInfo: expect.objectContaining({ name: "manual-zai-model" }),
modelInfo: expect.objectContaining({
name: "manual-zai-model",
supportsPromptCache: false,
@@ -160,27 +235,27 @@ describe("createProviderConfigStore", () => {
const store = createProviderConfigStore()
const geminiProviderId = parseProviderId("gemini")
const deepSeekProviderId = parseProviderId("deepseek")
const geminiSelection = { providerId: geminiProviderId, modelId: "gemini-3.1-pro-preview", modelInfo: modelInfoA }
const deepSeekSelection = { providerId: deepSeekProviderId, modelId: "deepseek-v4-pro", modelInfo: modelInfoB }
const geminiSelection = selectionFromModelInfo(geminiProviderId, "gemini-3.1-pro-preview", modelInfoA)
const deepSeekSelection = selectionFromModelInfo(deepSeekProviderId, "deepseek-v4-pro", modelInfoB)
store.commitSelection(geminiProviderId, "act", geminiSelection)
store.commitSelection(deepSeekProviderId, "act", deepSeekSelection)
expect(store.readSelection(geminiProviderId, "act")).toEqual(geminiSelection)
expect(store.readSelection(deepSeekProviderId, "act")).toEqual(deepSeekSelection)
expectResolvedSelection(store.readSelection(geminiProviderId, "act"), geminiSelection, modelInfoA)
expectResolvedSelection(store.readSelection(deepSeekProviderId, "act"), deepSeekSelection, modelInfoB)
})
it("handles normalized nousResearch provider casing for writes and selections", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("nousResearch")
const selection = { providerId, modelId: "nousresearch/hermes-4-70b", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "nousresearch/hermes-4-70b", modelInfoA)
const written = store.write(providerId, { apiKey: "nous-key" })
store.commitSelection(providerId, "act", selection)
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
apiKey: "nous-key",
@@ -227,6 +302,179 @@ describe("createProviderConfigStore", () => {
})
})
it("lazily migrates meaningful legacy custom-model metadata once", async () => {
const legacyModelInfo = {
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: true,
}
mocks.setApiConfiguration({
actModeOpenAiModelId: "legacy-custom",
actModeOpenAiModelInfo: legacyModelInfo,
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const first = store.readSelection(providerId, "act")
const second = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["legacy-custom"]).toEqual({
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
capabilities: ["prompt-cache"],
supportsVision: false,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: "openai-responses",
isR1FormatRequired: true,
})
expect(first?.overrides).toEqual(second?.overrides)
expect(first?.modelInfo).toMatchObject({
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 1,
outputPrice: 2,
cacheReadsPrice: 0.25,
cacheWritesPrice: 0.5,
temperature: 0.3,
apiFormat: ApiFormat.R1_CHAT,
})
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
})
it("does not create migration noise for legacy safe defaults", async () => {
mocks.setApiConfiguration({
actModeOpenAiModelId: "default-custom",
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, name: "default-custom" },
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const first = store.readSelection(providerId, "act")
const second = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["default-custom"]).toBeUndefined()
expect(first?.overrides).toBeUndefined()
expect(second?.overrides).toBeUndefined()
expect(first?.modelInfo).toMatchObject({ contextWindow: 128_000, supportsImages: true, temperature: 0 })
expect(first?.modelInfo.maxTokens).toBeUndefined()
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("never overwrites an existing models.json entry during migration", async () => {
mocks.setModelsFile({
version: 1,
providers: {
"openai-compatible": { models: { "existing-custom": { temperature: 0.7 } } },
},
})
mocks.setApiConfiguration({
actModeOpenAiModelId: "existing-custom",
actModeOpenAiModelInfo: { ...openAiModelInfoSafeDefaults, temperature: 0.2 },
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["existing-custom"]).toEqual({
temperature: 0.7,
})
expect(selection?.overrides).toEqual({ temperature: 0.7 })
expect(selection?.modelInfo.temperature).toBe(0.7)
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("does not migrate stale legacy snapshots for catalog-known models", async () => {
mocks.setGeneratedModels("openai-compatible", {
"known-model": {
name: "Current Catalog Model",
contextWindow: 256_000,
supportsPromptCache: false,
temperature: 0.1,
},
})
mocks.setApiConfiguration({
actModeOpenAiModelId: "known-model",
actModeOpenAiModelInfo: {
name: "Stale Catalog Model",
contextWindow: 32_000,
supportsPromptCache: false,
temperature: 0.9,
},
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["known-model"]).toBeUndefined()
expect(selection?.overrides).toBeUndefined()
expect(selection?.modelInfo).toMatchObject({
name: "Current Catalog Model",
contextWindow: 256_000,
temperature: 0.1,
})
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("migrates separate Plan and Act legacy custom models independently", async () => {
mocks.setApiConfiguration({
planActSeparateModelsSetting: true,
planModeOpenAiModelId: "legacy-plan",
planModeOpenAiModelInfo: {
...openAiModelInfoSafeDefaults,
contextWindow: 64_000,
apiFormat: ApiFormat.OPENAI_RESPONSES,
},
actModeOpenAiModelId: "legacy-act",
actModeOpenAiModelInfo: {
...openAiModelInfoSafeDefaults,
maxTokens: 2_048,
isR1FormatRequired: true,
},
})
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const plan = store.readSelection(providerId, "plan")
const act = store.readSelection(providerId, "act")
store.readSelection(providerId, "plan")
store.readSelection(providerId, "act")
expect(mocks.getModelsFile().providers["openai-compatible"]?.models).toMatchObject({
"legacy-plan": { contextWindow: 64_000, apiFormat: "openai-responses" },
"legacy-act": { maxTokens: 2_048, isR1FormatRequired: true },
})
expect(plan?.modelInfo).toMatchObject({ contextWindow: 64_000, apiFormat: ApiFormat.OPENAI_RESPONSES })
expect(act?.modelInfo).toMatchObject({ maxTokens: 2_048, apiFormat: ApiFormat.R1_CHAT })
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
})
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
@@ -237,7 +485,7 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "gpt-oss-120b", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -248,6 +496,242 @@ describe("createProviderConfigStore", () => {
})
})
it("preserves per-model OpenAI Compatible overrides when switching models without new overrides", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const modelASelection = selectionFromModelInfo(providerId, "model-a", modelInfoA)
store.commitSelection(providerId, "act", modelASelection)
store.commitSelection(providerId, "act", { providerId, modelId: "model-b" })
store.commitSelection(providerId, "act", { providerId, modelId: "model-a" })
expectResolvedSelection(store.readSelection(providerId, "act"), modelASelection, modelInfoA)
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["model-a"]).toMatchObject({
name: "Model A",
maxTokens: 8_192,
contextWindow: 128_000,
})
})
it("deletes a model entry when an explicit replacement override set is empty", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["tools", "streaming"],
temperature: 0.2,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toMatchObject({
apiFormat: "openai-responses",
capabilities: ["tools", "streaming"],
temperature: 0.2,
})
store.commitSelection(providerId, "act", { providerId, modelId: "custom-model", overrides: {} })
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
})
it("replaces an existing model override set instead of merging stale fields", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { apiFormat: ApiFormat.OPENAI_RESPONSES, inputPrice: 1, temperature: 0.2 },
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { temperature: 0.4 },
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({ temperature: 0.4 })
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ temperature: 0.4 })
})
it.each([
[ApiFormat.OPENAI_CHAT, "default"],
[ApiFormat.R1_CHAT, "r1"],
[ApiFormat.OPENAI_RESPONSES, "openai-responses"],
] as const)("round-trips supported apiFormat %s through models.json", async (apiFormat, storedApiFormat) => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { apiFormat },
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
apiFormat: storedApiFormat,
})
expect(store.readSelection(providerId, "act")?.overrides).toEqual({ apiFormat })
})
it("normalizes invalid override values before storage and resolved legacy state", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
name: "Custom model",
maxTokens: -1,
contextWindow: Number.POSITIVE_INFINITY,
maxInputTokens: 0,
capabilities: ["tools", "tools", "vision", "unknown"],
supportsVision: false,
supportsReasoning: false,
inputPrice: Number.NaN,
outputPrice: 2,
cacheReadsPrice: 0,
cacheWritesPrice: -1,
temperature: -1,
apiFormat: 999 as ApiFormat,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toEqual({
name: "Custom model",
capabilities: ["tools"],
supportsVision: false,
supportsReasoning: false,
outputPrice: 2,
cacheReadsPrice: 0,
})
const selection = store.readSelection(providerId, "act")
expect(selection?.overrides).toEqual({
name: "Custom model",
capabilities: ["tools"],
supportsVision: false,
supportsReasoning: false,
outputPrice: 2,
cacheReadsPrice: 0,
})
expect(selection?.modelInfo.maxTokens).toBeUndefined()
expect(selection?.modelInfo.temperature).toBe(0)
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("maxTokens")
expect(mocks.getApiConfiguration().actModeOpenAiModelInfo).not.toHaveProperty("temperature", -1)
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(1)
})
it("deletes a stored entry when normalization removes every replacement field", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: { temperature: 0.2 },
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
maxTokens: -1,
contextWindow: 0,
capabilities: ["vision", "unknown"],
inputPrice: Number.NaN,
temperature: -1,
apiFormat: 999 as ApiFormat,
},
})
expect(mocks.getModelsFile().providers["openai-compatible"]?.models?.["custom-model"]).toBeUndefined()
expect(store.readSelection(providerId, "act")?.overrides).toBeUndefined()
expect(syncStoredProviderRegistration).toHaveBeenCalledTimes(2)
})
it("normalizes invalid values already present in models.json on read", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": { provider: "openai-compatible", model: "custom-model" },
})
mocks.setModelsFile({
version: 1,
providers: {
"openai-compatible": {
models: {
"custom-model": {
maxTokens: -1,
contextWindow: 64_000,
inputPrice: -2,
temperature: -1,
capabilities: ["tools"],
},
},
},
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = store.readSelection(providerId, "act")
expect(selection?.overrides).toEqual({ contextWindow: 64_000, capabilities: ["tools"] })
expect(selection?.modelInfo.maxTokens).toBeUndefined()
expect(selection?.modelInfo.temperature).toBe(0)
expect(syncStoredProviderRegistration).not.toHaveBeenCalled()
})
it("lets explicit capability booleans win and applies the R1 alias deterministically", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["images", "prompt-cache", "reasoning"],
supportsVision: false,
supportsReasoning: false,
isR1FormatRequired: false,
},
})
let selection = store.readSelection(providerId, "act")
expect(selection?.modelInfo).toMatchObject({
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: false,
apiFormat: ApiFormat.OPENAI_RESPONSES,
})
store.commitSelection(providerId, "act", {
providerId,
modelId: "custom-model",
overrides: {
apiFormat: ApiFormat.OPENAI_RESPONSES,
capabilities: ["prompt-cache"],
supportsVision: true,
isR1FormatRequired: true,
},
})
selection = store.readSelection(providerId, "act")
expect(selection?.modelInfo).toMatchObject({
supportsImages: true,
supportsPromptCache: true,
apiFormat: ApiFormat.R1_CHAT,
})
})
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
@@ -259,14 +743,14 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
const planSelection = selectionFromModelInfo(providerId, "plan-openai-model", modelInfoA)
const actSelection = selectionFromModelInfo(providerId, "act-openai-model", modelInfoB)
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "plan-openai-model",
planModeOpenAiModelInfo: modelInfoA,
@@ -286,12 +770,12 @@ describe("createProviderConfigStore", () => {
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "shared-openai-model", modelInfoA)
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "plan")).toEqual(selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expectResolvedSelection(store.readSelection(providerId, "plan"), selection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), selection, modelInfoA)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "shared-openai-model",
planModeOpenAiModelInfo: modelInfoA,
@@ -320,14 +804,22 @@ describe("createProviderConfigStore", () => {
expect(mocks.getApiConfiguration().zaiApiKey).toBe("shared-zai-key")
})
it("returns undefined from readSelection when modelId or modelInfo is missing", async () => {
it("resolves a bare state modelId with fallback metadata and ignores a bare modelInfo", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
// The mode-specific model id alone identifies the selection; commits
// whose resolution was pure fallback intentionally leave the state
// modelInfo snapshot unset.
mocks.setApiConfiguration({ actModeOpenRouterModelId: "anthropic/claude-sonnet-4" })
expect(store.readSelection(providerId, "act")).toBeUndefined()
expect(store.readSelection(providerId, "act")).toMatchObject({
providerId,
modelId: "anthropic/claude-sonnet-4",
modelInfoSource: "fallback",
})
// A modelInfo snapshot without a model id is not a selection.
mocks.setApiConfiguration({ actModeOpenRouterModelInfo: modelInfoA })
expect(store.readSelection(providerId, "act")).toBeUndefined()
})
@@ -337,14 +829,14 @@ describe("createProviderConfigStore", () => {
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const planSelection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "provider/model-b", modelInfo: modelInfoB }
const planSelection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
const actSelection = selectionFromModelInfo(providerId, "provider/model-b", modelInfoB)
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expectResolvedSelection(store.readSelection(providerId, "plan"), planSelection, modelInfoA)
expectResolvedSelection(store.readSelection(providerId, "act"), actSelection, modelInfoB)
expect(mocks.getSavedProviderSettings("openrouter")).toMatchObject({
provider: "openrouter",
model: "provider/model-b",
@@ -361,7 +853,7 @@ describe("createProviderConfigStore", () => {
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -381,7 +873,7 @@ describe("createProviderConfigStore", () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("claude-code")
const selection = { providerId, modelId: "haiku", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "haiku", modelInfoA)
store.commitSelection(providerId, "act", selection)
@@ -423,7 +915,7 @@ describe("createProviderConfigStore", () => {
const store = createProviderConfigStore()
const providerId = parseProviderId("openrouter")
const events: ProviderConfigChange[] = []
const selection = { providerId, modelId: "provider/model-a", modelInfo: modelInfoA }
const selection = selectionFromModelInfo(providerId, "provider/model-a", modelInfoA)
store.subscribe((event) => events.push(event))
store.write(providerId, { apiKey: "openrouter-key" })
@@ -431,7 +923,12 @@ describe("createProviderConfigStore", () => {
expect(events.map((event) => event.kind)).toEqual(["fields", "selection"])
expect(events[0]).toMatchObject({ kind: "fields", providerId })
expect(events[1]).toEqual({ kind: "selection", providerId, mode: "act", selection })
expect(events[1]).toEqual({
kind: "selection",
providerId,
mode: "act",
selection: store.readSelection(providerId, "act"),
})
})
it("dispose unregisters listeners", async () => {
@@ -446,4 +943,50 @@ describe("createProviderConfigStore", () => {
expect(listener).not.toHaveBeenCalled()
})
// Contract test against the REAL SDK schemas (imported by relative path,
// bypassing the @cline/core mock above): the store's converters must pass
// every SDK capability through, and a fully-populated stored entry must
// parse under the schema `writeModelsFileSync` enforces in production.
it("round-trips every SDK model capability and a full override set under the real stored-entry schema", async () => {
const { ModelCapabilitySchema } = await import("@cline/shared")
// vi.importActual bypasses the @cline/core mock above and resolves via
// the vitest alias to the stub, which re-exports the real schema.
const { StoredModelEntrySchema } = (await vi.importActual("@cline/core")) as {
StoredModelEntrySchema: { parse(input: unknown): unknown }
}
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.commitSelection(providerId, "act", {
providerId,
modelId: "contract-model",
overrides: {
name: "Contract Model",
maxTokens: 1024,
contextWindow: 200_000,
maxInputTokens: 100_000,
capabilities: [...ModelCapabilitySchema.options],
supportsVision: true,
supportsAttachments: true,
supportsReasoning: true,
inputPrice: 0.5,
outputPrice: 1.5,
cacheReadsPrice: 0.1,
cacheWritesPrice: 0.2,
temperature: 0.7,
apiFormat: ApiFormat.OPENAI_RESPONSES,
isR1FormatRequired: true,
},
})
const entry = mocks.getModelsFile().providers["openai-compatible"]?.models?.["contract-model"]
expect(entry).toBeDefined()
// No SDK capability may be silently stripped by the store's converter.
expect([...(entry?.capabilities as string[])].sort()).toEqual([...ModelCapabilitySchema.options].sort())
// The entry written by the extension must satisfy the real schema that
// the SDK's writeModelsFileSync enforces.
expect(() => StoredModelEntrySchema.parse(entry)).not.toThrow()
})
})
+431 -22
View File
@@ -1,5 +1,15 @@
import {
readModelsFileSync,
resolveModelsRegistryPath,
type StoredModelEntry,
syncStoredProviderRegistration,
writeModelsFileSync,
} from "@cline/core"
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { ModelCapabilitySchema } from "@cline/shared"
import { type ApiConfiguration, type ApiProvider, type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api"
import { ApiFormat } from "@shared/proto/cline/models"
import { Logger } from "@shared/services/Logger"
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { isSecretKey, isSettingsKey, type SecretKey, type SettingsKey } from "@shared/storage/state-keys"
import { StateManager } from "@/core/storage/StateManager"
@@ -9,14 +19,17 @@ import type {
EffectiveProviderConfig,
Mode,
ModelSelection,
ModelSelectionOverrides,
ProviderConfigChange,
ProviderConfigChangeListener,
ProviderConfigPatch,
ProviderConfigStore,
ProviderId,
ResolvedModelSelection,
} from "./contracts"
import { buildEffectiveProviderConfig } from "./effective-config"
import { applyHostModelInfoOverrides } from "./host-overrides"
import { fromSdkApiFormat, nonNegativeFiniteNumber, positiveFiniteNumber, toSdkApiFormat } from "./model-values"
import { toSdkProviderId } from "./sdk-provider-id"
import { adaptSdkModelInfo } from "./shape-adapter"
@@ -113,7 +126,7 @@ const modelInfoKeysByProvider: Partial<Record<string, ModelInfoKeys>> = {
// provider+mode so that switching between providers that share the same
// `*ModeApiModelId` key does not combine one provider's model id with
// another provider's model info.
const selectionMemory = new Map<string, ModelSelection>()
const selectionMemory = new Map<string, ResolvedModelSelection>()
function providerKey(providerId: ProviderId): string {
return providerId.toString()
@@ -168,11 +181,210 @@ function readProviderSettingsModelId(providerId: ProviderId): string | undefined
return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined
}
function fallbackModelInfo(modelId: string): ModelInfo {
return { ...openAiModelInfoSafeDefaults, name: modelId }
function sanitizeResolvedModelInfo(modelInfo: ModelInfo): ModelInfo {
const next = { ...modelInfo }
if (positiveFiniteNumber(next.maxTokens) === undefined) delete next.maxTokens
if (nonNegativeFiniteNumber(next.temperature) === undefined) delete next.temperature
return next
}
function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
function fallbackModelInfo(modelId: string): ModelInfo {
return sanitizeResolvedModelInfo({ ...openAiModelInfoSafeDefaults, name: modelId })
}
function toStoredCapabilities(capabilities: readonly string[] | undefined): StoredModelEntry["capabilities"] | undefined {
if (!capabilities) {
return undefined
}
// Validate against the SDK schema rather than a hardcoded list so new
// capabilities added to ModelCapabilitySchema are never silently stripped.
const next = new Set<NonNullable<StoredModelEntry["capabilities"]>[number]>()
for (const capability of capabilities) {
const parsed = ModelCapabilitySchema.safeParse(capability)
if (parsed.success) {
next.add(parsed.data)
}
}
return next.size > 0 ? [...next] : undefined
}
function toStoredApiFormat(apiFormat: ModelInfo["apiFormat"]): StoredModelEntry["apiFormat"] | undefined {
return toSdkApiFormat(apiFormat)
}
function fromStoredApiFormat(apiFormat: StoredModelEntry["apiFormat"]): ModelInfo["apiFormat"] | undefined {
return fromSdkApiFormat(apiFormat)
}
function readModelsState() {
return readModelsFileSync(resolveModelsRegistryPath(getProviderSettingsManager()))
}
/**
* Normalizes user-authored model metadata at the host/storage boundary.
* Token limits must be positive, prices and temperatures non-negative, and
* unsupported capabilities/formats are omitted. UI sentinels never cross
* this boundary; an object with no meaningful fields becomes undefined.
*/
function normalizeModelSelectionOverrides(overrides: ModelSelectionOverrides | undefined): ModelSelectionOverrides | undefined {
if (!overrides) {
return undefined
}
const maxTokens = positiveFiniteNumber(overrides.maxTokens)
const contextWindow = positiveFiniteNumber(overrides.contextWindow)
const maxInputTokens = positiveFiniteNumber(overrides.maxInputTokens)
const capabilities = toStoredCapabilities(overrides.capabilities)
const inputPrice = nonNegativeFiniteNumber(overrides.inputPrice)
const outputPrice = nonNegativeFiniteNumber(overrides.outputPrice)
const cacheReadsPrice = nonNegativeFiniteNumber(overrides.cacheReadsPrice)
const cacheWritesPrice = nonNegativeFiniteNumber(overrides.cacheWritesPrice)
const temperature = nonNegativeFiniteNumber(overrides.temperature)
const apiFormat = toStoredApiFormat(overrides.apiFormat) !== undefined ? overrides.apiFormat : undefined
const next: ModelSelectionOverrides = {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
...(capabilities !== undefined ? { capabilities } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(inputPrice !== undefined ? { inputPrice } : {}),
...(outputPrice !== undefined ? { outputPrice } : {}),
...(cacheReadsPrice !== undefined ? { cacheReadsPrice } : {}),
...(cacheWritesPrice !== undefined ? { cacheWritesPrice } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
return Object.keys(next).length > 0 ? next : undefined
}
function toStoredModelEntry(overrides: ModelSelectionOverrides): StoredModelEntry {
const capabilities = toStoredCapabilities(overrides.capabilities)
const apiFormat = toStoredApiFormat(overrides.apiFormat)
return {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
...(capabilities !== undefined ? { capabilities } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
}
function toSelectionOverrides(entry: StoredModelEntry | undefined): ModelSelectionOverrides | undefined {
if (!entry) {
return undefined
}
const apiFormat = fromStoredApiFormat(entry.apiFormat)
return normalizeModelSelectionOverrides({
...(entry.name !== undefined ? { name: entry.name } : {}),
...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}),
...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
...(entry.maxInputTokens !== undefined ? { maxInputTokens: entry.maxInputTokens } : {}),
...(entry.capabilities !== undefined ? { capabilities: [...entry.capabilities] } : {}),
...(entry.supportsVision !== undefined ? { supportsVision: entry.supportsVision } : {}),
...(entry.supportsAttachments !== undefined ? { supportsAttachments: entry.supportsAttachments } : {}),
...(entry.supportsReasoning !== undefined ? { supportsReasoning: entry.supportsReasoning } : {}),
...(entry.inputPrice !== undefined ? { inputPrice: entry.inputPrice } : {}),
...(entry.outputPrice !== undefined ? { outputPrice: entry.outputPrice } : {}),
...(entry.cacheReadsPrice !== undefined ? { cacheReadsPrice: entry.cacheReadsPrice } : {}),
...(entry.cacheWritesPrice !== undefined ? { cacheWritesPrice: entry.cacheWritesPrice } : {}),
...(entry.temperature !== undefined ? { temperature: entry.temperature } : {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(entry.isR1FormatRequired !== undefined ? { isR1FormatRequired: entry.isR1FormatRequired } : {}),
})
}
function readStoredModelEntry(providerId: ProviderId, modelId: string): { exists: boolean; entry: StoredModelEntry | undefined } {
const models = readModelsState().providers[providerSettingsProviderId(providerId)]?.models
return {
exists: models ? Object.hasOwn(models, modelId) : false,
entry: models?.[modelId],
}
}
function readModelOverrides(providerId: ProviderId, modelId: string): ModelSelectionOverrides | undefined {
return toSelectionOverrides(readStoredModelEntry(providerId, modelId).entry)
}
function writeModelOverrides(providerId: ProviderId, modelId: string, overrides: ModelSelectionOverrides | undefined): void {
const modelsPath = resolveModelsRegistryPath(getProviderSettingsManager())
const state = readModelsFileSync(modelsPath)
const provider = providerSettingsProviderId(providerId)
const providerEntry = state.providers[provider] ?? {}
const nextModels = { ...(providerEntry.models ?? {}) }
const normalizedOverrides = normalizeModelSelectionOverrides(overrides)
const storedEntry = normalizedOverrides ? toStoredModelEntry(normalizedOverrides) : undefined
if (storedEntry && Object.keys(storedEntry).length > 0) {
nextModels[modelId] = storedEntry
} else {
delete nextModels[modelId]
}
const nextProviderEntry = {
...providerEntry,
models: nextModels,
}
writeModelsFileSync(modelsPath, {
...state,
providers: {
...state.providers,
[provider]: nextProviderEntry,
},
})
// ensureCustomProvidersLoadedSync is load-once per path and would no-op
// here; sync the live registry explicitly so this write is visible to new
// sessions without a restart.
syncStoredProviderRegistration(provider, state.providers[provider], nextProviderEntry)
}
function applyModelOverrides(modelInfo: ModelInfo, overrides: ModelSelectionOverrides | undefined): ModelInfo {
if (!overrides) {
return modelInfo
}
const next: ModelInfo = { ...modelInfo }
if (overrides.name !== undefined) next.name = overrides.name
if (overrides.maxTokens !== undefined) next.maxTokens = overrides.maxTokens
if (overrides.contextWindow !== undefined) next.contextWindow = overrides.contextWindow
if (overrides.maxInputTokens !== undefined)
(next as ModelInfo & { maxInputTokens?: number }).maxInputTokens = overrides.maxInputTokens
if (overrides.inputPrice !== undefined) next.inputPrice = overrides.inputPrice
if (overrides.outputPrice !== undefined) next.outputPrice = overrides.outputPrice
if (overrides.cacheReadsPrice !== undefined) next.cacheReadsPrice = overrides.cacheReadsPrice
if (overrides.cacheWritesPrice !== undefined) next.cacheWritesPrice = overrides.cacheWritesPrice
if (overrides.temperature !== undefined) next.temperature = overrides.temperature
if (overrides.apiFormat !== undefined) next.apiFormat = overrides.apiFormat
// Capability arrays are additive fallback flags: they can only enable
// capabilities the base metadata lacks, never disable base capabilities
// (an array authored for one purpose, e.g. prompt-cache, must not strip
// unrelated base flags like vision). Explicit booleans win when both
// representations are present.
if (overrides.capabilities !== undefined) {
if (overrides.capabilities.includes("images")) next.supportsImages = true
if (overrides.capabilities.includes("prompt-cache")) next.supportsPromptCache = true
if (overrides.capabilities.includes("reasoning")) next.supportsReasoning = true
}
if (overrides.supportsVision !== undefined) next.supportsImages = overrides.supportsVision
if (overrides.supportsReasoning !== undefined) next.supportsReasoning = overrides.supportsReasoning
// apiFormat is canonical. The legacy R1 flag remains a compatibility alias
// that forces R1 only when explicitly true.
if (overrides.isR1FormatRequired) next.apiFormat = ApiFormat.R1_CHAT
return next
}
function readBaseModelInfoForProvider(providerId: ProviderId, modelId: string): ModelInfo | undefined {
const sdkProviderId = toSdkProviderId(providerId)
const generatedModelInfo = getGeneratedModelsForProvider(sdkProviderId)[modelId]
if (isModelInfo(generatedModelInfo)) {
@@ -201,17 +413,36 @@ function readKnownModelInfoForProvider(providerId: ProviderId, modelId: string):
return undefined
}
function readSelectionFromProviderSettings(providerId: ProviderId): ModelSelection | undefined {
function resolveSelection(selection: ModelSelection, stateModelInfoHint?: ModelInfo): ResolvedModelSelection {
const overrides = normalizeModelSelectionOverrides(
selection.overrides ?? readModelOverrides(selection.providerId, selection.modelId),
)
// Base resolution order: SDK catalog, then the picker's persisted state
// snapshot (the only accurate data for dynamic-list models the static
// catalog does not know), then provider-safe fallback defaults.
const catalogModelInfo = readBaseModelInfoForProvider(selection.providerId, selection.modelId)
const baseModelInfo = catalogModelInfo ?? stateModelInfoHint ?? fallbackModelInfo(selection.modelId)
const modelInfoSource = catalogModelInfo ? "catalog" : stateModelInfoHint ? "state" : "fallback"
return {
...selection,
overrides,
modelInfoSource,
baseModelInfo,
modelInfo: sanitizeResolvedModelInfo(applyModelOverrides(baseModelInfo, overrides)),
}
}
export function resolveRuntimeModelSelection(providerId: ProviderId, modelId: string): ResolvedModelSelection {
return resolveSelection({ providerId, modelId })
}
function readSelectionFromProviderSettings(providerId: ProviderId): ResolvedModelSelection | undefined {
const modelId = readProviderSettingsModelId(providerId)
if (!modelId) {
return undefined
}
return {
providerId,
modelId,
modelInfo: readKnownModelInfoForProvider(providerId, modelId) ?? fallbackModelInfo(modelId),
}
return resolveSelection({ providerId, modelId })
}
function writeStateKey(key: SecretKey | SettingsKey, value: unknown): void {
@@ -281,6 +512,17 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
writeStateKey("clineApiKey", patch.auth?.accessToken)
writeStateKey("clineAccountId", patch.auth?.accountId)
}
// Mirror the Ollama context window to the legacy state key so older
// readers (proto ApiConfiguration, webview display fallback) stay in sync
// with providers.json.
if (provider === "ollama" && "contextWindow" in patch) {
const contextWindow = patch.contextWindow
writeStateKey(
"ollamaApiOptionsCtxNum",
typeof contextWindow === "number" && contextWindow > 0 ? String(contextWindow) : undefined,
)
}
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
@@ -330,6 +572,15 @@ function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConf
}
}
if ("contextWindow" in patch) {
const contextWindow = patch.contextWindow
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
next.contextWindow = Math.floor(contextWindow)
} else {
delete next.contextWindow
}
}
if ("aws" in patch) {
const awsPatch = patch.aws
if (awsPatch === null || awsPatch === undefined) {
@@ -389,13 +640,27 @@ function syncedModes(mode: Mode): Mode[] {
return StateManager.get().getGlobalSettingsKey("planActSeparateModelsSetting") ? [mode] : ["plan", "act"]
}
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: ResolvedModelSelection): void {
const updates: Partial<Record<SettingsKey, unknown>> = {}
for (const targetMode of syncedModes(mode)) {
updates[getModelIdKey(providerId, targetMode)] = selection.modelId
const modelInfoKey = getModelInfoKey(providerId, targetMode)
if (modelInfoKey) {
updates[modelInfoKey] = selection.modelInfo
// For hint-eligible providers the snapshot must stay genuine base
// metadata: never persist fabricated fallback data (later reads
// would treat it as authoritative "state" data and shadow live
// catalog lookups), and persist the pre-override base rather than
// the resolved value (a deleted override must not be resurrected
// from a snapshot it was baked into). openai-compatible keeps the
// legacy resolved write — its snapshot is never used as a
// resolution base, and old extension versions still read it after
// a rollback.
if (usesStateModelInfoHint(providerId)) {
updates[modelInfoKey] =
selection.modelInfoSource === "fallback" ? undefined : (selection.baseModelInfo ?? selection.modelInfo)
} else {
updates[modelInfoKey] = selection.modelInfo
}
}
selectionMemory.set(memoryKey(providerId, targetMode), { ...selection, providerId })
}
@@ -404,28 +669,164 @@ function writeSelectionToState(providerId: ProviderId, mode: Mode, selection: Mo
function writeSelectionToProviderSettings(providerId: ProviderId, selection: ModelSelection): void {
const next: ProviderSettingsRecord = { ...getProviderSettings(providerId), model: selection.modelId }
// Prune model metadata that earlier builds may have written to providers.json.
delete next.contextWindow
// Prune model metadata that earlier builds may have written to
// providers.json — except for Ollama, whose contextWindow is a real
// user setting (maps to num_ctx) written by the settings UI.
if (providerKey(providerId) !== "ollama") {
delete next.contextWindow
}
delete next.maxTokens
saveProviderSettings(providerId, next)
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
type LegacyModelInfo = ModelInfo & { maxInputTokens?: number; isR1FormatRequired?: boolean }
type MutableModelSelectionOverrides = { -readonly [Key in keyof ModelSelectionOverrides]: ModelSelectionOverrides[Key] }
function legacyModelInfoToOverrides(modelInfo: LegacyModelInfo, fallback: ModelInfo): ModelSelectionOverrides | undefined {
const fallbackInfo = fallback as LegacyModelInfo
const overrides: MutableModelSelectionOverrides = {}
if (modelInfo.name !== undefined && modelInfo.name !== fallback.name) overrides.name = modelInfo.name
if (modelInfo.maxTokens !== undefined && modelInfo.maxTokens !== fallback.maxTokens) overrides.maxTokens = modelInfo.maxTokens
if (modelInfo.contextWindow !== undefined && modelInfo.contextWindow !== fallback.contextWindow)
overrides.contextWindow = modelInfo.contextWindow
if (modelInfo.maxInputTokens !== undefined && modelInfo.maxInputTokens !== fallbackInfo.maxInputTokens)
overrides.maxInputTokens = modelInfo.maxInputTokens
const supportsVision = modelInfo.supportsImages ?? fallback.supportsImages
if (Boolean(supportsVision) !== Boolean(fallback.supportsImages)) overrides.supportsVision = Boolean(supportsVision)
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
const capabilities: string[] = []
if (supportsVision) capabilities.push("images")
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
overrides.capabilities = capabilities
}
if (modelInfo.inputPrice !== undefined && modelInfo.inputPrice !== fallback.inputPrice)
overrides.inputPrice = modelInfo.inputPrice
if (modelInfo.outputPrice !== undefined && modelInfo.outputPrice !== fallback.outputPrice)
overrides.outputPrice = modelInfo.outputPrice
if (modelInfo.cacheReadsPrice !== undefined && modelInfo.cacheReadsPrice !== fallback.cacheReadsPrice)
overrides.cacheReadsPrice = modelInfo.cacheReadsPrice
if (modelInfo.cacheWritesPrice !== undefined && modelInfo.cacheWritesPrice !== fallback.cacheWritesPrice)
overrides.cacheWritesPrice = modelInfo.cacheWritesPrice
if (modelInfo.temperature !== undefined && modelInfo.temperature !== fallback.temperature)
overrides.temperature = modelInfo.temperature
if (modelInfo.apiFormat !== undefined && modelInfo.apiFormat !== fallback.apiFormat) overrides.apiFormat = modelInfo.apiFormat
if (modelInfo.isR1FormatRequired === true && fallbackInfo.isR1FormatRequired !== true) overrides.isR1FormatRequired = true
return normalizeModelSelectionOverrides(overrides)
}
// Providers/models whose legacy-state migration has already been attempted in
// this process. The migration runs from the read path, so it must be cheap on
// repeat reads and must never run more than once per selection — including
// when the legacy snapshot diffs to an empty override set and nothing is
// written.
const attemptedLegacyMigrations = new Set<string>()
function migrateLegacyModelOverridesIfNeeded(providerId: ProviderId, modelId: string, modelInfo: ModelInfo): void {
if (providerSettingsProviderId(providerId) !== "openai-compatible") {
return
}
const migrationKey = `${providerId}:${modelId}`
if (attemptedLegacyMigrations.has(migrationKey)) {
return
}
attemptedLegacyMigrations.add(migrationKey)
if (readStoredModelEntry(providerId, modelId).exists) {
return
}
if (readBaseModelInfoForProvider(providerId, modelId) !== undefined) {
return
}
const overrides = legacyModelInfoToOverrides(modelInfo as LegacyModelInfo, fallbackModelInfo(modelId))
if (overrides) {
try {
writeModelOverrides(providerId, modelId, overrides)
} catch (error) {
// The migration is best-effort and runs inside read paths; a
// failed write (read-only fs, disk full) must not fail read RPCs.
Logger.warn(
`[ModelCatalog] Failed to migrate legacy overrides for provider=${providerId} model=${modelId}: ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
}
/**
* The picker writes the live model metadata to the mode-specific
* `*ModeModelInfo` state key before committing. When the state still refers to
* the model being resolved, that snapshot is the best available base for
* dynamic-list models the static catalog does not know.
*
* openai-compatible is excluded: its legacy state snapshot is user-authored
* metadata that {@link migrateLegacyModelOverridesIfNeeded} converts into
* models.json overrides, which are the source of truth there. Feeding the
* snapshot back as a base would resurrect overrides the user deleted.
*/
function usesStateModelInfoHint(providerId: ProviderId): boolean {
return providerSettingsProviderId(providerId) !== "openai-compatible"
}
/**
* Pickers write `{ ...openAiModelInfoSafeDefaults, name: modelId }` to the
* state key when the user selects an id the live model list does not (yet)
* contain. Such a snapshot carries no real information and must not be
* treated as authoritative "state" metadata.
*/
function isSafeDefaultsSnapshot(modelInfo: ModelInfo, modelId: string): boolean {
const fabricated: Record<string, unknown> = { ...openAiModelInfoSafeDefaults, name: modelId }
const snapshot = modelInfo as unknown as Record<string, unknown>
for (const key of new Set([...Object.keys(fabricated), ...Object.keys(snapshot)])) {
if (fabricated[key] !== snapshot[key]) {
return false
}
}
return true
}
function readStateModelInfoHint(providerId: ProviderId, mode: Mode, modelId: string): ModelInfo | undefined {
if (!usesStateModelInfoHint(providerId)) {
return undefined
}
const modelInfoKey = getModelInfoKey(providerId, mode)
if (!modelInfoKey) {
return undefined
}
const apiConfiguration = StateManager.get().getApiConfiguration()
const stateModelId = apiConfiguration[getModelIdKey(providerId, mode)]
const stateModelInfo = apiConfiguration[modelInfoKey]
return stateModelId === modelId && isModelInfo(stateModelInfo) && !isSafeDefaultsSnapshot(stateModelInfo, modelId)
? stateModelInfo
: undefined
}
function readSelectionFromState(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
const apiConfiguration = StateManager.get().getApiConfiguration()
const modelId = apiConfiguration[getModelIdKey(providerId, mode)]
const modelInfoKey = getModelInfoKey(providerId, mode)
const rememberedSelection = selectionMemory.get(memoryKey(providerId, mode))
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
if (modelInfoKey) {
const modelInfo = apiConfiguration[modelInfoKey]
if (typeof modelId !== "string" || modelId.length === 0 || !isModelInfo(modelInfo)) {
return providerSettingsSelection
if (typeof modelId !== "string" || modelId.length === 0) {
return readSelectionFromProviderSettings(providerId)
}
return { providerId, modelId, modelInfo }
// The mode-specific model id alone identifies the selection; the state
// modelInfo snapshot is optional input for legacy migration and, for
// dynamic-list providers, the base-metadata hint. Fallback-tier commits
// intentionally leave it unset.
if (isModelInfo(modelInfo)) {
migrateLegacyModelOverridesIfNeeded(providerId, modelId, modelInfo)
}
return resolveSelection({ providerId, modelId }, readStateModelInfoHint(providerId, mode, modelId))
}
const providerSettingsSelection = readSelectionFromProviderSettings(providerId)
const activeProvider = mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
const provider = providerForStorage(providerId)
if (activeProvider !== provider) {
@@ -464,7 +865,7 @@ export function createProviderConfigStore(): ProviderConfigStore {
return { ...buildEffectiveProviderConfig(providerId) }
},
readSelection(providerId: ProviderId, mode: Mode): ModelSelection | undefined {
readSelection(providerId: ProviderId, mode: Mode): ResolvedModelSelection | undefined {
return readSelectionFromState(providerId, mode)
},
@@ -482,9 +883,17 @@ export function createProviderConfigStore(): ProviderConfigStore {
},
commitSelection(providerId: ProviderId, mode: Mode, selection: ModelSelection): void {
writeSelectionToState(providerId, mode, selection)
writeSelectionToProviderSettings(providerId, selection)
emit({ kind: "selection", providerId, mode, selection })
if (selection.overrides !== undefined) {
writeModelOverrides(providerId, selection.modelId, selection.overrides)
}
// Read the picker-written state snapshot before writeSelectionToState
// replaces it, so dynamic-list models keep their live metadata instead
// of being re-resolved to fallback defaults.
const stateModelInfoHint = readStateModelInfoHint(providerId, mode, selection.modelId)
const resolvedSelection = resolveSelection({ providerId, modelId: selection.modelId }, stateModelInfoHint)
writeSelectionToState(providerId, mode, resolvedSelection)
emit({ kind: "selection", providerId, mode, selection: resolvedSelection })
},
}
}
@@ -0,0 +1,36 @@
import { readModelsFileSync, writeModelsFileSync } from "@cline/core"
import { describe, expect, it } from "vitest"
type StoredModelsFile = ReturnType<typeof readModelsFileSync>
const firstPath = "/tmp/first-models.json"
const secondPath = "/tmp/second-models.json"
const storedModelsFile = (): StoredModelsFile => ({
version: 1,
providers: {
"openai-compatible": {
models: {
custom: { name: "Custom", capabilities: ["tools"] },
},
},
},
})
describe("cline core model-file test stub", () => {
it("isolates paths and returns defensive copies", () => {
const input = storedModelsFile()
writeModelsFileSync(firstPath, input)
input.providers["openai-compatible"].models!.custom.name = "mutated input"
const firstRead = readModelsFileSync(firstPath)
firstRead.providers["openai-compatible"].models!.custom.name = "mutated read"
expect(readModelsFileSync(firstPath)).toEqual(storedModelsFile())
expect(readModelsFileSync(secondPath)).toEqual({ version: 1, providers: {} })
})
it("cannot observe model writes from the preceding test", () => {
expect(readModelsFileSync(firstPath)).toEqual({ version: 1, providers: {} })
})
})
@@ -454,13 +454,35 @@ describe("SdkDiffEditCoordinator", () => {
expect(callOrder).toEqual(["close", "apply"])
})
it("applies patches without preview sessions directly", async () => {
it("shows a brief preview around auto-approved patches", async () => {
await writeFile("patched.ts", "line one\nline two\n")
const patch = ["*** Begin Patch", "*** Update File: patched.ts", "@@", "-line one", "+line ONE", "*** End Patch"].join(
"\n",
)
const result = await coordinator.executeApplyPatchTool({ input: patch }, tempDir, makeContext("tc9"))
expect(result).toBe("fallback apply_patch result")
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
expect(previews).toHaveLength(1)
expect(previews[0].opened).toMatchObject({
absolutePath: path.join(tempDir, "patched.ts"),
leftContent: "line one\nline two\n",
rightContent: "line ONE\nline two\n",
})
expect(previews[0].closed).toBe(1)
})
it("applies auto-approved patches without a preview when background edit is enabled", async () => {
backgroundEdit = true
const result = await coordinator.executeApplyPatchTool(
{ input: "*** Begin Patch\n*** End Patch" },
tempDir,
makeContext("tc9"),
)
expect(result).toBe("fallback apply_patch result")
expect(fallbackApplyPatch).toHaveBeenCalledOnce()
expect(previews).toHaveLength(0)
})
})
@@ -122,12 +122,32 @@ export class SdkDiffEditCoordinator {
}
/**
* The `apply_patch` tool executor override: close the preview, then delegate the
* whole patch application to the SDK's default executor.
* The `apply_patch` tool executor override: manually-approved patches close their
* approval preview before applying; auto-approved patches show a brief preview
* around execution, matching the `editor` tool behavior.
*/
async executeApplyPatchTool(input: ApplyPatchInput, cwd: string, context: AgentToolContext): Promise<string> {
await this.discardPreview(context.toolCallId ?? "")
return this.fallbackApplyPatchExecutor(input, cwd, context)
const toolCallId = context.toolCallId ?? ""
const hadPreApprovalPreview = this.sessions.has(toolCallId)
try {
if (hadPreApprovalPreview) {
await this.discardPreview(toolCallId)
} else if (!this.options.isBackgroundEditEnabled()) {
try {
await this.openPatchPreview(toolCallId, input)
} catch (error) {
Logger.warn(`[SdkDiffEditCoordinator] Failed to show auto-approve patch preview: ${error}`)
}
}
const result = await this.fallbackApplyPatchExecutor(input, cwd, context)
if (!hadPreApprovalPreview && this.sessions.get(toolCallId)?.preview) {
await lingerDelay(this.autoApprovePreviewLingerMs, context.signal)
}
return result
} finally {
await this.discardPreview(toolCallId)
}
}
/** Closes one preview (reject / abort / edit applied). Never throws; unknown ids are a no-op. */
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest"
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
describe("SdkForegroundCommandCoordinator", () => {
it("reports isRunning while a handle is registered and notifies on changes", () => {
const onRunningChanged = vi.fn()
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
expect(coordinator.isRunning).toBe(false)
const unregister = coordinator.register({ detach: () => {} })
expect(coordinator.isRunning).toBe(true)
expect(onRunningChanged).toHaveBeenCalledWith(true)
unregister()
expect(coordinator.isRunning).toBe(false)
expect(onRunningChanged).toHaveBeenCalledWith(false)
})
it("only notifies on actual transitions, not per handle", () => {
const onRunningChanged = vi.fn()
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
const unregister1 = coordinator.register({ detach: () => {} })
const unregister2 = coordinator.register({ detach: () => {} })
expect(onRunningChanged).toHaveBeenCalledTimes(1)
unregister1()
expect(onRunningChanged).toHaveBeenCalledTimes(1)
unregister2()
expect(onRunningChanged).toHaveBeenCalledTimes(2)
})
it("unregister is idempotent", () => {
const onRunningChanged = vi.fn()
const coordinator = new SdkForegroundCommandCoordinator({ onRunningChanged })
const unregister = coordinator.register({ detach: () => {} })
unregister()
unregister()
expect(onRunningChanged).toHaveBeenCalledTimes(2)
})
it("proceedWhileRunning detaches every registered handle and reports the count", () => {
const coordinator = new SdkForegroundCommandCoordinator()
const detach1 = vi.fn()
const detach2 = vi.fn()
coordinator.register({ detach: detach1 })
coordinator.register({ detach: detach2 })
expect(coordinator.proceedWhileRunning()).toBe(2)
expect(detach1).toHaveBeenCalledTimes(1)
expect(detach2).toHaveBeenCalledTimes(1)
})
it("proceedWhileRunning is a no-op returning 0 when nothing is running", () => {
const coordinator = new SdkForegroundCommandCoordinator()
expect(coordinator.proceedWhileRunning()).toBe(0)
})
it("proceedWhileRunning survives a handle whose detach throws", () => {
const coordinator = new SdkForegroundCommandCoordinator()
const detach2 = vi.fn()
coordinator.register({
detach: () => {
throw new Error("boom")
},
})
coordinator.register({ detach: detach2 })
expect(coordinator.proceedWhileRunning()).toBe(2)
expect(detach2).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,80 @@
/**
* Tracks in-flight foreground (VS Code terminal) command executions so the
* "Proceed While Running" button can detach them: each pending tool call
* returns with its partial output while the command keeps running in the
* user's terminal, streaming further output to a log file.
*
* Owned by SdkController so it outlives session rebuilds (which recreate the
* tool set and its reused executor closure). Handles are registered per tool
* invocation never on the reused executor so parallel commands in one
* tool call each get their own handle and log file.
*/
import { Logger } from "@/shared/services/Logger"
export interface ForegroundCommandHandle {
/**
* Stop waiting for the command: flush the output captured so far to a
* log file, keep appending until the command completes, and resolve the
* pending tool execution with the partial output. Idempotent.
*/
detach(): void
}
export interface SdkForegroundCommandCoordinatorOptions {
/** Called whenever isRunning flips; used to push the flag to the webview. */
onRunningChanged?: (running: boolean) => void
}
export class SdkForegroundCommandCoordinator {
private readonly handles = new Set<ForegroundCommandHandle>()
constructor(private readonly options: SdkForegroundCommandCoordinatorOptions = {}) {}
/** Whether any foreground command is currently awaited by a tool call. */
get isRunning(): boolean {
return this.handles.size > 0
}
/**
* Track one in-flight foreground execution. Returns an unregister
* function the caller must invoke when the execution settles (completes,
* fails, aborts, or detaches) typically from a `finally` block.
*/
register(handle: ForegroundCommandHandle): () => void {
const wasRunning = this.isRunning
this.handles.add(handle)
this.notifyIfChanged(wasRunning)
return () => {
const wasRunningBefore = this.isRunning
if (this.handles.delete(handle)) {
this.notifyIfChanged(wasRunningBefore)
}
}
}
/**
* Detach every in-flight foreground command ("Proceed While Running").
* Each pending tool execution resolves with its partial output and log
* file path; the commands keep running in their terminals.
*
* @returns the number of commands detached (0 when none were running).
*/
proceedWhileRunning(): number {
const handles = [...this.handles]
for (const handle of handles) {
try {
handle.detach()
} catch (error) {
Logger.error("[ForegroundCommands] Failed to detach foreground command:", error)
}
}
return handles.length
}
private notifyIfChanged(wasRunning: boolean): void {
if (this.isRunning !== wasRunning) {
this.options.onRunningChanged?.(this.isRunning)
}
}
}
@@ -12,6 +12,7 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
import { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import type { ActiveSession } from "./cline-session-factory"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { buildToolPolicies } from "./sdk-tool-policies"
import type { SdkSessionHost } from "./session-host"
import { VscodeSessionHost } from "./vscode-session-host"
@@ -32,6 +33,8 @@ export interface SdkSessionLifecycleOptions {
onSessionEvent: (event: CoreSessionEvent) => void
/** Lazy factory for the VscodeTerminalManager (foreground terminal support). */
getTerminalManager?: () => VscodeTerminalManager
/** Registry of in-flight foreground executions for "Proceed While Running". */
foregroundCommands?: SdkForegroundCommandCoordinator
/** Returns the latest prepared remote-config integration, if remote config is active. */
getRemoteConfigIntegration?: () => PreparedRemoteConfigCoreIntegration | undefined
/** Shared SDK telemetry service owned by SdkController. */
@@ -322,6 +325,7 @@ export class SdkSessionLifecycle {
editorExecutor: this.options.editorExecutor,
applyPatchExecutor: this.options.applyPatchExecutor,
getTerminalManager: this.options.getTerminalManager,
foregroundCommands: this.options.foregroundCommands,
getRemoteConfigIntegration: this.options.getRemoteConfigIntegration,
telemetry: this.options.telemetry,
})
@@ -33,8 +33,11 @@ export class SdkTerminalExecutionModeCoordinator {
if (previous === next) {
return
}
this.requestRebuild(`Terminal execution mode changed: ${previous} -> ${next}`)
}
Logger.log(`[SdkController] Terminal execution mode changed: ${previous} -> ${next}`)
private requestRebuild(reason: string): void {
Logger.log(`[SdkController] ${reason}`)
const activeSession = this.options.sessions.getActiveSession()
if (!activeSession) {
@@ -1,9 +1,111 @@
import { CommandExitError } from "@cline/core"
import { EventEmitter } from "events"
import { describe, expect, it } from "vitest"
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
import * as fs from "fs"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as vscode from "vscode"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { executeForeground, formatCommandForTerminal } from "./vscode-run-commands-tool"
import type { TerminalCompletionDetails } from "@/integrations/terminal/types"
import { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import {
createVscodeRunCommandsTool,
executeForeground,
formatCommandForTerminal,
PROCEED_LOG_MAX_BYTES,
} from "./vscode-run-commands-tool"
const mocks = vi.hoisted(() => ({
existsSync: vi.fn<(path: fs.PathLike) => boolean>(),
getGlobalSettingsKey: vi.fn(() => "default"),
}))
vi.mock("fs", async (importOriginal) => ({
...(await importOriginal<typeof import("fs")>()),
existsSync: mocks.existsSync,
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({ getGlobalSettingsKey: mocks.getGlobalSettingsKey }),
},
}))
// The real telemetry proxy lazily initializes TelemetryService, which requires
// a HostProvider that unit tests don't set up.
vi.mock("@services/telemetry", () => ({
TerminalUserInterventionAction: { PROCESS_WHILE_RUNNING: "process_while_running" },
telemetryService: {
captureTerminalUserIntervention: () => {},
captureTerminalExecution: () => {},
},
}))
const originalPlatform = process.platform
const originalEnv = { ...process.env }
const originalGetConfiguration = vscode.workspace.getConfiguration
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
process.env = { ...originalEnv }
vscode.workspace.getConfiguration = originalGetConfiguration
mocks.existsSync.mockReset()
mocks.getGlobalSettingsKey.mockReset()
mocks.getGlobalSettingsKey.mockReturnValue("default")
})
describe("createVscodeRunCommandsTool", () => {
it("constructs a cmd tool from the stock array-valued Command Prompt profile", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.windir = "C:\\Windows"
mocks.existsSync.mockImplementation((candidate) => candidate === "C:\\Windows\\System32\\cmd.exe")
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => {
if (key === "defaultProfile.windows") {
return "Command Prompt"
}
if (key === "profiles.windows") {
return {
"Command Prompt": {
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
},
}
}
return undefined
},
}) as never
const tool = createVscodeRunCommandsTool({
cwd: "C:\\workspace",
getTerminalManager: () => {
throw new Error("Terminal manager should not be created during tool construction")
},
vscodeTerminalExecutionMode: "vscodeTerminal",
})
expect(tool.name).toBe("run_commands")
expect(tool.description).toContain("Commands run through cmd.exe")
})
it("re-derives the description from the current profile on each read", () => {
Object.defineProperty(process, "platform", { value: "win32" })
mocks.existsSync.mockReturnValue(true)
mocks.getGlobalSettingsKey.mockReturnValue("cmd")
const tool = createVscodeRunCommandsTool({
cwd: "C:\\workspace",
getTerminalManager: () => {
throw new Error("Terminal manager should not be created during tool construction")
},
vscodeTerminalExecutionMode: "vscodeTerminal",
})
expect(tool.description).toContain("Commands run through cmd.exe")
// A profile change takes effect at the next description read (the
// model-request boundary), without a session rebuild.
mocks.getGlobalSettingsKey.mockReturnValue("powershell-7")
expect(tool.description).toContain("Commands run through PowerShell")
})
})
/**
* Minimal fake of the process object returned by VscodeTerminalManager.runCommand():
@@ -12,11 +114,13 @@ import { executeForeground, formatCommandForTerminal } from "./vscode-run-comman
*/
function createFakeTerminalProcess(options: { lines?: string[]; completionDetails?: TerminalCompletionDetails } = {}) {
const emitter = new EventEmitter()
let resolvePromise!: () => void
// Emit on a macrotask (not a microtask) so executeForeground's
// `await terminalManager.getOrCreateTerminal(cwd)` and subsequent
// `process.on("line", ...)` registration are guaranteed to run first,
// matching the ordering a real terminal process provides.
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
setTimeout(() => {
for (const line of options.lines ?? []) {
emitter.emit("line", line)
@@ -31,6 +135,10 @@ function createFakeTerminalProcess(options: { lines?: string[]; completionDetail
catch: promise.catch.bind(promise),
finally: promise.finally.bind(promise),
getCompletionDetails: () => options.completionDetails ?? {},
detach: () => {
emitter.emit("continue")
resolvePromise()
},
})
return fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>
}
@@ -42,6 +150,50 @@ function createFakeTerminalManager(process: ReturnType<VscodeTerminalManager["ru
} as unknown as VscodeTerminalManager
}
/**
* A controllable fake terminal process for detach tests: the caller decides
* when lines are emitted and when the command completes. Mirrors the real
* VscodeTerminalProcess contract: detach() resolves the awaited promise while
* 'line'/'completed' events keep flowing.
*/
function createControllableTerminalProcess() {
const emitter = new EventEmitter()
let resolvePromise!: () => void
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
})
const fakeProcess = Object.assign(emitter, {
then: promise.then.bind(promise),
catch: promise.catch.bind(promise),
finally: promise.finally.bind(promise),
getCompletionDetails: () => ({}),
detach: () => {
emitter.emit("continue")
resolvePromise()
},
})
return {
process: fakeProcess as unknown as ReturnType<VscodeTerminalManager["runCommand"]>,
emitLine: (line: string) => emitter.emit("line", line),
complete: (details?: TerminalCompletionDetails) => {
emitter.emit("completed", details)
emitter.emit("continue")
resolvePromise()
},
}
}
/** Poll until the predicate holds, for asserting on async log-file writes. */
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("waitFor timed out")
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
describe("formatCommandForTerminal", () => {
it.each([
{
@@ -129,6 +281,19 @@ describe("executeForeground", () => {
expect(result).toBe("hello")
})
it("passes the caller's terminal profile through to getOrCreateTerminal", async () => {
const process = createFakeTerminalProcess({ lines: ["ok"] })
const getOrCreateTerminal = vi.fn(async () => ({ terminal: { show: () => {} } }) as never)
const terminalManager = {
getOrCreateTerminal,
runCommand: () => process,
} as unknown as VscodeTerminalManager
await executeForeground("echo ok", "/workspace", terminalManager, 1000, undefined, undefined, "wsl-bash")
expect(getOrCreateTerminal).toHaveBeenCalledWith("/workspace", "wsl-bash")
})
it("throws CommandExitError with the exit code on non-zero exit", async () => {
const terminalManager = createFakeTerminalManager(
createFakeTerminalProcess({ lines: ["boom"], completionDetails: { exitCode: 127 } }),
@@ -172,4 +337,207 @@ describe("executeForeground", () => {
expect((error as InstanceType<typeof CommandExitError>).output).toContain("Terminal closed")
}
})
it("unregisters its foreground handle when the command completes normally", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const terminalManager = createFakeTerminalManager(createFakeTerminalProcess({ lines: ["hello"] }))
const result = await executeForeground("echo hello", "/workspace", terminalManager, 1000, undefined, coordinator)
expect(result).toBe("hello")
expect(coordinator.isRunning).toBe(false)
})
})
describe("executeForeground — Proceed While Running", () => {
it("detach returns the partial output with the log file path, and later output lands in the log", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
emitLine("listening on :3000")
expect(coordinator.proceedWhileRunning()).toBe(1)
const result = await resultPromise
expect(result).toContain("still running")
expect(result).toContain("listening on :3000")
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
expect(logFilePath).toBeTruthy()
// The handle is unregistered once the tool call returns.
expect(coordinator.isRunning).toBe(false)
// Output emitted after detach is appended to the log file, and
// completion closes it out with a completion marker.
emitLine("compiled successfully")
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain("listening on :3000") // buffered lines flushed at detach
expect(log).toContain("compiled successfully") // streamed after detach
fs.rmSync(logFilePath!, { force: true })
})
it("detaches each parallel command into its own log file", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const first = createControllableTerminalProcess()
const second = createControllableTerminalProcess()
const firstPromise = executeForeground(
"first-cmd",
"/workspace",
createFakeTerminalManager(first.process),
100_000,
undefined,
coordinator,
)
const secondPromise = executeForeground(
"second-cmd",
"/workspace",
createFakeTerminalManager(second.process),
100_000,
undefined,
coordinator,
)
await waitFor(() => coordinator.isRunning)
first.emitLine("first output")
second.emitLine("second output")
expect(coordinator.proceedWhileRunning()).toBe(2)
const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise])
const firstLog = /redirected to this file[^:]*: (.+)$/m.exec(firstResult)?.[1]?.trim()
const secondLog = /redirected to this file[^:]*: (.+)$/m.exec(secondResult)?.[1]?.trim()
expect(firstLog).toBeTruthy()
expect(secondLog).toBeTruthy()
expect(firstLog).not.toBe(secondLog)
expect(coordinator.isRunning).toBe(false)
first.complete()
second.complete()
await waitFor(() => {
try {
return (
fs.readFileSync(firstLog!, "utf8").includes("[Command completed]") &&
fs.readFileSync(secondLog!, "utf8").includes("[Command completed]")
)
} catch {
return false
}
})
expect(fs.readFileSync(firstLog!, "utf8")).toContain("first output")
expect(fs.readFileSync(secondLog!, "utf8")).toContain("second output")
fs.rmSync(firstLog!, { force: true })
fs.rmSync(secondLog!, { force: true })
})
it("stops logging before a line that would exceed the size cap", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
expect(coordinator.proceedWhileRunning()).toBe(1)
const result = await resultPromise
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
expect(logFilePath).toBeTruthy()
// A single line larger than the whole cap must not be written at all —
// the cap is checked before writing, so one huge line (e.g. a dumped
// blob) cannot blow the log far past PROCEED_LOG_MAX_BYTES.
emitLine("small line before the blob")
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
emitLine("after the cap")
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain("small line before the blob")
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
expect(log).not.toContain("xxxx")
expect(log).not.toContain("after the cap")
expect(log.length).toBeLessThan(PROCEED_LOG_MAX_BYTES)
fs.rmSync(logFilePath!, { force: true })
})
it("applies the size cap to lines buffered before detach", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
emitLine("x".repeat(PROCEED_LOG_MAX_BYTES))
expect(coordinator.proceedWhileRunning()).toBe(1)
const result = await resultPromise
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
expect(logFilePath).toBeTruthy()
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain(`[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached`)
expect(log).not.toContain("xxxx")
expect(Buffer.byteLength(log)).toBeLessThanOrEqual(PROCEED_LOG_MAX_BYTES)
fs.rmSync(logFilePath!, { force: true })
})
it("freezes the partial output at detach while later output still reaches the log", async () => {
const coordinator = new SdkForegroundCommandCoordinator()
const { process, emitLine, complete } = createControllableTerminalProcess()
const terminalManager = createFakeTerminalManager(process)
const resultPromise = executeForeground("devserver", "/workspace", terminalManager, 100_000, undefined, coordinator)
await waitFor(() => coordinator.isRunning)
emitLine("before detach")
expect(coordinator.proceedWhileRunning()).toBe(1)
// Emitted after detach but before the tool call's result is built:
// must appear only in the log, never in the partial output.
emitLine("after detach")
const result = await resultPromise
expect(result).toContain("before detach")
expect(result).not.toContain("after detach")
const logFilePath = /redirected to this file[^:]*: (.+)$/m.exec(result)?.[1]?.trim()
complete({ exitCode: 0 })
await waitFor(() => {
try {
return fs.readFileSync(logFilePath!, "utf8").includes("[Command completed with exit code 0]")
} catch {
return false
}
})
const log = fs.readFileSync(logFilePath!, "utf8")
expect(log).toContain("before detach")
expect(log).toContain("after detach")
fs.rmSync(logFilePath!, { force: true })
})
})
+175 -18
View File
@@ -21,12 +21,16 @@ import {
truncateCommandOutput,
} from "@cline/core"
import type { AgentTool } from "@cline/shared"
import { telemetryService } from "@services/telemetry"
import { TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { ClineTempManager } from "@services/temp"
import * as fs from "fs"
import { StateManager } from "@/core/storage/StateManager"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { MAX_UNRETRIEVED_LINES } from "@/integrations/terminal/constants"
import type { ITerminalProcess } from "@/integrations/terminal/types"
import { Logger } from "@/shared/services/Logger"
import { getShellForProfile } from "@/utils/shell"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
// ---------------------------------------------------------------------------
// Types
@@ -38,6 +42,14 @@ type VscodeTerminalExecutionMode = "vscodeTerminal" | "backgroundExec"
/** Foreground VS Code terminals cannot be forcibly terminated; give long-running commands room to finish. */
export const VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS = 60 * 60 * 1000
/**
* Cap on the "Proceed While Running" log file. A detached devserver can log
* for days; once the cap is hit we stop appending and note the truncation.
* ClineTempManager's periodic cleanup (age + total-size caps) is the backstop
* for the files themselves.
*/
export const PROCEED_LOG_MAX_BYTES = 10 * 1024 * 1024
/** Options for creating the VSCode run_commands tool. */
export interface VscodeRunCommandsToolOptions {
/** Workspace root directory. */
@@ -48,6 +60,14 @@ export interface VscodeRunCommandsToolOptions {
bashTimeoutMs?: number
/** Terminal execution mode captured when this session's tool set is built. */
vscodeTerminalExecutionMode?: VscodeTerminalExecutionMode
/**
* Registry of in-flight foreground executions, owned by SdkController.
* When provided, each foreground command can be detached via the
* "Proceed While Running" button. Foreground-only: background (SDK
* child_process) executions cannot be detached their abort signal
* kills the process tree.
*/
foregroundCommands?: SdkForegroundCommandCoordinator
}
// ---------------------------------------------------------------------------
@@ -74,6 +94,69 @@ export function formatCommandForTerminal(command: ShellCommand): string {
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
}
/**
* Stream the rest of a detached command's output to a log file: write the
* lines buffered so far, then append each further 'line' event until
* 'completed'. The write volume is capped at PROCEED_LOG_MAX_BYTES; the
* stream is always closed by the 'completed' event, which the terminal
* process emits on every exit path (command end, Ctrl+C, terminal closed,
* markerless fallback).
*/
function beginLogCapture(process: ITerminalProcess, terminalCommand: string, existingLines: string[]): string {
const logFilePath = ClineTempManager.createTempFilePath("proceed-while-running")
const stream = fs.createWriteStream(logFilePath, { flags: "a" })
const sizeCapMessage = `[Log size cap of ${PROCEED_LOG_MAX_BYTES} bytes reached; further output is not logged.]`
stream.on("error", (error) => {
Logger.error(`[VscodeRunCommands] Failed writing proceed-while-running log ${logFilePath}:`, error)
})
let bytesWritten = 0
const tryWriteLine = (line: string): boolean => {
const chunk = `${line}\n`
const chunkBytes = Buffer.byteLength(chunk)
if (bytesWritten + chunkBytes > PROCEED_LOG_MAX_BYTES) {
return false
}
bytesWritten += chunkBytes
stream.write(chunk)
return true
}
let sizeCapReached = !tryWriteLine(`[Running command: ${terminalCommand}]`)
for (const line of existingLines) {
if (!tryWriteLine(line)) {
sizeCapReached = true
break
}
}
const onLine = (line: string): void => {
// Check the cap before writing: a single huge line (e.g. a dumped
// binary blob or minified bundle) must not blow past the cap.
if (!tryWriteLine(line)) {
tryWriteLine(sizeCapMessage)
process.removeListener("line", onLine)
}
}
if (sizeCapReached) {
tryWriteLine(sizeCapMessage)
} else {
process.on("line", onLine)
}
process.once("completed", (details) => {
process.removeListener("line", onLine)
const exitCode = details?.exitCode
tryWriteLine(
exitCode !== undefined && exitCode !== null
? `[Command completed with exit code ${exitCode}]`
: "[Command completed]",
)
stream.end()
})
return logFilePath
}
/** Exported for direct unit testing of the CommandExitError/terminalClosed mapping. */
export async function executeForeground(
command: ShellCommand,
@@ -81,9 +164,11 @@ export async function executeForeground(
terminalManager: VscodeTerminalManager,
maxOutputChars: number,
abortSignal?: AbortSignal,
foregroundCommands?: SdkForegroundCommandCoordinator,
terminalProfileId?: string,
): Promise<string> {
const terminalCommand = formatCommandForTerminal(command)
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd)
const terminalInfo = await terminalManager.getOrCreateTerminal(cwd, terminalProfileId)
terminalInfo.terminal.show()
const process = terminalManager.runCommand(terminalInfo, terminalCommand)
@@ -100,7 +185,7 @@ export async function executeForeground(
// truncateCommandOutput's own head/tail strategy below — since build/test
// failures usually appear at the end of output.
const maxBufferedLines = MAX_UNRETRIEVED_LINES
process.on("line", (line: string) => {
const bufferLine = (line: string): void => {
if (outputLines.length < maxBufferedLines) {
outputLines.push(line)
} else {
@@ -108,7 +193,8 @@ export async function executeForeground(
outputLines.push(line)
droppedLines++
}
})
}
process.on("line", bufferLine)
// Handle abort signal
if (abortSignal) {
@@ -121,8 +207,33 @@ export async function executeForeground(
process.once("continue", cleanupAbortListener)
}
// Wait for completion
await process
// "Proceed While Running": register a per-invocation handle so the user
// can detach this command. Detaching redirects the remaining output to a
// log file and resolves the awaited promise; the command keeps running in
// the user's terminal (and the terminal stays busy until it completes).
let detachedLogFilePath: string | undefined
const unregister = foregroundCommands?.register({
detach: () => {
if (detachedLogFilePath !== undefined) {
return
}
detachedLogFilePath = beginLogCapture(process, terminalCommand, outputLines)
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING, "vscode")
// detach() flushes any partial line (reaching both bufferLine and
// the log) before resolving the awaited promise. After that the
// partial output is final: stop buffering so the remaining
// (log-only) output doesn't mutate outputLines while it's read.
process.detach()
process.removeListener("line", bufferLine)
},
})
try {
// Wait for completion (or detach, which also resolves the promise)
await process
} finally {
unregister?.()
}
if (abortSignal?.aborted) {
throw new Error("Command execution aborted")
}
@@ -135,6 +246,14 @@ export async function executeForeground(
maxChars: maxOutputChars,
})
if (detachedLogFilePath !== undefined) {
return [
"The user chose to proceed while the command is still running in their terminal.",
`This is partial output; further output is being redirected to this file, which you can read to check progress: ${detachedLogFilePath}`,
output.length > 0 ? `Output so far:\n${output}` : "No output so far.",
].join("\n")
}
const completionDetails = process.getCompletionDetails?.()
// A terminal closed mid-command has no exit code and no reliable output —
@@ -171,26 +290,56 @@ export async function executeForeground(
// Tool factory
// ---------------------------------------------------------------------------
/**
* The shell selected by the user's terminal profile setting at one moment:
* the profile ID for foreground terminal creation and the shell executable
* it resolves to for background spawning and description building.
*/
interface ShellSnapshot {
profileId: string
shell: string
}
/** Resolves the shell the user's terminal profile setting selects right now. */
function takeShellSnapshot(): ShellSnapshot {
// The setting is typed string, but guard empty values the same way the
// settings handlers do (they skip persisting "" but older stores may hold one).
const profileId = StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") || "default"
return { profileId, shell: getShellForProfile(profileId) }
}
/**
* Creates the custom `run_commands` tool for the VSCode extension.
*
* This tool suppresses and replaces the SDK's built-in `run_commands` tool.
* The terminal execution mode is captured when the session's tool set is built.
* Switching modes rebuilds the active SDK session so the tool timeout and
* execution mode stay aligned.
* The terminal execution mode is captured when the session's tool set is
* built; switching modes rebuilds the active SDK session so the tool timeout
* and execution path follow it.
*
* The shell is snapshotted each time the runtime reads the tool description,
* which happens when a model request is built. Tool calls produced by that
* request execute with the same snapshot, so changing the terminal profile
* while the model is generating does not change the shell under commands the
* model has already planned: the new shell is named in the next request (the
* one carrying these tool results) and used by the commands it produces.
*/
export function createVscodeRunCommandsTool(options: VscodeRunCommandsToolOptions): AgentTool {
return createShellTool(createVscodeShellExecutor(options), {
const state = { snapshot: takeShellSnapshot() }
return createShellTool(createVscodeShellExecutor(options, state), {
cwd: options.cwd,
bashTimeoutMs: options.bashTimeoutMs,
shell: () => {
state.snapshot = takeShellSnapshot()
return state.snapshot.shell
},
})
}
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): ShellExecutor {
function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions, state: { snapshot: ShellSnapshot }): ShellExecutor {
const { cwd, getTerminalManager } = options
const executionMode = options.vscodeTerminalExecutionMode ?? "backgroundExec"
// Lazy-init background executor — recreated when the user's shell profile changes.
// Lazy-init background executor — recreated when the snapshotted shell changes.
let bgExecutor: ShellExecutor | undefined
let bgExecutorShell: string | undefined
@@ -200,12 +349,12 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
return async (command, commandCwd, context): Promise<string> => {
Logger.log(`[VscodeRunCommands] Executing command in ${executionMode} mode`)
if (executionMode === "backgroundExec") {
// Background path — use SDK's createShellExecutor
// Resolve shell from the user's terminal profile setting
const profileId = (StateManager.get().getGlobalSettingsKey("defaultTerminalProfile") as string) || "default"
const shell = getShellForProfile(profileId)
// Execute with the shell named in the model request that produced this
// tool call, not the setting's current value (see createVscodeRunCommandsTool).
const { profileId, shell } = state.snapshot
if (executionMode === "backgroundExec") {
// Background path — use SDK's createShellExecutor.
// Recreate the executor if the shell has changed
if (!bgExecutor || bgExecutorShell !== shell) {
bgExecutorShell = shell
@@ -240,6 +389,14 @@ function createVscodeShellExecutor(options: VscodeRunCommandsToolOptions): Shell
if (!terminalManager) {
terminalManager = getTerminalManager()
}
return await executeForeground(command, commandCwd || cwd, terminalManager, MAX_COMMAND_OUTPUT_CHARS, context.signal)
return await executeForeground(
command,
commandCwd || cwd,
terminalManager,
MAX_COMMAND_OUTPUT_CHARS,
context.signal,
options.foregroundCommands,
profileId,
)
}
}
@@ -3,6 +3,7 @@ import { type AgentTool, type AgentToolContext, createTool } from "@cline/shared
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import { createVscodeRunCommandsTool, VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS } from "./vscode-run-commands-tool"
interface McpToolDescriptor {
@@ -124,6 +125,8 @@ export interface VscodeExtraToolsOptions {
getTerminalManager?: () => VscodeTerminalManager
/** Current VS Code terminal execution mode, captured when the session tools are built. */
vscodeTerminalExecutionMode?: "vscodeTerminal" | "backgroundExec"
/** Registry of in-flight foreground executions for "Proceed While Running". */
foregroundCommands?: SdkForegroundCommandCoordinator
}
export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExtraToolsOptions): Promise<AgentTool[]> {
@@ -159,6 +162,7 @@ export async function createVscodeExtraTools(mcpHub: McpHub, options?: VscodeExt
getTerminalManager: options.getTerminalManager,
bashTimeoutMs: executionMode === "vscodeTerminal" ? VSCODE_FOREGROUND_RUN_COMMANDS_TIMEOUT_MS : undefined,
vscodeTerminalExecutionMode: executionMode,
foregroundCommands: options.foregroundCommands,
}),
)
Logger.log(
+5 -1
View File
@@ -36,9 +36,10 @@ import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTermin
import { getDistinctId } from "@/services/logging/distinctId"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
import type { SdkForegroundCommandCoordinator } from "./sdk-foreground-command-coordinator"
import type { SdkSessionHost } from "./session-host"
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
import { createVscodeExtraTools } from "./vscode-runtime-builder"
import { getEffectiveTerminalExecutionMode } from "./vscode-terminal-execution-mode"
export interface VscodeSessionHostOptions {
mcpHub: McpHub
@@ -75,6 +76,8 @@ export interface VscodeSessionHostOptions {
* with a custom tool that supports foreground/background terminal execution.
*/
getTerminalManager?: () => VscodeTerminalManager
/** Registry of in-flight foreground executions for "Proceed While Running". */
foregroundCommands?: SdkForegroundCommandCoordinator
}
export class VscodeSessionHost implements SdkSessionHost {
@@ -133,6 +136,7 @@ export class VscodeSessionHost implements SdkSessionHost {
cwd: inputWithRemoteConfig.config.cwd,
getTerminalManager: options.getTerminalManager,
vscodeTerminalExecutionMode: getEffectiveTerminalExecutionMode(requestedTerminalExecutionMode),
foregroundCommands: options.foregroundCommands,
})
return {
...inputWithRemoteConfig,
@@ -124,6 +124,7 @@ export class WebviewGrpcBridge {
stateManager,
mcpHub: undefined,
backgroundCommandRunning: false,
foregroundCommandRunning: false,
backgroundCommandTaskId: undefined,
})
await sendStateUpdate(state)
@@ -93,6 +93,11 @@ export interface ExtensionState {
vscodeTerminalExecutionMode: string
backgroundCommandRunning?: boolean
backgroundCommandTaskId?: string
/**
* True while a foreground (VS Code terminal) command is awaited by a
* run_commands tool call. Drives the "Proceed While Running" button.
*/
foregroundCommandRunning?: boolean
lastCompletedCommandTs?: number
userInfo?: UserInfo
version: string
+4 -2
View File
@@ -61,8 +61,10 @@
* JetBrains exports trusted certificates from the OS and writes them to a
* temporary file, then configures node TLS by setting NODE_EXTRA_CA_CERTS.
*
* CLI users should set the NODE_EXTRA_CA_CERTS environment variable if
* necessary, because node does not automatically use the OS' trusted certs.
* The CLI's npm wrapper (bin/cline) does the same automatically: it harvests
* the OS trust store and points the child's NODE_EXTRA_CA_CERTS at a managed
* bundle, because the Bun runtime does not read the OS store on its own. A
* user-set NODE_EXTRA_CA_CERTS is merged in rather than replaced.
*
* ## Limitations in JetBrains & CLI
*
@@ -0,0 +1,85 @@
import type { ModelInfo } from "@shared/api"
import { ModelOverrides } from "@shared/proto/cline/models"
/**
* Domain shape of user-authored per-model metadata overrides, shared by the
* webview (commit path) and the host (read/commit handlers). Mirrors the
* `ModelOverrides` proto message.
*
* Semantics (enforced host-side in the provider config store):
* - `capabilities` accepts only SDK `ModelCapability` values; unknown
* strings are silently dropped. The array is additive over the base
* metadata; the explicit `supports*` booleans win when both are present.
* - `isR1FormatRequired` is a legacy alias that forces the R1 chat format
* only when true; `apiFormat` is canonical.
* - Invalid numbers (non-positive token limits, negative prices or
* temperature, non-finite values) are silently discarded, not rejected.
*
* When committing a selection, the overrides value is tri-state: `undefined`
* preserves the model's stored overrides, an explicitly empty object clears
* them, and a non-empty object replaces them wholesale (no per-field merge).
*/
export interface ProviderModelOverrides {
name?: string
maxTokens?: number
contextWindow?: number
maxInputTokens?: number
capabilities?: readonly string[]
supportsVision?: boolean
supportsAttachments?: boolean
supportsReasoning?: boolean
inputPrice?: number
outputPrice?: number
cacheReadsPrice?: number
cacheWritesPrice?: number
temperature?: number
apiFormat?: ModelInfo["apiFormat"]
isR1FormatRequired?: boolean
}
export function toProtobufModelOverrides(overrides: ProviderModelOverrides): ModelOverrides {
return ModelOverrides.create({
name: overrides.name,
maxTokens: overrides.maxTokens,
contextWindow: overrides.contextWindow,
maxInputTokens: overrides.maxInputTokens,
capabilities: overrides.capabilities ? [...overrides.capabilities] : [],
supportsVision: overrides.supportsVision,
supportsAttachments: overrides.supportsAttachments,
supportsReasoning: overrides.supportsReasoning,
inputPrice: overrides.inputPrice,
outputPrice: overrides.outputPrice,
cacheReadsPrice: overrides.cacheReadsPrice,
cacheWritesPrice: overrides.cacheWritesPrice,
temperature: overrides.temperature,
apiFormat: overrides.apiFormat,
isR1FormatRequired: overrides.isR1FormatRequired,
})
}
/**
* Preserves the proto tri-state: `undefined` stays `undefined` (no override
* payload), and an empty message becomes an empty object (explicit clear).
*/
export function fromProtobufModelOverrides(overrides: ModelOverrides | undefined): ProviderModelOverrides | undefined {
if (!overrides) {
return undefined
}
return {
...(overrides.name !== undefined ? { name: overrides.name } : {}),
...(overrides.maxTokens !== undefined ? { maxTokens: overrides.maxTokens } : {}),
...(overrides.contextWindow !== undefined ? { contextWindow: overrides.contextWindow } : {}),
...(overrides.maxInputTokens !== undefined ? { maxInputTokens: overrides.maxInputTokens } : {}),
...(overrides.capabilities.length > 0 ? { capabilities: [...overrides.capabilities] } : {}),
...(overrides.supportsVision !== undefined ? { supportsVision: overrides.supportsVision } : {}),
...(overrides.supportsAttachments !== undefined ? { supportsAttachments: overrides.supportsAttachments } : {}),
...(overrides.supportsReasoning !== undefined ? { supportsReasoning: overrides.supportsReasoning } : {}),
...(overrides.inputPrice !== undefined ? { inputPrice: overrides.inputPrice } : {}),
...(overrides.outputPrice !== undefined ? { outputPrice: overrides.outputPrice } : {}),
...(overrides.cacheReadsPrice !== undefined ? { cacheReadsPrice: overrides.cacheReadsPrice } : {}),
...(overrides.cacheWritesPrice !== undefined ? { cacheWritesPrice: overrides.cacheWritesPrice } : {}),
...(overrides.temperature !== undefined ? { temperature: overrides.temperature } : {}),
...(overrides.apiFormat !== undefined ? { apiFormat: overrides.apiFormat } : {}),
...(overrides.isR1FormatRequired !== undefined ? { isR1FormatRequired: overrides.isR1FormatRequired } : {}),
}
}
+9 -1
View File
@@ -32,8 +32,9 @@
// Importing the real package here is safe: the preload runs before any test
// file, so this is the only point the real module is linked, and we only read
// its export *names*, never its behavior (the mock shadows it everywhere tests look).
import { vi as bunVi, mock } from "bun:test"
import { beforeEach as bunBeforeEach, vi as bunVi, mock } from "bun:test"
import * as realClineCore from "@cline/core"
import * as LlmsModels from "@cline/llms"
import * as clineCoreStub from "./cline-core-vitest-stub"
import * as vscodeStub from "./vscode-vitest-stub"
@@ -43,6 +44,13 @@ for (const name of Object.keys(realClineCore)) {
}
Object.assign(clineCoreNamespace, clineCoreStub)
bunBeforeEach(() => {
clineCoreStub.resetModelsFileState()
// The stub's syncStoredProviderRegistration mutates the real shared
// @cline/llms registry; reset it so registrations never leak across tests.
LlmsModels.resetRegistry()
})
mock.module("@cline/core", () => clineCoreNamespace)
// `vscode`: the stub provides both named exports (Position, Uri, …) and a
+52 -7
View File
@@ -13,6 +13,54 @@ export interface StartSessionResult {
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
export interface StoredModelEntry {
id?: string
name?: string
maxTokens?: number
contextWindow?: number
maxInputTokens?: number
capabilities?: string[]
[key: string]: unknown
}
export interface StoredModelsFile {
version: 1
providers: Record<string, { models?: Record<string, StoredModelEntry> }>
}
const modelsFiles = new Map<string, StoredModelsFile>()
function cloneModelsFile(modelsFile: StoredModelsFile): StoredModelsFile {
return structuredClone(modelsFile)
}
export function resetModelsFileState(): void {
modelsFiles.clear()
}
export function readModelsFileSync(filePath: string): StoredModelsFile {
return cloneModelsFile(modelsFiles.get(filePath) ?? { version: 1, providers: {} })
}
export function writeModelsFileSync(filePath: string, next: StoredModelsFile): void {
modelsFiles.set(filePath, cloneModelsFile(next))
}
export function resolveModelsRegistryPath(): string {
return "/tmp/models.json"
}
export function ensureCustomProvidersLoadedSync(): void {}
// Real implementation re-exported from the sdk source (same pattern as the
// apply-patch executors below) so store writes are reflected in the live
// @cline/llms registry exactly as in production. Tests that touch it must
// reset the registry (LlmsModels.resetRegistry()) between tests.
export {
StoredModelEntrySchema,
syncStoredProviderRegistration,
} from "../../../../sdk/packages/core/src/services/providers/local-provider-registry"
export type GlobalCompactionStrategy = "basic" | "agentic"
export function readCompactionStrategyGlobally(): GlobalCompactionStrategy {
@@ -53,6 +101,10 @@ export function createShellExecutor() {
return async () => ""
}
// The real createShellTool, so tests exercise the actual description
// building and shell classification (getShellKind) rather than a stub that
// would have to duplicate those invariants.
export { createShellTool } from "../../../../sdk/packages/core/src/extensions/tools/definitions"
// Real (dependency-light) edit-executor implementations, re-exported from the sdk source so
// the diff-edit coordinator and its tests exercise the actual content/parse semantics. These
// modules only pull in node:fs/node:path and the patch parser — not the heavy core runtime.
@@ -66,13 +118,6 @@ export { createEditorExecutor } from "../../../../sdk/packages/core/src/extensio
export type { EditFileInput } from "../../../../sdk/packages/core/src/extensions/tools/schemas"
export type { ApplyPatchExecutor, EditorExecutor } from "../../../../sdk/packages/core/src/extensions/tools/types"
export function createShellTool(execute: unknown) {
return {
name: "run_commands",
execute,
}
}
export interface SessionHistoryRecord {
id: string
metadata?: Record<string, unknown>
+128 -5
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { expect } from "chai"
import * as actualFs from "fs"
import * as actualOs from "os"
import * as vscode from "vscode"
@@ -15,6 +16,16 @@ const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
mock.module("os", osMock)
mock.module("node:os", osMock)
// getShell() probes the filesystem for PowerShell 7 when no Windows terminal
// profile is configured. Route existsSync through a mutable delegate so tests
// control which PowerShell installs "exist" regardless of the host machine.
let existsSyncImpl: typeof actualFs.existsSync = actualFs.existsSync
const existsSyncDelegate = ((path: unknown) => existsSyncImpl(path as string)) as typeof actualFs.existsSync
const fsMockNamespace = { ...actualFs, existsSync: existsSyncDelegate }
const fsMock = () => ({ ...fsMockNamespace, default: fsMockNamespace })
mock.module("fs", fsMock)
mock.module("node:fs", fsMock)
import { getShell } from "@utils/shell"
describe("Shell Detection Tests", () => {
@@ -22,6 +33,7 @@ describe("Shell Detection Tests", () => {
let originalEnv: NodeJS.ProcessEnv
let originalGetConfig: typeof vscode.workspace.getConfiguration
let originalUserInfo: typeof actualOs.userInfo
let originalExistsSync: typeof actualFs.existsSync
// Helper to mock VS Code configuration
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
@@ -45,6 +57,7 @@ describe("Shell Detection Tests", () => {
originalEnv = { ...process.env }
originalGetConfig = vscode.workspace.getConfiguration
originalUserInfo = userInfoImpl
originalExistsSync = existsSyncImpl
// Clear environment variables for a clean test
delete process.env.SHELL
@@ -52,6 +65,9 @@ describe("Shell Detection Tests", () => {
// Default userInfo() mock
userInfoImpl = (() => ({ shell: null })) as any
// Default: PowerShell 7 is not installed, so the Windows default
// resolves to legacy Windows PowerShell.
existsSyncImpl = (() => false) as any
})
afterEach(() => {
@@ -60,6 +76,7 @@ describe("Shell Detection Tests", () => {
process.env = originalEnv
vscode.workspace.getConfiguration = originalGetConfig
userInfoImpl = originalUserInfo
existsSyncImpl = originalExistsSync
})
// --------------------------------------------------------------------------
@@ -71,12 +88,63 @@ describe("Shell Detection Tests", () => {
})
it("uses explicit PowerShell 7 path from VS Code config (profile path)", () => {
existsSyncImpl = ((candidate: actualFs.PathLike) =>
candidate === "C:\\Program Files\\PowerShell\\7\\pwsh.exe") as typeof actualFs.existsSync
mockVsCodeConfig("windows", "PowerShell", {
PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" },
})
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
})
it("expands and selects the first configured profile path when it exists", () => {
process.env.windir = "C:\\Windows"
existsSyncImpl = (() => true) as typeof actualFs.existsSync
mockVsCodeConfig("windows", "Command Prompt", {
"Command Prompt": {
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
},
})
expect(getShell()).to.equal("C:\\Windows\\Sysnative\\cmd.exe")
})
it("falls through configured profile paths in order", () => {
process.env.windir = "C:\\Windows"
existsSyncImpl = ((candidate: actualFs.PathLike) =>
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
mockVsCodeConfig("windows", "Command Prompt", {
"Command Prompt": {
path: [`\${env:windir}\\Sysnative\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
},
})
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
})
it("skips profile paths with variable references it cannot expand", () => {
existsSyncImpl = ((candidate: actualFs.PathLike) =>
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
process.env.windir = "C:\\Windows"
mockVsCodeConfig("windows", "Command Prompt", {
"Command Prompt": {
path: [`\${workspaceFolder}\\tools\\cmd.exe`, `\${env:windir}\\System32\\cmd.exe`],
},
})
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
})
it("resolves a configured executable name from PATH", () => {
process.env.PATH = "C:\\Tools;C:\\Windows\\System32"
existsSyncImpl = ((candidate: actualFs.PathLike) =>
candidate === "C:\\Windows\\System32\\cmd.exe") as typeof actualFs.existsSync
mockVsCodeConfig("windows", "Command Prompt", {
"Command Prompt": { path: "cmd.exe" },
})
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
})
it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => {
mockVsCodeConfig("windows", "PowerShell", {
PowerShell: { source: "PowerShell" },
@@ -117,18 +185,36 @@ describe("Shell Detection Tests", () => {
expect(getShell()).to.equal("C:\\Windows\\System32\\cmd.exe")
})
it("respects userInfo() if no VS Code config is available", () => {
it("defaults to PowerShell 7 when no profile is configured and pwsh is installed", () => {
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
userInfoImpl = () => ({ shell: "C:\\Custom\\PowerShell.exe" }) as any
process.env.ProgramW6432 = "C:\\Program Files"
existsSyncImpl = (() => true) as any
expect(getShell()).to.equal("C:\\Custom\\PowerShell.exe")
expect(getShell()).to.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
})
it("respects an odd COMSPEC if no userInfo shell is available", () => {
it("defaults to Store-installed pwsh when that is the only pwsh present", () => {
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
const storePwsh = "C:\\Users\\Test\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"
existsSyncImpl = ((path: string) => path === storePwsh) as any
expect(getShell()).to.equal(storePwsh)
})
it("defaults to legacy Windows PowerShell when no profile is configured and pwsh is absent", () => {
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
existsSyncImpl = (() => false) as any
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
})
it("ignores userInfo() and COMSPEC — VS Code's default terminal ignores them too", () => {
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
userInfoImpl = () => ({ shell: "C:\\Custom\\OtherShell.exe" }) as any
process.env.COMSPEC = "D:\\CustomCmd\\cmd.exe"
expect(getShell()).to.equal("D:\\CustomCmd\\cmd.exe")
expect(getShell()).to.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
})
})
@@ -141,12 +227,31 @@ describe("Shell Detection Tests", () => {
})
it("uses VS Code profile path if available", () => {
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/local/bin/fish") as typeof actualFs.existsSync
mockVsCodeConfig("osx", "MyCustomShell", {
MyCustomShell: { path: "/usr/local/bin/fish" },
})
expect(getShell()).to.equal("/usr/local/bin/fish")
})
it("expands and selects the first existing path in an array-valued profile", () => {
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/zsh") as typeof actualFs.existsSync
mockVsCodeConfig("osx", "MyCustomShell", {
MyCustomShell: { path: ["/opt/homebrew/bin/zsh", "/bin/zsh"] },
})
expect(getShell()).to.equal("/bin/zsh")
})
it("falls back past a configured path that does not exist", () => {
existsSyncImpl = (() => false) as typeof actualFs.existsSync
mockVsCodeConfig("osx", "MyCustomShell", {
MyCustomShell: { path: "/missing/shell" },
})
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
expect(getShell()).to.equal("/opt/homebrew/bin/zsh")
})
it("falls back to userInfo().shell if no VS Code config is available", () => {
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
userInfoImpl = () => ({ shell: "/opt/homebrew/bin/zsh" }) as any
@@ -177,12 +282,30 @@ describe("Shell Detection Tests", () => {
})
it("uses VS Code profile path if available", () => {
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
mockVsCodeConfig("linux", "CustomProfile", {
CustomProfile: { path: "/usr/bin/fish" },
})
expect(getShell()).to.equal("/usr/bin/fish")
})
it("expands and selects the first existing path in an array-valued profile", () => {
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/bin/bash") as typeof actualFs.existsSync
mockVsCodeConfig("linux", "CustomProfile", {
CustomProfile: { path: ["/usr/bin/fish", "/bin/bash"] },
})
expect(getShell()).to.equal("/bin/bash")
})
it("resolves a bare executable name from PATH without PATHEXT probing", () => {
process.env.PATH = "/opt/tools:/usr/bin"
existsSyncImpl = ((candidate: actualFs.PathLike) => candidate === "/usr/bin/fish") as typeof actualFs.existsSync
mockVsCodeConfig("linux", "CustomProfile", {
CustomProfile: { path: "fish" },
})
expect(getShell()).to.equal("/usr/bin/fish")
})
it("falls back to userInfo().shell if no VS Code config is available", () => {
vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any
userInfoImpl = () => ({ shell: "/usr/bin/zsh" }) as any
+15
View File
@@ -0,0 +1,15 @@
// Under vitest, `@cline/core` is aliased to src/test/cline-core-vitest-stub.ts
// (see vitest.config.ts), which holds models.json state in memory and exposes
// the stub-only `resetModelsFileState` — hence the cast below.
import * as ClineCore from "@cline/core"
import { resetRegistry } from "@cline/llms"
import { beforeEach } from "vitest"
const { resetModelsFileState } = ClineCore as typeof ClineCore & { resetModelsFileState(): void }
beforeEach(() => {
resetModelsFileState()
// The stub's syncStoredProviderRegistration mutates the real shared
// @cline/llms registry; reset it so registrations never leak across tests.
resetRegistry()
})
+2 -9
View File
@@ -1,6 +1,6 @@
import * as childProcess from "child_process"
import { Logger } from "@/shared/services/Logger"
import { WINDOWS_POWERSHELL_7_PATH, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
import { getWindowsPwshInstallPaths, WINDOWS_POWERSHELL_LEGACY_PATH } from "./shell"
const POWERSHELL_PROBE_TIMEOUT_MS = 1200
@@ -16,14 +16,7 @@ export function getFallbackWindowsPowerShellPath(): string {
}
export function getWindowsPowerShellCandidates(): string[] {
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
const envAbsoluteCandidates = [
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
WINDOWS_POWERSHELL_7_PATH,
WINDOWS_POWERSHELL_LEGACY_PATH,
]
const envAbsoluteCandidates = [...getWindowsPwshInstallPaths(), WINDOWS_POWERSHELL_LEGACY_PATH]
const commandNameFallbacks = ["pwsh.exe", "pwsh", "powershell.exe", "powershell"]
+129 -24
View File
@@ -1,5 +1,8 @@
import { existsSync } from "fs"
import { userInfo } from "os"
import * as nodePath from "path"
import * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
export const WINDOWS_POWERSHELL_7_PATH = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
export const WINDOWS_POWERSHELL_LEGACY_PATH = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
@@ -29,21 +32,24 @@ const SHELL_PATHS = {
FALLBACK: "/bin/sh",
} as const
// VS Code permits `path: string | string[]` in terminal profiles on every
// platform (the stock Windows "Command Prompt" profile is an array), so all
// three profile shapes model both forms.
interface MacTerminalProfile {
path?: string
path?: string | string[]
}
type MacTerminalProfiles = Record<string, MacTerminalProfile>
interface WindowsTerminalProfile {
path?: string
path?: string | string[]
source?: "PowerShell" | "WSL"
}
type WindowsTerminalProfiles = Record<string, WindowsTerminalProfile>
interface LinuxTerminalProfile {
path?: string
path?: string | string[]
}
type LinuxTerminalProfiles = Record<string, LinuxTerminalProfile>
@@ -89,6 +95,83 @@ function getLinuxTerminalConfig() {
// 2) Platform-Specific VS Code Shell Retrieval
// -----------------------------------------------------
function isWindows(): boolean {
return process.platform === "win32"
}
/** The path module matching the host platform's separators and semantics. */
function hostPath(): nodePath.PlatformPath {
return isWindows() ? nodePath.win32 : nodePath.posix
}
function getEnvironmentVariable(name: string): string | undefined {
// Windows environment variable names are case-insensitive; POSIX names
// are case-sensitive.
if (!isWindows()) {
return process.env[name]
}
const entry = Object.entries(process.env).find(([key]) => key.toLowerCase() === name.toLowerCase())
return entry?.[1]
}
/** Expands VS Code's `${env:NAME}` references in a profile path. */
function expandShellPath(candidate: string): string {
return candidate.replace(/\$\{env:([^}]+)\}/gi, (reference, name: string) => {
return getEnvironmentVariable(name.trim()) ?? reference
})
}
/**
* Resolves a profile path candidate to an existing executable: absolute or
* relative paths are checked directly; bare names are searched on PATH
* (with PATHEXT on Windows, mirroring how VS Code launches profiles).
*/
function findExecutable(candidate: string): string | null {
const path = hostPath()
if (path.basename(candidate) !== candidate) {
const normalized = path.normalize(candidate)
return existsSync(normalized) ? normalized : null
}
const pathValue = getEnvironmentVariable("PATH")
if (!pathValue) {
return null
}
const extensions =
!isWindows() || path.extname(candidate) ? [""] : (getEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";")
for (const directory of pathValue.split(path.delimiter)) {
for (const extension of extensions) {
const executable = path.join(directory, `${candidate}${extension}`)
if (existsSync(executable)) {
return executable
}
}
}
return null
}
/** Resolves the first usable path in a VS Code terminal profile. */
function resolveShellPath(configuredPath: string | string[] | undefined): string | null {
const candidates = typeof configuredPath === "string" ? [configuredPath] : configuredPath
for (const candidate of candidates ?? []) {
const expandedPath = expandShellPath(candidate)
if (expandedPath.includes("${")) {
// Only ${env:NAME} references are expanded here. VS Code resolves
// more variable kinds (e.g. ${workspaceFolder}); surface the gap
// instead of silently skipping the user's configured shell.
Logger.warn(`[shell] Skipping terminal profile path with unresolved variable reference: ${candidate}`)
continue
}
const executable = findExecutable(expandedPath)
if (executable) {
return executable
}
}
return null
}
/** Attempts to retrieve a shell path from VS Code config on Windows. */
function getWindowsShellFromVSCode(): string | null {
const { defaultProfileName, profiles } = getWindowsTerminalConfig()
@@ -97,14 +180,15 @@ function getWindowsShellFromVSCode(): string | null {
}
const profile = profiles[defaultProfileName]
const configuredShell = resolveShellPath(profile?.path)
// If the profile name indicates PowerShell, do version-based detection.
// In testing it was found these typically do not have a path, and this
// implementation manages to deductively get the correct version of PowerShell
if (defaultProfileName.toLowerCase().includes("powershell")) {
if (profile?.path) {
if (configuredShell) {
// If there's an explicit PowerShell path, return that
return profile.path
return configuredShell
}
if (profile?.source === "PowerShell") {
// If the profile is sourced from PowerShell, assume the newest
@@ -115,8 +199,8 @@ function getWindowsShellFromVSCode(): string | null {
}
// If there's a specific path, return that immediately
if (profile?.path) {
return profile.path
if (configuredShell) {
return configuredShell
}
// If the profile indicates WSL
@@ -135,8 +219,7 @@ function getMacShellFromVSCode(): string | null {
return null
}
const profile = profiles[defaultProfileName]
return profile?.path || null
return resolveShellPath(profiles[defaultProfileName]?.path)
}
/** Attempts to retrieve a shell path from VS Code config on Linux. */
@@ -146,8 +229,7 @@ function getLinuxShellFromVSCode(): string | null {
return null
}
const profile = profiles[defaultProfileName]
return profile?.path || null
return resolveShellPath(profiles[defaultProfileName]?.path)
}
// -----------------------------------------------------
@@ -171,11 +253,6 @@ function getShellFromUserInfo(): string | null {
function getShellFromEnv(): string | null {
const { env } = process
if (process.platform === "win32") {
// On Windows, COMSPEC typically holds cmd.exe
return env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"
}
if (process.platform === "darwin") {
// On macOS/Linux, SHELL is commonly the environment variable
return env.SHELL || "/bin/zsh"
@@ -304,6 +381,35 @@ export function getShellForProfile(profileId: string): string {
// 5) Publicly Exposed Shell Getter
// -----------------------------------------------------
/**
* Absolute paths where a modern PowerShell (pwsh) may be installed, most
* preferred first: MSI/ZIP installs under Program Files (either architecture),
* then the Microsoft Store install under LOCALAPPDATA. This is the single
* candidate list shared with the async prober in utils/powershell.ts.
*/
export function getWindowsPwshInstallPaths(): string[] {
const programFiles = process.env.ProgramW6432 || process.env.ProgramFiles || "C:\\Program Files"
const localAppData = process.env.LOCALAPPDATA
return [
`${programFiles}\\PowerShell\\7\\pwsh.exe`,
`${programFiles}\\PowerShell\\6\\pwsh.exe`,
SHELL_PATHS.POWERSHELL_7,
...(localAppData ? [`${localAppData}\\Microsoft\\WindowsApps\\pwsh.exe`] : []),
]
}
/**
* The shell VS Code launches on Windows when the user has not configured a
* default terminal profile: its built-in default is PowerShell (pwsh when
* installed, Windows PowerShell otherwise) never cmd.exe. Mirroring that
* here keeps the "default" profile meaning the same shell whether commands
* run in a visible VS Code terminal or a background child process.
*/
function getWindowsDefaultShell(): string {
const pwsh = getWindowsPwshInstallPaths().find((candidate) => existsSync(candidate))
return pwsh ?? SHELL_PATHS.POWERSHELL_LEGACY
}
export function getShell(): string {
// 1. Check VS Code config first.
if (process.platform === "win32") {
@@ -312,7 +418,12 @@ export function getShell(): string {
if (windowsShell) {
return windowsShell
}
} else if (process.platform === "darwin") {
// No profile configured — match the shell VS Code's default terminal
// would launch. userInfo()/COMSPEC are not consulted: VS Code's own
// terminal ignores them too, and they would resolve to cmd.exe.
return getWindowsDefaultShell()
}
if (process.platform === "darwin") {
// macOS from VS Code
const macShell = getMacShellFromVSCode()
if (macShell) {
@@ -338,12 +449,6 @@ export function getShell(): string {
return envShell
}
// 4. Finally, fall back to a default
if (process.platform === "win32") {
// On Windows, if we got here, we have no config, no COMSPEC, and one very messed up operating system.
// Use CMD as a last resort
return SHELL_PATHS.CMD
}
// On macOS/Linux, fallback to a POSIX shell - This is the behavior of our old shell detection method.
// 4. Fall back to a POSIX shell - This is the behavior of our old shell detection method.
return SHELL_PATHS.FALLBACK
}
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
"src/core/controller/models/__tests__/refreshGroqModels.test.ts",
],
environment: "node",
setupFiles: ["./src/test/vitest-setup.ts"],
// Several suites lazily `await import()` their subject inside the first test
// (needed so vi.mock factories apply first). That import pulls in heavy
// workspace packages (@cline/core/@cline/llms/@cline/shared), and on loaded
@@ -20,7 +20,7 @@ interface ActionButtonsProps {
*/
export const ActionButtons: React.FC<ActionButtonsProps> = ({ task, messages, chatState, mode, messageHandlers }) => {
const { inputValue, selectedImages, selectedFiles, setSendingDisabled } = chatState
const { turnState } = useExtensionState()
const { turnState, foregroundCommandRunning } = useExtensionState()
// Tracks the ask the user last acted on. Clicking a footer button latches this so the
// buttons disable immediately (and survive the trailing bookkeeping re-renders before the
@@ -41,8 +41,8 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({ task, messages, ch
// buttons immune to trailing bookkeeping messages and never disagree with the thinking
// indicator (RC1).
const buttonConfig = useMemo(() => {
return getButtonConfigFromState(messages, turnState, mode)
}, [messages, turnState, mode])
return getButtonConfigFromState(messages, turnState, mode, foregroundCommandRunning)
}, [messages, turnState, mode, foregroundCommandRunning])
// Identity of the ask that currently owns the footer buttons. The button config objects are
// shared singletons (e.g. BUTTON_CONFIGS.tool_approve), so two consecutive identical asks
@@ -367,6 +367,15 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
clearInputState()
break
case "proceed_while_running":
// Detach the running foreground terminal command: the agent
// receives the partial output plus a log file path for the
// rest, and the command keeps running in the terminal.
await TaskServiceClient.proceedWhileRunningCommand(EmptyRequest.create({})).catch((err) =>
console.error("Failed to proceed while running:", err),
)
break
case "new_task":
if (clineAsk === "new_task") {
await TaskServiceClient.newTask(
@@ -252,4 +252,19 @@ describe("getButtonConfigFromState (dispatch + legacy fallback)", () => {
const turnState: TurnState = { phase: "completed", seq: 3 }
expect(getButtonConfigFromState(messages, turnState, "act")).toEqual(BUTTON_CONFIGS.completion_result)
})
it("streaming phase shows Proceed While Running when a foreground command is running", () => {
const messages: ClineMessage[] = [{ ts: 1, type: "say", say: "command", text: "npm run dev", partial: true }]
const turnState: TurnState = { phase: "streaming", seq: 4 }
expect(getButtonConfigFromState(messages, turnState, "act", true)).toEqual(BUTTON_CONFIGS.foreground_command_running)
expect(getButtonConfigFromState(messages, turnState, "act", false)).toEqual(BUTTON_CONFIGS.partial)
})
it("foreground command flag only affects the streaming phase", () => {
const messages: ClineMessage[] = []
expect(getButtonConfigFromState(messages, { phase: "completed", seq: 5 }, "act", true)).toEqual(
BUTTON_CONFIGS.completion_result,
)
expect(getButtonConfigFromState(messages, { phase: "idle", seq: 6 }, "act", true)).toEqual(BUTTON_CONFIGS.default)
})
})
@@ -8,6 +8,7 @@ export type ButtonActionType =
| "approve" // Send yesButtonClicked
| "reject" // Send noButtonClicked
| "proceed" // Send messageResponse or yesButtonClicked
| "proceed_while_running" // Detach the running foreground terminal command
| "new_task" // Start a new task
| "cancel" // Cancel streaming
| "utility" // Execute utility function (condense, report_bug)
@@ -188,6 +189,17 @@ export const BUTTON_CONFIGS: Record<string, ButtonConfig> = {
secondaryAction: "cancel",
},
// A foreground terminal command is running (SDK path): the user can detach
// it and let the agent proceed with the partial output, or cancel the task.
foreground_command_running: {
sendingDisabled: true,
enableButtons: true,
primaryText: "Proceed While Running",
secondaryText: "Cancel",
primaryAction: "proceed_while_running",
secondaryAction: "cancel",
},
// Default states
default: {
sendingDisabled: false,
@@ -360,12 +372,19 @@ export function getButtonConfigForMessages(messages: ClineMessage[], mode: Mode
* The button SET is chosen by phase; the LABEL/variant for approvals (Save vs Approve, command
* vs tool vs MCP vs subagents) comes from the anchored message (turnState.anchorTs).
*/
export function buttonsForPhase(turnState: TurnState, anchoredMessage: ClineMessage | undefined): ButtonConfig {
export function buttonsForPhase(
turnState: TurnState,
anchoredMessage: ClineMessage | undefined,
foregroundCommandRunning = false,
): ButtonConfig {
switch (turnState.phase) {
case "idle":
return BUTTON_CONFIGS.default
case "streaming":
return BUTTON_CONFIGS.partial
// A running foreground terminal command offers "Proceed While Running":
// detach the command (output continues to a log file) and let the
// agent continue with the partial output.
return foregroundCommandRunning ? BUTTON_CONFIGS.foreground_command_running : BUTTON_CONFIGS.partial
case "completed":
return BUTTON_CONFIGS.completion_result
case "resumable":
@@ -398,10 +417,11 @@ export function getButtonConfigFromState(
messages: ClineMessage[],
turnState: TurnState | undefined,
mode: Mode = "act",
foregroundCommandRunning = false,
): ButtonConfig {
if (turnState) {
const anchored = turnState.anchorTs !== undefined ? messages.find((m) => m.ts === turnState.anchorTs) : undefined
return buttonsForPhase(turnState, anchored)
return buttonsForPhase(turnState, anchored, foregroundCommandRunning)
}
return getButtonConfigForMessages(messages, mode)
}
@@ -471,12 +471,10 @@ const OnboardingViewContent = ({ onboardingModels }: { onboardingModels: Onboard
commitSelection("plan", {
providerId: "cline",
modelId: selectedModelId,
modelInfo: selectedModelInfo,
}),
commitSelection("act", {
providerId: "cline",
modelId: selectedModelId,
modelInfo: selectedModelInfo,
}),
])
@@ -510,7 +510,7 @@ const ApiOptions = ({
<AIhubmixProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && (selectedProvider.includes("openai") || isCustomProvider) && (
{apiConfiguration && (selectedProvider === "openai" || isCustomProvider) && (
<OpenAICompatibleProvider
currentMode={currentMode}
isPopup={isPopup}

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