Compare commits

...
Author SHA1 Message Date
abeatrix 1e45137c24 example: use cline sdk 2026-03-09 21:40:23 -07:00
Bee 03ab2968a6 feat: responses api for openai native provider (#9411)
* fix: openai native provider token usage mapping

- Add `store` parameter support to OpenAI native provider options to allow persisting completions.
- Fix incorrect mapping of `cached_tokens` and `reasoning_tokens` in usage statistics.
- Include `thoughtsTokenCount` in the final usage report to track reasoning model performance and costs.

* feat: add websocket support for OpenAI Responses API

This commit introduces WebSocket support for the OpenAI Native provider's Responses API, providing an alternative to the standard HTTP streaming.

- Implement `createResponseStreamWebsocket` in `OpenAiNativeHandler` with a fallback to HTTP on failure.
- Refactor `OpenAiNativeHandler` to modularize tool mapping and parameter construction for the Responses API.
- Update `OcaHandler` to explicitly disable `previousResponseId` when using the Responses API and add validation for model information.
- Integrate `undici` WebSocket for better compatibility in the extension environment.

* disablePreviousResponseId

* feat: add timestamp to conversation messages for response chaining

Add `ts` field to `ClineStorageMessage` to track when messages were
created. Use this timestamp to enforce a 23-hour validity window when
chaining OpenAI responses via `previousResponseId`, since the API only
retains responses for 24 hours. Also fix non-null assertion operators
in tests to use optional chaining for safer access.

* add OpenAI Responses Websocket Mode ApiFormat support

- Add `OPENAI_RESPONSES_WEBSOCKET_MODE` to the `ApiFormat` enum in proto definitions.
- Update `OpenAiNativeHandler` to use the new API format for determining when to use websocket mode, replacing previous environment-based logic.
- Refactor tool mapping for OpenAI Responses to support strict mode and correctly handle null parameters.
- Ensure the `store` option is disabled when `previous_response_id` is present in websocket mode.
- Bump version to 2.4.1 and update package dependencies.

* use abortController

* add support for websocket mode to openai-codex

* set behind feature flag
2026-02-20 17:17:54 -08:00
MaxandMax Paulus 🥪 a0d52d4d59 cli yolo mode should not persist yolo setting to disk ever (#9370)
- added a method to StateManager, setSessionOverride, which overrides
state settings while the statemanager lives in memory

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-20 11:16:29 -08:00
MaxandMax Paulus 🥪 75fbeb4aad fix cline auth with acp flag (#9405)
- was missing a check for "cline:clineAccountId" in the isAuthed method
of acp agent

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-20 11:15:45 -08:00
Bee 70db6bde34 fix: inline focus-chain slider within its feature row (#9444)
* fix: inline focus-chain slider within its feature row

Moves the focus-chain reminder interval `SettingsSlider` from a
standalone element rendered after all experimental feature rows to
being rendered directly beneath the focus-chain `FeatureRow`. The
slider now renders conditionally when `feature.id === "focus-chain"`
and the feature is enabled, improving UI cohesion and making the
relationship between the toggle and its configuration more explicit.

Additionally:
- Relocates focus-chain from `experimentalFeatures` to `agentFeatures`
- Removes the `isExperimental` prop and "Experimental:" label badge
  from `FeatureRow` and related feature toggle definitions
- Simplifies `SettingsSlider` markup by removing the wrapper card
  styling, making it suitable for inline embedding
  - Removes unused line from common.ts

* nestedKey
2026-02-20 10:58:46 -08:00
Saoud Rizwan 02c2601e0e fix(gemini): add 3.1 pro while keeping 3.0 compatibility (#9438) 2026-02-20 09:18:40 -08:00
Juan Pablo Flores 94692b5091 docs: add MCP support documentation for Cline CLI (#9390) 2026-02-20 09:39:02 -06:00
Robin Newhouse a2794c680f fix(evals): restore missing smoke eval npm scripts (#9429) 2026-02-19 14:49:27 -08:00
CandiedUniverse 6d3f8e1d5d fix(release-eng): Pin VSCode nightly build to node version 22 (#9423)
* fix(release-eng): Pin VSCode nightlybuild to node version 22

* fix(release-eng): Pin publish.yml GitHub workflow to node version 22
2026-02-19 12:04:14 -08:00
cryptoque 3e5847890b feat: add dynamic flag to adjust banner cache duration (#9421) 2026-02-19 11:03:50 -08:00
CandiedUniverse 0eab54ab12 fix(release-eng): Fix nightly extension publish failure caused by workspace self-link mismatch (#9420) 2026-02-19 10:40:45 -08:00
7fa0a4924b Changeset version bump (#9413)
* changeset version bump

* Updating CHANGELOG.md format

* Adding 1m

* Adding 1m

* Adding 1m

* Adding 1m

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-02-19 10:36:21 -08:00
Junjie Tang 8680218e0e Update sap-ai-sdk version (#9417) 2026-02-19 06:46:09 -08:00
Dominic Cooney 8787ab35b9 Update package-lock.json. (#9414) 2026-02-18 22:56:49 -08:00
Dominic Cooney 6ed3944f04 Export global, workspace and secrets from VSCode to share with CLI (#9227)
Caveat: Task history, tasks, etc. are not written to the same place by all clients yet. This is just about globalState, workspaceState and secrets.
2026-02-19 15:46:23 +09:00
Bee 1dd8e763d1 fix(chat): make Cmd/Ctrl+A select-all deterministic (#9408)
Handle plain Cmd/Ctrl+A directly in `ChatTextArea` keydown to force
textarea-wide selection via `setSelectionRange`, while preventing default
and propagation. This avoids intermittent failures caused by global shortcut
listener races, and keeps Cmd/Ctrl+Shift+A behavior unchanged.fix(chat): make Cmd/Ctrl+A select-all deterministic

Handle plain Cmd/Ctrl+A directly in `ChatTextArea` keydown to force
textarea-wide selection via `setSelectionRange`, while preventing default
and propagation. This avoids intermittent failures caused by global shortcut
listener races, and keeps Cmd/Ctrl+Shift+A behavior unchanged.
2026-02-18 20:55:57 -08:00
Bee 28e6297769 fix: flaky Cancel behavior by preventing duplicate cancel actions (CLINE-1380) (#9409)
* fix: flaky Cancel behavior by preventing duplicate cancel actions

This PR fixes chat cancel behavior where users sometimes had to click Cancel multiple times, and repeated clicks could accidentally transition into Resume/restart behavior.

* move to finally
2026-02-18 20:55:41 -08:00
Bee 5a2a5d1c0a refactor: replace non-null assertions with checks in PatchParser (#9402)
* refactor: replace non-null assertions with safe null checks in PatchParser

Replace all forbidden non-null assertions (`!`) in PatchParser.ts with
safe alternatives using optional chaining (`?.`) and nullish coalescing
(`?? ""`/`?? 0`). Also refactor the Levenshtein distance matrix from a
2D array to a flat array to eliminate index-based non-null assertions,
improving type safety and code robustness.

No feature behavior changes.

* simplify Levenshtein matrix indexing

Initialize the distance matrix with zeroes and add `at`/`set` helpers for flat-array access in `levenshteinDistance`.

This removes repeated index math and nullish fallbacks, making the algorithm easier to read while keeping bounds-safe access and identical behavior.refactor(patch-parser): simplify Levenshtein matrix indexing

Initialize the distance matrix with zeroes and add `at`/`set` helpers for flat-array access in `levenshteinDistance`.

This removes repeated index math and nullish fallbacks, making the algorithm easier to read while keeping bounds-safe access and identical behavior.
2026-02-18 19:35:48 -08:00
Max 113039a259 CLI 2.0: allow custom inference profile arn for bedrock provider (#9271) 2026-02-18 19:16:32 -08:00
Max 4023c18257 remove default timeout (#9401) 2026-02-18 17:28:50 -08:00
cryptoque 7c95b53892 Add support for DB backed Welcome Banner (#9315)
* feat: add welcome banner support from backend

* make DB banner format conform with existing banners

* add support for welcome banner actions

* remove debugging helper that bypass dismissal, dismissal should work again

* undo changes to make welcome banner always appear during debugging

* remove console logs for debugging

* clean up bannerservice

* clean up welcomesection.tsx

* add new tests for ide type filtering

* add welcome banner own feature flag and conditionally display between hard coded welcome banner and DB backed ones

* turn on welcome banner flag locally by default

* close welcome banners when clicking on actions

* apply bot review suggestion, fix memory leak

* address feedback: use p without span

* split welcome banners into a seperate component to keep whatsnewmodal clean

* get action through api schema instead of extractin it from rules_json

* use only bannerWaitTimeoutRef, remove waitingForBannersRef

* resolve new merge conflict

* linter

* cerebra
2026-02-18 14:52:02 -08:00
Bee 9fd2b99be4 chore: remove autoCondenseThreshold setting and related code (#9396)
- Remove `auto_condense_threshold` from `Settings` and `UpdateSettingsRequest` in `state.proto`.
- Remove `autoCondenseThreshold` from `ApiProviderInfo` interface.
- Update `generate-state-proto.mjs` to remove double field handling and improve integer parsing.
- Add error handling to `ContextManager` when parsing previous request JSON to prevent crashes on malformed data.
2026-02-18 12:43:30 -08:00
7c782abaf4 Changeset version bump (#9364)
* changeset version bump

* Updating CHANGELOG.md format

* update package versions

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-18 12:22:53 -08:00
Ara 3a14a88f4f fix: include root workspace for changesets release (#9393) 2026-02-18 11:40:02 -08:00
Saoud Rizwan 28d60a83a4 fix(models): keep Sonnet 4.5 as default for now (#9389)
* fix(models): keep Sonnet 4.5 as default

* chore(changeset): add release note for Sonnet 4.5 default

* fix(models): remove Sonnet 4.6 from curated model lists

* fix(models): restore Sonnet 4.6 in web recommended list
2026-02-18 10:53:07 -08:00
Saoud Rizwan ae6468b161 fix(models): reinstate minimax m2.5 free promo surfaces (#9387) 2026-02-18 10:28:54 -08:00
Saoud Rizwan af93e31862 feat(models): make sonnet 4.6 default and remove free promo positioning (#9377) 2026-02-18 10:07:20 -08:00
Tomás Barreiro ca154eb8f5 Add MiniMax M2.5 to the MiniMax provider (#9381)
* Add MiniMax M2.5 to the MiniMax provider

* Add changeset

* Update default model
2026-02-18 09:16:23 -08:00
Tomás Barreiro 1d8497c6bf Prevent error messages when displaying featured models in the CLI (#9379)
* Fix the featured models key

* Add changeset
2026-02-18 16:51:29 +01:00
Seb Duerr 5871fd02b1 feat(cerebras): remove deprecated llama-3.3-70b and qwen-3-32b models (#9345)
* feat(cerebras): remove deprecated llama-3.3-70b and qwen-3-32b models

These models have been deprecated from the Cerebras inference platform.

- Remove llama-3.3-70b and qwen-3-32b from cerebrasModels in api.ts
- Update supported models documentation in cerebras.mdx
- Add changeset for the deprecation

* fix: remove stale llama-3.3-70b and qwen-3-32b references from rate limits

Remove dead switch cases in getRateLimits() that referenced deprecated models
no longer present in cerebrasModels.
2026-02-17 23:39:38 -08:00
alex-lum 2d81c310d2 Alex/inf 413 bug no telemetry for versions greater than 20 (#9372) 2026-02-17 18:55:07 -08:00
Robin NewhouseandCursor 0c691f72d2 feat(cli): add /skills slash command (#9089)
* feat(cli): add /skills slash command for managing skills

- Add /skills to CLI_ONLY_COMMANDS in slashCommands.ts
- Create SkillsPanelContent component with:
  - Display global and workspace skills with toggle indicators
  - Enter to use skill (inserts @path into input)
  - Space to toggle skill enabled/disabled
  - Selectable marketplace link to skills.sh
  - Keyboard navigation with arrow keys and vim keys
- Wire up panel in ChatView.tsx
- Add comprehensive tests for keyboard interactions

* refactor(cli): use static skill controller imports

* fix(cli): add React import to skills panel test

* fix(cli): suppress required React import lint in skills test

* fix(cli): harden /skills panel interactions

Revert optimistic skill toggle state when persistence fails, and surface a fallback URL when opening the marketplace fails. Also tighten and extend tests to verify exact marketplace URL handling and rollback behavior.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 17:13:51 -08:00
Robin NewhouseandCursor a8409137b3 fix: disable click-to-set auto-condense threshold and hardcode default (#9348)
* fix: disable click-to-set auto-condense threshold and hardcode default

Clicking anywhere on the context window progress bar silently set
autoCondenseThreshold to a value based on click position (e.g. 0.05),
persisting in globalState. This caused compaction to fire at ~10K tokens
instead of the intended ~150K, resulting in ~20 context resets per task.

- Comment out click and keyboard handlers on progress bar (keep components
  for future release with proper UX)
- Hardcode threshold to 0.75 default, ignoring corrupted stored values

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

* test: add shouldCompactContextWindow unit tests

Cover threshold math including the accidental low-threshold bug case,
undefined/zero fallbacks, cache token inclusion, and maxAllowedSize cap.

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

* fix: hardcode autoCondenseThreshold in all remaining callsites

Address Greptile review: SubagentRunner.ts, task/index.ts display
logic, and controller/index.ts webview state all still read the
corrupted value from globalState. Hardcode 0.75 everywhere.

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

* style: remove unnecessary union type on hardcoded threshold

Drop `number | undefined` annotation from the hardcoded 0.75 literal
in SubagentRunner.ts per Greptile review feedback.

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

* refactor: use SETTINGS_DEFAULTS constant, remove commented-out code, clarify test

- Replace hardcoded 0.75 with SETTINGS_DEFAULTS.autoCondenseThreshold
  across all 4 callsites for a single source of truth
- Delete commented-out click/keyboard handlers in ContextWindow.tsx,
  replace with TODO referencing PR #9348
- Make bug-case test self-documenting by deriving token values from
  the threshold calculation

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 16:32:13 -08:00
Saoud Rizwan 34a21b8e26 docs: add security policy (#9365) 2026-02-17 16:00:01 -08:00
eb9f53edf4 fix: smarter retry for write_to_file missing content parameter (#9276)
* fix: smarter retry for write_to_file missing content parameter (#7998)

Replace generic 'missing parameter' error with progressive guidance when
write_to_file fails due to empty content parameter. This breaks the
infinite retry loop where the model repeatedly attempts the same
write_to_file call that exceeds output token limits.

Changes:
- Add writeToFileMissingContentError() to formatResponse with 3 tiers:
  1st failure: suggestions (use skeleton + replace_in_file)
  2nd failure: strong directive (stop retrying write_to_file)
  3rd+ failure: CRITICAL stop, forces alternative strategies
- Add context window awareness: warns model when >50% context used
- Add getContextUsagePercent() helper to WriteToFileToolHandler
- Add 22 unit tests covering progressive escalation and context awareness

Fixes #7998

* add changeset for write_to_file retry fix

* refactor: simplify write_to_file error handling per review

- Simplify writeToFileMissingContentError to single-tier error following
  existing diffError pattern (no progressive escalation)
- Use shared getLastApiReqTotalTokens() for context window awareness
- Remove private getContextUsagePercent() method from handler
- Add proactive skeleton + replace_in_file guidance to write_to_file
  tool description for all variants
- Simplify tests to match new API (11 tests)

* test: update system prompt snapshots

* chore: revert write_to_file prompt guidance

* feat: restore progressive 3-tier guidance for write_to_file missing content

Restore the progressive escalation that was removed in dd3c12d4e:
- Tier 1 (1st failure): Gentle suggestions (skeleton + replace_in_file)
- Tier 2 (2nd failure): Strong directive, 'Do NOT attempt full write again'
- Tier 3 (3rd+ failure): CRITICAL stop, forces alternative strategies
- Context window warning when >50% full
- Dynamic UI message: 'Retrying...' vs 'multiple times — different approach'
- 21 tests covering all tiers and context awareness

* nit: extract context window warning threshold to named constant

Also replace emoji with plain text in warning message for consistency.

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

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 19:08:42 -03:00
github-actions[bot]andArafatkatze c60f18d907 Adding 1m (#9346)
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-02-17 11:11:54 -08:00
Saoud Rizwan 36c68a6ab9 fix: remove expired MiniMax free promo surfaces (#9361)
* fix: remove expired MiniMax free promo surfaces

* fix: remove MiniMax M2.5 from recommended models

* chore: update GLM 5 whats-new promo wording
2026-02-17 10:57:19 -08:00
Saoud Rizwan 80dfce0f60 docs: remove stale Claude 5 mention from Auto Compact docs (#9360)
* docs: remove stale Claude 5 wording from auto compact docs

* Remove stale Claude 5 mention from docs
2026-02-17 10:48:23 -08:00
Saoud Rizwan 955ae2f62f feat: add Claude Sonnet 4.6 support and surface it as free (#9356)
* feat: add Sonnet 5 support and make it default across surfaces

* feat: surface Sonnet 5 as free while keeping Sonnet 4.5 defaults

* fix: rename Sonnet 5 support to Sonnet 4.6 across providers and UI

* fix: allow duplicate onboarding model ids across free and frontier

* chore: update Sonnet 4.6 banner to limited-time free messaging

* fix: align Bedrock Sonnet 4.6 model ids with AWS format

* feat: update whats new promo to Sonnet 4.6 free offer

* chore: update Sonnet 4.6 promo copy and timing
2026-02-17 10:43:24 -08:00
Dominic Cooney 8cb0c6d236 Fix e2e tests. (#9350) 2026-02-17 15:06:40 +09:00
155 changed files with 6644 additions and 4112 deletions
+4
View File
@@ -0,0 +1,4 @@
"claude-dev": patch
---
Add missing smoke evaluation npm scripts so documented commands like `npm run eval:smoke:run` work from the repository root.
+64
View File
@@ -0,0 +1,64 @@
# Storage Architecture
Global settings, secrets and workspace state are stored in **file-backed JSON stores** under `~/.cline/data/`. This is the shared storage layer used by VSCode, CLI, and JetBrains.
## Key Abstractions
### `StorageContext` (src/shared/storage/storage-context.ts)
The entry point. Created via `createStorageContext()` and passed to `StateManager.initialize()`. Contains three `ClineFileStorage` instances:
- `globalState``~/.cline/data/globalState.json`
- `secrets``~/.cline/data/secrets.json` (mode 0o600)
- `workspaceState``~/.cline/data/workspaces/<hash>/workspaceState.json`
### `ClineFileStorage` (src/shared/storage/ClineFileStorage.ts)
Synchronous JSON key-value store backed by a single file. Supports `get()`, `set()`, `setBatch()`, `delete()`. Writes are atomic (write-then-rename).
### `StateManager` (src/core/storage/StateManager.ts)
In-memory cache on top of `StorageContext`. All runtime reads hit the cache; writes update cache immediately and debounce-flush to disk.
## ⚠️ Do NOT Use VSCode's ExtensionContext for Storage
**Do not** read from or write to `context.globalState`, `context.workspaceState`, or `context.secrets` for persistent data. These are VSCode-specific and not available on CLI or JetBrains.
Instead, use:
```typescript
// Reading state
StateManager.get().getGlobalStateKey("myKey")
StateManager.get().getSecretKey("mySecretKey")
StateManager.get().getWorkspaceStateKey("myWsKey")
// Writing state
StateManager.get().setGlobalState("myKey", value)
StateManager.get().setSecret("mySecretKey", value)
StateManager.get().setWorkspaceState("myWsKey", value)
```
Remember that your data may be read by a different client than the one that wrote it. For example, a value written by Cline in JetBrains may be read by Cline CLI.
## VSCode Migration (src/hosts/vscode/vscode-to-file-migration.ts)
On VSCode startup, a migration copies data from VSCode's `ExtensionContext` storage into the file-backed stores. This runs in `src/common.ts` before `StateManager.initialize()`.
- **Sentinel**: `__vscodeMigrationVersion` key in global state and workspace state — prevents re-migration.
- **Merge strategy**: File store wins. Existing values are never overwritten.
- **Safe downgrade**: VSCode storage is NOT cleared, so older extension versions still work.
## Adding New Storage Keys
1. Add to `src/shared/storage/state-keys.ts` (see existing patterns)
2. Read/write via `StateManager` (NOT via `context.globalState`)
3. If adding a secret, add to `SecretKeys` array in `state-keys.ts`
## File Layout
```
~/.cline/
data/
globalState.json # Global settings & state
secrets.json # API keys (mode 0o600)
tasks/
taskHistory.json # Task history (separate file)
workspaces/
<hash>/
workspaceState.json # Per-workspace toggles
```
@@ -39,6 +39,7 @@ jobs:
!endsWith(github.actor, '[bot]')
)
uses: ./.github/workflows/npm-main.yaml
secrets: inherit
with:
confirm_publish: ${{ github.event.inputs.confirm_publish }}
@@ -49,5 +50,6 @@ jobs:
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_target == 'nightly')
)
uses: ./.github/workflows/npm-nightly.yaml
secrets: inherit
with:
force_publish: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
+3 -1
View File
@@ -36,7 +36,9 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
node-version: 22
- name: Install root dependencies
run: npm ci --include=optional
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
node-version: 22
- name: Install root dependencies
run: npm install --include=optional
+10
View File
@@ -51,3 +51,13 @@ test-results
# Smoke test results (generated)
evals/smoke-tests/results/
# output
out
dist
*.tg
.next
target
packages-wip
+3
View File
@@ -1,3 +1,6 @@
[submodule "evals/cline-bench"]
path = evals/cline-bench
url = https://github.com/cline/cline-bench.git
[submodule "sdk-wip"]
path = sdk-wip
url = https://github.com/cline/sdk-wip.git
+42 -13
View File
@@ -1,5 +1,30 @@
# Changelog
## [3.66.0]
### Added
- Gemini-3.1 Pro Preview
## [3.65.0]
### Added
- Add /skills slash command to CLI for viewing and managing installed skills
### Fixed
- Fix aggressive context compaction caused by accidental clicks on the context window progress bar silently setting a very low auto-condense threshold
- Fix infinite retry loop when write_to_file fails with missing content parameter.
- Fixed default claude model
## [3.64.0]
### Added
- Added sonnet 4.6
## [3.63.0]
### Added
@@ -13,6 +38,7 @@
## [3.62.0]
### Fixed
- Banners now display immediately when opening the extension instead of requiring user interaction first
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
@@ -27,11 +53,12 @@
## [3.59.0]
- Added Minimax 2.5 Free Promo
- Fixed Response chaining for OpenAI's Responses API
- Fixed Response chaining for OpenAI's Responses API
## [3.58.0]
### Added
- Subagent: replace legacy subagents with the native `use_subagents` tool
- Bundle `endpoints.json` support so packaged distributions can ship required endpoints out-of-the-box
- Amazon Bedrock: support parallel tool calling
@@ -42,6 +69,7 @@
- ZAI/GLM: add GLM-5
### Fixed
- CLI: handle stdin redirection correctly in CI/headless environments
- CLI: preserve OAuth callback paths during auth redirects
- VS Code Web: generate auth callback URLs via `vscode.env.asExternalUri` (OAuth callback reliability)
@@ -53,6 +81,7 @@
- CI: increase Windows E2E test timeout to reduce flakiness
### Changed
- Settings/model UX: move "reasoning effort" into model configuration and expose it in settings
- CLI provider selection: limit provider list to those remotely configured
- UI: consolidate ViewHeader component/styling across views
@@ -70,7 +99,7 @@
### Added
- Cline CLI 2.0 now available. Install with `npm install -g cline`
- Anthopic Opus 4.6
- Anthopic Opus 4.6
- Minimax-2.1 and Kimi-k2.5 now available for free for a limited time promo
- Codex-5.3 through ChatGPT subscription
@@ -90,23 +119,23 @@
### Added
- __CLI authentication:__ Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
- __New model:__ Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
- __Prompt variant:__ Added Trinity Large prompt variant for improved tool-calling support
- __OpenTelemetry:__ Added support for custom headers on metrics and logs endpoints
- __Social links:__ Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
- **CLI authentication:** Added Vercel AI Gateway and Cline API key provider support for headless CI/automation workflows
- **New model:** Added Kimi-K2.5 model to Moonshot provider (262K context, image support, prompt caching)
- **Prompt variant:** Added Trinity Large prompt variant for improved tool-calling support
- **OpenTelemetry:** Added support for custom headers on metrics and logs endpoints
- **Social links:** Added community icons (X, Discord, GitHub, Reddit, LinkedIn) to the What's New modal
### Fixed
- __LiteLLM:__ Fixed thinking configuration not appearing for reasoning-capable models
- __OpenTelemetry:__ Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
- __CLI auth:__ Fixed `cline auth` displaying incorrect provider information after configuration
- **LiteLLM:** Fixed thinking configuration not appearing for reasoning-capable models
- **OpenTelemetry:** Fixed endpoint path handling (no longer incorrectly appends `/v1/logs` or `/v1/metrics`) and ensured logs are sent regardless of VSCode telemetry settings
- **CLI auth:** Fixed `cline auth` displaying incorrect provider information after configuration
### Changed
- __Hooks:__ Hook scripts now run from the workspace repository root instead of filesystem root
- __Default settings:__ Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
- __Settings UI:__ Refreshed feature settings section with collapsible design
- **Hooks:** Hook scripts now run from the workspace repository root instead of filesystem root
- **Default settings:** Enabled multi-root workspaces, parallel tool calling, and skills by default; disabled strict plan mode by default
- **Settings UI:** Refreshed feature settings section with collapsible design
## [3.55.0]
+27
View File
@@ -0,0 +1,27 @@
# Security Policy
## Supported Versions
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/cline/cline/security/advisories/new) tab.
The team will send a response indicating the next steps in handling your report. After the initial reply, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
When reporting, please include:
- A short summary of the issue
- Steps to reproduce or a proof of concept
- Any logs, stack traces, or screenshots that might help us understand the problem
We acknowledge reports within 48 hours and aim to release a fix or mitigation within 30 days. While we work on a resolution, please keep the details private.
## Escalation
If you do not receive an acknowledgement of your report within 5 business days, you may send an email to security@cline.bot.
Thank you for helping us keep Cline users safe.
+22 -2
View File
@@ -1,13 +1,33 @@
# cline
## 2.2.3
## 2.4.2
### Added
- Gemini-3.1 Pro Preview
### Patch Changes
- c0020e1: Allows users to enter custom aws region when selecting bedrock as a provider in CLI
- VSCode uses shared files for global, workspace and secret state.
## [2.4.1]
### Fixed
- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop.
## [2.4.0]
### Added
- Adding Anthropic Sonnet 4.6
- Allows users to enter custom aws region when selecting bedrock as a provider in CLI
- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning.
- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion.
### Fixed
- Banners now display immediately when opening the extension instead of requiring user interaction first
- Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others)
## [2.2.2]
+4
View File
@@ -56,6 +56,8 @@ Run a new task with a prompt.
**-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete)
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
**-m**, **\--model** *model* : Model to use for the task
**-i**, **\--images** *paths...* : Image file paths to include with the task
@@ -144,6 +146,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava
**-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode.
**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided.
**-m**, **\--model** *model* : Model to use for the task
**-v**, **\--verbose** : Show verbose output
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "2.2.3",
"version": "2.4.2",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "dist/cli.mjs",
"bin": {
+10 -18
View File
@@ -53,7 +53,6 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/index.js"
import { AuthService } from "@/services/auth/AuthService.js"
import { Logger } from "@/shared/services/Logger.js"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import type { Mode } from "@/shared/storage/types"
import { openExternal } from "@/utils/env"
import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js"
@@ -177,7 +176,7 @@ export class ClineAgent implements acp.Agent {
this.clientCapabilities = params.clientCapabilities
this.initializeHostProvider(this.clientCapabilities, connection)
await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR)
await StateManager.initialize(this.ctx.extensionContext)
await StateManager.initialize(this.ctx.storageContext)
return {
protocolVersion: PROTOCOL_VERSION,
@@ -1007,13 +1006,14 @@ export class ClineAgent implements acp.Agent {
const startTime = Date.now()
while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
const stateManager = StateManager.get()
// Check if auth data has been stored
const authData = await secretStorage.get("cline:clineAccountId")
const authData = stateManager.getSecretKey("cline:clineAccountId")
if (authData) {
Logger.debug("[ClineAgent] Authentication successful")
// Set up the provider configuration for cline
const stateManager = StateManager.get()
stateManager.setGlobalState("actModeApiProvider", "cline")
stateManager.setGlobalState("planModeApiProvider", "cline")
await stateManager.flushPendingState()
@@ -1161,13 +1161,15 @@ export class ClineAgent implements acp.Agent {
if (currentProvider === "cline") {
// For Cline provider, check if we have stored auth data
const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key)))
return values.some(Boolean)
return Boolean(
stateManager.getSecretKey("clineApiKey") ||
stateManager.getSecretKey("clineAccountId") ||
stateManager.getSecretKey("cline:clineAccountId"),
)
}
// For OpenAI Codex provider, check OAuth credentials
if (currentProvider === "openai-codex") {
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
return await openAiCodexOAuthManager.isAuthenticated()
}
@@ -1178,14 +1180,7 @@ export class ClineAgent implements acp.Agent {
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
for (const field of fields) {
const value = await secretStorage.get(field)
if (value) {
return true
}
}
return false
return fields.some((key) => stateManager.getSecretKey(key))
}
/**
@@ -1201,9 +1196,6 @@ export class ClineAgent implements acp.Agent {
Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...")
try {
// Initialize the OAuth manager with extension context
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
// Get the authorization URL and start the callback server
const authUrl = openAiCodexOAuthManager.startAuthorizationFlow()
+60 -5
View File
@@ -23,6 +23,7 @@ import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-confi
import { useValidProviders } from "../utils/providers"
import { ApiKeyInput } from "./ApiKeyInput"
import { StaticRobotFrame } from "./AsciiMotionCli"
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import {
FeaturedModelPicker,
@@ -31,7 +32,7 @@ import {
isBrowseAllSelected,
} from "./FeaturedModelPicker"
import { ImportView } from "./ImportView"
import { getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { CUSTOM_MODEL_ID, getDefaultModelId, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { getProviderLabel } from "./ProviderPicker"
@@ -51,6 +52,7 @@ type AuthStep =
| "openai_codex_auth"
| "bedrock"
| "import"
| "bedrock_custom"
interface AuthViewProps {
controller: any
@@ -76,7 +78,7 @@ const Select: React.FC<{
const [selectedIndex, setSelectedIndex] = useState(0)
useInput(
(input, key) => {
(_, key) => {
if (key.upArrow) {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
} else if (key.downArrow) {
@@ -142,7 +144,11 @@ const TextInput: React.FC<{
return (
<Box>
<Text color="white">{displayValue || placeholder || ""}</Text>
{!displayValue && placeholder ? (
<Text color="gray">e.g. {placeholder}</Text>
) : (
<Text color="white">{displayValue || ""}</Text>
)}
<Text inverse> </Text>
</Box>
)
@@ -248,6 +254,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
}, [])
// Reset provider index when search changes
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here
useEffect(() => {
setProviderIndex(0)
}, [providerSearch])
@@ -273,7 +280,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
return
}
if (authState.user && authState.user.email) {
if (authState.user?.email) {
// Auth succeeded - save configuration and transition to model selection
await applyProviderConfig({ providerId: "cline", controller })
setSelectedProvider("cline")
@@ -389,6 +396,33 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
[selectedProvider],
)
// Save custom Bedrock ARN configuration with base model for capability detection
const saveCustomBedrockConfiguration = useCallback(
async (arn: string, baseModelId: string) => {
try {
if (!bedrockConfig) {
throw new Error("Bedrock configuration is missing")
}
await applyBedrockConfig({
bedrockConfig,
modelId: arn,
customModelBaseId: baseModelId,
controller,
})
const stateManager = StateManager.get()
stateManager.setGlobalState("welcomeViewCompleted", true)
await stateManager.flushPendingState()
setStep("success")
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : String(error))
setStep("error")
}
},
[bedrockConfig, controller],
)
const saveConfiguration = useCallback(
async (model: string, base: string) => {
try {
@@ -423,6 +457,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const handleModelIdSubmit = useCallback(
(value: string) => {
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") {
setStep("bedrock_custom")
return
}
if (value.trim()) {
setModelId(value)
}
@@ -530,6 +570,9 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
// Go back to cline_model if we came from there (Cline provider)
if (selectedProvider === "cline") {
setStep("cline_model")
} else if (selectedProvider === "bedrock") {
// Bedrock skips the API key step — go back to Bedrock setup
setStep("bedrock")
} else {
setStep("apikey")
}
@@ -646,7 +689,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
<Box flexDirection="column">
<Text color="white">Model ID</Text>
<Text> </Text>
<Text color="gray">e.g., claude-sonnet-4-20250514, gpt-4o</Text>
<Text color="gray">e.g., claude-sonnet-4-6, gpt-4o</Text>
<Text> </Text>
<TextInput onChange={setModelId} onSubmit={handleModelIdSubmit} placeholder="model-id" value={modelId} />
<Text> </Text>
@@ -741,6 +784,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
/>
)
case "bedrock_custom":
return (
<BedrockCustomModelFlow
isActive={step === "bedrock_custom"}
onCancel={() => setStep("modelid")}
onComplete={(arn, baseModelId) => {
setStep("saving")
saveCustomBedrockConfiguration(arn, baseModelId)
}}
/>
)
case "import":
if (!importSource) {
return null
@@ -0,0 +1,111 @@
/**
* Bedrock Custom Model Flow component
* Two-step flow: ARN/custom model ID input → base model selection for capability detection.
* Used by both AuthView (onboarding) and SettingsPanelContent (/settings).
*/
import { Box, Text, useInput } from "ink"
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime
import React, { useCallback, useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { getModelList } from "./ModelPicker"
import { SearchableList } from "./SearchableList"
type FlowStep = "arn_input" | "base_model"
interface BedrockCustomModelFlowProps {
/** Whether this component should capture keyboard input */
isActive: boolean
/** Called when the user completes both steps (ARN + base model selection) */
onComplete: (arn: string, baseModelId: string) => void
/** Called when the user presses Escape on the first step (ARN input) */
onCancel: () => void
}
export const BedrockCustomModelFlow: React.FC<BedrockCustomModelFlowProps> = ({ isActive, onComplete, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [step, setStep] = useState<FlowStep>("arn_input")
const [customArn, setCustomArn] = useState("")
const handleArnSubmit = useCallback(() => {
if (customArn.trim()) {
setStep("base_model")
}
}, [customArn])
const handleBaseModelCancel = useCallback(() => {
setStep("arn_input")
}, [])
useInput(
(input, key) => {
if (step === "arn_input") {
if (key.escape) {
onCancel()
} else if (key.return) {
handleArnSubmit()
} else if (key.backspace || key.delete) {
setCustomArn((prev) => prev.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
setCustomArn((prev) => prev + input)
}
return
}
if (step === "base_model") {
if (key.escape) {
handleBaseModelCancel()
}
// Other input is handled by SearchableList
}
},
{ isActive: isActive && isRawModeSupported },
)
if (step === "arn_input") {
return (
<Box flexDirection="column">
<Text bold color={COLORS.primaryBlue}>
Custom Model ID
</Text>
<Box marginTop={1}>
<Text color="gray">Enter your Application Inference Profile ARN or custom model ID</Text>
</Box>
<Box marginTop={1}>
{customArn ? (
<Text color="white">{customArn}</Text>
) : (
<Text color="gray">e.g. arn:aws:bedrock:region:account:application-inference-profile/...</Text>
)}
<Text inverse> </Text>
</Box>
<Box marginTop={1}>
<Text color="gray">Enter to continue, Esc to go back</Text>
</Box>
</Box>
)
}
// step === "base_model"
return (
<Box flexDirection="column">
<Text bold color={COLORS.primaryBlue}>
Base Inference Model
</Text>
<Text color="gray">Select the base model your inference profile uses (for capability detection)</Text>
<Box marginTop={1}>
<SearchableList
isActive={isActive && step === "base_model"}
items={getModelList("bedrock").map((id) => ({ id, label: id }))}
onSelect={(item) => {
onComplete(customArn, item.id)
}}
/>
</Box>
<Box marginTop={1}>
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
</Box>
)
}
+23
View File
@@ -150,6 +150,7 @@ import { HighlightedInput } from "./HighlightedInput"
import { HistoryPanelContent } from "./HistoryPanelContent"
import { providerModels } from "./ModelPicker"
import { SettingsPanelContent } from "./SettingsPanelContent"
import { SkillsPanelContent } from "./SkillsPanelContent"
import { SlashCommandMenu } from "./SlashCommandMenu"
import { ThinkingIndicator } from "./ThinkingIndicator"
@@ -412,6 +413,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
| { type: "history" }
| { type: "help" }
| { type: "skills" }
| null
>(null)
@@ -1156,6 +1158,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "skills") {
setActivePanel({ type: "skills" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "clear") {
clearViewAndResetTask()
setSelectedSlashIndex(0)
@@ -1545,6 +1555,19 @@ export const ChatView: React.FC<ChatViewProps> = ({
{/* Help panel */}
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
{/* Skills panel */}
{activePanel?.type === "skills" && ctrl && (
<SkillsPanelContent
controller={ctrl}
onClose={() => setActivePanel(null)}
onUseSkill={(skillPath) => {
setActivePanel(null)
setTextInput(`@${skillPath} `)
setCursorPos(skillPath.length + 2)
}}
/>
)}
{/* Slash command menu - below input (takes priority over file menu) */}
{showSlashMenu && !activePanel && (
<Box paddingLeft={1} paddingRight={1}>
+2 -2
View File
@@ -39,7 +39,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
const isSelected = i === selectedIndex
return (
<Box flexDirection="column" key={model.id} marginBottom={1}>
<Box flexDirection="column" key={`${model.id}-${model.labels[0] || "default"}`} marginBottom={1}>
<Box>
<Text color={isSelected ? COLORS.primaryBlue : undefined}>{isSelected ? " " : " "}</Text>
<Text bold color={isSelected ? COLORS.primaryBlue : "white"}>
@@ -81,7 +81,7 @@ export const FeaturedModelPicker: React.FC<FeaturedModelPickerProps> = ({
* Get the maximum valid index for the featured model picker
* (includes "Browse all" option if showBrowseAll is true)
*/
export function getFeaturedModelMaxIndex(showBrowseAll: boolean = true): number {
export function getFeaturedModelMaxIndex(showBrowseAll = true): number {
const featuredModels = getAllFeaturedModels()
return showBrowseAll ? featuredModels.length : featuredModels.length - 1
}
+16 -2
View File
@@ -71,6 +71,9 @@ import { COLORS } from "../constants/colors"
import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models"
import { SearchableList, SearchableListItem } from "./SearchableList"
// Special ID used to indicate the user wants to enter a custom model ID / ARN
export const CUSTOM_MODEL_ID = "__custom__"
// Map providers to their static model lists and defaults
export const providerModels: Record<string, { models: Record<string, unknown>; defaultId: string }> = {
anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId },
@@ -169,12 +172,23 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({ provider, controller,
return getModelList(provider)
}, [provider, asyncModels])
// Providers that support custom model IDs (e.g., Bedrock Application Inference Profiles)
const supportsCustomModel = provider === "bedrock"
const items: SearchableListItem[] = useMemo(() => {
return modelList.map((modelId) => ({
const list = modelList.map((modelId) => ({
id: modelId,
label: modelId,
}))
}, [modelList])
// Add "Custom" option at the end for providers that support it
if (supportsCustomModel) {
list.push({
id: CUSTOM_MODEL_ID,
label: "Custom (ARN / Inference Profile)",
})
}
return list
}, [modelList, supportsCustomModel])
// For providers without a model picker, render nothing
if (!hasModelPicker(provider)) {
+74 -5
View File
@@ -12,7 +12,6 @@ import type { TelemetrySetting } from "@shared/TelemetrySetting"
import { Box, Text, useInput } from "ink"
import Spinner from "ink-spinner"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { refreshOcaModels } from "@/core/controller/models/refreshOcaModels"
import { StateManager } from "@/core/storage/StateManager"
@@ -29,6 +28,7 @@ import { useOcaAuth } from "../hooks/useOcaAuth"
import { isMouseEscapeSequence } from "../utils/input"
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
import { ApiKeyInput } from "./ApiKeyInput"
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import { Checkbox } from "./Checkbox"
import {
@@ -38,7 +38,7 @@ import {
isBrowseAllSelected,
} from "./FeaturedModelPicker"
import { LanguagePicker } from "./LanguagePicker"
import { hasModelPicker, ModelPicker } from "./ModelPicker"
import { CUSTOM_MODEL_ID, hasModelPicker, ModelPicker } from "./ModelPicker"
import { OcaEmployeeCheck } from "./OcaEmployeeCheck"
import { OrganizationPicker } from "./OrganizationPicker"
import { Panel, PanelTab } from "./Panel"
@@ -171,6 +171,9 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
const [apiKeyValue, setApiKeyValue] = useState("")
const [editValue, setEditValue] = useState("")
// Bedrock custom ARN flow state
const [isBedrockCustomFlow, setIsBedrockCustomFlow] = useState(false)
// Settings state - single object for feature toggles
const [features, setFeatures] = useState<Record<FeatureKey, boolean>>(() => {
const initial: Record<string, boolean> = {}
@@ -741,7 +744,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
}
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
controller.task.updateApiHandler(apiConfig, currentMode)
}, [controller, stateManager])
const setReasoningEffortForMode = useCallback(
@@ -944,10 +947,56 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
setReasoningEffortForMode,
])
// Handle completion of the Bedrock custom ARN flow (ARN + base model selected)
const handleBedrockCustomFlowComplete = useCallback(
async (arn: string, baseModelId: string) => {
if (!pickingModelKey) return
const apiConfig = stateManager.getApiConfiguration()
// Build a minimal BedrockConfig from current state for applyBedrockConfig
const bedrockConfig: BedrockConfig = {
awsRegion: apiConfig.awsRegion ?? "us-east-1",
awsAuthentication: apiConfig.awsUseProfile ? "profile" : "credentials",
awsUseCrossRegionInference: Boolean(apiConfig.awsUseCrossRegionInference),
}
await applyBedrockConfig({
bedrockConfig,
modelId: arn,
customModelBaseId: baseModelId,
controller,
})
// Flush pending state to ensure everything is persisted
await stateManager.flushPendingState()
// Rebuild API handler if there's an active task
rebuildTaskApi()
refreshModelIds()
setIsBedrockCustomFlow(false)
setPickingModelKey(null)
// If opened from /models command, close the entire settings panel
if (initialMode) {
onClose()
}
},
[pickingModelKey, stateManager, controller, rebuildTaskApi, refreshModelIds, initialMode, onClose],
)
// Handle model selection from picker
const handleModelSelect = useCallback(
async (modelId: string) => {
if (!pickingModelKey) return
// Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow
if (modelId === CUSTOM_MODEL_ID && provider === "bedrock") {
setIsPickingModel(false)
setIsBedrockCustomFlow(true)
return
}
const apiConfig = stateManager.getApiConfiguration()
const actProvider = apiConfig.actModeApiProvider
const planProvider = apiConfig.planModeApiProvider || actProvider
@@ -996,7 +1045,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const freshApiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...freshApiConfig, ulid: controller.task.ulid }, currentMode)
controller.task.updateApiHandler(freshApiConfig, currentMode)
}
refreshModelIds()
@@ -1008,7 +1057,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
onClose()
}
},
[pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose],
[pickingModelKey, separateModels, stateManager, controller, provider, refreshModelIds, initialMode, onClose],
)
// Handle language selection from picker
@@ -1332,6 +1381,11 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
return
}
// Bedrock custom flow - input handled by BedrockCustomModelFlow component
if (isBedrockCustomFlow) {
return
}
if (isEditing) {
if (key.escape) {
setIsEditing(false)
@@ -1584,6 +1638,20 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
)
}
// Bedrock custom model flow (ARN input + base model selection)
if (isBedrockCustomFlow) {
return (
<BedrockCustomModelFlow
isActive={isBedrockCustomFlow}
onCancel={() => {
setIsBedrockCustomFlow(false)
setIsPickingModel(true)
}}
onComplete={handleBedrockCustomFlowComplete}
/>
)
}
// Account tab - loading state
if (currentTab === "account" && isAccountLoading) {
return (
@@ -1748,6 +1816,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
isWaitingForClineAuth ||
isShowingOcaEmployeeCheck ||
isWaitingForOcaAuth ||
isBedrockCustomFlow ||
isEditing
return (
@@ -0,0 +1,230 @@
/**
* Tests for SkillsPanelContent component
*
* Tests keyboard interactions and callbacks.
* Rendering tests are limited due to ink-testing-library constraints with nested components.
*/
import { render } from "ink-testing-library"
// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file.
import React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock refreshSkills
const mockRefreshSkills = vi.fn()
vi.mock("@/core/controller/file/refreshSkills", () => ({
refreshSkills: () => mockRefreshSkills(),
}))
// Mock toggleSkill
const mockToggleSkill = vi.fn()
vi.mock("@/core/controller/file/toggleSkill", () => ({
toggleSkill: (...args: unknown[]) => mockToggleSkill(...args),
}))
// Mock child_process exec
const mockExec = vi.fn()
vi.mock("node:child_process", () => ({
exec: (...args: unknown[]) => mockExec(...args),
}))
// Mock StdinContext
vi.mock("../context/StdinContext", () => ({
useStdinContext: () => ({ isRawModeSupported: true }),
}))
import { SkillsPanelContent } from "./SkillsPanelContent"
// Helper to wait for async state updates
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
describe("SkillsPanelContent", () => {
const mockController = {} as any
const mockOnClose = vi.fn()
const mockOnUseSkill = vi.fn()
const defaultProps = {
controller: mockController,
onClose: mockOnClose,
onUseSkill: mockOnUseSkill,
}
beforeEach(() => {
vi.clearAllMocks()
mockRefreshSkills.mockResolvedValue({
globalSkills: [],
localSkills: [],
})
})
describe("keyboard interactions", () => {
it("should call onClose when Escape is pressed", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write("\x1B") // Escape
await delay()
expect(mockOnClose).toHaveBeenCalled()
})
it("should call onUseSkill with skill path when Enter is pressed on a skill", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write("\r") // Enter
await delay()
expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md")
})
it("should call toggleSkill when Space is pressed on a skill", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write(" ") // Space
await delay()
expect(mockToggleSkill).toHaveBeenCalledWith(
mockController,
expect.objectContaining({
skillPath: "/test/path/SKILL.md",
isGlobal: true,
enabled: false, // toggled from true to false
}),
)
})
it("should open marketplace URL when Enter is pressed on marketplace item", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "skill", description: "desc", path: "/path", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate down to marketplace (past the one skill)
stdin.write("\x1B[B") // Down arrow
await delay()
stdin.write("\r") // Enter
await delay()
// Should have called exec with open command
expect(mockExec).toHaveBeenCalled()
const execCall = mockExec.mock.calls[0][0]
expect(execCall).toContain("https://skills.sh/")
})
it("should navigate through skills with arrow keys", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate down
stdin.write("\x1B[B") // Down arrow
await delay()
// Press Enter - should use second skill
stdin.write("\r")
await delay()
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
})
it("should navigate with vim keys (j/k)", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [
{ name: "skill-1", description: "First", path: "/path1", enabled: true },
{ name: "skill-2", description: "Second", path: "/path2", enabled: true },
],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate down with j
stdin.write("j")
await delay()
// Press Enter - should use second skill
stdin.write("\r")
await delay()
expect(mockOnUseSkill).toHaveBeenCalledWith("/path2")
})
it("should revert optimistic toggle on failure", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }],
localSkills: [],
})
mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed"))
const { stdin, lastFrame } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
stdin.write(" ") // Space to toggle
await delay(100)
// toggleSkill was called with enabled: false (toggled from true)
expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false }))
const frame = lastFrame() || ""
expect(frame).toContain("● test-skill")
expect(frame).not.toContain("○ test-skill")
})
it("should wrap navigation at list boundaries", async () => {
mockRefreshSkills.mockResolvedValue({
globalSkills: [{ name: "only-skill", description: "Only", path: "/only", enabled: true }],
localSkills: [],
})
const { stdin } = render(<SkillsPanelContent {...defaultProps} />)
await delay()
// Navigate up from first item (should wrap to last - marketplace)
stdin.write("\x1B[A") // Up arrow
await delay()
stdin.write("\r") // Enter
await delay()
// Should have opened marketplace (wrapped to last item)
expect(mockExec).toHaveBeenCalled()
})
})
describe("skill loading", () => {
it("should call refreshSkills on mount", async () => {
render(<SkillsPanelContent {...defaultProps} />)
await delay()
expect(mockRefreshSkills).toHaveBeenCalled()
})
})
})
+257
View File
@@ -0,0 +1,257 @@
/**
* Skills panel content for inline display in ChatView
* Shows installed skills with toggle and use functionality
*/
import { exec } from "node:child_process"
import os from "node:os"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import type { Controller } from "@/core/controller"
import { refreshSkills } from "@/core/controller/file/refreshSkills"
import { toggleSkill } from "@/core/controller/file/toggleSkill"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
const SKILLS_MARKETPLACE_URL = "https://skills.sh/"
interface SkillInfo {
name: string
description: string
path: string
enabled: boolean
}
interface SkillsPanelContentProps {
controller: Controller
onClose: () => void
onUseSkill: (skillPath: string) => void
}
const MAX_VISIBLE = 8
export const SkillsPanelContent: React.FC<SkillsPanelContentProps> = ({ controller, onClose, onUseSkill }) => {
const { isRawModeSupported } = useStdinContext()
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLoading, setIsLoading] = useState(true)
// Load skills on mount
useEffect(() => {
const loadSkills = async () => {
try {
const skillsData = await refreshSkills(controller)
setGlobalSkills(skillsData.globalSkills || [])
setLocalSkills(skillsData.localSkills || [])
} catch (_error) {
// Skills loading failed, show empty state
} finally {
setIsLoading(false)
}
}
loadSkills()
}, [controller])
// Build flat list of skills with source info (global first, then local, alphabetical within each)
const skillEntries = useMemo(() => {
const entries: { skill: SkillInfo; isGlobal: boolean }[] = []
globalSkills.forEach((skill) => entries.push({ skill, isGlobal: true }))
localSkills.forEach((skill) => entries.push({ skill, isGlobal: false }))
return entries.sort((a, b) => {
if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1
return a.skill.name.localeCompare(b.skill.name)
})
}, [globalSkills, localSkills])
// Handle toggle
const handleToggle = useCallback(async () => {
const entry = skillEntries[selectedIndex]
if (!entry) return
const newEnabled = !entry.skill.enabled
const setter = entry.isGlobal ? setGlobalSkills : setLocalSkills
const update = (enabled: boolean) =>
setter((prev) => prev.map((s) => (s.path === entry.skill.path ? { ...s, enabled } : s)))
// Optimistic update
update(newEnabled)
try {
await toggleSkill(controller, {
metadata: undefined,
skillPath: entry.skill.path,
isGlobal: entry.isGlobal,
enabled: newEnabled,
})
} catch {
// Revert on failure
update(!newEnabled)
}
}, [controller, skillEntries, selectedIndex])
// Handle use skill (insert @ mention)
const handleUse = useCallback(() => {
const entry = skillEntries[selectedIndex]
if (!entry) return
onUseSkill(entry.skill.path)
}, [skillEntries, selectedIndex, onUseSkill])
// Handle opening the marketplace URL
const openMarketplace = useCallback(() => {
const platform = os.platform()
let command: string
if (platform === "darwin") {
command = `open "${SKILLS_MARKETPLACE_URL}"`
} else if (platform === "win32") {
command = `start "${SKILLS_MARKETPLACE_URL}"`
} else {
command = `xdg-open "${SKILLS_MARKETPLACE_URL}"`
}
exec(command, (err) => {
if (err) {
// Fallback: show URL in terminal if browser open fails
console.error(`Visit: ${SKILLS_MARKETPLACE_URL}`)
}
})
}, [])
// Total items = skills + 1 for marketplace link
const totalItems = skillEntries.length + 1
const isMarketplaceSelected = selectedIndex === skillEntries.length
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) {
return
}
if (key.escape) {
onClose()
return
}
// Navigation
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
return
}
if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0))
return
}
// Actions
if (key.return) {
if (isMarketplaceSelected) {
openMarketplace()
} else {
handleUse()
}
return
}
if (input === " " && !isMarketplaceSelected) {
handleToggle()
return
}
},
{ isActive: isRawModeSupported },
)
// Scrolling window (includes marketplace row)
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, totalItems - MAX_VISIBLE))
if (isLoading) {
return (
<Panel label="Skills">
<Text color="gray">Loading skills...</Text>
</Panel>
)
}
// Check if marketplace row is in visible window
const marketplaceIndex = skillEntries.length
const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE
return (
<Panel label="Skills">
<Box flexDirection="column" gap={1}>
{skillEntries.length === 0 ? (
<Box flexDirection="column" gap={1}>
<Text color="gray">No skills installed.</Text>
<Text>
Install skills with: <Text color="white">npx skills add owner/repo</Text>
</Text>
</Box>
) : (
<Box flexDirection="column">
{skillEntries
.slice(startIndex, Math.min(startIndex + MAX_VISIBLE, skillEntries.length))
.map((entry, idx) => {
const actualIndex = startIndex + idx
const prevEntry = skillEntries[actualIndex - 1]
const showHeader = actualIndex === 0 || (prevEntry && prevEntry.isGlobal !== entry.isGlobal)
return (
<React.Fragment key={entry.skill.path}>
{showHeader && (
<Box marginTop={actualIndex > 0 ? 1 : 0}>
<Text bold color="gray">
{entry.isGlobal ? "Global Skills:" : "Workspace Skills:"}
</Text>
</Box>
)}
<SkillRow isSelected={actualIndex === selectedIndex} skill={entry.skill} />
</React.Fragment>
)
})}
</Box>
)}
{/* Marketplace link - selectable */}
{showMarketplace && (
<Box marginTop={1}>
<Text color={isMarketplaceSelected ? "cyan" : undefined}>
{isMarketplaceSelected ? " " : " "}
<Text color={COLORS.primaryBlue}>Browse more skills at https://skills.sh/</Text>
</Text>
</Box>
)}
{/* Help text */}
<Box marginTop={1}>
<Text color="gray">
/ Navigate Enter {isMarketplaceSelected ? "Open" : "Use"}
{!isMarketplaceSelected && " • Space Toggle"}
</Text>
</Box>
</Box>
</Panel>
)
}
const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => {
return (
<Box flexDirection="column">
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={skill.enabled ? "green" : "red"}>{skill.enabled ? "●" : "○"}</Text>
<Text> </Text>
<Text bold color="white">
{skill.name}
</Text>
</Text>
</Box>
{skill.description && (
<Box marginLeft={4}>
<Text color="gray">
{skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description}
</Text>
</Box>
)}
</Box>
)
}
+14 -8
View File
@@ -12,23 +12,29 @@ export interface FeaturedModel {
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
recommended: [
{
id: "google/gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
description: "Latest Gemini release with 1m ctx window and strong coding performance",
labels: ["NEW"],
},
{
id: "anthropic/claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
description: "Latest Sonnet release with strong coding and agent performance",
labels: ["NEW"],
},
{
id: "anthropic/claude-opus-4.6",
name: "Claude Opus 4.6",
description: "State-of-the-art for complex coding",
description: "Most intelligent model for agents and coding",
labels: ["BEST"],
},
{
id: "openai/gpt-5.2-codex",
name: "GPT 5.2 Codex",
description: "OpenAI's latest with strong coding abilities",
labels: ["NEW"],
},
{
id: "google/gemini-3-pro-preview",
name: "Gemini 3 Pro",
description: "1M context window for large codebases",
labels: ["TRENDING"],
labels: ["HOT"],
},
],
free: [
+9 -40
View File
@@ -8,12 +8,11 @@ import { Command } from "commander"
import { render } from "ink"
import React from "react"
import { ClineEndpoint } from "@/config"
import { Controller } from "@/core/controller"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager"
import { ErrorService } from "@/services/error/ErrorService"
import { telemetryService } from "@/services/telemetry"
@@ -74,24 +73,7 @@ async function disposeTelemetryServices(): Promise<void> {
await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()])
}
/**
* Restore yoloModeToggled to its original value from before this CLI session.
* This ensures the --yolo flag is session-only and doesn't leak into future runs.
* Must be called before flushPendingState so the restored value gets persisted.
*/
function restoreYoloState(): void {
if (savedYoloModeToggled !== null) {
try {
StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled)
savedYoloModeToggled = null
} catch {
// StateManager may not be initialized (e.g., early exit before init)
}
}
}
async function disposeCliContext(ctx: CliContext): Promise<void> {
restoreYoloState()
await ctx.controller.stateManager.flushPendingState()
await ctx.controller.dispose()
await ErrorService.get().dispose()
@@ -204,12 +186,10 @@ function applyTaskOptions(options: TaskOptions): void {
telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes))
}
// Override yolo mode only if --yolo flag is explicitly passed.
// The original value is saved in initializeCli and restored on exit.
// Set yolo mode as a session-scoped override so AutoApprove picks it up,
// but it is never persisted to disk (setSessionOverride never touches pendingGlobalState).
if (options.yolo) {
const state = StateManager.get()
savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false
state.setGlobalState("yoloModeToggled", true)
StateManager.get().setSessionOverride("yoloModeToggled", true)
telemetryService.captureHostEvent("yolo_flag", "true")
}
@@ -314,9 +294,6 @@ let activeContext: CliContext | null = null
let isShuttingDown = false
// Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected
let isPlainTextMode = false
// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit.
// The --yolo flag should only affect the current invocation, not persist across runs.
let savedYoloModeToggled: boolean | null = null
/**
* Wait for stdout to fully drain before exiting.
@@ -358,10 +335,6 @@ function setupSignalHandlers() {
printWarning(`${signal} received, shutting down...`)
try {
// Restore yolo state before any cleanup - this is idempotent and safe
// even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null)
restoreYoloState()
if (activeContext) {
const task = activeContext.controller.task
if (task) {
@@ -425,7 +398,7 @@ interface InitOptions {
*/
async function initializeCli(options: InitOptions): Promise<CliContext> {
const workspacePath = options.cwd || process.cwd()
const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
clineDir: options.config,
workspaceDir: workspacePath,
})
@@ -466,13 +439,9 @@ async function initializeCli(options: InitOptions): Promise<CliContext> {
DATA_DIR,
)
await StateManager.initialize(extensionContext as any)
await StateManager.initialize(storageContext)
await ErrorService.initialize()
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(extensionContext)
const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider
const controller = webview.controller
@@ -754,7 +723,7 @@ program
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yes/yolo mode (default: 600)")
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
@@ -792,7 +761,7 @@ program
.description("Authenticate a provider and configure what model is used")
.option("-p, --provider <id>", "Provider ID for quick setup (e.g., openai-native, anthropic, moonshot)")
.option("-k, --apikey <key>", "API key for the provider")
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929, kimi-k2.5)")
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
@@ -983,7 +952,7 @@ program
.option("-a, --act", "Run in act mode")
.option("-p, --plan", "Run in plan mode")
.option("-y, --yolo", "Enable yolo mode (auto-approve actions)")
.option("-t, --timeout <seconds>", "Timeout in seconds for yolo mode (default: 600)")
.option("-t, --timeout <seconds>", "Optional timeout in seconds (applies only when provided)")
.option("-m, --model <model>", "Model to use for the task")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory")
+9 -5
View File
@@ -26,7 +26,7 @@ export interface PlainTextTaskOptions {
imageDataUrls?: string[]
verbose?: boolean
jsonOutput?: boolean
/** Timeout in seconds (default: 600 = 10 minutes) */
/** Timeout in seconds (only applied when explicitly provided) */
timeoutSeconds?: number
/** Task ID to resume an existing task */
taskId?: string
@@ -153,10 +153,14 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise<b
throw new Error("Either taskId or prompt must be provided")
}
// Normal mode: wait for task completion
const timeoutMs = (options.timeoutSeconds ?? 600) * 1000 // default 10 minutes
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
await Promise.race([completionPromise, timeoutPromise])
// Wait for task completion, with optional timeout only when explicitly configured
if (options.timeoutSeconds) {
const timeoutMs = options.timeoutSeconds * 1000
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs))
await Promise.race([completionPromise, timeoutPromise])
} else {
await completionPromise
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
if (jsonOutput) {
+18 -4
View File
@@ -5,7 +5,6 @@
import type { ApiProvider } from "@shared/api"
import { getProviderModelIdKey, ProviderToApiKeyMap } from "@shared/storage"
import { buildApiHandler } from "@/core/api"
import type { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import type { BedrockConfig } from "../components/BedrockSetup"
@@ -73,22 +72,25 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
controller.task.updateApiHandler(apiConfig, currentMode)
}
}
export interface ApplyBedrockConfigOptions {
bedrockConfig: BedrockConfig
modelId?: string
customModelBaseId?: string // Base model ID for custom ARN/Inference Profile (for capability detection)
controller?: Controller
}
/**
* Apply Bedrock provider configuration to state
* Handles AWS-specific fields (authentication, region, credentials)
* When customModelBaseId is provided, sets the custom model flags so the system
* knows to use the ARN as the model ID and the base model for capability detection.
*/
export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Promise<void> {
const { bedrockConfig, modelId, controller } = options
const { bedrockConfig, modelId, customModelBaseId, controller } = options
const stateManager = StateManager.get()
const config: Record<string, unknown> = {
@@ -108,6 +110,18 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
if (planModelKey) config[planModelKey] = finalModelId
}
// Handle custom model (Application Inference Profile ARN)
if (customModelBaseId) {
config.actModeAwsBedrockCustomSelected = true
config.planModeAwsBedrockCustomSelected = true
config.actModeAwsBedrockCustomModelBaseId = customModelBaseId
config.planModeAwsBedrockCustomModelBaseId = customModelBaseId
} else {
// Ensure custom flags are cleared when using a standard model
config.actModeAwsBedrockCustomSelected = false
config.planModeAwsBedrockCustomSelected = false
}
// Add optional AWS credentials
if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile
if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey
@@ -122,6 +136,6 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
controller.task.updateApiHandler(apiConfig, currentMode)
}
}
+56 -92
View File
@@ -1,23 +1,21 @@
/**
* VSCode context stub for CLI mode
* Provides mock implementations of VSCode extension context
* Provides mock implementations of VSCode extension context.
*/
import { mkdirSync } from "node:fs"
import { fileURLToPath } from "node:url"
import os from "os"
import path from "path"
import { ExtensionRegistryInfo } from "@/registry"
import { ClineExtensionContext } from "@/shared/cline"
import { ClineFileStorage } from "@/shared/storage"
import type { ClineMemento } from "@/shared/storage/ClineStorage"
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim"
// ES module equivalent of __dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const SETTINGS_SUBFOLDER = "data"
/**
* CLI-specific state overrides.
* These values are always returned regardless of what's stored,
@@ -35,33 +33,43 @@ const CLI_STATE_OVERRIDES: Record<string, any> = {
}
/**
* File-based Memento store with optional key overrides.
* Implements VSCode's Memento interface using SyncJsonFileStorage.
* Memento adapter that wraps a ClineFileStorage with optional key overrides.
* Used for globalState where CLI needs to inject hardcoded overrides.
*/
class MementoStore extends ClineFileStorage {
private overrides: Record<string, any>
class MementoAdapter implements ClineMemento {
constructor(
private readonly store: ClineMemento,
private readonly overrides: Record<string, any> = {},
) {}
constructor(filePath: string, overrides: Record<string, any> = {}) {
super(filePath, "MementoStore")
this.overrides = overrides
}
// VSCode Memento interface - override base class get() with overload support
override get<T>(key: string): T | undefined
override get<T>(key: string, defaultValue: T): T
override get<T>(key: string, defaultValue?: T): T | undefined {
get<T>(key: string): T | undefined
get<T>(key: string, defaultValue: T): T
get<T>(key: string, defaultValue?: T): T | undefined {
if (key in this.overrides) {
return this.overrides[key] as T
}
const value = super.get<T>(key)
const value = this.store.get<T>(key)
return value !== undefined ? value : defaultValue
}
override async update(key: string, value: any): Promise<void> {
if (key in this.overrides) {
return
update(key: string, value: any): Thenable<void> {
return this.setBatch({ [key]: value })
}
keys(): readonly string[] {
return this.store.keys()
}
setBatch(entries: Record<string, any>): Thenable<void> {
// Filter out overridden keys and delegate to underlying store
const filteredEntries: Record<string, any> = {}
for (const [key, value] of Object.entries(entries)) {
if (!(key in this.overrides)) {
filteredEntries[key] = value
}
}
this.set(key, value)
this.store.setBatch(filteredEntries)
return Promise.resolve()
}
setKeysForSync(_keys: readonly string[]): void {
@@ -69,81 +77,45 @@ class MementoStore extends ClineFileStorage {
}
}
/**
* File-based secret storage implementing VSCode's SecretStorage interface.
* Uses sync storage internally but exposes async API for VSCode compatibility.
*/
class SecretStore {
private storage: ClineFileStorage<string>
private onDidChangeEmitter = {
event: () => ({ dispose: () => {} }),
fire: (_e: any) => {},
dispose: () => {},
}
onDidChange = this.onDidChangeEmitter.event
constructor(filePath: string) {
this.storage = new ClineFileStorage<string>(filePath, "SecretStore")
}
get(key: string): Promise<string | undefined> {
return Promise.resolve(this.storage.get(key))
}
store(key: string, value: string): Promise<void> {
this.storage.set(key, value)
return Promise.resolve()
}
delete(key: string): Promise<void> {
this.storage.delete(key)
return Promise.resolve()
}
}
export interface CliContextConfig {
clineDir?: string
/** The workspace directory being worked in (for hashing into storage path) */
/** The workspace directory being worked in (used to compute workspace storage hash) */
workspaceDir?: string
}
/**
* Create a short hash of a string for use in directory names
*/
function hashString(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return Math.abs(hash).toString(16).substring(0, 8)
}
export interface CliContextResult {
extensionContext: ClineExtensionContext
storageContext: StorageContext
DATA_DIR: string
EXTENSION_DIR: string
WORKSPACE_STORAGE_DIR: string
}
/**
* Initialize the VSCode-like context for CLI mode
* Initialize the VSCode-like context for CLI mode.
*
* Creates a shared StorageContext (the single source of truth for all storage)
* and wraps it in a ClineExtensionContext shell for legacy APIs that still
* expect the VSCode ExtensionContext shape.
*/
export function initializeCliContext(config: CliContextConfig = {}): CliContextResult {
const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
// Workspace storage should always be under ~/.cline/data/workspaces/<hash>/
// where hash is derived from the workspace path to keep workspaces isolated
const workspacePath = config.workspaceDir || process.cwd()
const workspaceHash = hashString(workspacePath)
const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash)
// Create the shared StorageContext — this owns all ClineFileStorage instances.
// CLI, JetBrains, and VSCode all share this same file-backed implementation.
let storageContext = createStorageContext({
clineDir: CLINE_DIR,
workspacePath: config.workspaceDir || process.cwd(),
workspaceStorageDir: process.env.WORKSPACE_STORAGE_DIR || undefined,
})
storageContext = {
...storageContext,
// Storage — delegates to storageContext stores (with CLI overrides for globalState)
globalState: new MementoAdapter(storageContext.globalState, CLI_STATE_OVERRIDES),
}
// Ensure directories exist
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
const DATA_DIR = storageContext.dataDir
const WORKSPACE_STORAGE_DIR = storageContext.workspaceStoragePath
// For CLI, extension dir is the package root (one level up from dist/)
const EXTENSION_DIR = path.resolve(__dirname, "..")
@@ -160,38 +132,30 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR
extensionKind: ExtensionKind.UI,
}
// Build the ClineExtensionContext shell. All storage delegates to storageContext —
// there are NO separate ClineFileStorage instances here.
const extensionContext: ClineExtensionContext = {
extension: extension,
extensionMode: EXTENSION_MODE,
// Set up KV stores (globalState has CLI-specific overrides)
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
// Set up URIs
// URIs / paths
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
storagePath: WORKSPACE_STORAGE_DIR,
globalStorageUri: URI.file(DATA_DIR),
globalStoragePath: DATA_DIR,
// Logs
logUri: URI.file(DATA_DIR),
logPath: DATA_DIR,
extensionUri: URI.file(EXTENSION_DIR),
extensionPath: EXTENSION_DIR,
asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath),
subscriptions: [],
environmentVariableCollection: new EnvironmentVariableCollection() as any,
// Workspace state
workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")),
}
return {
extensionContext,
storageContext,
DATA_DIR,
EXTENSION_DIR,
WORKSPACE_STORAGE_DIR,
+12
View File
@@ -24,6 +24,18 @@
"useDefineForClassFields": true,
"useUnknownInCatchVariables": false,
"paths": {
"@cline/agents": [
"../packages/agents/dist/index.d.ts"
],
"@cline/agents/*": [
"../packages/agents/dist/*"
],
"@cline/llms": [
"../packages/llms/dist/index.d.ts"
],
"@cline/llms/*": [
"../packages/llms/dist/*"
],
"@/*": [
"../src/*"
],
+42
View File
@@ -73,6 +73,8 @@ Cline stores configuration in `~/.cline/data/`:
├── data/ # Configuration directory
│ ├── globalState.json # Global settings
│ ├── secrets.json # API keys (encrypted)
│ ├── settings/ # Settings files
│ │ └── cline_mcp_settings.json # MCP server configuration
│ ├── workspace/ # Workspace-specific state
│ └── tasks/ # Task history and data
└── log/ # Log files
@@ -172,6 +174,46 @@ cline --config ~/.cline-work "review this PR"
cline --config ~/.cline-personal "help me with this side project"
```
## MCP Server Configuration
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, giving you access to external tools and data sources directly from the terminal. The CLI uses the same MCP configuration format as the VS Code extension.
### Setting Up MCP Servers
To configure MCP servers for the CLI, create or edit the settings file at:
```
~/.cline/data/settings/cline_mcp_settings.json
```
The file uses the same JSON format as the VS Code extension:
```json
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/server.js"],
"env": {
"API_KEY": "your_api_key"
},
"alwaysAllow": ["tool1", "tool2"],
"disabled": false
}
}
}
```
For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers).
<Note>
The CLI does not yet have a `/mcp` slash command for managing MCP servers interactively. For now, you'll need to edit the `cline_mcp_settings.json` file directly.
</Note>
### Custom Config Directory
If you use the `CLINE_DIR` environment variable or `--config` flag, the MCP settings file will be located at `<your-config-dir>/data/settings/cline_mcp_settings.json` instead.
## Configuration for Local Providers
### Ollama
+9
View File
@@ -207,6 +207,15 @@ Chains multiple Cline invocations together for creative multi-step workflows.
| Session summary | ✓ | - |
| JSON output | - | `--json` |
| Piped input | - | ✓ |
| [MCP servers](/cline-cli/configuration#mcp-server-configuration) | ✓ | ✓ |
## MCP Server Support
Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, the same extensibility system available in the VS Code extension. MCP servers give Cline access to external tools and data sources, from databases and APIs to browser automation and project management.
To use MCP servers with the CLI, add your server configuration to `~/.cline/data/settings/cline_mcp_settings.json`. The format is identical to the VS Code extension.
[Configure MCP servers for the CLI →](/cline-cli/configuration#mcp-server-configuration)
## Learn More
+2
View File
@@ -70,6 +70,8 @@ Cline does not come with any pre-installed MCP servers. You'll need to find and
## Integration with Cline
MCP servers work with both the **Cline VS Code extension** and the **[Cline CLI](/cline-cli/overview)**. If you use the CLI, see [MCP Server Configuration for the CLI](/cline-cli/configuration#mcp-server-configuration) to get set up.
Cline simplifies the building and use of MCP servers through its AI capabilities.
### Building MCP Servers
+8 -3
View File
@@ -16,8 +16,13 @@ description: "Learn how to configure and use Anthropic Claude models with Cline.
Cline supports the following Anthropic Claude models:
#### Claude 4.6 Series
- `claude-sonnet-4-6` - Latest Sonnet with extended thinking support
- `claude-sonnet-4-6:1m` - 1M context window variant with tiered pricing
#### Claude 4.5 Series
- `claude-sonnet-4-5-20250929` (Recommended) - Latest Sonnet with extended thinking support
- `claude-sonnet-4-5-20250929` (Recommended) - Stable default Sonnet with reasoning support
- `claude-sonnet-4-5-20250929:1m` - 1M context window variant with tiered pricing
#### Claude 4 Series
@@ -56,8 +61,8 @@ Cline users can leverage this by checking the `Enable Extended Thinking` box bel
**Key Aspects of Extended Thinking:**
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 4.5, and Claude Sonnet 3.7.
- **Summarized Thinking (Claude 4):** For Claude 4 and 4.5 models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
- **Supported Models:** This feature is available for select models, including Claude Opus 4, Claude Sonnet 3.7+.
- **Summarized Thinking (Claude 3.7+):** For Claude 3.7+ models, the API returns a summary of the full thinking process to balance insight with efficiency and prevent misuse. You are billed for the full thinking tokens, not just the summary.
- **Streaming:** Extended thinking responses, including the `thinking` blocks, can be streamed.
- **Tool Use & Prompt Caching:** Extended thinking interacts with tool use (requiring thinking blocks to be passed back) and prompt caching (with specific behaviors around cache invalidation and context).
+1
View File
@@ -20,6 +20,7 @@ Cline supports the following AskSage models (subscription-based pricing):
#### Claude Models
- `claude-4-sonnet` (Default) - Claude 4 Sonnet via AskSage
- `claude-4.6-sonnet` - Claude 4.6 Sonnet via AskSage
- `claude-4-opus` - Claude 4 Opus via AskSage
- `claude-37-sonnet` - Claude 3.7 Sonnet
- `claude-35-sonnet` - Claude 3.5 Sonnet
+4 -6
View File
@@ -16,13 +16,11 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
### Supported Models
Cline supports the following Cerebras models (all currently free):
Cline supports the following Cerebras models:
- `zai-glm-4.7` (Default) - Highly capable general-purpose model (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks (131K context)
- `gpt-oss-120b` - OpenAI's intelligent general purpose model with 3,000 tokens/s (128K context)
- `qwen-3-235b-a22b-instruct-2507` - Intelligent model with ~1,400 tokens/s (64K context)
- `llama-3.3-70b` - Powerful model with ~2,600 tokens/s (64K context)
- `qwen-3-32b` - SOTA coding performance with ~2,500 tokens/s (64K context)
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
- `gpt-oss-120b` - Intelligent general purpose model with 3,000 tokens/s
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
### Configuration in Cline
+1 -1
View File
@@ -19,7 +19,7 @@ Google Gemini is Google's family of multimodal AI models, offering some of the l
Cline supports the following Google Gemini models:
#### Gemini 3 Series (Latest)
- `gemini-3-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
- `gemini-3.1-pro-preview` (Default) - Latest pro model with 1M context, thinking support, and tiered pricing ($2.00-$4.00/M input)
- `gemini-3-flash-preview` - Fast model with 1M context and thinking level support ($0.30-$0.50/M input)
#### Gemini 2.5 Series
+5
View File
@@ -19,6 +19,11 @@ const aliasResolverPlugin = {
name: "alias-resolver",
setup(build) {
const aliases = {
"@cline/agents/extensions": path.resolve(__dirname, "sdk-wip/packages/agents/dist/index.js"),
"@cline/agents/hooks": path.resolve(__dirname, "sdk-wip/packages/agents/dist/index.js"),
"@cline/agents": path.resolve(__dirname, "sdk-wip/packages/agents/dist"),
"@cline/core": path.resolve(__dirname, "sdk-wip/packages/core/dist"),
"@cline/llms": path.resolve(__dirname, "sdk-wip/packages/llms/dist"),
"@": path.resolve(__dirname, "src"),
"@core": path.resolve(__dirname, "src/core"),
"@integrations": path.resolve(__dirname, "src/integrations"),
+27 -23
View File
@@ -1,14 +1,15 @@
{
"name": "claude-dev",
"version": "3.63.0",
"version": "3.66.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.63.0",
"version": "3.66.0",
"license": "Apache-2.0",
"workspaces": [
".",
"cli"
],
"dependencies": {
@@ -45,8 +46,8 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/orchestration": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.2.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
@@ -161,7 +162,7 @@
},
"cli": {
"name": "cline",
"version": "2.2.1",
"version": "2.4.2",
"cpu": [
"x64",
"arm64"
@@ -4032,7 +4033,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -6114,20 +6114,20 @@
]
},
"node_modules/@sap-ai-sdk/ai-api": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.6.0.tgz",
"integrity": "sha512-HdQ3T6FSD/jlPnsDKEGsNg+SQONpHBvxfu6OVwW/5nLdlvPmWpysirYvOc0eqj7e+X3zBHGIgoGWKSxIcYOjNg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/ai-api/-/ai-api-2.7.0.tgz",
"integrity": "sha512-dDc2Si5Mu62dVZkJ/f55fBIlbIjVjCKmPi9tt2jWix59o/f9z2Zkw7l7Il+H0RbgANyyQmZsCkXDHRUmzAKKDw==",
"license": "Apache-2.0",
"dependencies": {
"@sap-ai-sdk/core": "^2.6.0",
"@sap-ai-sdk/core": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.4.0",
"@sap-cloud-sdk/util": "^4.4.0"
}
},
"node_modules/@sap-ai-sdk/core": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.6.0.tgz",
"integrity": "sha512-HgB2xtjx6iFwAq+a5cEH6rss4sBXAkkW6Vas07OxoZihc7BijjNFm+H19rkEAhBYQmkA415zJG3imHd6/slooQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/core/-/core-2.7.0.tgz",
"integrity": "sha512-NSuCrEFinMR8/EYKJcACh71a1kj1H3pNfpnJH1R4Z+cxnlr0CtiFDzz9hKwsXFtf+ixO6WnIf7xMVmXkoeGNSQ==",
"license": "Apache-2.0",
"dependencies": {
"@sap-cloud-sdk/connectivity": "^4.4.0",
@@ -6137,25 +6137,25 @@
}
},
"node_modules/@sap-ai-sdk/orchestration": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.6.0.tgz",
"integrity": "sha512-FGBZ0oiRbbZdL9UUytsJ7QBiVUmrg4eD/i61aHVeBGvaCvsgSlx7nJM07CAT5JHhJC8ss97+EYPXXzVNPATcyQ==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/orchestration/-/orchestration-2.7.0.tgz",
"integrity": "sha512-/aCRwb3o5yJu5/8jCDVatMGBIxPO5rKtr0sT+zAl5w3hD/PqAqbFZXNztQ263xK1ooW8YAFite8PpXJgdjnFZg==",
"license": "Apache-2.0",
"dependencies": {
"@sap-ai-sdk/ai-api": "^2.6.0",
"@sap-ai-sdk/core": "^2.6.0",
"@sap-ai-sdk/prompt-registry": "^2.6.0",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/core": "^2.7.0",
"@sap-ai-sdk/prompt-registry": "^2.7.0",
"@sap-cloud-sdk/util": "^4.4.0",
"yaml": "^2.8.2"
}
},
"node_modules/@sap-ai-sdk/prompt-registry": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.6.0.tgz",
"integrity": "sha512-vSyBCx437Cba3AsBHf1G0KCldv4dPi60kNgp2rpF/kDZjf3Rga3VwZ6OnETaKtbJJbM8G8itR0USHgviMU8UUg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@sap-ai-sdk/prompt-registry/-/prompt-registry-2.7.0.tgz",
"integrity": "sha512-hbfb75CD67dgKl/hLLCXdzhsR80FHFqKCpVUjiULs3KW1Rauxc+i6ZKNRcc+uzdb8kgsdhKizFFCtsz90ay44g==",
"license": "Apache-2.0",
"dependencies": {
"@sap-ai-sdk/core": "^2.6.0",
"@sap-ai-sdk/core": "^2.7.0",
"zod": "^4.3.6"
}
},
@@ -10288,6 +10288,10 @@
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"license": "MIT"
},
"node_modules/claude-dev": {
"resolved": "",
"link": true
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+9 -4
View File
@@ -2,9 +2,10 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.63.0",
"version": "3.66.0",
"icon": "assets/icons/icon.png",
"workspaces": [
".",
"cli"
],
"engines": {
@@ -443,7 +444,11 @@
"docs:rename-file": "cd docs && npm run rename",
"report-issue": "node scripts/report-issue.js",
"storybook": "cd webview-ui && npm run storybook",
"cli:unlink": "cd cli && npm run unlink"
"cli:unlink": "cd cli && npm run unlink",
"eval:smoke:build": "npm run cli:build && npm run cli:link",
"eval:smoke:run": "npx tsx evals/smoke-tests/run-smoke-tests.ts",
"eval:smoke": "npm run eval:smoke:build && npm run eval:smoke:run",
"eval:smoke:ci": "npm run eval:smoke:build && npm run eval:smoke:run -- --trials 1 --parallel"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
@@ -535,8 +540,8 @@
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.37.0",
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-ai-sdk/ai-api": "^2.7.0",
"@sap-ai-sdk/orchestration": "^2.7.0",
"@sap-cloud-sdk/connectivity": "^4.2.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
+1
View File
@@ -448,6 +448,7 @@ enum ApiFormat {
OPENAI_CHAT = 2;
R1_CHAT = 3;
OPENAI_RESPONSES = 4;
OPENAI_RESPONSES_WEBSOCKET_MODE = 5;
}
// Model info for OpenAI-compatible models
+2 -7
View File
@@ -104,15 +104,13 @@ message Secrets {
optional string oca_refresh_token = 42;
optional string mcp_o_auth_secrets = 43;
optional string cline_api_key = 44;
optional string openai_codex_oauth_credentials = 46;
optional string openai_codex_oauth_credentials = 48;
}
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 146; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
optional string anthropic_base_url = 4;
@@ -251,7 +249,6 @@ message Settings {
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional int32 subagent_terminal_output_line_limit = 140;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
@@ -261,7 +258,6 @@ message Settings {
optional DictationSettings dictation_settings = 148;
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional double auto_condense_threshold = 151;
optional bool subagents_enabled = 153;
optional bool enable_parallel_tool_calling = 154;
optional bool background_edit_enabled = 155;
@@ -282,8 +278,8 @@ message Settings {
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
optional bool auto_approve_all_toggled = 174;
map<string, string> open_ai_headers = 175;
optional bool double_check_completion_enabled = 176;
map<string, string> open_ai_headers = 177;
}
message DictationSettings {
@@ -415,7 +411,6 @@ message UpdateSettingsRequest {
optional string default_terminal_profile = 21;
optional bool yolo_mode_toggled = 22;
optional DictationSettings dictation_settings = 23;
optional double auto_condense_threshold = 24;
optional bool multi_root_enabled = 25;
optional string vscode_terminal_execution_mode = 27;
optional int32 max_consecutive_mistakes = 28;
+5 -6
View File
@@ -30,7 +30,7 @@ function toProtoFieldName(str) {
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
// Fields that should use double instead of int32
const DOUBLE_FIELDS = new Set(["autoCondenseThreshold"])
const DOUBLE_FIELDS = new Set()
/**
* Infer proto type from TypeScript type expression
@@ -254,7 +254,7 @@ function parseProtoMessageFieldNumbers(protoContent, messageName) {
for (const fieldMatch of matches) {
const snakeName = fieldMatch[1]
const fieldNum = parseInt(fieldMatch[2], 10)
const fieldNum = Number.parseInt(fieldMatch[2], 10)
const camelName = snakeToCamel(snakeName)
fieldNumbers[camelName] = fieldNum
}
@@ -354,11 +354,10 @@ function replaceMessage(protoContent, messageName, newMessageContent) {
if (messageRegex.test(protoContent)) {
return protoContent.replace(messageRegex, newMessageContent)
} else {
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
async function main() {
+136
View File
@@ -56,6 +56,7 @@ const log = {
const config = {
// The name and display name for the nightly version
nightlyName: "cline-nightly",
originalName: "claude-dev",
nightlyDisplayName: "Cline (Nightly)",
projectRoot: path.join(__dirname, ".."),
get packageJsonPath() {
@@ -70,6 +71,15 @@ const config = {
get vsixPath() {
return path.join(this.distDir, "cline-nightly.vsix")
},
get nodeModulesPath() {
return path.join(this.projectRoot, "node_modules")
},
get originalWorkspaceLinkPath() {
return path.join(this.nodeModulesPath, this.originalName)
},
get nightlyWorkspaceLinkPath() {
return path.join(this.nodeModulesPath, this.nightlyName)
},
}
// Utility class for managing the publish process
@@ -77,6 +87,31 @@ class NightlyPublisher {
constructor() {
this.originalPackageJson = null
this.hasBackup = false
this.didRenameWorkspaceLink = false
this.didCreateNightlyWorkspaceLink = false
}
/**
* Resolve symlink target to an absolute path.
*/
resolveSymlinkTarget(linkPath) {
const target = fs.readlinkSync(linkPath)
return path.resolve(path.dirname(linkPath), target)
}
/**
* Validate that a path is the expected workspace self-link to project root.
*/
isExpectedWorkspaceSelfLink(linkPath) {
try {
if (!fs.lstatSync(linkPath).isSymbolicLink()) {
return false
}
return this.resolveSymlinkTarget(linkPath) === path.resolve(config.projectRoot)
} catch {
return false
}
}
/**
@@ -145,6 +180,98 @@ class NightlyPublisher {
}
}
/**
* Keep workspace self-link consistent with package name during nightly packaging.
*
* The repo root is a workspace package ("."). When npm installs dependencies,
* it creates a self-link at node_modules/<package-name>. Nightly packaging
* changes package.json name from "claude-dev" to "cline-nightly". If we don't
* align this link, vsce's dependency detection (`npm list --production`) fails
* with ELSPROBLEMS (missing cline-nightly + extraneous claude-dev).
*/
reconcileWorkspaceSelfLinkForNightly() {
const originalPath = config.originalWorkspaceLinkPath
const nightlyPath = config.nightlyWorkspaceLinkPath
if (!fs.existsSync(config.nodeModulesPath)) {
log.warn("node_modules not found, skipping workspace self-link reconciliation")
return
}
if (fs.existsSync(nightlyPath)) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to continue: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info("Nightly workspace self-link already exists")
return
}
if (fs.existsSync(originalPath)) {
if (!this.isExpectedWorkspaceSelfLink(originalPath)) {
throw new Error(
`Refusing to continue: unexpected path at ${originalPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Renaming workspace self-link: ${config.originalName} -> ${config.nightlyName}`)
fs.renameSync(originalPath, nightlyPath)
this.didRenameWorkspaceLink = true
return
}
// In some environments npm may not have created the workspace self-link yet.
// Create it explicitly so `npm list --production` can resolve the renamed
// package name during vsce dependency detection.
log.warn("Original workspace self-link not found, creating nightly workspace self-link")
fs.symlinkSync(config.projectRoot, nightlyPath, "dir")
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(`Failed to create expected workspace symlink at ${nightlyPath}`)
}
this.didCreateNightlyWorkspaceLink = true
}
/**
* Restore workspace self-link after packaging.
*/
restoreWorkspaceSelfLink() {
if (!this.didRenameWorkspaceLink && !this.didCreateNightlyWorkspaceLink) {
return
}
const originalPath = config.originalWorkspaceLinkPath
const nightlyPath = config.nightlyWorkspaceLinkPath
if (fs.existsSync(nightlyPath) && !fs.existsSync(originalPath)) {
if (this.didRenameWorkspaceLink) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to restore: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Restoring workspace self-link: ${config.nightlyName} -> ${config.originalName}`)
fs.renameSync(nightlyPath, originalPath)
} else if (this.didCreateNightlyWorkspaceLink) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to remove: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Removing temporary workspace self-link: ${config.nightlyName}`)
fs.unlinkSync(nightlyPath)
}
}
this.didRenameWorkspaceLink = false
this.didCreateNightlyWorkspaceLink = false
}
/**
* Generate new version with timestamp
* Format: major.minor.timestamp
@@ -300,6 +427,9 @@ class NightlyPublisher {
// Step 3: Update package.json
const newVersion = this.updatePackageJson()
// Step 3.5: Keep npm workspace self-link aligned with nightly package name
this.reconcileWorkspaceSelfLinkForNightly()
// Step 4: Package extension
this.packageExtension()
@@ -326,6 +456,9 @@ class NightlyPublisher {
log.error(`Publish failed: ${error.message}`)
process.exit(1)
} finally {
// Always restore workspace link first
this.restoreWorkspaceSelfLink()
// Always restore package.json
this.restorePackageJson()
}
@@ -336,17 +469,20 @@ class NightlyPublisher {
const publisher = new NightlyPublisher()
process.on("exit", () => {
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
})
process.on("SIGINT", () => {
log.info("\nInterrupted, cleaning up...")
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
process.exit(130)
})
process.on("SIGTERM", () => {
log.info("\nTerminated, cleaning up...")
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
process.exit(143)
})
Submodule
+1
Submodule sdk-wip added at 814a2e42b7
+9 -12
View File
@@ -1,15 +1,14 @@
import * as vscode from "vscode"
import { WebviewProvider } from "./core/webview"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
import { ExtensionRegistryInfo } from "./registry"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
@@ -32,7 +31,7 @@ import { arePathsEqual } from "./utils/path"
* @returns The webview provider
* @throws ClineConfigurationError if endpoints.json exists but is invalid
*/
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
// Configure the shared Logging class to use HostProvider's output channels and debug logger
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
@@ -44,7 +43,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
try {
await StateManager.initialize(context)
await StateManager.initialize(storageContext)
} catch (error) {
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
HostProvider.window.showMessage({
@@ -55,8 +54,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// =============== External services ===============
await ErrorService.initialize()
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
openAiCodexOAuthManager.initialize(context)
// Initialize PostHog client provider (skip in self-hosted mode)
if (!ClineEndpoint.isSelfHosted()) {
PostHogClientProvider.getInstance()
@@ -67,7 +64,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
const stateManager = StateManager.get()
// Non-blocking announcement check and display
showVersionUpdateAnnouncement(context)
showVersionUpdateAnnouncement(stateManager)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(stateManager)
@@ -78,24 +75,24 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
// Clean up orphaned file context warnings (startup cleanup)
FileContextTracker.cleanupOrphanedWarnings(context)
FileContextTracker.cleanupOrphanedWarnings(stateManager)
telemetryService.captureExtensionActivated()
return webview
}
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
// Version checking for autoupdate notification
const currentVersion = ExtensionRegistryInfo.version
const previousVersion = context.globalState.get<string>("clineVersion")
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Check if there's a new announcement to show
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId()
if (lastShownAnnouncementId !== latestAnnouncementId) {
@@ -109,7 +106,7 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
})
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
await stateManager.setGlobalState("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
+226
View File
@@ -0,0 +1,226 @@
import { type ApiHandler, buildApiHandler } from "@core/api"
import type {
ApiStream,
ApiStreamChunk,
ApiStreamTextChunk,
ApiStreamThinkingChunk,
ApiStreamToolCallsChunk,
ApiStreamUsageChunk,
} from "@core/api/transform/stream"
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
import { isNextGenModelFamily } from "@utils/model-utils"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import type { ApiConfiguration } from "@/shared/api"
import type { Mode } from "@/shared/storage/types"
export interface InitialStreamRetryDecision {
isAuthError: boolean
isBalanceError: boolean
shouldRetry: boolean
}
export type CoreLoopStatus = "continue" | "complete" | "failed"
export interface CoreLoopTurnResult<TInput, TOutput> {
status: CoreLoopStatus
nextInput?: TInput
output?: TOutput
error?: string
}
export interface CoreLoopParams<TInput, TOutput> {
initialInput: TInput
runTurn: (input: TInput, iteration: number) => Promise<CoreLoopTurnResult<TInput, TOutput>>
shouldAbort?: () => boolean
}
export type CoreLoopResult<TOutput> =
| { status: "complete"; output: TOutput }
| { status: "failed"; error?: string }
| { status: "aborted" }
export interface CoreStreamUsage {
inputTokens: number
outputTokens: number
cacheWriteTokens: number
cacheReadTokens: number
totalCost?: number
}
export interface CoreStreamResult {
requestId?: string
assistantText: string
assistantTextSignature?: string
usage: CoreStreamUsage
aborted: boolean
interrupted: boolean
}
export interface CoreStreamParams {
stream: ApiStream
onUsageChunk?: (chunk: ApiStreamUsageChunk, state: CoreStreamResult) => Promise<void> | void
onTextChunk?: (chunk: ApiStreamTextChunk, state: CoreStreamResult) => Promise<void> | void
onToolCallChunk?: (chunk: ApiStreamToolCallsChunk, state: CoreStreamResult) => Promise<void> | void
onReasoningChunk?: (chunk: ApiStreamThinkingChunk, state: CoreStreamResult) => Promise<void> | void
onChunkProcessed?: (chunk: ApiStreamChunk, state: CoreStreamResult) => Promise<"break" | void> | "break" | void
shouldAbort?: () => boolean
onAbort?: (state: CoreStreamResult) => Promise<void> | void
}
export class CoreAgent {
static readonly AUTO_CONDENSE_THRESHOLD = 0.75
private apiHandler?: ApiHandler
constructor(params?: { apiConfiguration: ApiConfiguration; mode: Mode }) {
if (params) {
this.apiHandler = buildApiHandler(params.apiConfiguration, params.mode)
}
}
initializeApiHandler(apiConfiguration: ApiConfiguration, mode: Mode): ApiHandler {
this.apiHandler = buildApiHandler(apiConfiguration, mode)
return this.apiHandler
}
getApiHandler(): ApiHandler {
if (!this.apiHandler) {
throw new Error("CoreAgent API handler has not been initialized")
}
return this.apiHandler
}
getModel(): ReturnType<ApiHandler["getModel"]> {
return this.getApiHandler().getModel()
}
createMessage(...args: Parameters<ApiHandler["createMessage"]>): ReturnType<ApiHandler["createMessage"]> {
return this.getApiHandler().createMessage(...args)
}
abortCurrentRequest(): void {
this.getApiHandler().abort?.()
}
getApiStreamUsage(): ReturnType<NonNullable<ApiHandler["getApiStreamUsage"]>> | undefined {
return this.getApiHandler().getApiStreamUsage?.()
}
getLastRequestIdSafe(): string | undefined {
const apiLike = this.getApiHandler() as Partial<{
getLastRequestId: () => string | undefined
lastGenerationId?: string
}>
return apiLike.getLastRequestId?.() ?? apiLike.lastGenerationId
}
async runLoop<TInput, TOutput>(params: CoreLoopParams<TInput, TOutput>): Promise<CoreLoopResult<TOutput>> {
let iteration = 0
let currentInput = params.initialInput
while (!params.shouldAbort?.()) {
iteration += 1
const turn = await params.runTurn(currentInput, iteration)
if (turn.status === "complete") {
return { status: "complete", output: turn.output as TOutput }
}
if (turn.status === "failed") {
return { status: "failed", error: turn.error }
}
currentInput = turn.nextInput as TInput
}
return { status: "aborted" }
}
async consumeStream(params: CoreStreamParams): Promise<CoreStreamResult> {
const state: CoreStreamResult = {
requestId: undefined,
assistantText: "",
assistantTextSignature: undefined,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: undefined,
},
aborted: false,
interrupted: false,
}
for await (const chunk of params.stream) {
state.requestId = state.requestId ?? chunk.id
switch (chunk.type) {
case "usage":
state.usage.inputTokens += chunk.inputTokens
state.usage.outputTokens += chunk.outputTokens
state.usage.cacheWriteTokens += chunk.cacheWriteTokens ?? 0
state.usage.cacheReadTokens += chunk.cacheReadTokens ?? 0
state.usage.totalCost = chunk.totalCost ?? state.usage.totalCost
await params.onUsageChunk?.(chunk, state)
break
case "text":
state.assistantText += chunk.text || ""
state.assistantTextSignature = chunk.signature || state.assistantTextSignature
await params.onTextChunk?.(chunk, state)
break
case "tool_calls":
await params.onToolCallChunk?.(chunk, state)
break
case "reasoning":
await params.onReasoningChunk?.(chunk, state)
break
}
const shouldBreak = await params.onChunkProcessed?.(chunk, state)
if (shouldBreak === "break") {
state.interrupted = true
break
}
if (params.shouldAbort?.()) {
state.aborted = true
await params.onAbort?.(state)
break
}
}
return state
}
isAutoCondenseEnabledForModel(useAutoCondense: boolean, modelId: string): boolean {
return useAutoCondense && isNextGenModelFamily(modelId)
}
getAutoCondenseThresholdTokens(contextWindow: number, maxAllowedSize: number): number {
const roundedThreshold = Math.floor(contextWindow * CoreAgent.AUTO_CONDENSE_THRESHOLD)
return Math.min(roundedThreshold, maxAllowedSize)
}
shouldCompactBeforeNextRequest(
previousRequestTotalTokens: number,
api: ApiHandler,
modelId: string,
useAutoCondense: boolean,
): boolean {
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
if (this.isAutoCondenseEnabledForModel(useAutoCondense, modelId)) {
const thresholdTokens = this.getAutoCondenseThresholdTokens(contextWindow, maxAllowedSize)
return previousRequestTotalTokens >= thresholdTokens
}
return previousRequestTotalTokens >= maxAllowedSize
}
classifyInitialStreamRetry(error: unknown, providerId: string, modelId: string): InitialStreamRetryDecision {
const parsedError = ClineError.transform(error, modelId, providerId)
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
return {
isAuthError,
isBalanceError,
shouldRetry: !isAuthError && !isBalanceError,
}
}
}
-1
View File
@@ -66,7 +66,6 @@ export interface ApiProviderInfo {
model: ApiHandlerModel
mode: Mode
customPrompt?: string // "compact"
autoCondenseThreshold?: number // 0-1 range
}
export interface SingleCompletionHandler {
@@ -938,6 +938,19 @@ describe("AwsBedrockHandler", () => {
modelId.should.equal("jp.anthropic.claude-sonnet-4-5-20250929-v1:0")
})
it("should apply JP cross-region prefix for sonnet 4.6", async () => {
const jpOptions: AwsBedrockHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
apiModelId: "anthropic.claude-sonnet-4-6",
awsRegion: "ap-northeast-1",
}
const jpHandler = new AwsBedrockHandler(jpOptions)
const modelId = await jpHandler.getModelId()
modelId.should.equal("jp.anthropic.claude-sonnet-4-6")
})
it("should apply global cross-region prefix for supported models", async () => {
const globalOptions: AwsBedrockHandlerOptions = {
...mockOptions,
@@ -276,6 +276,16 @@ describe("ClaudeCodeHandler", () => {
model.info.contextWindow.should.equal(1_000_000)
})
it("should support Sonnet 4.6 1m model id", () => {
const handler = new ClaudeCodeHandler({
apiModelId: "claude-sonnet-4-6[1m]",
})
const model = handler.getModel()
model.id.should.equal("claude-sonnet-4-6[1m]")
model.info.contextWindow.should.equal(1_000_000)
})
it("should return default model when not specified", () => {
const handler = new ClaudeCodeHandler({})
@@ -69,6 +69,7 @@ describe("SapAiCoreHandler", () => {
it("should support different Claude model variants", () => {
const modelVariants = [
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
"anthropic--claude-3.7-sonnet",
@@ -11,22 +11,22 @@ describe("VercelAIGatewayHandler", () => {
}
const handler = new VercelAIGatewayHandler({
openRouterModelId: "google/gemini-3-pro-preview",
openRouterModelId: "google/gemini-3.1-pro-preview",
openRouterModelInfo: customModelInfo,
})
const result = handler.getModel()
result.id.should.equal("google/gemini-3-pro-preview")
result.id.should.equal("google/gemini-3.1-pro-preview")
result.info.should.deepEqual(customModelInfo)
})
it("should preserve configured model ID when model info is missing", () => {
const handler = new VercelAIGatewayHandler({
openRouterModelId: "google/gemini-3-pro-preview",
openRouterModelId: "google/gemini-3.1-pro-preview",
})
const result = handler.getModel()
result.id.should.equal("google/gemini-3-pro-preview")
result.id.should.equal("google/gemini-3.1-pro-preview")
result.info.should.deepEqual(openRouterDefaultModelInfo)
})
+3 -1
View File
@@ -127,9 +127,11 @@ interface ProviderChainOptions {
profile?: string
}
// a special jp inference profile was created for opus 4.6, sonnet 4.5 & haiku 4.5
// a special jp inference profile was created for sonnet 4.6, opus 4.6, sonnet 4.5 & haiku 4.5
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
const JP_SUPPORTED_CRIS_MODELS = [
"anthropic.claude-sonnet-4-6",
"anthropic.claude-sonnet-4-6:1m",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-6-v1:1m",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
+10 -7
View File
@@ -84,7 +84,8 @@ export class CerebrasHandler implements ApiHandler {
.map((block) => {
if (block.type === "text") {
return block.text
} else if (block.type === "image") {
}
if (block.type === "image") {
return "[Image content not supported in Cerebras]"
}
return ""
@@ -195,14 +196,18 @@ export class CerebrasHandler implements ApiHandler {
// Rate limit error - will be handled by retry decorator with patient backoff
const _limits = this.getRateLimits()
throw new Error(`Cerebras API rate limit exceeded.`)
} else if (error?.status === 401) {
}
if (error?.status === 401) {
throw new Error("Cerebras API authentication failed. Please check your API key.")
} else if (error?.status === 403) {
}
if (error?.status === 403) {
throw new Error("Cerebras API access denied. Please check your API key permissions.")
} else if (error?.status >= 500) {
}
if (error?.status >= 500) {
// Server errors - retryable
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
} else if (error?.status === 400) {
}
if (error?.status === 400) {
// Client errors - not retryable
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
}
@@ -249,9 +254,7 @@ export class CerebrasHandler implements ApiHandler {
case "qwen-3-235b-a22b-instruct-2507":
case "qwen-3-235b-a22b-thinking-2507":
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
case "llama-3.3-70b":
case "gpt-oss-120b":
case "qwen-3-32b":
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
default:
// Default rate limits for unknown models
+4 -2
View File
@@ -30,6 +30,8 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
clineApiKey?: string
}
const CLINE_FREE_MODELS = ["minimax/minimax-m2.5", "kwaipilot/kat-coder-pro", "z-ai/glm-5"]
export class ClineHandler implements ApiHandler {
private options: ClineHandlerOptions
private clineAccountService = ClineAccountService.getInstance()
@@ -198,7 +200,7 @@ export class ClineHandler implements ApiHandler {
// @ts-expect-error-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
if (isFreeModel) {
totalCost = 0
@@ -252,7 +254,7 @@ export class ClineHandler implements ApiHandler {
const generation = response.data
let totalCost = generation?.total_cost || 0
const modelId = this.getModel().id
const isFreeModel = ["kwaipilot/kat-coder-pro", "minimax/minimax-m2.5", "z-ai/glm-5"].includes(modelId)
const isFreeModel = CLINE_FREE_MODELS.includes(modelId)
if (isFreeModel) {
totalCost = 0
+1 -1
View File
@@ -157,7 +157,7 @@ export class GeminiHandler implements ApiHandler {
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
systemInstruction: systemPrompt,
// Set temperature (default to 0)
// Gemini 3.0 recommends 1.0
// Gemini 3 recommends 1.0
temperature: info.temperature ?? 1,
}
+8 -4
View File
@@ -307,7 +307,7 @@ export class OcaHandler implements ApiHandler {
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const inputMessages = convertToOpenAIResponsesInput(messages).input
const inputMessages = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: false }).input
// Convert messages to Responses API input format
const input: OpenAI.Responses.ResponseInputItem[] = [{ role: "system", content: systemPrompt }, ...inputMessages]
@@ -329,14 +329,18 @@ export class OcaHandler implements ApiHandler {
tools: responseTools,
}
if (this.options.ocaModelInfo && this.options.ocaModelInfo.supportsReasoning) {
responsesParams["reasoning"] = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
const ocaModelInfo = this.options.ocaModelInfo
if (!ocaModelInfo) {
throw new Error("Oracle Code Assist (OCA) model info is required for Responses API")
}
if (ocaModelInfo.supportsReasoning) {
responsesParams.reasoning = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
}
// Create the response using Responses API
const stream = await client.responses.create(responsesParams)
yield* handleResponsesApiStreamResponse(stream, this.options.ocaModelInfo!, this.calculateCost.bind(this))
yield* handleResponsesApiStreamResponse(stream, ocaModelInfo, this.calculateCost.bind(this))
}
getModel() {
+260 -8
View File
@@ -3,11 +3,16 @@ import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import * as os from "os"
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
import { v7 as uuidv7 } from "uuid"
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
@@ -17,6 +22,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
* Routes to chatgpt.com/backend-api/codex
*/
const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex"
const CODEX_RESPONSES_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses"
interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
reasoningEffort?: string
@@ -36,6 +42,8 @@ interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
export class OpenAiCodexHandler implements ApiHandler {
private options: OpenAiCodexHandlerOptions
private client?: OpenAI
private responsesWs: UndiciWebSocket | undefined
private websocketRequestInFlight = false
// Session ID for the Codex API (persists for the lifetime of the handler)
private readonly sessionId: string
// Abort controller for cancelling ongoing requests
@@ -49,7 +57,7 @@ export class OpenAiCodexHandler implements ApiHandler {
this.sessionId = uuidv7()
}
private normalizeUsage(usage: any, model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
private normalizeUsage(usage: any, _model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
if (!usage) {
return undefined
}
@@ -101,17 +109,18 @@ export class OpenAiCodexHandler implements ApiHandler {
if (!accessToken) {
throw new Error("Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.")
}
// Format conversation for Responses API
const formattedInput = convertToOpenAIResponsesInput(messages).input
const useWebsocketMode = this.useWebsocketMode(model.info.apiFormat)
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId: useWebsocketMode })
const usePreviousResponseId = useWebsocketMode && !!previousResponseId
// Build request body
const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, tools)
const requestBody = this.buildRequestBody(model, input, systemPrompt, tools, previousResponseId)
const fallbackRequestBody = this.buildRequestBody(model, input, systemPrompt, tools)
// Make the request with retry on auth failure
for (let attempt = 0; attempt < 2; attempt++) {
try {
yield* this.executeRequest(requestBody, model, accessToken)
yield* this.executeRequest(requestBody, fallbackRequestBody, model, accessToken, usePreviousResponseId)
return
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -133,11 +142,19 @@ export class OpenAiCodexHandler implements ApiHandler {
}
}
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
}
return false
}
private buildRequestBody(
model: { id: string; info: ModelInfo },
formattedInput: any,
systemPrompt: string,
tools?: ChatCompletionTool[],
previousResponseId?: string,
): any {
// Determine reasoning effort
const reasoningEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
@@ -147,8 +164,9 @@ export class OpenAiCodexHandler implements ApiHandler {
model: model.id,
input: formattedInput,
stream: true,
store: false,
store: !previousResponseId,
instructions: systemPrompt,
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
...(includeReasoning ? { include: ["reasoning.encrypted_content"] } : {}),
...(includeReasoning
? {
@@ -177,7 +195,13 @@ export class OpenAiCodexHandler implements ApiHandler {
return body
}
private async *executeRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
private async *executeRequest(
requestBody: any,
fallbackRequestBody: any,
model: { id: string; info: ModelInfo },
accessToken: string,
useWebsocketMode: boolean,
): ApiStream {
// Create AbortController for cancellation
this.abortController = new AbortController()
@@ -194,6 +218,16 @@ export class OpenAiCodexHandler implements ApiHandler {
...buildExternalBasicHeaders(),
}
if (useWebsocketMode) {
try {
yield* this.createResponseStreamWebsocket(requestBody, fallbackRequestBody, accessToken, codexHeaders, model)
return
} catch (error) {
Logger.error("OpenAI Codex websocket mode failed, falling back to HTTP Responses API:", error)
this.closeResponsesWebsocket()
}
}
// Try using OpenAI SDK first
try {
const client =
@@ -232,6 +266,223 @@ export class OpenAiCodexHandler implements ApiHandler {
}
}
private async *createResponseStreamWebsocket(
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
accessToken: string,
codexHeaders: Record<string, string>,
model: { id: string; info: ModelInfo },
): ApiStream {
try {
for await (const event of this.createResponseEventsViaWebsocket(primaryParams, accessToken, codexHeaders)) {
if (this.abortController?.signal.aborted) {
return
}
yield* this.processEvent(event, model)
}
} catch (error) {
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
Logger.log(
"Retrying Codex websocket response with full context after previous_response_not_found or socket reset",
)
this.closeResponsesWebsocket()
for await (const event of this.createResponseEventsViaWebsocket(fallbackParams, accessToken, codexHeaders)) {
if (this.abortController?.signal.aborted) {
return
}
yield* this.processEvent(event, model)
}
return
}
throw error
}
}
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
const errorCode =
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
? (error as { code: string }).code
: undefined
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
return true
}
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
return true
}
return false
}
private async ensureResponsesWebsocket(accessToken: string, codexHeaders: Record<string, string>): Promise<UndiciWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
return this.responsesWs
}
this.closeResponsesWebsocket()
const ws = new UndiciWebSocket(CODEX_RESPONSES_WEBSOCKET_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...codexHeaders,
},
})
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener("open", handleOpen)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
}
const handleOpen = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error("Failed to open Codex Responses websocket"))
}
const handleClose = () => {
cleanup()
reject(new Error("Codex Responses websocket closed before opening"))
}
ws.addEventListener("open", handleOpen)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
})
this.responsesWs = ws
return ws
}
private closeResponsesWebsocket() {
if (this.responsesWs) {
try {
this.responsesWs.close()
} catch {}
this.responsesWs = undefined
}
}
private async *createResponseEventsViaWebsocket(
params: OpenAI.Responses.ResponseCreateParamsStreaming,
accessToken: string,
codexHeaders: Record<string, string>,
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
if (this.websocketRequestInFlight) {
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
error.code = "websocket_concurrency_limit"
throw error
}
const ws = await this.ensureResponsesWebsocket(accessToken, codexHeaders)
this.websocketRequestInFlight = true
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
let resolver: (() => void) | undefined
let completed = false
let failure: (Error & { code?: string }) | undefined
const wake = () => {
const next = resolver
resolver = undefined
next?.()
}
const handleMessage = (evt: UndiciMessageEvent) => {
try {
let raw = ""
if (typeof evt.data === "string") {
raw = evt.data
} else if (evt.data instanceof ArrayBuffer) {
raw = new TextDecoder().decode(new Uint8Array(evt.data))
} else if (ArrayBuffer.isView(evt.data)) {
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
} else {
raw = String(evt.data)
}
const parsed = JSON.parse(raw)
if (parsed?.type === "error" && parsed?.error) {
const error: Error & { code?: string } = new Error(parsed.error.message || "Codex Responses websocket error")
error.code = parsed.error.code
failure = error
completed = true
wake()
return
}
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
completed = true
}
wake()
} catch (error) {
const parseError: Error & { code?: string } = new Error(
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
)
parseError.code = "websocket_parse_error"
failure = parseError
completed = true
wake()
}
}
const handleError = () => {
const error: Error & { code?: string } = new Error("Codex Responses websocket emitted an error event")
error.code = "websocket_error"
failure = error
completed = true
wake()
}
const handleClose = () => {
if (!completed) {
const error: Error & { code?: string } = new Error("Codex Responses websocket closed during response stream")
error.code = "websocket_closed"
failure = error
completed = true
wake()
}
}
ws.addEventListener("message", handleMessage)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
try {
ws.send(
JSON.stringify({
type: "response.create",
...params,
}),
)
while (!completed || eventQueue.length > 0) {
if (eventQueue.length === 0) {
await new Promise<void>((resolve) => {
resolver = resolve
})
continue
}
const event = eventQueue.shift()
if (event) {
yield event
}
}
if (failure) {
throw failure
}
} finally {
ws.removeEventListener("message", handleMessage)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
this.websocketRequestInFlight = false
}
}
private async *makeCodexRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
const url = `${CODEX_API_BASE_URL}/responses`
@@ -465,6 +716,7 @@ export class OpenAiCodexHandler implements ApiHandler {
}
abort(): void {
this.closeResponsesWebsocket()
this.abortController?.abort()
}
+316 -34
View File
@@ -8,10 +8,18 @@ import {
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
import type {
ChatCompletionFunctionTool,
ChatCompletionReasoningEffort,
ChatCompletionTool,
} from "openai/resources/chat/completions"
import { MessageEvent as UndiciMessageEvent, WebSocket as UndiciWebSocket } from "undici"
import { buildExternalBasicHeaders } from "@/services/EnvUtils"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/cline/models"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { isGPT5ModelFamily } from "@/utils/model-utils"
import { ApiHandler, CommonApiHandlerOptions } from "../"
@@ -26,12 +34,15 @@ interface OpenAiNativeHandlerOptions extends CommonApiHandlerOptions {
reasoningEffort?: string
thinkingBudgetTokens?: number
apiModelId?: string
store?: boolean
openAiNativeUseResponsesWebsocket?: boolean
}
export class OpenAiNativeHandler implements ApiHandler {
private options: OpenAiNativeHandlerOptions
private client: OpenAI | undefined
private responsesWs: UndiciWebSocket | undefined
private websocketRequestInFlight = false
private abortController?: AbortController
constructor(options: OpenAiNativeHandlerOptions) {
this.options = options
@@ -73,7 +84,8 @@ export class OpenAiNativeHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
// Responses API requires tool format to be set to OPENAI_RESPONSES with native tools calling enabled
if (this.getModel()?.info?.apiFormat === ApiFormat.OPENAI_RESPONSES) {
const apiFormat = this.getModel()?.info?.apiFormat
if (apiFormat === ApiFormat.OPENAI_RESPONSES || apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE) {
if (!tools?.length) {
throw new Error("Native Tool Call must be enabled in your setting for OpenAI Responses API")
}
@@ -91,13 +103,17 @@ export class OpenAiNativeHandler implements ApiHandler {
const client = this.ensureClient()
const model = this.getModel()
const toolCallProcessor = new ToolCallProcessor()
this.abortController = new AbortController()
// Handle o1 models separately as they don't support streaming
if (model.info.supportsStreaming === false) {
const response = await client.chat.completions.create({
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
const response = await client.chat.completions.create(
{
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
},
{ signal: this.abortController?.signal },
)
yield {
type: "text",
text: response.choices[0]?.message.content || "",
@@ -152,26 +168,66 @@ export class OpenAiNativeHandler implements ApiHandler {
messages: ClineStorageMessage[],
tools: ChatCompletionTool[],
): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const usePreviousResponseId = this.useWebsocketMode(model.info.apiFormat)
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId })
const responseTools = this.mapResponseTools(tools)
this.abortController = new AbortController()
// Convert messages to Responses API input format
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages)
const params = this.buildResponseCreateParams({
modelId: model.id,
systemPrompt,
input,
previousResponseId,
tools: responseTools,
})
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool?.type === "function")
.map((tool: any) => ({
const fallbackParams = this.buildResponseCreateParams({
modelId: model.id,
systemPrompt,
input,
tools: responseTools,
})
if (usePreviousResponseId && previousResponseId) {
try {
yield* this.createResponseStreamWebsocket(model.info, params, fallbackParams)
return
} catch (error) {
Logger.error("OpenAI websocket mode failed, falling back to HTTP Responses API:", error)
this.closeResponsesWebsocket()
}
}
yield* this.createResponseStreamHttp(model.info, params)
}
private useWebsocketMode(apiFormat?: ApiFormat): boolean {
if (featureFlagsService.getBooleanFlagEnabled(FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE)) {
return apiFormat === ApiFormat.OPENAI_RESPONSES_WEBSOCKET_MODE
}
return false
}
private mapResponseTools(tools: ChatCompletionTool[]): OpenAI.Responses.Tool[] {
return tools
?.filter((tool): tool is ChatCompletionFunctionTool => tool?.type === "function")
.map((tool) => ({
type: "function" as const,
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
parameters: tool.function.parameters ?? null,
strict: tool.function.strict ?? true,
}))
}
Logger.debug(`OpenAI Responses Input: ${JSON.stringify(input)}`)
// Create the response using Responses API
private buildResponseCreateParams(args: {
modelId: string
systemPrompt: string
input: OpenAI.Responses.ResponseInput
tools: OpenAI.Responses.Tool[]
previousResponseId?: string
}): OpenAI.Responses.ResponseCreateParamsStreaming {
const requestedEffort = normalizeOpenaiReasoningEffort(this.options.reasoningEffort)
const reasoning: { effort: ChatCompletionReasoningEffort; summary: "auto" } | undefined =
requestedEffort === "none"
@@ -181,25 +237,243 @@ export class OpenAiNativeHandler implements ApiHandler {
summary: "auto",
}
const stream = await client.responses.create({
model: model.id,
instructions: systemPrompt,
input,
return {
model: args.modelId,
instructions: args.systemPrompt,
input: args.input,
stream: true,
tools: responseTools,
store: this.options.store ?? false,
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
tools: args.tools,
store: !args.previousResponseId, // Do not use store when websocket mode is enabled.
...(args.previousResponseId ? { previous_response_id: args.previousResponseId } : {}),
...(reasoning ? { reasoning } : {}),
// include: ["reasoning.encrypted_content"],
}
}
private async *createResponseStreamHttp(
modelInfo: ModelInfo,
params: OpenAI.Responses.ResponseCreateParamsStreaming,
): ApiStream {
const client = this.ensureClient()
Logger.debug(`OpenAI Responses Input (HTTP): ${JSON.stringify(params.input)}`)
const stream = await client.responses.create(params, { signal: this.abortController?.signal })
yield* this.processResponsesEvents(stream, modelInfo)
}
private async *createResponseStreamWebsocket(
modelInfo: ModelInfo,
primaryParams: OpenAI.Responses.ResponseCreateParamsStreaming,
fallbackParams: OpenAI.Responses.ResponseCreateParamsStreaming,
): ApiStream {
Logger.debug(`OpenAI Responses Input (WebSocket): ${JSON.stringify(primaryParams.input)}`)
try {
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(primaryParams), modelInfo)
} catch (error) {
if (this.shouldRetryWebsocketWithFullContext(error, !!primaryParams.previous_response_id)) {
Logger.log("Retrying websocket response with full context after previous_response_not_found or socket reset")
this.closeResponsesWebsocket()
yield* this.processResponsesEvents(this.createResponseEventsViaWebsocket(fallbackParams), modelInfo)
return
}
throw error
}
}
private shouldRetryWebsocketWithFullContext(error: unknown, hadPreviousResponseId: boolean): boolean {
const errorCode =
typeof error === "object" && error && "code" in error && typeof (error as { code: unknown }).code === "string"
? (error as { code: string }).code
: undefined
if (hadPreviousResponseId && errorCode === "previous_response_not_found") {
return true
}
if (errorCode === "websocket_closed" || errorCode === "websocket_error") {
return true
}
return false
}
private async ensureResponsesWebsocket(): Promise<UndiciWebSocket> {
if (this.responsesWs && this.responsesWs.readyState === UndiciWebSocket.OPEN) {
return this.responsesWs
}
this.closeResponsesWebsocket()
if (!this.options.openAiNativeApiKey) {
throw new Error("OpenAI API key is required")
}
const ws = new UndiciWebSocket("wss://api.openai.com/v1/responses", {
headers: {
Authorization: `Bearer ${this.options.openAiNativeApiKey}`,
"OpenAI-Beta": "responses_websockets=2026-02-06",
...buildExternalBasicHeaders(),
},
})
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener("open", handleOpen)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
}
const handleOpen = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error("Failed to open Responses websocket"))
}
const handleClose = () => {
cleanup()
reject(new Error("Responses websocket closed before opening"))
}
ws.addEventListener("open", handleOpen)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
})
this.responsesWs = ws
return ws
}
private closeResponsesWebsocket() {
if (this.responsesWs) {
try {
this.responsesWs.close()
} catch {}
this.responsesWs = undefined
}
}
private async *createResponseEventsViaWebsocket(
params: OpenAI.Responses.ResponseCreateParamsStreaming,
): AsyncGenerator<OpenAI.Responses.ResponseStreamEvent> {
if (this.websocketRequestInFlight) {
const error: Error & { code?: string } = new Error("Websocket response.create is already in progress")
error.code = "websocket_concurrency_limit"
throw error
}
const ws = await this.ensureResponsesWebsocket()
this.websocketRequestInFlight = true
const eventQueue: OpenAI.Responses.ResponseStreamEvent[] = []
let resolver: (() => void) | undefined
let completed = false
let failure: (Error & { code?: string }) | undefined
const wake = () => {
const next = resolver
resolver = undefined
next?.()
}
const handleMessage = (evt: UndiciMessageEvent) => {
try {
let raw = ""
if (typeof evt.data === "string") {
raw = evt.data
} else if (evt.data instanceof ArrayBuffer) {
raw = new TextDecoder().decode(new Uint8Array(evt.data))
} else if (ArrayBuffer.isView(evt.data)) {
raw = new TextDecoder().decode(new Uint8Array(evt.data.buffer, evt.data.byteOffset, evt.data.byteLength))
} else {
raw = String(evt.data)
}
const parsed = JSON.parse(raw)
if (parsed?.type === "error" && parsed?.error) {
const error: Error & { code?: string } = new Error(parsed.error.message || "Responses websocket error")
error.code = parsed.error.code
failure = error
completed = true
wake()
return
}
eventQueue.push(parsed as OpenAI.Responses.ResponseStreamEvent)
if (parsed?.type === "response.completed" || parsed?.type === "response.failed") {
completed = true
}
wake()
} catch (error) {
const parseError: Error & { code?: string } = new Error(
`Failed to parse websocket event: ${error instanceof Error ? error.message : String(error)}`,
)
parseError.code = "websocket_parse_error"
failure = parseError
completed = true
wake()
}
}
const handleError = () => {
const error: Error & { code?: string } = new Error("Responses websocket emitted an error event")
error.code = "websocket_error"
failure = error
completed = true
wake()
}
const handleClose = () => {
if (!completed) {
const error: Error & { code?: string } = new Error("Responses websocket closed during response stream")
error.code = "websocket_closed"
failure = error
completed = true
wake()
}
}
ws.addEventListener("message", handleMessage)
ws.addEventListener("error", handleError)
ws.addEventListener("close", handleClose)
try {
ws.send(
JSON.stringify({
type: "response.create",
...params,
}),
)
while (!completed || eventQueue.length > 0) {
if (eventQueue.length === 0) {
await new Promise<void>((resolve) => {
resolver = resolve
})
continue
}
const event = eventQueue.shift()
if (event) {
yield event
}
}
if (failure) {
throw failure
}
} finally {
ws.removeEventListener("message", handleMessage)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
this.websocketRequestInFlight = false
}
}
private async *processResponsesEvents(
stream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent>,
modelInfo: ModelInfo,
): ApiStream {
const functionCallByItemId = new Map<string, { call_id?: string; name?: string; id?: string }>()
// Process the response stream
for await (const chunk of stream) {
Logger.debug(`OpenAI Responses Chunk: ${JSON.stringify(chunk)}`)
// Handle different event types from Responses API
if (chunk.type === "response.output_item.added") {
const item = chunk.item
if (item.type === "function_call" && item.id) {
@@ -277,7 +551,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.output_text.delta") {
// Handle text content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
@@ -287,7 +560,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.reasoning_text.delta") {
// Handle reasoning content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
@@ -315,7 +587,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.type === "response.function_call_arguments.done") {
// Handle completed function call
if (chunk.item_id && chunk.name && chunk.arguments) {
const pendingCall = functionCallByItemId.get(chunk.item_id)
const callId = pendingCall?.call_id
@@ -348,7 +619,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
if (chunk.type === "response.completed" && chunk.response?.usage) {
// Handle usage information when response is complete
const usage = chunk.response.usage
const inputTokens = usage.input_tokens || 0
const outputTokens = usage.output_tokens || 0
@@ -357,7 +627,13 @@ export class OpenAiNativeHandler implements ApiHandler {
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens || 0
const totalTokens = usage.total_tokens || 0
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens + reasoningTokens, cacheWriteTokens, cacheReadTokens)
const totalCost = calculateApiCostOpenAI(
modelInfo,
inputTokens,
outputTokens + reasoningTokens,
cacheWriteTokens,
cacheReadTokens,
)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
@@ -373,6 +649,12 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
abort(): void {
this.closeResponsesWebsocket()
this.abortController?.abort()
this.abortController = undefined
}
getModel(): { id: OpenAiNativeModelId; info: OpenAiCompatibleModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in openAiNativeModels) {
+271
View File
@@ -0,0 +1,271 @@
import { providers } from "@cline/llms"
import type { ClineContent, ClineImageContentBlock, ClineStorageMessage, ClineTextContentBlock } from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import { SELECTOR_SEPARATOR } from "@/shared/vsCodeSelectorUtils"
import { VsCodeLmHandler } from "./vscode-lm"
export const VSCODE_LM_SELECTOR_HEADER = "x-cline-vscode-lm-selector"
let createHandlersPatched = false
let vscodeLmProviderRegistered = false
let originalCreateHandler: ((config: providers.ProviderConfig) => providers.ApiHandler) | undefined
let originalCreateHandlerAsync: ((config: providers.ProviderConfig) => Promise<providers.ApiHandler>) | undefined
const extensionProviderFactories = new Map<string, (config: providers.ProviderConfig) => providers.ApiHandler>()
function ensureProvidersCreateHandlerPatched() {
if (createHandlersPatched) {
return
}
const mutableProviders = providers as typeof providers & {
createHandler: (config: providers.ProviderConfig) => providers.ApiHandler
createHandlerAsync: (config: providers.ProviderConfig) => Promise<providers.ApiHandler>
}
originalCreateHandler = mutableProviders.createHandler.bind(providers)
originalCreateHandlerAsync = mutableProviders.createHandlerAsync.bind(providers)
mutableProviders.createHandler = (config: providers.ProviderConfig): providers.ApiHandler => {
const extensionFactory = extensionProviderFactories.get(config.providerId)
if (extensionFactory) {
return extensionFactory(config)
}
if (!originalCreateHandler) {
throw new Error("LLMS createHandler has not been initialized")
}
return originalCreateHandler(config)
}
mutableProviders.createHandlerAsync = async (config: providers.ProviderConfig): Promise<providers.ApiHandler> => {
const extensionFactory = extensionProviderFactories.get(config.providerId)
if (extensionFactory) {
return extensionFactory(config)
}
if (!originalCreateHandlerAsync) {
throw new Error("LLMS createHandlerAsync has not been initialized")
}
return originalCreateHandlerAsync(config)
}
createHandlersPatched = true
}
function normalizeImageMediaType(mediaType: string): "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
switch (mediaType) {
case "image/jpeg":
case "image/png":
case "image/gif":
case "image/webp":
return mediaType
default:
return "image/png"
}
}
function toClineContentBlock(block: providers.ContentBlock): ClineContent {
switch (block.type) {
case "text":
return { type: "text", text: block.text }
case "image":
return {
type: "image",
source: {
type: "base64",
media_type: normalizeImageMediaType(block.mediaType),
data: block.data,
},
}
case "file":
return {
type: "text",
text: `File: ${block.path}\n${block.content}`,
}
case "tool_use":
return {
type: "tool_use",
id: block.id,
name: block.name,
input: block.input,
}
case "tool_result":
return {
type: "tool_result",
tool_use_id: block.tool_use_id,
content:
typeof block.content === "string"
? block.content
: block.content.map((contentBlock): ClineTextContentBlock | ClineImageContentBlock => {
switch (contentBlock.type) {
case "text":
return { type: "text", text: contentBlock.text }
case "image":
return {
type: "image",
source: {
type: "base64",
media_type: normalizeImageMediaType(contentBlock.mediaType),
data: contentBlock.data,
},
}
case "file":
return { type: "text", text: `File: ${contentBlock.path}\n${contentBlock.content}` }
default: {
const exhaustiveCheck: never = contentBlock
throw new Error(
`Unsupported provider tool_result content block type: ${JSON.stringify(exhaustiveCheck)}`,
)
}
}
}),
is_error: block.is_error,
}
case "thinking":
return {
type: "thinking",
thinking: block.thinking,
signature: block.signature ?? "",
}
case "redacted_thinking":
return {
type: "redacted_thinking",
data: block.data,
}
default: {
const exhaustiveCheck: never = block
throw new Error(`Unsupported provider content block type: ${JSON.stringify(exhaustiveCheck)}`)
}
}
}
function toClineMessages(messages: providers.Message[]): ClineStorageMessage[] {
return messages.map((message) => {
if (typeof message.content === "string") {
return {
role: message.role,
content: message.content,
}
}
return {
role: message.role,
content: message.content.map((block) => toClineContentBlock(block)),
}
})
}
function parseVsCodeLmSelector(modelId?: string): Record<string, string> | undefined {
if (!modelId) {
return undefined
}
const parts = modelId.split(SELECTOR_SEPARATOR).filter(Boolean)
if (parts.length === 0) {
return undefined
}
const [vendor, family, version, id] = parts
const selector: Record<string, string> = {}
if (vendor) {
selector.vendor = vendor
}
if (family) {
selector.family = family
}
if (version) {
selector.version = version
}
if (id) {
selector.id = id
}
return Object.keys(selector).length > 0 ? selector : undefined
}
function toRegisteredModelInfo(modelId: string, modelInfo: ReturnType<VsCodeLmHandler["getModel"]>["info"]): providers.ModelInfo {
const capabilities: providers.ModelInfo["capabilities"] = []
if (modelInfo.supportsImages) {
capabilities.push("images")
}
if (modelInfo.supportsPromptCache) {
capabilities.push("prompt-cache")
}
if (modelInfo.supportsReasoning) {
capabilities.push("reasoning")
}
if (modelInfo.supportsGlobalEndpoint) {
capabilities.push("global-endpoint")
}
return {
id: modelId,
name: modelInfo.name,
description: modelInfo.description,
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
temperature: modelInfo.temperature,
capabilities: capabilities.length > 0 ? capabilities : undefined,
pricing:
modelInfo.inputPrice !== undefined ||
modelInfo.outputPrice !== undefined ||
modelInfo.cacheWritesPrice !== undefined ||
modelInfo.cacheReadsPrice !== undefined
? {
input: modelInfo.inputPrice,
output: modelInfo.outputPrice,
cacheWrite: modelInfo.cacheWritesPrice,
cacheRead: modelInfo.cacheReadsPrice,
}
: undefined,
thinkingConfig: modelInfo.thinkingConfig
? {
maxBudget: modelInfo.thinkingConfig.maxBudget,
outputPrice: modelInfo.thinkingConfig.outputPrice,
thinkingLevel: modelInfo.thinkingConfig.geminiThinkingLevel,
}
: undefined,
}
}
export function ensureVsCodeLmProviderRegistered() {
if (vscodeLmProviderRegistered) {
return
}
ensureProvidersCreateHandlerPatched()
extensionProviderFactories.set("vscode-lm", (config: providers.ProviderConfig): providers.ApiHandler => {
const selectorFromHeader = config?.headers?.[VSCODE_LM_SELECTOR_HEADER]
let selector: Record<string, string> | undefined
if (selectorFromHeader) {
try {
selector = JSON.parse(selectorFromHeader)
} catch {
selector = undefined
}
}
selector = selector ?? parseVsCodeLmSelector(config?.modelId)
const handler = new VsCodeLmHandler({
vsCodeLmModelSelector: selector,
})
return {
getMessages: (systemPrompt: string, messages: providers.Message[]) => ({
systemPrompt,
messages: toClineMessages(messages),
}),
createMessage: (
systemPrompt: string,
messages: providers.Message[],
_tools?: providers.ToolDefinition[],
): providers.ApiStream =>
handler.createMessage(systemPrompt, toClineMessages(messages)) as unknown as providers.ApiStream,
getModel: () => {
const model = handler.getModel()
return {
id: model.id,
info: toRegisteredModelInfo(model.id, model.info),
}
},
}
})
vscodeLmProviderRegistered = true
Logger.debug("Registered extension provider handler for vscode-lm")
}
+2
View File
@@ -78,6 +78,8 @@ export class RequestyHandler implements ApiHandler {
: { thinking: { type: "disabled" } }
const thinkingArgs =
model.id.includes("claude-opus-4-6") ||
model.id.includes("claude-sonnet-4-6") ||
model.id.includes("claude-4.6-sonnet") ||
model.id.includes("claude-3-7-sonnet") ||
model.id.includes("claude-sonnet-4") ||
model.id.includes("claude-opus-4") ||
+16 -12
View File
@@ -372,12 +372,12 @@ export class SapAiCoreHandler implements ApiHandler {
private chunkToString(chunk: any): string {
if (Buffer.isBuffer(chunk)) {
return chunk.toString("utf-8")
} else if (typeof chunk === "string") {
return chunk
} else {
// Handle comma-separated byte values or other array-like formats
return Buffer.from(chunk).toString("utf-8")
}
if (typeof chunk === "string") {
return chunk
}
// Handle comma-separated byte values or other array-like formats
return Buffer.from(chunk).toString("utf-8")
}
private validateCredentials(): void {
@@ -526,7 +526,7 @@ export class SapAiCoreHandler implements ApiHandler {
if (!expiresIn) {
throw new Error("Destination is missing required authTokens with expiresIn")
}
this.destinationExpiresAt = Date.now() + parseInt(expiresIn, 10) * 1000
this.destinationExpiresAt = Date.now() + Number.parseInt(expiresIn, 10) * 1000
}
}
@@ -610,6 +610,7 @@ export class SapAiCoreHandler implements ApiHandler {
const anthropicModels = [
"anthropic--claude-4.5-haiku",
"anthropic--claude-4.5-opus",
"anthropic--claude-4.6-sonnet",
"anthropic--claude-4.5-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
@@ -659,6 +660,7 @@ export class SapAiCoreHandler implements ApiHandler {
if (
model.id === "anthropic--claude-4.5-opus" ||
model.id === "anthropic--claude-4.6-sonnet" ||
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4.5-haiku" ||
model.id === "anthropic--claude-4-sonnet" ||
@@ -791,6 +793,7 @@ export class SapAiCoreHandler implements ApiHandler {
yield* this.streamCompletionGPT(response.data, model)
} else if (
model.id === "anthropic--claude-4.5-opus" ||
model.id === "anthropic--claude-4.6-sonnet" ||
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4.5-haiku" ||
model.id === "anthropic--claude-4-sonnet" ||
@@ -846,20 +849,21 @@ export class SapAiCoreHandler implements ApiHandler {
if (error.response.status === 404) {
throw new Error(`404 Not Found: ${errorMessage}`)
} else if (error.response.status === 400) {
}
if (error.response.status === 400) {
throw new Error(`400 Bad Request: ${errorMessage}`)
}
throw new Error(`HTTP ${error.response.status}: ${errorMessage}`)
} else if (error.request) {
}
if (error.request) {
// The request was made but no response was received
Logger.error("Error request:", error.request)
throw new Error("No response received from server")
} else {
// Something happened in setting up the request that triggered an Error
Logger.error("Error message:", error.message)
throw new Error(`Error setting up request: ${error.message}`)
}
// Something happened in setting up the request that triggered an Error
Logger.error("Error message:", error.message)
throw new Error(`Error setting up request: ${error.message}`)
}
}
+7 -7
View File
@@ -392,7 +392,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
const totalInputTokens: number = await this.calculateTotalInputTokens(vsCodeLmMessages)
// Accumulate the text and count at the end of the stream to reduce token counting overhead.
let accumulatedText: string = ""
let accumulatedText = ""
try {
// Create the response stream with minimal required options
@@ -498,17 +498,17 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
// Return original error if it's already an Error instance
throw error
} else if (typeof error === "object" && error !== null) {
}
if (typeof error === "object" && error !== null) {
// Handle error-like objects
const errorDetails = JSON.stringify(error, null, 2)
Logger.error("Cline <Language Model API>: Stream error object:", errorDetails)
throw new Error(`Cline <Language Model API>: Response stream error: ${errorDetails}`)
} else {
// Fallback for unknown error types
const errorMessage = String(error)
Logger.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
}
// Fallback for unknown error types
const errorMessage = String(error)
Logger.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
}
}
@@ -85,7 +85,10 @@ export function convertToOpenAIResponsesInput(
if (options?.usePreviousResponseId) {
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i]
if (msg.role === "assistant" && msg.id) {
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
// Set to 23 hours to account for any potential delays in processing.
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
previousResponseId = msg.id
messages = _messages.slice(i + 1)
break
+9 -1
View File
@@ -6,6 +6,7 @@ import {
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
@@ -34,6 +35,7 @@ export async function createOpenRouterStream(
const isClaude1m =
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeOpus461mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
@@ -50,6 +52,8 @@ export async function createOpenRouterStream(
case "anthropic/claude-opus-4.6":
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet": // OpenRouter accidentally included this in model list for a brief moment, and users may be using this model id. And to support prompt caching, we need to add it here.
case "anthropic/claude-sonnet-4":
@@ -118,6 +122,8 @@ export async function createOpenRouterStream(
case "anthropic/claude-opus-4.6":
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
@@ -154,7 +160,7 @@ export async function createOpenRouterStream(
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
if (model.id.startsWith("google/gemini-3")) {
// Recommended value from google
temperature = 1.0
}
@@ -166,6 +172,8 @@ export async function createOpenRouterStream(
case "anthropic/claude-opus-4.6":
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
@@ -5,6 +5,7 @@ import {
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
@@ -32,6 +33,7 @@ export async function createVercelAIGatewayStream(
const isClaude1m =
model.id === openRouterClaudeSonnet41mModelId ||
model.id === openRouterClaudeSonnet451mModelId ||
model.id === openRouterClaudeSonnet461mModelId ||
model.id === openRouterClaudeOpus461mModelId
if (isClaude1m) {
// remove the custom :1m suffix, to create the model id the API expects
@@ -98,7 +100,7 @@ export async function createVercelAIGatewayStream(
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
if (model.id.startsWith("google/gemini-3")) {
// Recommended value from google
temperature = 1.0
}
@@ -154,15 +154,21 @@ export class ContextManager {
thresholdPercentage?: number,
): boolean {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const previousRequestText = clineMessages[previousApiReqIndex]?.text
if (previousRequestText) {
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return totalTokens >= thresholdTokens
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const roundedThreshold = thresholdPercentage
? Math.floor(contextWindow * thresholdPercentage)
: maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return totalTokens >= thresholdTokens
} catch {
return false
}
}
}
return false
@@ -195,10 +201,10 @@ export class ContextManager {
}
if (targetIndex >= 0) {
const targetRequest = clineMessages[targetIndex]
if (targetRequest && targetRequest.text) {
const targetRequestText = clineMessages[targetIndex]?.text
if (targetRequestText) {
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequest.text)
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequestText)
const tokensUsed = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { contextWindow } = getContextWindowInfo(api)
@@ -232,10 +238,10 @@ export class ContextManager {
if (!useAutoCondense) {
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const previousRequestText = clineMessages[previousApiReqIndex]?.text
if (previousRequestText) {
const timestamp = clineMessages[previousApiReqIndex].ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequestText)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
@@ -846,9 +852,8 @@ export class ContextManager {
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
} else {
// for all other cases we can assume that we dont need to check this again
@@ -1,7 +1,29 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineMessage } from "@shared/ExtensionMessage"
import { expect } from "chai"
import { ContextManager } from "../ContextManager"
// Minimal mock for ApiHandler — only getModel().info.contextWindow is used by shouldCompactContextWindow
function createMockApi(contextWindow: number) {
return {
getModel: () => ({ id: "test-model", info: { contextWindow } }),
} as any
}
function createApiReqMessage(tokens: {
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
}): ClineMessage {
return {
ts: Date.now(),
type: "say",
say: "api_req_started",
text: JSON.stringify(tokens),
}
}
describe("ContextManager", () => {
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
const messages: Anthropic.Messages.MessageParam[] = []
@@ -381,4 +403,93 @@ describe("ContextManager", () => {
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
})
})
describe("shouldCompactContextWindow", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("does not compact at 33K tokens with default 0.75 threshold on 200K context", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 30_000, tokensOut: 3_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
expect(result).to.equal(false)
})
it("compacts when tokens exceed 0.75 threshold on 200K context", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 140_000, tokensOut: 15_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
expect(result).to.equal(true)
})
it("compacts at only 10K tokens when threshold is accidentally set to 0.05", () => {
const contextWindow = 200_000
const accidentalThreshold = 0.05
// floor(200000 * 0.05) = 10000 — this is the bug case from PR #9348.
// Accidental clicks on the progress bar set threshold to ~5%, triggering
// compaction at 10K tokens instead of the intended 150K (0.75 * 200K).
const compactionTriggersAt = Math.floor(contextWindow * accidentalThreshold) // 10,000
const totalTokens = compactionTriggersAt + 500 // 10,500 — just above the trigger
const api = createMockApi(contextWindow)
const tokensIn = totalTokens - 1_500
const tokensOut = 1_500
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn, tokensOut })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, accidentalThreshold)
expect(result).to.equal(true)
})
it("falls back to maxAllowedSize when threshold is undefined", () => {
const api = createMockApi(200_000)
// 155K tokens — above 0.75 threshold (150K) but below maxAllowedSize (160K)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, undefined)
// undefined → uses maxAllowedSize (160K), so 155K < 160K → false
expect(result).to.equal(false)
})
it("falls back to maxAllowedSize when threshold is 0", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 150_000, tokensOut: 5_000 })]
// 0 is falsy, so ternary falls back to maxAllowedSize (160K)
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0)
expect(result).to.equal(false)
})
it("includes cacheWrites and cacheReads in total token count", () => {
const api = createMockApi(200_000)
// Low direct tokens but high cache reads push total over threshold
const clineMessages: ClineMessage[] = [
createApiReqMessage({ tokensIn: 5_000, tokensOut: 500, cacheWrites: 0, cacheReads: 150_000 }),
]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 0.75)
expect(result).to.equal(true)
})
it("returns false when previousApiReqIndex is negative", () => {
const api = createMockApi(200_000)
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 200_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, -1, 0.75)
expect(result).to.equal(false)
})
it("threshold is capped at maxAllowedSize even when percentage is very high", () => {
const api = createMockApi(200_000)
// threshold of 1.0 → floor(200000 * 1.0) = 200000, but min(200000, 160000) = 160000
const clineMessages: ClineMessage[] = [createApiReqMessage({ tokensIn: 165_000 })]
const result = contextManager.shouldCompactContextWindow(clineMessages, api, 0, 1.0)
expect(result).to.equal(true)
})
})
})
@@ -2,8 +2,8 @@ import { getTaskMetadata, readTaskHistoryFromState, saveTaskMetadata } from "@co
import type { ClineMessage } from "@shared/ExtensionMessage"
import chokidar, { FSWatcher } from "chokidar"
import * as path from "path"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { StateManager } from "@/core/storage/StateManager"
import { Logger } from "@/shared/services/Logger"
import { getCwd } from "@/utils/path"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
@@ -250,6 +250,8 @@ export class FileContextTracker {
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
return files
} catch (error) {
@@ -265,6 +267,8 @@ export class FileContextTracker {
try {
const files = await this.retrievePendingFileContextWarning()
if (files) {
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
return files
}
@@ -278,12 +282,12 @@ export class FileContextTracker {
* Static method to clean up orphaned pending file context warnings at startup
* This removes warnings for tasks that may no longer exist
*/
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
static async cleanupOrphanedWarnings(stateManager: StateManager): Promise<void> {
const startTime = Date.now()
try {
const taskHistory = await readTaskHistoryFromState()
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
const allStateKeys = context.workspaceState.keys()
const allStateKeys = Object.keys(stateManager.getAllWorkspaceStateEntries())
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
const orphanedPendingContextTasks: string[] = []
@@ -296,8 +300,9 @@ export class FileContextTracker {
if (orphanedPendingContextTasks.length > 0) {
for (const key of orphanedPendingContextTasks) {
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
await context.workspaceState.update(key, undefined)
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
await stateManager.setWorkspaceState(key as any, undefined)
}
}
+14 -15
View File
@@ -1,5 +1,4 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { buildApiHandler } from "@core/api"
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import { tryAcquireTaskLockWithRetry } from "@core/task/TaskLockUtils"
import { detectWorkspaceRoots } from "@core/workspace/detection"
@@ -13,7 +12,7 @@ import type { ChatContent } from "@shared/ChatContent"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import type { HistoryItem } from "@shared/HistoryItem"
import type { McpMarketplaceCatalog, McpMarketplaceItem } from "@shared/mcp"
import type { Settings } from "@shared/storage/state-keys"
import { type Settings } from "@shared/storage/state-keys"
import type { Mode } from "@shared/storage/types"
import type { TelemetrySetting } from "@shared/TelemetrySetting"
import type { UserInfo } from "@shared/UserInfo"
@@ -23,7 +22,6 @@ import fs from "fs/promises"
import open from "open"
import pWaitFor from "p-wait-for"
import * as path from "path"
import type * as vscode from "vscode"
import { ClineEnv } from "@/config"
import type { FolderLockWithRetryResult } from "@/core/locks/types"
import { HostProvider } from "@/hosts/host-provider"
@@ -35,6 +33,7 @@ import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { telemetryService } from "@/services/telemetry"
import { ClineExtensionContext } from "@/shared/cline"
import { getAxiosSettings } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
@@ -117,7 +116,7 @@ export class Controller {
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
}
constructor(readonly context: vscode.ExtensionContext) {
constructor(readonly context: ClineExtensionContext) {
Session.reset() // Reset session on controller initialization
PromptRegistry.getInstance() // Ensure prompts and tools are registered
this.stateManager = StateManager.get()
@@ -370,10 +369,10 @@ export class Controller {
// Switch to act mode
this.stateManager.setGlobalState("mode", modeToSwitchTo)
// Update API handler with new mode (buildApiHandler now selects provider based on mode)
// Update API handler with new mode
if (this.task) {
const apiConfiguration = this.stateManager.getApiConfiguration()
this.task.api = buildApiHandler({ ...apiConfiguration, ulid: this.task.ulid }, modeToSwitchTo)
this.task.updateApiHandler(apiConfiguration, modeToSwitchTo)
}
await this.postStateToWebview()
@@ -394,10 +393,10 @@ export class Controller {
// Capture mode switch telemetry | Capture regardless of if we know the taskId
telemetryService.captureModeSwitch(this.task?.ulid ?? "0", modeToSwitchTo)
// Update API handler with new mode (buildApiHandler now selects provider based on mode)
// Update API handler with new mode
if (this.task) {
const apiConfiguration = this.stateManager.getApiConfiguration()
this.task.api = buildApiHandler({ ...apiConfiguration, ulid: this.task.ulid }, modeToSwitchTo)
this.task.updateApiHandler(apiConfiguration, modeToSwitchTo)
}
await this.postStateToWebview()
@@ -547,7 +546,7 @@ export class Controller {
await fetchRemoteConfig(this)
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
this.task.updateApiHandler(updatedConfig, currentMode)
}
await this.postStateToWebview()
@@ -598,7 +597,7 @@ export class Controller {
this.stateManager.setGlobalState("welcomeViewCompleted", true)
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
this.task.updateApiHandler(updatedConfig, currentMode)
}
await this.postStateToWebview()
@@ -716,7 +715,7 @@ export class Controller {
await this.postStateToWebview()
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
this.task.updateApiHandler(updatedConfig, currentMode)
}
// Dont send settingsButtonClicked because its bad ux if user is on welcome
}
@@ -736,7 +735,7 @@ export class Controller {
this.stateManager.setApiConfiguration(updatedConfig)
await this.postStateToWebview()
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
this.task.updateApiHandler(updatedConfig, currentMode)
}
}
@@ -776,7 +775,7 @@ export class Controller {
await this.postStateToWebview()
this.accountService
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
this.task.updateApiHandler(updatedConfig, currentMode)
}
}
@@ -894,7 +893,6 @@ export class Controller {
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
// Spread to create new array reference - React needs this to detect changes in useEffect dependencies
@@ -914,6 +912,7 @@ export class Controller {
const clineConfig = ClineEnv.config()
const environment = clineConfig.environment
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
// Check OpenAI Codex authentication status
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
@@ -976,7 +975,6 @@ export class Controller {
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
favoritedModelIds,
autoCondenseThreshold,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
// NEW: Add workspace information
@@ -1007,6 +1005,7 @@ export class Controller {
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
doubleCheckCompletionEnabled,
banners,
welcomeBanners,
openAiCodexIsAuthenticated,
}
}
@@ -12,21 +12,34 @@ export function getClineOnboardingModels(): OnboardingModelGroup {
}
const remoteOverrides = featureFlagsService.getOnboardingOverrides()
const models = new Map<string, OnboardingModel>(CLINE_ONBOARDING_MODELS.map((model) => [model.id, model]))
const models = [...CLINE_ONBOARDING_MODELS]
// Apply remote overrides if available
if (remoteOverrides) {
for (const [id, override] of Object.entries(remoteOverrides) as [string, OnboardingModelOverride][]) {
if (override.hidden) {
models.delete(id)
for (let i = models.length - 1; i >= 0; i--) {
if (models[i].id === id) {
models.splice(i, 1)
}
}
} else {
const baseModel = models.get(id)
models.set(id, mergeModelWithOverride(baseModel, override))
let found = false
for (let i = 0; i < models.length; i++) {
if (models[i].id === id) {
models[i] = mergeModelWithOverride(models[i], override)
found = true
}
}
if (!found) {
models.push(mergeModelWithOverride(undefined, override))
}
}
}
}
cached = { models: Array.from(models.values()) }
cached = { models }
return cached
}
@@ -12,6 +12,7 @@ import {
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -118,7 +119,7 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
return Number.parseFloat(price) * 1_000_000
}
return undefined
}
@@ -144,6 +145,8 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
}
switch (rawModel.id) {
case "anthropic/claude-sonnet-4.6":
case "anthropic/claude-4.6-sonnet":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
@@ -264,14 +267,28 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
models[rawModel.id] = modelInfo
// add custom :1m model variant for sonnet
if (rawModel.id === "anthropic/claude-sonnet-4" || rawModel.id === "anthropic/claude-sonnet-4.5") {
if (
rawModel.id === "anthropic/claude-sonnet-4" ||
rawModel.id === "anthropic/claude-sonnet-4.5" ||
rawModel.id === "anthropic/claude-4.5-sonnet" ||
rawModel.id === "anthropic/claude-sonnet-4.6" ||
rawModel.id === "anthropic/claude-4.6-sonnet"
) {
const claudeSonnet1mModelInfo = cloneDeep(modelInfo)
claudeSonnet1mModelInfo.contextWindow = 1_000_000 // limiting providers to those that support 1m context window
claudeSonnet1mModelInfo.tiers = CLAUDE_SONNET_1M_TIERS
// sonnet 4
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
if (rawModel.id === "anthropic/claude-sonnet-4") {
models[openRouterClaudeSonnet41mModelId] = claudeSonnet1mModelInfo
}
// sonnet 4.5
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
if (rawModel.id === "anthropic/claude-sonnet-4.5" || rawModel.id === "anthropic/claude-4.5-sonnet") {
models[openRouterClaudeSonnet451mModelId] = claudeSonnet1mModelInfo
}
// sonnet 4.6
if (rawModel.id === "anthropic/claude-sonnet-4.6" || rawModel.id === "anthropic/claude-4.6-sonnet") {
models[openRouterClaudeSonnet461mModelId] = claudeSonnet1mModelInfo
}
}
// add custom :1m model variant for opus 4.6
@@ -68,8 +68,8 @@ function deriveTemperature(modelId: string): number | undefined {
return 0.7
}
// Gemini 3.0 recommends temperature 1.0
if (modelId.startsWith("google/gemini-3.0") || modelId === "google/gemini-3.0") {
// Gemini 3 models recommend temperature 1.0
if (modelId.startsWith("google/gemini-3")) {
return 1.0
}
@@ -1,6 +1,5 @@
import { Empty } from "@shared/proto/cline/common"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { buildApiHandler } from "@/core/api"
import { ApiHandlerOptions, ApiProvider } from "@/shared/api"
import { UpdateApiConfigurationRequestNew } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
@@ -41,7 +40,8 @@ function parseFieldMask(updateMask: string[]): {
function getAlternateModeField(fieldName: string): string | null {
if (fieldName.startsWith("planMode")) {
return fieldName.replace("planMode", "actMode")
} else if (fieldName.startsWith("actMode")) {
}
if (fieldName.startsWith("actMode")) {
return fieldName.replace("actMode", "planMode")
}
return null
@@ -141,14 +141,7 @@ export async function updateApiConfiguration(controller: Controller, request: Up
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
// Build updated config
controller.task.api = buildApiHandler(
{
...controller.stateManager.getApiConfiguration(),
ulid: controller.task.ulid,
},
currentMode,
)
controller.task.updateApiHandler(controller.stateManager.getApiConfiguration(), currentMode)
}
// Post updated state to webview
@@ -1,4 +1,3 @@
import { buildApiHandler } from "@core/api"
import { Empty } from "@shared/proto/cline/common"
import { UpdateApiConfigurationPartialRequest } from "@shared/proto/cline/models"
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
@@ -44,7 +43,7 @@ export async function updateApiConfigurationPartial(
controller.stateManager.setApiConfiguration(updatedConfig)
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
controller.task.api = buildApiHandler({ ...updatedConfig, ulid: controller.task.ulid }, currentMode)
controller.task.updateApiHandler(updatedConfig, currentMode)
}
// Notify webview
@@ -8,7 +8,6 @@ import {
fromProtobufOpenAiCompatibleModelInfo,
} from "@shared/proto-conversions/models/typeConversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { buildApiHandler } from "@/core/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
@@ -124,10 +123,7 @@ export async function updateApiConfigurationProto(
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
controller.task.api = buildApiHandler(
{ ...convertedApiConfigurationFromProto, ulid: controller.task.ulid },
currentMode,
)
controller.task.updateApiHandler(convertedApiConfigurationFromProto, currentMode)
}
// Post updated state to webview
+2 -2
View File
@@ -20,13 +20,13 @@ export async function resetState(controller: Controller, request: ResetStateRequ
type: ShowMessageType.INFORMATION,
message: "Resetting global state...",
})
await resetGlobalState(controller)
await resetGlobalState()
} else {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Resetting workspace state...",
})
await resetWorkspaceState(controller)
await resetWorkspaceState()
}
if (controller.task) {
+3 -13
View File
@@ -1,4 +1,3 @@
import { buildApiHandler } from "@core/api"
import { Empty } from "@shared/proto/cline/common"
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
@@ -49,11 +48,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const apiConfigForHandler = {
...convertedApiConfigurationFromProto,
ulid: controller.task.ulid,
}
controller.task.api = buildApiHandler(apiConfigForHandler, currentMode)
controller.task.updateApiHandler(convertedApiConfigurationFromProto, currentMode)
}
}
@@ -185,7 +180,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
telemetryService.captureAutoCondenseToggle(
controller.task.ulid,
request.useAutoCondense,
controller.task.api.getModel().id,
controller.task.getModel().id,
)
}
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
@@ -301,11 +296,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("backgroundEditEnabled", !!request.backgroundEditEnabled)
}
if (request.autoCondenseThreshold !== undefined) {
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
}
if (request.multiRootEnabled !== undefined) {
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
}
@@ -317,7 +307,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.task.ulid,
"native-tool-call",
request.nativeToolCallEnabled,
controller.task.api.getModel().id,
controller.task.getModel().id,
)
}
}
@@ -1,5 +1,3 @@
import { buildApiHandler } from "@core/api"
import { Empty } from "@shared/proto/cline/common"
import { PlanActMode, UpdateSettingsRequestCli } from "@shared/proto/cline/state"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
@@ -118,11 +116,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const apiConfigForHandler = {
...controller.stateManager.getApiConfiguration(),
ulid: controller.task.ulid,
}
controller.task.api = buildApiHandler(apiConfigForHandler, currentMode)
controller.task.updateApiHandler(controller.stateManager.getApiConfiguration(), currentMode)
}
// Update telemetry setting
@@ -144,7 +138,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
telemetryService.captureAutoCondenseToggle(
controller.task.ulid,
useAutoCondense,
controller.task.api.getModel().id,
controller.task.getModel().id,
)
}
controller.stateManager.setGlobalState("useAutoCondense", useAutoCondense)
@@ -0,0 +1,127 @@
import { describe, it } from "mocha"
import "should"
import { formatResponse } from "../responses"
describe("formatResponse.writeToFileMissingContentError", () => {
describe("first failure (tier 1)", () => {
it("should include the file path in the error message", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("src/index.ts")
})
it("should include the base error explanation", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("'content' parameter was empty")
result.should.containEql("output token limits")
})
it("should include helpful suggestions", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("Suggestions")
result.should.containEql("replace_in_file")
result.should.containEql("skeleton")
})
it("should include the tool use instructions reminder", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("Reminder: Instructions for Tool Use")
})
it("should mention breaking down the task into smaller steps", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("breaking down the task into smaller steps")
})
it("should suggest using replace_in_file for existing files", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.containEql("prefer replace_in_file to make targeted edits")
})
it("should not include CRITICAL language on first failure", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1)
result.should.not.containEql("CRITICAL")
})
it("should work with different file paths", () => {
const result = formatResponse.writeToFileMissingContentError("components/MyComponent.tsx", 1)
result.should.containEql("components/MyComponent.tsx")
})
})
describe("second failure (tier 2)", () => {
it("should indicate this is the 2nd failed attempt", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.containEql("2nd failed attempt")
})
it("should strongly suggest alternative approaches", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.containEql("must use a different strategy")
result.should.containEql("Recommended approaches")
})
it("should tell model not to retry full write again", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.containEql("Do NOT attempt to write the full file content")
})
it("should not include CRITICAL language on second failure", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 2)
result.should.not.containEql("CRITICAL")
})
})
describe("third+ failure (tier 3)", () => {
it("should include CRITICAL language on third failure", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
result.should.containEql("CRITICAL")
result.should.containEql("3 times in a row")
})
it("should tell model to NOT retry write_to_file", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
result.should.containEql("do NOT retry write_to_file")
})
it("should include required action strategies", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3)
result.should.containEql("Required action")
result.should.containEql("replace_in_file")
result.should.containEql("50-100 lines")
})
it("should show correct count for higher failure counts", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 5)
result.should.containEql("5 times in a row")
})
})
describe("context window awareness", () => {
it("should include context warning when usage exceeds 50%", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 60)
result.should.containEql("60% full")
result.should.containEql("MUST use a strategy that produces smaller outputs")
})
it("should not include context warning when usage is 50% or below", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 50)
result.should.not.containEql("% full")
})
it("should not include context warning when percent is undefined", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, undefined)
result.should.not.containEql("% full")
})
it("should show high context window usage correctly", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 1, 85)
result.should.containEql("85% full")
})
it("should include context warning in tier 3 messages", () => {
const result = formatResponse.writeToFileMissingContentError("src/index.ts", 3, 75)
result.should.containEql("75% full")
result.should.containEql("CRITICAL")
})
})
})
+50
View File
@@ -4,6 +4,8 @@ import * as path from "path"
import { Mode } from "@/shared/storage/types"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
const CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT = 50
export const formatResponse = {
duplicateFileReadNotice: () =>
`[[NOTE] This file read has been removed to save space in the context window. Refer to the latest file read for the most up to date version of this file.]`,
@@ -46,6 +48,54 @@ Otherwise, if you have not completed the task and do not need additional informa
missingToolParameterError: (paramName: string) =>
`Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`,
/**
* Specialized error for write_to_file when the 'content' parameter is missing.
* Provides progressive guidance based on how many times this has happened consecutively,
* and includes token budget awareness to help the model understand output constraints.
*/
writeToFileMissingContentError: (relPath: string, consecutiveFailures: number, contextUsagePercent?: number): string => {
const baseError = `Failed to write to '${relPath}': The 'content' parameter was empty. This typically happens when the file content is too large to generate in a single response, or when output token limits are reached before the content parameter is fully written.`
const contextWarning =
contextUsagePercent !== undefined && contextUsagePercent > CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT
? `\n\nWarning: Context window is ${contextUsagePercent}% full. The remaining output budget may be insufficient for large file writes. You MUST use a strategy that produces smaller outputs.`
: ""
if (consecutiveFailures >= 3) {
// After 3+ failures, be very directive — stop trying write_to_file entirely
return (
`${baseError}${contextWarning}\n\n` +
`CRITICAL: You have failed to write this file ${consecutiveFailures} times in a row. You MUST change your approach — do NOT retry write_to_file for this file again.\n\n` +
`Required action — choose ONE of these strategies:\n` +
`1. **Create an empty file first, then use replace_in_file** to add content in small sections (recommended)\n` +
`2. **Break the file into multiple smaller files** if architecturally appropriate\n` +
`3. **Write a minimal skeleton** using write_to_file (just imports, class/function signatures, no implementations), then use replace_in_file to fill in each section one at a time\n\n` +
`Each replace_in_file call should add no more than 50-100 lines of content at a time.`
)
}
if (consecutiveFailures >= 2) {
// After 2 failures, strongly suggest alternative approaches
return (
`${baseError}${contextWarning}\n\n` +
`This is your ${consecutiveFailures}${consecutiveFailures === 2 ? "nd" : "rd"} failed attempt. The file content is likely too large to generate in one response. You must use a different strategy:\n\n` +
`Recommended approaches:\n` +
`1. **Use write_to_file with a minimal skeleton** (just the structure — imports, class/function signatures, no implementations), then use replace_in_file to fill in each section incrementally\n` +
`2. **Use replace_in_file with smaller chunks** — if the file already exists, make targeted edits instead of rewriting the entire file\n` +
`3. **Break the task into smaller steps** — write one function or section at a time\n\n` +
`Do NOT attempt to write the full file content in a single write_to_file call again.`
)
}
// First failure — provide helpful guidance
return (
`${baseError}${contextWarning}\n\n` +
`Suggestions:\n` +
`- If the file is large, try breaking down the task into smaller steps. Write a skeleton first, then fill in sections using replace_in_file.\n` +
`- If the file already exists, prefer replace_in_file to make targeted edits instead of rewriting the entire file.\n` +
`- Ensure the 'content' parameter contains the complete file content before closing the tool tag.\n\n` +
toolUseInstructionsReminder
)
},
invalidMcpToolArgumentError: (serverName: string, toolName: string) =>
`Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`,
+104 -89
View File
@@ -1,25 +1,24 @@
import { ApiConfiguration, ModelInfo } from "@shared/api"
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import {
ApiHandlerSettingsKeys,
GlobalState,
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
type GlobalState,
type GlobalStateAndSettings,
type GlobalStateAndSettingsKey,
isSecretKey,
isSettingsKey,
LocalState,
LocalStateKey,
RemoteConfigFields,
SecretKey,
type LocalState,
type LocalStateKey,
type RemoteConfigFields,
type SecretKey,
SecretKeys,
Secrets,
Settings,
SettingsKey,
type Secrets,
type Settings,
type SettingsKey,
} from "@shared/storage/state-keys"
import type { StorageContext } from "@shared/storage/storage-context"
import chokidar, { FSWatcher } from "chokidar"
import type { ExtensionContext } from "vscode"
import { initializeDistinctId } from "@/services/logging/distinctId"
import { Logger } from "@/shared/services/Logger"
import { secretStorage } from "@/shared/storage/ClineSecretStorage"
import {
getTaskHistoryStateFilePath,
readTaskHistoryFromState,
@@ -29,7 +28,7 @@ import {
} from "./disk"
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
import { filterAllowedRemoteConfigFields } from "./remote-config/utils"
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
import { readGlobalStateFromStorage, readSecretsFromStorage, readWorkspaceStateFromStorage } from "./utils/state-helpers"
export interface PersistenceErrorEvent {
error: Error
}
@@ -38,6 +37,9 @@ export interface PersistenceErrorEvent {
* In-memory state manager for fast state access.
* Provides immediate reads/writes with async disk persistence.
*
* All persistent storage is backed by file-based stores via StorageContext.
* This is shared across all platforms (VSCode, CLI, JetBrains).
*
* MULTI-INSTANCE BEHAVIOR:
* StateManager reads from disk ONLY during initialize(). After that, all reads come from
* the in-memory cache. Writes update both the cache and disk, but other running instances
@@ -57,10 +59,16 @@ export class StateManager {
private globalStateCache: GlobalStateAndSettings = {} as GlobalStateAndSettings
private taskStateCache: Partial<Settings> = {}
private sessionOverrideCache: Partial<Settings> = {}
private remoteConfigCache: Partial<RemoteConfigFields> = {} as RemoteConfigFields
private secretsCache: Secrets = {} as Secrets
private workspaceStateCache: LocalState = {} as LocalState
private context: ExtensionContext
/**
* File-backed storage context. All reads/writes to persistent state go through here.
* Do NOT access VSCode's ExtensionContext for storage use this instead.
*/
private storage: StorageContext
private isInitialized = false
// Cache TTL: 1 hour - long enough to prevent duplicate fetches, short enough to see new models
@@ -107,17 +115,16 @@ export class StateManager {
// Callback to sync external state changes with the UI client
onSyncExternalChange?: () => void | Promise<void>
private constructor(context: ExtensionContext) {
this.context = context
secretStorage.init(context.secrets)
private constructor(storage: StorageContext) {
this.storage = storage
}
/**
* Initialize the cache by loading data from disk
* Initialize the cache by loading data from the file-backed StorageContext.
*/
public static async initialize(context: ExtensionContext): Promise<StateManager> {
public static async initialize(storage: StorageContext): Promise<StateManager> {
if (!StateManager.instance) {
StateManager.instance = new StateManager(context)
StateManager.instance = new StateManager(storage)
}
if (StateManager.instance.isInitialized) {
@@ -125,11 +132,12 @@ export class StateManager {
}
try {
await initializeDistinctId(context)
// Load all extension state from disk
const globalState = await readGlobalStateFromDisk(context)
const secrets = await readSecretsFromDisk()
const workspaceState = await readWorkspaceStateFromDisk(context)
await initializeDistinctId(storage)
// Load all extension state from file-backed stores
const globalState = await readGlobalStateFromStorage(storage.globalState)
const secrets = readSecretsFromStorage(storage.secrets)
const workspaceState = readWorkspaceStateFromStorage(storage.workspaceState)
// Populate the cache with all extension state and secrets fields
// Use populate method to avoid triggering persistence during initialization
@@ -291,8 +299,6 @@ export class StateManager {
this.pendingTaskState.clear()
} catch (error) {
Logger.error("[StateManager] Failed to persist task settings before clearing:", error)
// If persistence fails, we just move on with clearing the in-memory state.
// clearTaskSettings realistically probably won't be called in the small window of time between task settings being set and their persistence anyways
}
}
@@ -374,6 +380,21 @@ export class StateManager {
this.scheduleDebouncedPersistence()
}
/**
* Set a session-scoped override for a settings key.
* Session overrides are in-memory only and are NEVER persisted to disk.
* They take precedence after remote config but before task-specific and global settings.
*
* Use this for CLI flags like --yolo that should apply for the current
* process lifetime only, without modifying the user's saved settings.
*/
setSessionOverride<K extends keyof Settings>(key: K, value: Settings[K]): void {
if (!this.isInitialized) {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
this.sessionOverrideCache[key] = value
}
/**
* Set method for remote config field - updates cache immediately (no persistence)
* Remote config is read-only from the extension's perspective and only stored in memory
@@ -599,17 +620,18 @@ export class StateManager {
/**
* Get method for global settings keys - reads from in-memory cache
* Precedence: remote config > task settings > global settings
* Precedence: remote config > session override > task settings > global settings
*/
getGlobalSettingsKey<K extends keyof Settings>(key: K): Settings[K] {
if (!this.isInitialized) {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
if (this.remoteConfigCache[key] !== undefined) {
// type casting here, TS cannot infer that the key will ONLY be one of Settings
return this.remoteConfigCache[key] as Settings[K]
}
if (this.sessionOverrideCache[key] !== undefined) {
return this.sessionOverrideCache[key] as Settings[K]
}
if (this.taskStateCache[key] !== undefined) {
return this.taskStateCache[key]
}
@@ -624,7 +646,6 @@ export class StateManager {
throw new Error(STATE_MANAGER_NOT_INITIALIZED)
}
if (this.remoteConfigCache[key] !== undefined) {
// type casting here, TS cannot infer that the key will ONLY be one of GlobalState
return this.remoteConfigCache[key] as GlobalState[K]
}
return this.globalStateCache[key]
@@ -655,11 +676,14 @@ export class StateManager {
* Used for error recovery when write operations fail
*/
async reInitialize(currentTaskId?: string): Promise<void> {
if (this.persistenceTimeout) {
await this.persistPendingState()
}
// Clear all cached data and pending state
this.dispose()
// Reinitialize from disk
await StateManager.initialize(this.context)
// Reinitialize from the same storage context
await StateManager.initialize(this.storage)
// If there's an active task, reload its settings
if (currentTaskId) {
@@ -691,6 +715,7 @@ export class StateManager {
this.workspaceStateCache = {} as LocalState
this.taskStateCache = {}
this.remoteConfigCache = {} as GlobalStateAndSettings
this.sessionOverrideCache = {}
this.isInitialized = false
}
@@ -765,21 +790,25 @@ export class StateManager {
}
/**
* Private method to batch persist global state keys with Promise.all
* Persist global state keys to the file-backed store.
* Uses setBatch for efficiency (single disk write).
*/
private async persistGlobalStateBatch(keys: Set<GlobalStateAndSettingsKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
if (key === "taskHistory") {
// Route task history persistence to file, not VS Code globalState
return writeTaskHistoryToState(this.globalStateCache[key])
}
return this.context.globalState.update(key, this.globalStateCache[key])
}),
)
} catch (error) {
throw error
// Separate taskHistory (goes to its own file) from regular global state
const regularEntries: Record<string, any> = {}
for (const key of keys) {
if (key === "taskHistory") {
// Route task history persistence to its own file
await writeTaskHistoryToState(this.globalStateCache[key])
} else {
regularEntries[key] = this.globalStateCache[key]
}
}
// Batch write all regular keys in a single disk operation
if (Object.keys(regularEntries).length > 0) {
this.storage.globalStateBackingStore.setBatch(regularEntries)
}
}
@@ -790,61 +819,47 @@ export class StateManager {
if (pendingTaskStates.size === 0) {
return
}
try {
// Persist each task's settings
await Promise.all(
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
if (keys.size === 0) {
return Promise.resolve()
// Persist each task's settings
await Promise.all(
Array.from(pendingTaskStates.entries()).map(([taskId, keys]) => {
if (keys.size === 0) {
return Promise.resolve()
}
const settingsToWrite: Record<string, any> = {}
for (const key of keys) {
const value = this.taskStateCache[key]
if (value !== undefined) {
settingsToWrite[key] = value
}
const settingsToWrite: Record<string, any> = {}
for (const key of keys) {
const value = this.taskStateCache[key]
if (value !== undefined) {
settingsToWrite[key] = value
}
}
return writeTaskSettingsToStorage(taskId, settingsToWrite)
}),
)
} catch (error) {
throw error
}
}
return writeTaskSettingsToStorage(taskId, settingsToWrite)
}),
)
}
/**
* Private method to batch persist secrets with Promise.all
* Persist secrets to the file-backed store.
* Uses setBatch for efficiency (single disk write).
*/
private async persistSecretsBatch(keys: Set<SecretKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.secretsCache[key]
if (value) {
return this.context.secrets.store(key, value)
}
return this.context.secrets.delete(key)
}),
)
} catch (error) {
throw error
const entries: Record<string, string | undefined> = {}
for (const key of keys) {
const value = this.secretsCache[key]
entries[key] = value || undefined // Convert empty strings to undefined (delete)
}
this.storage.secrets.setBatch(entries)
}
/**
* Private method to batch persist workspace state keys with Promise.all
* Persist workspace state to the file-backed store.
* Uses setBatch for efficiency (single disk write).
*/
private async persistWorkspaceStateBatch(keys: Set<LocalStateKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.workspaceStateCache[key]
return this.context.workspaceState.update(key, value)
}),
)
} catch (error) {
throw error
const entries: Record<string, any> = {}
for (const key of keys) {
entries[key] = this.workspaceStateCache[key]
}
this.storage.workspaceState.setBatch(entries)
}
/**
+7 -8
View File
@@ -261,14 +261,13 @@ export async function getSavedClineMessages(taskId: string): Promise<ClineMessag
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
} else {
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
}
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
return []
}
@@ -4,6 +4,8 @@ import * as fs from "fs/promises"
import type { McpHub } from "@/services/mcp/McpHub"
import { Logger } from "@/shared/services/Logger"
const REMOTE_CONFIG_WATCHER_GRACE_MS = 300
/**
* Synchronizes remote MCP servers from remote config to the local MCP settings file
* This allows admins to centrally configure MCP servers that are automatically deployed to users
@@ -33,6 +35,7 @@ export async function syncRemoteMcpServersToSettings(
// Read current settings
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const initialSerializedConfig = JSON.stringify(config)
// Ensure mcpServers object exists
if (!config.mcpServers || typeof config.mcpServers !== "object") {
@@ -81,9 +84,18 @@ export async function syncRemoteMcpServersToSettings(
}
try {
const nextSerializedConfig = JSON.stringify(config)
if (initialSerializedConfig === nextSerializedConfig) {
return
}
// Write back to file
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
} finally {
// Keep the flag set briefly to outlive chokidar's awaitWriteFinish delay.
// Otherwise the watcher may process our own remote-config write and reconnect servers.
await new Promise((resolve) => setTimeout(resolve, REMOTE_CONFIG_WATCHER_GRACE_MS))
// Always clear flag, even if write fails
if (mcpHub) {
mcpHub.setIsUpdatingFromRemoteConfig(false)
+2 -3
View File
@@ -4,7 +4,6 @@ import * as vscode from "vscode"
import { HistoryItem } from "@/shared/HistoryItem"
import { Logger } from "@/shared/services/Logger"
import { ensureRulesDirectoryExists, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
import { StateManager } from "./StateManager"
export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionContext) {
// Keys to migrate from workspace storage back to global storage
@@ -665,12 +664,12 @@ export async function cleanupMcpMarketplaceCatalogFromGlobalState(context: vscod
}
}
export function cleanupOldApiKey() {
export async function cleanupOldApiKey(context: vscode.ExtensionContext) {
try {
// Old API Keys were introduced in March 2025 and later replaced with tokens
// Now that we have new API keys that are prefixed with `sk_`,
// we need to clean up the old ones to free the secret storage
StateManager.get().setSecret("clineApiKey", undefined)
await context.secrets.delete("clineApiKey")
} catch (error) {
Logger.error("Failed to cleanup old clineApiKey", error)
}
+34 -43
View File
@@ -1,4 +1,5 @@
import { ApiProvider } from "@shared/api"
import type { ClineFileStorage } from "@shared/storage/ClineFileStorage"
import {
applyTransform,
GlobalStateAndSettingKeys,
@@ -11,88 +12,81 @@ import {
SecretKeys,
Secrets,
} from "@shared/storage/state-keys"
import { ExtensionContext } from "vscode"
import { Controller } from "@/core/controller"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { Logger } from "@/shared/services/Logger"
import { secretStorage } from "@/shared/storage"
import { ClineMemento } from "@/shared/storage"
import { readTaskHistoryFromState } from "../disk"
import { StateManager } from "../StateManager"
export async function readSecretsFromDisk(): Promise<Secrets> {
const secrets = await Promise.all(SecretKeys.map((key) => secretStorage.get(key)))
// ─── File-backed storage readers (used by StateManager) ────────────────────
return SecretKeys.reduce((acc, key, index) => {
acc[key] = secrets[index]
/**
* Read secrets from a ClineFileStorage instance.
*/
export function readSecretsFromStorage(store: ClineFileStorage<string>): Secrets {
return SecretKeys.reduce((acc, key) => {
acc[key] = store.get(key)
return acc
}, {} as Secrets)
}
export async function readWorkspaceStateFromDisk(context: ExtensionContext): Promise<LocalState> {
const states = LocalStateKeys.map((key) => context.workspaceState.get<ClineRulesToggles | undefined>(key))
return LocalStateKeys.reduce((acc, key, index) => {
acc[key] = states[index] || {}
/**
* Read workspace state from a ClineFileStorage instance.
*/
export function readWorkspaceStateFromStorage(store: ClineFileStorage): LocalState {
return LocalStateKeys.reduce((acc, key) => {
acc[key] = store.get(key) || {}
return acc
}, {} as LocalState)
}
export async function readGlobalStateFromDisk(context: ExtensionContext): Promise<GlobalStateAndSettings> {
/**
* Read global state from a ClineFileStorage instance.
*/
export async function readGlobalStateFromStorage(store: ClineMemento): Promise<GlobalStateAndSettings> {
try {
// Batch read all state values in a single optimized pass
const stateValues = new Map<string, any>()
// Read all values at once for better performance
for (const key of GlobalStateAndSettingKeys) {
const value = context.globalState.get(key as string)
const value = store.get(key as string)
stateValues.set(key, value)
}
// Build result object with proper typing
const result = {} as any // Use any for assignment, but return proper type
const result = {} as any
// Process each state property using optimized approach
for (const key of GlobalStateAndSettingKeys) {
const stateKey = key as keyof GlobalStateAndSettings
let value = stateValues.get(stateKey)
// Skip async properties - they need special handling
if (isAsyncProperty(stateKey)) {
continue
}
// Skip computed properties - they need special handling
if (isComputedProperty(stateKey)) {
continue
}
// Apply default value if needed
if (value === undefined) {
const defaultValue = getDefaultValue(stateKey)
if (defaultValue !== undefined) {
value = defaultValue
}
}
// Apply transformation if provided
if (value !== undefined) {
value = applyTransform(stateKey, value)
}
// Set the processed value
result[stateKey] = value
}
// Handle computed properties with special logic
await handleComputedProperties(result, stateValues)
// Handle async properties
await handleAsyncProperties(result)
return result as GlobalStateAndSettings
} catch (error) {
Logger.error("[StateHelpers] Failed to read global state:", error)
Logger.error("[StateHelpers] Failed to read global state from storage:", error)
throw error
}
}
// ─── Legacy readers (for VSCode migration — reads from ExtensionContext) ────
/**
* Handle properties that require computed logic
*/
@@ -120,19 +114,16 @@ async function handleAsyncProperties(result: any): Promise<void> {
result.taskHistory = await readTaskHistoryFromState()
}
export async function resetWorkspaceState(controller: Controller) {
await Promise.all(LocalStateKeys.map((key) => controller.context.workspaceState.update(key, undefined)))
await controller.stateManager.reInitialize()
export async function resetWorkspaceState() {
const stateManager = StateManager.get()
LocalStateKeys.map((key) => stateManager.setWorkspaceState(key, {}))
await stateManager.reInitialize()
}
export async function resetGlobalState(controller: Controller) {
export async function resetGlobalState() {
// TODO: Reset all workspace states?
const context = controller.context
await Promise.all(GlobalStateAndSettingKeys.map((key) => context.globalState.update(key, undefined)))
await Promise.all(SecretKeys.map((key) => secretStorage.delete(key)))
await controller.stateManager.reInitialize()
const stateManager = StateManager.get()
GlobalStateAndSettingKeys.map((key) => stateManager.setGlobalState(key, undefined))
SecretKeys.map((key) => stateManager.setSecret(key, undefined))
await stateManager.reInitialize()
}
+546
View File
@@ -0,0 +1,546 @@
import { Agent, type AgentEvent, type AgentHooks, getClineDefaultSystemPrompt, type Tool } from "@cline/agents"
import { createBuiltinTools } from "@cline/core"
import { providers } from "@cline/llms"
import type { Hooks as HookInputs } from "@core/hooks/hook-factory"
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import { formatContentBlockToMarkdown } from "@integrations/misc/export-markdown"
import { telemetryService } from "@services/telemetry"
import type { ApiConfiguration } from "@shared/api"
import type { ClineSay } from "@shared/ExtensionMessage"
import { buildClineExtraHeaders } from "@/services/EnvUtils"
import { type ClineContent, ClineMessageModelInfo } from "@/shared/messages"
import { Logger } from "@/shared/services/Logger"
import type { ApiProviderInfo } from "../api"
import { ensureVsCodeLmProviderRegistered, VSCODE_LM_SELECTOR_HEADER } from "../api/providers/registry"
import type { MessageStateHandler } from "./message-state"
import type { CreateTaskAgentHooksOptions } from "./TaskHookExtensionAdapter"
import type { HookExecution } from "./types/HookExecution"
type ProviderConnectionConfig = {
apiKey?: string
baseUrl?: string
headers?: Record<string, string>
knownModels?: Record<string, providers.ModelInfo>
}
type RegisteredModelInfo = providers.ModelInfo
function toRegisteredModelInfo(modelId: string, modelInfo: ApiProviderInfo["model"]["info"]): RegisteredModelInfo {
const capabilities: RegisteredModelInfo["capabilities"] = []
if (modelInfo.supportsImages) {
capabilities.push("images")
}
if (modelInfo.supportsPromptCache) {
capabilities.push("prompt-cache")
}
if (modelInfo.supportsReasoning) {
capabilities.push("reasoning")
}
if (modelInfo.supportsGlobalEndpoint) {
capabilities.push("global-endpoint")
}
return {
id: modelId,
name: modelInfo.name,
description: modelInfo.description,
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
temperature: modelInfo.temperature,
capabilities: capabilities.length > 0 ? capabilities : undefined,
pricing:
modelInfo.inputPrice !== undefined ||
modelInfo.outputPrice !== undefined ||
modelInfo.cacheWritesPrice !== undefined ||
modelInfo.cacheReadsPrice !== undefined
? {
input: modelInfo.inputPrice,
output: modelInfo.outputPrice,
cacheWrite: modelInfo.cacheWritesPrice,
cacheRead: modelInfo.cacheReadsPrice,
}
: undefined,
thinkingConfig: modelInfo.thinkingConfig
? {
maxBudget: modelInfo.thinkingConfig.maxBudget,
outputPrice: modelInfo.thinkingConfig.outputPrice,
thinkingLevel: modelInfo.thinkingConfig.geminiThinkingLevel,
}
: undefined,
}
}
type AgentRuntimeHost = {
taskId: string
ulid: string
cwd: string
taskState: {
apiRequestCount: number
apiRequestsSinceLastTodoUpdate: number
abort: boolean
}
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
postStateToWebview: () => Promise<void>
getCurrentProviderInfo: () => ApiProviderInfo
getApiConfiguration: () => ApiConfiguration
loadContext: (
userContent: ClineContent[],
includeFileDetails: boolean,
useCompactPrompt: boolean,
) => Promise<[ClineContent[], string, boolean]>
messageStateHandler: MessageStateHandler
setActiveHookExecution: (hookExecution: HookExecution) => Promise<void>
clearActiveHookExecution: () => Promise<void>
runTaskLifecycleHook: <K extends "TaskStart" | "TaskResume">(params: {
hookName: K
hookInput: HookInputs[K]
}) => Promise<{ cancel?: boolean; contextModification?: string; errorMessage?: string; wasCancelled: boolean }>
runUserPromptSubmitHook: (
userContent: ClineContent[],
) => Promise<{ cancel?: boolean; contextModification?: string; errorMessage?: string; wasCancelled: boolean }>
handleHookCancellation: (hookName: string, wasCancelled: boolean) => Promise<void>
createAgentHooks: (options: CreateTaskAgentHooksOptions) => AgentHooks
cancelTask: () => Promise<void>
}
export type TaskAgentRunContext = {
phase: "initial_task" | "resume" | "continue"
initialTask?: string
resumePreviousState?: {
lastMessageTs: string
messageCount: string
conversationHistoryDeleted: string
}
userPromptHookContent?: ClineContent[]
}
export class TaskAgentRuntime {
private agent: InstanceType<typeof Agent> | undefined
private agentSignature: string | undefined
private readonly builtInTools: Tool[]
private readonly host: AgentRuntimeHost
private pendingRunContext: TaskAgentRunContext | undefined
private streamedReasoning = ""
constructor(host: AgentRuntimeHost) {
this.host = host
this.builtInTools = createBuiltinTools({ cwd: host.cwd })
}
async run(userContent: ClineContent[], runContext: TaskAgentRunContext): Promise<void> {
this.host.taskState.apiRequestCount++
this.host.taskState.apiRequestsSinceLastTodoUpdate++
this.streamedReasoning = ""
// const [parsedUserContent, environmentDetails] = await this.host.loadContext(userContent, includeFileDetails, false)
// const normalizedUserContent = [...parsedUserContent]
// if (environmentDetails) {
// normalizedUserContent.push({ type: "text", text: environmentDetails })
// }
const normalizedUserContent = [...userContent]
const userMessage = normalizedUserContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n")
await this.host.say("api_req_started", JSON.stringify({ request: userMessage }))
const providerInfo = this.host.getCurrentProviderInfo()
const modelInfo: ClineMessageModelInfo = {
modelId: providerInfo.model.id,
providerId: providerInfo.providerId,
mode: providerInfo.mode,
}
await this.host.messageStateHandler.addToApiConversationHistory({
role: "user",
content: normalizedUserContent,
ts: Date.now(),
})
const customHeader = await buildClineExtraHeaders()
this.pendingRunContext = runContext
const { agent, reusedExistingAgent } = this.getOrCreateAgent(providerInfo, customHeader)
const result = await (async () => {
try {
return reusedExistingAgent ? await agent.continue(userMessage) : await agent.run(userMessage)
} finally {
this.pendingRunContext = undefined
}
})()
this.agent = agent
if (result.text) {
await this.host.say("text", result.text, undefined, undefined, false)
}
await this.host.messageStateHandler.addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: result.text || "" }],
modelInfo,
metrics: {
tokens: {
prompt: result.usage.inputTokens || 0,
completion: result.usage.outputTokens || 0,
cached: (result.usage.cacheWriteTokens || 0) + (result.usage.cacheReadTokens || 0),
},
cost: result.usage.totalCost,
},
ts: Date.now(),
})
telemetryService.captureConversationTurnEvent(
this.host.ulid,
modelInfo.providerId,
modelInfo.modelId,
"assistant",
modelInfo.mode,
{
tokensIn: result.usage.inputTokens || 0,
tokensOut: result.usage.outputTokens || 0,
cacheWriteTokens: result.usage.cacheWriteTokens || 0,
cacheReadTokens: result.usage.cacheReadTokens || 0,
totalCost: result.usage.totalCost,
},
)
await this.host.postStateToWebview()
}
public abort(): void {
this.agent?.abort()
}
private getOrCreateAgent(
providerInfo: ReturnType<AgentRuntimeHost["getCurrentProviderInfo"]>,
customHeader: Record<string, string>,
): {
agent: InstanceType<typeof Agent>
reusedExistingAgent: boolean
} {
const apiConfig = this.host.getApiConfiguration()
if (providerInfo.providerId === "vscode-lm") {
ensureVsCodeLmProviderRegistered()
}
const providerConfig = this.resolveProviderConfig(providerInfo, apiConfig)
const nextSignature = JSON.stringify({
providerId: providerInfo.providerId,
modelId: providerInfo.model.id,
apiKey: providerConfig.apiKey ?? "",
baseUrl: providerConfig.baseUrl ?? "",
headers: providerConfig.headers ?? {},
knownModels: providerConfig.knownModels ?? {},
})
if (this.agent && this.agentSignature === nextSignature) {
return { agent: this.agent, reusedExistingAgent: true }
}
const defaultHeaders: Record<string, string> = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-Task-ID": this.host.ulid || "",
}
Object.assign(defaultHeaders, customHeader)
const hooks = this.createAgentHooks()
const agent = new Agent({
providerId: providerInfo.providerId,
modelId: providerInfo.model.id,
apiKey: providerConfig.apiKey,
baseUrl: providerConfig.baseUrl,
headers: providerConfig.headers,
knownModels: providerConfig.knownModels,
systemPrompt: getClineDefaultSystemPrompt("VS Code", this.host.cwd),
tools: this.builtInTools,
hooks,
maxIterations: 50,
onEvent: async (event: AgentEvent) => {
switch (event.type) {
case "content_start":
if (event.contentType === "text") {
const nextText = event.accumulated ?? event.text
if (nextText !== undefined) {
await this.host.say("text", nextText, undefined, undefined, true)
}
}
if (event.contentType === "reasoning" && event.reasoning !== undefined) {
this.streamedReasoning += event.reasoning
await this.host.say("reasoning", this.streamedReasoning, undefined, undefined, true)
}
if (event.contentType === "tool" && event.toolName) {
Logger.debug(`[Task ${this.host.taskId}] Agent tool call start: ${event.toolName}`)
}
break
case "content_end":
if (event.contentType === "reasoning") {
const finalReasoning = event.reasoning ?? this.streamedReasoning
if (finalReasoning) {
await this.host.say("reasoning", finalReasoning, undefined, undefined, false)
}
}
break
case "error":
Logger.error(`[Task ${this.host.taskId}] Agent runtime error`, event.error)
break
}
},
})
this.agent = agent
this.agentSignature = nextSignature
return { agent, reusedExistingAgent: false }
}
private createAgentHooks(): AgentHooks {
return this.host.createAgentHooks({
onRunStart: () => this.handleRunStartHook(),
buildPendingToolInfo: (toolName, toolInput) => this.buildPendingToolInfo(toolName, toolInput),
toHookStringParameters: (input) => this.toHookStringParameters(input),
createHookContextBlock: (hookName, contextModification) => this.createHookContextBlock(hookName, contextModification),
safeJsonStringify: (value) => this.safeJsonStringify(value),
})
}
private async handleRunStartHook(): Promise<{ cancel?: boolean; context?: string } | undefined> {
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return undefined
}
const runContext = this.pendingRunContext
if (!runContext) {
return undefined
}
const contextBlocks: string[] = []
if (runContext.phase === "initial_task") {
const taskStartResult = await this.host.runTaskLifecycleHook({
hookName: "TaskStart",
hookInput: {
taskStart: {
taskMetadata: {
taskId: this.host.taskId,
ulid: this.host.ulid,
initialTask: runContext.initialTask || "",
},
},
},
})
if (taskStartResult.cancel) {
await this.host.handleHookCancellation("TaskStart", taskStartResult.wasCancelled)
await this.host.cancelTask()
return { cancel: true }
}
const taskStartContext = this.createHookContextBlock("TaskStart", taskStartResult.contextModification)
if (taskStartContext) {
contextBlocks.push(taskStartContext)
}
}
if (runContext.phase === "resume") {
const taskResumeResult = await this.host.runTaskLifecycleHook({
hookName: "TaskResume",
hookInput: {
taskResume: {
taskMetadata: {
taskId: this.host.taskId,
ulid: this.host.ulid,
},
previousState: runContext.resumePreviousState || {
lastMessageTs: "",
messageCount: "0",
conversationHistoryDeleted: "false",
},
},
},
})
if (taskResumeResult.cancel) {
await this.host.handleHookCancellation("TaskResume", taskResumeResult.wasCancelled)
await this.host.cancelTask()
return { cancel: true }
}
const taskResumeContext = this.createHookContextBlock("TaskResume", taskResumeResult.contextModification)
if (taskResumeContext) {
contextBlocks.push(taskResumeContext)
}
}
const userPromptHookContent = runContext.userPromptHookContent ?? []
const userPromptResult = await this.host.runUserPromptSubmitHook(userPromptHookContent)
if (userPromptResult.cancel) {
await this.host.handleHookCancellation("UserPromptSubmit", userPromptResult.wasCancelled)
await this.host.cancelTask()
return { cancel: true }
}
const userPromptContext = this.createHookContextBlock("UserPromptSubmit", userPromptResult.contextModification)
if (userPromptContext) {
contextBlocks.push(userPromptContext)
}
if (contextBlocks.length > 0) {
return { context: contextBlocks.join("\n") }
}
return undefined
}
private createHookContextBlock(source: string, contextModification?: string): string | undefined {
if (!contextModification) {
return
}
const contextText = contextModification.trim()
if (!contextText) {
return
}
const lines = contextText.split("\n")
const firstLine = lines[0]
let contextType = "general"
let content = contextText
const typeMatch = /^([A-Z_]+):\s*(.*)/.exec(firstLine)
if (typeMatch) {
contextType = typeMatch[1].toLowerCase()
const remainingLines = lines.slice(1).filter((line) => line.trim())
content = typeMatch[2] ? [typeMatch[2], ...remainingLines].join("\n") : remainingLines.join("\n")
}
if (contextType === "general") {
return `<hook_context source="${source}">\n${content}\n</hook_context>`
}
return `<hook_context source="${source}" type="${contextType}">\n${content}\n</hook_context>`
}
private toHookParameters(input: unknown): Record<string, unknown> {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return {}
}
return { ...(input as Record<string, unknown>) }
}
private toHookStringParameters(input: unknown): Record<string, string> {
const params = this.toHookParameters(input)
const entries = Object.entries(params).map(([key, value]) => {
if (typeof value === "string") {
return [key, value] as const
}
return [key, this.safeJsonStringify(value)] as const
})
return Object.fromEntries(entries)
}
private buildPendingToolInfo(toolName: string, input: unknown): Record<string, unknown> {
const pendingToolInfo: Record<string, unknown> = { tool: toolName }
const params = this.toHookParameters(input)
for (const key of ["path", "command", "regex", "url", "tool_name", "server_name", "uri"]) {
if (params[key] !== undefined) {
pendingToolInfo[key] = params[key]
}
}
if (typeof params.content === "string") {
pendingToolInfo.content = params.content.slice(0, 200)
}
if (typeof params.diff === "string") {
pendingToolInfo.diff = params.diff.slice(0, 200)
}
return pendingToolInfo
}
private safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
private resolveProviderConfig(providerInfo: ApiProviderInfo, apiConfig: ApiConfiguration): ProviderConnectionConfig {
const { providerId, mode, model } = providerInfo
switch (providerId) {
case "anthropic":
return { apiKey: apiConfig.apiKey, baseUrl: apiConfig.anthropicBaseUrl }
case "openrouter":
return { apiKey: apiConfig.openRouterApiKey }
case "cline":
return {
apiKey: apiConfig.clineAccountId,
knownModels:
model?.id && model?.info ? { [model.id]: toRegisteredModelInfo(model.id, model.info) } : undefined,
}
case "openai":
return {
apiKey: apiConfig.openAiApiKey,
baseUrl: apiConfig.openAiBaseUrl,
headers: apiConfig.openAiHeaders,
}
case "gemini":
return { apiKey: apiConfig.geminiApiKey, baseUrl: apiConfig.geminiBaseUrl }
case "vertex":
return { apiKey: apiConfig.geminiApiKey, baseUrl: apiConfig.geminiBaseUrl }
case "ollama":
return { apiKey: apiConfig.ollamaApiKey, baseUrl: apiConfig.ollamaBaseUrl }
case "lmstudio":
return { baseUrl: apiConfig.lmStudioBaseUrl }
case "vscode-lm": {
const selector =
mode === "plan" ? apiConfig.planModeVsCodeLmModelSelector : apiConfig.actModeVsCodeLmModelSelector
return {
headers: selector ? { [VSCODE_LM_SELECTOR_HEADER]: JSON.stringify(selector) } : undefined,
}
}
case "deepseek":
return { apiKey: apiConfig.deepSeekApiKey }
case "together":
return { apiKey: apiConfig.togetherApiKey }
case "litellm":
return { apiKey: apiConfig.liteLlmApiKey, baseUrl: apiConfig.liteLlmBaseUrl }
case "nebius":
return { apiKey: apiConfig.nebiusApiKey }
case "sambanova":
return { apiKey: apiConfig.sambanovaApiKey }
case "cerebras":
return { apiKey: apiConfig.cerebrasApiKey }
case "baseten":
return { apiKey: apiConfig.basetenApiKey }
case "huggingface":
return { apiKey: apiConfig.huggingFaceApiKey }
case "huawei-cloud-maas":
return { apiKey: apiConfig.huaweiCloudMaasApiKey }
case "vercel-ai-gateway":
return { apiKey: apiConfig.vercelAiGatewayApiKey }
case "aihubmix":
return {
apiKey: apiConfig.aihubmixApiKey,
baseUrl: apiConfig.aihubmixBaseUrl,
headers: apiConfig.aihubmixAppCode ? { "APP-Code": apiConfig.aihubmixAppCode } : undefined,
}
case "hicap":
return {
apiKey: apiConfig.hicapApiKey,
headers: apiConfig.hicapApiKey ? { "api-key": apiConfig.hicapApiKey } : undefined,
}
case "nousResearch":
return { apiKey: apiConfig.nousResearchApiKey }
case "requesty":
return { apiKey: apiConfig.requestyApiKey, baseUrl: apiConfig.requestyBaseUrl }
case "xai":
return { apiKey: apiConfig.xaiApiKey }
case "groq":
return { apiKey: apiConfig.groqApiKey }
case "fireworks":
return { apiKey: apiConfig.fireworksApiKey }
default:
Logger.warn(`[Task ${this.host.taskId}] No explicit provider config mapping for provider "${providerId}"`)
return {}
}
}
}
+342
View File
@@ -0,0 +1,342 @@
import type { AgentHooks } from "@cline/agents"
import { executeHook } from "@core/hooks/hook-executor"
import type { Hooks as HookInputs } from "@core/hooks/hook-factory"
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import { executePreCompactHookWithCleanup, type HookExecution } from "@core/hooks/precompact-executor"
import type { ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import type { ClineContent, ClineStorageMessage } from "@shared/messages/content"
import type { ContextManager } from "@/core/context/context-management/ContextManager"
import type { MessageStateHandler } from "@/core/task/message-state"
import { Logger } from "@/shared/services/Logger"
type WithStateLock = <T>(fn: () => T | Promise<T>) => Promise<T>
export interface TaskHookResult {
cancel?: boolean
contextModification?: string
errorMessage?: string
wasCancelled: boolean
}
export interface TaskHookAdapterContext {
taskId: string
ulid: string
taskState: {
abort: boolean
didFinishAbortingStream: boolean
activeHookExecution?: HookExecution
}
messageStateHandler: MessageStateHandler
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
postStateToWebview: () => Promise<void>
cancelTask: () => Promise<void>
withStateLock?: WithStateLock
}
export interface CreateTaskAgentHooksOptions {
onRunStart: () => Promise<{ cancel?: boolean; context?: string } | undefined>
buildPendingToolInfo: (toolName: string, toolInput: unknown) => Record<string, unknown> | undefined
toHookStringParameters: (input: unknown) => Record<string, string>
createHookContextBlock: (hookName: string, contextModification?: string) => string | undefined
safeJsonStringify: (value: unknown) => string
}
/**
* Adapter that ports task-coupled hook behavior to the agents-extension boundary.
* This keeps hook semantics in one place while task/runtime migration completes.
*/
export class TaskHookExtensionAdapter {
constructor(private readonly ctx: TaskHookAdapterContext) {}
async setActiveHookExecution(hookExecution: HookExecution): Promise<void> {
if (this.ctx.withStateLock) {
await this.ctx.withStateLock(() => {
this.ctx.taskState.activeHookExecution = hookExecution
})
return
}
this.ctx.taskState.activeHookExecution = hookExecution
}
async clearActiveHookExecution(): Promise<void> {
if (this.ctx.withStateLock) {
await this.ctx.withStateLock(() => {
this.ctx.taskState.activeHookExecution = undefined
})
return
}
this.ctx.taskState.activeHookExecution = undefined
}
async getActiveHookExecution(): Promise<HookExecution | undefined> {
if (this.ctx.withStateLock) {
return await this.ctx.withStateLock(() => this.ctx.taskState.activeHookExecution)
}
return this.ctx.taskState.activeHookExecution
}
async runTaskLifecycleHook<K extends "TaskStart" | "TaskResume">(params: {
hookName: K
hookInput: HookInputs[K]
}): Promise<TaskHookResult> {
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return { wasCancelled: false }
}
const result = await executeHook({
hookName: params.hookName,
hookInput: params.hookInput,
isCancellable: true,
say: this.ctx.say,
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
messageStateHandler: this.ctx.messageStateHandler,
taskId: this.ctx.taskId,
hooksEnabled,
})
return {
cancel: result.cancel,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: result.wasCancelled,
}
}
async runUserPromptSubmitHook(userContent: ClineContent[]): Promise<TaskHookResult> {
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return { wasCancelled: false }
}
const { extractUserPromptFromContent } = await import("@/core/task/utils/extractUserPromptFromContent")
const promptText = extractUserPromptFromContent(userContent)
const result = await executeHook({
hookName: "UserPromptSubmit",
hookInput: {
userPromptSubmit: {
prompt: promptText,
attachments: [],
},
},
isCancellable: true,
say: this.ctx.say,
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
messageStateHandler: this.ctx.messageStateHandler,
taskId: this.ctx.taskId,
hooksEnabled,
})
if (result.cancel === true && result.wasCancelled) {
this.ctx.taskState.didFinishAbortingStream = true
await this.ctx.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.ctx.messageStateHandler.overwriteApiConversationHistory(
this.ctx.messageStateHandler.getApiConversationHistory(),
)
await this.ctx.postStateToWebview()
}
return {
cancel: result.cancel,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: result.wasCancelled,
}
}
async runTaskCancelHook(taskMetadata: { taskId: string; ulid: string; completionStatus: string }): Promise<void> {
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return
}
await executeHook({
hookName: "TaskCancel",
hookInput: {
taskCancel: {
taskMetadata,
},
},
isCancellable: false,
say: this.ctx.say,
messageStateHandler: this.ctx.messageStateHandler,
taskId: this.ctx.taskId,
hooksEnabled,
})
}
async runPreCompactHookWithCleanup(params: {
apiConversationHistory: ClineStorageMessage[]
conversationHistoryDeletedRange: [number, number] | undefined
contextManager: ContextManager
clineMessages: ClineMessage[]
deletedRange?: [number, number]
}): Promise<{ contextModification?: string }> {
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return {}
}
return await executePreCompactHookWithCleanup({
taskId: this.ctx.taskId,
ulid: this.ctx.ulid,
apiConversationHistory: params.apiConversationHistory,
conversationHistoryDeletedRange: params.conversationHistoryDeletedRange,
contextManager: params.contextManager,
clineMessages: params.clineMessages,
messageStateHandler: this.ctx.messageStateHandler,
compactionStrategy: "standard-truncation-lastquarter",
deletedRange: params.deletedRange,
say: this.ctx.say,
setActiveHookExecution: async (hookExecution: HookExecution | undefined) => {
if (hookExecution) {
await this.setActiveHookExecution(hookExecution)
}
},
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
postStateToWebview: this.ctx.postStateToWebview,
taskState: this.ctx.taskState,
cancelTask: this.ctx.cancelTask,
hooksEnabled,
})
}
async cancelHookExecution(logError: (message: string, error: unknown) => void): Promise<boolean> {
const activeHook = await this.getActiveHookExecution()
if (!activeHook) {
return false
}
const { hookName, toolName, messageTs, abortController } = activeHook
try {
abortController.abort()
const clineMessages = this.ctx.messageStateHandler.getClineMessages()
const hookMessageIndex = clineMessages.findIndex((m) => m.ts === messageTs)
if (hookMessageIndex !== -1) {
await this.ctx.messageStateHandler.updateClineMessage(hookMessageIndex, {
text: JSON.stringify({
hookName,
toolName,
status: "cancelled",
exitCode: 130,
}),
})
}
await this.ctx.say("hook_output_stream", "\nHook execution cancelled by user")
return true
} catch (error) {
logError("Failed to cancel hook execution", error)
return false
}
}
async handleHookCancellation(hookName: string, wasCancelled: boolean): Promise<void> {
this.ctx.taskState.didFinishAbortingStream = true
await this.ctx.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.ctx.messageStateHandler.overwriteApiConversationHistory(
this.ctx.messageStateHandler.getApiConversationHistory(),
)
await this.ctx.postStateToWebview()
Logger.log(`[Task ${this.ctx.taskId}] ${hookName} hook cancelled (userInitiated: ${wasCancelled})`)
}
createAgentHooks(options: CreateTaskAgentHooksOptions): AgentHooks {
return {
onRunStart: async () => options.onRunStart(),
onToolCallStart: async (ctx) => {
if (this.ctx.taskState.abort) {
return { cancel: true }
}
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return
}
const pendingToolInfo = options.buildPendingToolInfo(ctx.call.name, ctx.call.input)
const result = await executeHook({
hookName: "PreToolUse",
hookInput: {
preToolUse: {
toolName: ctx.call.name,
parameters: options.toHookStringParameters(ctx.call.input),
},
},
isCancellable: true,
say: this.ctx.say,
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
messageStateHandler: this.ctx.messageStateHandler,
taskId: this.ctx.taskId,
hooksEnabled,
toolName: ctx.call.name,
pendingToolInfo,
})
if (result.cancel) {
await this.clearActiveHookExecution()
if (result.errorMessage) {
await this.ctx.say("error", result.errorMessage)
}
await this.ctx.cancelTask()
return { cancel: true }
}
const context = options.createHookContextBlock("PreToolUse", result.contextModification)
return context ? { context } : undefined
},
onToolCallEnd: async (ctx) => {
if (this.ctx.taskState.abort) {
return { cancel: true }
}
const hooksEnabled = getHooksEnabledSafe()
if (!hooksEnabled) {
return
}
const toolResult = ctx.record.error
? { error: ctx.record.error }
: typeof ctx.record.output === "string"
? ctx.record.output
: options.safeJsonStringify(ctx.record.output)
const result = await executeHook({
hookName: "PostToolUse",
hookInput: {
postToolUse: {
toolName: ctx.record.name,
parameters: options.toHookStringParameters(ctx.record.input),
result: typeof toolResult === "string" ? toolResult : options.safeJsonStringify(toolResult),
success: !ctx.record.error,
executionTimeMs: ctx.record.durationMs,
},
},
isCancellable: true,
say: this.ctx.say,
setActiveHookExecution: async (execution) => this.setActiveHookExecution(execution as HookExecution),
clearActiveHookExecution: async () => this.clearActiveHookExecution(),
messageStateHandler: this.ctx.messageStateHandler,
taskId: this.ctx.taskId,
hooksEnabled,
toolName: ctx.record.name,
})
if (result.cancel) {
if (result.errorMessage) {
await this.ctx.say("error", result.errorMessage)
}
await this.ctx.cancelTask()
return { cancel: true }
}
const context = options.createHookContextBlock("PostToolUse", result.contextModification)
return context ? { context } : undefined
},
}
}
}
+4
View File
@@ -4,6 +4,10 @@ import { ClineAskResponse } from "@shared/WebviewMessage"
import type { HookExecution } from "./types/HookExecution"
export class TaskState {
// Task-level timing
taskStartTimeMs = Date.now()
taskFirstTokenTimeMs?: number
// Streaming flags
isStreaming = false
isWaitingForFirstChunk = false
+4 -3
View File
@@ -12,7 +12,6 @@ import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineContent } from "@shared/messages/content"
import { ClineDefaultTool } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import * as vscode from "vscode"
import { isParallelToolCallingEnabled, modelDoesntSupportWebp } from "@/utils/model-utils"
import { ToolUse } from "../assistant-message"
import { ContextManager } from "../context/context-management/ContextManager"
@@ -67,6 +66,10 @@ export class ToolExecutor {
private autoApprover: AutoApprove
private coordinator: ToolExecutorCoordinator
public setApiHandler(api: ApiHandler): void {
this.api = api
}
// Auto-approval methods using the AutoApprove class
private shouldAutoApproveTool(toolName: ClineDefaultTool): boolean | [boolean, boolean] {
return this.autoApprover.shouldAutoApproveTool(toolName)
@@ -81,7 +84,6 @@ export class ToolExecutor {
constructor(
// Core Services & Managers
private context: vscode.ExtensionContext,
private taskState: TaskState,
private messageStateHandler: MessageStateHandler,
private api: ApiHandler,
@@ -160,7 +162,6 @@ export class ToolExecutor {
const config: TaskConfig = {
taskId: this.taskId,
ulid: this.ulid,
context: this.context,
mode: this.stateManager.getGlobalSettingsKey("mode"),
strictPlanModeEnabled: this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled"),
yoloModeToggled: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
+99 -2359
View File
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ import { buildUserFeedbackContent } from "../../utils/buildUserFeedbackContent"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { getTaskCompletionTelemetry } from "../utils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
const TASK_PREVIEW_MAX_CHARS = 8000
@@ -152,7 +153,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
await config.callbacks.saveCheckpoint(true, completionMessageTs)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(config.ulid)
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
} else {
// we already sent a command message, meaning the complete completion message has also been sent
await config.callbacks.saveCheckpoint(true)
@@ -199,7 +200,7 @@ export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHand
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
await config.callbacks.saveCheckpoint(true, completionMessageTs)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(config.ulid)
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
@@ -9,12 +9,11 @@ import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { getTaskCompletionTelemetry } from "../utils"
export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandler {
readonly name = ClineDefaultTool.PLAN_MODE
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
@@ -85,9 +84,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
// we dont need to process any text, options, files or other content here
return formatResponse.toolResult(`[The user has switched to ACT MODE, so you may now proceed with the task.]`)
} else {
Logger.warn("YOLO MODE: Failed to switch to ACT MODE, continuing with normal plan mode")
}
Logger.warn("YOLO MODE: Failed to switch to ACT MODE, continuing with normal plan mode")
}
// Set awaiting plan response state
@@ -134,6 +132,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
fileContentString = await processFilesIntoText(planResponseFiles)
}
telemetryService.captureTaskCompleted(config.ulid, getTaskCompletionTelemetry(config))
// Handle mode switching response
if (config.taskState.didRespondToPlanAskBySwitchingMode) {
const result = formatResponse.toolResult(
@@ -147,9 +147,8 @@ export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandle
// Reset the flag after using it to prevent it from persisting
config.taskState.didRespondToPlanAskBySwitchingMode = false
return result
} else {
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
}
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
}
}
@@ -1,10 +1,8 @@
import type { ToolUse } from "@core/assistant-message"
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import { executePreCompactHookWithCleanup, HookCancellationError } from "@core/hooks/precompact-executor"
import { continuationPrompt } from "@core/prompts/contextManagement"
import { formatResponse } from "@core/prompts/responses"
import { ensureTaskDirectoryExists } from "@core/storage/disk"
import { StateManager } from "@core/storage/StateManager"
import { TaskHookExtensionAdapter } from "@core/task/TaskHookExtensionAdapter"
import { resolveWorkspacePath } from "@core/workspace"
import { extractFileContent } from "@integrations/misc/extract-file-content"
import { ClineSayTool } from "@shared/ExtensionMessage"
@@ -41,63 +39,43 @@ export class SummarizeTaskHandler implements IToolHandler, IPartialBlockHandler
// Variable to store context modification from PreCompact hook
let hookContextModification: string | undefined
// Run PreCompact hook right before showing the condensing message
const hooksEnabled = getHooksEnabledSafe()
if (hooksEnabled) {
try {
// Determine compaction strategy
const useAutoCondense = StateManager.get().getGlobalSettingsKey("useAutoCondense")
const strategy = useAutoCondense ? "auto-condense" : "standard-truncation-firstpair"
const apiHistory = config.messageState.getApiConversationHistory()
const result = await executePreCompactHookWithCleanup({
taskId: config.taskId,
ulid: config.ulid,
apiConversationHistory: apiHistory,
conversationHistoryDeletedRange: config.taskState.conversationHistoryDeletedRange,
contextManager: config.services.contextManager,
clineMessages: config.messageState.getClineMessages(),
messageStateHandler: config.messageState,
compactionStrategy: strategy,
say: config.callbacks.say,
setActiveHookExecution: async (hookExecution) => {
if (hookExecution) {
await config.callbacks.setActiveHookExecution(hookExecution)
}
},
clearActiveHookExecution: config.callbacks.clearActiveHookExecution,
postStateToWebview: config.callbacks.postStateToWebview,
taskState: config.taskState,
cancelTask: config.callbacks.cancelTask,
hooksEnabled,
})
// Hook completed successfully - capture context modification if provided
if (result.contextModification) {
hookContextModification = result.contextModification
Logger.log(`[PreCompact] Hook provided context modification for task ${config.taskId}`)
}
} catch (error) {
// Check if this is a hook cancellation error
if (error instanceof HookCancellationError) {
// Hook was cancelled - show message and return early without executing summarization
// (State already saved and task already cancelled by executePreCompactHookWithCleanup)
await config.callbacks.say(
"error",
"Context compaction was cancelled by PreCompact hook. Task has been aborted.",
)
return "Context compaction was cancelled. Task has been aborted."
}
// Graceful degradation: Show warning but continue with compaction
// Hook UI already shows "Failed" status with error details
// Run PreCompact hook right before showing the condensing message.
try {
const adapter = new TaskHookExtensionAdapter({
taskId: config.taskId,
ulid: config.ulid,
taskState: config.taskState,
messageStateHandler: config.messageState,
say: config.callbacks.say,
postStateToWebview: config.callbacks.postStateToWebview,
cancelTask: config.callbacks.cancelTask,
})
const preCompactResult = await adapter.runPreCompactHookWithCleanup({
apiConversationHistory: config.messageState.getApiConversationHistory(),
conversationHistoryDeletedRange: config.taskState.conversationHistoryDeletedRange,
contextManager: config.services.contextManager,
clineMessages: config.messageState.getClineMessages(),
})
if (preCompactResult.contextModification) {
hookContextModification = preCompactResult.contextModification
Logger.log(`[PreCompact] Hook provided context modification for task ${config.taskId}`)
}
} catch (error) {
// If cancellation happened, cancelTask has already run inside the adapter.
if (config.taskState.abort) {
await config.callbacks.say(
"error",
`PreCompact hook failed, continuing with compaction: ${error instanceof Error ? error.message : String(error)}`,
"Context compaction was cancelled by PreCompact hook. Task has been aborted.",
)
Logger.error("[PreCompact] Hook execution failed, continuing with compaction:", error)
return "Context compaction was cancelled. Task has been aborted."
}
// Graceful degradation: Show warning but continue with compaction.
await config.callbacks.say(
"error",
`PreCompact hook failed, continuing with compaction: ${error instanceof Error ? error.message : String(error)}`,
)
Logger.error("[PreCompact] Hook execution failed, continuing with compaction:", error)
}
// Show completed summary in tool UI
@@ -6,6 +6,7 @@ import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceBasename, resolveWorkspacePath } from "@core/workspace"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { ClineSayTool } from "@shared/ExtensionMessage"
import { getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { fileExistsAtPath } from "@utils/fs"
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
@@ -117,7 +118,27 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
if (block.name === "write_to_file" && !rawContent) {
config.taskState.consecutiveMistakeCount++
await config.services.diffViewProvider.reset()
return await config.callbacks.sayAndCreateMissingParamError(block.name, "content")
// Use progressive error with token budget awareness
const relPath = rawRelPath || "unknown"
const contextWindow = config.api.getModel().info.contextWindow ?? 128_000
const lastApiReqTotalTokens = getLastApiReqTotalTokens(config.messageState.getClineMessages())
const contextUsagePercent = contextWindow > 0 ? Math.round((lastApiReqTotalTokens / contextWindow) * 100) : undefined
const errorMessage = formatResponse.writeToFileMissingContentError(
relPath,
config.taskState.consecutiveMistakeCount,
contextUsagePercent,
)
await config.callbacks.say(
"error",
`Cline tried to use write_to_file for '${relPath}' without value for required parameter 'content'. ${
config.taskState.consecutiveMistakeCount >= 2
? "This has happened multiple times — Cline will try a different approach."
: "Retrying..."
}`,
)
return formatResponse.toolError(errorMessage)
}
if (block.name === "new_rule" && !rawContent) {
@@ -246,35 +267,34 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
await config.services.diffViewProvider.revertChanges()
return `The user denied this operation. ${fileDeniedNote}`
} else {
// User hit the approve button, and may have provided feedback
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
// Push additional tool feedback using existing utilities
ToolResultUtils.pushAdditionalToolFeedback(
config.taskState.userMessageContent,
text,
images,
fileContentString,
)
await config.callbacks.say("user_feedback", text, images, files)
}
// User hit the approve button, and may have provided feedback
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
telemetryService.captureToolUsage(
config.ulid,
block.name,
modelId,
providerId,
false,
true,
workspaceContext,
block.isNativeToolCall,
// Push additional tool feedback using existing utilities
ToolResultUtils.pushAdditionalToolFeedback(
config.taskState.userMessageContent,
text,
images,
fileContentString,
)
await config.callbacks.say("user_feedback", text, images, files)
}
telemetryService.captureToolUsage(
config.ulid,
block.name,
modelId,
providerId,
false,
true,
workspaceContext,
block.isNativeToolCall,
)
}
// Run PreToolUse hook after approval but before execution
@@ -327,9 +347,8 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
finalContent,
newProblemsMessage,
)
} else {
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
}
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
} catch (error) {
// Reset diff view on error
await config.services.diffViewProvider.revertChanges()
+210 -254
View File
@@ -1,9 +1,8 @@
import { setTimeout as delay } from "node:timers/promises"
import { buildApiHandler } from "@core/api"
import type { ApiHandler } from "@core/api"
import { parseAssistantMessageV2, ToolUse } from "@core/assistant-message"
import { ContextManager } from "@core/context/context-management/ContextManager"
import { checkContextWindowExceededError } from "@core/context/context-management/context-error-handling"
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
import { discoverSkills, getAvailableSkills } from "@core/context/instructions/user-instructions/skills"
import { formatResponse } from "@core/prompts/responses"
import { PromptRegistry } from "@core/prompts/system-prompt"
@@ -14,10 +13,9 @@ import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock
import { Logger } from "@shared/services/Logger"
import type { ClineTool } from "@shared/tools"
import { ClineDefaultTool } from "@shared/tools"
import { isNextGenModelFamily } from "@utils/model-utils"
import * as path from "path"
import { CoreAgent } from "@/core/agents/CoreAgent"
import { HostProvider } from "@/hosts/host-provider"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ApiFormat } from "@/shared/proto/cline/models"
import { calculateApiCostAnthropic } from "@/utils/cost"
import { TaskState } from "../../TaskState"
@@ -225,6 +223,7 @@ export class SubagentRunner {
private abortRequested = false
private activeCommandExecutions = 0
private abortingCommands = false
private coreAgent = new CoreAgent()
constructor(private baseConfig: TaskConfig) {}
@@ -302,15 +301,15 @@ export class SubagentRunner {
...apiConfiguration,
ulid: this.baseConfig.ulid,
}
const api = buildApiHandler(effectiveApiConfiguration, mode)
this.activeApiAbort = api.abort?.bind(api)
const api = this.coreAgent.initializeApiHandler(effectiveApiConfiguration, mode)
this.activeApiAbort = () => this.coreAgent.abortCurrentRequest()
const providerId = (
mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
) as string
const providerInfo = {
providerId,
model: api.getModel(),
model: this.coreAgent.getModel(),
mode,
customPrompt: this.baseConfig.services.stateManager.getGlobalSettingsKey("customPrompt"),
}
@@ -377,66 +376,59 @@ export class SubagentRunner {
},
]
while (true) {
if (
previousRequestTotalTokens !== undefined &&
this.shouldCompactBeforeNextRequest(previousRequestTotalTokens, api, providerInfo.model.id)
) {
const didCompact = this.compactConversationForContextWindow(conversation)
if (didCompact) {
Logger.warn("[SubagentRunner] Proactively compacted context before next subagent request.")
const loopResult = await this.coreAgent.runLoop<void, SubagentRunResult>({
initialInput: undefined,
shouldAbort: () => this.shouldAbort(),
runTurn: async () => {
if (
previousRequestTotalTokens !== undefined &&
this.coreAgent.shouldCompactBeforeNextRequest(
previousRequestTotalTokens,
api,
providerInfo.model.id,
this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense"),
)
) {
const didCompact = this.compactConversationForContextWindow(conversation)
if (didCompact) {
Logger.warn("[SubagentRunner] Proactively compacted context before next subagent request.")
}
previousRequestTotalTokens = undefined
}
// Prevent repeated compaction attempts off the same token sample.
previousRequestTotalTokens = undefined
}
const streamHandler = new StreamResponseHandler()
const { toolUseHandler } = streamHandler.getHandlers()
let requestInputTokens = 0
let requestOutputTokens = 0
let requestCacheWriteTokens = 0
let requestCacheReadTokens = 0
let requestTotalCost: number | undefined
const streamHandler = new StreamResponseHandler()
const { toolUseHandler } = streamHandler.getHandlers()
let assistantText = ""
let assistantTextSignature: string | undefined
let requestId: string | undefined
const stream = this.createMessageWithInitialChunkRetry(
api,
systemPrompt,
conversation,
nativeTools,
providerInfo.providerId,
providerInfo.model.id,
)
const stream = this.createMessageWithInitialChunkRetry(
api,
systemPrompt,
conversation,
nativeTools,
providerInfo.providerId,
providerInfo.model.id,
)
for await (const chunk of stream) {
switch (chunk.type) {
case "usage":
requestId = requestId ?? chunk.id
const streamResult = await this.coreAgent.consumeStream({
stream,
shouldAbort: () => this.shouldAbort(),
onAbort: async () => {
await this.abort()
},
onUsageChunk: (chunk, state) => {
stats.inputTokens += chunk.inputTokens || 0
stats.outputTokens += chunk.outputTokens || 0
stats.cacheWriteTokens += chunk.cacheWriteTokens || 0
stats.cacheReadTokens += chunk.cacheReadTokens || 0
requestInputTokens += chunk.inputTokens || 0
requestOutputTokens += chunk.outputTokens || 0
requestCacheWriteTokens += chunk.cacheWriteTokens || 0
requestCacheReadTokens += chunk.cacheReadTokens || 0
requestTotalCost = chunk.totalCost ?? requestTotalCost
stats.contextTokens =
requestInputTokens + requestOutputTokens + requestCacheWriteTokens + requestCacheReadTokens
state.usage.inputTokens +
state.usage.outputTokens +
state.usage.cacheWriteTokens +
state.usage.cacheReadTokens
stats.contextUsagePercentage =
stats.contextWindow > 0 ? (stats.contextTokens / stats.contextWindow) * 100 : 0
onProgress({ stats: { ...stats } })
break
case "text":
requestId = requestId ?? chunk.id
assistantText += chunk.text || ""
assistantTextSignature = chunk.signature || assistantTextSignature
break
case "tool_calls":
requestId = requestId ?? chunk.id
},
onToolCallChunk: (chunk) => {
toolUseHandler.processToolUseDelta(
{
id: chunk.tool_call.function?.id,
@@ -447,188 +439,184 @@ export class SubagentRunner {
},
chunk.tool_call.call_id,
)
break
case "reasoning":
requestId = requestId ?? chunk.id
break
}
if (this.shouldAbort()) {
await this.abort()
const error = "Subagent run cancelled."
onProgress({ status: "failed", error, stats: { ...stats } })
return { status: "failed", error, stats }
}
}
const calculatedRequestCost =
requestTotalCost ??
calculateApiCostAnthropic(
providerInfo.model.info,
requestInputTokens,
requestOutputTokens,
requestCacheWriteTokens,
requestCacheReadTokens,
)
stats.totalCost += calculatedRequestCost || 0
previousRequestTotalTokens =
requestInputTokens + requestOutputTokens + requestCacheWriteTokens + requestCacheReadTokens
const nativeFinalizedToolCalls = toolUseHandler.getAllFinalizedToolUses().map((toolCall, index) => ({
toolUseId: resolveToolUseId(toolCall, index),
id: toolCall.id,
call_id: toolCall.call_id,
signature: toolCall.signature,
name: toolCall.name,
input: toolCall.input,
isNativeToolCall: true,
}))
const parsedNonNativeToolCalls = parseNonNativeToolCalls(assistantText)
const fallbackNonNativeToolCalls = nativeFinalizedToolCalls.map((toolCall) => ({
...toolCall,
isNativeToolCall: false,
}))
let finalizedToolCalls: SubagentToolCall[] = []
if (useNativeToolCalls) {
finalizedToolCalls = nativeFinalizedToolCalls
} else if (parsedNonNativeToolCalls.length > 0) {
finalizedToolCalls = parsedNonNativeToolCalls
} else if (fallbackNonNativeToolCalls.length > 0) {
// Defensive fallback: if non-native mode receives structured tool call chunks,
// execute them but serialize results as plain text to avoid tool_result pairing mismatches.
Logger.warn(
"[SubagentRunner] Received structured tool_calls while native tool calling is disabled; falling back to non-native result serialization.",
)
finalizedToolCalls = fallbackNonNativeToolCalls
}
const assistantContent = [] as any[]
if (assistantText.trim().length > 0) {
assistantContent.push({
type: "text",
text: assistantText,
signature: assistantTextSignature,
},
})
}
if (useNativeToolCalls) {
assistantContent.push(...finalizedToolCalls.map(toAssistantToolUseBlock))
}
if (assistantContent.length > 0) {
conversation.push({
role: "assistant",
content: assistantContent,
id: requestId,
})
}
if (finalizedToolCalls.length === 0) {
emptyAssistantResponseRetries += 1
if (emptyAssistantResponseRetries > MAX_EMPTY_ASSISTANT_RETRIES) {
const error = "Subagent did not call attempt_completion."
onProgress({ status: "failed", error, stats: { ...stats } })
return { status: "failed", error, stats }
if (streamResult.aborted) {
return { status: "failed", error: "Subagent run cancelled." }
}
// Mirror the main loop's no-tools-used nudge so empty/blank model turns
// can recover without surfacing an immediate hard failure in subagent UI.
if (assistantContent.length === 0) {
conversation.push({
role: "assistant",
content: [
{
type: "text",
text: "Failure: I did not provide a response.",
},
],
id: requestId,
const calculatedRequestCost =
streamResult.usage.totalCost ??
calculateApiCostAnthropic(
providerInfo.model.info,
streamResult.usage.inputTokens,
streamResult.usage.outputTokens,
streamResult.usage.cacheWriteTokens,
streamResult.usage.cacheReadTokens,
)
stats.totalCost += calculatedRequestCost || 0
previousRequestTotalTokens =
streamResult.usage.inputTokens +
streamResult.usage.outputTokens +
streamResult.usage.cacheWriteTokens +
streamResult.usage.cacheReadTokens
const nativeFinalizedToolCalls = toolUseHandler.getAllFinalizedToolUses().map((toolCall, index) => ({
toolUseId: resolveToolUseId(toolCall, index),
id: toolCall.id,
call_id: toolCall.call_id,
signature: toolCall.signature,
name: toolCall.name,
input: toolCall.input,
isNativeToolCall: true,
}))
const parsedNonNativeToolCalls = parseNonNativeToolCalls(streamResult.assistantText)
const fallbackNonNativeToolCalls = nativeFinalizedToolCalls.map((toolCall) => ({
...toolCall,
isNativeToolCall: false,
}))
let finalizedToolCalls: SubagentToolCall[] = []
if (useNativeToolCalls) {
finalizedToolCalls = nativeFinalizedToolCalls
} else if (parsedNonNativeToolCalls.length > 0) {
finalizedToolCalls = parsedNonNativeToolCalls
} else if (fallbackNonNativeToolCalls.length > 0) {
Logger.warn(
"[SubagentRunner] Received structured tool_calls while native tool calling is disabled; falling back to non-native result serialization.",
)
finalizedToolCalls = fallbackNonNativeToolCalls
}
const assistantContent = [] as any[]
if (streamResult.assistantText.trim().length > 0) {
assistantContent.push({
type: "text",
text: streamResult.assistantText,
signature: streamResult.assistantTextSignature,
})
}
conversation.push({
role: "user",
content: [
{
type: "text",
text: formatResponse.noToolsUsed(useNativeToolCalls),
},
],
})
await delay(0)
continue
}
emptyAssistantResponseRetries = 0
if (useNativeToolCalls) {
assistantContent.push(...finalizedToolCalls.map(toAssistantToolUseBlock))
}
const toolResultBlocks = [] as any[]
for (const call of finalizedToolCalls) {
const toolName = call.name as ClineDefaultTool
const toolCallParams = toToolUseParams(call.input)
if (assistantContent.length > 0) {
conversation.push({
role: "assistant",
content: assistantContent,
id: streamResult.requestId,
})
}
if (toolName === ClineDefaultTool.ATTEMPT) {
const completionResult = toolCallParams.result?.trim()
if (!completionResult) {
const missingResultError = formatResponse.missingToolParameterError("result")
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, missingResultError)
if (finalizedToolCalls.length === 0) {
emptyAssistantResponseRetries += 1
if (emptyAssistantResponseRetries > MAX_EMPTY_ASSISTANT_RETRIES) {
return { status: "failed", error: "Subagent did not call attempt_completion." }
}
if (assistantContent.length === 0) {
conversation.push({
role: "assistant",
content: [{ type: "text", text: "Failure: I did not provide a response." }],
id: streamResult.requestId,
})
}
conversation.push({
role: "user",
content: [{ type: "text", text: formatResponse.noToolsUsed(useNativeToolCalls) }],
})
await delay(0)
return { status: "continue", nextInput: undefined }
}
emptyAssistantResponseRetries = 0
const toolResultBlocks = [] as any[]
for (const call of finalizedToolCalls) {
const toolName = call.name as ClineDefaultTool
const toolCallParams = toToolUseParams(call.input)
if (toolName === ClineDefaultTool.ATTEMPT) {
const completionResult = toolCallParams.result?.trim()
if (!completionResult) {
const missingResultError = formatResponse.missingToolParameterError("result")
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, missingResultError)
continue
}
stats.toolCalls += 1
onProgress({ stats: { ...stats } })
const completed: SubagentRunResult = { status: "completed", result: completionResult, stats }
return { status: "complete", output: completed }
}
if (!SUBAGENT_ALLOWED_TOOLS.includes(toolName)) {
const deniedResult = formatResponse.toolError(
`Tool '${toolName}' is not available inside subagent runs.`,
)
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, deniedResult)
continue
}
const toolCallBlock: ToolUse = {
type: "tool_use",
name: toolName,
params: toolCallParams,
partial: false,
isNativeToolCall: call.isNativeToolCall,
call_id: call.call_id || call.toolUseId,
signature: call.signature,
}
if (call.call_id) {
state.toolUseIdMap.set(call.call_id, call.toolUseId)
}
onProgress({ latestToolCall: formatToolCallPreview(toolName, toolCallParams) })
const subagentConfig = this.createSubagentTaskConfig(state)
const handler = this.baseConfig.coordinator.getHandler(toolName)
let toolResult: unknown
if (!handler) {
toolResult = formatResponse.toolError(`No handler registered for tool '${toolName}'.`)
} else {
try {
toolResult = await this.baseConfig.coordinator.execute(subagentConfig, toolCallBlock)
} catch (error) {
toolResult = formatResponse.toolError((error as Error).message)
}
}
stats.toolCalls += 1
onProgress({ stats: { ...stats } })
onProgress({ status: "completed", result: completionResult, stats: { ...stats } })
return { status: "completed", result: completionResult, stats }
const serializedToolResult = serializeToolResult(toolResult)
const toolDescription = handler?.getDescription(toolCallBlock) || `[${toolName}]`
pushSubagentToolResultBlock(toolResultBlocks, call, toolDescription, serializedToolResult)
}
if (!SUBAGENT_ALLOWED_TOOLS.includes(toolName)) {
const deniedResult = formatResponse.toolError(`Tool '${toolName}' is not available inside subagent runs.`)
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, deniedResult)
continue
}
conversation.push({
role: "user",
content: toolResultBlocks,
})
const toolCallBlock: ToolUse = {
type: "tool_use",
name: toolName,
params: toolCallParams,
partial: false,
isNativeToolCall: call.isNativeToolCall,
call_id: call.call_id || call.toolUseId,
signature: call.signature,
}
await delay(0)
return { status: "continue", nextInput: undefined }
},
})
if (call.call_id) {
state.toolUseIdMap.set(call.call_id, call.toolUseId)
}
const latestToolCall = formatToolCallPreview(toolName, toolCallParams)
onProgress({ latestToolCall })
const subagentConfig = this.createSubagentTaskConfig(state)
const handler = this.baseConfig.coordinator.getHandler(toolName)
let toolResult: unknown
if (!handler) {
toolResult = formatResponse.toolError(`No handler registered for tool '${toolName}'.`)
} else {
try {
toolResult = await handler.execute(subagentConfig, toolCallBlock)
} catch (error) {
toolResult = formatResponse.toolError((error as Error).message)
}
}
stats.toolCalls += 1
onProgress({ stats: { ...stats } })
const serializedToolResult = serializeToolResult(toolResult)
const toolDescription = handler?.getDescription(toolCallBlock) || `[${toolName}]`
pushSubagentToolResultBlock(toolResultBlocks, call, toolDescription, serializedToolResult)
}
conversation.push({
role: "user",
content: toolResultBlocks,
})
await delay(0)
if (loopResult.status === "complete") {
onProgress({ status: "completed", result: loopResult.output.result, stats: { ...stats } })
return loopResult.output
}
if (loopResult.status === "failed") {
const error = loopResult.error || "Subagent execution failed."
onProgress({ status: "failed", error, stats: { ...stats } })
return { status: "failed", error, stats }
}
const cancelledError = "Subagent run cancelled."
onProgress({ status: "failed", error: cancelledError, stats: { ...stats } })
return { status: "failed", error: cancelledError, stats }
} catch (error) {
if (this.shouldAbort()) {
const cancelledError = "Subagent run cancelled."
@@ -673,19 +661,6 @@ export class SubagentRunner {
}
}
private shouldRetryInitialStreamError(error: unknown, providerId: string, modelId: string): boolean {
// Mirror main loop behavior: do not auto-retry auth/balance failures.
const parsedError = ClineError.transform(error, modelId, providerId)
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
if (isAuthError || isBalanceError) {
return false
}
return true
}
private compactConversationForContextWindow(conversation: ClineStorageMessage[]): boolean {
const contextManager = new ContextManager()
const optimizationResult = this.optimizeConversationForContextWindow(contextManager, conversation)
@@ -729,27 +704,8 @@ export class SubagentRunner {
return { didOptimize: true, needToTruncate: optimizationResult.needToTruncate }
}
private shouldCompactBeforeNextRequest(
previousRequestTotalTokens: number,
api: ReturnType<typeof buildApiHandler>,
modelId: string,
): boolean {
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const useAutoCondense = this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense")
if (useAutoCondense && isNextGenModelFamily(modelId)) {
const autoCondenseThreshold = this.baseConfig.services.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
| number
| undefined
const roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return previousRequestTotalTokens >= thresholdTokens
}
return previousRequestTotalTokens >= maxAllowedSize
}
private async *createMessageWithInitialChunkRetry(
api: ReturnType<typeof buildApiHandler>,
api: ApiHandler,
systemPrompt: string,
conversation: ClineStorageMessage[],
nativeTools: ClineTool[] | undefined,
@@ -757,7 +713,7 @@ export class SubagentRunner {
modelId: string,
) {
for (let attempt = 1; attempt <= MAX_INITIAL_STREAM_ATTEMPTS; attempt += 1) {
const stream = api.createMessage(systemPrompt, conversation, nativeTools)
const stream = this.coreAgent.createMessage(systemPrompt, conversation, nativeTools)
const iterator = stream[Symbol.asyncIterator]()
try {
@@ -783,7 +739,7 @@ export class SubagentRunner {
const shouldRetry =
!this.shouldAbort() &&
attempt < MAX_INITIAL_STREAM_ATTEMPTS &&
this.shouldRetryInitialStreamError(error, providerId, modelId)
this.coreAgent.classifyInitialStreamRetry(error, providerId, modelId).shouldRetry
if (!shouldRetry) {
throw error
}
-2
View File
@@ -15,7 +15,6 @@ import type { ClineContent } from "@shared/messages/content"
import type { Mode } from "@shared/storage/types"
import type { ClineDefaultTool } from "@shared/tools"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import * as vscode from "vscode"
import { WorkspaceRootManager } from "@/core/workspace"
import type { ContextManager } from "../../../context/context-management/ContextManager"
import type { StateManager } from "../../../storage/StateManager"
@@ -41,7 +40,6 @@ export interface TaskConfig {
vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec"
enableParallelToolCalling: boolean
isSubagentExecution: boolean
context: vscode.ExtensionContext
// Multi-workspace support (optional for backward compatibility)
workspaceManager?: WorkspaceRootManager
+28 -25
View File
@@ -62,11 +62,11 @@ export class PatchParser {
private parseNextAction(): void {
const line = this.lines[this.index]
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
if (line?.startsWith(PATCH_MARKERS.UPDATE)) {
this.parseUpdate(line.substring(PATCH_MARKERS.UPDATE.length).trim())
} else if (line.startsWith(PATCH_MARKERS.DELETE)) {
} else if (line?.startsWith(PATCH_MARKERS.DELETE)) {
this.parseDelete(line.substring(PATCH_MARKERS.DELETE.length).trim())
} else if (line.startsWith(PATCH_MARKERS.ADD)) {
} else if (line?.startsWith(PATCH_MARKERS.ADD)) {
this.parseAdd(line.substring(PATCH_MARKERS.ADD.length).trim())
} else {
throw new DiffError(`Unknown line while parsing: ${line}`)
@@ -85,14 +85,14 @@ export class PatchParser {
this.index++
const movePath = this.lines[this.index]?.startsWith(PATCH_MARKERS.MOVE)
? this.lines[this.index++].substring(PATCH_MARKERS.MOVE.length).trim()
? (this.lines[this.index++] ?? "").substring(PATCH_MARKERS.MOVE.length).trim()
: undefined
if (!(path in this.currentFiles)) {
throw new DiffError(`Update File Error: Missing File: ${path}`)
}
const text = this.currentFiles[path]!
const text = this.currentFiles[path] ?? ""
const action = this.parseUpdateFile(text, path)
action.movePath = movePath
@@ -114,8 +114,9 @@ export class PatchParser {
]
while (!stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
const defStr = this.lines[this.index]?.startsWith("@@ ") ? this.lines[this.index]!.substring(3) : undefined
const sectionStr = this.lines[this.index] === "@@" ? this.lines[this.index] : undefined
const currentLine = this.lines[this.index]
const defStr = currentLine?.startsWith("@@ ") ? currentLine.substring(3) : undefined
const sectionStr = currentLine === "@@" ? currentLine : undefined
if (defStr !== undefined || sectionStr !== undefined) {
this.index++
@@ -127,9 +128,10 @@ export class PatchParser {
if (defStr?.trim()) {
const canonDefStr = canonicalize(defStr.trim())
for (let i = index; i < fileLines.length; i++) {
if (canonicalize(fileLines[i]!) === canonDefStr || canonicalize(fileLines[i]!.trim()) === canonDefStr) {
const fileLine = fileLines[i]
if (fileLine && (canonicalize(fileLine) === canonDefStr || canonicalize(fileLine.trim()) === canonDefStr)) {
index = i + 1
if (canonicalize(fileLines[i]!.trim()) === canonDefStr && canonicalize(fileLines[i]!) !== canonDefStr) {
if (canonicalize(fileLine.trim()) === canonDefStr && canonicalize(fileLine) !== canonDefStr) {
this.fuzz++
}
break
@@ -192,8 +194,11 @@ export class PatchParser {
const stopMarkers = [PATCH_MARKERS.END, PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE, PATCH_MARKERS.ADD]
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index].startsWith(m.trim()))) {
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
const line = this.lines[this.index++]
if (line === undefined) {
break
}
if (!line.startsWith("+")) {
throw new DiffError(`Invalid Add File line (missing '+'): ${line}`)
}
@@ -222,31 +227,29 @@ function calculateSimilarity(str1: string, str2: string): number {
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = []
const rows = str2.length + 1
const cols = str1.length + 1
const matrix = new Array<number>(rows * cols).fill(0) // avoid undefined access with flat array
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i]
const at = (r: number, c: number): number => matrix[r * cols + c] ?? 0
const set = (r: number, c: number, v: number) => {
matrix[r * cols + c] = v
}
for (let j = 0; j <= str1.length; j++) {
matrix[0]![j] = j
}
for (let i = 0; i <= str2.length; i++) set(i, 0, i)
for (let j = 0; j <= str1.length; j++) set(0, j, j)
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2[i - 1] === str1[j - 1]) {
matrix[i]![j] = matrix[i - 1]![j - 1]!
set(i, j, at(i - 1, j - 1))
} else {
matrix[i]![j] = Math.min(
matrix[i - 1]![j - 1]! + 1, // substitution
matrix[i]![j - 1]! + 1, // insertion
matrix[i - 1]![j]! + 1, // deletion
)
set(i, j, 1 + Math.min(at(i - 1, j - 1), at(i, j - 1), at(i - 1, j)))
}
}
}
return matrix[str2.length]![str1.length]!
return at(str2.length, str1.length)
}
/**
@@ -356,8 +359,8 @@ function peek(lines: string[], initialIndex: number): PeekResult {
]
while (index < lines.length) {
const s = lines[index]!
if (stopMarkers.some((m) => s.startsWith(m.trim()))) {
const s = lines[index]
if (!s || stopMarkers.some((m) => s.startsWith(m.trim()))) {
break
}
if (s === "***") {
@@ -20,7 +20,6 @@ export const TASK_CONFIG_KEYS = [
"vscodeTerminalExecutionMode",
"enableParallelToolCalling",
"isSubagentExecution",
"context",
"taskState",
"messageState",
"api",
@@ -375,6 +375,45 @@ describe("PatchParser", () => {
expectedError: /Unknown line while parsing/,
},
// Duplicate delete error
{
name: "error: duplicate delete",
patchLines: ["*** Begin Patch", "*** Delete File: old.ts", "*** Delete File: old.ts", "*** End Patch"],
currentFiles: {
"old.ts": "content",
},
expectedError: /Duplicate delete/,
},
// Duplicate add error
{
name: "error: duplicate add",
patchLines: [
"*** Begin Patch",
"*** Add File: new.ts",
"+content",
"*** Add File: new.ts",
"+content2",
"*** End Patch",
],
currentFiles: {},
expectedError: /Duplicate add/,
},
// Invalid add line (missing '+')
{
name: "error: add file line missing '+'",
patchLines: [
"*** Begin Patch",
"*** Add File: new.ts",
"+valid line",
"invalid line without plus",
"*** End Patch",
],
currentFiles: {},
expectedError: /Invalid Add File line/,
},
// Move operation
{
name: "update with move",
@@ -782,34 +821,34 @@ describe("PatchParser", () => {
expect(actualAction, `Action for ${filePath} should exist`).to.exist
// Check action type
expect(actualAction!.type).to.equal(expectedAction.type)
expect(actualAction.type).to.equal(expectedAction.type)
// Check newFile for ADD operations
if (expectedAction.newFile !== undefined) {
expect(actualAction!.newFile).to.equal(expectedAction.newFile)
expect(actualAction?.newFile).to.equal(expectedAction.newFile)
}
// Check movePath for MOVE operations
if (expectedAction.movePath !== undefined) {
expect(actualAction!.movePath).to.equal(expectedAction.movePath)
expect(actualAction?.movePath).to.equal(expectedAction.movePath)
}
// Check chunks if provided
if (expectedAction.chunks !== undefined) {
expect(actualAction!.chunks).to.have.lengthOf(expectedAction.chunks.length)
expect(actualAction?.chunks).to.have.lengthOf(expectedAction.chunks.length)
for (let i = 0; i < expectedAction.chunks.length; i++) {
const expectedChunk = expectedAction.chunks[i]
const actualChunk = actualAction!.chunks[i]
const actualChunk = actualAction?.chunks[i]
if (expectedChunk.origIndex !== undefined) {
expect(actualChunk!.origIndex).to.equal(expectedChunk.origIndex)
expect(actualChunk?.origIndex).to.equal(expectedChunk.origIndex)
}
if (expectedChunk.delLines !== undefined) {
expect(actualChunk!.delLines).to.deep.equal(expectedChunk.delLines)
expect(actualChunk?.delLines).to.deep.equal(expectedChunk.delLines)
}
if (expectedChunk.insLines !== undefined) {
expect(actualChunk!.insLines).to.deep.equal(expectedChunk.insLines)
expect(actualChunk?.insLines).to.deep.equal(expectedChunk.insLines)
}
}
}
@@ -857,8 +896,8 @@ describe("PatchParser", () => {
const result = parser.parse()
expect(result.patch.actions["large.ts"]).to.exist
expect(result.patch.actions["large.ts"]!.chunks).to.have.lengthOf(1)
expect(result.patch.actions["large.ts"]!.chunks[0]!.origIndex).to.equal(501)
expect(result.patch.actions["large.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["large.ts"].chunks[0].origIndex).to.equal(501)
})
it("should handle unicode characters in content", () => {
@@ -878,7 +917,7 @@ describe("PatchParser", () => {
const result = parser.parse()
expect(result.patch.actions["unicode.ts"]).to.exist
expect(result.patch.actions["unicode.ts"]!.chunks[0]!.insLines[0]).to.equal("const text = '你好'")
expect(result.patch.actions["unicode.ts"]?.chunks[0]?.insLines[0]).to.equal("const text = '你好'")
})
it("should handle empty lines in context", () => {
@@ -891,6 +930,183 @@ describe("PatchParser", () => {
expect(result.patch.actions["test.ts"]).to.exist
})
it("should handle updating an empty file", () => {
// A file with only a single line (no context needed)
const patchLines = [
"*** Begin Patch",
"*** Update File: empty.ts",
"@@",
"-old content",
"+new content",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"empty.ts": "old content",
})
const result = parser.parse()
expect(result.patch.actions["empty.ts"]).to.exist
expect(result.patch.actions["empty.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["empty.ts"].chunks[0].origIndex).to.equal(0)
expect(result.patch.actions["empty.ts"].chunks[0].delLines).to.deep.equal(["old content"])
expect(result.patch.actions["empty.ts"].chunks[0].insLines).to.deep.equal(["new content"])
})
it("should not include warnings property when there are no warnings", () => {
const patchLines = ["*** Begin Patch", "*** Update File: test.ts", "@@", " context", "-old", "+new", "*** End Patch"]
const parser = new PatchParser(patchLines, {
"test.ts": "context\nold",
})
const result = parser.parse()
// warnings should be absent (deleted) when empty
expect(result.patch.warnings).to.be.undefined
})
it("should handle add file with empty content (no lines)", () => {
const patchLines = ["*** Begin Patch", "*** Add File: empty.ts", "*** End Patch"]
const parser = new PatchParser(patchLines, {})
const result = parser.parse()
expect(result.patch.actions["empty.ts"]).to.exist
expect(result.patch.actions["empty.ts"].type).to.equal(PatchActionType.ADD)
expect(result.patch.actions["empty.ts"].newFile).to.equal("")
})
it("should handle move without content changes", () => {
// Move-only: no @@ section, just rename
const patchLines = [
"*** Begin Patch",
"*** Update File: old.ts",
"*** Move to: new.ts",
"@@",
" unchanged line",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"old.ts": "unchanged line",
})
const result = parser.parse()
expect(result.patch.actions["old.ts"]).to.exist
expect(result.patch.actions["old.ts"].movePath).to.equal("new.ts")
expect(result.patch.actions["old.ts"].chunks).to.have.lengthOf(0)
})
it("should handle unicode punctuation normalization (curly quotes match straight quotes)", () => {
// File uses straight quotes, patch uses Unicode curly quotes — canonicalize should match them
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" const x = \u201Chello\u201D", // LEFT/RIGHT DOUBLE QUOTATION MARK
"-const y = \u2018old\u2019", // LEFT/RIGHT SINGLE QUOTATION MARK
"+const y = 'new'",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "const x = \"hello\"\nconst y = 'old'",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
})
it("should handle unicode dash normalization (em-dash matches hyphen)", () => {
// File uses a regular hyphen, patch uses an em-dash — canonicalize should match them
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" // section \u2014 header", // EM DASH
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "// section - header\nold",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
})
it("should handle @@ context string not found in file (silently continues from current position)", () => {
// When the @@ context string doesn't exist in the file, the parser should
// continue searching from the current index rather than throwing
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@ nonexistent function",
" actual context",
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "actual context\nold",
})
const result = parser.parse()
// Should still find the chunk via context lines even though @@ string wasn't found
expect(result.patch.actions["test.ts"]).to.exist
})
it("should handle patch with only insertions (no deletions)", () => {
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" line1",
"+inserted line",
" line2",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "line1\nline2",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
const chunks = result.patch.actions["test.ts"].chunks
expect(chunks).to.have.lengthOf(1)
expect(chunks[0].delLines).to.deep.equal([])
expect(chunks[0].insLines).to.deep.equal(["inserted line"])
})
it("should handle patch with only deletions (no insertions)", () => {
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" line1",
"-line to delete",
" line2",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "line1\nline to delete\nline2",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
const chunks = result.patch.actions["test.ts"].chunks
expect(chunks).to.have.lengthOf(1)
expect(chunks[0].delLines).to.deep.equal(["line to delete"])
expect(chunks[0].insLines).to.deep.equal([])
})
})
describe("Fuzz Scoring", () => {
@@ -968,14 +1184,14 @@ describe("PatchParser", () => {
// Should have one valid chunk applied
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"]!.chunks).to.have.lengthOf(1)
expect(result.patch.actions["test.ts"]!.chunks[0]!.origIndex).to.equal(1)
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["test.ts"].chunks[0].origIndex).to.equal(1)
// Should have warnings for skipped chunk
expect(result.patch.warnings).to.exist
expect(result.patch.warnings).to.have.lengthOf(1)
expect(result.patch.warnings![0]!.path).to.equal("test.ts")
expect(result.patch.warnings![0]!.message).to.match(/Could not find matching context/)
expect(result.patch.warnings?.[0]?.path).to.equal("test.ts")
expect(result.patch.warnings?.[0]?.message).to.match(/Could not find matching context/)
})
it("should handle mixed valid and invalid chunks", () => {
@@ -1003,7 +1219,7 @@ describe("PatchParser", () => {
const result = parser.parse()
// Should have 2 valid chunks (1st and 3rd)
expect(result.patch.actions["test.ts"]!.chunks).to.have.lengthOf(2)
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(2)
// Should have 1 warning for skipped chunk
expect(result.patch.warnings).to.have.lengthOf(1)
+20
View File
@@ -1,3 +1,23 @@
import { TaskConfig } from "../types/TaskConfig"
export * from "../types/TaskConfig"
export * from "./ToolConstants"
export { ToolDisplayUtils } from "./ToolDisplayUtils"
export { ToolResultUtils } from "./ToolResultUtils"
export function getTaskCompletionTelemetry(config: TaskConfig) {
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const apiConfig = config.services.stateManager.getApiConfiguration()
const provider = currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
const model = config.api.getModel()
const durationMs = Math.max(0, Date.now() - config.taskState.taskStartTimeMs)
return {
provider,
modelId: model.id,
apiFormat: model.info.apiFormat,
timeToFirstTokenMs: config.taskState.taskFirstTokenTimeMs,
durationMs,
mode: currentMode,
}
}
+3 -3
View File
@@ -2,8 +2,8 @@ import path from "node:path"
import { Controller } from "@core/controller/index"
import axios from "axios"
import { readFile } from "fs/promises"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { ClineExtensionContext } from "@/shared/cline"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Logger } from "@/shared/services/Logger"
import { getNonce } from "./getNonce"
@@ -12,7 +12,7 @@ export abstract class WebviewProvider {
private static instance: WebviewProvider | null = null
controller: Controller
constructor(readonly context: vscode.ExtensionContext) {
constructor(readonly context: ClineExtensionContext) {
WebviewProvider.instance = this
// Create controller with cache service
@@ -140,7 +140,7 @@ export abstract class WebviewProvider {
return readFile(portFilePath, "utf8")
.then((portFile) => {
const port = parseInt(portFile.trim()) || DEFAULT_PORT
const port = Number.parseInt(portFile.trim()) || DEFAULT_PORT
Logger.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
return port
+36 -28
View File
@@ -19,6 +19,7 @@ import path from "node:path"
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { createStorageContext } from "@/shared/storage/storage-context"
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
import { initialize, tearDown } from "./common"
import { addToCline } from "./core/controller/commands/addToCline"
@@ -28,7 +29,6 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { StateManager } from "./core/storage/StateManager"
import {
cleanupMcpMarketplaceCatalogFromGlobalState,
cleanupOldApiKey,
@@ -48,6 +48,7 @@ import {
import { VscodeTerminalManager } from "./hosts/vscode/terminal/VscodeTerminalManager"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { exportVSCodeStorageToSharedFiles } from "./hosts/vscode/vscode-to-file-migration"
import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { LogoutReason } from "./services/auth/types"
@@ -66,18 +67,26 @@ export async function activate(context: vscode.ExtensionContext) {
// IMPORTANT: This must be done before any service can be registered
setupHostProvider(context)
// 2. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup
const webview = (await initialize(context)) as VscodeWebviewProvider
// 2. Clean up legacy data patterns within VSCode's native storage.
// Moves workspace→global keys, task history→file, custom instructions→rules, etc.
// Must run BEFORE the file export so we copy clean state.
await cleanupLegacyVSCodeStorage(context)
// 3. Register services and commands specific to VS Code
// 3. One-time export of VSCode's native storage to shared file-backed stores.
// After this, all platforms (VSCode, CLI, JetBrains) read from ~/.cline/data/.
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const storageContext = createStorageContext({ workspacePath })
await exportVSCodeStorageToSharedFiles(context, storageContext)
// 4. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup and migrations are complete
const webview = (await initialize(storageContext)) as VscodeWebviewProvider
// 5. Register services and commands specific to VS Code
// Initialize test mode and add disposables to context
const testModeWatchers = await initializeTestMode(webview)
context.subscriptions.push(...testModeWatchers)
// Perform storage migrations that does not block extension activation
performStorageMigrations(context)
// Initialize hook discovery cache for performance optimization
HookDiscoveryCache.getInstance().initialize(
context as any, // Adapt VSCode ExtensionContext to generic interface
@@ -514,25 +523,24 @@ ${ctx.cellJson || "{}"}
}),
)
context.subscriptions.push(
context.secrets.onDidChange(async (event) => {
if (event.key === "cline:clineAccountId") {
// Check if the secret was removed (logout) or added/updated (login)
const secretValue = await context.secrets.get(event.key)
const activeWebview = WebviewProvider.getVisibleInstance()
const controller = activeWebview?.controller
// Listen for secrets changes (e.g., cross-window login/logout sync)
const unsubSecrets = storageContext.secrets.onDidChange((event) => {
if (event.key === "cline:clineAccountId") {
const secretValue = storageContext.secrets.get<string>(event.key)
const activeWebview = WebviewProvider.getVisibleInstance()
const controller = activeWebview?.controller
const authService = AuthService.getInstance(controller)
if (secretValue) {
// Secret was added or updated - restore auth info (login from another window)
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
} else {
// Secret was removed - handle logout for all windows
authService?.handleDeauth(LogoutReason.CROSS_WINDOW_SYNC)
}
const authService = AuthService.getInstance(controller)
if (secretValue) {
// Secret was added or updated - restore auth info (login from another window)
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
} else {
// Secret was removed - handle logout for all windows
authService?.handleDeauth(LogoutReason.CROSS_WINDOW_SYNC)
}
}),
)
}
})
context.subscriptions.push({ dispose: unsubSecrets })
Logger.log(`[Cline] extension activated in ${performance.now() - activationStartTime} ms`)
@@ -706,11 +714,11 @@ if (IS_DEV) {
}
// VSCode-specific storage migrations
async function performStorageMigrations(context: ExtensionContext): Promise<void> {
async function cleanupLegacyVSCodeStorage(context: ExtensionContext): Promise<void> {
try {
cleanupOldApiKey()
await cleanupOldApiKey(context)
// Migrate is not done if the new storage does not have the lastShownAnnouncementId flag
const hasMigrated = StateManager.get().getGlobalStateKey("lastShownAnnouncementId")
const hasMigrated = context.globalState.get("lastShownAnnouncementId")
if (hasMigrated !== undefined) {
return
}

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