Compare commits

...

33 Commits

Author SHA1 Message Date
Tony Loehr e073277d70 Merge branch 'main' into cve-example 2026-02-23 13:47:42 -08:00
Bee 01547ba1f4 feat: add AgentConfigLoader for file-based agent configs (ENG-1547) (#9245)
* refactor: centralize tool handler registration and filter by allowed tools

- Create centralized toolHandlersMap for all tool handler instantiation
- Add registerToolHandlers method to register only allowed tools from config
- Filter subagent tools to only include allowed tools from allowedTools config
- Remove scattered tool handler registration logic in favor of single source of truth
- Improve maintainability by consolidating tool handler creation in one place

This refactoring ensures subagents only have access to explicitly allowed tools
and makes the tool registration process more maintainable and consistent.

It also improves separation of concerns by having the coordinator manage all tool handler registration, while the executor focuses on orchestration. The allowedTools parameter enables runtime filtering of available tools for different contexts.

Also update PromptRegistry to load synchronous and simplify variant lookup

- Convert async load() to synchronous, called in constructor as both loadVariants and loadComponents are not async functions
- Remove health check logic and loaded state tracking
- Extract getVariant() method with proper generic fallback
- Add getComponents() accessor and simplify component loading
- Convert variant/component loaders from async to synchronous
- Remove unnecessary await calls throughout the codebase
- Add PromptRegistry tests for variant resolution and components

* fix test

* feat: add AgentConfigLoader for file-based agent configs

Add AgentConfigLoader singleton to manage agent configurations loaded from
YAML files in the agents directory. Supports hot-reloading via file watcher,
validates config schema with Zod, and integrates with extension lifecycle
(StateManager initialization and tearDown disposal).

* add tests

* add missing export

* update tests

* feat(tools): implement dynamic tool registration for subagents

Updates the tool system to support dynamically registered subagents as individual tools.

- Modifies `ClineToolSet` to generate specific tool definitions for configured subagents via `AgentConfigLoader`.
- Updates `parseAssistantMessageV2` to use `getToolUseNames()` instead of a static list, enabling the parser to recognize dynamic tool tags.
- Replaces the generic `USE_SUBAGENTS` tool with specific subagent instances when available in the system prompt context.

* update config path and refine tool descriptions

- Relocate the subagent configuration directory from `~/.cline/data/agents` to `~/Documents/Cline/Agents` to improve user accessibility.
- Update subagent tool descriptions and parameter instructions in the system prompt to be more descriptive and helpful for the model.

* revert unrelated changes

* revert unrelated changes

* update unit test

* fix: await AgentConfigLoader initialization before StateManager completes

Ensure agent configs are fully loaded during StateManager initialization
by awaiting the `ready()` promise. Previously, `AgentConfigLoader` was
instantiated without waiting for the initial load to complete, causing
potential race conditions where configs might not be available when
needed.

- Add `initialLoadPromise` field to track the async initial load
- Expose a `ready()` method to allow callers to await initialization
- Await `AgentConfigLoader.getInstance().ready()` in StateManager

* set previousRequestTotalTokens
2026-02-23 13:26:41 -08:00
Robin Newhouse 7f1632f09f fix: prevent reasoning delta crash on usage-only stream chunks (#9432)
* fix: guard missing delta in reasoning streams

* test: isolate litellm prompt cache call-count assertions
2026-02-23 12:29:34 -08:00
Tony Loehr 5a2756d778 Merge branch 'main' into cve-example 2026-02-23 11:39:23 -08:00
Han Wang 1b0ab3d01b sambanova provider: update models list (#9479)
* Add changeset

* Allow temperature config

* update issues summary

* Update SambaNova docs

* Update list of sambanova models

* Update minimax m2.5

* remove 2 models

* Remove residual
2026-02-23 11:27:36 -08:00
Tony Loehr a41753c9a1 docs: add CVE scan sample to navigation, fix accordion labels, clarify --yolo flag 2026-02-23 11:21:41 -08:00
Chaitanya Eranki dce0902596 Oca Messages API implementation for new Claude Models (#9447)
* Made messages api changes

* Made changes

* Added changes

* Made maxTokens point to the right thing

* Removed deprecated max_tokens field

* Reverted the change

* Making an additional change to not cause any issues with chat completions

* reverting changes so we can make them in the backend

* Added changeset

* Fixed changes based on AI comments
2026-02-23 11:20:05 -08:00
Bee 9e7a30bd34 feat: preconnect websocket to reduce response latency (#9458)
Warm up the OpenAI WebSocket connection early in WebSocket mode to avoid handshake latency on the first response.create call. This introduces a responsesWsReadyPromise to track the connection state and prevent duplicate connection attempts while the initial connection is in flight.
2026-02-23 11:17:03 -08:00
Bee 2b1b1d1cf2 fix: restrict OpenAI tool ID transformation to native provider (#9459)
* fix: restrict OpenAI tool ID transformation to native provider

Update `convertToOpenAiMessages` and `transformToolCallId` to only apply tool ID transformations when the provider is explicitly set to `openai-native`. This prevents unintended ID modifications for other providers (like OpenRouter or local LLMs) that use the OpenAI format but may have different tool ID requirements or already provide compatible IDs.

* update tests

* transformToolCallIdForNativeApi
2026-02-23 11:16:55 -08:00
Max fcf3792f63 fix auth check for acp mode (#9491)
- acp code wasn't using the proper 'isAuthConfigured' method for
checking auth status

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-02-23 09:41:56 -08:00
shey-cline 0e833ade82 Add /q command to quit CLI (#9400)
* init

* changeset

* Apply suggestion from @greptile-apps[bot]

oops

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

* add tests

* add /q info to help panel

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-23 07:37:34 -08:00
Ara 4455db5198 Pull Cline's recommended from internal endpoint (#9376)
* feat(cline): fetch recommended models from API endpoint

* Adding 1m

* Adding 1m

* Adding 1m

* fix: harden model tag label handling and tab init

* fix(models): add retry-safe fetch, id canonicalization, and shared filtering

* chore: trigger PR head refresh

* refactor(models): remove canonical alias map for OpenRouter IDs

* refactor(webview): remove redundant cline fetch on mount

* Adding 1m
2026-02-22 22:28:55 -08: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
Max 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
Max 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
github-actions[bot] 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
156 changed files with 6519 additions and 2095 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"cline": minor
---
Adds Messages API support to Oracle Code Assist, adding functionality for Claude models
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add /q command to quit CLI
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix a streaming crash when a chunk has usage data but no `delta` by guarding reasoning field checks in provider handlers. Add regression tests for OpenRouter, Cline, Vercel AI Gateway, and Fireworks handlers to cover usage-only chunks.
+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.
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
fix acp auth check so acp mode can be used with more providers
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Update SambaNova Provider models list and add temperature for models
+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
```
+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
+2 -1
View File
@@ -16,7 +16,8 @@
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
"--disable-extension",
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
"${workspaceFolder}"
"${workspaceFolder}",
"--disable-extensions"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [3.66.0]
### Added
- Gemini-3.1 Pro Preview
## [3.65.0]
### Added
+10
View File
@@ -1,5 +1,15 @@
# cline
## 2.4.2
### Added
- Gemini-3.1 Pro Preview
### Patch Changes
- VSCode uses shared files for global, workspace and secret state.
## [2.4.1]
### Fixed
+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.4.1",
"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": {
+6 -51
View File
@@ -38,7 +38,6 @@ import {
} from "@shared/api"
import type { ClineAsk, ClineMessage as ClineMessageType } from "@shared/ExtensionMessage"
import { CLI_ONLY_COMMANDS, VSCODE_ONLY_COMMANDS } from "@shared/slashCommands"
import { ProviderToApiKeyMap } from "@shared/storage"
import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { ClineEndpoint } from "@/config.js"
import { Controller } from "@/core/controller"
@@ -53,12 +52,12 @@ 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"
import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js"
import { AcpTerminalManager } from "../acp/AcpTerminalManager.js"
import { isAuthConfigured } from "../index.js"
import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models"
import { CliContextResult, initializeCliContext } from "../vscode-context.js"
import { ClineSessionEmitter } from "./ClineSessionEmitter.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,
@@ -266,7 +265,7 @@ export class ClineAgent implements acp.Agent {
*/
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
// Check if authentication is required
const isAuthenticated = await this.isAuthConfigured()
const isAuthenticated = await isAuthConfigured()
if (!isAuthenticated) {
throw RequestError.authRequired()
}
@@ -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()
@@ -1146,48 +1146,6 @@ export class ClineAgent implements acp.Agent {
}
}
/**
* Check if the user has authentication configured.
* Returns true if they have either:
* - Cline provider with stored auth data
* - OpenAI Codex provider with OAuth credentials
* - BYO provider with an API key configured
*/
private async isAuthConfigured(): Promise<boolean> {
const stateManager = StateManager.get()
const mode = stateManager.getGlobalSettingsKey("mode") as string
const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider"
const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline"
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)
}
// For OpenAI Codex provider, check OAuth credentials
if (currentProvider === "openai-codex") {
openAiCodexOAuthManager.initialize(this.ctx.extensionContext)
return await openAiCodexOAuthManager.isAuthenticated()
}
// For BYO providers, check if the API key is configured
const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap]
if (!keyField) {
return false
}
const fields = Array.isArray(keyField) ? keyField : [keyField]
for (const field of fields) {
const value = await secretStorage.get(field)
if (value) {
return true
}
}
return false
}
/**
* Handle OpenAI Codex OAuth authentication flow.
*
@@ -1201,9 +1159,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()
+59 -4
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")
}
@@ -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>
)
}
+1 -1
View File
@@ -1172,7 +1172,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "exit") {
if (cmd.name === "exit" || cmd.name === "q") {
handleExit()
return
}
+4
View File
@@ -88,6 +88,10 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
{" "}
<Text color="white">/clear</Text> - Start a fresh task
</Text>
<Text>
{" "}
<Text color="white">/q</Text> - Quit Cline
</Text>
</Box>
<Text>
+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)) {
+112
View File
@@ -0,0 +1,112 @@
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 ink's useApp
const mockExit = vi.fn()
vi.mock("ink", async (importOriginal) => {
const actual = await importOriginal<typeof import("ink")>()
return {
...actual,
useApp: () => ({ exit: mockExit }),
}
})
// Mock child_process
vi.mock("child_process", () => ({
execSync: vi.fn().mockReturnValue(""),
exec: vi.fn(),
}))
// Mock dependencies
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
}))
vi.mock("@/core/storage/StateManager", () => ({
StateManager: {
get: () => ({
getGlobalSettingsKey: vi.fn().mockReturnValue("act"),
getGlobalStateKey: vi.fn().mockReturnValue([]),
getApiConfiguration: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("@/services/telemetry", () => ({
telemetryService: {
captureHostEvent: vi.fn(),
},
}))
vi.mock("@shared/services/Session", () => ({
Session: {
get: () => ({
getStats: vi.fn().mockReturnValue({}),
}),
},
}))
vi.mock("../context/TaskContext", () => ({
useTaskContext: () => ({
controller: {},
clearState: vi.fn(),
}),
useTaskState: () => ({
clineMessages: [],
}),
}))
vi.mock("../hooks/useStateSubscriber", () => ({
useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }),
}))
import { ChatView } from "./ChatView"
// Helper to wait for async state updates
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
describe("Quit Command (/q and /exit)", () => {
const mockOnExit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it("should exit the application when /q is selected from slash menu", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
await delay()
// Type /q
stdin.write("/q")
await delay()
// Press Enter
stdin.write("\r")
// handleExit has a 150ms timeout
await delay(200)
expect(mockExit).toHaveBeenCalled()
expect(mockOnExit).toHaveBeenCalled()
})
it("should exit the application when /exit is selected from slash menu", async () => {
const { stdin } = render(<ChatView onExit={mockOnExit} />)
await delay()
// Type /exit
stdin.write("/exit")
await delay()
// Press Enter
stdin.write("\r")
// handleExit has a 150ms timeout
await delay(200)
expect(mockExit).toHaveBeenCalled()
expect(mockOnExit).toHaveBeenCalled()
})
})
+72 -2
View File
@@ -29,6 +29,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 +39,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 +172,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> = {}
@@ -944,10 +948,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
@@ -1008,7 +1058,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 +1382,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 +1639,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 +1817,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
isWaitingForClineAuth ||
isShowingOcaEmployeeCheck ||
isWaitingForOcaAuth ||
isBedrockCustomFlow ||
isEditing
return (
+12 -6
View File
@@ -13,16 +13,22 @@ export interface FeaturedModel {
export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = {
recommended: [
{
id: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed, cost, and quality",
labels: ["BEST"],
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",
labels: ["NEW"],
description: "Most intelligent model for agents and coding",
labels: ["BEST"],
},
{
id: "openai/gpt-5.2-codex",
+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")
@@ -828,7 +797,7 @@ devCommand
* If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials
* and sets the flag accordingly.
*/
async function isAuthConfigured(): Promise<boolean> {
export async function isAuthConfigured(): Promise<boolean> {
const stateManager = StateManager.get()
// Check welcomeViewCompleted first - this is the single source of truth
@@ -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) {
+16 -1
View File
@@ -80,15 +80,18 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions):
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 +111,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
+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,
+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
+535
View File
@@ -0,0 +1,535 @@
---
title: "CVE Vulnerability Scanner"
description: "Automatically scan dependencies for CVEs and get AI-powered security reports using Cline CLI in GitHub Actions."
---
Turn noisy dependency audit output into actionable, prioritized security intelligence. This sample uses Cline CLI in GitHub Actions to scan for CVEs automatically — on every PR, on a weekly schedule, or on-demand — and post clear, prioritized reports with exact fix commands.
<Note>
**New to Cline CLI?** This sample assumes you understand Cline CLI basics and have completed the [Installation Guide](https://docs.cline.bot/cline-cli/installation). Start with the [GitHub RCA sample](./github-issue-rca) if you're looking for something simpler.
</Note>
## What It Does
| Trigger | What happens |
|---------|-------------|
| **PR opened** (dependency files changed) | Scans for CVEs, posts analysis as a PR comment |
| **Weekly schedule** (Monday 9am UTC) | Scans for newly disclosed CVEs, creates a GitHub Issue |
| **Manual trigger** | Scan with custom severity filter and optional auto-fix |
For each vulnerability found, Cline provides:
- **Plain-English impact** — what an attacker could actually do
- **Exploitability assessment** — is this theoretical or actively exploited?
- **Exact fix commands** — copy-paste remediation
- **Auto-fix safety** — which fixes are safe to apply without breaking changes
## Quick Start — Local Usage
Before setting up CI/CD, try it locally:
```bash
# Download the script
curl -O https://raw.githubusercontent.com/cline/cline/main/src/samples/cli/cve-scan/scan-cves.sh
chmod +x scan-cves.sh
# Run it (auto-detects npm/yarn/pnpm/pip)
./scan-cves.sh
```
Or skip the script and pipe directly:
```bash
npm audit --json | cline --yolo "Analyze these CVEs. For each: explain impact, assess exploitability, give exact fix commands. Prioritize by severity."
```
<Tip>
The `--yolo` flag (or `-y` for short) runs Cline in fully autonomous mode — it executes commands without waiting for approval. This is what makes piping and CI/CD workflows possible.
</Tip>
## Prerequisites
- **Cline CLI** installed and authenticated ([Installation Guide](https://docs.cline.bot/cline-cli/installation))
- **GitHub repository** with Actions enabled
- **API provider account** (Anthropic, OpenRouter, etc.) with API key added as a repository secret
## Setup
### 1. Copy the Workflow File
```bash
mkdir -p .github/workflows
curl -o .github/workflows/cline-cve-scan.yml \
https://raw.githubusercontent.com/cline/cline/main/src/samples/cli/cve-scan/cline-cve-scan.yml
```
<Accordion title="Click to view the complete cline-cve-scan.yml workflow">
```yaml
name: Cline CVE Scanner
on:
# Weekly scheduled scan — catches new CVEs in existing dependencies
schedule:
- cron: "0 9 * * 1" # Every Monday at 9am UTC
# PR scan — catch vulnerable dependencies before they merge
pull_request:
types: [opened, synchronize, ready_for_review]
paths:
- "package.json"
- "package-lock.json"
- "yarn.lock"
- "pnpm-lock.yaml"
- "requirements.txt"
- "Pipfile.lock"
- "pyproject.toml"
# Manual trigger with options
workflow_dispatch:
inputs:
severity:
description: "Minimum severity to report"
required: false
default: "all"
type: choice
options:
- all
- low
- medium
- high
- critical
auto_fix:
description: "Attempt safe auto-fixes"
required: false
default: false
type: boolean
concurrency:
group: cve-scan-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
cve-scan:
if: |
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install Cline CLI
run: npm install -g cline
- name: Configure Cline Authentication
run: |
cline auth --provider anthropic \
--apikey "${{ secrets.ANTHROPIC_API_KEY }}" \
--modelid claude-sonnet-4-5-20250929
- name: Determine scan parameters
id: params
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "severity=${{ inputs.severity }}" >> $GITHUB_OUTPUT
echo "auto_fix=${{ inputs.auto_fix }}" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" == "pull_request" ]; then
echo "severity=high" >> $GITHUB_OUTPUT
echo "auto_fix=false" >> $GITHUB_OUTPUT
else
echo "severity=all" >> $GITHUB_OUTPUT
echo "auto_fix=false" >> $GITHUB_OUTPUT
fi
if [ "${{ github.event_name }}" == "pull_request" ]; then
echo "output=pr-comment" >> $GITHUB_OUTPUT
echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
else
echo "output=github-issue" >> $GITHUB_OUTPUT
echo "pr_number=" >> $GITHUB_OUTPUT
fi
- name: Download CVE scan script
run: |
curl -sL https://raw.githubusercontent.com/${{ github.repository }}/main/scan-cves.sh -o scan-cves.sh \
|| cp src/samples/cli/cve-scan/scan-cves.sh scan-cves.sh 2>/dev/null \
|| true
chmod +x scan-cves.sh
- name: Run CVE scan with Cline
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
CLINE_COMMAND_PERMISSIONS: |
{
"allow": [
"npm audit *",
"yarn audit *",
"pnpm audit *",
"pip-audit *",
"gh issue create *",
"gh issue list *",
"gh pr comment *",
"cat *",
"echo *"
],
"deny": [
"rm *",
"sudo *",
"npm install *",
"npm publish *"
]
}
run: |
PR_FLAG=""
if [ -n "${{ steps.params.outputs.pr_number }}" ]; then
PR_FLAG="--pr ${{ steps.params.outputs.pr_number }}"
fi
AUTO_FIX_FLAG=""
if [ "${{ steps.params.outputs.auto_fix }}" == "true" ]; then
AUTO_FIX_FLAG="--auto-fix"
fi
./scan-cves.sh \
--scanner npm \
--output ${{ steps.params.outputs.output }} \
--severity ${{ steps.params.outputs.severity }} \
$PR_FLAG \
$AUTO_FIX_FLAG
```
</Accordion>
### 2. Add the Scan Script
Add `scan-cves.sh` to your repository root (or wherever the workflow downloads it from):
```bash
curl -O https://raw.githubusercontent.com/cline/cline/main/src/samples/cli/cve-scan/scan-cves.sh
chmod +x scan-cves.sh
```
<Accordion title="Click to view scan-cves.sh (simplified — see source for full version)">
```bash
#!/bin/bash
# scan-cves.sh — CVE vulnerability scanner powered by Cline CLI
#
# Usage:
# ./scan-cves.sh # Auto-detect scanner, stdout
# ./scan-cves.sh --output github-issue # Post as GitHub Issue
# ./scan-cves.sh --output pr-comment --pr 42 # Post as PR comment
# ./scan-cves.sh --scanner npm --severity critical # Filter by severity
# cat audit.json | ./scan-cves.sh --scanner custom # Custom scanner input
set -euo pipefail
SCANNER=""
OUTPUT="stdout"
SEVERITY="all"
PR_NUMBER=""
REPO="${GITHUB_REPOSITORY:-}"
AUTO_FIX="false"
CLINE_EXTRA_FLAGS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--scanner) SCANNER="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--pr) PR_NUMBER="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--auto-fix) AUTO_FIX="true"; shift ;;
--config) CLINE_EXTRA_FLAGS="$CLINE_EXTRA_FLAGS --config $2"; shift 2 ;;
--model) CLINE_EXTRA_FLAGS="$CLINE_EXTRA_FLAGS -m $2"; shift 2 ;;
-h|--help) echo "Usage: scan-cves.sh [--scanner npm|yarn|pnpm|pip|custom] [--output stdout|github-issue|pr-comment|file] [--severity all|critical|high|medium|low] [--pr N] [--repo owner/repo] [--auto-fix] [--config path] [--model id]"; exit 0 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# Auto-detect scanner from lockfiles
if [[ -z "$SCANNER" ]]; then
if [[ -f "package-lock.json" ]]; then SCANNER="npm"
elif [[ -f "yarn.lock" ]]; then SCANNER="yarn"
elif [[ -f "pnpm-lock.yaml" ]]; then SCANNER="pnpm"
elif [[ -f "requirements.txt" ]] || [[ -f "Pipfile.lock" ]]; then SCANNER="pip"
else echo "Error: Could not detect package manager." >&2; exit 1; fi
echo "Auto-detected scanner: $SCANNER" >&2
fi
# Run the scan
case "$SCANNER" in
npm) SCAN_OUTPUT=$(npm audit --json 2>/dev/null || true) ;;
yarn) SCAN_OUTPUT=$(yarn audit --json 2>/dev/null || true) ;;
pnpm) SCAN_OUTPUT=$(pnpm audit --json 2>/dev/null || true) ;;
pip) SCAN_OUTPUT=$(pip-audit --format json 2>/dev/null || true) ;;
custom) SCAN_OUTPUT=$(cat) ;;
*) echo "Unknown scanner: $SCANNER" >&2; exit 1 ;;
esac
if [[ -z "$SCAN_OUTPUT" ]]; then echo "✅ No vulnerabilities found!" >&2; exit 0; fi
# Build the security analyst prompt
PROMPT='You are a senior security analyst. Analyze these vulnerability scan results.
For EACH vulnerability: provide CVE ID, severity, affected package with versions,
plain-English impact, exploitability assessment, exact fix commands, and auto-fix safety.
Format as markdown with sections: 🔴 Critical, 🟠 High, 🟡 Medium, 🔵 Low,
Summary & Recommended Actions, Risk Assessment.
Omit empty severity sections. Flag actively exploited CVEs with ⚠️.'
if [[ "$SEVERITY" != "all" ]]; then
PROMPT="$PROMPT Focus ONLY on $SEVERITY severity or higher."
fi
# Run Cline analysis
echo "Analyzing vulnerabilities with Cline..." >&2
REPORT=$(echo "$SCAN_OUTPUT" | cline -y $CLINE_EXTRA_FLAGS "$PROMPT" 2>/dev/null)
# Output results
case "$OUTPUT" in
stdout) echo "$REPORT" ;;
github-issue) gh issue create --repo "$REPO" --title "🔒 CVE Report — $(date +%Y-%m-%d)" --body "$REPORT" --label "security,automated" ;;
pr-comment) gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$REPORT" ;;
file) echo "$REPORT" > "cve-report-$(date +%Y%m%d-%H%M%S).md" ;;
esac
```
The [full source script](https://github.com/cline/cline/blob/main/src/samples/cli/cve-scan/scan-cves.sh) includes additional features: a `--help` usage guide, `detect_scanner()` and `run_scan()` helper functions, a detailed heredoc security prompt with auto-fix instructions, and JSON output extraction via `jq`.
</Accordion>
### 3. Configure Secrets
1. Go to your repository **Settings** → **Secrets and variables** → **Actions**
2. Add a **New repository secret**:
- **Name:** `ANTHROPIC_API_KEY` (or match the provider in your workflow)
- **Value:** Your API key
### 4. Commit and Push
```bash
git add .github/workflows/cline-cve-scan.yml scan-cves.sh
git commit -m "Add Cline CVE scanner workflow"
git push
```
## Usage
### Automatic Triggers
Once set up, the scanner runs automatically:
- **Weekly (Monday 9am UTC):** Creates a GitHub Issue with a full vulnerability report
- **On PR:** Posts a comment on PRs that modify dependency files (only high+ severity)
### Manual Trigger
Go to **Actions** → **Cline CVE Scanner** → **Run workflow** to trigger a scan with custom options:
- Choose minimum severity level
- Optionally enable auto-fix for safe updates
### Local Usage
```bash
# Basic scan (auto-detects package manager)
./scan-cves.sh
# Save to file
./scan-cves.sh --output file
# Only critical CVEs
./scan-cves.sh --severity critical
# Post as GitHub Issue
./scan-cves.sh --output github-issue --repo myorg/myrepo
# Use a specific model
./scan-cves.sh --model claude-opus-4-5-20251101
# Pipe from any scanner (Trivy, Snyk, Grype, etc.)
trivy fs --format json . | ./scan-cves.sh --scanner custom
```
## How It Works
### Architecture
The scanner follows a three-layer design that keeps each concern separate and extensible:
```
┌─────────────────────────────────────────────────┐
│ Layer 1: Scanner Adapter (pluggable) │
│ npm audit | yarn audit | pip-audit | custom │
└────────────────────┬────────────────────────────┘
│ JSON vulnerability data
┌────────────────────▼────────────────────────────┐
│ Layer 2: Cline Security Analyst (reusable) │
│ AI-powered analysis via cline --yolo │
└────────────────────┬────────────────────────────┘
│ Markdown report
┌────────────────────▼────────────────────────────┐
│ Layer 3: Output Adapter (pluggable) │
│ stdout | GitHub Issue | PR comment | file │
└─────────────────────────────────────────────────┘
```
**Layer 1 (Scanner)** runs the appropriate audit command and produces JSON. You can swap scanners without touching the analysis logic.
**Layer 2 (Cline)** receives the raw vulnerability JSON and produces a prioritized, human-readable report. The security analyst prompt is self-contained and could be extracted into a Prompts Library entry.
**Layer 3 (Output)** delivers the report to its destination. Adding a new output target (e.g., Slack webhook) requires only a few lines in the output case statement.
### The Security Analyst Prompt
The core prompt instructs Cline to act as a senior security analyst. For each CVE, it provides:
1. **CVE ID & Severity** with color-coded sections
2. **Impact Assessment** in plain English (not just "RCE" — the actual attack vector)
3. **Exploitability** — is this a real-world risk or theoretical?
4. **Exact Fix** — copy-paste commands specific to your package manager
5. **Auto-fix Safety** — whether a simple version bump is safe
This prompt is **reusable** — it works with any JSON vulnerability data, not just npm audit. It could be published to the Cline Prompts Library for broader use.
### Security: Command Permissions
The workflow uses `CLINE_COMMAND_PERMISSIONS` to restrict Cline to safe, read-only operations:
```json
{
"allow": ["npm audit *", "gh issue create *", "gh pr comment *"],
"deny": ["rm *", "sudo *", "npm install *", "npm publish *"]
}
```
This ensures Cline can scan and report, but cannot modify your codebase or install packages — even in YOLO mode.
## Customization
### Different Package Managers
The script auto-detects from lockfiles, or you can specify explicitly:
```bash
./scan-cves.sh --scanner yarn
./scan-cves.sh --scanner pnpm
./scan-cves.sh --scanner pip
```
### Model Orchestration
Combine with [Model Orchestration](./model-orchestration) patterns for cost optimization:
```bash
# Cheap model for weekly triage
./scan-cves.sh --config ~/.cline-haiku --severity all
# Expensive model only for critical CVEs
./scan-cves.sh --config ~/.cline-opus --severity critical
```
### Custom Scanners
Pipe output from any scanner that produces JSON:
```bash
# Trivy (container/filesystem scanner)
trivy fs --format json . | ./scan-cves.sh --scanner custom
# Snyk
snyk test --json | ./scan-cves.sh --scanner custom
# Grype
grype dir:. -o json | ./scan-cves.sh --scanner custom
```
### Slack Notifications
Extend the output adapter by piping stdout to a Slack webhook:
```bash
REPORT=$(./scan-cves.sh)
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\": \"$REPORT\"}" \
"$SLACK_WEBHOOK_URL"
```
## Sample Output
Here's an example of a Cline-generated CVE report:
```markdown
# 🔒 CVE Vulnerability Report
**Scan Date:** 2026-02-11
**Scanner:** npm
**Total Vulnerabilities:** 4
## 🔴 Critical Vulnerabilities (1)
### CVE-2022-24999: qs
- **Severity:** Critical
- **Package:** `qs@6.7.0` → fix in `qs@6.11.0`
- **Impact:** Prototype pollution via crafted query strings. An attacker can inject
properties into Object.prototype, which in Express.js apps can lead to remote code
execution or denial of service.
- **Exploitability:** ⚠️ ACTIVELY EXPLOITED — public exploits available, any Express
app using query parsing is vulnerable.
- **Fix:** `npm install qs@6.11.0`
- **Auto-fix safe:** Yes
## 🟠 High Vulnerabilities (2)
### CVE-2023-28155: jsonwebtoken
- **Severity:** High
- **Package:** `jsonwebtoken@8.5.1` → fix in `jsonwebtoken@9.0.0`
- **Impact:** Insecure default algorithm allows an attacker to forge tokens if the
server doesn't explicitly set the algorithm. Could lead to authentication bypass.
- **Exploitability:** Medium — requires the server to not specify algorithms explicitly.
- **Fix:** `npm install jsonwebtoken@9.0.0`
- **Auto-fix safe:** No (major version bump, verify API compatibility)
### CVE-2023-45857: axios
- **Severity:** High
- **Package:** `axios@0.21.1` → fix in `axios@1.6.0`
- **Impact:** SSRF vulnerability allows specially crafted requests to access internal
services. An attacker controlling request URLs could probe internal infrastructure.
- **Exploitability:** Medium — requires user-controlled URL input.
- **Fix:** `npm install axios@1.6.0`
- **Auto-fix safe:** No (major version bump)
## 📋 Summary & Recommended Actions
1. **Immediate:** Update qs to 6.11.0 — critical, actively exploited, safe auto-fix
2. **This sprint:** Update jsonwebtoken to 9.0.0 and axios to 1.6.0 (test for breaking changes)
3. **Safe auto-fix command:** `npm audit fix`
## 📊 Risk Assessment
This project has 1 critical and 2 high severity vulnerabilities. The critical qs
vulnerability is actively exploited and should be fixed immediately — it's a safe
auto-fix with no breaking changes. The jsonwebtoken and axios updates are major
version bumps that require testing but should be scheduled for the current sprint.
Overall dependency hygiene needs improvement — consider running automated CVE scans
weekly to catch issues earlier.
```
## Related Samples
- **[GitHub PR Review](./github-pr-review)** — Automated code review on PRs
- **[GitHub Integration](./github-integration)** — Respond to issues with @cline
- **[Model Orchestration](./model-orchestration)** — Multi-model workflows for cost optimization
+8
View File
@@ -47,6 +47,14 @@ This section provides sample implementations that demonstrate various Cline CLI
>
Automatically review Pull Requests with AI. Configures Cline in GitHub Actions to analyze diffs, check for security issues, and post detailed reviews with inline code suggestions.
</Card>
<Card
title="CVE Vulnerability Scanner (Actions)"
icon="shield-halved"
href="/cline-cli/samples/cve-scan"
>
Automatically scan dependencies for CVEs and get AI-powered security reports. Runs on PRs, weekly schedules, or on-demand. Supports npm, yarn, pnpm, pip, and custom scanners like Trivy and Snyk.
</Card>
</CardGroup>
## Additional Resources
+1
View File
@@ -110,6 +110,7 @@
"cline-cli/samples/github-issue-rca",
"cline-cli/samples/github-integration",
"cline-cli/samples/github-pr-review",
"cline-cli/samples/cve-scan",
"cline-cli/samples/model-orchestration",
"cline-cli/samples/worktree-workflows"
]
+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
+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
-23
View File
@@ -14,29 +14,6 @@ SambaNova provides fast AI inference on custom-built hardware, hosting popular o
3. **Create a Key:** Generate a new API key.
4. **Copy the Key:** Copy the API key immediately and store it securely.
### Supported Models
Cline supports the following SambaNova models:
#### Meta Llama Models
- `Llama-4-Maverick-17B-128E-Instruct` - Llama 4 Maverick with vision support ($0.63/$1.80 per 1M tokens)
- `Llama-4-Scout-17B-16E-Instruct` - Llama 4 Scout ($0.40/$0.70 per 1M tokens)
- `Meta-Llama-3.3-70B-Instruct` (Default) - Versatile 70B model with 128K context ($0.60/$1.20 per 1M tokens)
- `Meta-Llama-3.1-405B-Instruct` - Largest Llama model ($5.00/$10.00 per 1M tokens)
- `Meta-Llama-3.1-8B-Instruct` - Compact 8B model ($0.10/$0.20 per 1M tokens)
- `Meta-Llama-3.2-1B-Instruct` - Ultra-compact 1B model ($0.04/$0.08 per 1M tokens)
- `Meta-Llama-3.2-3B-Instruct` - Small 3B model ($0.08/$0.16 per 1M tokens)
#### DeepSeek Models
- `DeepSeek-R1` - Reasoning model ($5.00/$7.00 per 1M tokens)
- `DeepSeek-R1-Distill-Llama-70B` - Distilled reasoning model ($0.70/$1.40 per 1M tokens)
- `DeepSeek-V3-0324` - General-purpose model ($3.00/$4.50 per 1M tokens)
- `DeepSeek-V3.1` - Latest DeepSeek with hybrid reasoning ($3.00/$4.50 per 1M tokens)
#### Qwen Models
- `Qwen3-32B` - Dense 32B model ($0.40/$0.80 per 1M tokens)
- `QwQ-32B` - Reasoning-focused Qwen model ($0.50/$1.00 per 1M tokens)
### Configuration in Cline
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
+22 -23
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.65.0",
"version": "3.66.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.65.0",
"version": "3.66.0",
"license": "Apache-2.0",
"workspaces": [
".",
@@ -46,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",
@@ -162,7 +162,7 @@
},
"cli": {
"name": "cline",
"version": "2.2.1",
"version": "2.4.1",
"cpu": [
"x64",
"arm64"
@@ -4033,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",
@@ -6115,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",
@@ -6138,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"
}
},
+8 -4
View File
@@ -2,7 +2,7 @@
"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.65.0",
"version": "3.66.0",
"icon": "assets/icons/icon.png",
"workspaces": [
".",
@@ -444,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": [
@@ -536,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",
+15
View File
@@ -19,6 +19,8 @@ service ModelsService {
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
// Refreshes and returns OpenRouter models
rpc refreshOpenRouterModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns recommended and free Cline models
rpc refreshClineRecommendedModelsRpc(EmptyRequest) returns (ClineRecommendedModelsResponse);
// Refreshes and returns Hugging Face models
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
@@ -113,6 +115,18 @@ message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
}
message ClineRecommendedModel {
string id = 1;
string name = 2;
string description = 3;
repeated string tags = 4;
}
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
}
// Request for fetching OpenAI models
message OpenAiModelsRequest {
Metadata metadata = 1;
@@ -448,6 +462,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)
})
+11 -12
View File
@@ -1,15 +1,15 @@
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 { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
@@ -32,7 +32,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 +44,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 +55,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 +65,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 +76,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 +107,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)
@@ -157,6 +155,7 @@ export async function tearDown(): Promise<void> {
// Clean up audio recording service to ensure no orphaned processes
audioRecordingService.cleanup()
AgentConfigLoader.getInstance()?.dispose()
PostHogClientProvider.getInstance().dispose()
telemetryService.dispose()
ErrorService.get().dispose()
-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 {
@@ -0,0 +1,59 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = Object.create(ClineHandler.prototype) as ClineHandler
;(handler as any).options = {}
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 17,
completion_tokens: 9,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 17,
outputTokens: 9,
totalCost: 0,
},
])
})
})
@@ -0,0 +1,55 @@
import "should"
import sinon from "sinon"
import { FireworksHandler } from "../fireworks"
describe("FireworksHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new FireworksHandler({
fireworksApiKey: "test-api-key",
fireworksModelId: "accounts/fireworks/models/llama-v3p1-8b-instruct",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 19,
completion_tokens: 4,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
inputTokens: 19,
outputTokens: 4,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
])
})
})
@@ -46,6 +46,8 @@ describe("LiteLlmHandler", () => {
}
beforeEach(() => {
fakeClient.chat.completions.create.resetHistory()
mockFetchForTesting(mockFetch, () => {
return new Promise((resolve) => {
doneMockingFetch = resolve
@@ -0,0 +1,60 @@
import "should"
import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { OpenRouterHandler } from "../openrouter"
describe("OpenRouterHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new OpenRouterHandler({
openRouterApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 13,
completion_tokens: 5,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
sinon.stub(handler, "getModel").returns({
id: "openai/gpt-4o-mini",
info: openRouterDefaultModelInfo,
})
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 13,
outputTokens: 5,
totalCost: 0,
},
])
})
})
@@ -1,8 +1,19 @@
import "should"
import { openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { VercelAIGatewayHandler } from "../vercel-ai-gateway"
describe("VercelAIGatewayHandler", () => {
afterEach(() => {
sinon.restore()
})
const createAsyncIterable = (data: any[] = []) => ({
[Symbol.asyncIterator]: async function* () {
yield* data
},
})
describe("getModel", () => {
it("should return configured model and info when both are provided", () => {
const customModelInfo = {
@@ -11,22 +22,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)
})
@@ -38,4 +49,46 @@ describe("VercelAIGatewayHandler", () => {
result.info.should.deepEqual(openRouterDefaultModelInfo)
})
})
describe("createMessage", () => {
it("should handle usage-only chunks when delta is missing", async () => {
const handler = new VercelAIGatewayHandler({
vercelAiGatewayApiKey: "test-api-key",
})
const fakeClient = {
chat: {
completions: {
create: sinon.stub().resolves(
createAsyncIterable([
{
choices: [{}],
usage: {
prompt_tokens: 11,
completion_tokens: 7,
},
},
]),
),
},
},
}
sinon.stub(handler as any, "ensureClient").returns(fakeClient as any)
const chunks: any[] = []
for await (const chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
chunks.push(chunk)
}
chunks.should.deepEqual([
{
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: 0,
inputTokens: 11,
outputTokens: 7,
totalCost: 0,
},
])
})
})
})
+10 -5
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"}`)
}
+7 -1
View File
@@ -168,7 +168,12 @@ export class ClineHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -183,6 +188,7 @@ export class ClineHandler implements ApiHandler {
See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
*/
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
+1 -1
View File
@@ -71,7 +71,7 @@ export class FireworksHandler implements ApiHandler {
}
}
if (reasoning || ("reasoning_content" in delta && delta.reasoning_content)) {
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
yield {
type: "reasoning",
reasoning: delta.content || ((delta as any).reasoning_content as string | undefined) || "",
+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,
}
+129 -15
View File
@@ -1,5 +1,6 @@
import { Anthropic, APIError as AnthropicAPIError } from "@anthropic-ai/sdk"
import { liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults, ModelInfo } from "@shared/api"
import OpenAI, { APIError, OpenAIError } from "openai"
import OpenAI, { APIError as OpenAIAPIError, OpenAIError } from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import {
@@ -16,10 +17,12 @@ import { ApiFormat } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { ApiHandler, type CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { sanitizeAnthropicMessages } from "../transform/anthropic-format"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
import { convertOpenAIToolsToAnthropicTools, handleAnthropicMessagesApiStreamResponse } from "../utils/messages_api_support"
import { handleResponsesApiStreamResponse } from "../utils/responses_api_support"
export interface OcaHandlerOptions extends CommonApiHandlerOptions {
@@ -35,14 +38,15 @@ export interface OcaHandlerOptions extends CommonApiHandlerOptions {
export class OcaHandler implements ApiHandler {
protected options: OcaHandlerOptions
protected client: OpenAI | undefined
protected openAIClient: OpenAI | undefined
protected anthropicClient: Anthropic | undefined
protected externalHeaders: Record<string, string> = {}
constructor(options: OcaHandlerOptions) {
this.options = options
}
protected initializeClient(options: OcaHandlerOptions): OpenAI {
protected initializeOpenAIClient(options: OcaHandlerOptions): OpenAI {
const externalHeaders = buildExternalBasicHeaders()
return new (class OCIOpenAI extends OpenAI {
protected override async prepareOptions(opts: any): Promise<void> {
@@ -63,7 +67,7 @@ export class OcaHandler implements ApiHandler {
error: Object | undefined,
message: string | undefined,
headers: any | undefined,
): APIError {
): OpenAIAPIError {
interface OciError {
code?: string
message?: string
@@ -94,23 +98,89 @@ export class OcaHandler implements ApiHandler {
})
}
protected ensureClient(): OpenAI {
if (!this.client) {
protected initializeAnthropicClient(options: OcaHandlerOptions): Anthropic {
const externalHeaders = buildExternalBasicHeaders()
return new (class OCIAnthropic extends Anthropic {
protected override async prepareOptions(opts: any): Promise<void> {
const token = await OcaAuthService.getInstance().getAuthToken()
if (!token) {
throw new OpenAIError("Unable to handle auth, Oracle Code Assist (OCA) access token is not available")
}
opts.headers ??= {}
// OCA Headers
const ociHeaders = await createOcaHeaders(token, options.taskId!)
opts.headers = { ...opts.headers, ...externalHeaders, ...ociHeaders }
Logger.log(`Making request with customer opc-request-id: ${opts.headers?.["opc-request-id"]}`)
return super.prepareOptions(opts)
}
protected override makeStatusError(
status: number | undefined,
error: Object | undefined,
message: string | undefined,
headers: any | undefined,
): AnthropicAPIError {
interface OciError {
code?: string
message?: string
}
let ociErrorMessage = message
if (typeof error === "object" && error !== null) {
try {
ociErrorMessage = JSON.stringify(error)
const ociErr = error as OciError
if (ociErr.code !== undefined && ociErr.message !== undefined) {
ociErrorMessage = `${ociErr.code}: ${ociErr.message}`
}
} catch {}
}
const opcRequestId = headers?.[OCI_HEADER_OPC_REQUEST_ID]
if (opcRequestId) {
ociErrorMessage += `\n(${OCI_HEADER_OPC_REQUEST_ID}: ${opcRequestId})`
}
const statusCode = typeof status === "number" ? status : 500
return super.makeStatusError(statusCode, error ?? {}, ociErrorMessage, headers)
}
})({
baseURL:
options.ocaBaseUrl ||
(options.ocaMode === "internal" ? DEFAULT_INTERNAL_OCA_BASE_URL : DEFAULT_EXTERNAL_OCA_BASE_URL),
apiKey: "noop",
fetch, // Use configured fetch with proxy support
})
}
protected ensureOpenAIClient(): OpenAI {
if (!this.openAIClient) {
if (!this.options.ocaModelId) {
throw new Error("Oracle Code Assist (OCA) model is not selected")
}
try {
this.client = this.initializeClient(this.options)
this.openAIClient = this.initializeOpenAIClient(this.options)
} catch (error) {
throw new Error(`Error creating Oracle Code Assist (OCA) client: ${error.message}`)
}
}
return this.client
return this.openAIClient
}
protected ensureAnthropicClient(): Anthropic {
if (!this.anthropicClient) {
if (!this.options.ocaModelId) {
throw new Error("Oracle Code Assist (OCA) model is not selected")
}
try {
this.anthropicClient = this.initializeAnthropicClient(this.options)
} catch (error) {
throw new Error(`Error creating Oracle Code Assist (OCA) client: ${error.message}`)
}
}
return this.anthropicClient
}
async getApiCosts(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const client = this.ensureClient()
const client = this.ensureOpenAIClient()
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
const token = await OcaAuthService.getInstance().getAuthToken()
if (!token) {
@@ -163,13 +233,15 @@ export class OcaHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
if (this.options.ocaModelInfo?.apiFormat == ApiFormat.OPENAI_RESPONSES) {
yield* this.createMessageResponsesApi(systemPrompt, messages, tools)
} else if (this.options.ocaModelInfo?.apiFormat == ApiFormat.ANTHROPIC_CHAT) {
yield* this.createMessageMessagesApi(systemPrompt, messages, tools)
} else {
yield* this.createMessageChatApi(systemPrompt, messages, tools)
}
}
async *createMessageChatApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const client = this.ensureOpenAIClient()
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
@@ -306,8 +378,8 @@ export class OcaHandler implements ApiHandler {
}
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const inputMessages = convertToOpenAIResponsesInput(messages).input
const client = this.ensureOpenAIClient()
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 +401,56 @@ 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))
}
async *createMessageMessagesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureAnthropicClient()
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
const budgetTokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = this.options.ocaModelInfo?.supportsReasoning && budgetTokens !== 0
let temperature: number | undefined = this.options.ocaModelInfo?.temperature ?? 0
const maxTokens: number | undefined = this.options.ocaModelInfo?.maxTokens || 8192
if (reasoningOn) {
temperature = 0
}
const anthropicTools = convertOpenAIToolsToAnthropicTools(tools)
const anthropicMessages = sanitizeAnthropicMessages(messages, this.options.ocaUsePromptCache ?? false)
const stream = await client.messages.create({
model: modelId,
max_tokens: maxTokens,
temperature: reasoningOn ? undefined : temperature,
system: [
{
text: systemPrompt,
type: "text",
cache_control: this.options.ocaUsePromptCache ? { type: "ephemeral" } : undefined,
},
],
messages: anthropicMessages,
stream: true,
tools: anthropicTools,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined,
})
yield* handleAnthropicMessagesApiStreamResponse(stream)
}
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()
}
+349 -35
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,16 @@ 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 responsesWsReadyPromise: Promise<UndiciWebSocket> | undefined
private websocketRequestInFlight = false
private abortController?: AbortController
constructor(options: OpenAiNativeHandlerOptions) {
this.options = options
@@ -73,7 +85,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 +104,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, "openai-native")],
},
{ signal: this.abortController?.signal },
)
yield {
type: "text",
text: response.choices[0]?.message.content || "",
@@ -115,7 +132,7 @@ export class OpenAiNativeHandler implements ApiHandler {
const stream = await client.chat.completions.create({
model: model.id,
messages: [{ role: systemRole, content: systemPrompt }, ...convertToOpenAiMessages(messages)],
messages: [{ role: systemRole, content: systemPrompt }, ...convertToOpenAiMessages(messages, "openai-native")],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: reasoningEffort,
@@ -152,26 +169,79 @@ 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)
// Convert messages to Responses API input format
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages)
// Warm websocket connection early in websocket mode so the first response.create avoids handshake latency.
if (usePreviousResponseId) {
this.preconnectResponsesWebsocket()
}
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool?.type === "function")
.map((tool: any) => ({
const { input, previousResponseId } = convertToOpenAIResponsesInput(messages, { usePreviousResponseId })
const responseTools = this.mapResponseTools(tools)
this.abortController = new AbortController()
const params = this.buildResponseCreateParams({
modelId: model.id,
systemPrompt,
input,
previousResponseId,
tools: responseTools,
})
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 preconnectResponsesWebsocket(): void {
void this.ensureResponsesWebsocket().catch((error) => {
Logger.debug("OpenAI websocket preconnect failed:", error)
this.closeResponsesWebsocket()
})
}
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 +251,261 @@ 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
}
if (this.responsesWsReadyPromise) {
return this.responsesWsReadyPromise
}
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(),
},
})
this.responsesWs = ws
const readyPromise = new Promise<UndiciWebSocket>((resolve, reject) => {
const cleanup = () => {
ws.removeEventListener("open", handleOpen)
ws.removeEventListener("error", handleError)
ws.removeEventListener("close", handleClose)
}
const handleOpen = () => {
cleanup()
resolve(ws)
}
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.responsesWsReadyPromise = readyPromise
try {
return await readyPromise
} catch (error) {
if (this.responsesWs === ws) {
this.responsesWs = undefined
}
throw error
} finally {
if (this.responsesWsReadyPromise === readyPromise) {
this.responsesWsReadyPromise = undefined
}
}
}
private closeResponsesWebsocket() {
this.responsesWsReadyPromise = undefined
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 +583,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 +592,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 +619,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 +651,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 +659,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 +681,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) {
+7 -1
View File
@@ -122,7 +122,12 @@ export class OpenRouterHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -132,6 +137,7 @@ export class OpenRouterHandler implements ApiHandler {
// OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
+2 -2
View File
@@ -52,7 +52,7 @@ export class SambanovaHandler implements ApiHandler {
const modelId = model.id.toLowerCase()
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
if (modelId.includes("deepseek") || modelId.includes("qwen3")) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
@@ -60,7 +60,7 @@ export class SambanovaHandler implements ApiHandler {
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
temperature: model.info.temperature ?? 0,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
+13 -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
}
}
@@ -849,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 -1
View File
@@ -84,7 +84,12 @@ export class VercelAIGatewayHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for models that don't support it (e.g., devstral, grok-4)
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
delta &&
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -93,6 +98,7 @@ export class VercelAIGatewayHandler implements ApiHandler {
// Reasoning details that can be passed back in API requests to preserve reasoning traces
if (
delta &&
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-expect-error-next-line
@@ -37,7 +37,7 @@ describe("Tool Call Parsing", () => {
},
]
const result = convertToOpenAiMessages(messages)
const result = convertToOpenAiMessages(messages, "openai-native")
result.should.have.length(1)
const msg = result[0] as any
@@ -64,7 +64,7 @@ describe("Tool Call Parsing", () => {
},
]
const result = convertToOpenAiMessages(messages)
const result = convertToOpenAiMessages(messages, "openai-native")
const msg = result[0] as any
msg.tool_calls[0].id.length.should.be.belowOrEqual(40)
+11 -3
View File
@@ -1,5 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProvider } from "@/shared/api"
import {
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
@@ -27,20 +28,25 @@ function isOpenAIResponseToolId(callId: string): boolean {
/**
* Transforms a tool ID to a consistent format for OpenAI's Chat Completions API.
* NOTE: We do not want to transform tool IDs for non-OpenAI providers that may have different requirements.
* This function MUST be used for both tool_calls[].id (assistant) and tool_call_id (tool result)
* to ensure they match - otherwise OpenAI will reject the request with:
* "Invalid parameter: 'tool_call_id' of 'xxx' not found in 'tool_calls' of previous message."
*
* @param toolId - The original tool ID from Cline/Anthropic format
* @param provider - The API provider that the OpenAI formatted messages will be sent to
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallId(toolId: string): string {
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
// OpenAI Responses API uses "fc_" prefix with 53 char length
// Convert these to "call_" prefix format for Chat Completions API
if (isOpenAIResponseToolId(toolId)) {
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
}
if (provider !== "openai-native") {
return toolId
}
// Ensure ID doesn't exceed max length
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
@@ -55,10 +61,12 @@ function transformToolCallId(toolId: string): string {
* into OpenAI's expected message structure, including tool_calls and tool_call_id fields.
*
* @param anthropicMessages - Array of ClineStorageMessage objects to be converted
* @param provider - Optional parameter to indicate the API provider, which may affect ID transformation logic
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
@@ -120,7 +128,7 @@ export function convertToOpenAiMessages(
role: "tool",
// The tool_call_id must match the id used in the assistant's tool_calls array.
// Use the same transformation logic as tool_calls to ensure IDs match.
tool_call_id: transformToolCallId(toolMessage.tool_use_id),
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
content: content,
})
})
@@ -233,7 +241,7 @@ export function convertToOpenAiMessages(
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallId(toolId),
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
@@ -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
+2 -2
View File
@@ -5,8 +5,8 @@ import {
OPENROUTER_PROVIDER_PREFERENCES,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
@@ -160,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
}
@@ -4,8 +4,8 @@ import {
ModelInfo,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@shared/api"
import { normalizeOpenaiReasoningEffort } from "@shared/storage/types"
import { shouldSkipReasoningForModel, supportsReasoningEffortForModel } from "@utils/model-utils"
@@ -100,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
}
+150
View File
@@ -0,0 +1,150 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ApiStream } from "../transform/stream"
export async function* handleAnthropicMessagesApiStreamResponse(
stream: AnthropicStream<Anthropic.RawMessageStreamEvent>,
): ApiStream {
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
switch (chunk?.type) {
case "message_start": {
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
}
case "message_delta":
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
signature: chunk.content_block.signature,
}
break
case "redacted_thinking":
// Content is encrypted, and we don't want to pass placeholder text back to the API
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
redacted_data: chunk.content_block.data,
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: chunk.content_block.text,
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "signature_delta":
if (chunk.delta.signature) {
yield {
type: "reasoning",
reasoning: "",
signature: chunk.delta.signature,
}
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
...lastStartedToolCall,
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
}
break
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
}
export function convertOpenAIToolsToAnthropicTools(tools?: OpenAITool[]): AnthropicTool[] | undefined {
if (!tools?.length) {
return undefined
}
const anthropicTools: AnthropicTool[] = []
for (const tool of tools) {
if (tool?.type !== "function" || !tool.function?.name) {
continue
}
const fn = tool.function
const hasSchemaObject = fn.parameters && typeof fn.parameters === "object"
const inputSchema = hasSchemaObject ? { ...fn.parameters } : {}
if (typeof (inputSchema as { type?: unknown }).type !== "string") {
;(inputSchema as { type: string }).type = "object"
}
anthropicTools.push({
name: fn.name,
description: fn.description || undefined,
input_schema: inputSchema as AnthropicTool["input_schema"],
})
}
return anthropicTools.length > 0 ? anthropicTools : undefined
}
@@ -1,4 +1,4 @@
import { ClineDefaultTool, toolUseNames } from "@shared/tools"
import { ClineDefaultTool, getToolUseNames } from "@shared/tools"
import { nanoid } from "nanoid"
import { AssistantMessageContent, TextStreamContent, ToolParamName, ToolUse, toolParamNames } from "." // Assuming types are defined in index.ts or a similar file
@@ -35,9 +35,9 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
let currentParamName: ToolParamName | undefined
// Precompute tags for faster lookups
const toolUseOpenTags = new Map<string, ClineDefaultTool>()
const toolUseOpenTags = new Map<string, string>()
const toolParamOpenTags = new Map<string, ToolParamName>()
for (const name of toolUseNames) {
for (const name of getToolUseNames()) {
toolUseOpenTags.set(`<${name}>`, name)
}
for (const name of toolParamNames) {
@@ -173,7 +173,7 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
// Start the new tool use
currentToolUse = {
type: "tool_use",
name: toolName,
name: toolName as ClineDefaultTool,
params: {},
partial: true, // Assume partial until closing tag is found
call_id: nanoid(8),
@@ -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
@@ -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)
}
}
+5 -7
View File
@@ -13,7 +13,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 { SETTINGS_DEFAULTS, 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 +23,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 +34,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 +117,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()
@@ -894,9 +894,6 @@ export class Controller {
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
const localAgentsRulesToggles = this.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
// See: https://github.com/cline/cline/pull/9348
const autoCondenseThreshold = SETTINGS_DEFAULTS.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
@@ -916,6 +913,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")
@@ -978,7 +976,6 @@ export class Controller {
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
favoritedModelIds,
autoCondenseThreshold,
backgroundCommandRunning: this.backgroundCommandRunning,
backgroundCommandTaskId: this.backgroundCommandTaskId,
// NEW: Add workspace information
@@ -1009,6 +1006,7 @@ export class Controller {
optOutOfRemoteConfig: this.stateManager.getGlobalSettingsKey("optOutOfRemoteConfig"),
doubleCheckCompletionEnabled,
banners,
welcomeBanners,
openAiCodexIsAuthenticated,
}
}
@@ -0,0 +1,133 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { ClineEnv } from "@/config"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from ".."
export interface ClineRecommendedModelData {
id: string
name: string
description: string
tags: string[]
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModelData[]
free: ClineRecommendedModelData[]
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
if (!raw || typeof raw !== "object") {
return null
}
const data = raw as Record<string, unknown>
if (typeof data.id !== "string" || data.id.length === 0) {
return null
}
return {
id: data.id,
name: typeof data.name === "string" && data.name.length > 0 ? data.name : data.id,
description: typeof data.description === "string" ? data.description : "",
tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : [],
}
}
function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModelsData | null {
if (!raw || typeof raw !== "object") {
return null
}
const data = raw as Record<string, unknown>
if (
(data.recommended !== undefined && !Array.isArray(data.recommended)) ||
(data.free !== undefined && !Array.isArray(data.free))
) {
return null
}
const recommendedRaw = Array.isArray(data.recommended) ? data.recommended : []
const freeRaw = Array.isArray(data.free) ? data.free : []
const recommended = recommendedRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
const free = freeRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
return { recommended, free }
}
export async function refreshClineRecommendedModels(_controller: Controller): Promise<ClineRecommendedModelsData> {
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
return inMemoryCache.data
}
if (pendingRefresh) {
return pendingRefresh
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheClineRecommendedModels()
} finally {
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/recommended-models`, getAxiosSettings())
const normalized = normalizeRecommendedModelsResponse(response.data)
if (!normalized) {
throw new Error("Invalid response data when fetching Cline recommended models")
}
result = normalized
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result))
Logger.log("Cline recommended models fetched and saved")
} catch (error) {
Logger.error("Error fetching Cline recommended models:", error)
try {
const fileExists = await fs
.access(clineRecommendedModelsFilePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
const parsed = JSON.parse(fileContents)
if (parsed) {
result = parsed
Logger.log("Loaded Cline recommended models from cache")
}
}
} catch (cacheError) {
Logger.error("Error reading Cline recommended models from cache:", cacheError)
}
}
// Avoid pinning empty results in memory for the full TTL after a transient API/cache miss.
if (result.recommended.length > 0 || result.free.length > 0) {
inMemoryCache = { data: result, timestamp: Date.now() }
}
return result
}
@@ -0,0 +1,29 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { ClineRecommendedModel, ClineRecommendedModelsResponse } from "@shared/proto/cline/models"
import type { Controller } from "../index"
import { refreshClineRecommendedModels } from "./refreshClineRecommendedModels"
export async function refreshClineRecommendedModelsRpc(
controller: Controller,
_request: EmptyRequest,
): Promise<ClineRecommendedModelsResponse> {
const models = await refreshClineRecommendedModels(controller)
return ClineRecommendedModelsResponse.create({
recommended: models.recommended.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
free: models.free.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
})
}
+23 -3
View File
@@ -7,6 +7,7 @@ import {
CHAT_COMPLETIONS_API,
DEFAULT_EXTERNAL_OCA_BASE_URL,
DEFAULT_INTERNAL_OCA_BASE_URL,
MESSAGES_API,
RESPONSES_API,
} from "@/services/auth/oca/utils/constants"
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
@@ -63,9 +64,16 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
}
const modelInfo = model.model_info
const supportedApiList = modelInfo.supported_api_list ?? [CHAT_COMPLETIONS_API]
const apiFormat: ApiFormat = supportedApiList.includes(RESPONSES_API)
? ApiFormat.OPENAI_RESPONSES
: ApiFormat.OPENAI_CHAT
let apiFormat: ApiFormat = ApiFormat.OPENAI_CHAT
if (supportsChatCompletions(supportedApiList)) {
apiFormat = ApiFormat.OPENAI_CHAT
} else if (supportsResponses(supportedApiList)) {
apiFormat = ApiFormat.OPENAI_RESPONSES
} else if (supportsMessages(supportedApiList)) {
apiFormat = ApiFormat.ANTHROPIC_CHAT
}
models[modelId] = OcaModelInfo.create({
maxTokens: model.litellm_params?.max_tokens || -1,
contextWindow: modelInfo.context_window,
@@ -179,3 +187,15 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
}
return OcaCompatibleModelInfo.create({ models })
}
function supportsChatCompletions(modelSupportedApiList: any): boolean {
return modelSupportedApiList.includes(CHAT_COMPLETIONS_API)
}
function supportsResponses(modelSupportedApiList: any): boolean {
return modelSupportedApiList.includes(RESPONSES_API)
}
function supportsMessages(modelSupportedApiList: any): boolean {
return modelSupportedApiList.includes(MESSAGES_API)
}
@@ -11,8 +11,8 @@ import {
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeOpus461mModelId,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet461mModelId,
openRouterClaudeSonnet451mModelId,
openRouterClaudeSonnet461mModelId,
} from "@/shared/api"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
@@ -119,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
}
@@ -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
}
+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) {
@@ -301,11 +301,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)
}
@@ -1,3 +1,4 @@
import { AgentConfigLoader } from "@core/task/tools/subagent/AgentConfigLoader"
import { CLINE_MCP_TOOL_IDENTIFIER, McpServer } from "@/shared/mcp"
import { ModelFamily } from "@/shared/prompts"
import { ClineDefaultTool } from "@/shared/tools"
@@ -104,6 +105,46 @@ export class ClineToolSet {
return enabledTools
}
private static getDynamicSubagentToolSpecs(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
if (context.subagentsEnabled !== true || context.isSubagentRun) {
return []
}
const requestedIds = variant.tools ? [...variant.tools] : []
const shouldIncludeSubagentTools = requestedIds.length === 0 || requestedIds.includes(ClineDefaultTool.USE_SUBAGENTS)
if (!shouldIncludeSubagentTools) {
return []
}
const agentConfigs = AgentConfigLoader.getInstance().getAllCachedConfigsWithToolNames()
return agentConfigs.map(({ toolName, config }) => ({
variant: variant.family,
id: ClineDefaultTool.USE_SUBAGENTS,
name: toolName,
description: `Use the "${config.name}" subagent: ${config.description}`,
contextRequirements: (ctx) => ctx.subagentsEnabled === true && !ctx.isSubagentRun,
parameters: [
{
name: "prompt",
required: true,
instruction: "Helpful instruction for the task that the subagent will perform.",
},
],
}))
}
public static getEnabledToolSpecs(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
const registeredTools = ClineToolSet.getEnabledTools(variant, context).map((tool) => tool.config)
const dynamicSubagentTools = ClineToolSet.getDynamicSubagentToolSpecs(variant, context)
const includesDynamicSubagents = dynamicSubagentTools.length > 0
const filteredRegistered = includesDynamicSubagents
? registeredTools.filter((tool) => tool.id !== ClineDefaultTool.USE_SUBAGENTS)
: registeredTools
return [...filteredRegistered, ...dynamicSubagentTools]
}
/**
* Get the appropriate native tool converter for the given provider
*/
@@ -136,8 +177,7 @@ export class ClineToolSet {
}
// Base set
const toolsets = ClineToolSet.getEnabledTools(variant, context)
const toolConfigs = toolsets.map((tool) => tool.config)
const toolConfigs = ClineToolSet.getEnabledToolSpecs(variant, context)
// MCP tools
const mcpServers = context.mcpHub?.getServers()?.filter((s) => s.disabled !== true) || []
@@ -132,38 +132,15 @@ export class PromptBuilder {
}
}
private static getEnabledTools(variant: PromptVariant, context: SystemPromptContext) {
let resolvedTools: ReturnType<typeof ClineToolSet.getTools> = []
// If the variant explicitly lists tools, resolve each by id with fallback to GENERIC
if (variant?.tools?.length) {
const requestedIds = [...variant.tools]
resolvedTools = ClineToolSet.getToolsForVariantWithFallback(variant.family, requestedIds)
// Preserve requested order
resolvedTools = requestedIds
.map((id) => resolvedTools.find((t) => t.config.id === id))
.filter((t): t is NonNullable<typeof t> => Boolean(t))
} else {
// Otherwise, use all tools registered for the variant, or generic if none
resolvedTools = ClineToolSet.getTools(variant.family)
// Sort by id for stable ordering
resolvedTools = resolvedTools.sort((a, b) => a.config.id.localeCompare(b.config.id))
}
// Filter by context requirements
const enabledTools = resolvedTools.filter(
(tool) => !tool.config.contextRequirements || tool.config.contextRequirements(context),
)
return enabledTools
private static getEnabledTools(variant: PromptVariant, context: SystemPromptContext): ClineToolSpec[] {
return ClineToolSet.getEnabledToolSpecs(variant, context)
}
public static async getToolsPrompts(variant: PromptVariant, context: SystemPromptContext) {
const enabledTools = PromptBuilder.getEnabledTools(variant, context)
const ids = enabledTools.map((tool) => tool.config.id)
return Promise.all(enabledTools.map((tool) => PromptBuilder.tool(tool.config, ids, context)))
const ids = enabledTools.map((tool) => tool.id)
return Promise.all(enabledTools.map((tool) => PromptBuilder.tool(tool, ids, context)))
}
public static tool(config: ClineToolSpec, registry: ClineDefaultTool[], context: SystemPromptContext): string {
@@ -171,7 +148,8 @@ export class PromptBuilder {
if (!config.parameters?.length && !config.description?.length) {
return ""
}
const title = `## ${config.id}`
const displayName = config.name || config.id
const title = `## ${displayName}`
const description = [`Description: ${config.description}`]
if (!config.parameters?.length) {
@@ -209,7 +187,7 @@ export class PromptBuilder {
title,
description.join("\n"),
PromptBuilder.buildParametersSection(filteredParams, context),
PromptBuilder.buildUsageSection(config.id, filteredParams),
PromptBuilder.buildUsageSection(displayName, filteredParams),
]
return sections.filter(Boolean).join("\n")
@@ -13,11 +13,11 @@ export class PromptRegistry {
private static instance: PromptRegistry
private variants: Map<string, PromptVariant> = new Map()
private components: ComponentRegistry = {}
private loaded: boolean = false
public nativeTools: ClineTool[] | undefined = undefined
private constructor() {
registerClineToolSets()
this.load()
}
static getInstance(): PromptRegistry {
@@ -30,42 +30,9 @@ export class PromptRegistry {
/**
* Load all prompts and components on initialization
*/
async load(): Promise<void> {
if (this.loaded) {
return
}
await Promise.all([this.loadVariants(), this.loadComponents()])
// Perform health check to ensure critical variants are available
this.performHealthCheck()
this.loaded = true
}
/**
* Perform health check to ensure registry is in a valid state
*/
private performHealthCheck(): void {
const criticalVariants = [ModelFamily.GENERIC]
const missingVariants = criticalVariants.filter((variant) => !this.variants.has(variant))
if (missingVariants.length > 0) {
Logger.error(`Registry health check failed: Missing critical variants: ${missingVariants.join(", ")}`)
Logger.error(`Available variants: ${Array.from(this.variants.keys()).join(", ")}`)
}
if (this.variants.size === 0) {
Logger.error("Registry health check failed: No variants loaded at all")
}
if (Object.keys(this.components).length === 0) {
Logger.warn("Registry health check warning: No components loaded")
}
Logger.log(
`Registry health check: ${this.variants.size} variants, ${Object.keys(this.components).length} components loaded`,
)
load(): void {
this.loadVariants()
this.loadComponents()
}
getModelFamily(context: SystemPromptContext) {
@@ -89,19 +56,10 @@ export class PromptRegistry {
Logger.log(`[Prompt variant] No matching variant found for model: ${modelId}, falling back to generic`)
return ModelFamily.GENERIC
}
/**
* Get prompt by matching against all registered variants
*/
async get(context: SystemPromptContext): Promise<string> {
await this.load()
// Loop through all registered variants to find the first one that matches
getVariant(context: SystemPromptContext): PromptVariant {
const family = this.getModelFamily(context)
// Fallback to generic variant if no match found
const variant = this.variants.get(family)
const variant = this.variants.get(family) || this.variants.get(ModelFamily.GENERIC)
if (!variant) {
// Enhanced error with debugging information
const availableVariants = Array.from(this.variants.keys())
@@ -110,7 +68,6 @@ export class PromptRegistry {
availableVariants,
variantsCount: this.variants.size,
componentsCount: Object.keys(this.components).length,
isLoaded: this.loaded,
}
Logger.error("Prompt variant lookup failed:", errorDetails)
@@ -118,9 +75,16 @@ export class PromptRegistry {
throw new Error(
`No prompt variant found for model '${context.providerInfo.model.id}' and no generic fallback available. ` +
`Available variants: [${availableVariants.join(", ")}]. ` +
`Registry state: loaded=${this.loaded}, variants=${this.variants.size}, components=${Object.keys(this.components).length}`,
`Registry state: variants=${this.variants.size}, components=${Object.keys(this.components).length}`,
)
}
return variant
}
/**
* Get prompt by matching against all registered variants
*/
async get(context: SystemPromptContext): Promise<string> {
const variant = this.getVariant(context)
// Hacky way to get native tools for the current variant - it's bad and ugly
this.nativeTools = ClineToolSet.getNativeTools(variant, context)
@@ -138,8 +102,6 @@ export class PromptRegistry {
context: SystemPromptContext,
isNextGenModelFamily?: boolean,
): Promise<string> {
await this.load()
// If isNextGenModelFamily is true, prioritize next-gen variant with the specified version
if (isNextGenModelFamily) {
const nextGenVariant = this.variants.get(ModelFamily.NEXT_GEN)
@@ -181,8 +143,6 @@ export class PromptRegistry {
context?: SystemPromptContext,
isNextGenModelFamily?: boolean,
): Promise<string> {
await this.load()
if (!context) {
throw new Error("Context is required for prompt building")
}
@@ -316,7 +276,7 @@ export class PromptRegistry {
/**
* Load all components from the components directory
*/
private async loadComponents(): Promise<void> {
private loadComponents(): void {
try {
// Register each component function
const componentMappings = getSystemPromptComponents()
+107 -89
View File
@@ -1,25 +1,25 @@
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 { AgentConfigLoader } from "../task/tools/subagent/AgentConfigLoader"
import {
getTaskHistoryStateFilePath,
readTaskHistoryFromState,
@@ -29,7 +29,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 +38,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 +60,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 +116,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 +133,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
@@ -139,6 +148,8 @@ export class StateManager {
await StateManager.instance.setupTaskHistoryWatcher()
StateManager.instance.isInitialized = true
await AgentConfigLoader.getInstance().ready()
} catch (error) {
Logger.error("[StateManager] Failed to initialize:", error)
throw error
@@ -291,8 +302,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 +383,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 +623,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 +649,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 +679,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 +718,7 @@ export class StateManager {
this.workspaceStateCache = {} as LocalState
this.taskStateCache = {}
this.remoteConfigCache = {} as GlobalStateAndSettings
this.sessionOverrideCache = {}
this.isInitialized = false
}
@@ -765,21 +793,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 +822,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)
}
/**
+8 -8
View File
@@ -45,6 +45,7 @@ export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
contextHistory: "context_history.json",
uiMessages: "ui_messages.json",
clineRecommendedModels: "cline_recommended_models.json",
openRouterModels: "openrouter_models.json",
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
groqModels: "groq_models.json",
@@ -261,14 +262,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 []
}
+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()
}
+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
+7 -62
View File
@@ -10,9 +10,8 @@ import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { McpHub } from "@services/mcp/McpHub"
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineContent } from "@shared/messages/content"
import { ClineDefaultTool } from "@shared/tools"
import { ClineDefaultTool, toolUseNames } 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"
@@ -23,31 +22,7 @@ import { ToolResponse } from "."
import { MessageStateHandler } from "./message-state"
import { TaskState } from "./TaskState"
import { AutoApprove } from "./tools/autoApprove"
import { AccessMcpResourceHandler } from "./tools/handlers/AccessMcpResourceHandler"
import { ActModeRespondHandler } from "./tools/handlers/ActModeRespondHandler"
import { ApplyPatchHandler } from "./tools/handlers/ApplyPatchHandler"
import { AskFollowupQuestionToolHandler } from "./tools/handlers/AskFollowupQuestionToolHandler"
import { AttemptCompletionHandler } from "./tools/handlers/AttemptCompletionHandler"
import { BrowserToolHandler } from "./tools/handlers/BrowserToolHandler"
import { CondenseHandler } from "./tools/handlers/CondenseHandler"
import { ExecuteCommandToolHandler } from "./tools/handlers/ExecuteCommandToolHandler"
import { GenerateExplanationToolHandler } from "./tools/handlers/GenerateExplanationToolHandler"
import { ListCodeDefinitionNamesToolHandler } from "./tools/handlers/ListCodeDefinitionNamesToolHandler"
import { ListFilesToolHandler } from "./tools/handlers/ListFilesToolHandler"
import { LoadMcpDocumentationHandler } from "./tools/handlers/LoadMcpDocumentationHandler"
import { NewTaskHandler } from "./tools/handlers/NewTaskHandler"
import { PlanModeRespondHandler } from "./tools/handlers/PlanModeRespondHandler"
import { ReadFileToolHandler } from "./tools/handlers/ReadFileToolHandler"
import { ReportBugHandler } from "./tools/handlers/ReportBugHandler"
import { SearchFilesToolHandler } from "./tools/handlers/SearchFilesToolHandler"
import { UseSubagentsToolHandler } from "./tools/handlers/SubagentToolHandler"
import { SummarizeTaskHandler } from "./tools/handlers/SummarizeTaskHandler"
import { UseMcpToolHandler } from "./tools/handlers/UseMcpToolHandler"
import { UseSkillToolHandler } from "./tools/handlers/UseSkillToolHandler"
import { WebFetchToolHandler } from "./tools/handlers/WebFetchToolHandler"
import { WebSearchToolHandler } from "./tools/handlers/WebSearchToolHandler"
import { WriteToFileToolHandler } from "./tools/handlers/WriteToFileToolHandler"
import { IPartialBlockHandler, SharedToolHandler, ToolExecutorCoordinator } from "./tools/ToolExecutorCoordinator"
import { IPartialBlockHandler, ToolExecutorCoordinator } from "./tools/ToolExecutorCoordinator"
import { ToolValidator } from "./tools/ToolValidator"
import { TaskConfig, validateTaskConfig } from "./tools/types/TaskConfig"
import { createUIHelpers } from "./tools/types/UIHelpers"
@@ -81,7 +56,6 @@ export class ToolExecutor {
constructor(
// Core Services & Managers
private context: vscode.ExtensionContext,
private taskState: TaskState,
private messageStateHandler: MessageStateHandler,
private api: ApiHandler,
@@ -160,7 +134,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"),
@@ -196,7 +169,7 @@ export class ToolExecutor {
postStateToWebview: async () => {},
reinitExistingTaskFromId: async () => {},
cancelTask: this.cancelTask,
updateTaskHistory: async (_: any) => [],
updateTaskHistory: async () => [],
executeCommandTool: this.executeCommandTool,
cancelRunningCommandTool: this.cancelRunningCommandTool,
doesLatestTaskCompletionHaveNewChanges: this.doesLatestTaskCompletionHaveNewChanges,
@@ -225,38 +198,10 @@ export class ToolExecutor {
*/
private registerToolHandlers(): void {
const validator = new ToolValidator(this.clineIgnoreController)
// Register all tool handlers
this.coordinator.register(new ListFilesToolHandler(validator))
this.coordinator.register(new ReadFileToolHandler(validator))
this.coordinator.register(new BrowserToolHandler())
this.coordinator.register(new AskFollowupQuestionToolHandler())
this.coordinator.register(new WebFetchToolHandler())
this.coordinator.register(new WebSearchToolHandler())
// Register WriteToFileToolHandler for all three file tools with proper typing
const writeHandler = new WriteToFileToolHandler(validator)
this.coordinator.register(writeHandler) // registers as "write_to_file" (ClineDefaultTool.FILE_NEW)
this.coordinator.register(new SharedToolHandler(ClineDefaultTool.FILE_EDIT, writeHandler))
this.coordinator.register(new SharedToolHandler(ClineDefaultTool.NEW_RULE, writeHandler))
this.coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
this.coordinator.register(new SearchFilesToolHandler(validator))
this.coordinator.register(new ExecuteCommandToolHandler(validator))
this.coordinator.register(new UseMcpToolHandler())
this.coordinator.register(new AccessMcpResourceHandler())
this.coordinator.register(new LoadMcpDocumentationHandler())
this.coordinator.register(new UseSkillToolHandler())
this.coordinator.register(new PlanModeRespondHandler())
this.coordinator.register(new ActModeRespondHandler())
this.coordinator.register(new NewTaskHandler())
this.coordinator.register(new AttemptCompletionHandler())
this.coordinator.register(new CondenseHandler())
this.coordinator.register(new SummarizeTaskHandler(validator))
this.coordinator.register(new ReportBugHandler())
this.coordinator.register(new ApplyPatchHandler(validator))
this.coordinator.register(new GenerateExplanationToolHandler())
this.coordinator.register(new UseSubagentsToolHandler())
// Register all tools via toolUseNames
for (const tool of toolUseNames) {
this.coordinator.registerByName(tool, validator)
}
}
/**
+15 -12
View File
@@ -60,7 +60,6 @@ import { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
import { USER_CONTENT_TAGS } from "@shared/messages/constants"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { SETTINGS_DEFAULTS } from "@shared/storage/state-keys"
import { ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import {
@@ -310,7 +309,7 @@ export class Task {
this.terminalManager.setTerminalOutputLineLimit(terminalOutputLineLimit)
this.terminalManager.setDefaultTerminalProfile(defaultTerminalProfile)
this.urlContentFetcher = new UrlContentFetcher(controller.context)
this.urlContentFetcher = new UrlContentFetcher()
this.browserSession = new BrowserSession(stateManager)
this.contextManager = new ContextManager()
this.streamHandler = new StreamResponseHandler()
@@ -531,7 +530,6 @@ export class Task {
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
this.toolExecutor = new ToolExecutor(
this.controller.context,
this.taskState,
this.messageStateHandler,
this.api,
@@ -2390,14 +2388,10 @@ export class Task {
}
}
} else {
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
// See: https://github.com/cline/cline/pull/9348
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold
shouldCompact = this.contextManager.shouldCompactContextWindow(
this.messageStateHandler.getClineMessages(),
this.api,
previousApiReqIndex,
autoCondenseThreshold,
)
// Edge case: summarize_task tool call completes but user cancels next request before it finishes.
@@ -2488,6 +2482,7 @@ export class Task {
await this.messageStateHandler.addToApiConversationHistory({
role: "user",
content: userContent,
ts: Date.now(),
})
telemetryService.captureConversationTurnEvent(this.ulid, providerId, model.id, "user", modelInfo.mode)
@@ -2530,7 +2525,7 @@ export class Task {
// if last message is a partial we need to update and save it
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
if (lastMessage && lastMessage.partial) {
if (lastMessage?.partial) {
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
lastMessage.partial = false
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
@@ -2576,6 +2571,7 @@ export class Task {
},
cost: taskMetrics.totalCost,
},
ts: Date.now(),
})
telemetryService.captureConversationTurnEvent(
@@ -2651,6 +2647,13 @@ export class Task {
try {
for await (const chunk of stream) {
if (
!this.taskState.taskFirstTokenTimeMs &&
(chunk.type === "text" || chunk.type === "reasoning" || chunk.type === "tool_calls")
) {
this.taskState.taskFirstTokenTimeMs = Math.max(0, Date.now() - this.taskState.taskStartTimeMs)
}
switch (chunk.type) {
case "usage":
this.streamHandler.setRequestId(chunk.id)
@@ -2946,6 +2949,7 @@ export class Task {
},
cost: taskMetrics.totalCost,
},
ts: Date.now(),
})
}
}
@@ -3038,6 +3042,7 @@ export class Task {
},
cost: taskMetrics.totalCost,
},
ts: Date.now(),
})
let response: ClineAskResponse
@@ -3518,9 +3523,7 @@ export class Task {
let shouldShowContextWindow = true
// For next-gen models, only show context window usage if it exceeds a certain threshold
if (isNextGenModel) {
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
// See: https://github.com/cline/cline/pull/9348
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold ?? 0.75
const autoCondenseThreshold = 0.75
const displayThreshold = autoCondenseThreshold - 0.15
const currentUsageRatio = lastApiReqTotalTokens / contextWindow
shouldShowContextWindow = currentUsageRatio >= displayThreshold
@@ -3534,7 +3537,7 @@ export class Task {
details += "\n\n# Current Mode"
const mode = this.stateManager.getGlobalSettingsKey("mode")
if (mode === "plan") {
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
details += `\nPLAN MODE\n${formatResponse.planModeInstructions()}`
} else {
details += "\nACT MODE"
}
+85 -3
View File
@@ -2,6 +2,32 @@ import type { ToolUse } from "@core/assistant-message"
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../index"
import { AccessMcpResourceHandler } from "./handlers/AccessMcpResourceHandler"
import { ActModeRespondHandler } from "./handlers/ActModeRespondHandler"
import { ApplyPatchHandler } from "./handlers/ApplyPatchHandler"
import { AskFollowupQuestionToolHandler } from "./handlers/AskFollowupQuestionToolHandler"
import { AttemptCompletionHandler } from "./handlers/AttemptCompletionHandler"
import { BrowserToolHandler } from "./handlers/BrowserToolHandler"
import { CondenseHandler } from "./handlers/CondenseHandler"
import { ExecuteCommandToolHandler } from "./handlers/ExecuteCommandToolHandler"
import { GenerateExplanationToolHandler } from "./handlers/GenerateExplanationToolHandler"
import { ListCodeDefinitionNamesToolHandler } from "./handlers/ListCodeDefinitionNamesToolHandler"
import { ListFilesToolHandler } from "./handlers/ListFilesToolHandler"
import { LoadMcpDocumentationHandler } from "./handlers/LoadMcpDocumentationHandler"
import { NewTaskHandler } from "./handlers/NewTaskHandler"
import { PlanModeRespondHandler } from "./handlers/PlanModeRespondHandler"
import { ReadFileToolHandler } from "./handlers/ReadFileToolHandler"
import { ReportBugHandler } from "./handlers/ReportBugHandler"
import { SearchFilesToolHandler } from "./handlers/SearchFilesToolHandler"
import { UseSubagentsToolHandler } from "./handlers/SubagentToolHandler"
import { SummarizeTaskHandler } from "./handlers/SummarizeTaskHandler"
import { UseMcpToolHandler } from "./handlers/UseMcpToolHandler"
import { UseSkillToolHandler } from "./handlers/UseSkillToolHandler"
import { WebFetchToolHandler } from "./handlers/WebFetchToolHandler"
import { WebSearchToolHandler } from "./handlers/WebSearchToolHandler"
import { WriteToFileToolHandler } from "./handlers/WriteToFileToolHandler"
import { AgentConfigLoader } from "./subagent/AgentConfigLoader"
import { ToolValidator } from "./ToolValidator"
import type { TaskConfig } from "./types/TaskConfig"
import type { StronglyTypedUIHelpers } from "./types/UIHelpers"
@@ -48,6 +74,39 @@ export class SharedToolHandler implements IFullyManagedTool {
*/
export class ToolExecutorCoordinator {
private handlers = new Map<string, IToolHandler>()
private dynamicSubagentHandlers = new Map<string, IToolHandler>()
private readonly toolHandlersMap: Record<ClineDefaultTool, (v: ToolValidator) => IToolHandler | undefined> = {
[ClineDefaultTool.ASK]: (_v: ToolValidator) => new AskFollowupQuestionToolHandler(),
[ClineDefaultTool.ATTEMPT]: (_v: ToolValidator) => new AttemptCompletionHandler(),
[ClineDefaultTool.BASH]: (v: ToolValidator) => new ExecuteCommandToolHandler(v),
[ClineDefaultTool.FILE_EDIT]: (v: ToolValidator) =>
new SharedToolHandler(ClineDefaultTool.FILE_EDIT, new WriteToFileToolHandler(v)),
[ClineDefaultTool.FILE_READ]: (v: ToolValidator) => new ReadFileToolHandler(v),
[ClineDefaultTool.FILE_NEW]: (v: ToolValidator) => new WriteToFileToolHandler(v),
[ClineDefaultTool.SEARCH]: (v: ToolValidator) => new SearchFilesToolHandler(v),
[ClineDefaultTool.LIST_FILES]: (v: ToolValidator) => new ListFilesToolHandler(v),
[ClineDefaultTool.LIST_CODE_DEF]: (v: ToolValidator) => new ListCodeDefinitionNamesToolHandler(v),
[ClineDefaultTool.BROWSER]: (_v: ToolValidator) => new BrowserToolHandler(),
[ClineDefaultTool.MCP_USE]: (_v: ToolValidator) => new UseMcpToolHandler(),
[ClineDefaultTool.MCP_ACCESS]: (_v: ToolValidator) => new AccessMcpResourceHandler(),
[ClineDefaultTool.MCP_DOCS]: (_v: ToolValidator) => new LoadMcpDocumentationHandler(),
[ClineDefaultTool.NEW_TASK]: (_v: ToolValidator) => new NewTaskHandler(),
[ClineDefaultTool.PLAN_MODE]: (_v: ToolValidator) => new PlanModeRespondHandler(),
[ClineDefaultTool.ACT_MODE]: (_v: ToolValidator) => new ActModeRespondHandler(),
[ClineDefaultTool.TODO]: (_v: ToolValidator) => undefined,
[ClineDefaultTool.WEB_FETCH]: (_v: ToolValidator) => new WebFetchToolHandler(),
[ClineDefaultTool.WEB_SEARCH]: (_v: ToolValidator) => new WebSearchToolHandler(),
[ClineDefaultTool.CONDENSE]: (_v: ToolValidator) => new CondenseHandler(),
[ClineDefaultTool.SUMMARIZE_TASK]: (_v: ToolValidator) => new SummarizeTaskHandler(_v),
[ClineDefaultTool.REPORT_BUG]: (_v: ToolValidator) => new ReportBugHandler(),
[ClineDefaultTool.NEW_RULE]: (v: ToolValidator) =>
new SharedToolHandler(ClineDefaultTool.NEW_RULE, new WriteToFileToolHandler(v)),
[ClineDefaultTool.APPLY_PATCH]: (_v: ToolValidator) => new ApplyPatchHandler(_v),
[ClineDefaultTool.GENERATE_EXPLANATION]: (_v: ToolValidator) => new GenerateExplanationToolHandler(),
[ClineDefaultTool.USE_SKILL]: (_v: ToolValidator) => new UseSkillToolHandler(),
[ClineDefaultTool.USE_SUBAGENTS]: (_v: ToolValidator) => new UseSubagentsToolHandler(),
}
/**
* Register a tool handler
@@ -56,11 +115,18 @@ export class ToolExecutorCoordinator {
this.handlers.set(handler.name, handler)
}
registerByName(toolName: ClineDefaultTool, validator: ToolValidator): void {
const handler = this.toolHandlersMap[toolName]?.(validator)
if (handler) {
this.register(handler)
}
}
/**
* Check if a handler is registered for the given tool
*/
has(toolName: string): boolean {
return this.handlers.has(toolName)
return this.getHandler(toolName) !== undefined
}
/**
@@ -71,14 +137,30 @@ export class ToolExecutorCoordinator {
if (toolName.includes(CLINE_MCP_TOOL_IDENTIFIER)) {
toolName = ClineDefaultTool.MCP_USE
}
return this.handlers.get(toolName)
const staticHandler = this.handlers.get(toolName)
if (staticHandler) {
return staticHandler
}
if (AgentConfigLoader.getInstance().isDynamicSubagentTool(toolName)) {
const existingHandler = this.dynamicSubagentHandlers.get(toolName)
if (existingHandler) {
return existingHandler
}
const handler = new SharedToolHandler(toolName as ClineDefaultTool, new UseSubagentsToolHandler())
this.dynamicSubagentHandlers.set(toolName, handler)
return handler
}
return undefined
}
/**
* Execute a tool through its registered handler
*/
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
const handler = this.handlers.get(block.name)
const handler = this.getHandler(block.name)
if (!handler) {
throw new Error(`No handler registered for tool: ${block.name}`)
}
@@ -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)
}
}
@@ -10,6 +10,7 @@ import { telemetryService } from "@/services/telemetry"
import { ClineDefaultTool } from "@/shared/tools"
import type { ToolResponse } from "../../index"
import { showNotificationForApproval } from "../../utils"
import { AgentConfigLoader } from "../subagent/AgentConfigLoader"
import { SubagentRunner } from "../subagent/SubagentRunner"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
@@ -19,6 +20,19 @@ import { ToolResultUtils } from "../utils/ToolResultUtils"
const MAX_SUBAGENT_PROMPTS = 5
const PROMPT_KEYS = ["prompt_1", "prompt_2", "prompt_3", "prompt_4", "prompt_5"] as const
function resolveConfiguredSubagentName(toolName: string): string | undefined {
return AgentConfigLoader.getInstance().resolveSubagentNameForTool(toolName)
}
function collectPrompts(block: ToolUse, configuredSubagentName?: string): string[] {
if (configuredSubagentName) {
const dynamicPrompt = block.params.prompt?.trim() || block.params.prompt_1?.trim()
return dynamicPrompt ? [dynamicPrompt] : []
}
return PROMPT_KEYS.map((key) => block.params[key]?.trim()).filter((prompt): prompt is string => !!prompt)
}
function excerpt(text: string | undefined, maxChars = 1200): string {
if (!text) {
return ""
@@ -36,13 +50,21 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
readonly name = ClineDefaultTool.USE_SUBAGENTS
getDescription(_block: ToolUse): string {
return "[subagents]"
const configuredSubagentName = resolveConfiguredSubagentName(_block.name)
return configuredSubagentName ? `[subagent: ${configuredSubagentName}]` : "[subagents]"
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const prompts = PROMPT_KEYS.map((key) => uiHelpers.removeClosingTag(block, key, block.params[key]?.trim()))
.map((prompt) => prompt?.trim())
.filter((prompt): prompt is string => !!prompt)
const configuredSubagentName = resolveConfiguredSubagentName(block.name)
const prompts = configuredSubagentName
? [
uiHelpers
.removeClosingTag(block, "prompt", block.params.prompt?.trim() || block.params.prompt_1?.trim())
?.trim(),
].filter((prompt): prompt is string => !!prompt)
: PROMPT_KEYS.map((key) => uiHelpers.removeClosingTag(block, key, block.params[key]?.trim()))
.map((prompt) => prompt?.trim())
.filter((prompt): prompt is string => !!prompt)
if (prompts.length === 0) {
return
@@ -67,14 +89,15 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
return formatResponse.toolError("Subagents are disabled. Enable them in Settings > Features to use this tool.")
}
const prompts = PROMPT_KEYS.map((key) => block.params[key]?.trim()).filter((prompt): prompt is string => !!prompt)
const configuredSubagentName = resolveConfiguredSubagentName(block.name)
const prompts = collectPrompts(block, configuredSubagentName)
if (prompts.length === 0) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError(this.name, "prompt_1")
return await config.callbacks.sayAndCreateMissingParamError(this.name, configuredSubagentName ? "prompt" : "prompt_1")
}
if (prompts.length > MAX_SUBAGENT_PROMPTS) {
if (!configuredSubagentName && prompts.length > MAX_SUBAGENT_PROMPTS) {
config.taskState.consecutiveMistakeCount++
return formatResponse.toolError(
`Too many subagent prompts provided (${prompts.length}). Maximum is ${MAX_SUBAGENT_PROMPTS}.`,
@@ -104,7 +127,9 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
)
} else {
showNotificationForApproval(
prompts.length === 1 ? "Cline wants to use a subagent" : `Cline wants to use ${prompts.length} subagents`,
prompts.length === 1
? `Cline wants to use ${configuredSubagentName ? `the '${configuredSubagentName}' subagent` : "a subagent"}`
: `Cline wants to use ${prompts.length} subagents`,
config.autoApprovalSettings.enableNotifications,
)
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_subagents", approvalBody, config)
@@ -187,7 +212,7 @@ export class UseSubagentsToolHandler implements IFullyManagedTool {
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "subagent")
await queueStatusUpdate("running", true)
const runners = prompts.map(() => new SubagentRunner(config))
const runners = prompts.map(() => new SubagentRunner(config, configuredSubagentName))
const abortPollInterval = setInterval(() => {
if (!config.taskState.abort) {
return
@@ -5,6 +5,7 @@ import { ClineDefaultTool } from "@shared/tools"
import { afterEach, describe, it } from "mocha"
import sinon from "sinon"
import { TaskState } from "../../../TaskState"
import { AgentConfigLoader } from "../../subagent/AgentConfigLoader"
import { SubagentRunner } from "../../subagent/SubagentRunner"
import type { TaskConfig } from "../../types/TaskConfig"
import { createUIHelpers } from "../../types/UIHelpers"
@@ -380,4 +381,62 @@ describe("SubagentToolHandler", () => {
assert.ok((result as string).includes("Failed: 1"))
assert.ok((result as string).includes("boom"))
})
it("runs configured subagent tools using the prompt parameter", async () => {
const { config } = createConfig({ autoApproveSafe: true, autoApproveAll: true })
const handler = new UseSubagentsToolHandler()
const dynamicToolName = "use_subagent_code_reviewer"
sinon.stub(AgentConfigLoader, "getInstance").returns({
resolveSubagentNameForTool: (toolName: string) => (toolName === dynamicToolName ? "code-reviewer" : undefined),
getCachedConfig: () => undefined,
} as unknown as AgentConfigLoader)
const runStub = sinon.stub(SubagentRunner.prototype, "run").resolves({
status: "completed",
result: "dynamic done",
stats: {
toolCalls: 1,
inputTokens: 2,
outputTokens: 3,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: 0.1,
contextTokens: 100,
contextWindow: 200000,
contextUsagePercentage: 0.05,
},
})
const result = await handler.execute(config, {
type: "tool_use",
name: dynamicToolName as ClineDefaultTool,
params: { prompt: "review this PR" },
partial: false,
})
assert.match(String(result), /dynamic done/)
sinon.assert.calledOnce(runStub)
assert.equal(runStub.firstCall.args[0], "review this PR")
})
it("requires prompt for configured subagent tools", async () => {
const { config, callbacks, taskState } = createConfig()
const handler = new UseSubagentsToolHandler()
const dynamicToolName = "use_subagent_code_reviewer"
sinon.stub(AgentConfigLoader, "getInstance").returns({
resolveSubagentNameForTool: (toolName: string) => (toolName === dynamicToolName ? "code-reviewer" : undefined),
getCachedConfig: () => undefined,
} as unknown as AgentConfigLoader)
const result = await handler.execute(config, {
type: "tool_use",
name: dynamicToolName as ClineDefaultTool,
params: {},
partial: false,
})
assert.equal(result, "missing")
assert.equal(taskState.consecutiveMistakeCount, 1)
sinon.assert.calledWithExactly(callbacks.sayAndCreateMissingParamError, ClineDefaultTool.USE_SUBAGENTS, "prompt")
})
})
@@ -0,0 +1,335 @@
import { parseYamlFrontmatter } from "@core/context/instructions/user-instructions/frontmatter"
import { Logger } from "@shared/services/Logger"
import { ClineDefaultTool, setDynamicToolUseNames } from "@shared/tools"
import chokidar, { type FSWatcher } from "chokidar"
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import { z } from "zod"
import { buildSubagentToolName } from "./SubagentToolName"
/** Default Directory for agent configurations: ~/Documents/Cline/Agents */
export const AGENTS_CONFIG_DIRECTORY_NAME = "Agents"
const SUBAGENT_DYNAMIC_TOOL_NAMESPACE = "subagent"
const AgentBaseConfigSchema = z.object({
name: z.string().trim().min(1),
description: z.string().trim().min(1),
tools: z.array(z.nativeEnum(ClineDefaultTool)).default([]),
modelId: z.string().trim().min(1),
systemPrompt: z.string().trim().min(1),
})
const AgentConfigFrontmatterSchema = z.object({
name: z.string().trim().min(1),
description: z.string().trim().min(1),
modelId: z.string().trim().min(1),
tools: z.union([z.string(), z.array(z.string())]).optional(),
})
export type AgentBaseConfig = z.infer<typeof AgentBaseConfigSchema>
function normalizeToolName(toolName: string): ClineDefaultTool {
const trimmed = toolName.trim()
if (!trimmed) {
throw new Error("Tool name cannot be empty.")
}
const asDefaultTool = trimmed as ClineDefaultTool
if (Object.values(ClineDefaultTool).includes(asDefaultTool)) {
return asDefaultTool
}
throw new Error(
`Unknown tool '${trimmed}'. Expected a ClineDefaultTool value (for example: read_file, list_files, search_files).`,
)
}
function parseTools(tools: string | string[] | undefined): ClineDefaultTool[] {
if (!tools) {
return []
}
const rawTools = Array.isArray(tools) ? tools : tools.split(",")
if (rawTools.length === 0) {
return []
}
return Array.from(new Set(rawTools.map(normalizeToolName)))
}
export function parseAgentConfigFromYaml(content: string): AgentBaseConfig {
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
if (parseError) {
throw new Error(`Failed to parse YAML frontmatter: ${parseError}`)
}
if (!hadFrontmatter) {
throw new Error("Missing YAML frontmatter block in agent config file.")
}
const parsedFrontmatter = AgentConfigFrontmatterSchema.parse(data)
const systemPrompt = body.trim()
if (!systemPrompt) {
throw new Error("Missing system prompt body in agent config file.")
}
return AgentBaseConfigSchema.parse({
name: parsedFrontmatter.name,
description: parsedFrontmatter.description,
modelId: parsedFrontmatter.modelId,
tools: parseTools(parsedFrontmatter.tools),
systemPrompt,
}) as AgentBaseConfig
}
export function getAgentsConfigPath(homeDir = os.homedir()): string {
return path.join(homeDir, "Documents", "Cline", AGENTS_CONFIG_DIRECTORY_NAME)
}
function normalizeAgentName(name: string): string {
return name.trim().toLowerCase()
}
function isYamlFile(filePath: string): boolean {
return /\.(yaml|yml)$/i.test(filePath)
}
export async function readAgentConfigsFromDisk(homeDir = os.homedir()): Promise<Map<string, AgentBaseConfig>> {
const agentsDirectoryPath = getAgentsConfigPath(homeDir)
const configs = new Map<string, AgentBaseConfig>()
try {
const entries = await fs.readdir(agentsDirectoryPath, { withFileTypes: true })
const yamlFiles = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter(isYamlFile)
.sort((a, b) => a.localeCompare(b))
Logger.debug(`[AgentConfigLoader] Found ${yamlFiles.length} YAML file(s).`)
await Promise.all(
yamlFiles.map(async (fileName) => {
const filePath = path.join(agentsDirectoryPath, fileName)
try {
const content = await fs.readFile(filePath, "utf8")
const parsed = parseAgentConfigFromYaml(content)
Logger.debug(`[AgentConfigLoader] Loaded agent config '${fileName}'`, parsed)
configs.set(normalizeAgentName(parsed.name), parsed)
} catch (error) {
Logger.error(`[AgentConfigLoader] Failed to parse agent config '${fileName}'`, error)
}
}),
)
return configs
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === "ENOENT") {
return configs
}
Logger.error("[AgentConfigLoader] Failed to read agent configs from disk", error)
throw error
}
}
export type AgentConfigChangeListener = (configs: ReadonlyMap<string, AgentBaseConfig>, error?: Error) => void
export class AgentConfigLoader {
private static instance?: AgentConfigLoader
private readonly homeDir: string
private readonly directoryPath: string
private readonly initialLoadPromise: Promise<void>
private watcher?: FSWatcher
private cachedConfigs = new Map<string, AgentBaseConfig>()
private cachedAgentToolNames = new Map<string, string>()
private cachedToolNameToAgentName = new Map<string, string>()
private listeners = new Set<AgentConfigChangeListener>()
private constructor(homeDir = os.homedir()) {
this.homeDir = homeDir
this.directoryPath = getAgentsConfigPath(homeDir)
this.initialLoadPromise = this.load()
.then(() => undefined)
.catch((error) => {
Logger.error("[AgentConfigLoader] Failed to load initial agent configs", error)
})
.finally(() =>
this.watch().catch((error) => Logger.error("[AgentConfigLoader] Failed to start watching agent configs", error)),
)
}
public static getInstance(homeDir = os.homedir()): AgentConfigLoader {
if (!AgentConfigLoader.instance) {
AgentConfigLoader.instance = new AgentConfigLoader(homeDir)
}
return AgentConfigLoader.instance
}
/**
* Test-only helper to clear singleton state between unit tests.
*/
public static async resetInstanceForTests(): Promise<void> {
if (!AgentConfigLoader.instance) {
return
}
await AgentConfigLoader.instance.dispose()
AgentConfigLoader.instance = undefined
}
public getConfigPath(): string {
return this.directoryPath
}
public async ready(): Promise<void> {
await this.initialLoadPromise
}
public getCachedConfig(subagentName?: string): AgentBaseConfig | undefined {
if (!subagentName?.trim()) {
return undefined
}
return this.cachedConfigs.get(normalizeAgentName(subagentName))
}
public getAllCachedConfigs(): ReadonlyMap<string, AgentBaseConfig> {
return new Map(this.cachedConfigs)
}
public getAllCachedConfigsWithToolNames(): Array<{ toolName: string; config: AgentBaseConfig }> {
const result: Array<{ toolName: string; config: AgentBaseConfig }> = []
for (const [normalizedName, config] of this.cachedConfigs.entries()) {
const toolName = this.cachedAgentToolNames.get(normalizedName)
if (toolName) {
result.push({ toolName, config })
}
}
return result
}
public resolveSubagentNameForTool(toolName?: string): string | undefined {
if (!toolName?.trim()) {
return undefined
}
const normalizedName = this.cachedToolNameToAgentName.get(toolName.trim())
if (!normalizedName) {
return undefined
}
return this.cachedConfigs.get(normalizedName)?.name
}
public isDynamicSubagentTool(toolName?: string): boolean {
if (!toolName?.trim()) {
return false
}
return this.cachedToolNameToAgentName.has(toolName.trim())
}
public async load(): Promise<ReadonlyMap<string, AgentBaseConfig>> {
const configs = await readAgentConfigsFromDisk(this.homeDir)
this.cachedConfigs = configs
this.rebuildDynamicToolMappings()
Logger.debug(`[AgentConfigLoader] Loaded ${configs.size} agent config(s) from disk.`)
return this.getAllCachedConfigs()
}
public async watch(listener?: AgentConfigChangeListener): Promise<void> {
if (listener) {
this.listeners.add(listener)
}
if (this.watcher) {
return
}
this.watcher = chokidar.watch(this.directoryPath, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 300,
pollInterval: 100,
},
})
this.watcher
.on("add", (filePath) => {
if (isYamlFile(filePath)) {
void this.reloadAndNotify()
}
})
.on("change", (filePath) => {
if (isYamlFile(filePath)) {
void this.reloadAndNotify()
}
})
.on("unlink", (filePath) => {
if (isYamlFile(filePath)) {
void this.reloadAndNotify()
}
})
.on("error", (error) => {
const watcherError = error instanceof Error ? error : new Error(String(error))
Logger.error("[AgentConfigLoader] Failed to watch agent configs directory", watcherError)
this.notify(this.cachedConfigs, watcherError)
})
}
public unwatch(listener: AgentConfigChangeListener): void {
this.listeners.delete(listener)
}
public async dispose(): Promise<void> {
if (!this.watcher) {
return
}
await this.watcher.close()
this.watcher = undefined
}
private async reloadAndNotify(): Promise<void> {
try {
await this.load()
this.notify(this.cachedConfigs)
} catch (error) {
const parseError = error instanceof Error ? error : new Error(String(error))
Logger.error("[AgentConfigLoader] Failed to reload agent configs", parseError)
this.notify(this.cachedConfigs, parseError)
}
}
private notify(configs: ReadonlyMap<string, AgentBaseConfig>, error?: Error): void {
for (const listener of this.listeners) {
listener(new Map(configs), error)
}
}
private rebuildDynamicToolMappings(): void {
const sortedConfigs = Array.from(this.cachedConfigs.entries()).sort((a, b) => a[0].localeCompare(b[0]))
const usedToolNames = new Set<string>()
const agentToolNames = new Map<string, string>()
const toolNameToAgentName = new Map<string, string>()
for (const [normalizedName, config] of sortedConfigs) {
const baseName = buildSubagentToolName(config.name)
let candidate = baseName
let suffix = 2
while (usedToolNames.has(candidate)) {
const suffixText = `_${suffix++}`
const maxBaseLength = Math.max(1, 64 - suffixText.length)
candidate = `${baseName.slice(0, maxBaseLength)}${suffixText}`
}
usedToolNames.add(candidate)
agentToolNames.set(normalizedName, candidate)
toolNameToAgentName.set(candidate, normalizedName)
}
this.cachedAgentToolNames = agentToolNames
this.cachedToolNameToAgentName = toolNameToAgentName
setDynamicToolUseNames(SUBAGENT_DYNAMIC_TOOL_NAMESPACE, Array.from(toolNameToAgentName.keys()))
}
}
@@ -0,0 +1,123 @@
import { buildApiHandler } from "@core/api"
import { PromptRegistry } from "@core/prompts/system-prompt"
import { ClineToolSet } from "@core/prompts/system-prompt/registry/ClineToolSet"
import type { SystemPromptContext } from "@core/prompts/system-prompt/types"
import { ClineDefaultTool } from "@shared/tools"
import { ApiProvider } from "@/shared/api"
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
import type { TaskConfig } from "../types/TaskConfig"
import type { AgentBaseConfig } from "./AgentConfigLoader"
import { AgentConfigLoader } from "./AgentConfigLoader"
export type AgentConfig = Partial<AgentBaseConfig>
export const SUBAGENT_DEFAULT_ALLOWED_TOOLS: ClineDefaultTool[] = [
ClineDefaultTool.FILE_READ,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_CODE_DEF,
ClineDefaultTool.BASH,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.ATTEMPT,
]
export const SUBAGENT_SYSTEM_SUFFIX = `\n\n# Subagent Execution Mode
You are running as a research subagent. Your job is to explore the codebase and gather information to answer the question.
Explore, read related files, trace through call chains, and build a complete picture before reporting back.
You can read files, list directories, search for patterns, list code definitions, and run commands.
Only use execute_command for readonly operations like ls, grep, git log, git diff, gh, etc.
When it makes sense, be clever about chaining commands or in-command scripting in execute_command to quickly get relevant context - and using pipes / filters to help narrow results.
Do not run commands that modify files or system state.
When you have a comprehensive answer, call the attempt_completion tool.
The attempt_completion result field is sent directly to the main agent, so put your full final findings there.
Unless the subagent prompt explicitly asks for detailed analysis, keep the result concise and focus on the files the main agent should read next.
Include a section titled "Relevant file paths" and list only file paths, one per line.
Do not include line numbers, summaries, or per-file explanations unless explicitly requested.
`
export class SubagentBuilder {
private readonly agentConfig: AgentConfig = {}
private readonly allowedTools: ClineDefaultTool[]
private readonly apiHandler: ReturnType<typeof buildApiHandler>
constructor(
private readonly baseConfig: TaskConfig,
subagentName?: string,
) {
const subagentConfig = AgentConfigLoader.getInstance().getCachedConfig(subagentName)
this.agentConfig = subagentConfig ?? {}
this.allowedTools = this.resolveAllowedTools(this.agentConfig.tools)
const mode = this.baseConfig.services.stateManager.getGlobalSettingsKey("mode")
const apiConfiguration = this.baseConfig.services.stateManager.getApiConfiguration()
const effectiveApiConfiguration = {
...apiConfiguration,
ulid: this.baseConfig.ulid,
} as Record<string, unknown>
this.applyModelOverride(effectiveApiConfiguration, mode, this.agentConfig.modelId)
this.apiHandler = buildApiHandler(effectiveApiConfiguration as typeof apiConfiguration, mode)
}
getApiHandler(): ReturnType<typeof buildApiHandler> {
return this.apiHandler
}
getAllowedTools(): ClineDefaultTool[] {
return this.allowedTools
}
buildSystemPrompt(generatedSystemPrompt: string): string {
const configuredSystemPrompt = this.agentConfig?.systemPrompt?.trim()
const systemPrompt = configuredSystemPrompt || generatedSystemPrompt
return `${systemPrompt}${this.buildAgentIdentitySystemPrefix()}${SUBAGENT_SYSTEM_SUFFIX}`
}
buildNativeTools(context: SystemPromptContext) {
const family = PromptRegistry.getInstance().getModelFamily(context)
const toolSets = ClineToolSet.getToolsForVariantWithFallback(family, this.allowedTools)
const filteredToolSpecs = toolSets
.map((toolSet) => toolSet.config)
.filter(
(toolSpec) =>
this.allowedTools.includes(toolSpec.id) &&
(!toolSpec.contextRequirements || toolSpec.contextRequirements(context)),
)
const converter = ClineToolSet.getNativeConverter(context.providerInfo.providerId, context.providerInfo.model.id)
return filteredToolSpecs.map((tool) => converter(tool, context))
}
private resolveAllowedTools(configuredTools?: ClineDefaultTool[]): ClineDefaultTool[] {
const sourceTools = configuredTools && configuredTools.length > 0 ? configuredTools : SUBAGENT_DEFAULT_ALLOWED_TOOLS
return Array.from(new Set([...sourceTools, ClineDefaultTool.ATTEMPT]))
}
private buildAgentIdentitySystemPrefix(): string {
const name = this.agentConfig?.name?.trim()
const description = this.agentConfig?.description?.trim()
if (!name && !description) {
return ""
}
const lines = ["# Agent Profile"]
if (name) {
lines.push(`Name: ${name}`)
}
if (description) {
lines.push(`Description: ${description}`)
}
return `${lines.join("\n")}\n\n`
}
private applyModelOverride(apiConfiguration: Record<string, unknown>, _mode: string, modelId?: string): void {
const trimmedModelId = modelId?.trim()
if (!trimmedModelId) {
return
}
const mode = _mode === "plan" ? "plan" : "act"
const provider = apiConfiguration[_mode === "plan" ? "planModeApiProvider" : "actModeApiProvider"] as ApiProvider
apiConfiguration[getProviderModelIdKey(provider as ApiProvider, mode)] = trimmedModelId
}
}
+38 -60
View File
@@ -1,38 +1,29 @@
import * as path from "node:path"
import { setTimeout as delay } from "node:timers/promises"
import { buildApiHandler } from "@core/api"
import type { ApiHandler, buildApiHandler } 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"
import { ClineToolSet } from "@core/prompts/system-prompt/registry/ClineToolSet"
import type { SystemPromptContext } from "@core/prompts/system-prompt/types"
import { StreamResponseHandler } from "@core/task/StreamResponseHandler"
import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock } from "@shared/messages"
import { ClineAssistantToolUseBlock, ClineStorageMessage, ClineTextContentBlock, ClineUserContent } from "@shared/messages"
import { Logger } from "@shared/services/Logger"
import { SETTINGS_DEFAULTS } from "@shared/storage/state-keys"
import type { ClineTool } from "@shared/tools"
import { ClineDefaultTool } from "@shared/tools"
import { isNextGenModelFamily } from "@utils/model-utils"
import * as path from "path"
import { ClineDefaultTool, ClineTool } from "@shared/tools"
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 { HostProvider } from "@/hosts/host-provider"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ClineError, ClineErrorType } from "@/services/error"
import { ApiFormat } from "@/shared/proto/cline/models"
import { calculateApiCostAnthropic } from "@/utils/cost"
import { isNextGenModelFamily } from "@/utils/model-utils"
import { TaskState } from "../../TaskState"
import { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import { SubagentBuilder } from "./SubagentBuilder"
const SUBAGENT_ALLOWED_TOOLS: ClineDefaultTool[] = [
ClineDefaultTool.FILE_READ,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_CODE_DEF,
ClineDefaultTool.BASH,
ClineDefaultTool.USE_SKILL,
ClineDefaultTool.ATTEMPT,
]
const MAX_EMPTY_ASSISTANT_RETRIES = 3
const MAX_INITIAL_STREAM_ATTEMPTS = 3
const INITIAL_STREAM_RETRY_BASE_DELAY_MS = 250
@@ -76,20 +67,6 @@ interface SubagentToolCall {
isNativeToolCall: boolean
}
const SUBAGENT_SYSTEM_SUFFIX = `\n\n# Subagent Execution Mode
You are running as a research subagent. Your job is to explore the codebase and gather information to answer the question.
Explore, read related files, trace through call chains, and build a complete picture before reporting back.
You can read files, list directories, search for patterns, list code definitions, and run commands.
Only use execute_command for readonly operations like ls, grep, git log, git diff, gh, etc.
When it makes sense, be clever about chaining commands or in-command scripting in execute_command to quickly get relevant context - and using pipes / filters to help narrow results.
Do not run commands that modify files or system state.
When you have a comprehensive answer, call the attempt_completion tool.
The attempt_completion result field is sent directly to the main agent, so put your full final findings there.
Unless the subagent prompt explicitly asks for detailed analysis, keep the result concise and focus on the files the main agent should read next.
Include a section titled "Relevant file paths" and list only file paths, one per line.
Do not include line numbers, summaries, or per-file explanations unless explicitly requested.
`
function serializeToolResult(result: unknown): string {
if (typeof result === "string") {
return result
@@ -222,12 +199,22 @@ function pushSubagentToolResultBlock(toolResultBlocks: any[], call: SubagentTool
}
export class SubagentRunner {
private readonly agent: SubagentBuilder
private readonly apiHandler: ApiHandler
private readonly allowedTools: ClineDefaultTool[]
private activeApiAbort: (() => void) | undefined
private abortRequested = false
private activeCommandExecutions = 0
private abortingCommands = false
constructor(private baseConfig: TaskConfig) {}
constructor(
private baseConfig: TaskConfig,
subagentName = "subagent",
) {
this.agent = new SubagentBuilder(baseConfig, subagentName)
this.apiHandler = this.agent.getApiHandler()
this.allowedTools = this.agent.getAllowedTools()
}
async abort(): Promise<void> {
this.abortRequested = true
@@ -299,11 +286,7 @@ export class SubagentRunner {
try {
const mode = this.baseConfig.services.stateManager.getGlobalSettingsKey("mode")
const apiConfiguration = this.baseConfig.services.stateManager.getApiConfiguration()
const effectiveApiConfiguration = {
...apiConfiguration,
ulid: this.baseConfig.ulid,
}
const api = buildApiHandler(effectiveApiConfiguration, mode)
const api = this.apiHandler
this.activeApiAbort = api.abort?.bind(api)
const providerId = (
@@ -338,9 +321,10 @@ export class SubagentRunner {
}
const promptRegistry = PromptRegistry.getInstance()
const systemPrompt = (await promptRegistry.get(context)) + SUBAGENT_SYSTEM_SUFFIX
const generatedSystemPrompt = await promptRegistry.get(context)
const systemPrompt = this.agent.buildSystemPrompt(generatedSystemPrompt)
const useNativeToolCalls = !!promptRegistry.nativeTools?.length
const nativeTools = useNativeToolCalls ? this.buildNativeTools(context) : undefined
const nativeTools = useNativeToolCalls ? this.agent.buildNativeTools(context) : undefined
const workspaceMetadataEnvironmentBlock = await this.getWorkspaceMetadataEnvironmentBlock()
if (useNativeToolCalls && (!nativeTools || nativeTools.length === 0)) {
@@ -559,7 +543,7 @@ export class SubagentRunner {
}
emptyAssistantResponseRetries = 0
const toolResultBlocks = [] as any[]
const toolResultBlocks = [] as ClineUserContent[]
for (const call of finalizedToolCalls) {
const toolName = call.name as ClineDefaultTool
const toolCallParams = toToolUseParams(call.input)
@@ -578,7 +562,7 @@ export class SubagentRunner {
return { status: "completed", result: completionResult, stats }
}
if (!SUBAGENT_ALLOWED_TOOLS.includes(toolName)) {
if (!this.allowedTools.includes(toolName)) {
const deniedResult = formatResponse.toolError(`Tool '${toolName}' is not available inside subagent runs.`)
pushSubagentToolResultBlock(toolResultBlocks, call, toolName, deniedResult)
continue
@@ -648,9 +632,17 @@ export class SubagentRunner {
private createSubagentTaskConfig(state: TaskState): TaskConfig {
const baseCallbacks = this.baseConfig.callbacks
const coordinator = new ToolExecutorCoordinator()
const validator = new ToolValidator(this.baseConfig.services.clineIgnoreController)
for (const tool of this.allowedTools) {
coordinator.registerByName(tool, validator)
}
return {
...this.baseConfig,
api: this.apiHandler,
coordinator,
taskState: state,
isSubagentExecution: true,
vscodeTerminalExecutionMode: "backgroundExec",
@@ -738,9 +730,7 @@ export class SubagentRunner {
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
const useAutoCondense = this.baseConfig.services.stateManager.getGlobalSettingsKey("useAutoCondense")
if (useAutoCondense && isNextGenModelFamily(modelId)) {
// Use default — the UI to adjust this is disabled and stored values may be corrupted.
// See: https://github.com/cline/cline/pull/9348
const autoCondenseThreshold = SETTINGS_DEFAULTS.autoCondenseThreshold
const autoCondenseThreshold = 0.75
const roundedThreshold = autoCondenseThreshold ? Math.floor(contextWindow * autoCondenseThreshold) : maxAllowedSize
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
return previousRequestTotalTokens >= thresholdTokens
@@ -795,16 +785,4 @@ export class SubagentRunner {
}
}
}
private buildNativeTools(context: SystemPromptContext): ClineTool[] {
const family = PromptRegistry.getInstance().getModelFamily(context)
const toolSets = ClineToolSet.getToolsForVariantWithFallback(family, SUBAGENT_ALLOWED_TOOLS)
const filteredToolSpecs = toolSets
.map((toolSet) => toolSet.config)
.filter((toolSpec) => !toolSpec.contextRequirements || toolSpec.contextRequirements(context))
const converter = ClineToolSet.getNativeConverter(context.providerInfo.providerId, context.providerInfo.model.id)
return filteredToolSpecs.map((tool) => converter(tool, context))
}
}
@@ -0,0 +1,45 @@
const SUBAGENT_TOOL_NAME_PREFIX = "use_subagent_"
const SUBAGENT_TOOL_NAME_MAX_LENGTH = 64
function sanitizeAgentName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "")
}
function hashString(value: string): string {
let hash = 2166136261
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(36)
}
function trimToolNameToMax(value: string): string {
if (value.length <= SUBAGENT_TOOL_NAME_MAX_LENGTH) {
return value
}
return value.slice(0, SUBAGENT_TOOL_NAME_MAX_LENGTH)
}
export function buildSubagentToolName(agentName: string): string {
const sanitized = sanitizeAgentName(agentName) || "agent"
const hashSuffix = hashString(agentName).slice(0, 6)
const base = `${SUBAGENT_TOOL_NAME_PREFIX}${sanitized}`
if (base.length <= SUBAGENT_TOOL_NAME_MAX_LENGTH) {
return base
}
const maxBodyLength = SUBAGENT_TOOL_NAME_MAX_LENGTH - SUBAGENT_TOOL_NAME_PREFIX.length - hashSuffix.length - 1
const body = sanitized.slice(0, Math.max(1, maxBodyLength))
return trimToolNameToMax(`${SUBAGENT_TOOL_NAME_PREFIX}${body}_${hashSuffix}`)
}
export function isSubagentToolName(toolName: string): boolean {
return toolName.startsWith(SUBAGENT_TOOL_NAME_PREFIX)
}
@@ -0,0 +1,152 @@
import { strict as assert } from "node:assert"
import fs from "fs/promises"
import { afterEach, describe, it } from "mocha"
import os from "os"
import * as path from "path"
import { ClineDefaultTool, getToolUseNames } from "@/shared/tools"
import { AgentConfigLoader, getAgentsConfigPath, parseAgentConfigFromYaml, readAgentConfigsFromDisk } from "../AgentConfigLoader"
async function createTempHomeDir(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), "agent-config-loader-"))
}
describe("AgentConfigLoader", () => {
const tempDirs: string[] = []
afterEach(async () => {
await AgentConfigLoader.resetInstanceForTests()
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })))
tempDirs.length = 0
})
it("parses an Agents.yaml frontmatter config and system prompt body", () => {
const content = `---
name: code-reviewer
description: Reviews code for quality and best practices
tools: read_file, list_files, search_files
modelId: sonnet
---
You are a code reviewer.`
const parsed = parseAgentConfigFromYaml(content)
assert.equal(parsed.name, "code-reviewer")
assert.equal(parsed.description, "Reviews code for quality and best practices")
assert.equal(parsed.modelId, "sonnet")
assert.deepEqual(parsed.tools, [ClineDefaultTool.FILE_READ, ClineDefaultTool.LIST_FILES, ClineDefaultTool.SEARCH])
assert.equal(parsed.systemPrompt, "You are a code reviewer.")
})
it("supports raw Cline tool ids in tools", () => {
const content = `---
name: cli-agent
description: Uses internal ids
tools:
- read_file
- list_files
modelId: sonnet
---
Prompt body`
const parsed = parseAgentConfigFromYaml(content)
assert.deepEqual(parsed.tools, [ClineDefaultTool.FILE_READ, ClineDefaultTool.LIST_FILES])
})
it("throws for unknown tools", () => {
const content = `---
name: bad-agent
description: bad
tools: Read, NotARealTool
modelId: sonnet
---
Prompt body`
assert.throws(() => parseAgentConfigFromYaml(content), /Unknown tool/)
})
it("returns an empty config map when the agents directory does not exist", async () => {
const tempHome = await createTempHomeDir()
tempDirs.push(tempHome)
const result = await readAgentConfigsFromDisk(tempHome)
assert.equal(result.size, 0)
})
it("loads all yaml/yml files from homeDir/.cline/data/agents", async () => {
const tempHome = await createTempHomeDir()
tempDirs.push(tempHome)
const directoryPath = getAgentsConfigPath(tempHome)
await fs.mkdir(directoryPath, { recursive: true })
await fs.writeFile(
path.join(directoryPath, "local-agent.yaml"),
`---
name: local-agent
description: local agent
tools: read_file
modelId: sonnet
---
Prompt body`,
"utf8",
)
await fs.writeFile(
path.join(directoryPath, "reviewer.yml"),
`---
name: reviewer
description: reviewer agent
tools: list_files
modelId: sonnet
---
Reviewer prompt`,
"utf8",
)
await fs.writeFile(path.join(directoryPath, "ignored.txt"), "not yaml", "utf8")
const loader = AgentConfigLoader.getInstance(tempHome)
await loader.load()
const localAgent = loader.getCachedConfig("local-agent")
const reviewer = loader.getCachedConfig("reviewer")
assert.equal(localAgent?.name, "local-agent")
assert.deepEqual(localAgent?.tools, [ClineDefaultTool.FILE_READ])
assert.equal(localAgent?.systemPrompt, "Prompt body")
assert.equal(reviewer?.name, "reviewer")
assert.deepEqual(reviewer?.tools, [ClineDefaultTool.LIST_FILES])
assert.equal(loader.getAllCachedConfigs().size, 2)
})
it("creates dynamic subagent tool mappings after loading configs", async () => {
const tempHome = await createTempHomeDir()
tempDirs.push(tempHome)
const directoryPath = getAgentsConfigPath(tempHome)
await fs.mkdir(directoryPath, { recursive: true })
await fs.writeFile(
path.join(directoryPath, "code-reviewer.yaml"),
`---
name: code reviewer
description: reviewer agent
tools: read_file
modelId: sonnet
---
Reviewer prompt`,
"utf8",
)
const loader = AgentConfigLoader.getInstance(tempHome)
await loader.load()
const withToolNames = loader.getAllCachedConfigsWithToolNames()
assert.equal(withToolNames.length, 1)
assert.equal(withToolNames[0].config.name, "code reviewer")
assert.equal(loader.resolveSubagentNameForTool(withToolNames[0].toolName), "code reviewer")
assert.equal(loader.isDynamicSubagentTool(withToolNames[0].toolName), true)
assert.ok(getToolUseNames().includes(withToolNames[0].toolName))
})
})
@@ -0,0 +1,166 @@
import { strict as assert } from "node:assert"
import * as api from "@core/api"
import { PromptRegistry } from "@core/prompts/system-prompt"
import { ClineToolSet } from "@core/prompts/system-prompt/registry/ClineToolSet"
import type { TaskConfig } from "@core/task/tools/types/TaskConfig"
import { afterEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineDefaultTool } from "@/shared/tools"
import { AgentConfigLoader } from "../AgentConfigLoader"
import { SUBAGENT_DEFAULT_ALLOWED_TOOLS, SUBAGENT_SYSTEM_SUFFIX, SubagentBuilder } from "../SubagentBuilder"
function createTaskConfig(mode: "act" | "plan", provider: string): TaskConfig {
return {
ulid: "ulid-123",
services: {
stateManager: {
getGlobalSettingsKey: (key: string) => (key === "mode" ? mode : undefined),
getApiConfiguration: () => ({
actModeApiProvider: provider,
planModeApiProvider: provider,
actModeApiModelId: "act-default",
planModeApiModelId: "plan-default",
actModeOpenAiModelId: "openai-act-default",
planModeOpenRouterModelId: "openrouter-plan-default",
}),
},
},
} as unknown as TaskConfig
}
describe("SubagentBuilder", () => {
afterEach(() => {
sinon.restore()
})
it("uses cached config by subagent name and applies act-mode provider model override", () => {
sinon.stub(AgentConfigLoader, "getInstance").returns({
getCachedConfig: (subagentName?: string) =>
subagentName === "cached-agent"
? {
name: "cached-agent",
description: "cached description",
tools: [ClineDefaultTool.LIST_FILES],
modelId: "gpt-5",
systemPrompt: "cached system prompt",
}
: undefined,
} as unknown as AgentConfigLoader)
const fakeHandler = { getModel: sinon.stub(), createMessage: sinon.stub() }
const buildApiHandlerStub = sinon.stub(api, "buildApiHandler").returns(fakeHandler as never)
const builder = new SubagentBuilder(createTaskConfig("act", "openai"), "cached-agent")
assert.equal(buildApiHandlerStub.callCount, 1)
const [effectiveApiConfig, selectedMode] = buildApiHandlerStub.firstCall.args
assert.equal(selectedMode, "act")
assert.equal((effectiveApiConfig as Record<string, unknown>).ulid, "ulid-123")
assert.equal((effectiveApiConfig as Record<string, unknown>).actModeOpenAiModelId, "gpt-5")
assert.equal((effectiveApiConfig as Record<string, unknown>).actModeApiModelId, "act-default")
assert.deepEqual(builder.getAllowedTools(), [ClineDefaultTool.LIST_FILES, ClineDefaultTool.ATTEMPT])
const prompt = builder.buildSystemPrompt("generated system prompt")
assert.match(prompt, /# Agent Profile/)
assert.match(prompt, /Name: cached-agent/)
assert.match(prompt, /Description: cached description/)
assert.match(prompt, /cached system prompt/)
assert.match(prompt, new RegExp(SUBAGENT_SYSTEM_SUFFIX.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
})
it("uses defaults when no cached config is provided", () => {
sinon.stub(AgentConfigLoader, "getInstance").returns({
getCachedConfig: () => undefined,
} as unknown as AgentConfigLoader)
sinon.stub(api, "buildApiHandler").returns({ getModel: sinon.stub(), createMessage: sinon.stub() } as never)
const builder = new SubagentBuilder(createTaskConfig("act", "anthropic"))
assert.deepEqual(builder.getAllowedTools(), SUBAGENT_DEFAULT_ALLOWED_TOOLS)
const prompt = builder.buildSystemPrompt("generated prompt")
assert.equal(prompt, `generated prompt${SUBAGENT_SYSTEM_SUFFIX}`)
})
it("applies plan-mode openrouter model override fields", () => {
sinon.stub(AgentConfigLoader, "getInstance").returns({
getCachedConfig: (subagentName?: string) =>
subagentName === "openrouter-agent"
? {
name: "openrouter-agent",
description: "openrouter plan agent",
tools: [ClineDefaultTool.FILE_READ],
modelId: "openrouter/custom-model",
systemPrompt: "plan system",
}
: undefined,
} as unknown as AgentConfigLoader)
const buildApiHandlerStub = sinon.stub(api, "buildApiHandler").returns({
getModel: sinon.stub(),
createMessage: sinon.stub(),
} as never)
new SubagentBuilder(createTaskConfig("plan", "openrouter"), "openrouter-agent")
const [effectiveApiConfig, selectedMode] = buildApiHandlerStub.firstCall.args
assert.equal(selectedMode, "plan")
assert.equal((effectiveApiConfig as Record<string, unknown>).planModeOpenRouterModelId, "openrouter/custom-model")
assert.equal((effectiveApiConfig as Record<string, unknown>).planModeApiModelId, "plan-default")
assert.equal((effectiveApiConfig as Record<string, unknown>).actModeApiModelId, "act-default")
})
it("builds native tools by filtering allowed ids and context requirements then converting", () => {
sinon.stub(AgentConfigLoader, "getInstance").returns({
getCachedConfig: (subagentName?: string) =>
subagentName === "tools-agent"
? {
name: "tools-agent",
description: "tool-limited",
tools: [ClineDefaultTool.LIST_FILES],
modelId: "sonnet",
systemPrompt: "tool prompt",
}
: undefined,
} as unknown as AgentConfigLoader)
sinon.stub(api, "buildApiHandler").returns({ getModel: sinon.stub(), createMessage: sinon.stub() } as never)
const getModelFamilyStub = sinon.stub(PromptRegistry.getInstance(), "getModelFamily").returns("test-family" as never)
const getToolsStub = sinon.stub(ClineToolSet, "getToolsForVariantWithFallback").returns([
{
config: {
id: ClineDefaultTool.LIST_FILES,
contextRequirements: () => true,
},
},
{
config: {
id: ClineDefaultTool.SEARCH,
contextRequirements: () => true,
},
},
{
config: {
id: ClineDefaultTool.ATTEMPT,
contextRequirements: () => false,
},
},
] as never)
const converter = sinon.stub().callsFake((tool: { id: string }) => ({ converted: tool.id }))
const getConverterStub = sinon.stub(ClineToolSet, "getNativeConverter").returns(converter as never)
const builder = new SubagentBuilder(createTaskConfig("act", "anthropic"), "tools-agent")
const context = {
providerInfo: {
providerId: "anthropic",
model: { id: "m1" },
},
} as never
const result = builder.buildNativeTools(context)
assert.equal(getModelFamilyStub.callCount, 1)
assert.equal(getToolsStub.callCount, 1)
assert.equal(getConverterStub.callCount, 1)
assert.deepEqual(result, [{ converted: ClineDefaultTool.LIST_FILES }])
})
})
@@ -1,6 +1,5 @@
import { strict as assert } from "node:assert"
import * as coreApi from "@core/api"
import { ContextManager } from "@core/context/context-management/ContextManager"
import * as skills from "@core/context/instructions/user-instructions/skills"
import { PromptRegistry } from "@core/prompts/system-prompt"
import type { TaskConfig } from "@core/task/tools/types/TaskConfig"
@@ -10,6 +9,7 @@ import { HostProvider } from "@/hosts/host-provider"
import { ApiFormat } from "@/shared/proto/cline/models"
import { ClineDefaultTool } from "@/shared/tools"
import { TaskState } from "../../../TaskState"
import { SubagentBuilder } from "../SubagentBuilder"
import { SubagentRunner } from "../SubagentRunner"
function initializeHostProvider() {
@@ -35,16 +35,7 @@ function initializeHostProvider() {
)
}
function createTaskConfig(
nativeToolCallEnabled: boolean,
options?: {
useAutoCondense?: boolean
autoCondenseThreshold?: number
},
): TaskConfig {
const useAutoCondense = options?.useAutoCondense ?? false
const autoCondenseThreshold = options?.autoCondenseThreshold ?? 0.75
function createTaskConfig(nativeToolCallEnabled: boolean): TaskConfig {
return {
taskId: "task-1",
ulid: "ulid-1",
@@ -52,6 +43,7 @@ function createTaskConfig(
mode: "act",
strictPlanModeEnabled: false,
yoloModeToggled: false,
doubleCheckCompletionEnabled: false,
vscodeTerminalExecutionMode: "backgroundExec",
enableParallelToolCalling: false,
isSubagentExecution: false,
@@ -67,6 +59,7 @@ function createTaskConfig(
supportsPromptCache: true,
},
}),
createMessage: sinon.stub().callsFake(async function* () {}),
},
services: {
stateManager: {
@@ -77,12 +70,6 @@ function createTaskConfig(
if (key === "customPrompt") {
return undefined
}
if (key === "useAutoCondense") {
return useAutoCondense
}
if (key === "autoCondenseThreshold") {
return autoCondenseThreshold
}
return undefined
},
getGlobalStateKey: (key: string) => (key === "nativeToolCallEnabled" ? nativeToolCallEnabled : undefined),
@@ -137,6 +124,21 @@ function createTaskConfig(
} as unknown as TaskConfig
}
function stubApiHandler(createMessage: sinon.SinonStub) {
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
} as never)
}
describe("SubagentRunner", () => {
afterEach(() => {
sinon.restore()
@@ -163,19 +165,16 @@ describe("SubagentRunner", () => {
content: Array<{ type?: string; [key: string]: unknown }>
}
assert.equal(assistantMessage.role, "assistant")
assert.ok(Array.isArray(assistantMessage.content))
const toolUse = assistantMessage.content.find((block) => block.type === "tool_use")
assert.ok(toolUse, "assistant message should include tool_use block")
assert.ok(toolUse)
assert.equal(toolUse.id, "toolu_subagent_1")
assert.equal(toolUse.name, ClineDefaultTool.LIST_FILES)
const userMessage = conversation[2] as { role: string; content: Array<{ type?: string; [key: string]: unknown }> }
assert.equal(userMessage.role, "user")
assert.ok(Array.isArray(userMessage.content))
const toolResult = userMessage.content.find((block) => block.type === "tool_result")
assert.ok(toolResult, "user message should include tool_result block")
assert.ok(toolResult)
assert.equal(toolResult.tool_use_id, "toolu_subagent_1")
yield {
@@ -195,29 +194,13 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = [{ name: "list_files" } as any]
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(SubagentBuilder.prototype, "buildNativeTools").returns([{ name: "list_files" }] as any)
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(true)
const runner = new SubagentRunner(config)
sinon
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
.returns([{ name: "list_files" }])
const runner = new SubagentRunner(createTaskConfig(true))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
@@ -246,7 +229,6 @@ describe("SubagentRunner", () => {
}
assert.equal(lastMessage.role, "user")
assert.ok(Array.isArray(lastMessage.content))
assert.ok(lastMessage.content.every((block) => block.type === "text"))
assert.equal(
lastMessage.content.some((block) => block.type === "tool_result"),
@@ -254,8 +236,14 @@ describe("SubagentRunner", () => {
)
yield {
type: "text",
text: "<attempt_completion><result>done</result></attempt_completion>",
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_complete_2",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
},
}
})
@@ -264,25 +252,12 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const runner = new SubagentRunner(createTaskConfig(false))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
@@ -292,9 +267,7 @@ describe("SubagentRunner", () => {
it("retries empty assistant turns with a no-tools-used nudge before failing", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
// Empty response turn
})
createMessage.onFirstCall().callsFake(async function* () {})
createMessage.onSecondCall().callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
const lastAssistant = conversation[1] as {
role: string
@@ -313,8 +286,14 @@ describe("SubagentRunner", () => {
assert.match(lastUser.content[0]?.text || "", /You did not use a tool in your previous response/i)
yield {
type: "text",
text: "<attempt_completion><result>done</result></attempt_completion>",
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_complete_3",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
},
}
})
@@ -323,25 +302,12 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const runner = new SubagentRunner(createTaskConfig(false))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
@@ -349,7 +315,7 @@ describe("SubagentRunner", () => {
assert.equal(createMessage.callCount, 2)
})
it("retries initial stream failures before failing the subagent", async () => {
it("retries initial stream failures before failing", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield* []
@@ -358,10 +324,16 @@ describe("SubagentRunner", () => {
)
})
createMessage.onSecondCall().callsFake(async function* () {
yield {
type: "text",
text: "<attempt_completion><result>done</result></attempt_completion>",
}
yield* []
throw new Error(
'{"code":"stream_initialization_failed","message":"Failed to create stream: failed to generate stream from Vercel: failed to send request"}',
)
})
createMessage.onThirdCall().callsFake(async function* () {
yield* []
throw new Error(
'{"code":"stream_initialization_failed","message":"Failed to create stream: failed to generate stream from Vercel: failed to send request"}',
)
})
const promptRegistry = PromptRegistry.getInstance()
@@ -369,119 +341,19 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const runner = new SubagentRunner(createTaskConfig(false))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 2)
assert.equal(result.status, "failed")
assert.equal(createMessage.callCount, 3)
})
it("compacts context and retries when initial stream fails with context window exceeded", async () => {
const createMessage = sinon.stub()
let compactedConversation: unknown[] | undefined
let preCompactionLength = 0
createMessage.onCall(0).callsFake(async function* () {
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_ctx_1",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(1).callsFake(async function* () {
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_ctx_2",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(2).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
preCompactionLength = conversation.length
yield* []
const contextError = new Error("context length exceeded")
;(contextError as Error & { status: number }).status = 400
throw contextError
})
createMessage.onCall(3).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
compactedConversation = conversation
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_ctx_complete",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
},
}
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = [{ name: "list_files" } as any]
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(true)
const runner = new SubagentRunner(config)
sinon
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
.returns([{ name: "list_files" }])
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 4)
assert.ok(compactedConversation)
assert.ok(compactedConversation.length < preCompactionLength)
})
it("fails context window errors when there is no compactable subagent context", async () => {
it("fails context window errors", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield* []
@@ -495,217 +367,25 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const runner = new SubagentRunner(createTaskConfig(false))
const result = await runner.run("Huge prompt", () => {})
assert.equal(result.status, "failed")
assert.equal(createMessage.callCount, 1)
})
it("proactively compacts before next request when prior usage exceeds threshold", async () => {
const createMessage = sinon.stub()
let postCompactionConversationLength = 0
createMessage.onCall(0).callsFake(async function* () {
yield {
type: "usage",
inputTokens: 160_000,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
}
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_threshold_1",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(1).callsFake(async function* () {
yield {
type: "usage",
inputTokens: 160_000,
outputTokens: 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
}
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_threshold_2",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onCall(2).callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
postCompactionConversationLength = conversation.length
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_threshold_complete",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
},
}
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(false, { useAutoCondense: true, autoCondenseThreshold: 0.75 })
const runner = new SubagentRunner(config)
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 3)
assert.equal(postCompactionConversationLength, 3)
})
it("skips truncation when file-read optimization is sufficient", () => {
const config = createTaskConfig(false)
const runner = new SubagentRunner(config)
const conversation = [{ role: "user", content: [{ type: "text", text: "hello" }] }] as any[]
const optimizeStub = sinon
.stub(
runner as unknown as {
optimizeConversationForContextWindow: () => { didOptimize: boolean; needToTruncate: boolean }
},
"optimizeConversationForContextWindow",
)
.returns({ didOptimize: true, needToTruncate: false })
const getNextTruncationRangeSpy = sinon.spy(ContextManager.prototype, "getNextTruncationRange")
const didCompact = (
runner as unknown as { compactConversationForContextWindow: (value: unknown[]) => boolean }
).compactConversationForContextWindow(conversation)
assert.equal(didCompact, true)
assert.equal(optimizeStub.calledOnce, true)
assert.equal(getNextTruncationRangeSpy.called, false)
})
it("falls back to non-native mode when native settings are enabled but variant has no native tools", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_3",
name: ClineDefaultTool.LIST_FILES,
arguments: JSON.stringify({ path: ".", recursive: false }),
},
},
}
})
createMessage.onSecondCall().callsFake(async function* (_systemPrompt: string, conversation: unknown[]) {
const lastMessage = conversation[conversation.length - 1] as {
role: string
content: Array<{ type?: string; [key: string]: unknown }>
}
assert.equal(lastMessage.role, "user")
assert.ok(Array.isArray(lastMessage.content))
assert.ok(lastMessage.content.every((block) => block.type === "text"))
assert.equal(
lastMessage.content.some((block) => block.type === "tool_result"),
false,
)
yield {
type: "text",
text: "<attempt_completion><result>done</result></attempt_completion>",
}
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
initializeHostProvider()
const config = createTaskConfig(true)
const runner = new SubagentRunner(config)
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(result.result, "done")
assert.equal(createMessage.callCount, 2)
})
it("builds subagent api handler with the parent task ulid", async () => {
it("uses the configured task api handler for subagent requests", async () => {
const createMessage = sinon.stub().callsFake(async function* () {
yield {
type: "tool_calls",
tool_call: {
function: {
id: "toolu_subagent_complete_2",
id: "toolu_subagent_complete_4",
name: ClineDefaultTool.ATTEMPT,
arguments: JSON.stringify({ result: "done" }),
},
@@ -718,33 +398,17 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = [{ name: "list_files" } as any]
return "system prompt"
})
const buildApiHandlerStub = sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(SubagentBuilder.prototype, "buildNativeTools").returns([{ name: "list_files" }] as any)
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(true)
const runner = new SubagentRunner(config)
sinon
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
.returns([{ name: "list_files" }])
const runner = new SubagentRunner(createTaskConfig(true))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
assert.equal(buildApiHandlerStub.called, true)
sinon.assert.calledWithMatch(buildApiHandlerStub, sinon.match({ ulid: "ulid-1" }), "act")
assert.equal(createMessage.callCount, 1)
})
it("includes workspace metadata only in the initial user message", async () => {
@@ -801,28 +465,13 @@ describe("SubagentRunner", () => {
promptRegistry.nativeTools = [{ name: "list_files" } as any]
return "system prompt"
})
sinon.stub(coreApi, "buildApiHandler").returns({
abort: sinon.stub(),
getModel: () => ({
id: "anthropic/claude-sonnet-4.5",
info: {
contextWindow: 200_000,
apiFormat: ApiFormat.ANTHROPIC_CHAT,
supportsPromptCache: true,
},
}),
createMessage,
})
sinon.stub(SubagentBuilder.prototype, "buildNativeTools").returns([{ name: "list_files" }] as any)
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const config = createTaskConfig(true)
const runner = new SubagentRunner(config)
sinon
.stub(runner as unknown as { buildNativeTools: () => unknown[] }, "buildNativeTools")
.returns([{ name: "list_files" }])
const runner = new SubagentRunner(createTaskConfig(true))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "completed")
-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 === "***") {

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