Compare commits

..

34 Commits

Author SHA1 Message Date
abeatrix 2263078684 test(core): agentic compaction unit test 2026-07-14 02:10:37 +08:00
Saoud Rizwan a3989acc38 fix(sdk): don't log users out when token refresh fails due to network/server errors (#12255)
* fix(sdk): stop misclassifying transient refresh failures as invalid_grant

getValidClineCredentials returned null for BOTH a rejected refresh token and
any transient error (network down, timeout, 5xx) that happened to land after
the access token expired. Callers treat null as 'session dead' — the
extension wipes providers.json over it, logging out every Cline process on
the machine, which is what CLI users then hit as 'Unauthorized: please
re-authenticate'. A laptop waking from sleep past the ~1h token expiry with
a background job (balance/banners/remote-config) refreshing before the
network is up was enough to trigger it — no refresh-token rotation involved.

Now: null means the refresh token was REJECTED (re-auth required); transient
failures throw so callers keep stored credentials and retry later. The
extension's refreshAccessToken catch and the CLI's error surface already
handle the throw correctly with no changes.

* fix(sdk): write providers.json atomically

providers.json was written with a bare writeFileSync while being read
concurrently by every other Cline process (CLI, extension, hub). A reader
catching a partial write parses garbage, which read() silently treats as
EMPTY settings — indistinguishable from being logged out — and any
subsequent save from that process persists the empty state, erasing every
configured provider.

Stage to a pid-unique temp file and rename into place; rename is atomic on
POSIX and replaces on Windows, so readers only ever see a complete file.
2026-07-13 08:58:26 -07:00
Saoud Rizwan d199b1bff9 fix(desktop-app): allow loopback origins for Next dev resources (#12251)
Next 16 blocks dev-resource requests (/_next/webpack-hmr, dev fonts) from
origins that don't match the dev server's own hostname. Browsing the web
dev mode via 127.0.0.1 left the page hanging with 'Blocked cross-origin
request to Next.js dev resource' warnings. allowedDevOrigins is dev-only,
so production/Tauri builds are unaffected.
2026-07-12 22:18:45 -07:00
Saoud Rizwan d41eed1198 feat(desktop-app): env-configurable sidecar bind host, trusted origins, and webview WS endpoint (#12250)
* feat(desktop-app): env-configurable sidecar bind host, trusted origins, and webview WS endpoint

Allows running the desktop app's web dev mode (dev:web + dev:sidecar) inside
a Docker container with published ports:

- CLINE_SIDECAR_HOST: sidecar bind hostname (default remains 127.0.0.1)
- CLINE_SIDECAR_TRUSTED_ORIGINS: comma-separated extra browser origins for
  the sidecar's origin allowlist (validation itself stays on)
- NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: overrides the webview's hardcoded
  ws://127.0.0.1:3126/transport fallback so a browser on the Docker host can
  dial the published port

All defaults are unchanged, so local/Tauri behavior is unaffected when the
env vars are absent. When bound to 0.0.0.0 the printed ready endpoint
advertises 127.0.0.1 since a wildcard bind is not dialable.

* chore(desktop-app): untrack next-env.d.ts

It was added to .gitignore previously but never removed from the index, so
it kept showing as modified: Next.js rewrites the routes.d.ts import path
depending on whether 'next dev' or 'next build' ran last. The file is
regenerated by Next on every dev/build run, and the app's typecheck
(tsconfig.dev.json) excludes webview/, so nothing needs it tracked.

* style(desktop-app): format SIDECAR_HOST declaration
2026-07-12 21:59:07 -07:00
Tomás Barreiro 6309971089 Add the ClinePass limit error to the CLI (#12191)
* Add the ClinePass limit error to the CLI

* Update apps/cli/src/runtime/run-agent.test.ts

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

* format code and improve instructions

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-11 02:41:55 +02:00
Saoud Rizwan 2d2c669421 fix(cli): reload provider config when switching models (#12232) 2026-07-10 16:57:50 -07:00
Dominic Cooney c5f146a418 Add debug logging for Cline credential lifecycle (ENG-2213) (#12000)
* fix(auth): add early SDK debug logging for Cline credential lifecycle (ENG-2213)

Adds targeted debug-level logging at key points in the Cline/Cline Pass
credential lifecycle to diagnose intermittent logout issues. Credentials
are never logged in cleartext; an 8-hex-digit SHA-256 hash is used instead.

The SDK has two logger layers:
1. ClineCore.logger — session-scoped, threaded from ClineCore.create({logger})
   into session config and the agent event bridge.
2. setSdkLogger() — early/module-level, for components that operate before
   or outside of ClineCore sessions: ProviderSettingsManager (constructed
   at startup), RuntimeOAuthTokenManager, and cline.ts auth functions
   (token refresh). These can't be reached by the session-scoped logger.

Both VS Code (common.ts) and CLI (main.ts) call setSdkLogger() once at
startup. When no logger is registered (or the host filters out debug),
every call is a no-op — logging is never collected in normal use.

Instrumentation points (SDK core, shared by both surfaces):
- ProviderSettingsManager.read(): logs provider IDs, last-used, and whether
  Cline auth is present (with hashed access/refresh token fingerprints)
- ProviderSettingsManager.saveProviderSettings(): logs the provider being
  saved, tokenSource, whether Cline auth was present before/after, and
  flags authDropped when a previously-present Cline auth block disappears
- RuntimeOAuthTokenManager.resolveProviderApiKeyInternal(): logs each
  decision point (no_settings, no_credentials, refresh_start, refresh_null,
  refreshed+saved, not_refreshed) with hashed token fingerprints
- cline.ts refreshClineToken(): logs the refresh request URL, response
  status/errorCode on failure, and new token hashes on success
- cline.ts getValidClineCredentials(): logs the outcome at each branch
  (no_current_credentials, still_valid, needs_refresh, invalid_grant,
  transient_failure_kept_current, transient_failure_expired)

VS Code extension (auth-service.ts):
- readClineCredentials/writeClineCredentials/clearClineCredentials: logs
  credential presence and hashes at each disk I/O point
- refreshAccessToken: logs refresh start, null result (cleared), changed
  (written), or unchanged outcomes
- fetchUserInfoFromApi: logs the GET /api/v1/users/me request and response
  status

What to collect when investigating:

VS Code extension:
- Open the "Cline" output channel (View -> Output -> select "Cline")
- Look for lines containing: [SdkAuthService], providers.read,
  providers.save, oauth.resolve, cline.refresh, cline.getCredentials
- Debug logging is emitted at the DEBUG level; it appears in the output
  channel when IS_DEV=true or in development builds

CLI:
- Set CLINE_LOG_LEVEL=debug environment variable before running cline
- Collect the log file at ~/.cline/data/logs/cline.cli.log (or the path
  set by CLINE_LOG_PATH)
- Look for the same event names as above

Files changed:
- sdk/packages/core/src/auth/auth-debug.ts (NEW): hashSecret,
  setSdkLogger, getSdkLogger, sdkDebug
- sdk/packages/core/src/auth/cline.ts: refresh/getCredentials logging
- sdk/packages/core/src/services/storage/provider-settings-manager.ts:
  read/save logging
- sdk/packages/core/src/runtime/orchestration/runtime-oauth-token-manager.ts:
  resolve logging
- sdk/packages/core/src/index.ts: export early logger utilities
- apps/vscode/src/sdk/auth-service.ts: credential lifecycle logging
- apps/vscode/src/common.ts: register SDK early logger
- apps/cli/src/main.ts: register SDK early logger

* fix(vscode): inline SDK debug metadata into log message string (ENG-2213)

* fix(auth): gate debug logging on CLINE_LOG_LEVEL at runtime (ENG-2213)

* fix(auth): use interpolated debug strings, remove log-level gating (ENG-2213)

* refactor: move early logger to sdk/packages/core/src/logging/early-logger.ts

* fix: address review feedback — early logger registration, log after write, remove getSdkLogger from public API

* fix(vscode): add ISO timestamps to all log lines

* fix core import

* fix import

* fix tests

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-07-10 23:40:46 +02:00
Saoud Rizwan 261ee4c313 fix(vscode): show requested line range on read-file chat rows (#12225)
The webview already knew how to render readLineStart/readLineEnd on
readFile tool rows, but the SDK message translator never populated
them, so successive ranged reads of the same file all rendered as
identical bare paths. Extract start_line/end_line from read_files
input (per-file and single-path forms) and render open-ended reads
(start_line only) as "start+".
2026-07-10 13:25:30 -07:00
Saoud Rizwan d45b051c04 fix(cli): detect bun global installs after symlink resolution in auto-update (#12224) 2026-07-10 12:15:26 -07:00
Bee 78c83cdf33 fix(cli): preserve session id when in same session (#12188) 2026-07-10 17:59:55 +08:00
Bee 3266121fa1 feat(desktop): add typography spec (#12215)
* feat(desktop): add typography spec

* remove unused background component
2026-07-09 19:34:28 -07:00
Bee 6467de65a2 fix(plugin): follow up fix for agent-squad (#12216)
Follow up on my last PR where the last commit revert the removal of the regex field from zod schema
2026-07-09 19:33:04 -07:00
Bee 65fe885638 fix(sdk): remove regex from zod schema for agent-squad plugin example (#12214)
* fix(sdk): remove regex from zod schema for agent-squad plugin example

The `HandoffPathInput` schema used negative lookaheads to reject absolute paths and `..` traversal segments. When converted to JSON Schema, this regex caused consumers without lookaround support to fail with `invalid JSON schema: regex lookaround is not supported`.

This change removes the lookaround-based regex from the published schema and moves those checks to runtime validation. It preserves validation for allowed characters, absolute paths, traversal segments, and maximum length while strengthening cross-platform directory containment checks using Node’s path utilities.

* add back logger examples
2026-07-10 10:19:27 +08:00
Bee c3033d6f13 fix(vscode): refreshGroqModels caused cacheReadsPrice undefined error (#12213) 2026-07-10 08:52:06 +08:00
Max cfb1327a1b fix vscode hmr not working (#12212) 2026-07-09 16:49:35 -07:00
Alex Taboada 264af96e1b fix(vscode): prevent infinite loading when initializing task with an image (#12203) 2026-07-09 18:16:52 +02:00
Robin Newhouse 10cb9bd97a Add compaction budget hardening (#12142)
* Add compaction budget projection contract

* Tighten budget projection contract types

* Tighten dropped block action paths

* Add pure compaction budget projection engine

* Fix budget projection truncation accounting

* Drop provider-native blocks during budget projection

* Recompute protected tail after thinking pruning

* Align budget projection test tool results

* Clean up budget projection fixture indentation

* fix(core): narrow compaction protected tail

* Fix budget projection action accounting

* Budget agentic compaction summary input

* Harden agentic summary budget fallback

* Align agentic compaction test tool result

* Align agentic file ops with projected input

* Budget basic compaction projections

* Clarify basic projection budget logging

* Align basic sanitization image expectation

* Align basic compaction budget expectation

* Emit compaction budget emergency telemetry

* Tighten compaction budget telemetry types

* Preserve compaction status notice reasons

* fix(core): account compaction tokens consistently

* fix(core): align skipped compaction token accounting
2026-07-09 02:15:55 -07:00
Saoud Rizwan 2ee18e7f0c chore(cli): release v3.0.39 2026-07-08 21:15:22 -07:00
Saoud Rizwan 0b65506a2b chore(sdk): release v0.0.59 2026-07-08 20:00:37 -07:00
Saoud Rizwan 3502608081 fix(telemetry): emit telemetry from the detached hub daemon process (#12177)
* feat(sdk): emit telemetry from the hub daemon process

The detached hub daemon hosts the LocalRuntimeHost that emits
task.conversation_turn and task.tokens for every hub-backed session
(CLI in prefer-hub mode, desktop app, connectors), but the daemon
entrypoint never created a telemetry handle - startHubWebSocketServer
received telemetry: undefined and every capture in the daemon-side
runtime was a no-op. Sessions billed normally on the backend while
reporting nothing to OTel.

- create a ConfiguredTelemetryHandle in the daemon entry and pass it to
  the websocket server and schedule runtime handlers
- identify from the cached cline account at startup and re-resolve
  periodically, since the long-lived daemon often starts before login
  or outlives an account switch
- flush and dispose the handle on graceful and fatal shutdown

* fix(sdk): flush daemon telemetry when server startup fails

If startHubWebSocketServer throws, dispose the telemetry handle before
rethrowing so failed daemon starts are visible in telemetry instead of
dying silently.

* fix(sdk): bound daemon telemetry flush and reuse settings manager

- Race dispose's flush against a 5s deadline so a hung exporter can't
  keep a crashed daemon alive holding the hub port (before this PR the
  daemon exited immediately on fatal errors; the flush must not change
  that materially).
- Construct ProviderSettingsManager once instead of every identity
  refresh; its constructor runs legacy-migration and provider
  registration side effects, and getProviderSettings re-reads the file
  per call anyway.
- Test the dispose-on-startup-failure path and the cline-hub-daemon
  platform metadata.

* fix(sdk): label daemon telemetry cline_type as hub

Review feedback from @abeatrix: daemon-hosted sessions can be triggered
by the CLI, desktop app, or connectors, so daemon-emitted events should
not share the CLI process's cline_type. Existing values are "cli" and
"VSCode Extension"; the daemon now reports "hub" (with the finer
platform=cline-hub-daemon kept as-is).
2026-07-08 19:35:26 -07:00
Saoud Rizwan ed3107f9ec Revert "docs: add Cline free models page (#12183)" (#12185)
This reverts commit 6bce48aad4.
2026-07-08 19:32:36 -07:00
Renee Huang 6bce48aad4 docs: add Cline free models page (#12183)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 19:22:32 -07:00
Bee 1e1b6af51c fix(sdk): set versioned Cline client-identity headers for Cline provider (#12182)
* fix(sdk): set versioned Cline client-identity headers for Cline provider

* address feedback

* feat: add platform metadata to client context

Include platform, platformVersion, and isMultiRoot in extension client
context for CLI, ACP, and VS Code sessions. This provides downstream
core/session logic with richer runtime information and distinguishes ACP
clients from the standard CLI client.

* lint

* clean up

* fix: resolve client host identity via HostProvider for standalone compatibility

cline-session-factory.ts is also bundled into the standalone cline-core
(JetBrains), where the 'vscode' module resolves to the generated Proxy-stub
module: vscode.env.appName and vscode.version return Proxy objects, which
would flow into X-PLATFORM/X-PLATFORM-VERSION header values and fail at
request serialization.

Resolve the identity through HostProvider.env.getHostVersion() instead —
the VS Code hostbridge returns the identical values (vscode.env.appName,
vscode.version, ClineClient.VSCode, extension version), and JetBrains'
hostbridge returns its real host values, so the standalone stops reporting
itself as the VS Code extension as a bonus. Multi-root detection goes
through HostProvider.workspace.getWorkspacePaths() for the same reason.
Both resolvers degrade gracefully (undefined/false) if the host bridge is
unavailable, in which case the header builder falls back to source-derived
values.

* Add unit test as proof

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 19:08:25 -07:00
Saoud Rizwan ee49900232 chore(greptile): update telemetry review rules for the monorepo layout (#12180)
The .greptile config was written for the pre-merge standalone cline/sdk
repo and never updated after the monorepo merge:

- the sdk-telemetry-doc-update rule enforced an Event Catalog in DOC.md,
  a file that does not exist in this repo (it now emits a false P2 on
  every PR touching core-events.ts, e.g. #12177)
- rules.md cited PR #357, apps/vscode/src/hub-daemon.ts, and
  apps/vscode/src/telemetry.ts - none of which exist here
- the 'Hub Daemon Metadata Forwarding' section described an argv-based
  metadata payload that was never implemented in this repo; replaced
  with the actual daemon-owned telemetry pattern from #12177
- the opted-out-test rule now describes the real convention: assert the
  event flows through capture (no-op for OptedOutTelemetryService), not
  captureRequired
2026-07-08 16:43:14 -07:00
Saoud Rizwan a1d5589d19 feat: allow selecting Cline free models on the ClinePass provider (#12140)
* feat(llms): include Cline free models in the cline-pass catalog

* feat(vscode): show Subscribed/Free model tabs on the ClinePass provider

* feat(cli): show Subscribed/Free sections in the ClinePass model picker

* fix(cli): drop redundant browse-all entry from ClinePass picker

* fix(cli): show only subscribed models in ClinePass onboarding picker

* feat(cli): include free models and quota explainer in ClinePass onboarding picker

* fix: shorten ClinePass free section copy

* fix(cli): strip redundant free markers from sectioned picker names

* fix: drop free from ClinePass free section copy

* fix: tighten ClinePass free section copy

* refactor: address review feedback on ClinePass free models

- single buildFeaturedModelEntries(providerId) dispatcher, builders private
- rename isClineProvider to isClineManagedProvider (includes cline-pass)
- use isClineManagedProvider in the free-model cost check
- themed tab border, pretty names on free model cards
- clearer cline-pass cost test name

* fix: address ClinePass free-model review blockers

- Stop re-sorting the cline-pass live catalog by release date in
  mergeKnownModels: free models carry OpenRouter release dates, so the
  sort could put a free model first and make it the fallback default
  when the bundled default id rotates out of the live clinePass bucket.
  Preserve the normalize-time order (pass models first) and pin it with
  an end-to-end resolveProviderConfig test.
- Add the browse-all escape to the CLI ClinePass picker when the
  clinePass bucket is empty (bundled fallback after a fetch failure),
  so a subscriber isn't left with a free-models-only picker.
- Rename ErrorRow's local isClineManagedProvider to
  isClineUsageBillingProvider: it only matches the cline provider,
  unlike the shared util of the same name that also matches cline-pass.
2026-07-08 15:08:27 -07:00
Tomás Barreiro 721fda2e99 Add ClinePass limit error (#12162)
* Add ClinePass limit error

* refactor regex

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-08 14:54:20 -07:00
Saoud Rizwan 5e78861eb5 fix(vscode): update ClinePass onboarding option copy (#12173) 2026-07-08 13:21:12 -07:00
Robin Newhouse 29798f59f3 Persist VS Code manual compaction sidecar (#11900)
* Persist VS Code manual compaction sidecar

* Fix compaction test isolation

* Address PR feedback on compaction comments

* Fix compaction test core mock hoisting

* fix(vscode): avoid compaction session rebuild

* fix(core): validate active compaction from persisted transcript

* fix(vscode): harden manual compaction sidecar flow
2026-07-08 13:18:22 -07:00
Ara 177d0eb07f Remove Cline model picker recommendation copy (#12170) 2026-07-08 12:15:53 -07:00
Bee 869a87a220 fix(core): use no-emit TypeScript config for checks (#12139)
* fix(core): use no-emit TypeScript config for checks

Update the core package TypeScript config to run checks without emitting files,
allowing broader workspace sources via the package parent rootDir. Simplify the
dev config so it only extends the main package config and avoids duplicated
compiler overrides.

* feedback

* remove dead code
2026-07-08 11:15:16 -07:00
Max 0cfd0bbe05 Fix VS Code F5 webview debug flow (#12027)
* fix vscode f5 settings

- fixed the hot module reloading issue while debugging the extension.
- also fixed issue where deb:webview task wasn't showing as complete

* fix vscode webview dev cleanup

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-07-08 10:53:41 -07:00
yanalialiuk 08f656532f docs: add Atomic Chat local provider setup guide (#11966)
* docs: add Atomic Chat local provider setup guide

Document Atomic Chat alongside Ollama and LM Studio in the local models
overview and add a dedicated provider configuration page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update overview.mdx

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-07-08 10:48:58 -07:00
Tomás Barreiro 10dece6677 Remove all ClinePass GLM 5.1 references (#12107)
* Remove all ClinePass GLM 5.1 references

* fix other references
2026-07-08 14:59:10 +02:00
Sufiyan Khan 885a2936b6 docs(authorizing): remove model-specific wording from generic setup step (#12156)
Step 4 in the IDE setup flow says 'Choose your desired Claude model'
but applies to all providers (OpenAI, Gemini, DeepSeek, local, etc.).
Drop 'Claude' to keep it provider-agnostic.
2026-07-08 21:22:33 +09:00
193 changed files with 8146 additions and 1611 deletions
-8
View File
@@ -39,14 +39,6 @@
"sdk/packages/core/src/auth/**"
],
"severity": "high"
},
{
"id": "sdk-telemetry-doc-update",
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
"scope": [
"sdk/packages/core/src/services/telemetry/core-events.ts"
],
"severity": "medium"
}
]
}
+1 -5
View File
@@ -16,13 +16,9 @@
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
},
{
"path": "DOC.md",
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
},
{
"path": "sdk/ARCHITECTURE.md",
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
},
{
"path": "sdk/AGENTS.md",
+20 -17
View File
@@ -36,8 +36,11 @@ event names. It exports:
1. Add the constant to `CORE_TELEMETRY_EVENTS`
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
3. Update the Event Catalog section in `DOC.md`
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
opt-out must use `captureRequired` and assert that explicitly.
## The Activation Funnel
@@ -82,7 +85,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
config dir.
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
The canonical pattern is in `apps/cli/src/main.ts`:
```ts
if (configDir) setClineDir(configDir);
@@ -90,18 +93,18 @@ setHomeDir(homedir());
captureCliExtensionActivated(); // <-- after dir overrides
```
## Hub Daemon Metadata Forwarding
## Hub Daemon Telemetry
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
metadata into the daemon argv so the daemon can reconstruct an equivalent
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
identifies from the cached cline account (re-resolved periodically, since the daemon often
starts before login) and flushes on every shutdown path, including startup failure.
```
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
```
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
## Auth Lifecycle Completeness
@@ -120,10 +123,10 @@ canonical examples of all four phases.
## Single Telemetry Service Per Host
On VS Code, the telemetry handle is built **once** in `activate()`
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
command, and daemon spawn payload. Do not let individual controllers construct their own
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
On VS Code, all callers go through the lazy `telemetryService` proxy in
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
use. Do not let individual controllers construct their own `ITelemetryService` — that
fragments distinct-id state, opt-out tracking, and flush ownership.
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
+7 -7
View File
@@ -68,7 +68,7 @@
"command": "bun run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
"isBackground": false,
"label": "npm: build:webview",
"dependsOn": [
"npm: protos"
@@ -89,7 +89,7 @@
"command": "bun run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
"isBackground": false,
"label": "npm: build:webview:test",
"dependsOn": [
"npm: protos"
@@ -114,16 +114,16 @@
{
"pattern": [
{
"regexp": ".",
"regexp": "^(?!)((?:.*))$",
"kind": "file",
"file": 1,
"location": 2,
"message": 3
"message": 1
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".",
"endsPattern": "."
"beginsPattern": "^Building webview for|^\\s*VITE",
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
}
}
],
+10
View File
@@ -1,5 +1,15 @@
# Cline CLI Changelog
## 3.0.39
- You can now select Cline free models on the ClinePass provider in the model picker
- Removed the retired ClinePass GLM 5.1 model
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
- `str_replace` edits now report accurate diffs
- Fixed context compaction so canonical session history is preserved
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
- Cline provider requests now send versioned client-identity headers
## 3.0.38
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.38",
"version": "3.0.39",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+19 -1
View File
@@ -511,6 +511,7 @@ export class AcpAgent implements Agent {
private async buildConfig(session: SessionState): Promise<Config> {
const cwd = session.cwd || process.cwd();
const workspaceRoot = resolveWorkspaceRoot(cwd);
// Resolve credentials: env vars take precedence, then session provider.
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
@@ -519,6 +520,7 @@ export class AcpAgent implements Agent {
providerId,
mode: session.currentMode,
});
const cliBuildInfo = getCliBuildInfo();
return {
providerId,
@@ -537,7 +539,23 @@ export class AcpAgent implements Agent {
enableAgentTeams: false,
enableTools: true,
cwd,
workspaceRoot: resolveWorkspaceRoot(cwd),
workspaceRoot,
extensionContext: {
client: {
name: "cline-acp",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
workspace: {
rootPath: workspaceRoot,
cwd,
workspaceName: cwd,
ide: "Terminal Shell",
platform: process.platform,
},
},
};
}
}
+16
View File
@@ -101,6 +101,22 @@ describe("getInstallationInfo", () => {
});
});
it("detects bun global installs from the resolved install path", () => {
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
// and realpathSync resolves through the symlink before detection runs.
const wrapperPath = createTempFile(
".bun/install/global/node_modules/cline/bin/cline",
);
process.env.CLINE_WRAPPER_PATH = wrapperPath;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
expect(getInstallationInfo("1.2.3")).toEqual({
packageManager: PackageManager.BUN,
packageName: "cline",
updateCommand: "bun add -g cline@latest",
});
});
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
delete process.env.CLINE_WRAPPER_PATH;
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
+6 -1
View File
@@ -118,7 +118,12 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
};
}
if (scriptPath.includes("/.bun/bin")) {
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
// them to ~/.bun/install/global/node_modules/..., so match both.
if (
scriptPath.includes("/.bun/bin") ||
scriptPath.includes("/.bun/install/global/")
) {
return {
packageManager: PackageManager.BUN,
packageName: DEFAULT_PACKAGE_NAME,
@@ -125,12 +125,12 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
it("uses auth material resolved by provider settings manager", async () => {
@@ -153,11 +153,11 @@ describe("buildConnectorStartRequest", () => {
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
defaultModel: "cline-pass/glm-5.2",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
expect(request.model).toBe("cline-pass/glm-5.2");
});
});
+2 -1
View File
@@ -158,8 +158,9 @@ vi.mock("./runtime/run-interactive", () => {
});
vi.mock("./utils/session", () => sessionMocks);
vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", () => {
vi.mock("@cline/core", async () => {
return {
...(await vi.importActual("@cline/core")),
resolveProviderConfig: llmMocks.resolveProviderConfig,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
+22 -3
View File
@@ -15,6 +15,7 @@ import {
getPreferredKanbanInstaller,
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
@@ -925,7 +926,18 @@ export async function runCli(): Promise<void> {
coreServer: { createUserInstructionConfigService },
resolveSystemPrompt,
runAgent,
} = await loadCliRuntimeModules();
} = await loadCliRuntimeModules();
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
// These components operate before/outside ClineCore sessions, so the
// session-scoped logger can't reach them.
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
component: "main",
});
coreServer.setSdkLogger(loggerAdapter.core);
const userInstructionService = createUserInstructionConfigService({
skills: {
@@ -1043,6 +1055,7 @@ export async function runCli(): Promise<void> {
reasoningEffort: args.reasoningEffort,
persistedReasoning: selectedProviderSettings?.reasoning,
});
const cliBuildInfo = getCliBuildInfo();
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -1052,7 +1065,7 @@ export async function runCli(): Promise<void> {
interactive: args.interactive === true,
hasPrompt: !!args.prompt?.trim(),
cwd,
});
});
const config: Config = {
providerId: provider,
@@ -1093,7 +1106,13 @@ export async function runCli(): Promise<void> {
cwd,
workspaceRoot,
extensionContext: {
client: { name: "cline-cli" },
client: {
name: "cline-cli",
version: cliBuildInfo.version,
platform: "cli",
platformVersion: cliBuildInfo.version,
isMultiRoot: false,
},
workspace: {
rootPath: workspaceRoot,
cwd,
@@ -157,6 +157,7 @@ function makeManager() {
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
updateSessionConnection: vi.fn(async () => {}),
pendingPrompts: {
update: vi.fn(),
},
@@ -814,6 +815,83 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
const manager = makeManager();
const config = {
...createConfig(),
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
};
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "hello" }] },
];
manager.readMessages.mockResolvedValue(messages);
const runtime = await makeRuntime(manager, { config });
await runtime.ensureReady();
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
providerId: "cline",
modelId: "anthropic/claude-sonnet-4.6",
apiKey: "cline-key",
}),
}),
);
config.providerId = "openai-compatible";
config.modelId = "custom-model";
config.apiKey = "new-key";
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "session-1",
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
}),
initialMessages: messages,
}),
);
});
it("updates the active session connection in place without restarting", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.updateCurrentSessionConnection({
providerId: "openai",
modelId: "codex-test",
});
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
providerId: "openai",
modelId: "codex-test",
});
expect(manager.start).toHaveBeenCalledTimes(1);
expect(runtime.getActiveSessionId()).toBe("session-1");
});
it("does not reuse the session id when restarting empty", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartEmpty();
expect(manager.start).toHaveBeenCalledTimes(2);
const secondStart = manager.start.mock.calls[1]?.[0] as {
config?: { sessionId?: string };
};
expect(secondStart?.config?.sessionId).toBeUndefined();
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
@@ -49,6 +49,9 @@ type RuntimeHooks = ReturnType<typeof createRuntimeHooks>;
type StartedSession = Awaited<ReturnType<CliCore["start"]>>;
type CurrentTurnInput = Omit<Parameters<CliCore["send"]>[0], "sessionId">;
type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
export type SessionConnectionUpdate = Parameters<
CliCore["updateSessionConnection"]
>[1];
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
@@ -210,12 +213,18 @@ export function createInteractiveSessionRuntime(input: {
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
// Restarting an old session associate with this ID,
// For continuing the same conversation, e.g. after a config change.
sessionId?: string,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const started = await manager.start({
source: SessionSource.CLI,
config: buildSessionConfig(),
config: {
...buildSessionConfig(),
...(sessionId ? { sessionId } : {}),
},
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
@@ -411,43 +420,51 @@ export function createInteractiveSessionRuntime(input: {
});
};
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
// Publish the restart as the in-flight startup. Teardown leaves a window
// with no active session, and without this barrier a concurrent
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
// reads that window as "no session" and boots an empty session that then
// races the restarted one for the active slot.
const restart = (async () => {
await stopCurrentSession();
clearActiveSession();
await startFreshSession(
messages,
sessionMetadata,
initialCompactionState,
);
})().catch((error) => {
startupError = error;
throw error;
});
startupPromise = restart;
try {
await restart;
} finally {
// Restore the pre-restart steady state (startupPromise unset) so a
// failed restart stays retryable by the next ensureReady(). A newer
// startup that already replaced the barrier is left alone.
if (startupPromise === restart) {
startupPromise = undefined;
}
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
options?: { preserveSessionId?: boolean },
): Promise<void> => {
// Config-only restarts (model/mode/account changes) continue the same
// conversation, so they must keep the session id — otherwise each
// restart mints a new session history entry for the same conversation.
const reuseSessionId = options?.preserveSessionId
? activeSessionId || undefined
: undefined;
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupError = undefined;
// Publish the restart as the in-flight startup. Teardown leaves a window
// with no active session, and without this barrier a concurrent
// ensureReady() (e.g. a message submitted right after a plan/act toggle)
// reads that window as "no session" and boots an empty session that then
// races the restarted one for the active slot.
const restart = (async () => {
await stopCurrentSession();
clearActiveSession();
await startFreshSession(
messages,
sessionMetadata,
initialCompactionState,
reuseSessionId,
);
})().catch((error) => {
startupError = error;
throw error;
});
startupPromise = restart;
try {
await restart;
} finally {
// Restore the pre-restart steady state (startupPromise unset) so a
// failed restart stays retryable by the next ensureReady(). A newer
// startup that already replaced the barrier is left alone.
if (startupPromise === restart) {
startupPromise = undefined;
}
};
}
};
const restartWithCurrentMessages = async (): Promise<void> => {
const [{ messages, status }, compactionState] = await Promise.all([
@@ -473,9 +490,24 @@ export function createInteractiveSessionRuntime(input: {
systemPrompt: compactionState?.system_prompt,
})
: undefined,
{ preserveSessionId: true },
);
};
const updateCurrentSessionConnection = async (
update: SessionConnectionUpdate,
): Promise<void> => {
await ensureReady();
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
// No live session to update; the next startup builds its config from
// the already-mutated CLI config, so nothing else is needed.
return;
}
await manager.updateSessionConnection(sessionId, update);
};
const restartEmpty = async (): Promise<void> => {
await restartWithMessages([]);
};
@@ -840,6 +872,7 @@ export function createInteractiveSessionRuntime(input: {
resetForNewSession,
restartWithMessages,
restartWithCurrentMessages,
updateCurrentSessionConnection,
resumeSession,
forkCurrentSession,
compactCurrentSession,
+153
View File
@@ -43,6 +43,15 @@ const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_PASS_LIMIT_DETAIL_MESSAGE =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const CLI_CLINE_PASS_LIMIT_MESSAGE = [
"ClinePass limit reached",
CLINE_PASS_LIMIT_DETAIL_MESSAGE,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
].join("\n");
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
@@ -65,6 +74,30 @@ vi.mock("@cline/core", () => ({
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
extractClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
const prefix = "you have reached your";
const suffix = "please try again later.";
const start = normalized.indexOf(prefix);
if (start === -1) return undefined;
const suffixStart = normalized.indexOf(suffix, start);
if (suffixStart === -1) return undefined;
const end = suffixStart + suffix.length;
if (!normalized.slice(start, end).includes("clinepass limit")) {
return undefined;
}
return text.slice(start, end);
},
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -769,6 +802,126 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("formats ClinePass limit errors with usage-based billing guidance", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLI_CLINE_PASS_LIMIT_MESSAGE,
);
});
it("does not duplicate ClinePass limit errors already displayed by agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockImplementation(async () => {
sessionEventsMocks.listener?.({
type: "error",
error: new Error(`Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`),
recoverable: false,
});
return {
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: `Error: ${CLINE_PASS_LIMIT_DETAIL_MESSAGE}`,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
};
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+72 -2
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveReasoningForModelChange } from "./run-interactive";
import { describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
import {
applyInteractiveModelChange,
resolveReasoningForModelChange,
} from "./run-interactive";
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
@@ -38,3 +42,69 @@ describe("resolveReasoningForModelChange", () => {
).toEqual({ enabled: true, effort: "medium" });
});
});
describe("applyInteractiveModelChange", () => {
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
const config = {
providerId: "openai-compatible",
modelId: "custom-model",
apiKey: "new-key",
thinking: undefined,
reasoningEffort: undefined,
} as Config;
const getProviderSettings = vi.fn(() => ({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible" as const,
protocol: "openai-chat" as const,
model: "old-model",
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
providers: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
const updateCurrentSessionConnection = vi.fn(async () => {});
await applyInteractiveModelChange({
config,
providerSettingsManager: {
getProviderSettings,
saveProviderSettings,
},
sessionRuntime: {
ensureReady,
restartWithCurrentMessages,
updateCurrentSessionConnection,
},
});
expect(saveProviderSettings).toHaveBeenCalledWith({
provider: "openai-compatible",
apiKey: "new-key",
baseUrl: "https://example.com/v1",
headers: { "X-Custom-Header": "custom-value" },
client: "openai-compatible",
protocol: "openai-chat",
model: "custom-model",
});
expect(ensureReady).toHaveBeenCalledOnce();
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
providerId: "openai-compatible",
modelId: "custom-model",
});
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
);
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
);
});
});
+50 -18
View File
@@ -82,6 +82,51 @@ export function resolveReasoningForModelChange(
return existing.reasoning;
}
export async function applyInteractiveModelChange(input: {
config: Config;
providerSettingsManager: Pick<
ProviderSettingsManager,
"getProviderSettings" | "saveProviderSettings"
>;
sessionRuntime: Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
| "ensureReady"
| "restartWithCurrentMessages"
| "updateCurrentSessionConnection"
>;
}): Promise<void> {
const { config, providerSettingsManager, sessionRuntime } = input;
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
// Provider changes affect more than the model connection: startup resolves
// the endpoint, headers, provider-specific options, tools, and plugins. Rebuild
// the runtime with the existing transcript so all of that state changes
// together. restartWithCurrentMessages preserves the session ID.
await sessionRuntime.restartWithCurrentMessages();
// A same-ID restart reuses the existing manifest. Sync its connection label
// after the fully configured runtime is live so session history reflects the
// provider/model that will handle subsequent turns.
await sessionRuntime.updateCurrentSessionConnection({
providerId: config.providerId,
modelId: config.modelId,
});
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -687,25 +732,12 @@ export async function runInteractive(
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
onModelChange: () =>
applyInteractiveModelChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
...(reasoning === undefined ? {} : { reasoning }),
});
await sessionRuntime.restartWithCurrentMessages();
},
providerSettingsManager,
sessionRuntime,
}),
onSessionRestart: async () => {
await sessionRuntime.ensureReady();
await sessionRuntime.restartEmpty();
@@ -5,9 +5,11 @@ import { useEffect, useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -419,6 +421,54 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
);
}
function ClinePassLimitErrorView(props: {
message: string;
defaultFg?: string;
terminalTheme: TerminalTheme;
}) {
const detail = getClinePassLimitDetailMessage(props.message) ?? props.message;
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">ClinePass limit reached</text>
<text fg={props.defaultFg} selectable content={detail} />
<text
fg={props.defaultFg}
selectable
content="Switch to Cline usage-based billing and retry with the Cline provider."
/>
<box flexDirection="row">
<text fg="gray">Interactive CLI: </text>
<text
fg={props.defaultFg}
selectable
content="type /model, press tab to change provider, choose Cline, then retry."
/>
</box>
<box flexDirection="row">
<text fg="gray">Headless CLI: </text>
<text fg={props.defaultFg} selectable content="rerun with " />
<code
content="--provider cline"
filetype="bash"
syntaxStyle={getSyntaxStyle(props.terminalTheme)}
selectable
/>
<text fg={props.defaultFg} selectable content="." />
</box>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -534,6 +584,15 @@ export function ChatEntryView(props: {
/>
);
}
if (isClinePassLimitErrorMessage(entry.text)) {
return (
<ClinePassLimitErrorView
message={entry.text}
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
);
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -0,0 +1,106 @@
import type {
ClineRecommendedModel,
ClineRecommendedModelsData,
} from "@cline/core";
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
export interface ClineModelPickerItem {
kind: "model";
model: ClineRecommendedModel;
tier: ClineModelPickerTier;
}
export interface ClineModelPickerBrowse {
kind: "browse";
}
export type ClineModelPickerEntry =
| ClineModelPickerItem
| ClineModelPickerBrowse;
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
ClineModelPickerTier,
string
> = {
recommended: "Recommended",
subscribed: "Subscribed",
free: "Free",
};
// Featured entries for the sectioned picker, keyed by provider: cline gets
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
export function buildFeaturedModelEntries(
providerId: string,
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
return providerId === "cline-pass"
? buildClinePassModelEntries(data)
: buildClineModelEntries(data);
}
function buildClineModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.recommended) {
entries.push({ kind: "model", model: m, tier: "recommended" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
entries.push({ kind: "browse" });
return entries;
}
// Shown under the Free section header when picking a model for ClinePass
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
"Try with limited usage, separate from ClinePass quota.";
// ClinePass shows the subscription's models plus the Cline free models — both
// providers hit the same Cline API, so free models are selectable in place
// (they ride usage billing at $0 instead of the subscription quota).
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
// the ClinePass catalog contains exactly these two buckets, so the sections
// already list every selectable model. An empty clinePass bucket means the
// fetch fell back to the bundled list (which has no pass models) — without an
// escape into the full catalog a subscriber could only pick free models, so
// browse-all comes back in that degraded mode.
function buildClinePassModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.clinePass) {
entries.push({ kind: "model", model: m, tier: "subscribed" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
if (data.clinePass.length === 0) {
entries.push({ kind: "browse" });
}
return entries;
}
// The quota explainer only makes sense in the ClinePass picker, which is the
// only picker that has a "subscribed" section
export function freeTierDescriptionFor(
entries: ClineModelPickerEntry[],
): string | undefined {
const isClinePassPicker = entries.some(
(entry) => entry.kind === "model" && entry.tier === "subscribed",
);
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
}
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
// disambiguate them from their paid twins. Inside the sectioned pickers the
// Free header already says it, so the markers are redundant — but keep them in
// flat lists (e.g. browse-all), where both variants appear side by side.
export function stripFreeMarker(displayName: string): string {
return displayName
.replace(/\s*\(free\)\s*$/i, "")
.replace(/:free$/i, "")
.trim();
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildFeaturedModelEntries,
CLINE_PASS_FREE_SECTION_DESCRIPTION,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
describe("cline model picker entries", () => {
it("builds Recommended/Free sections for the cline provider", () => {
const entries = buildFeaturedModelEntries("cline", {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1")],
});
expect(entries).toEqual([
{
kind: "model",
model: model("anthropic/claude-sonnet-5"),
tier: "recommended",
},
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
{ kind: "browse" },
]);
});
it("builds Subscribed/Free sections for the cline-pass provider", () => {
const entries = buildFeaturedModelEntries("cline-pass", {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
});
expect(entries).toEqual([
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
{
kind: "model",
model: model("cline-pass/kimi-k2.6"),
tier: "subscribed",
},
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
]);
});
it("adds the browse-all escape when the clinePass bucket is empty", () => {
// The fetch fell back to the bundled list (no pass models); the sections
// alone would leave a subscriber able to pick only free models.
const entries = buildFeaturedModelEntries("cline-pass", {
recommended: [],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [],
});
expect(entries).toEqual([
{
kind: "model",
model: model("deepseek/deepseek-v4-flash"),
tier: "free",
},
{ kind: "browse" },
]);
});
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
const data = {
recommended: [model("anthropic/claude-sonnet-5")],
free: [model("deepseek/deepseek-v4-flash")],
clinePass: [model("cline-pass/glm-5.1")],
};
expect(
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
expect(
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
).toBe(undefined);
});
it("strips redundant free markers from display names", () => {
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
"Trinity Large Preview",
);
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
});
});
@@ -1,7 +1,6 @@
// @jsxImportSource @opentui/react
import {
type ClineRecommendedModel,
type ClineRecommendedModelsData,
fetchClineRecommendedModels,
} from "@cline/core";
@@ -9,20 +8,23 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { palette } from "../../palette";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
export interface ClineModelPickerItem {
kind: "model";
model: ClineRecommendedModel;
tier: "recommended" | "free";
}
export interface ClineModelPickerBrowse {
kind: "browse";
}
export type ClineModelPickerEntry =
| ClineModelPickerItem
| ClineModelPickerBrowse;
export {
buildFeaturedModelEntries,
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerBrowse,
type ClineModelPickerEntry,
type ClineModelPickerItem,
type ClineModelPickerTier,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-entries";
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
@@ -39,12 +41,13 @@ function resolveDisplayName(
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
if (hit?.name) return stripFreeMarker(hit.name);
}
}
return modelId.includes("/")
const fallback = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function useClineRecommendedModels() {
@@ -68,20 +71,6 @@ export function useClineRecommendedModels() {
return { data, loading };
}
export function buildClineModelEntries(
data: ClineRecommendedModelsData,
): ClineModelPickerEntry[] {
const entries: ClineModelPickerEntry[] = [];
for (const m of data.recommended) {
entries.push({ kind: "model", model: m, tier: "recommended" });
}
for (const m of data.free) {
entries.push({ kind: "model", model: m, tier: "free" });
}
entries.push({ kind: "browse" });
return entries;
}
export function ClineModelPicker(props: {
entries: ClineModelPickerEntry[];
selected: number;
@@ -103,6 +92,7 @@ export function ClineModelPicker(props: {
let lastTier: string | null = null;
let isFirstHeader = true;
const rows: ReactNode[] = [];
const freeTierDescription = freeTierDescriptionFor(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -112,14 +102,20 @@ export function ClineModelPicker(props: {
if (entry.kind === "model") {
if (entry.tier !== lastTier) {
lastTier = entry.tier;
const label = entry.tier === "recommended" ? "Recommended" : "Free";
const label = CLINE_MODEL_PICKER_TIER_LABELS[entry.tier];
rows.push(
<box
key={`tier-${entry.tier}`}
paddingX={1}
marginTop={isFirstHeader ? 0 : 1}
flexDirection="column"
>
<text fg="gray">{label}</text>
{entry.tier === "free" && freeTierDescription && (
<text fg="gray">
<em>{freeTierDescription}</em>
</text>
)}
</box>,
);
isFirstHeader = false;
@@ -3,7 +3,12 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import type { ClineModelPickerEntry } from "./cline-model-picker";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
freeTierDescriptionFor,
stripFreeMarker,
} from "./cline-model-picker";
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
import { ProviderRow } from "./provider-row";
@@ -29,12 +34,13 @@ function resolveDisplayName(
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
if (hit?.name) return stripFreeMarker(hit.name);
}
}
return modelId.includes("/")
const fallback = modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
return stripFreeMarker(fallback);
}
export function ClineModelSelectorContent(
@@ -62,11 +68,13 @@ export function ClineModelSelectorContent(
key: string;
kind: "header" | "model" | "browse";
label: string;
description?: string;
tags: string[];
isCurrent: boolean;
entryIndex: number;
}[] = [];
let lastTier: string | null = null;
const freeTierDescription = freeTierDescriptionFor(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry) continue;
@@ -76,7 +84,9 @@ export function ClineModelSelectorContent(
rows.push({
key: `tier-${entry.tier}`,
kind: "header",
label: entry.tier === "recommended" ? "Recommended" : "Free",
label: CLINE_MODEL_PICKER_TIER_LABELS[entry.tier],
description:
entry.tier === "free" ? freeTierDescription : undefined,
tags: [],
isCurrent: false,
entryIndex: -1,
@@ -156,8 +166,18 @@ export function ClineModelSelectorContent(
if (row.kind === "header") {
const isFirst = idx === 0;
return (
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
<box
key={row.key}
paddingX={1}
marginTop={isFirst ? 0 : 1}
flexDirection="column"
>
<text fg="gray">{row.label}</text>
{row.description && (
<text fg="gray">
<em>{row.description}</em>
</text>
)}
</box>
);
}
+12 -3
View File
@@ -26,7 +26,7 @@ import {
ProviderPickerContent,
UseExistingOrReconfigureContent,
} from "../components/dialogs/provider-picker";
import { buildClineModelEntries } from "../components/model-selector/cline-model-picker";
import { buildFeaturedModelEntries } from "../components/model-selector/cline-model-picker";
import {
BROWSE_ALL_ACTION,
ClineModelSelectorDialogContent,
@@ -341,7 +341,13 @@ export function useModelSelector(opts: {
continue;
}
if (config.providerId === "cline") {
if (
config.providerId === "cline" ||
config.providerId === "cline-pass"
) {
// ClinePass gets the same sectioned picker with Subscribed/Free
// sections — free models are selectable while staying on ClinePass
const featuredProviderId = config.providerId;
const clineResult = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
@@ -351,7 +357,10 @@ export function useModelSelector(opts: {
currentProviderName={providerDisplayName}
knownModels={config.knownModels as Record<string, unknown>}
loadEntries={async () =>
buildClineModelEntries(await fetchClineRecommendedModels())
buildFeaturedModelEntries(
featuredProviderId,
await fetchClineRecommendedModels(),
)
}
/>
),
+36 -16
View File
@@ -29,7 +29,7 @@ import {
loadIndividualSubscriptionPlansFromProviderSettings,
} from "../../cline-account";
import {
buildClineModelEntries,
buildFeaturedModelEntries,
type ClineModelPickerEntry,
useClineRecommendedModels,
} from "../../components/model-selector/cline-model-picker";
@@ -206,11 +206,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const modelList = useSearchableList(modelItems, createCustomModelItem);
// Cline featured model picker
// Cline featured model picker (ClinePass gets Subscribed/Free sections)
const recommended = useClineRecommendedModels();
const clineEntries: ClineModelPickerEntry[] = useMemo(
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
[recommended.data],
() =>
recommended.data
? buildFeaturedModelEntries(activeProviderId, recommended.data)
: [],
[recommended.data, activeProviderId],
);
const [clineModelSelected, setClineModelSelected] = useState(0);
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
@@ -221,20 +224,37 @@ export function useOnboardingController(props: OnboardingControllerProps) {
>(undefined);
useEffect(() => {
getLocalProviderModels("cline")
.then(({ models }) => {
const ids = new Set<string>();
for (const m of models) {
// The featured picker serves both cline and cline-pass, so pool reasoning
// support and display names from both catalogs
void Promise.allSettled(
["cline", "cline-pass"].map((providerId) =>
getLocalProviderModels(providerId),
),
).then((results) => {
const ids = new Set<string>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const m of result.value.models) {
if (m.supportsReasoning) ids.add(m.id);
}
setClineModelReasoningIds(ids);
})
.catch(() => {});
resolveProviderConfig("cline")
.then((resolved) => {
if (resolved?.knownModels) setClineKnownModels(resolved.knownModels);
})
.catch(() => {});
}
setClineModelReasoningIds(ids);
});
void Promise.allSettled(
["cline", "cline-pass"].map((providerId) =>
resolveProviderConfig(providerId),
),
).then((results) => {
const merged: Record<string, unknown> = {};
for (const result of results) {
if (result.status === "fulfilled" && result.value?.knownModels) {
Object.assign(merged, result.value.knownModels);
}
}
if (Object.keys(merged).length > 0) {
setClineKnownModels(merged);
}
});
}, []);
// Thinking level
@@ -135,9 +135,9 @@ describe("onboarding model helpers", () => {
expect(getOAuthProviderLabel("oca")).toBe("oca");
});
it("uses the featured Cline model picker only for the Cline provider", () => {
it("uses the featured Cline model picker for the Cline and ClinePass providers", () => {
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
+2 -1
View File
@@ -207,5 +207,6 @@ export function getOAuthProviderLabel(providerId: string): string {
}
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
return providerId === "cline";
// ClinePass uses the featured picker too, with Subscribed/Free sections
return providerId === "cline" || providerId === "cline-pass";
}
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -46,4 +49,22 @@ describe("cline-pass-errors", () => {
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
const raw =
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
const detail =
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClinePassLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Switch to Cline usage-based billing",
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
});
+41
View File
@@ -1,10 +1,13 @@
import {
type ClineSubscriptionPlan,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
isClinePassLimitError,
isClinePassLimitMessage,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
@@ -24,6 +27,18 @@ export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
export function getCliClinePassLimitMessage(message: string): string {
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
const lines = [
"ClinePass limit reached",
detail,
"Switch to Cline usage-based billing and retry with the Cline provider.",
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
"Headless CLI: rerun with --provider cline.",
];
return lines.filter((line) => line.trim().length > 0).join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -78,6 +93,27 @@ export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
);
}
export function getClinePassLimitDetailMessage(
error: unknown,
): string | undefined {
return extractClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
export function isClinePassLimitErrorMessage(error: unknown): boolean {
if (isClinePassLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClinePassLimitError" ||
isClinePassLimitMessage(error.message)
);
}
return typeof error === "string" && isClinePassLimitMessage(error);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
@@ -85,6 +121,11 @@ export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (isClinePassLimitErrorMessage(error)) {
return getCliClinePassLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (error instanceof Error) {
return error.message;
}
+63 -1
View File
@@ -1,19 +1,64 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleEvent, handleTeamEvent } from "./events";
import {
handleEvent,
handleTeamEvent,
resolveStatusNoticeLabel,
} from "./events";
import { setCurrentOutputMode } from "./output";
import type { Config } from "./types";
describe("resolveStatusNoticeLabel", () => {
it("maps compaction status reasons to stable labels", () => {
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "auto-compacting",
reason: "auto_compaction",
} as AgentEvent),
).toBe("auto-compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "manual",
reason: "manual_compaction",
} as AgentEvent),
).toBe("compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction-budget-adjusted",
reason: "compaction_budget_emergency",
} as AgentEvent),
).toBe("context budget adjusted");
});
});
describe("handleEvent text formatting", () => {
let output = "";
let errorOutput = "";
beforeEach(() => {
output = "";
errorOutput = "";
setCurrentOutputMode("text");
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
output += String(chunk);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
errorOutput += String(chunk);
return true;
});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
errorOutput += `${args.map(String).join(" ")}\n`;
});
});
it("adds a ⎿ before text that follows a tool block", () => {
@@ -160,6 +205,23 @@ describe("handleEvent text formatting", () => {
expect(output).toContain("── aborted (2 iterations) ──");
});
it("formats ClinePass limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("ClinePass limit reached");
expect(errorOutput).toContain("Switch to Cline usage-based billing");
expect(errorOutput).toContain("--provider cline");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+9 -3
View File
@@ -1,4 +1,5 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { formatCliErrorMessage } from "./cline-pass-errors";
import { formatToolInput, formatToolOutput, truncate } from "./helpers";
import {
c,
@@ -27,8 +28,13 @@ export function resolveStatusNoticeLabel(
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
if (event.reason === "auto_compaction") {
return "auto-compacting";
switch (event.reason) {
case "auto_compaction":
return "auto-compacting";
case "manual_compaction":
return "compacting";
case "compaction_budget_emergency":
return "context budget adjusted";
}
return event.message.trim() || undefined;
}
@@ -176,7 +182,7 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(event.error.message);
writeErr(formatCliErrorMessage(event.error));
}
break;
case "notice":
@@ -53,6 +53,37 @@ describe("shouldZeroClineFreeModelCost", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("zeros cost of free models selected on the cline-pass provider", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
// subscription (cline-pass/...) models are not in the free bucket
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "cline-pass/glm-5.1",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
+3 -1
View File
@@ -73,7 +73,9 @@ function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
// Free models are also selectable on ClinePass — they ride usage billing at $0
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
+2 -1
View File
@@ -24,7 +24,8 @@
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@fontsource/azeret-mono": "^5.2.9",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
@@ -179,58 +180,43 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
const updates: SessionConnectionUpdate = {};
// Coerce the untrusted webview JSON (snake_case aliases, blank strings)
// into typed fields; the thinking/reasoning transition rules live in the
// shared @cline/core builder.
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (providerId) {
updates.providerId = providerId;
}
const modelId = String(config.model ?? config.modelId ?? "").trim();
if (modelId) {
updates.modelId = modelId;
}
const apiKey =
const rawApiKey =
typeof config.apiKey === "string"
? config.apiKey.trim()
: typeof config.api_key === "string"
? config.api_key.trim()
: undefined;
if (apiKey) {
updates.apiKey = apiKey;
}
if (typeof config.baseUrl === "string" && config.baseUrl.trim()) {
updates.baseUrl = config.baseUrl.trim();
}
if (config.headers && typeof config.headers === "object") {
updates.headers = config.headers as Record<string, string>;
}
if (config.providerConfig && typeof config.providerConfig === "object") {
updates.providerConfig =
config.providerConfig as SessionConnectionUpdate["providerConfig"];
}
if (thinking === false) {
updates.thinking = false;
updates.reasoningEffort = null;
updates.thinkingBudgetTokens = null;
return updates;
}
if (thinking === true) {
updates.thinking = true;
}
if (reasoningEffort) {
updates.thinking = true;
updates.reasoningEffort = reasoningEffort;
}
if (thinkingBudgetTokens !== undefined) {
updates.thinking = true;
updates.thinkingBudgetTokens = thinkingBudgetTokens;
}
return updates;
const baseUrl =
typeof config.baseUrl === "string" ? config.baseUrl.trim() : undefined;
const reasoningEffort = readReasoningEffort(config.reasoningEffort);
const thinkingBudgetTokens = readPositiveInteger(
config.thinkingBudgetTokens ?? config.thinking_budget_tokens,
);
return buildConnectionUpdate({
...(providerId ? { providerId } : {}),
...(modelId ? { modelId } : {}),
...(rawApiKey ? { apiKey: rawApiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
...(config.headers && typeof config.headers === "object"
? { headers: config.headers as Record<string, string> }
: {}),
...(config.providerConfig && typeof config.providerConfig === "object"
? {
providerConfig:
config.providerConfig as SessionConnectionUpdate["providerConfig"],
}
: {}),
...(typeof config.thinking === "boolean"
? { thinking: config.thinking }
: {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
});
}
async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
+3 -1
View File
@@ -694,7 +694,9 @@ export async function initializeSessionManager(
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(`code-sidecar:${process.pid}:${randomUUID()}`),
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
const sessionManager = await ClineCore.create({
+5 -3
View File
@@ -5,7 +5,7 @@ import {
} from "./context";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { BunRuntime, SIDECAR_MODE, SIDECAR_PORT } from "./types";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -59,8 +59,10 @@ async function main() {
const { port } = startServer(ctx, SIDECAR_PORT, shutdown);
const endpoint = `http://127.0.0.1:${port}`;
const wsEndpoint = `ws://127.0.0.1:${port}/transport`;
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
const endpoint = `http://${dialHost}:${port}`;
const wsEndpoint = `ws://${dialHost}:${port}/transport`;
process.stdout.write(
`${JSON.stringify({
type: "ready",
+10 -1
View File
@@ -4,6 +4,7 @@ import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import {
BunRuntime,
SIDECAR_HOST,
SIDECAR_MODE,
SIDECAR_PORT,
type SidecarContext,
@@ -15,12 +16,20 @@ type SidecarServer = {
upgrade(req: Request): boolean;
};
// Comma-separated extra origins (e.g. a dev server on a nonstandard port when
// the sidecar runs inside a container). Origin validation itself stays on.
const EXTRA_TRUSTED_ORIGINS = (process.env.CLINE_SIDECAR_TRUSTED_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
const TRUSTED_BROWSER_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
"http://localhost:3125",
"http://127.0.0.1:3125",
...EXTRA_TRUSTED_ORIGINS,
]);
const JSON_HEADERS = {
@@ -115,7 +124,7 @@ export function startServer(
for (const candidate of candidates) {
try {
server = BunRuntime.serve({
hostname: "127.0.0.1",
hostname: SIDECAR_HOST,
port: candidate,
fetch: createFetchHandler(ctx, onShutdown),
websocket: createWebSocketHandler(ctx),
@@ -115,4 +115,8 @@ export type BunRuntimeApi = {
export const BunRuntime = (globalThis as { Bun?: BunRuntimeApi }).Bun;
export const SIDECAR_PORT = Number(process.env.CLINE_SIDECAR_PORT) || 3126;
// Loopback-only by default. Set CLINE_SIDECAR_HOST=0.0.0.0 to accept
// connections from outside the local host (e.g. Docker port publishing).
export const SIDECAR_HOST =
process.env.CLINE_SIDECAR_HOST?.trim() || "127.0.0.1";
export const SIDECAR_MODE = "sidecar";
+170 -41
View File
@@ -1,22 +1,21 @@
@import "@fontsource-variable/geist";
@import "@fontsource-variable/schibsted-grotesk";
@import "@fontsource/azeret-mono/latin.css";
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--font-geist-sans: "Geist Variable";
--font-geist-mono:
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
monospace;
--font-desktop-sans: "Schibsted Grotesk Variable";
--font-desktop-mono: "Azeret Mono";
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--primary: oklch(0.75 0.12 165);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -45,42 +44,102 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.398 0.195 277.366);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.75 0.12 165);
--primary-foreground: oklch(0.962 0.018 272.314);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.962 0.018 272.314);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--font-sans: var(--font-geist-sans), "Geist Fallback";
--font-mono: var(--font-geist-mono), "Geist Mono Fallback";
--font-sans: var(--font-desktop-sans), sans-serif;
--font-mono:
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
"Liberation Mono", monospace;
--font-weight-normal: 480;
--font-weight-medium: 560;
--font-weight-semibold: 640;
--font-weight-bold: 640;
--text-step-1: 12px;
--text-step-1--line-height: 16px;
--text-step-1--letter-spacing: 0.0025em;
--text-step-2: 14px;
--text-step-2--line-height: 20px;
--text-step-2--letter-spacing: 0em;
--text-step-3: 16px;
--text-step-3--line-height: 24px;
--text-step-3--letter-spacing: 0em;
--text-step-4: 18px;
--text-step-4--line-height: 26px;
--text-step-4--letter-spacing: -0.0025em;
--text-step-5: 20px;
--text-step-5--line-height: 28px;
--text-step-5--letter-spacing: -0.005em;
--text-step-6: 24px;
--text-step-6--line-height: 30px;
--text-step-6--letter-spacing: -0.00625em;
--text-step-7: 28px;
--text-step-7--line-height: 36px;
--text-step-7--letter-spacing: -0.0075em;
--text-step-8: 35px;
--text-step-8--line-height: 40px;
--text-step-8--letter-spacing: -0.01em;
--text-step-9: 60px;
--text-step-9--line-height: 60px;
--text-step-9--letter-spacing: -0.025em;
--text-xs: var(--text-step-1);
--text-xs--line-height: var(--text-step-1--line-height);
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
--text-sm: var(--text-step-2);
--text-sm--line-height: var(--text-step-2--line-height);
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
--text-base: var(--text-step-3);
--text-base--line-height: var(--text-step-3--line-height);
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
--text-lg: var(--text-step-4);
--text-lg--line-height: var(--text-step-4--line-height);
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
--text-xl: var(--text-step-5);
--text-xl--line-height: var(--text-step-5--line-height);
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
--text-2xl: var(--text-step-6);
--text-2xl--line-height: var(--text-step-6--line-height);
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
--text-3xl: var(--text-step-7);
--text-3xl--line-height: var(--text-step-7--line-height);
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
--text-4xl: var(--text-step-8);
--text-4xl--line-height: var(--text-step-8--line-height);
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
--text-6xl: var(--text-step-9);
--text-6xl--line-height: var(--text-step-9--line-height);
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -133,7 +192,7 @@
@apply border-border outline-ring/50;
}
body {
@apply m-0 bg-background text-foreground;
@apply m-0 bg-background text-base font-normal text-foreground;
}
}
@@ -206,3 +265,73 @@
::selection {
background: oklch(0.75 0.12 165 / 0.25);
}
/* Aurora background (components/ui/aurora-bg.tsx) */
@keyframes aurora-drift {
0%,
100% {
transform: translate(0, 0) rotate(0deg) scale(1);
}
25% {
transform: translate(14%, -10%) rotate(18deg) scale(1.25);
}
50% {
transform: translate(-4%, 6%) rotate(-6deg) scale(1.05);
}
75% {
transform: translate(-12%, -4%) rotate(-16deg) scale(0.9);
}
}
/* Curtain ribbons: sway side-to-side while skewing and stretching, like an
aurora curtain rippling. Ribbons are bottom-anchored (transform-origin
bottom), so skew/scale fan out from the horizon. */
@keyframes aurora-wave {
0%,
100% {
transform: translateX(0) skewX(0deg) scaleY(1);
opacity: 0.7;
}
20% {
transform: translateX(4%) skewX(8deg) scaleY(1.15);
opacity: 1;
}
45% {
transform: translateX(-3%) skewX(-10deg) scaleY(0.9);
opacity: 0.55;
}
70% {
transform: translateX(5%) skewX(12deg) scaleY(1.25);
opacity: 0.9;
}
}
/* Traveling wave: a 200%-wide striped sheet slides left by half its width,
looping seamlessly, while bobbing vertically — bands visibly roll across. */
@keyframes aurora-flow {
0% {
transform: translateX(0) translateY(0) skewX(-6deg);
}
25% {
transform: translateX(-12.5%) translateY(-4%) skewX(4deg);
}
50% {
transform: translateX(-25%) translateY(2%) skewX(-3deg);
}
75% {
transform: translateX(-37.5%) translateY(-5%) skewX(6deg);
}
100% {
transform: translateX(-50%) translateY(0) skewX(-6deg);
}
}
@keyframes aurora-twinkle {
0%,
100% {
opacity: 0.15;
}
50% {
opacity: 1;
}
}
@@ -826,6 +826,8 @@ function ChatThreadPane({
const displayedIsSwitching = hideDeletedSessionUi
? false
: isHydratingSession;
const isWelcomeState =
displayedMessages.length === 0 && !displayedIsSwitching;
const handleRenameTitle = useCallback(
async (nextTitle: string) => {
@@ -934,6 +936,7 @@ function ChatThreadPane({
}}
onRenameTitle={handleRenameTitle}
renamingTitle={renamingSession}
showSessionActions={!isWelcomeState}
status={status}
title={threadTitle}
/>
@@ -24,6 +24,7 @@ type AgentHeaderProps = {
canDeleteSession?: boolean;
deletingSession?: boolean;
onOpenDiff?: () => void;
showSessionActions?: boolean;
status?: ChatSessionStatus;
diff?: {
additions: number;
@@ -41,6 +42,7 @@ export function AgentHeader({
canDeleteSession,
deletingSession,
onOpenDiff,
showSessionActions = true,
status,
diff,
}: AgentHeaderProps) {
@@ -164,34 +166,35 @@ export function AgentHeader({
</DropdownMenu>
</div>
{/* Right: actions */}
<div className="flex items-center gap-2">
{/* DIFF */}
<Button
className={cn(
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
hasChanges ? "hover:bg-secondary/80" : "cursor-default opacity-60",
)}
disabled={!hasChanges}
id="diff-stats"
onClick={() => onOpenDiff?.()}
size="sm"
type="button"
variant="secondary"
>
<span className="text-chart-2">+{additions}</span>
<span className="text-destructive">-{deletions}</span>
</Button>
{/* New Chat Button */}
<Button
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onNewThread?.()}
size="icon-sm"
variant="ghost"
>
<Plus />
</Button>
</div>
{showSessionActions ? (
<div className="flex items-center gap-2">
<Button
className={cn(
"flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-mono transition-colors",
hasChanges
? "hover:bg-secondary/80"
: "cursor-default opacity-60",
)}
disabled={!hasChanges}
id="diff-stats"
onClick={() => onOpenDiff?.()}
size="sm"
type="button"
variant="secondary"
>
<span className="text-chart-2">+{additions}</span>
<span className="text-destructive">-{deletions}</span>
</Button>
<Button
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onNewThread?.()}
size="icon-sm"
variant="ghost"
>
<Plus />
</Button>
</div>
) : null}
</header>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useMemo } from "react";
interface Star {
left: string;
top: string;
size: number;
delay: string;
duration: string;
opacity: number;
}
// Big blurred gradient blobs that slowly drift/rotate to fake an aurora.
// Each entry is [positionClasses, gradient, animationDuration, animationDelay].
const BLOBS: Array<[string, string, string, string]> = [
[
"left-[-20%] bottom-[-40%] w-[70%] h-[80%]",
"radial-gradient(ellipse at center, oklch(0.55 0.2 278 / 0.55), transparent 70%)",
"16s",
"0s",
],
[
"left-[25%] bottom-[-50%] w-[60%] h-[90%]",
"radial-gradient(ellipse at center, oklch(0.65 0.19 200 / 0.4), transparent 70%)",
"22s",
"-6s",
],
[
"right-[-15%] bottom-[-40%] w-[65%] h-[85%]",
"radial-gradient(ellipse at center, oklch(0.6 0.18 310 / 0.5), transparent 70%)",
"19s",
"-12s",
],
[
"left-[10%] bottom-[-30%] w-[80%] h-[60%]",
"radial-gradient(ellipse at center, oklch(0.75 0.13 340 / 0.35), transparent 70%)",
"26s",
"-3s",
],
];
/**
* A decorative aurora background built entirely from CSS: blurred gradient
* blobs drifting on keyframe animations, plus twinkling star dots. No canvas,
* no WebGL, no per-frame JS. Absolutely positioned to fill its nearest
* positioned parent; pointer events pass through.
*
* Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css.
*/
export function AuroraBackground({ starCount = 90 }: { starCount?: number }) {
// Random star field, generated once per mount.
const stars = useMemo<Star[]>(
() =>
Array.from({ length: starCount }, () => {
// Squared skew biases stars toward the bottom, where the glow lives.
const r = Math.random();
return {
left: `${Math.random() * 100}%`,
top: `${100 - (1 - r * r) * 45}%`,
size: Math.random() < 0.15 ? 3 : Math.random() < 0.5 ? 2 : 1,
delay: `${Math.random() * 4}s`,
duration: `${1.5 + Math.random() * 3.5}s`,
opacity: 0.3 + Math.random() * 0.6,
};
}),
[starCount],
);
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{BLOBS.map(([position, gradient, duration, delay], idx) => (
<div
key={`blob${idx}`}
className={`absolute blur-3xl animate-[aurora-drift_linear_infinite] ${position}`}
style={{
background: gradient,
animationDuration: duration,
animationDelay: delay,
}}
/>
))}
{stars.map((s, idx) => (
<span
key={`star${idx}`}
className="absolute rounded-none bg-[#b8f3ee] animate-[aurora-twinkle_ease-in-out_infinite]"
style={{
left: s.left,
top: s.top,
width: s.size,
height: s.size,
opacity: s.opacity,
animationDelay: s.delay,
animationDuration: s.duration,
}}
/>
))}
</div>
);
}
@@ -376,7 +376,7 @@ function ChatMessagesImpl({
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
ref={scrollAreaRef}
>
<div className="relative mx-auto w-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
<div className="relative mx-auto w-full h-full min-w-0 max-w-full overflow-x-hidden px-6 py-6">
{showIdleDetails ? (
<WelcomeScreen
provider={provider}
@@ -2,6 +2,7 @@
import { Check, FolderOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AuroraBackground } from "@/components/ui/aurora-bg";
import {
Command,
CommandEmpty,
@@ -105,6 +106,7 @@ export function WelcomeScreen({
return (
<div className="flex flex-1 flex-col items-center overflow-hidden bg-background">
<AuroraBackground />
<div className="relative z-10 flex w-full max-w-3xl flex-1 flex-col items-center px-6 py-12">
<div className="mb-8 flex flex-col items-center">
<h1 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground">
@@ -59,7 +59,7 @@ type InstalledStatusState = "loading" | "ready";
const INSTALL_TIMEOUT_MS = 300_000;
const CODE_FONT_STYLE: CSSProperties = {
fontFamily:
'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
'"Azeret Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
};
const primitivePageDetails = {
@@ -35,8 +35,9 @@ let resolvedEndpointCache: string | null = null;
* or an integration test harness.
* 2. Tauri `get_desktop_backend_endpoint` command — used when running inside
* the full Tauri app shell.
* 3. Fallback to `ws://127.0.0.1:3126/transport` — the sidecar's default port
* when running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
* 3. `NEXT_PUBLIC_SIDECAR_WS_ENDPOINT` (inlined at build time), then fallback
* to `ws://127.0.0.1:3126/transport` — the sidecar's default port when
* running in plain web/dev mode (`bun run dev:sidecar` + `bun run dev:web`).
*/
export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
if (resolvedEndpointCache) return resolvedEndpointCache;
@@ -64,8 +65,10 @@ export async function resolveDesktopBackendWsEndpoint(): Promise<string> {
throw new Error("Tauri returned an empty desktop backend endpoint");
}
// 3. Default sidecar port for local dev mode without the Tauri bridge.
resolvedEndpointCache = "ws://127.0.0.1:3126/transport";
// 3. Env override, then default sidecar port for local dev mode without
// the Tauri bridge.
const envEndpoint = process.env.NEXT_PUBLIC_SIDECAR_WS_ENDPOINT?.trim();
resolvedEndpointCache = envEndpoint || "ws://127.0.0.1:3126/transport";
return resolvedEndpointCache;
}
@@ -401,7 +401,11 @@ function parseEditorFileDiff(event: SessionHookEvent): SessionFileDiff | null {
};
}
if (command === "create" || command === "insert" || command === "str_replace") {
if (
command === "create" ||
command === "insert" ||
command === "str_replace"
) {
const newContent =
toStringValue(input.new_text) ??
toStringValue(input.file_text) ??
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -11,6 +11,10 @@ const nextConfig = {
turbopack: {
root: workspaceRoot,
},
// Dev-only: Next blocks HMR/font/dev-resource requests from origins that
// don't match the dev server's own hostname. Both loopback spellings are
// legitimate ways to reach a local or port-forwarded dev server.
allowedDevOrigins: ["localhost", "127.0.0.1"],
reactStrictMode: true,
typescript: {
ignoreBuildErrors: true,
@@ -1,9 +1,13 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@fontsource-variable/schibsted-grotesk";
@import "@fontsource/azeret-mono/latin.css";
@custom-variant dark (&:is(.dark *));
:root {
--font-desktop-sans: "Schibsted Grotesk Variable";
--font-desktop-mono: "Azeret Mono";
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -75,8 +79,68 @@
}
@theme inline {
--font-sans: "Geist", "Geist Fallback";
--font-mono: "Geist Mono", "Geist Mono Fallback";
--font-sans: var(--font-desktop-sans), sans-serif;
--font-mono:
var(--font-desktop-mono), ui-monospace, "SFMono-Regular", Menlo, Consolas,
"Liberation Mono", monospace;
--font-weight-normal: 480;
--font-weight-medium: 560;
--font-weight-semibold: 640;
--font-weight-bold: 640;
--text-step-1: 12px;
--text-step-1--line-height: 16px;
--text-step-1--letter-spacing: 0.0025em;
--text-step-2: 14px;
--text-step-2--line-height: 20px;
--text-step-2--letter-spacing: 0em;
--text-step-3: 16px;
--text-step-3--line-height: 24px;
--text-step-3--letter-spacing: 0em;
--text-step-4: 18px;
--text-step-4--line-height: 26px;
--text-step-4--letter-spacing: -0.0025em;
--text-step-5: 20px;
--text-step-5--line-height: 28px;
--text-step-5--letter-spacing: -0.005em;
--text-step-6: 24px;
--text-step-6--line-height: 30px;
--text-step-6--letter-spacing: -0.00625em;
--text-step-7: 28px;
--text-step-7--line-height: 36px;
--text-step-7--letter-spacing: -0.0075em;
--text-step-8: 35px;
--text-step-8--line-height: 40px;
--text-step-8--letter-spacing: -0.01em;
--text-step-9: 60px;
--text-step-9--line-height: 60px;
--text-step-9--letter-spacing: -0.025em;
--text-xs: var(--text-step-1);
--text-xs--line-height: var(--text-step-1--line-height);
--text-xs--letter-spacing: var(--text-step-1--letter-spacing);
--text-sm: var(--text-step-2);
--text-sm--line-height: var(--text-step-2--line-height);
--text-sm--letter-spacing: var(--text-step-2--letter-spacing);
--text-base: var(--text-step-3);
--text-base--line-height: var(--text-step-3--line-height);
--text-base--letter-spacing: var(--text-step-3--letter-spacing);
--text-lg: var(--text-step-4);
--text-lg--line-height: var(--text-step-4--line-height);
--text-lg--letter-spacing: var(--text-step-4--letter-spacing);
--text-xl: var(--text-step-5);
--text-xl--line-height: var(--text-step-5--line-height);
--text-xl--letter-spacing: var(--text-step-5--letter-spacing);
--text-2xl: var(--text-step-6);
--text-2xl--line-height: var(--text-step-6--line-height);
--text-2xl--letter-spacing: var(--text-step-6--letter-spacing);
--text-3xl: var(--text-step-7);
--text-3xl--line-height: var(--text-step-7--line-height);
--text-3xl--letter-spacing: var(--text-step-7--letter-spacing);
--text-4xl: var(--text-step-8);
--text-4xl--line-height: var(--text-step-8--line-height);
--text-4xl--letter-spacing: var(--text-step-8--letter-spacing);
--text-6xl: var(--text-step-9);
--text-6xl--line-height: var(--text-step-9--line-height);
--text-6xl--letter-spacing: var(--text-step-9--letter-spacing);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -120,6 +184,6 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-base font-normal text-foreground;
}
}
-35
View File
@@ -1,6 +1,5 @@
import fs from "node:fs"
import path from "node:path"
import { createRequire } from "node:module"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
@@ -179,39 +178,6 @@ const e2eBuildConfig = {
plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin],
}
/**
* Copies the plugin sandbox bootstrap from the built @cline/core package into
* the extension's dist directory. The bootstrap runs in an isolated child
* process spawned by SubprocessSandbox and must be a separate file — it cannot
* be inlined into the main bundle. resolveBootstrap() (bundled into
* extension.js) searches for it at dist/extensions/plugin-sandbox-bootstrap.js.
*
* The bootstrap has external runtime dependencies (jiti for TypeScript
* transpilation, @cline/shared) that it resolves via Node's standard module
* resolution from its on-disk location. Both must be direct dependencies of
* the extension so they are present in node_modules and resolvable from
* dist/extensions/. The CLI build performs the same copy in apps/cli/bun.mts.
*/
function copyPluginSandboxBootstrap() {
if (e2eBuild) return
const projectRequire = createRequire(import.meta.url)
let corePackageDir
try {
corePackageDir = path.dirname(projectRequire.resolve("@cline/core/package.json"))
} catch {
console.warn("[esbuild] @cline/core not found — skipping plugin sandbox bootstrap copy")
return
}
const bootstrapSrc = path.join(corePackageDir, "dist", "extensions", "plugin-sandbox-bootstrap.js")
if (!fs.existsSync(bootstrapSrc)) {
console.warn(`[esbuild] plugin-sandbox-bootstrap.js not found at ${bootstrapSrc} — build @cline/core first`)
return
}
const bootstrapDest = path.join(__dirname, destDir, "extensions", "plugin-sandbox-bootstrap.js")
fs.mkdirSync(path.dirname(bootstrapDest), { recursive: true })
fs.copyFileSync(bootstrapSrc, bootstrapDest)
}
async function main() {
const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig
const extensionCtx = await esbuild.context(config)
@@ -221,7 +187,6 @@ async function main() {
await extensionCtx.rebuild()
await extensionCtx.dispose()
}
copyPluginSandboxBootstrap()
}
main().catch((e) => {
+1 -3
View File
@@ -394,7 +394,7 @@
"test:e2e:optimal": "bun run test:e2e:build && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
"install:all": "bun install",
"dev:webview": "cd webview-ui && bun run dev",
"dev:webview": "node scripts/clean-webview-vite-cache.mjs && cd webview-ui && bun run dev",
"build:webview": "bun run protos && cd webview-ui && bun run build",
"test:webview": "cd webview-ui && bun run test",
"publish:marketplace": "node scripts/publish-marketplace.mjs",
@@ -467,7 +467,6 @@
"@cline/agents": "workspace:*",
"@cline/core": "workspace:*",
"@cline/llms": "workspace:*",
"@cline/sdk": "workspace:*",
"@cline/shared": "workspace:*",
"@google/genai": "^1.30.0",
"@grpc/grpc-js": "^1.9.15",
@@ -520,7 +519,6 @@
"ignore": "^7.0.3",
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"jiti": "^2.7.0",
"js-yaml": "^4.1.1",
"jschardet": "^3.1.4",
"json5": "^2.2.3",
+17
View File
@@ -10,6 +10,23 @@ option java_package = "bot.cline.proto";
// SlashService provides methods for managing slash commands
service SlashService {
// Sends button click message
rpc reportBug(StringRequest) returns (Empty);
rpc condense(StringRequest) returns (Empty);
// Get available slash commands for autocomplete (used by CLI)
rpc getAvailableSlashCommands(EmptyRequest) returns (SlashCommandsResponse);
}
// Slash command definition for autocomplete
message SlashCommandInfo {
string name = 1; // Command name without slash, e.g., "newtask", "smol"
string description = 2; // Human-readable description
string section = 3; // "default", "custom", or "cli"
bool cli_compatible = 4; // false for VS Code-only commands
}
// Response containing all available slash commands
message SlashCommandsResponse {
repeated SlashCommandInfo commands = 1;
}
+1
View File
@@ -30,6 +30,7 @@ enum ClineAsk {
USE_MCP_SERVER = 11;
NEW_TASK = 12;
CONDENSE = 13;
REPORT_BUG = 14;
SUMMARIZE_TASK = 15;
ACT_MODE_RESPOND = 16;
USE_SUBAGENTS = 17;
@@ -0,0 +1,6 @@
import { rm } from "node:fs/promises"
import path from "node:path"
const viteCachePath = path.join(import.meta.dirname, "..", "webview-ui", "node_modules", ".vite")
await rm(viteCachePath, { recursive: true, force: true })
+12
View File
@@ -1,6 +1,7 @@
import { WebviewProvider } from "./core/webview"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { setSdkLogger } from "@cline/core"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
@@ -35,6 +36,17 @@ export async function initialize(storageContext: StorageContext): Promise<Webvie
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
// Register the SDK early logger so diagnostic events from
// ProviderSettingsManager, RuntimeOAuthTokenManager, and Cline auth
// flow through Logger.debug → Cline output channel.
// These components operate before/outside of ClineCore sessions, so the
// session-scoped logger can't reach them.
setSdkLogger({
debug: (message) => Logger.debug(message),
log: (message) => Logger.log(message),
error: (message) => Logger.error(message),
})
// Initialize ClineEndpoint configuration (reads bundled and ~/.cline/endpoints.json if present)
// This must be done before any other code that calls ClineEnv.config()
// Throws ClineConfigurationError if config file exists but is invalid
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { refreshGroqModels } from "../refreshGroqModels"
const mocks = vi.hoisted(() => ({
axiosGet: vi.fn(),
captureProviderApiError: vi.fn(),
getModelsCache: vi.fn(),
getProviderCollectionSync: vi.fn(),
getSecretKey: vi.fn(),
setModelsCache: vi.fn(),
writeFile: vi.fn(),
}))
vi.mock("@cline/llms", () => ({
getProviderCollectionSync: mocks.getProviderCollectionSync,
}))
vi.mock("@core/storage/disk", () => ({
GlobalFileNames: {
groqModels: "groq_models.json",
},
ensureCacheDirectoryExists: vi.fn(async () => "/tmp/cline-cache"),
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getModelsCache: mocks.getModelsCache,
setModelsCache: mocks.setModelsCache,
}),
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
captureProviderApiError: mocks.captureProviderApiError,
},
}))
vi.mock("@/shared/net", () => ({
getAxiosSettings: () => ({}),
}))
vi.mock("@/shared/services/Logger", () => ({
Logger: {
error: vi.fn(),
log: vi.fn(),
},
}))
vi.mock("@utils/fs", () => ({
fileExistsAtPath: vi.fn(async () => false),
}))
vi.mock("axios", () => ({
default: {
get: mocks.axiosGet,
isAxiosError: vi.fn(() => false),
},
}))
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn(),
writeFile: mocks.writeFile,
},
}))
describe("refreshGroqModels", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getModelsCache.mockReturnValue(null)
mocks.getProviderCollectionSync.mockReturnValue({ models: {} })
mocks.getSecretKey.mockReturnValue("gsk_test_key")
mocks.axiosGet.mockResolvedValue({
data: {
data: [
{
id: "groq-new-chat-model",
object: "model",
active: true,
max_completion_tokens: 4096,
context_window: 8192,
owned_by: "Groq",
},
],
},
})
})
it("defaults cache pricing for live models missing SDK catalog metadata", async () => {
const controller = {
stateManager: {
getSecretKey: mocks.getSecretKey,
},
task: {
ulid: "task-1",
},
} as unknown as Parameters<typeof refreshGroqModels>[0]
const models = await refreshGroqModels(controller)
expect(models["groq-new-chat-model"]).toMatchObject({
maxTokens: 4096,
contextWindow: 8192,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
description: "Groq model with 8,192 token context window",
})
expect(mocks.captureProviderApiError).not.toHaveBeenCalled()
expect(mocks.setModelsCache).toHaveBeenCalledWith("groq", expect.objectContaining(models))
})
})
@@ -228,6 +228,34 @@ describe("resolveModelInfo", () => {
expect(response.modelInfo).toBeUndefined()
})
it("resolves a free model id from the cline-pass catalog without coercing to the default", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("cline-pass") })
const catalog = makeCatalog()
// The cline-pass catalog carries the endpoint's clinePass bucket plus the
// Cline free models (zero-priced, OpenRouter-style ids without the
// cline-pass/ prefix). Selecting a free model must not be replaced by the
// default pass model.
vi.mocked(catalog.peekModels).mockReturnValue(
peekResult(
"cline-pass",
[
["cline-pass/glm-5.1", { name: "GLM 5.1", supportsPromptCache: false, contextWindow: 200_000 }],
["kwaipilot/kat-coder-pro", { name: "KAT Coder Pro", supportsPromptCache: false, contextWindow: 256_000 }],
],
"cline-pass/glm-5.1",
),
)
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "cline-pass",
modelId: "kwaipilot/kat-coder-pro",
})
expect(response.modelId).toBe("kwaipilot/kat-coder-pro")
expect(response.source).toBe("sdk-known-models")
})
it("still honors a custom-provider model id that does match the catalog", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const store = makeStore({ providerId: parseProviderId("openai") })
@@ -139,8 +139,8 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
inputPrice: staticModelInfo?.inputPrice || 0,
outputPrice: staticModelInfo?.outputPrice || 0,
cacheWritesPrice: (staticModelInfo as any)?.cacheWritesPrice || 0,
cacheReadsPrice: (staticModelInfo as any).cacheReadsPrice || 0,
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
}
@@ -0,0 +1,88 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { SlashCommandInfo, SlashCommandsResponse } from "@shared/proto/cline/slash"
import { BASE_SLASH_COMMANDS } from "@/shared/slashCommands"
import { Controller } from ".."
/**
* Returns all available slash commands for autocomplete.
*/
export async function getAvailableSlashCommands(controller: Controller, _request: EmptyRequest): Promise<SlashCommandsResponse> {
const commands: SlashCommandInfo[] = []
// Add built-in commands
for (const cmd of [...BASE_SLASH_COMMANDS]) {
commands.push(
SlashCommandInfo.create({
name: cmd.name,
description: cmd.description,
section: "default",
cliCompatible: cmd.cliCompatible,
}),
)
}
// Get workflow toggles from state
const localWorkflowToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles") ?? {}
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles") ?? {}
const remoteWorkflowToggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles") ?? {}
const remoteConfigSettings = controller.stateManager.getRemoteConfigSettings()
const remoteWorkflows = remoteConfigSettings?.remoteGlobalWorkflows ?? []
// Track local workflow names to avoid duplicates from global
const localNames = new Set<string>()
// Add local workflows (enabled only)
for (const [path, enabled] of Object.entries(localWorkflowToggles)) {
if (enabled) {
const fileName = fullPathToFileName(path)
localNames.add(fileName)
commands.push(
SlashCommandInfo.create({
name: fileName,
description: `Custom workflow: ${fileName}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
// Add global workflows (enabled only, skip if local exists with same name)
for (const [path, enabled] of Object.entries(globalWorkflowToggles)) {
if (enabled) {
const fileName = fullPathToFileName(path)
if (!localNames.has(fileName)) {
commands.push(
SlashCommandInfo.create({
name: fileName,
description: `Custom workflow: ${fileName}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
}
// Add remote workflows that are enabled
for (const workflow of remoteWorkflows) {
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
if (enabled) {
commands.push(
SlashCommandInfo.create({
name: workflow.name,
description: `Remote workflow: ${workflow.name}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
return SlashCommandsResponse.create({ commands })
}
function fullPathToFileName(path: string): string {
// e.g. replace /path/to/workflow.md with workflow.md
return path.replace(/^.*[/\\]/, "")
}
@@ -0,0 +1,10 @@
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { Controller } from ".."
/**
* Report bug slash command logic
*/
export async function reportBug(controller: Controller, _request: StringRequest): Promise<Empty> {
await controller.task?.handleWebviewAskResponse("yesButtonClicked")
return Empty.create()
}
@@ -27,7 +27,6 @@ export async function getStateToPostToWebview(controller: {
backgroundCommandTaskId?: string
workspaceManager?: any
checkpointRestoreInput?: ExtensionState["checkpointRestoreInput"]
getPluginSlashCommands?: () => Promise<{ name: string; description?: string }[]>
}): Promise<ExtensionState> {
const stateManager = controller.stateManager
@@ -109,15 +108,6 @@ export async function getStateToPostToWebview(controller: {
// Codex OAuth not available
}
// Plugin slash commands are fetched best-effort so autocomplete failures
// don't block state posting.
let pluginSlashCommands: { name: string; description?: string }[] = []
try {
pluginSlashCommands = (await controller.getPluginSlashCommands?.()) ?? []
} catch {
// Plugin command discovery is best-effort.
}
return {
version,
apiConfiguration,
@@ -165,7 +155,6 @@ export async function getStateToPostToWebview(controller: {
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
favoritedModelIds,
pluginSlashCommands,
backgroundCommandRunning: controller.backgroundCommandRunning ?? false,
backgroundCommandTaskId: controller.backgroundCommandTaskId,
workspaceRoots: controller.workspaceManager?.getRoots?.() ?? [],
@@ -117,7 +117,6 @@ export abstract class WebviewProvider {
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" nonce="${nonce}" src="${scriptUrl}"></script>
<script src="http://localhost:8097"></script>
</body>
</html>
`
@@ -157,7 +156,7 @@ export abstract class WebviewProvider {
*/
protected async getHMRHtmlContent(): Promise<string> {
const localPort = await this.getDevServerPort()
const localServerUrl = `localhost:${localPort}`
const localServerUrl = `127.0.0.1:${localPort}`
// Check if local dev server is running.
try {
@@ -204,7 +203,6 @@ export abstract class WebviewProvider {
<!DOCTYPE html>
<html lang="en">
<head>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
+6 -6
View File
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest"
import { isClineProvider } from "@/shared/utils/cline"
import { isClineManagedProvider } from "@/shared/utils/cline"
import { resolveWorkspaceRootPath } from "./workspace-root"
describe("isClineProvider", () => {
describe("isClineManagedProvider", () => {
it("treats both Cline account providers as Cline providers", () => {
expect(isClineProvider("cline")).toBe(true)
expect(isClineProvider("cline-pass")).toBe(true)
expect(isClineProvider("anthropic")).toBe(false)
expect(isClineProvider(undefined)).toBe(false)
expect(isClineManagedProvider("cline")).toBe(true)
expect(isClineManagedProvider("cline-pass")).toBe(true)
expect(isClineManagedProvider("anthropic")).toBe(false)
expect(isClineManagedProvider(undefined)).toBe(false)
})
})
+12 -64
View File
@@ -44,7 +44,7 @@ import { telemetryService } from "@/services/telemetry"
import type { ClineExtensionContext } from "@/shared/cline"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { isClineProvider } from "@/shared/utils/cline"
import { isClineManagedProvider } from "@/shared/utils/cline"
import { arePathsEqual, getDesktopDir } from "@/utils/path"
import { ClineAccountService } from "./account-service"
import { AuthService, LogoutReason } from "./auth-service"
@@ -71,7 +71,6 @@ import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
import { SdkMcpCoordinator } from "./sdk-mcp-coordinator"
import { SdkMessageCoordinator, type SessionEventListener } from "./sdk-message-coordinator"
import { SdkModeCoordinator } from "./sdk-mode-coordinator"
import { type PluginSlashCommand, SdkPluginCommandCoordinator } from "./sdk-plugin-commands"
import { SdkProviderChangeCoordinator } from "./sdk-provider-change-coordinator"
import { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
import { SdkSessionEventCoordinator } from "./sdk-session-event-coordinator"
@@ -167,7 +166,6 @@ export class Controller {
private compaction: SdkCompactionCoordinator
private sessionEvents: SdkSessionEventCoordinator
private sessionHistory: SdkSessionHistoryLoader
private pluginCommands: SdkPluginCommandCoordinator
private readonly sdkTelemetry: VscodeSdkTelemetryHandle
private readonly providerFailureTelemetryTurnGate = new ProviderFailureTelemetryTurnGate()
private readonly providerConfigStore: ProviderConfigStore
@@ -337,7 +335,7 @@ export class Controller {
const errorMessage = error instanceof Error ? error.message : String(error)
const providerId = this.getSessionProviderId(sessionId) ?? this.getActiveProviderId()
const isClineAuthError =
isClineProvider(providerId) &&
isClineManagedProvider(providerId) &&
(errorMessage.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
errorMessage.toLowerCase().includes("missing api key") ||
errorMessage.toLowerCase().includes("unauthorized"))
@@ -351,7 +349,7 @@ export class Controller {
failurePhase: PROVIDER_FAILURE_PHASE.PREFLIGHT,
})
this.emitClineAuthError()
} else if (isClineProvider(providerId) && this.isClineBalanceError(errorMessage)) {
} else if (isClineManagedProvider(providerId) && this.isClineBalanceError(errorMessage)) {
this.captureProviderFailure({
sessionId,
error,
@@ -454,7 +452,7 @@ export class Controller {
loadInitialMessages: (sessionHost, taskId) => this.sessionHistory.loadInitialMessages(sessionHost, taskId),
buildStartSessionInput,
resolveContextMentions: (text) => this.resolveContextMentions(text),
isClineProviderActive: () => this.isClineProviderActive(),
isClineManagedProviderActive: () => this.isClineManagedProviderActive(),
emitClineAuthError: () => this.emitClineAuthErrorWithTelemetry(),
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
postStateToWebview: () => this.postStateToWebview(),
@@ -482,7 +480,6 @@ export class Controller {
},
postStateToWebview: () => this.postStateToWebview(),
})
this.pluginCommands = new SdkPluginCommandCoordinator()
this.taskStart = new SdkTaskStartCoordinator({
stateManager: this.stateManager,
sessions: this.sessions,
@@ -504,7 +501,7 @@ export class Controller {
createTempSessionHost: () => VscodeSessionHost.create({ mcpHub: this.mcpHub }),
loadInitialMessages: (reader, taskId) => this.sessionHistory.loadInitialMessages(reader, taskId),
resolveContextMentions: (text) => this.resolveContextMentions(text),
isClineProviderActive: () => this.isClineProviderActive(),
isClineManagedProviderActive: () => this.isClineManagedProviderActive(),
emitClineAuthError: (task) => this.emitClineAuthErrorWithTelemetry(task),
captureProviderApiError: (event) => this.captureProviderFailure(event),
postStateToWebview: () => this.postStateToWebview(),
@@ -514,10 +511,7 @@ export class Controller {
sessions: this.sessions,
messages: this.messages,
sessionConfigBuilder: this.sessionConfigBuilder,
getTask: () => this.task,
getWorkspaceRoot: () => this.getWorkspaceRoot(),
buildStartSessionInput,
resetMessageTranslator: () => this.resetMessageTranslatorAndFence(),
postStateToWebview: () => this.postStateToWebview(),
})
this.sessionEvents = new SdkSessionEventCoordinator({
@@ -578,16 +572,6 @@ export class Controller {
this.providerCatalog.invalidateProviderListings()
}
/**
* Return plugin-registered slash commands for autocomplete. Surfaced to
* the webview as `pluginSlashCommands` in ExtensionState (see
* getStateToPostToWebview) so the chat input's slash-command menu can
* show them.
*/
getPluginSlashCommands(): Promise<PluginSlashCommand[]> {
return this.pluginCommands.getSlashCommands()
}
private handleProviderConfigChange(event: ProviderConfigChange): void {
this.scheduleProviderConfigStatePost()
@@ -690,7 +674,6 @@ export class Controller {
// are disposed below — see StatePostDebouncer.dispose().
await this.statePostDebouncer.dispose()
await this.invalidateUserInstructionService()
await this.pluginCommands.dispose()
this.messages.cancelPendingSave()
// Clear MCP tool list change callback before disposing McpHub
this.mcpHub?.clearToolListChangeCallback()
@@ -751,48 +734,14 @@ export class Controller {
}
/**
* Expand a leading slash command. First checks plugin-registered commands
* (e.g. `/goal`), then falls back to workflow/skill expansion via the
* user-instruction service. For plugin commands:
* - If the handler returns `submitPrompt`, that becomes the prompt text.
* - If the handler returns `reply`, it is emitted as a say message.
* - If only `reply` is returned (no `submitPrompt`), returns empty string
* so the agent turn is suppressed (the reply was already shown).
* Returns the input unchanged if it is not a known command.
* Expand a leading `/workflow` or `/skill` slash command into its instruction
* body. Mirrors the CLI's `buildUserInputMessage`. Returns the input unchanged
* if it is not a known command or expansion fails.
*/
private async resolveSlashCommands(text: string): Promise<string> {
if (this.isDisposed) {
return text
}
// Check plugin commands first — they take precedence over
// workflow/skill expansion so plugin names cannot be shadowed.
try {
const result = await this.pluginCommands.resolveCommand(text)
if (result) {
if (result.reply) {
this.messages.emitSessionEvents(
[
{
ts: Date.now(),
type: "say",
say: "text",
text: result.reply,
partial: false,
},
],
{
type: "status",
payload: { sessionId: this.sessions.getActiveSession()?.sessionId ?? "", status: "running" },
},
)
}
return result.submitPrompt ?? ""
}
} catch (error) {
Logger.warn("[SdkController] Plugin command resolution failed, falling through:", error)
}
try {
const workspaceRoot = await this.getWorkspaceRoot()
const service = await this.ensureUserInstructionService(workspaceRoot)
@@ -945,8 +894,8 @@ export class Controller {
/**
* Check if the active API provider uses Cline account auth for the current mode.
*/
private isClineProviderActive(): boolean {
return isClineProvider(this.getActiveProviderId())
private isClineManagedProviderActive(): boolean {
return isClineManagedProvider(this.getActiveProviderId())
}
private captureProviderFailure(event: ProviderFailureTelemetry): void {
@@ -1187,8 +1136,8 @@ export class Controller {
* Manually compact (condense) the active task's conversation. Triggered by
* the compact button and the `/compact` (alias `/smol`) slash command.
* Mirrors the CLI's `/compact` local command: runs an SDK manual compaction
* and restarts the session with the compacted transcript so the model's
* working context is actually reduced.
* and persists the compaction sidecar so the model's working context is
* reduced on the next turn and later resumes.
*/
async compactTask(): Promise<void> {
await this.compaction.compactTask()
@@ -1851,7 +1800,6 @@ export class Controller {
mcpHub: this.mcpHub,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
getPluginSlashCommands: () => this.pluginCommands.getSlashCommands(),
})
const sdkTaskHistory = (await this.taskHistory.listHistory({ limit: 100, hydrate: false }))
.map(sessionHistoryRecordToHistoryItem)
+3 -1
View File
@@ -103,7 +103,9 @@ vi.mock("axios", () => ({
const mockLoginClineOAuth = vi.hoisted(() => vi.fn())
// Mock @cline/core OAuth functions
vi.mock("@cline/core", () => ({
vi.mock("@cline/core", async () => ({
sdkDebug: () => {},
hashSecret: () => "hashed",
createOAuthClientCallbacks: (opts: {
onOutput?: (message: string) => void
onPrompt: () => void
+27 -2
View File
@@ -12,9 +12,11 @@ import type { OAuthCredentials } from "@cline/core"
import {
createOAuthClientCallbacks,
getValidClineCredentials,
hashSecret,
loginClineOAuth,
loginOcaOAuth,
loginOpenAICodex,
sdkDebug,
} from "@cline/core"
import type { ApiProvider } from "@shared/api"
import { AuthState, UserInfo } from "@shared/proto/cline/account"
@@ -99,7 +101,10 @@ function readClineCredentials(): {
try {
const manager = getProviderSettingsManager()
const settings = manager.getProviderSettings("cline")
if (!settings?.auth?.accessToken) return null
if (!settings?.auth?.accessToken) {
sdkDebug("[SdkAuthService] readClineCredentials: no auth.accessToken found")
return null
}
// Strip workos: prefix if present (providers.json stores it with prefix)
let accessToken = settings.auth.accessToken
@@ -107,12 +112,16 @@ function readClineCredentials(): {
accessToken = accessToken.slice(WORKOS_TOKEN_PREFIX.length)
}
return {
const result = {
accessToken,
refreshToken: settings.auth.refreshToken,
expiresAt: (settings.auth as { expiresAt?: number }).expiresAt,
accountId: settings.auth.accountId,
}
sdkDebug(
`[SdkAuthService] readClineCredentials: found credentials (accessHash=${hashSecret(result.accessToken)}, refreshHash=${hashSecret(result.refreshToken)}, expiresAt=${result.expiresAt})`,
)
return result
} catch (error) {
Logger.error("[SdkAuthService] Failed to read credentials from providers.json:", error)
return null
@@ -150,6 +159,9 @@ function writeClineCredentials(credentials: {
},
{ tokenSource: "oauth", setLastUsed: true },
)
sdkDebug(
`[SdkAuthService] writeClineCredentials: wrote (accessHash=${hashSecret(credentials.accessToken)}, refreshHash=${hashSecret(credentials.refreshToken)}, expiresAt=${credentials.expiresAt})`,
)
} catch (error) {
Logger.error("[SdkAuthService] Failed to write credentials to providers.json:", error)
}
@@ -163,6 +175,7 @@ function clearClineCredentials(): void {
const manager = getProviderSettingsManager()
const existing = manager.getProviderSettings("cline")
if (existing) {
sdkDebug("[SdkAuthService] clearClineCredentials: clearing auth from providers.json")
manager.saveProviderSettings(
{
...existing,
@@ -251,6 +264,9 @@ export class AuthService {
const bearerToken = accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
? accessToken
: `${WORKOS_TOKEN_PREFIX}${accessToken}`
sdkDebug(
`[SdkAuthService] fetchUserInfoFromApi: GET ${apiBaseUrl}/api/v1/users/me (tokenHash=${hashSecret(accessToken)})`,
)
const response = await axios.get(`${apiBaseUrl}/api/v1/users/me`, {
headers: {
Authorization: `Bearer ${bearerToken}`,
@@ -259,6 +275,7 @@ export class AuthService {
},
...getAxiosSettings(),
})
sdkDebug(`[SdkAuthService] fetchUserInfoFromApi: response status=${response.status}`)
return response.data?.data ?? null
} catch (error) {
Logger.error("[SdkAuthService] Failed to fetch user info from API:", error)
@@ -335,9 +352,11 @@ export class AuthService {
}
if (!this._clineAuthInfo?.refreshToken) {
sdkDebug("[SdkAuthService] refreshAccessToken: no refresh token available")
return false
}
sdkDebug(`[SdkAuthService] refreshAccessToken: starting (currentTokenHash=${hashSecret(this._clineAuthInfo.idToken)})`)
this._refreshPromise = (async () => {
try {
const currentInfo = this._clineAuthInfo
@@ -346,6 +365,7 @@ export class AuthService {
}
const newCredentials = await this.resolveValidClineCredentials(currentInfo, { forceRefresh: true })
if (!newCredentials) {
sdkDebug("[SdkAuthService] refreshAccessToken: refresh returned null — clearing credentials")
this._clineAuthInfo = null
this._authenticated = false
clearClineCredentials()
@@ -375,6 +395,9 @@ export class AuthService {
this._authenticated = true
if (credentialsChanged) {
sdkDebug(
`[SdkAuthService] refreshAccessToken: credentials changed (newTokenHash=${hashSecret(newCredentials.access)})`,
)
writeClineCredentials({
accessToken: newCredentials.access,
refreshToken: newCredentials.refresh,
@@ -387,6 +410,8 @@ export class AuthService {
Logger.error("[SdkAuthService] Error sending auth status update after refresh:", err)
})
})
} else {
sdkDebug("[SdkAuthService] refreshAccessToken: credentials unchanged after refresh")
}
return this._clineAuthInfo.idToken
@@ -622,16 +622,16 @@ describe("buildSessionConfig", () => {
it("uses ClinePass model storage and omits empty nested apiKey so SDK OAuth can fill it", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "cline-pass",
actModeClinePassModelId: "cline-pass/glm-5.1",
actModeClinePassModelId: "cline-pass/glm-5.2",
} as any)
mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
expect(config.providerId).toBe("cline-pass")
expect(config.modelId).toBe("cline-pass/glm-5.1")
expect(config.modelId).toBe("cline-pass/glm-5.2")
expect(config.apiKey).toBe("")
expect(config.providerConfig).toMatchObject({ providerId: "cline-pass", modelId: "cline-pass/glm-5.1" })
expect(config.providerConfig).toMatchObject({ providerId: "cline-pass", modelId: "cline-pass/glm-5.2" })
expect(config.providerConfig).not.toHaveProperty("apiKey")
})
+34 -2
View File
@@ -20,6 +20,7 @@ import {
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@cline/llms"
import { buildClineSystemPrompt } from "@cline/shared"
import type { ApiConfiguration } from "@shared/api"
import { ClineClient } from "@shared/cline"
import type { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, type LanguageDisplay } from "@shared/Languages"
import { Logger } from "@shared/services/Logger"
@@ -27,6 +28,7 @@ import type { Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import { stringifyVsCodeLmModelSelector } from "@shared/vsCodeSelectorUtils"
import { StateManager } from "@/core/storage/StateManager"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { getFeatureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
@@ -117,6 +119,31 @@ function createSdkLogger() {
}
}
/**
* Host identity for the session's client context, resolved through HostProvider
* rather than the `vscode` module directly: this file is also bundled into the
* standalone cline-core (JetBrains), where `vscode` is a Proxy-stub module and
* direct API reads would yield non-string values. The hostbridge returns the
* per-host values (e.g. "Cline for JetBrains" + IDE version on JetBrains).
*/
async function resolveHostIdentity() {
try {
return await HostProvider.env.getHostVersion({})
} catch (error) {
Logger.debug("Failed to resolve host version for client identity", error)
return undefined
}
}
async function resolveIsMultiRootWorkspace(): Promise<boolean> {
try {
const { paths } = await HostProvider.workspace.getWorkspacePaths({})
return paths.length > 1
} catch {
return false
}
}
function resolveWorkspaceName(workspacePath: string): string {
const trimmed = workspacePath.trim()
const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/, "")
@@ -664,6 +691,8 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
// own provider id spelling (e.g. "openai-compatible" rather than the
// extension's "openai"). Convert before handing the id to core.
const sdkProviderId = toSdkProviderId(providerId)
const hostIdentity = await resolveHostIdentity()
const isMultiRoot = await resolveIsMultiRootWorkspace()
// 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
@@ -714,8 +743,11 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
extensionContext: {
user: distinctId ? { distinctId } : undefined,
client: {
name: "cline-vscode",
version: ExtensionRegistryInfo.version,
name: hostIdentity?.clineType || ClineClient.VSCode,
version: hostIdentity?.clineVersion || ExtensionRegistryInfo.version,
platform: hostIdentity?.platform || undefined,
platformVersion: hostIdentity?.version || undefined,
isMultiRoot,
},
workspace: {
rootPath: workspaceRoot,
@@ -783,6 +783,101 @@ describe("translateSessionEvent — agent_event content_end", () => {
expect(second.path).toBe("/src/package.json")
})
it("content_end for read_files carries the requested line range into the readFile payload", () => {
const state = new MessageTranslatorState()
translateSessionEvent(
{
type: "agent_event",
payload: {
sessionId: "s1",
event: {
type: "content_start",
contentType: "tool",
toolName: "read_files",
toolCallId: "c1",
input: { files: [{ path: "/src/big-file.ts", start_line: 100, end_line: 200 }] },
} as AgentEvent,
},
},
state,
)
const endResult = translateSessionEvent(
{
type: "agent_event",
payload: {
sessionId: "s1",
event: {
type: "content_end",
contentType: "tool",
toolName: "read_files",
toolCallId: "c1",
} as AgentEvent,
},
},
state,
)
const endTool = JSON.parse(endResult.messages[0].text!)
expect(endTool.tool).toBe("readFile")
expect(endTool.path).toBe("/src/big-file.ts")
expect(endTool.readLineStart).toBe(100)
expect(endTool.readLineEnd).toBe(200)
})
it("content_end for read_files treats a start_line-only read as open-ended and defaults a missing start_line to 1", () => {
const state = new MessageTranslatorState()
translateSessionEvent(
{
type: "agent_event",
payload: {
sessionId: "s1",
event: {
type: "content_start",
contentType: "tool",
toolName: "read_files",
toolCallId: "c1",
input: {
files: [
{ path: "/src/paged.ts", start_line: 500, end_line: null },
{ path: "/src/head.ts", end_line: 50 },
{ path: "/src/whole.ts" },
],
},
} as AgentEvent,
},
},
state,
)
const endResult = translateSessionEvent(
{
type: "agent_event",
payload: {
sessionId: "s1",
event: {
type: "content_end",
contentType: "tool",
toolName: "read_files",
toolCallId: "c1",
} as AgentEvent,
},
},
state,
)
expect(endResult.messages).toHaveLength(3)
const [paged, head, whole] = endResult.messages.map((m) => JSON.parse(m.text!))
expect(paged.readLineStart).toBe(500)
expect(paged.readLineEnd).toBeUndefined()
expect(head.readLineStart).toBe(1)
expect(head.readLineEnd).toBe(50)
expect(whole.readLineStart).toBeUndefined()
expect(whole.readLineEnd).toBeUndefined()
})
it("content_end without prior content_start still works (graceful fallback)", () => {
const state = new MessageTranslatorState()
@@ -1149,6 +1244,25 @@ describe("translateSessionEvent — agent_event error", () => {
expect(parsed.providerId).toBe("cline")
})
it("preserves ClinePass period limit errors for specialized webview rendering", () => {
const state = new MessageTranslatorState(undefined, () => "cline-pass")
const message = "You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later."
const event: CoreSessionEvent = {
type: "agent_event",
payload: {
sessionId: "session-1",
event: {
type: "error",
error: { message },
} as AgentEvent,
},
}
const result = translateSessionEvent(event, state)
expect(result.messages).toHaveLength(2)
expect(result.messages[1].text).toBe(message)
})
it("rewrites Anthropic bare 'model: <id>' 404 into an actionable message", () => {
const state = new MessageTranslatorState(undefined, () => "anthropic")
const event: CoreSessionEvent = {
+51 -20
View File
@@ -452,10 +452,11 @@ function sdkToolToClineSayTool(toolName: string, input?: unknown): ClineSayTool
switch (toolName) {
case "read_files":
case "read_file": {
const filePath = extractFirstFilePath(parsedInput)
const fileRead = extractFileReads(parsedInput)[0]
return {
tool: "readFile",
path: filePath,
path: fileRead?.path ?? "",
...readLineRangeFields(fileRead),
}
}
@@ -665,32 +666,53 @@ function getCompletionResultText(input: unknown): string {
return getStringField(parsed, "summary") ?? getStringField(parsed, "result") ?? ""
}
/** Extract file paths from a read_files/read_file input */
function extractFilePaths(input: Record<string, unknown> | undefined): string[] {
/** A single file read request parsed from a read_files/read_file input */
interface FileReadRequest {
path: string
startLine?: number
endLine?: number
}
/** Extract file read requests (path + optional one-based inclusive line range) from a read_files/read_file input */
function extractFileReads(input: Record<string, unknown> | undefined): FileReadRequest[] {
if (!input) return []
const files = input.files
if (Array.isArray(files) && files.length > 0) {
const paths = files
.map((f) => {
if (typeof f === "string") return f
const reads = files
.map((f): FileReadRequest => {
if (typeof f === "string") return { path: f }
if (typeof f === "object" && f !== null) {
return ((f as Record<string, unknown>).path as string) ?? ""
const entry = f as Record<string, unknown>
return {
path: (entry.path as string) ?? "",
startLine: getNumberField(entry, "start_line"),
endLine: getNumberField(entry, "end_line"),
}
}
return ""
return { path: "" }
})
.filter(Boolean)
if (paths.length > 0) {
return paths
.filter((read) => read.path)
if (reads.length > 0) {
return reads
}
}
const singlePath =
(input.path as string) ?? (input.file_path as string) ?? (input.filePath as string) ?? (input.filename as string) ?? ""
return singlePath ? [singlePath] : []
return singlePath
? [{ path: singlePath, startLine: getNumberField(input, "start_line"), endLine: getNumberField(input, "end_line") }]
: []
}
/** Extract the first file path from a read_files input */
function extractFirstFilePath(input: Record<string, unknown> | undefined): string {
return extractFilePaths(input)[0] ?? ""
/**
* Map a read request's line range onto ClineSayTool fields. An omitted start_line with an
* explicit end_line means the read began at line 1; an omitted end_line stays undefined
* (open-ended read the UI renders it as "start+").
*/
function readLineRangeFields(read: FileReadRequest | undefined): Pick<ClineSayTool, "readLineStart" | "readLineEnd"> {
if (!read || (read.startLine == null && read.endLine == null)) {
return {}
}
return { readLineStart: read.startLine ?? 1, readLineEnd: read.endLine }
}
/** Get a string field from a parsed input object */
@@ -701,6 +723,14 @@ function getStringField(input: Record<string, unknown> | undefined, field: strin
return undefined
}
/** Get a finite number field from a parsed input object (null/non-number → undefined) */
function getNumberField(input: Record<string, unknown> | undefined, field: string): number | undefined {
if (!input) return undefined
const value = input[field]
if (typeof value === "number" && Number.isFinite(value)) return value
return undefined
}
/** Get an array field from a parsed input object */
function getArrayField(input: Record<string, unknown> | undefined, field: string): string[] | undefined {
if (!input) return undefined
@@ -1295,16 +1325,17 @@ function translateAgentEvent(event: AgentEvent, state: MessageTranslatorState):
// list reflect what was actually read.
if (toolName === "read_files" || toolName === "read_file") {
const parsedInput = parseToolInput(storedInput)
const filePaths = extractFilePaths(parsedInput)
if (filePaths.length > 1) {
filePaths.forEach((filePath, index) => {
const fileReads = extractFileReads(parsedInput)
if (fileReads.length > 1) {
fileReads.forEach((fileRead, index) => {
messages.push({
ts: index === 0 ? ts : state.nextTs(),
type: "say",
say: "tool",
text: JSON.stringify({
tool: "readFile",
path: filePath,
path: fileRead.path,
...readLineRangeFields(fileRead),
} satisfies ClineSayTool),
partial: false,
})
+2 -2
View File
@@ -43,14 +43,14 @@ describe("buildSdkProviderConfig", () => {
const providerConfig = buildSdkProviderConfig(
{
actModeApiProvider: "cline-pass",
actModeClinePassModelId: "cline-pass/glm-5.1",
actModeClinePassModelId: "cline-pass/glm-5.2",
},
"act",
)
expect(providerConfig).toMatchObject({
providerId: "cline-pass",
modelId: "cline-pass/glm-5.1",
modelId: "cline-pass/glm-5.2",
apiKey: "workos:shared-cline-token",
})
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("cline")
@@ -1,8 +1,18 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { createContextCompactionPrepareTurn } from "@cline/core"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { StateManager } from "@/core/storage/StateManager"
import { SdkCompactionCoordinator, type SdkCompactionCoordinatorOptions } from "./sdk-compaction-coordinator"
vi.mock("@cline/core", () => ({
createContextCompactionPrepareTurn: vi.fn(),
createSessionCompactionState: vi.fn((input: { compactedMessages: unknown[] }) => ({
version: 1,
messages: input.compactedMessages,
})),
}))
const mockCreateContextCompactionPrepareTurn = createContextCompactionPrepareTurn as unknown as ReturnType<typeof vi.fn>
vi.mock("@/shared/services/Logger", () => ({
Logger: {
debug: vi.fn(),
@@ -12,11 +22,6 @@ vi.mock("@/shared/services/Logger", () => ({
},
}))
const compactSessionMessages = vi.fn()
vi.mock("./sdk-compaction", () => ({
compactSessionMessages: (...args: unknown[]) => compactSessionMessages(...args),
}))
describe("SdkCompactionCoordinator", () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -28,7 +33,7 @@ describe("SdkCompactionCoordinator", () => {
await coordinator.compactTask()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(compactSessionMessages).not.toHaveBeenCalled()
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: "There is no active task to compact." })],
expect.anything(),
@@ -41,7 +46,7 @@ describe("SdkCompactionCoordinator", () => {
await coordinator.compactTask()
expect(compactSessionMessages).not.toHaveBeenCalled()
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: expect.stringContaining("Cannot compact while a response") })],
@@ -56,7 +61,7 @@ describe("SdkCompactionCoordinator", () => {
await coordinator.compactTask()
expect(compactSessionMessages).not.toHaveBeenCalled()
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: "No messages to compact." })],
@@ -64,17 +69,29 @@ describe("SdkCompactionCoordinator", () => {
)
})
it("reports when the strategy declines to compact", async () => {
it("reports unsupported runtime without running compaction", async () => {
const activeSession = makeActiveSession()
;(activeSession.sdkHost as Partial<typeof activeSession.sdkHost>).updateSessionCompactionState = undefined
const { coordinator, options } = makeCoordinator({ activeSession })
compactSessionMessages.mockResolvedValueOnce({
compacted: false,
messages: [{ role: "user", content: "a" }],
})
await coordinator.compactTask()
expect(compactSessionMessages).toHaveBeenCalledOnce()
expect(activeSession.sdkHost.readMessages).not.toHaveBeenCalled()
expect(mockCreateContextCompactionPrepareTurn).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: expect.stringContaining("not supported") })],
expect.anything(),
)
})
it("reports when the strategy declines to compact", async () => {
const activeSession = makeActiveSession()
const { coordinator, options } = makeCoordinator({ activeSession })
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(vi.fn().mockResolvedValue(undefined))
await coordinator.compactTask()
expect(mockCreateContextCompactionPrepareTurn).toHaveBeenCalledOnce()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: "No compaction needed." })],
@@ -82,53 +99,74 @@ describe("SdkCompactionCoordinator", () => {
)
})
it("compacts and restarts the session, preserving the session id", async () => {
it("compacts and persists the sidecar without rebuilding the session", async () => {
const activeSession = makeActiveSession()
activeSession.sdkHost.readMessages.mockResolvedValueOnce([
{ role: "user", content: "1" },
{ role: "assistant", content: "2" },
{ role: "user", content: "3" },
])
const task = makeTask("old-session")
const { coordinator, options } = makeCoordinator({ activeSession, task })
compactSessionMessages.mockResolvedValueOnce({
compacted: true,
messages: [{ role: "user", content: "summary" }],
})
const { coordinator, options } = makeCoordinator({ activeSession })
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(
vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] }),
)
await coordinator.compactTask()
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "old-session" }), {
cwd: "/workspace",
mode: "act",
expect(activeSession.sdkHost.updateSessionCompactionState).toHaveBeenCalledWith("old-session", {
version: 1,
messages: [{ role: "user", content: "summary" }],
})
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith({
startInput: expect.objectContaining({
config: expect.objectContaining({ sessionId: "old-session" }),
interactive: true,
prompt: undefined,
}),
initialMessages: [{ role: "user", content: "summary" }],
disposeReason: "compactTask",
})
expect(task.taskId).toBe("new-session")
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: "Compacted 3 messages to 1." })],
expect.anything(),
)
})
it("does not append compaction status to a different active session", async () => {
const activeSession = makeActiveSession()
const { coordinator, options } = makeCoordinator({ activeSession })
options.sessions.getActiveSession
.mockReturnValueOnce(activeSession)
.mockReturnValueOnce(makeActiveSession({ sessionId: "other-session" }))
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(
vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] }),
)
await coordinator.compactTask()
expect(activeSession.sdkHost.updateSessionCompactionState).toHaveBeenCalled()
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
})
it("does not restart or report success when sidecar persistence fails", async () => {
const activeSession = makeActiveSession()
activeSession.sdkHost.updateSessionCompactionState.mockResolvedValueOnce({ updated: false })
const { coordinator, options } = makeCoordinator({ activeSession })
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(
vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] }),
)
await coordinator.compactTask()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: "Couldn't compact the conversation. Please try again." })],
expect.anything(),
)
})
it("reports a failure when compaction throws", async () => {
const activeSession = makeActiveSession()
const { coordinator, options } = makeCoordinator({ activeSession })
compactSessionMessages.mockRejectedValueOnce(new Error("boom"))
mockCreateContextCompactionPrepareTurn.mockReturnValueOnce(vi.fn().mockRejectedValue(new Error("boom")))
await coordinator.compactTask()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
[expect.objectContaining({ say: "info", text: "Compaction failed: boom" })],
[expect.objectContaining({ say: "info", text: "Couldn't compact the conversation. Please try again." })],
expect.anything(),
)
})
@@ -136,7 +174,6 @@ describe("SdkCompactionCoordinator", () => {
interface MakeCoordinatorInput {
activeSession: ReturnType<typeof makeActiveSession> | undefined
task: ReturnType<typeof makeTask>
}
function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
@@ -168,14 +205,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
sessionConfigBuilder: {
build: vi.fn().mockResolvedValue(config),
},
getTask: vi.fn(() => input.task),
getWorkspaceRoot: vi.fn().mockResolvedValue("/workspace"),
buildStartSessionInput: vi.fn((startConfig) => ({
config: startConfig,
prompt: undefined,
interactive: true,
})),
resetMessageTranslator: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
} as unknown as SdkCompactionCoordinatorOptions & {
sessions: SdkCompactionCoordinatorOptions["sessions"] & {
@@ -188,8 +218,6 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
sessionConfigBuilder: SdkCompactionCoordinatorOptions["sessionConfigBuilder"] & {
build: ReturnType<typeof vi.fn>
}
buildStartSessionInput: ReturnType<typeof vi.fn>
resetMessageTranslator: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
}
@@ -199,11 +227,12 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
}
}
function makeActiveSession(input: { isRunning?: boolean } = {}) {
function makeActiveSession(input: { isRunning?: boolean; sessionId?: string } = {}) {
return {
sessionId: "old-session",
sessionId: input.sessionId ?? "old-session",
sdkHost: {
readMessages: vi.fn().mockResolvedValue([{ role: "user", content: "1" }]),
updateSessionCompactionState: vi.fn().mockResolvedValue({ updated: true }),
send: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -214,15 +243,3 @@ function makeActiveSession(input: { isRunning?: boolean } = {}) {
isRunning: input.isRunning ?? false,
}
}
function makeTask(taskId: string, messages: Array<Partial<ClineMessage>> = []) {
return {
taskId,
messageStateHandler: {
getClineMessages: vi.fn(() => messages as ClineMessage[]),
},
} as unknown as {
taskId: string
messageStateHandler: { getClineMessages: () => ClineMessage[] }
}
}
@@ -6,9 +6,8 @@
//
// 1. Read the active session's transcript.
// 2. Run a manual SDK compaction over it (sdk-compaction.ts).
// 3. Restart the session with the compacted messages as initialMessages, so
// the model's working context is actually reduced (reusing the mode-rebuild
// replaceActiveSession path, which lazily persists on the next turn).
// 3. Persist the SDK compaction sidecar so the next turn and resumes keep
// using the compacted working context.
//
// Before this, the VSCode button sent the literal text "/compact" to the model,
// which the SDK does not treat as a runtime command, so the model improvised a
@@ -24,22 +23,16 @@ import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
import type { SdkSessionLifecycle } from "./sdk-session-lifecycle"
import type { SdkSessionHost } from "./session-host"
import type { TaskProxy } from "./task-proxy"
import type { VscodeSessionHost } from "./vscode-session-host"
type StartInput = Parameters<VscodeSessionHost["start"]>[0]
type InitialMessages = StartInput["initialMessages"]
type SessionConfig = Awaited<ReturnType<SdkSessionConfigBuilder["build"]>>
const COMPACTION_FAILURE_MESSAGE = "Couldn't compact the conversation. Please try again."
const COMPACTION_UNSUPPORTED_MESSAGE = "Compaction is not supported by this runtime yet. Please update Cline and try again."
export interface SdkCompactionCoordinatorOptions {
stateManager: StateManager
sessions: SdkSessionLifecycle
messages: SdkMessageCoordinator
sessionConfigBuilder: SdkSessionConfigBuilder
getTask: () => TaskProxy | undefined
getWorkspaceRoot: () => Promise<string>
buildStartSessionInput: (config: SessionConfig, input: { cwd: string; mode: Mode }) => StartInput
resetMessageTranslator: () => void
postStateToWebview: () => Promise<void>
}
@@ -70,7 +63,10 @@ export class SdkCompactionCoordinator {
// A turn is still running; compacting mid-turn would race the live agent
// loop's own message persistence. Ask the user to wait until it finishes.
if (activeSession.isRunning) {
this.emitInfo("Cannot compact while a response is in progress. Try again once the current turn finishes.")
this.emitInfo(
"Cannot compact while a response is in progress. Try again once the current turn finishes.",
activeSession.sessionId,
)
await this.options.postStateToWebview()
return
}
@@ -80,7 +76,7 @@ export class SdkCompactionCoordinator {
await this.runCompaction(activeSession.sdkHost, activeSession.sessionId)
} catch (error) {
Logger.error("[SdkController] compactTask failed:", error)
this.emitInfo(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`)
this.emitInfo(COMPACTION_FAILURE_MESSAGE, activeSession.sessionId)
await this.options.postStateToWebview()
} finally {
this.compactInFlight = false
@@ -88,10 +84,15 @@ export class SdkCompactionCoordinator {
}
private async runCompaction(sdkHost: SdkSessionHost, sessionId: string): Promise<void> {
if (!sdkHost.updateSessionCompactionState) {
this.emitInfo(COMPACTION_UNSUPPORTED_MESSAGE, sessionId)
await this.options.postStateToWebview()
return
}
const messages = (await sdkHost.readMessages(sessionId)) as SdkMessage[]
const messagesBefore = messages.length
if (messagesBefore === 0) {
this.emitInfo("No messages to compact.")
this.emitInfo("No messages to compact.", sessionId)
await this.options.postStateToWebview()
return
}
@@ -115,44 +116,23 @@ export class SdkCompactionCoordinator {
})
if (!result.compacted) {
this.emitInfo("No compaction needed.")
this.emitInfo("No compaction needed.", sessionId)
await this.options.postStateToWebview()
return
}
// Restart the session with the compacted transcript. Reusing the
// sessionId keeps the task identity (history item, task header) stable;
// replaceActiveSession waits for the old session's stop before starting
// the replacement (same sequencing as a mode rebuild).
config.sessionId = sessionId
const startInput = this.options.buildStartSessionInput(config, { cwd, mode })
const rebuildResult = await this.options.sessions.replaceActiveSession({
startInput,
initialMessages: result.messages as InitialMessages,
disposeReason: "compactTask",
})
if (!rebuildResult) {
this.emitInfo("Compaction could not be applied because the session was replaced.")
await this.options.postStateToWebview()
return
if (!result.compactionState) {
throw new Error("Compaction did not return durable state.")
}
const persisted = await sdkHost.updateSessionCompactionState(sessionId, result.compactionState)
if (!persisted.updated) {
throw new Error("Compaction sidecar could not be persisted.")
}
const { startResult } = rebuildResult
const task = this.options.getTask()
if (task && task.taskId !== startResult.sessionId) {
task.taskId = startResult.sessionId
}
// Fence the conversation boundary so any straggler events from the old
// session carry an older epoch and are dropped by the webview.
this.options.resetMessageTranslator()
this.emitInfo(this.formatCompactionStatus(messagesBefore, result.messages.length))
this.emitInfo(this.formatCompactionStatus(messagesBefore, result.messages.length), sessionId)
await this.options.postStateToWebview()
Logger.log(
`[SdkController] Compacted session ${sessionId}: ${messagesBefore} -> ${result.messages.length} messages (new session ${startResult.sessionId})`,
)
Logger.log(`[SdkController] Compacted session ${sessionId}: ${messagesBefore} -> ${result.messages.length} messages`)
}
private getCurrentMode(): Mode {
@@ -168,8 +148,13 @@ export class SdkCompactionCoordinator {
return `Compacted ${messagesBefore} messages to ${messagesAfter}.`
}
private emitInfo(text: string): void {
const sessionId = this.options.sessions.getActiveSession()?.sessionId ?? ""
private emitInfo(text: string, sessionId?: string): void {
const activeSessionId = this.options.sessions.getActiveSession()?.sessionId
if (sessionId && activeSessionId !== sessionId) {
Logger.warn(`[SdkController] compactTask: skipped info for inactive session ${sessionId}`)
return
}
const targetSessionId = sessionId ?? activeSessionId ?? ""
const infoMessage: ClineMessage = {
ts: Date.now(),
type: "say",
@@ -179,7 +164,7 @@ export class SdkCompactionCoordinator {
}
this.options.messages.appendAndEmit([infoMessage], {
type: "status",
payload: { sessionId, status: "running" },
payload: { sessionId: targetSessionId, status: "running" },
})
}
}
+23 -4
View File
@@ -1,15 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { compactSessionMessages } from "./sdk-compaction"
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
const createContextCompactionPrepareTurn = vi.fn()
const createSessionCompactionState = vi.fn((input: unknown) => ({ version: 1, input }))
vi.mock("@cline/core", () => ({
createContextCompactionPrepareTurn: (...args: unknown[]) => createContextCompactionPrepareTurn(...args),
createSessionCompactionState: (input: unknown) => createSessionCompactionState(input),
}))
vi.mock("@/shared/services/Logger", () => ({
Logger: { debug: vi.fn(), error: vi.fn(), log: vi.fn(), warn: vi.fn() },
}))
let compactSessionMessages: typeof import("./sdk-compaction").compactSessionMessages
const baseConfig = {
providerConfig: { providerId: "anthropic", modelId: "claude" },
providerId: "anthropic",
@@ -21,6 +24,10 @@ const baseConfig = {
} as unknown as Parameters<typeof compactSessionMessages>[0]["config"]
describe("compactSessionMessages", () => {
beforeAll(async () => {
;({ compactSessionMessages } = await import("./sdk-compaction"))
})
beforeEach(() => {
vi.clearAllMocks()
})
@@ -33,7 +40,9 @@ describe("compactSessionMessages", () => {
})
it("builds a manual-mode prepareTurn and force-enables compaction", async () => {
const compact = vi.fn().mockResolvedValue({ messages: [{ role: "user", content: "summary" }] })
const compact = vi
.fn()
.mockResolvedValue({ messages: [{ role: "user", content: "summary" }], systemPrompt: "rewritten system" })
createContextCompactionPrepareTurn.mockReturnValueOnce(compact)
const messages = [
@@ -53,7 +62,17 @@ describe("compactSessionMessages", () => {
{ mode: "manual" },
)
expect(compact).toHaveBeenCalledOnce()
expect(result).toEqual({ compacted: true, messages: [{ role: "user", content: "summary" }] })
expect(createSessionCompactionState).toHaveBeenCalledWith({
sourceMessages: messages,
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "s1",
systemPrompt: "rewritten system",
})
expect(result).toEqual({
compacted: true,
messages: [{ role: "user", content: "summary" }],
compactionState: { version: 1, input: expect.anything() },
})
})
it("returns compacted=false when prepareTurn is unavailable", async () => {
+21 -7
View File
@@ -4,14 +4,18 @@
// apps/cli/src/runtime/interactive/compaction.ts (`compactInteractiveMessages`):
// it builds a manual-mode compaction `prepareTurn` via the SDK's
// `createContextCompactionPrepareTurn` and runs it against the current session
// transcript, returning the compacted messages.
// transcript, returning the compacted working-context sidecar state.
//
// The CLI then restarts the session with the compacted messages; the VSCode
// adapter does the same in SdkCompactionCoordinator. Keeping the actual
// compaction effect in the SDK (rather than asking the model to "summarize the
// conversation") is what makes the compact button real instead of improvised.
// The VSCode coordinator persists that sidecar without replacing the canonical
// transcript, so the active session and later resumes use compacted working
// context while saved messages remain intact.
import { type CoreSessionConfig, createContextCompactionPrepareTurn } from "@cline/core"
import {
type CoreSessionConfig,
createContextCompactionPrepareTurn,
createSessionCompactionState,
type SessionCompactionState,
} from "@cline/core"
import type { Message as SdkMessage, ModelInfo as SdkModelInfo } from "@cline/llms"
import { Logger } from "@/shared/services/Logger"
@@ -35,6 +39,7 @@ export interface CompactSessionMessagesInput {
export interface CompactSessionMessagesResult {
compacted: boolean
messages: SdkMessage[]
compactionState?: SessionCompactionState
}
/**
@@ -103,5 +108,14 @@ export async function compactSessionMessages(input: CompactSessionMessagesInput)
if (!result) {
return { compacted: false, messages: input.messages }
}
return { compacted: true, messages: result.messages }
return {
compacted: true,
messages: result.messages,
compactionState: createSessionCompactionState({
sourceMessages: input.messages,
compactedMessages: result.messages,
conversationId: input.sessionId,
systemPrompt: result.systemPrompt,
}),
}
}
@@ -312,7 +312,7 @@ describe("SdkFollowupCoordinator", () => {
const task = makeTask("task-1")
const { coordinator, options } = makeCoordinator({ task })
options.sessionConfigBuilder.build.mockRejectedValue(new Error("missing api key"))
options.isClineProviderActive.mockReturnValue(true)
options.isClineManagedProviderActive.mockReturnValue(true)
await coordinator.askResponse("continue")
@@ -383,7 +383,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
loadInitialMessages: vi.fn().mockResolvedValue([{ role: "user", content: "hello" }]),
buildStartSessionInput: vi.fn(() => ({ prompt: "start" })),
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
isClineProviderActive: vi.fn(() => false),
isClineManagedProviderActive: vi.fn(() => false),
emitClineAuthError: vi.fn(),
resetMessageTranslator: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
@@ -416,7 +416,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
getWorkspaceRoot: ReturnType<typeof vi.fn>
loadInitialMessages: ReturnType<typeof vi.fn>
resolveContextMentions: ReturnType<typeof vi.fn>
isClineProviderActive: ReturnType<typeof vi.fn>
isClineManagedProviderActive: ReturnType<typeof vi.fn>
emitClineAuthError: ReturnType<typeof vi.fn>
resetMessageTranslator: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
@@ -30,7 +30,7 @@ export interface SdkFollowupCoordinatorOptions {
loadInitialMessages: (sessionHost: SdkSessionHost, taskId: string) => Promise<unknown[] | undefined>
buildStartSessionInput: (config: SessionConfig, input: { cwd: string; mode: Mode }) => StartInput
resolveContextMentions: (text: string) => Promise<string>
isClineProviderActive: () => boolean
isClineManagedProviderActive: () => boolean
emitClineAuthError: () => void
resetMessageTranslator: () => void
postStateToWebview: () => Promise<void>
@@ -119,7 +119,7 @@ export class SdkFollowupCoordinator {
const errorMsg = error instanceof Error ? error.message : String(error)
const isClineAuth =
this.options.isClineProviderActive() &&
this.options.isClineManagedProviderActive() &&
(errorMsg.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
errorMsg.toLowerCase().includes("missing api key") ||
errorMsg.toLowerCase().includes("unauthorized"))
-156
View File
@@ -1,156 +0,0 @@
// SdkPluginCommandCoordinator — discovers and executes plugin-registered
// slash commands, mirroring the CLI's createWorkspaceChatCommandHost.
//
// Plugins register commands via `api.registerCommand({ name, handler })` in
// their setup(). The ContributionRegistry runs setup() and collects the
// registered commands. This coordinator:
// 1. Lazily loads plugins via resolveAndLoadAgentPlugins (sandbox mode)
// 2. Initializes a ContributionRegistry to run setup() and gather commands
// 3. Exposes getSlashCommands() for autocomplete
// 4. Exposes resolveCommand(text) to execute a /command and return its result
import {
type AgentExtensionCommand,
type AgentExtensionCommandResult,
createContributionRegistry,
noopBasicLogger,
resolveAndLoadAgentPlugins,
} from "@cline/core"
import type { AgentTool, Message } from "@cline/shared"
import { Logger } from "@shared/services/Logger"
export interface PluginSlashCommand {
name: string
description?: string
}
export interface PluginCommandResult {
reply?: string
submitPrompt?: string
}
interface LoadedPlugins {
commands: AgentExtensionCommand[]
shutdown: () => Promise<void>
}
export class SdkPluginCommandCoordinator {
private loadedPromise: Promise<LoadedPlugins | undefined> | undefined
/**
* Lazily load plugins and initialize the contribution registry. The result
* is cached so subsequent calls reuse the same sandbox process. Returns
* undefined if no plugins are installed or loading fails.
*/
private ensureLoaded(): Promise<LoadedPlugins | undefined> {
if (this.loadedPromise) {
return this.loadedPromise
}
this.loadedPromise = (async () => {
let loaded: Awaited<ReturnType<typeof resolveAndLoadAgentPlugins>>
try {
loaded = await resolveAndLoadAgentPlugins({
logger: noopBasicLogger,
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
Logger.warn(`[PluginCommands] Plugin loading failed; continuing without plugin commands (${message})`)
return undefined
}
if (!loaded.extensions.length) {
await loaded.shutdown?.().catch(() => {})
return undefined
}
const registry = createContributionRegistry<(typeof loaded.extensions)[number], AgentTool, Message[]>({
extensions: loaded.extensions,
})
try {
await registry.initialize()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
Logger.warn(`[PluginCommands] Contribution registry initialization failed (${message})`)
await loaded.shutdown?.().catch(() => {})
return undefined
}
return {
commands: registry.getRegistrySnapshot().commands,
shutdown: async () => {
await loaded.shutdown?.().catch(() => {})
},
}
})()
return this.loadedPromise
}
/**
* Return plugin-registered slash commands for autocomplete. Returns an
* empty array if no plugins are installed or loading fails.
*/
async getSlashCommands(): Promise<PluginSlashCommand[]> {
const loaded = await this.ensureLoaded()
if (!loaded) {
return []
}
return loaded.commands
.filter((cmd) => typeof cmd.handler === "function")
.map((cmd) => ({
name: cmd.name,
description: cmd.description,
}))
}
/**
* Resolve a leading /command from a plugin. Returns null if the text does
* not match a plugin command. Returns { reply?, submitPrompt? } from the
* command handler.
*/
async resolveCommand(text: string): Promise<PluginCommandResult | null> {
if (!text.startsWith("/") || text.length < 2) {
return null
}
const match = text.match(/^\/(\S+)/)
if (!match?.[1]) {
return null
}
const name = match[1]
const remainder = text.slice(name.length + 1).trim()
const loaded = await this.ensureLoaded()
if (!loaded) {
return null
}
const command = loaded.commands.find((cmd) => cmd.name === name && typeof cmd.handler === "function")
if (!command?.handler) {
return null
}
try {
const result: AgentExtensionCommandResult = await command.handler(remainder)
if (typeof result === "string") {
return { reply: result }
}
return {
reply: result.reply,
submitPrompt: result.submitPrompt,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
Logger.warn(`[PluginCommands] Command "/${name}" failed: ${message}`)
return { reply: `Command /${name} failed: ${message}` }
}
}
/**
* Shut down the plugin sandbox process. Called on extension disposal.
*/
async dispose(): Promise<void> {
const promise = this.loadedPromise
this.loadedPromise = undefined
if (promise) {
const loaded = await promise.catch(() => undefined)
await loaded?.shutdown().catch(() => {})
}
}
}
@@ -4,6 +4,7 @@ import type { StateManager } from "@/core/storage/StateManager"
import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@/shared/cline/recommended-models"
import type { ClineApiReqInfo, TurnPhase } from "@/shared/ExtensionMessage"
import { Logger } from "@/shared/services/Logger"
import { isClineManagedProvider } from "@/shared/utils/cline"
import type { MessageTranslatorState, TranslationResult } from "./message-translator"
import { translateSessionEvent } from "./message-translator"
import { PROVIDER_FAILURE_ERROR_TYPE, PROVIDER_FAILURE_PHASE, type ProviderFailureTelemetry } from "./provider-failure-telemetry"
@@ -250,11 +251,12 @@ export class SdkSessionEventCoordinator {
const apiConfig = stateManager.getApiConfiguration()
const mode = stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
const provider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
if (provider !== "cline") {
// Free models are also selectable on ClinePass — they ride usage billing at $0
if (!isClineManagedProvider(provider)) {
return false
}
const modelId = mode === "plan" ? apiConfig.planModeClineModelId : apiConfig.actModeClineModelId
const modelId = this.getCurrentClineModelId()
if (!modelId) {
return false
}
@@ -283,6 +285,10 @@ export class SdkSessionEventCoordinator {
}
const apiConfig = stateManager.getApiConfiguration()
const mode = stateManager.getGlobalSettingsKey("mode") === "plan" ? "plan" : "act"
const provider = mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
if (provider === "cline-pass") {
return mode === "plan" ? apiConfig.planModeClinePassModelId : apiConfig.actModeClinePassModelId
}
return mode === "plan" ? apiConfig.planModeClineModelId : apiConfig.actModeClineModelId
}
@@ -173,7 +173,7 @@ describe("SdkTaskStartCoordinator", () => {
it("emits Cline auth errors when reinitialization fails due auth", async () => {
const { coordinator, options } = makeCoordinator()
options.sessionConfigBuilder.build.mockRejectedValue(new Error("missing api key"))
options.isClineProviderActive.mockReturnValue(true)
options.isClineManagedProviderActive.mockReturnValue(true)
await coordinator.reinitExistingTaskFromId("task-1")
@@ -252,7 +252,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
createTempSessionHost: vi.fn().mockResolvedValue(tempHost),
loadInitialMessages: vi.fn().mockResolvedValue([{ role: "user", content: "hello" }]),
resolveContextMentions: vi.fn(async (text: string) => `resolved: ${text}`),
isClineProviderActive: vi.fn(() => false),
isClineManagedProviderActive: vi.fn(() => false),
emitClineAuthError: vi.fn(),
captureProviderApiError: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
@@ -277,7 +277,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
createTempSessionHost: ReturnType<typeof vi.fn>
loadInitialMessages: ReturnType<typeof vi.fn>
resolveContextMentions: ReturnType<typeof vi.fn>
isClineProviderActive: ReturnType<typeof vi.fn>
isClineManagedProviderActive: ReturnType<typeof vi.fn>
emitClineAuthError: ReturnType<typeof vi.fn>
captureProviderApiError: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
@@ -51,7 +51,7 @@ export interface SdkTaskStartCoordinatorOptions {
createTempSessionHost: () => Promise<SdkSessionHost>
loadInitialMessages: (reader: SdkSessionHost, taskId: string) => Promise<unknown[] | undefined>
resolveContextMentions: (text: string) => Promise<string>
isClineProviderActive: () => boolean
isClineManagedProviderActive: () => boolean
emitClineAuthError: (task?: string) => void
captureProviderApiError?: (event: ProviderFailureTelemetry) => void
postStateToWebview: () => Promise<void>
@@ -140,9 +140,9 @@ export class SdkTaskStartCoordinator {
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
await this.options.postStateToWebview()
if (prompt?.trim()) {
if (prompt?.trim() || images?.length || files?.length) {
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
const resolvedTask = await this.options.resolveContextMentions(prompt)
const resolvedTask = await this.options.resolveContextMentions(prompt || "")
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
}
@@ -255,7 +255,7 @@ export class SdkTaskStartCoordinator {
const reinitErrorMsg = error instanceof Error ? error.message : String(error)
const isClineAuthReinit =
this.options.isClineProviderActive() &&
this.options.isClineManagedProviderActive() &&
(reinitErrorMsg.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
reinitErrorMsg.toLowerCase().includes("missing api key") ||
reinitErrorMsg.toLowerCase().includes("unauthorized"))
+2
View File
@@ -11,6 +11,7 @@ import type {
RestoreResult,
SendSessionInput,
SessionAccumulatedUsage,
SessionCompactionState,
SessionHistoryRecord,
SessionPendingPrompt,
SessionRecord,
@@ -33,6 +34,7 @@ export interface SdkSessionHost {
listHistory(options?: ClineCoreListHistoryOptions): Promise<SessionHistoryRecord[]>
delete(sessionId: string): Promise<boolean>
readMessages(sessionId: string): Promise<SdkInitialMessages>
updateSessionCompactionState?(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }>
restore(input: RestoreInput): Promise<RestoreResult>
update(
sessionId: string,
@@ -20,6 +20,7 @@ import {
type RestoreResult,
type SendSessionInput,
type SessionAccumulatedUsage,
type SessionCompactionState,
type SessionHistoryRecord,
type SessionPendingPrompt,
type SessionRecord,
@@ -199,6 +200,10 @@ export class VscodeSessionHost implements SdkSessionHost {
return this.inner.readMessages(sessionId)
}
async updateSessionCompactionState(sessionId: string, state: SessionCompactionState): Promise<{ updated: boolean }> {
return this.inner.updateSessionCompactionState(sessionId, state)
}
async restore(input: RestoreInput): Promise<RestoreResult> {
return this.inner.restore(input)
}
@@ -2,6 +2,7 @@ import {
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionMessage,
isClinePassLimitMessage,
} from "@cline/llms"
import { serializeError } from "serialize-error"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
@@ -14,6 +15,7 @@ export enum ClineErrorType {
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
OrgClinePassRestriction = "orgClinePassRestriction",
ClinePassLimit = "clinePassLimit",
}
interface ErrorDetails {
@@ -183,6 +185,13 @@ export class ClineError extends Error {
return ClineErrorType.Entitlement
}
if (
(detailMessage ? isClinePassLimitMessage(detailMessage) : false) ||
(rawMessage ? isClinePassLimitMessage(rawMessage) : false)
) {
return ClineErrorType.ClinePassLimit
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
@@ -44,5 +44,24 @@ describe("ClineError", () => {
ClineError.getErrorType(err)!.should.equal(ClineErrorType.OrgClinePassRestriction)
})
it("should classify ClinePass period limit messages separately", () => {
const err = new ClineError(
"You have reached your weekly Clinepass limit. The limit resets in 7d, please try again later.",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit)
})
it("should classify nested ClinePass period limit messages separately", () => {
const err = new ClineError({
message: "403 Error 403",
error: {
message: "You have reached your monthly ClinePass limit. The limit resets in 12h, please try again later.",
},
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.ClinePassLimit)
})
})
})
@@ -1583,7 +1583,7 @@ export class TelemetryService {
/**
* Records when slash commands or workflows are activated
* @param ulid Unique identifier for the task
* @param commandName The name of the command (e.g., "newtask", "newrule", or custom workflow name)
* @param commandName The name of the command (e.g., "newtask", "reportbug", or custom workflow name)
* @param commandType Whether it's a built-in command, custom workflow, or MCP prompt
*/
public captureSlashCommandUsed(ulid: string, commandName: string, commandType: "builtin" | "workflow" | "mcp_prompt") {
+2 -4
View File
@@ -16,7 +16,6 @@ import { OnboardingModelGroup } from "./proto/cline/state"
import { Mode } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
import { UserInfo } from "./UserInfo"
import type { SlashCommand } from "./slashCommands"
// webview will hold state
export interface ExtensionMessage {
type: "grpc_response" // New type for gRPC responses
@@ -115,8 +114,6 @@ export interface ExtensionState {
worktreesEnabled?: ClineFeatureSetting
customPrompt?: string
favoritedModelIds: string[]
/** Plugin-registered slash commands surfaced for autocomplete. */
pluginSlashCommands?: SlashCommand[]
// NEW: Add workspace information
workspaceRoots: WorkspaceRoot[]
primaryRootIndex: number
@@ -214,6 +211,7 @@ export type ClineAsk =
| "new_task"
| "condense"
| "summarize_task"
| "report_bug"
| "use_subagents"
export type ClineSay =
@@ -277,7 +275,7 @@ export interface ClineSayTool {
operationIsLocatedInWorkspace?: boolean
/** Starting line numbers in the original file where each SEARCH block matched */
startLineNumbers?: number[]
/** Inclusive line range actually returned by read_file (for UI summaries). */
/** One-based inclusive line range requested by read_file; readLineEnd omitted = open-ended read (for UI summaries). */
readLineStart?: number
readLineEnd?: number
}
+2 -22
View File
@@ -142,8 +142,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
"Claude Sonnet 4.5 is an Anthropic model for coding, agentic search, and AI agent workflows. It supports planning and implementation tasks across the software development lifecycle.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
}
export type ClinePassModelId = keyof typeof clinePassModels
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
export const clinePassDefaultModelId = "cline-pass/glm-5.2"
export const clinePassModelInfoSaneDefaults: ModelInfo = {
maxTokens: 8_192,
contextWindow: 128_000,
@@ -156,21 +155,6 @@ export const clinePassModelInfoSaneDefaults: ModelInfo = {
cacheWritesPrice: 0,
description: "",
}
export const clinePassModels = {
"cline-pass/glm-5.1": {
name: "cline-pass/glm-5.1",
maxTokens: 131_072,
contextWindow: 202_752,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.98,
outputPrice: 3.08,
cacheReadsPrice: 0.182,
cacheWritesPrice: 0,
description: "",
},
} as const satisfies Record<string, ModelInfo>
export function getModelSlug(modelId: string): string {
return modelId.split("/").at(-1) ?? modelId
@@ -187,11 +171,7 @@ export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record
}
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
return (
clinePassModels[modelId as keyof typeof clinePassModels] ??
modelInfoByName?.[getModelSlug(modelId)] ??
clinePassModelInfoSaneDefaults
)
return modelInfoByName?.[getModelSlug(modelId)] ?? clinePassModelInfoSaneDefaults
}
export const openAiModelInfoSafeDefaults: OpenAiCompatibleModelInfo = {
@@ -24,6 +24,7 @@ function convertClineAskToProtoEnum(ask: AppClineAsk | undefined): ClineAsk | un
new_task: ClineAsk.NEW_TASK,
condense: ClineAsk.CONDENSE,
summarize_task: ClineAsk.SUMMARIZE_TASK,
report_bug: ClineAsk.REPORT_BUG,
use_subagents: ClineAsk.USE_SUBAGENTS,
}
@@ -56,6 +57,7 @@ function convertProtoEnumToClineAsk(ask: ClineAsk): AppClineAsk | undefined {
[ClineAsk.NEW_TASK]: "new_task",
[ClineAsk.CONDENSE]: "condense",
[ClineAsk.SUMMARIZE_TASK]: "summarize_task",
[ClineAsk.REPORT_BUG]: "report_bug",
[ClineAsk.USE_SUBAGENTS]: "use_subagents",
}
+2 -1
View File
@@ -54,7 +54,8 @@ export class Logger {
fullMessage += ` ${args.map((arg) => JSON.stringify(arg)).join(" ")}`
}
const errorSuffix = error?.message ? ` ${error.message}` : ""
Logger.output(`${level} ${fullMessage}${errorSuffix}`.trimEnd())
const ts = new Date().toISOString()
Logger.output(`${ts} ${level} ${fullMessage}${errorSuffix}`.trimEnd())
} catch {
// do nothing if Logger fails
}
+11
View File
@@ -2,6 +2,7 @@ export interface SlashCommand {
name: string
description?: string
section?: "default" | "custom" | "mcp"
cliCompatible?: boolean
}
export const BASE_SLASH_COMMANDS: SlashCommand[] = [
@@ -9,21 +10,31 @@ export const BASE_SLASH_COMMANDS: SlashCommand[] = [
name: "newtask",
description: "Create a new task with context from the current task",
section: "default",
cliCompatible: true,
},
{
name: "deep-planning",
description: "Create a comprehensive implementation plan before coding",
section: "default",
cliCompatible: true,
},
{
name: "smol",
description: "Condenses your current context window",
section: "default",
cliCompatible: true,
},
{
name: "newrule",
description: "Create a new Cline rule based on your conversation",
section: "default",
cliCompatible: true,
},
{
name: "reportbug",
description: "Create a Github issue with Cline",
section: "default",
cliCompatible: true,
},
]
+1
View File
@@ -27,6 +27,7 @@ export enum ClineDefaultTool {
WEB_SEARCH = "web_search",
CONDENSE = "condense",
SUMMARIZE_TASK = "summarize_task",
REPORT_BUG = "report_bug",
NEW_RULE = "new_rule",
APPLY_PATCH = "apply_patch",
USE_SKILL = "use_skill",
+1 -1
View File
@@ -1,3 +1,3 @@
export function isClineProvider(provider: string | undefined) {
export function isClineManagedProvider(provider: string | undefined) {
return provider === "cline" || provider === "cline-pass"
}
@@ -1,67 +0,0 @@
import { existsSync } from "node:fs"
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { join } from "node:path"
import { describe, expect, it } from "bun:test"
import { $ } from "bun"
/**
* Integration test for CLINE-2584: the plugin sandbox bootstrap
* (`plugin-sandbox-bootstrap.js`) must be shipped with the VS Code extension.
*
* The bootstrap runs in an isolated child process spawned by
* `SubprocessSandbox` it cannot be inlined into `extension.js` because the
* sandbox spawns it via `node <bootstrapFile>`. The CLI build copies this
* file (`apps/cli/bun.mts`); the extension build (`esbuild.mjs`) must do the
* same.
*
* The bootstrap also has external runtime dependencies that must be resolvable
* from its on-disk location via Node's standard module resolution:
* - jiti (TypeScript transpilation of .ts plugins)
* - @cline/shared, @cline/sdk (host-provided SDK packages that plugins import)
*
* This test runs the real `bun esbuild.mjs` build and checks the real
* `dist/` output, exercising the same build pipeline CI uses.
*/
const projectRoot = join(import.meta.dir, "..", "..")
const distDir = join(projectRoot, "dist")
const bootstrapPath = join(distDir, "extensions", "plugin-sandbox-bootstrap.js")
describe("plugin-sandbox bootstrap build artifact (CLINE-2584)", () => {
it("esbuild.mjs emits plugin-sandbox-bootstrap.js into dist/", async () => {
const result = await $`bun esbuild.mjs`.cwd(projectRoot).quiet()
expect(result.exitCode).toBe(0)
expect(existsSync(join(distDir, "extension.js"))).toBe(true)
expect(existsSync(bootstrapPath)).toBe(true)
}, 60_000)
it("the bootstrap is a real executable script with IPC handling", async () => {
expect(existsSync(bootstrapPath)).toBe(true)
const content = await readFile(bootstrapPath, "utf8")
expect(content.length).toBeGreaterThan(1000)
expect(content).toMatch(/process\.on\(.process\.message|process\.send|type:\s*["']response["']/)
}, 60_000)
it("the bootstrap's runtime dependencies resolve from dist/", () => {
expect(existsSync(bootstrapPath)).toBe(true)
// The bootstrap is spawned as a standalone Node child process. It
// imports jiti (for TypeScript transpilation) and @cline/shared as
// external modules, and plugins import @cline/sdk. Node resolves
// these by walking up from the bootstrap's directory. All must be
// direct dependencies of the extension so they appear in
// node_modules and are resolvable.
const requireFromBootstrap = createRequire(bootstrapPath)
expect(() => requireFromBootstrap.resolve("jiti")).not.toThrow()
expect(() => requireFromBootstrap.resolve("@cline/shared")).not.toThrow()
// @cline/sdk is a host-provided SDK specifier that plugins import.
// The bootstrap's findHostPackageRoot walks up from dist/extensions/
// looking for node_modules/@cline/sdk/package.json.
expect(
existsSync(join(projectRoot, "node_modules", "@cline", "sdk", "package.json")),
).toBe(true)
}, 60_000)
})
+285
View File
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import * as sinon from "sinon"
import { Controller } from "../core/controller"
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands"
import { EmptyRequest } from "../shared/proto/cline/common"
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands"
/**
* Unit tests for getAvailableSlashCommands RPC endpoint
* Tests the slash command discovery and filtering functionality
*/
describe("getAvailableSlashCommands", () => {
let mockController: Partial<Controller>
let mockStateManager: {
getWorkspaceStateKey: sinon.SinonStub
getGlobalSettingsKey: sinon.SinonStub
getGlobalStateKey: sinon.SinonStub
getRemoteConfigSettings: sinon.SinonStub
}
beforeEach(() => {
mockStateManager = {
getWorkspaceStateKey: sinon.stub(),
getGlobalSettingsKey: sinon.stub(),
getGlobalStateKey: sinon.stub(),
getRemoteConfigSettings: sinon.stub(),
}
// Default stubs return empty/null values
mockStateManager.getWorkspaceStateKey.returns(null)
mockStateManager.getGlobalSettingsKey.returns(null)
mockStateManager.getGlobalStateKey.returns(null)
mockStateManager.getRemoteConfigSettings.returns(null)
mockController = {
stateManager: mockStateManager as any,
}
})
afterEach(() => {
sinon.restore()
})
describe("Base Slash Commands", () => {
it("should return all base slash commands", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should have at least all base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
// Verify each base command is present
for (const baseCmd of BASE_SLASH_COMMANDS) {
const found = response.commands.find((cmd) => cmd.name === baseCmd.name)
found!.should.not.be.undefined()
found!.description.should.equal(baseCmd.description)
found!.section.should.equal("default")
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false)
}
})
it("should not include the deprecated subagent slash command", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const deprecatedCommand = response.commands.find((cmd) => cmd.name === "subagent")
;(deprecatedCommand === undefined).should.be.true()
})
it("should mark base commands with section 'default'", async () => {
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name)
for (const cmd of response.commands) {
if (baseCommandNames.includes(cmd.name)) {
cmd.section.should.equal("default")
}
}
})
})
describe("Local Workflow Toggles", () => {
it("should include enabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/path/to/my-workflow.md": true,
"/path/to/another-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
myWorkflow!.should.not.be.undefined()
myWorkflow!.section.should.equal("custom")
myWorkflow!.cliCompatible.should.equal(true)
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow.md")
anotherWorkflow!.should.not.be.undefined()
})
it("should exclude disabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/path/to/enabled-workflow.md": true,
"/path/to/disabled-workflow.md": false,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
enabled!.should.not.be.undefined()
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
;(disabled === undefined).should.be.true()
})
it("should extract filename from full path", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
workflow!.should.not.be.undefined()
})
it("should handle Windows-style paths", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
workflow!.should.not.be.undefined()
})
})
describe("Global Workflow Toggles", () => {
it("should include enabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/global-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
workflow!.should.not.be.undefined()
workflow!.section.should.equal("custom")
})
it("should exclude disabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/disabled-global.md": false,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
;(workflow === undefined).should.be.true()
})
})
describe("Workflow Deduplication", () => {
it("should prefer local workflows over global workflows with same name", async () => {
// Same filename in both local and global
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/local/path/shared-workflow.md": true,
})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/shared-workflow.md": true,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should only appear once
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
matches.length.should.equal(1)
})
it("should include global workflow if local with same name is disabled", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
"/local/path/shared-workflow.md": false, // disabled locally
})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
"/global/path/shared-workflow.md": true, // enabled globally
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Global should appear since local is disabled
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow.md")
workflow!.should.not.be.undefined()
})
})
describe("Remote Workflows", () => {
it("should include alwaysEnabled remote workflows", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "always-on-workflow", alwaysEnabled: true }],
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "always-on-workflow")
workflow!.should.not.be.undefined()
workflow!.section.should.equal("custom")
})
it("should include remote workflows enabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "toggle-workflow", alwaysEnabled: false }],
})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
"toggle-workflow": true, // not explicitly disabled
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "toggle-workflow")
workflow!.should.not.be.undefined()
})
it("should exclude remote workflows explicitly disabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "disabled-remote", alwaysEnabled: false }],
})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
"disabled-remote": false,
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "disabled-remote")
;(workflow === undefined).should.be.true()
})
it("should include remote workflows by default if not explicitly disabled", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [{ name: "default-enabled", alwaysEnabled: false }],
})
// No toggle entry for this workflow
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
const workflow = response.commands.find((cmd) => cmd.name === "default-enabled")
workflow!.should.not.be.undefined()
})
})
describe("Edge Cases", () => {
it("should handle null/undefined state values gracefully", async () => {
mockStateManager.getWorkspaceStateKey.returns(null)
mockStateManager.getGlobalSettingsKey.returns(undefined)
mockStateManager.getGlobalStateKey.returns(null)
mockStateManager.getRemoteConfigSettings.returns(null)
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should still return base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
})
it("should handle empty workflow toggle objects", async () => {
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({})
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({})
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [],
})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should only have base commands
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length)
})
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
mockStateManager.getRemoteConfigSettings.returns({})
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
// Should not throw, just return base commands
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
})
})
})
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
"src/core/controller/models/__tests__/refreshClineRecommendedModels.test.ts",
"src/core/controller/models/__tests__/refreshGroqModels.test.ts",
],
environment: "node",
// Several suites lazily `await import()` their subject inside the first test
@@ -780,6 +780,15 @@ export const CondenseConversation = quickStory(
"Would you like me to condense the conversation to improve performance?",
"Shows utility action to condense conversation for better performance.",
)
export const ReportBug = quickStory(
"Report Bug",
"report_bug",
JSON.stringify({
steps_to_reproduce: "1. Open Cline\n2. Start a new task\n3. Observe the error",
what_happened: "Cline crashes unexpectedly",
}),
"Shows utility action to report bugs to the GitHub repository.",
)
export const ResumeCompletedTask = quickStory(
"Resume Completed Task type",
"resume_completed_task",
@@ -55,6 +55,7 @@ import { MarkdownRow } from "./MarkdownRow"
import NewTaskPreview from "./NewTaskPreview"
import PlanCompletionOutputRow from "./PlanCompletionOutputRow"
import QuoteButton from "./QuoteButton"
import ReportBugPreview from "./ReportBugPreview"
import { RequestStartRow } from "./RequestStartRow"
import SearchResultsDisplay from "./SearchResultsDisplay"
import SubagentStatusRow from "./SubagentStatusRow"
@@ -506,10 +507,11 @@ export const ChatRowContent = memo(
{tool.path && !tool.path.startsWith(".") && <span>/</span>}
<span className="ph-no-capture whitespace-nowrap overflow-hidden text-ellipsis mr-2 text-left [direction: rtl]">
{cleanPathPrefix(tool.path ?? "") + "\u200E"}
{tool.readLineStart != null && tool.readLineEnd != null ? (
{tool.readLineStart != null ? (
<span className="opacity-80">
{" "}
({tool.readLineStart}-{tool.readLineEnd})
({tool.readLineStart}
{tool.readLineEnd != null ? `-${tool.readLineEnd}` : "+"})
</span>
) : null}
</span>
@@ -1158,6 +1160,16 @@ export const ChatRowContent = memo(
<NewTaskPreview context={message.text || ""} />
</div>
)
case "report_bug":
return (
<div>
<div className={HEADER_CLASSNAMES}>
<FilePlus2Icon className="size-2" />
<span className="text-foreground font-bold">Cline wants to create a Github issue:</span>
</div>
<ReportBugPreview data={message.text || ""} />
</div>
)
case "plan_mode_respond": {
let response: string | undefined
let options: string[] | undefined
@@ -224,7 +224,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
remoteConfigSettings,
navigateToSettingsModelPicker,
mcpServers,
pluginSlashCommands,
} = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [isDraggingOver, setIsDraggingOver] = useState(false)
@@ -490,7 +489,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
remoteWorkflowToggles,
remoteConfigSettings?.remoteGlobalWorkflows,
mcpServers,
pluginSlashCommands,
)
if (allCommands.length === 0) {
@@ -516,7 +514,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
remoteWorkflowToggles,
remoteConfigSettings?.remoteGlobalWorkflows,
mcpServers,
pluginSlashCommands,
)
if (commands.length > 0) {
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
@@ -676,7 +673,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
slashCommandsQuery,
handleSlashCommandsSelect,
sendingDisabled,
pluginSlashCommands,
],
)
@@ -988,8 +984,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
globalWorkflowToggles,
remoteWorkflowToggles,
remoteConfigSettings?.remoteGlobalWorkflows,
mcpServers,
pluginSlashCommands,
)
if (isValidCommand) {
@@ -1003,14 +997,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
highlightLayerRef.current.innerHTML = processedText
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}, [
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteConfigSettings,
mcpServers,
pluginSlashCommands,
])
}, [localWorkflowToggles, globalWorkflowToggles, remoteWorkflowToggles, remoteConfigSettings])
useLayoutEffect(() => {
updateHighlights()
@@ -1131,6 +1118,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
switch (selectedProvider) {
case "cline":
return `${selectedProvider}:${selectedModelId}`
case "cline-pass":
// Free models selected on ClinePass go through Cline usage billing,
// so label them the same way as the cline provider
return selectedModelId.startsWith("cline-pass/")
? `${selectedProvider}:${selectedModelId.replace(/^cline-pass\//, "")}`
: `cline:${selectedModelId}`
case "openai":
return `openai-compat:${selectedModelId}`
case "vscode-lm":
@@ -1413,7 +1406,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
mcpServers={mcpServers}
onMouseDown={handleMenuMouseDown}
onSelect={handleSlashCommandsSelect}
pluginSlashCommands={pluginSlashCommands}
query={slashCommandsQuery}
remoteWorkflows={remoteConfigSettings?.remoteGlobalWorkflows}
remoteWorkflowToggles={remoteWorkflowToggles}
@@ -1656,6 +1648,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"pt-0.5 pb-px px-2 z-10 text-xs w-1/2 text-center bg-transparent",
mode === m.toLowerCase() ? "text-white" : "text-input-foreground",
)}
key={m}
onMouseLeave={() => setShownTooltipMode(null)}
onMouseOver={() => setShownTooltipMode(m.toLowerCase() === "plan" ? "plan" : "act")}
role="switch">

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