Compare commits

...
Author SHA1 Message Date
Saoud Rizwan bf2c00f653 refactor(cli): keep model reasoning helper local 2026-06-16 18:38:10 -07:00
Saoud Rizwan 4de25dc68a fix(cli): preserve off thinking on model reselect 2026-06-16 18:35:32 -07:00
Saoud Rizwan e3b53255e2 fix(cli): default thinking for reasoning models 2026-06-16 18:29:06 -07:00
Tomás Barreiro 7d1abb25a7 Update fixed model list (#11591) 2026-06-17 03:28:35 +02:00
Saoud Rizwan 628aaa0675 feat(cli): authorize plugin MCP OAuth during install (#11575) 2026-06-16 17:46:19 -07:00
Robin Newhouse af7fda87c0 ENG-2184 Add generic provider-request capture for SDK CLI (#11481)
* Add generic provider request capture

* Use per-request provider capture files
2026-06-16 16:49:46 -07:00
Tomás Barreiroandgreptile-apps[bot] 9af6ced896 Rename Cline Pass to ClinePass everywhere (#11584)
* Rename Cline Pass to ClinePass everywhere

* Update apps/vscode/src/utils/path.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-16 21:50:40 +02:00
Ahmad Shahzad a3258dd79a feat: update Fireworks AI model registry with latest platform offerings (#11554)
The VS Code extension's Fireworks model list is out of date compared to the current active models available on the Fireworks platform. This commit updates the registry to match the current model lineup, ensuring users can select from the latest available models.

Changes:
- Add Kimi K2.7 Code (accounts/fireworks/models/kimi-k2p7-code)
- Add Kimi K2.7 Code Fast (accounts/fireworks/routers/kimi-k2p7-code-fast)
- Add Qwen 3.7 Plus (accounts/fireworks/models/qwen3p7-plus)
- Add MiniMax M3 (accounts/fireworks/models/minimax-m3)
- Remove deprecated Kimi K2.5 (accounts/fireworks/models/kimi-k2p5)
- Remove deprecated MiniMax M2.5 (accounts/fireworks/models/minimax-m2p5)
- Remove deprecated Qwen 3.6 Plus (accounts/fireworks/models/qwen3p6-plus)

The default model remains accounts/fireworks/models/kimi-k2p6.

Files:
- apps/vscode/src/shared/api.ts
2026-06-16 14:23:55 +02:00
Saoud Rizwan ebb05ed963 feat: add MCP support to plugins (#11516)
* feat: add MCP support to plugins

* fix: address plugin MCP review comments

* feat: sync plugin MCP servers to settings

* fix: tighten plugin MCP settings behavior

* fix: isolate plugin MCP sync failures

* fix: prune plugin MCP cleanup paths

* fix: surface plugin MCP re-enable failures

* fix: surface plugin MCP install sync failures

* fix: order plugin MCP state transitions
2026-06-15 19:44:46 -07:00
Tomás Barreiro 8d03d176f2 Fix WebView env replacing (#11574)
* Fix WebView env replacing

* fix node env resolution

* fix node env resolution

* fix node env resolution

* Fix platform reference

* Fix platform reference
2026-06-16 04:09:32 +02:00
MaxandMax Paulus 🥪 32b3cfc081 fix hugging face url (#11567)
hugging face inference url was incorrect

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-15 14:26:47 -07:00
Tomás Barreiro a6500a07b4 Enable feature flags on vscode (#11566)
* Enable feature flags on vscode

* Remove comment

* refactor

* fix
2026-06-15 22:52:21 +02:00
MaxandMax Paulus 🥪 81384089c4 allow dynamic models ids in huggingface provider (#11563)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-15 10:22:31 -07:00
Robin Newhouse 6364792c47 fix(cli): isolate history resume renderer ENG-2190 (#11502)
* fix(cli): isolate history resume renderer

* fix(cli): avoid duplicate history resume signals
2026-06-15 10:04:23 -07:00
Tomás Barreiro 40fc8879f1 Fix Postprotos modifying unrelated files (#11557) 2026-06-15 08:59:29 -07:00
e91cba4045 fix(sdk): search output cap + bash executor fixes (follow-up to #11480) (#11504)
* fix(sdk): search output cap + bash executor fixes (follow-up to #11480)

Slimmed from the original revision: the aggregate per-call output budget
is deferred to its own follow-up PR. What remains:

- cap search_codebase output at 48k chars per query with a middle-cut
  notice teaching the model to narrow the pattern (robinnewhouse's
  finding on #11480 — search was the last uncapped tool)
- rename bash executor maxOutputBytes -> maxOutputChars; the limit was
  always enforced in characters. Deprecated alias retained; stale
  @default annotation fixed
- flush the rolling collector's StringDecoder at end-of-stream so
  trailing incomplete multibyte sequences are not silently dropped
  (greptile's finding on #11480)
- decouple output-limits comments from MessageBuilder's specific
  backstop value; the durable invariant is that truncation notices live
  in the preserved head/tail of an entry

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

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/ygz2qho62ub6o8v1zhjvdktt

* test(sdk): cover search output cap

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-14 19:22:39 -07:00
Robin NewhouseandSaoud Rizwan e5e1aa3455 Add bounded provider request media budget ENG-2191 (#11520)
* fix(sdk): bound provider request media payloads

* fix(sdk): address media review feedback

* fix(sdk): scrub media from error tool results

* chore(sdk): remove media budget docs noise

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-14 18:21:12 -07:00
Robin NewhouseandSaoud Rizwan d2893d2e93 fix(sdk): coalesce split heredoc run_commands (#11518)
* fix(sdk): coalesce split heredoc run_commands

* fix(sdk): address heredoc coalescing review

* test(sdk): cover split heredoc edge cases

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-14 16:47:16 -07:00
Saoud Rizwan 4fc366df5f fix(sdk): allow ranged reads on large files (#11511)
* fix(sdk): allow ranged reads on large files

* fix(sdk): bound ranged file reads

* fix(sdk): bound streamed file reads

* fix(sdk): simplify file read streaming bounds
2026-06-12 17:23:25 -07:00
Saoud Rizwan d8eb06318b fix(sdk): fail apply_patch when a hunk is skipped (#11509) 2026-06-12 16:46:48 -07:00
Tomás BarreiroandSaoud Rizwan fe4eb44c6b Unselect the org when selecting Cline Pass (#11501)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken

* Add Cline Pass

* Properly read storageProviderId

* Use the name for the model generation

* Use the model for the capabilities lookup

* Fix capability discovery

* Fix getLastUsedProviderSettings

* remove the provider id from the resolveWithSingleFlight return

* Fix tests

* Remove the entry.name check

* Execute model API calls separetely

* Hide Cline Pass pricing

* update model list

* Unselect the org when selecting Cline Pass

* deduplicate onProviderChange calls

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-13 01:44:54 +02:00
Saoud Rizwan 6c52bdc177 fix(sdk): return captured stdout on failing run_commands (#11508)
* fix(sdk): return captured stdout on failing run_commands

* fix(sdk): respect combineOutput on command failure
2026-06-12 16:42:08 -07:00
Saoud Rizwan 5260595472 fix(sdk): treat zero search results as success (#11510) 2026-06-12 16:35:00 -07:00
Tomás Barreiro a279388451 Add feature flag for cline pass (#11500)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken

* Add Cline Pass

* Properly read storageProviderId

* Use the name for the model generation

* Use the model for the capabilities lookup

* Fix capability discovery

* Fix getLastUsedProviderSettings

* remove the provider id from the resolveWithSingleFlight return

* Fix tests

* Remove the entry.name check

* Execute model API calls separetely

* Hide Cline Pass pricing

* Display cline-pass only if the feature flag is enabled

* unselect cline pass when the feature flag is off

* Store and read feature flag cache

* Add comment

* Do not return userId

* fix tests

* Revert unrelated changes
2026-06-13 01:29:29 +02:00
Saoud Rizwan 7810a81efe feat(sdk): encourage parallel tool calls (#11514)
* feat(sdk): encourage parallel tool calls

* fix(sdk): tighten tool execution return type

* test(sdk): remove prompt assertion test

* chore(sdk): restore existing prompt formatting

* chore(sdk): soften command batching wording

* chore(sdk): preserve tool execution default

* test(sdk): remove tool description assertions
2026-06-12 16:29:07 -07:00
Tomás Barreiro 2a54e2a76e Add cline pass (#11355)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken

* Add Cline Pass

* Properly read storageProviderId

* Use the name for the model generation

* Use the model for the capabilities lookup

* Fix capability discovery

* Fix getLastUsedProviderSettings

* remove the provider id from the resolveWithSingleFlight return

* Fix tests

* Remove the entry.name check

* Execute model API calls separetely

* Hide Cline Pass pricing

* update model list

* Address PR feedback

* revert unrelated changes

* Update comment

* Update check
2026-06-13 00:49:52 +02:00
Tomás Barreiro b7c38f76c9 Add buildtime variables for posthog (#11503)
* Add buildtime variables for posthog

* Apply changes
2026-06-13 00:25:22 +02:00
Saoud Rizwan d20e517831 feat(sdk): cap tool output ingestion for bash and file reads (#11480)
Tool outputs previously entered conversation history nearly unbounded
(1MB command output, whole-file reads up to 10MB) and were re-sent on
every subsequent request. Evals showed single observations of 350KB-3.2MB
dominating token spend versus opencode's 50KB-bounded observations.

- run_commands: combined stdout/stderr capped at 48,000 chars with
  head+tail sampling (middle elided with a notice reporting total size),
  since failures usually live at the end of build/test output. Failing
  commands carry the notice in stderr errors too. Streams decode through
  StringDecoder so multibyte chars split across chunks stay intact.
- read_files: whole-file and oversized-range reads windowed to 2,000
  lines / 48,000 chars with a notice reporting total line count and how
  to paginate via start_line/end_line. Per-line cap of 2,000 chars
  defangs minified files. In-window ranged reads are byte-for-byte
  unchanged; the 10MB stat guard stays.
- Shared constants live in executors/output-limits.ts, sized below
  MessageBuilder's 50,000 per-string backstop so source notices survive
  provider-request truncation intact. Tool descriptions document the
  windowing so the model pages or filters instead of retrying.

Companion to #11463/#11465: those bound provider requests at build time;
this bounds what enters history at the source and gives the model a
recovery path.
2026-06-12 11:51:48 -07:00
Tomás Barreiro fa3630da47 Add posthog for feature flags on the cli (#11491)
* Introduce PostHog as a Feature Flag provider

* Set-up auth after login

* Update the context when something changes in the CLI

* Make the distinctId not be optional

* Dispose of the feature flag service

* Remove the distinctId from the options

* get rid of isSharedClient

* Remove timeoutMs from the posthog options

* Rename functions to not refer cli

* Change the PostHogFeatureFlagsProvider API
2026-06-12 18:02:50 +02:00
Saoud Rizwan 8229d0c9be fix: format Cline OAuth tokens in provider config (#11489) 2026-06-11 20:03:46 -07:00
Saoud Rizwan efa14b6cab chore(cli): release v3.0.24 2026-06-11 14:27:27 -07:00
Saoud Rizwan c10b417b78 chore(sdk): release v0.0.47 2026-06-11 14:02:45 -07:00
Saoud Rizwan 9958e3f354 feat(cli): allow plugin commands to submit prompts (#11479)
* feat(cli): allow plugin commands to submit prompts

* fix(cli): preserve plugin command output on abort

* revert(cli): drop ineffective clear view tweak
2026-06-11 13:49:50 -07:00
Tomás Barreiro ec75291d5b Allow overriding the API base url (#11440)
* Allow overriding the base url

* Override the mcpbaseurl

* update the api base url

* Fix tests
2026-06-11 22:29:17 +02:00
Tomás BarreiroandSaoud Rizwan a3a31da37d Add the FeatureFlagService to the SDK [NOOP] (#11444)
* Add the FeatureFlagService to the SDK

* Fix comments

* Dispose of the telemetry service

* Dispose of the feature flag service

* Address PR feedback

* Stop the polling early if a new one is triggered with another user id

* Address PR feedback

* Dispose of the feature flag service

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-11 22:11:35 +02:00
Tomás BarreiroandSaoud Rizwan a69d650838 Open URLs when starting device auth (#11393)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-11 21:27:44 +02:00
AraandClaude Fable 5 7934d367a9 fix(sdk): truncate structured ToolOperationResult strings in MessageBuilder (#11465)
* test(sdk): add regression tests for structured ToolOperationResult truncation

MessageBuilder tests only covered string and {type:

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo"text"} tool-result
content, not the structured ToolOperationResult[] shape the default tools
(run_commands, read_files, search_codebase) actually emit. Those entries
are plain {query, result, success} objects with no type discriminator, so
the token-bloat path they create was unprotected by tests.

Adds regression tests using the real structured shape: huge result, huge
query, huge read_files payload, aggregate budget across multiple results,
mutation safety, and provider-formatted AI SDK payload size. Assertions
are on actual serialized payload sizes, not transcript shape.

The new tests fail at this commit by design; the following commit makes
them pass.

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

* fix(sdk): truncate structured ToolOperationResult strings in MessageBuilder

The runtime stores structured tool outputs (ToolOperationResult[] from
run_commands/read_files/search_codebase) directly as the tool_result
content array (agentPartToContentBlock casts the array straight through).
Those entries have no type discriminator, so MessageBuilder's per-result
truncation, aggregate byte counting, and budget truncation all skipped
them — multi-megabyte command outputs and file reads were JSON-serialized
in full into every subsequent provider request.

MessageBuilder now deep-truncates nested strings inside structured
entries (middle truncation, preserving head and tail), counts them
against the aggregate text budget, collects them as budget-truncation
candidates, and deep-clones them before mutation so the original
conversation history stays untouched. Image blocks are skipped so base64
payloads survive intact.

Real-inference A/B on openrouter:minimax/minimax-m2.7 with realistic
structured payloads: 58.7% overall input-token reduction (82.6% on a
single huge command output) with identical answer correctness.

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

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo

* fix(sdk): include fetch_web_content in MessageBuilder truncation targets

Review feedback: fetch_web_content also returns ToolOperationResult[] and
its executor allows responses up to 5MB, but the tool was missing from
TARGET_TOOL_NAMES, so a single web fetch could still bloat every
subsequent provider request. Adds the tool to the truncation target set
with a regression test.

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

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:34:43 -07:00
AraandClaude Fable 5 7f9d5461f1 fix(sdk): stop echoing full command text in run_commands tool results (#11463)
The run_commands tool result's query field repeated the entire executed
command, which already exists verbatim in the assistant tool-call input.
For large generated-file commands (e.g. cat <<EOF heredocs) this
duplicated thousands of chars of source text into every subsequent
provider request.

Bound the provider-facing echo to a 200-char preview plus a truncation
note pointing at the tool call input. Short commands pass through
unchanged. Applies to both createBashTool and createWindowsShellTool,
on success and error paths.

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

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/og840mbjqaog4zita8m0262m
2026-06-11 11:22:20 -07:00
Saoud Rizwan e1bdeeff68 docs(changelog): note Vertex SDK companion bump in 3.89.2 (#11459) 2026-06-11 04:10:30 -07:00
Saoud Rizwan 49897830bb fix(vscode): align Anthropic Vertex SDK with runtime SDK (#11458) 2026-06-11 04:07:51 -07:00
Saoud Rizwan 1f316a2734 fix(vscode): remove unused ClineStorageMessage import in openai-format (#11457)
The SDK 0.50.1 upgrade widened convertToOpenAiMessages to take
Anthropic.Messages.MessageParam[], which left the ClineStorageMessage
import referenced only in comments. tsc does not flag unused imports in
this config, but biome lint does, and it blocked the 3.89.2 publish.
2026-06-11 03:52:55 -07:00
Saoud Rizwan 9c1f9133c7 v3.89.2 Release Notes (#11455) 2026-06-11 03:47:24 -07:00
Saoud Rizwan 2faef2b40d fix(vscode): upgrade @anthropic-ai/sdk to 0.50.1 for Node 24 compatibility (#11454)
VS Code 1.123.0 bumped its bundled runtime from Node 22 to Node 24
(Electron 39 to 42). The Anthropic provider broke on the updated editor
because the old SDK (<=0.41.x) shipped a legacy runtime built on
node-fetch and an internal _shims layer that does not work under Node 24.

0.50.1 is the first SDK release rewritten on top of the platform's native
fetch: it has zero runtime dependencies (no node-fetch, no _shims), which
removes the incompatibility. This is the actual fix; the earlier 0.40.1
bump did not change the runtime architecture.

The 0.50.1 type changes are minimal:
- Usage gained a required server_tool_use field, so fabricated Usage
  objects in the gemini/o1/openai/vscode-lm transforms set it to null.
- ContentBlockParam widened, so Anthropic.MessageParam is no longer
  structurally assignable to ClineStorageMessage. Handled by narrowing
  the two transform helpers that only ever receive Cline history
  (sanitizeAnthropicMessages, convertAnthropicMessageToGemini), typing
  getSavedApiConversationHistory as the Cline history it reads, and
  narrowing ContextManager's loosely-typed truncated output back to
  ClineStorageMessage at the two provider/hook boundaries.
2026-06-11 03:43:33 -07:00
Saoud Rizwan 64829bca8c chore(vscode): release v3.89.1 (#11451) 2026-06-11 02:49:39 -07:00
Saoud Rizwan 4c9ba6b091 fix(vscode): restore Anthropic provider on Node 24 by bumping SDK (#11449)
VS Code 1.123.0 bumped its bundled runtime from Node 22 to Node 24
(Electron 39 to 42). The extension passes VS Code's globalThis.fetch to
every provider SDK, but @anthropic-ai/sdk was pinned at 0.37.0, which
predates the SDK's native-fetch rewrite and relies on legacy _shims
runtime detection that breaks under Node 24. The modern OpenAI and Gemini
SDKs are unaffected, which is why only the Anthropic provider broke after
users updated VS Code.

Bump to ^0.40.1, the first release with the native-fetch rewrite that
restores Node 24 compatibility, while staying short of the latest line's
larger breaking surface.

The only code change the bump requires is narrowing the image source type:
ImageBlockParam.source widened from a base64-only type to
Base64ImageSource | URLImageSource. Add a getBase64ImageSource/
getImageDataUrl helper in shared/messages/content.ts and route the
provider transforms through it. Cline only ever produces base64 image
sources, so behavior is unchanged; the helper emits the same data URL the
inline code did.
2026-06-11 02:44:25 -07:00
BeeandSaoud Rizwan 6138bdfe40 feat: Enforce a production singleton Cline Hub (#11372)
* feat: Enforce a production singleton Cline Hub

This PR changes local Hub startup/discovery so production uses one stable daemon per user machine instead of silently creating additional hubs on random ports.

Replace resolveSharedHubOwnerContext with resolveProductionHubOwnerContext
across doctor and hub server lifecycle management to scope hub discovery
to the production owner.

Additionally:
- Preserve and propagate auth tokens when retiring incompatible hubs
- Throw a clear error when a compatible hub is already running but its
  discovery record is missing, guiding users to run 'cline doctor fix'
- Gate port fallback behind an explicit allowPortFallback override
- Update tests to mock the new production hub owner context

* patches

* fix

* hasExplicitPort

* Restored daemon cron startup, made discovery auth tokens required again, and fixed graceful hub stop/restart paths to use the selected production/shared owner context.

* clean up

* patches

* fix Polynomial regular expression

* test

* fix: require explicit hub port fallback in production

* fix(cli): stop pgrep from parsing the hub daemon marker as an option

pgrep treats the "--cline-hub-daemon" pattern as an unknown long option
and exits 2, so doctor never found stale daemons from compiled-binary
installs, which are exactly the processes 'cline doctor fix' is told to
clean up. Pass "--" before the pattern to end option parsing.

* fix(hub): retire legacy shared-owner hubs on production startup

Pre-singleton production builds tracked the local hub under the shared
owner discovery path and spawned daemons on random fallback ports. The
production owner context never reads that path, so upgrades would leave
those daemons running indefinitely with no way to reuse or stop them.
Retire the recorded legacy hub (its record carries the auth token and
pid needed for a graceful stop) and clear the legacy record before
resolving the production hub.

* refactor(hub): simplify stale discovery clearing, share capability list

shouldClearStaleHubDiscovery was only ever called with
discoveredVerified=false (the true assignment sits on a return path),
so the expected-hub probe and compatibility check had no effect and the
condition reduced to "a discovery record exists and was not reused".
Replace it with a plain conditional and drop the tests that exercised
unreachable states.

Also move the hub capability list into a typed HUB_CAPABILITIES
constant in @cline/shared next to HubCapabilityName so the server
cannot drift from the type.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-10 16:59:13 -07:00
Dominic Cooneyandgreptile-apps[bot] 7d119351b1 fix(cli): suppress flickering console windows on Windows (#11408)
* fix(cli): suppress flickering console windows on Windows by setting windowsHide on child processes

On Windows, child_process.spawn/execFile default to windowsHide: false,
so console-subsystem children (powershell, rg, git, node, npm) can
allocate a new visible console window - guaranteed when detached: true
is used. In the CLI this caused constant short-lived window flashes
from run_commands, the git status bar polling, ripgrep searches and
indexing, clipboard helpers, and hook/plugin node subprocesses.

Set windowsHide: true (CREATE_NO_WINDOW; a no-op on non-Windows) on all
remaining spawn/spawnSync/execFile call sites in the SDK core, CLI,
Cline Hub, and example plugins, matching the pattern already used by
the MCP client, checkpoint-hooks, and StandaloneTerminalProcess.

* Update apps/cli/src/commands/kanban.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-10 22:51:59 +09:00
Saoud Rizwan de987a5246 chore(cli): release v3.0.23 2026-06-09 17:43:24 -07:00
209 changed files with 17800 additions and 4520 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
+18
View File
@@ -1,5 +1,23 @@
# Cline CLI Changelog
## 3.0.24
- Plugin commands can now submit prompts to the agent
- Added support for overriding the API base URL
- Open the verification URL automatically when starting device authentication
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
- Suppressed flickering console windows on Windows
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 3.0.23
- Fixed Vertex AI GCP settings configuration
- Fixed the Azure Foundry API version
- Added support for configured agents as subagent tools
- Centralized OAuth management into the SDK
- Fixed an error caused by disabled reasoning on Fable 5
## 3.0.22
- Added support for the Claude Fable 5 model
+14
View File
@@ -85,6 +85,20 @@ const result = await Bun.build({
],
define: {
"process.env.NODE_ENV": '"production"',
...(process.env.TELEMETRY_SERVICE_API_KEY
? {
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
"TELEMETRY_SERVICE_API_KEY",
),
}
: {}),
...(process.env.ERROR_SERVICE_API_KEY
? {
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
"ERROR_SERVICE_API_KEY",
),
}
: {}),
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
"OTEL_TELEMETRY_ENABLED",
),
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.22",
"version": "3.0.24",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -87,6 +87,7 @@
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
+94 -22
View File
@@ -18,6 +18,8 @@ interface KeyStep {
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
const POST_ACTION_SETTLE_SECONDS = 1.0;
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
function normalizeTerminalOutput(output: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
@@ -51,16 +53,40 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function runInteractiveCli(
steps: KeyStep[],
options?: { launchConfigView?: boolean },
): CliResult {
function createCliEnv(): NodeJS.ProcessEnv {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
};
}
function runInteractiveCli(
steps: KeyStep[],
options?: {
launchConfigView?: boolean;
launchArgs?: string[];
env?: NodeJS.ProcessEnv;
},
): CliResult {
const env = options?.env ?? createCliEnv();
const scriptedInput = [
...steps,
// Exit each interactive run explicitly so tests do not idle until timeout.
@@ -80,9 +106,13 @@ function runInteractiveCli(
"-k",
"test-key",
];
const launchArgs = [
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
]
const launchArgs = (
options?.launchArgs
? [cliEntry, ...options.launchArgs]
: options?.launchConfigView
? [...baseArgs, "config"]
: baseArgs
)
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
@@ -90,21 +120,7 @@ function runInteractiveCli(
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env: {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
},
env,
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
@@ -188,6 +204,62 @@ describe("cli interactive e2e", () => {
expect(output).toContain("/ for commands · @ for files");
});
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
timeout: 120_000,
}, () => {
const env = createCliEnv();
// Seed one session; the invalid key makes the run fail fast while
// still persisting a resumable session record.
const seed = spawnSync(
bunExec,
[
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
expect(seed.error).toBeUndefined();
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: 60_000,
});
expect(history.error).toBeUndefined();
expect(history.status).toBe(0);
const historyRows = JSON.parse(history.stdout) as unknown[];
expect(historyRows.length).toBeGreaterThan(0);
// history picker -> Enter resumes the seeded session in the
// interactive TUI -> double Ctrl+C exits it. Regression guard for
// the Bun "panic(main thread): Segmentation fault" that occurred
// when the resumed TUI shared the picker's process (a second
// OpenTUI renderer in one process crashes natively on teardown).
const result = runInteractiveCli(
[
// Select the seeded session in the picker.
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
// Give the resumed TUI time to start, then double-press
// Ctrl+C; the harness appends the final press 0.2s later.
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
],
{ launchArgs: ["history"], env },
);
const output = outputOf(result);
// The exit summary only prints after the resumed interactive TUI ran
// and shut down cleanly; the history picker alone never prints it.
expect(output).toContain("Session Summary");
expect(output).not.toContain("panic(");
expect(output).not.toContain("Segmentation fault");
expect(result.status).toBe(0);
});
it("launches config view directly with `cline config`", () => {
const result = runInteractiveCli(
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
+7 -2
View File
@@ -4,7 +4,6 @@ import {
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
getProviderAuthHandler,
listLocalProviders,
loginAndSaveProviderOAuthCredentials,
type ProviderSettings,
type ProviderSettingsManager,
@@ -22,6 +21,8 @@ import {
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
import { listLocalProviders } from "../utils/provider-catalog";
import { identifyTelemetryAccount } from "../utils/telemetry";
export {
getPersistedProviderApiKey,
@@ -434,11 +435,15 @@ export async function runAuthProviderCommand(
return 1;
}
try {
await loginAndSaveProviderOAuthCredentials(
const settings = await loginAndSaveProviderOAuthCredentials(
providerSettingsManager,
providerId,
{ callbacks: createOAuthCallbacks(io) },
);
identifyTelemetryAccount({
id: settings.auth?.accountId,
provider: providerId,
});
io.writeln(
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
);
+24 -2
View File
@@ -14,6 +14,7 @@ import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockProbeHubServer,
@@ -24,6 +25,15 @@ const {
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
@@ -52,6 +62,7 @@ vi.mock("node:child_process", () => ({
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
@@ -76,6 +87,15 @@ describe("runDoctorCommand", () => {
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockResolveProductionHubOwnerContext.mockReturnValue({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
@@ -110,7 +130,8 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/apps/cli/src/index.ts"
args[1] === "--" &&
args[2] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
@@ -261,7 +282,8 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/src-tauri/bin/code-sidecar"
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
+60 -9
View File
@@ -7,10 +7,11 @@ import {
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
@@ -54,6 +55,7 @@ type DoctorStatus = {
hubStartedAt?: string;
hubUptime?: string;
listeningPids: number[];
staleHubPids: number[];
hubStartupLocks: StartupArtifact[];
staleCliPids: number[];
staleSidecarPids: number[];
@@ -77,7 +79,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
}
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
// "--" stops pgrep's option parsing so patterns that start with dashes
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
encoding: "utf8",
});
if (result.status !== 0 && result.status !== 1) {
return [];
}
@@ -148,6 +154,25 @@ function listStaleCliPids(): number[] {
.map((record) => record.pid);
}
function listStaleHubPids(currentHubPids: number[]): number[] {
const current = new Set(currentHubPids.filter((pid) => pid > 0));
const patterns = [
"/sdk/packages/core/src/hub/daemon/entry.ts",
"/sdk/packages/core/dist/hub/daemon/entry.js",
"--cline-hub-daemon",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
continue;
}
records.set(record.pid, record);
}
}
return [...records.values()].map((record) => record.pid);
}
function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
@@ -235,7 +260,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
}
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
if (!existsSync(ownerPath)) {
return [];
@@ -259,7 +284,7 @@ async function clearHubStartupArtifacts(
_cwd: string,
options?: { clearDiscovery?: boolean },
): Promise<{ startupLocks: number; discovery: number }> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const startupLocks = listHubStartupLocks(_cwd);
let clearedStartupLocks = 0;
for (const artifact of startupLocks) {
@@ -291,14 +316,25 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
: undefined;
const current = health ?? discovery;
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
const listeningPids = listListeningPids(current?.port);
const currentHubPids = [
...(current?.pid ? [current.pid] : []),
...listeningPids,
];
return {
cwd,
hubUrl: current?.url,
@@ -306,7 +342,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
hubPid: current?.pid,
hubStartedAt: health?.startedAt,
hubUptime,
listeningPids: listListeningPids(current?.port),
listeningPids,
staleHubPids: listStaleHubPids(currentHubPids),
hubStartupLocks: listHubStartupLocks(cwd),
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
@@ -388,6 +425,7 @@ export async function runDoctorCommand(
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
@@ -412,6 +450,7 @@ export async function runDoctorCommand(
}
if (
before.listeningPids.length > 0 ||
before.staleHubPids.length > 0 ||
before.staleCliPids.length > 0 ||
before.staleSidecarPids.length > 0
) {
@@ -423,7 +462,9 @@ export async function runDoctorCommand(
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully().catch(() => false)
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
() => false,
)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
@@ -431,13 +472,20 @@ export async function runDoctorCommand(
const killedHub = gracefullyStoppedHub
? 0
: killPids(refreshedAfterGracefulStop.listeningPids);
const staleCliTargets = before.staleCliPids.filter(
const staleHubTargets = before.staleHubPids.filter(
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedStaleHubs = killPids(staleHubTargets);
const staleCliTargets = before.staleCliPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid),
);
const killedCli = killPids(staleCliTargets);
const staleSidecarTargets = before.staleSidecarPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid) &&
!staleCliTargets.includes(pid),
);
const killedSidecars = killPids(staleSidecarTargets);
@@ -459,6 +507,7 @@ export async function runDoctorCommand(
after,
killed: {
hubListeners: killedHub,
staleHubDaemons: killedStaleHubs,
cliProcesses: killedCli,
sidecarProcesses: killedSidecars,
connectorProcesses: stoppedConnectors.stoppedProcesses,
@@ -471,6 +520,7 @@ export async function runDoctorCommand(
return 0;
}
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
writeln(
@@ -487,6 +537,7 @@ export async function runDoctorCommand(
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
writeln(
formatPidList(
"remaining hub startup locks",
+51 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
@@ -12,6 +13,10 @@ const {
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
@@ -24,13 +29,25 @@ vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
describe("createHubCommand", () => {
afterEach(() => {
vi.clearAllMocks();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
});
it("includes uptime in hub status output", async () => {
vi.spyOn(Date, "now").mockReturnValue(
new Date("2026-01-01T00:01:05.000Z").getTime(),
@@ -73,4 +90,37 @@ describe("createHubCommand", () => {
uptime: "1m 5s",
});
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
port: 25466,
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(true);
const output: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
(code) => {
exitCode = code;
},
);
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode).toBe(0);
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
});
+14 -5
View File
@@ -3,10 +3,11 @@ import {
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
interface HubCommandIo {
@@ -15,9 +16,9 @@ interface HubCommandIo {
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully()) {
if (await stopLocalHubServerGracefully(owner)) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
@@ -46,6 +47,12 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -112,10 +119,12 @@ export function createHubCommand(
hub.command("status").action(
action(async () => {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
})
: undefined;
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
io.writeln(
+6
View File
@@ -168,6 +168,8 @@ export function buildKanbanSpawnOptions(
detached: shouldDetachKanbanProcess(platform),
...(platform === "win32" ? { shell: true } : {}),
...options,
// Prevent a console window from flashing on Windows.
windowsHide: true,
};
}
@@ -178,6 +180,8 @@ function buildKanbanInstallSpawnOptions(
return {
detached: false,
stdio: "inherit",
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(platform === "win32" ? { shell: true } : {}),
...options,
};
@@ -203,6 +207,8 @@ export function getInstalledKanbanVersion(): string | null {
const result = spawnSync(getKanbanCommand(), ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
if (result.status !== 0) {
return null;
+338
View File
@@ -17,6 +17,7 @@ import {
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
collectPluginMcpOAuthCandidates,
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
@@ -35,6 +36,7 @@ describe("plugin install command", () => {
let originalHome: string | undefined;
let originalClineDir: string | undefined;
let originalClineDataDir: string | undefined;
let originalMcpSettingsPath: string | undefined;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
@@ -43,6 +45,7 @@ describe("plugin install command", () => {
originalHome = process.env.HOME;
originalClineDir = process.env.CLINE_DIR;
originalClineDataDir = process.env.CLINE_DATA_DIR;
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.HOME = home;
process.env.CLINE_DIR = join(home, ".cline");
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
@@ -91,6 +94,11 @@ describe("plugin install command", () => {
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
rmSync(root, { recursive: true, force: true });
});
@@ -676,11 +684,341 @@ describe("plugin install command", () => {
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect("mcpOAuthCandidates" in parsed).toBe(false);
} finally {
process.stdout.write = originalWrite;
}
});
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "json-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "json-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "json-oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
const authorize = vi.fn();
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
const parsed = JSON.parse(stdout.join("")) as {
installPath: string;
mcpOAuthCandidates?: unknown;
};
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect(parsed.mcpOAuthCandidates).toBeUndefined();
} finally {
process.stdout.write = originalWrite;
}
});
it("warns when plugin MCP settings sync fails after install", async () => {
const source = join(root, "mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "mcp-plugin",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const blockedDirectory = join(root, "not-a-directory");
writeFileSync(blockedDirectory, "file", "utf8");
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = join(
blockedDirectory,
"cline_mcp_settings.json",
);
const output: string[] = [];
try {
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain("Installed plugin from");
expect(output.join("\n")).toContain(
"Warning: failed to sync plugin MCP servers",
);
expect(output.join("\n")).toContain("mcp-plugin");
} finally {
if (originalSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
}
}
});
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([
expect.objectContaining({
name: "oauth-docs",
pluginName: "oauth-mcp-plugin",
transportType: "streamableHttp",
}),
]);
});
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "headers-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "headers-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "headers-docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
},
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([]);
});
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
const settingsPath = join(root, "mcp-settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const source = join(root, "authorized-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "authorized-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "authorized-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, { oauth?: unknown }>;
};
const server = settings.mcpServers?.["authorized-docs"];
if (!server) {
throw new Error("Expected authorized-docs MCP server to be written");
}
server.oauth = { tokens: { access_token: "oauth-token" } };
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
expect(
collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
settingsPath,
}),
).toEqual([]);
});
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const authorized: string[] = [];
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async (candidate) => {
authorized.push(candidate.name);
},
},
});
expect(code).toBe(0);
expect(authorized).toEqual(["interactive-docs"]);
expect(output.join("\n")).toContain("Installed plugin from");
});
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "failing-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "failing-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "failing-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async () => {
throw new Error("oauth unavailable");
},
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain(
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
);
});
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "non-interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "non-interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "non-interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const authorize = vi.fn();
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: false,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
expect(output.join("\n")).toContain(
"Plugin MCP servers may require OAuth authorization",
);
expect(output.join("\n")).toContain("non-interactive-docs");
expect(output.join("\n")).toContain('Run "cline mcp"');
});
it("prints JSON output for official plugin installs", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"json-plugin": {
+240 -3
View File
@@ -21,7 +21,15 @@ import {
resolve,
sep,
} from "node:path";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
type McpServerRegistration,
type PluginMcpSettingsSyncResult,
type PluginUninstallOptions,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
syncPluginMcpServersToSettings,
uninstallPlugin,
} from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
@@ -36,12 +44,31 @@ export interface PluginInstallOptions {
npmCommand?: string;
officialPluginsRepo?: string;
io?: PluginInstallIo;
mcpOAuth?: PluginInstallMcpOAuthOptions;
}
export interface PluginInstallResult {
source: string;
installPath: string;
entryPaths: string[];
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
}
export interface PluginMcpOAuthCandidate {
name: string;
pluginName: string;
pluginPath: string;
transportType: "sse" | "streamableHttp";
lastError?: string;
}
export interface PluginInstallMcpOAuthOptions {
interactive?: boolean;
selectCandidates?: (
candidates: PluginMcpOAuthCandidate[],
) => Promise<PluginMcpOAuthCandidate[]>;
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
}
export interface PluginInstallIo {
@@ -506,6 +533,8 @@ async function runCommand(
cwd: options.cwd,
stdio: ["ignore", "ignore", "pipe"],
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
@@ -1003,6 +1032,81 @@ function replaceInstallPath(
}
}
function hasStaticHeaders(registration: McpServerRegistration): boolean {
const transport = registration.transport;
if (transport.type === "stdio") {
return false;
}
return (
transport.headers !== undefined && Object.keys(transport.headers).length > 0
);
}
function hasOAuthAccessToken(registration: McpServerRegistration): boolean {
const accessToken = registration.oauth?.tokens?.access_token;
return typeof accessToken === "string" && accessToken.trim().length > 0;
}
function getPluginOwner(
registration: McpServerRegistration,
): { pluginName: string; pluginPath: string } | undefined {
const metadata = registration.metadata;
if (
!metadata ||
metadata.source !== "plugin" ||
typeof metadata.pluginName !== "string" ||
typeof metadata.pluginPath !== "string"
) {
return undefined;
}
return {
pluginName: metadata.pluginName,
pluginPath: metadata.pluginPath,
};
}
export function collectPluginMcpOAuthCandidates(input: {
pluginPaths: readonly string[];
settingsPath?: string;
}): PluginMcpOAuthCandidate[] {
const pluginPaths = new Set(input.pluginPaths.map((path) => resolve(path)));
if (pluginPaths.size === 0) {
return [];
}
let registrations: McpServerRegistration[];
try {
registrations = resolveMcpServerRegistrations({
filePath: input.settingsPath ?? resolveDefaultMcpSettingsPath(),
});
} catch {
return [];
}
const candidates: PluginMcpOAuthCandidate[] = [];
for (const registration of registrations) {
const owner = getPluginOwner(registration);
if (!owner || !pluginPaths.has(resolve(owner.pluginPath))) {
continue;
}
const transportType = registration.transport.type;
if (transportType === "stdio") {
continue;
}
if (hasStaticHeaders(registration) || hasOAuthAccessToken(registration)) {
continue;
}
candidates.push({
name: registration.name,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
transportType,
lastError: registration.oauth?.lastError,
});
}
return candidates.sort((left, right) => left.name.localeCompare(right.name));
}
export async function installPlugin(
options: PluginInstallOptions,
): Promise<PluginInstallResult> {
@@ -1069,28 +1173,161 @@ export async function installPlugin(
}
replaceInstallPath(stagingRoot, installPath, force);
return {
const result = {
source,
installPath,
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
};
const syncResult = await syncPluginMcpServersToSettings({
pluginPaths: result.entryPaths,
cwd,
workspacePath: cwd,
});
result.mcpSyncFailures = syncResult.failures;
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
});
return result;
} catch (error) {
rmSync(stagingRoot, { recursive: true, force: true });
throw error;
}
}
function serializePluginInstallResult(
result: PluginInstallResult,
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
return {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
};
}
function isInteractivePluginInstall(
options: PluginInstallOptions & { json?: boolean },
): boolean {
return (
options.mcpOAuth?.interactive ??
(options.json !== true && process.stdin.isTTY && process.stdout.isTTY)
);
}
async function selectMcpOAuthCandidatesWithClack(
candidates: PluginMcpOAuthCandidate[],
): Promise<PluginMcpOAuthCandidate[]> {
const p = await import("@clack/prompts");
const action = await p.select({
message: "Authorize plugin MCP servers now?",
options: [
{
value: "all",
label: "Authorize all",
hint: "open browser authorization for each server",
},
{
value: "choose",
label: "Choose servers",
hint: "select which servers to authorize",
},
{
value: "skip",
label: "Skip",
},
],
});
if (p.isCancel(action) || action === "skip") {
return [];
}
if (action === "all") {
return candidates;
}
const selectedNames = await p.multiselect({
message: "Select MCP servers to authorize",
options: candidates.map((candidate) => ({
value: candidate.name,
label: candidate.name,
hint: `${candidate.transportType} [${candidate.pluginName}]`,
})),
required: false,
});
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
return [];
}
const selected = new Set(selectedNames);
return candidates.filter((candidate) => selected.has(candidate.name));
}
async function authorizeMcpOAuthCandidate(
candidate: PluginMcpOAuthCandidate,
): Promise<void> {
const { authorizeMcpServerOAuthWithBrowser } = await import(
"../wizards/mcp/oauth"
);
await authorizeMcpServerOAuthWithBrowser(candidate.name);
}
async function runPluginMcpOAuthFollowup(
candidates: PluginMcpOAuthCandidate[],
options: PluginInstallOptions & { json?: boolean },
): Promise<void> {
if (candidates.length === 0 || options.json === true) {
return;
}
if (!isInteractivePluginInstall(options)) {
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
for (const candidate of candidates) {
options.io?.writeln(
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
);
}
options.io?.writeln(
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
);
return;
}
const selected =
options.mcpOAuth?.selectCandidates !== undefined
? await options.mcpOAuth.selectCandidates(candidates)
: await selectMcpOAuthCandidatesWithClack(candidates);
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
for (const candidate of selected) {
try {
await authorize(candidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(
`Warning: failed to authorize MCP server ${candidate.name}: ${message}`,
);
}
}
}
export async function runPluginInstallCommand(
options: PluginInstallOptions & { json?: boolean },
): Promise<number> {
try {
const result = await installPlugin(options);
if (options.json) {
process.stdout.write(JSON.stringify(result));
process.stdout.write(
JSON.stringify(serializePluginInstallResult(result)),
);
return 0;
}
options.io?.writeln(`Installed plugin from ${result.source}`);
options.io?.writeln(` Path: ${result.installPath}`);
for (const failure of result.mcpSyncFailures) {
options.io?.writeErr(
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
}
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+67
View File
@@ -7,10 +7,14 @@ import {
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
@@ -32,6 +36,21 @@ function createTempFile(pathSuffix: string): string {
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
@@ -96,6 +115,21 @@ describe("getInstallationInfo", () => {
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
@@ -153,6 +187,39 @@ describe("auto update settings", () => {
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
+23 -7
View File
@@ -5,9 +5,11 @@ import {
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
@@ -269,13 +271,22 @@ export function getPreferredKanbanInstaller(
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function waitForHubToStop(
url: string,
authToken: string | undefined,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const check = await probeHubServer(url).catch(() => undefined);
const check = await probeHubServer(url, { authToken }).catch(
() => undefined,
);
if (!check?.url) return true;
await sleep(100);
}
@@ -288,20 +299,22 @@ async function waitForHubToStop(
* clears stale discovery, then re-ensures a fresh instance is spawned.
*/
async function restartHubServerIfRunning(): Promise<void> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
const health = discovery?.url
? await probeHubServer(discovery.url).catch(() => undefined)
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
}).catch(() => undefined)
: undefined;
if (!health?.url) return;
if (!discovery || !health?.url) return;
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully().catch(() => false);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
@@ -310,14 +323,14 @@ async function restartHubServerIfRunning(): Promise<void> {
}
}
stopped = await waitForHubToStop(health.url, 3_000);
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
if (!stopped && pid) {
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
stopped = await waitForHubToStop(health.url, 2_000);
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
}
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
@@ -362,6 +375,9 @@ export function autoUpdateOnStartup(): void {
env: autoUpdateCommand.env
? { ...process.env, ...autoUpdateCommand.env }
: process.env,
// Prevent a console window from flashing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
const exitCode = await waitForProcessExit(child);
if (exitCode === 0) {
+3
View File
@@ -194,6 +194,9 @@ export function spawnDetachedConnector(
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
logSpawnedProcess({
component: options?.component ?? "connectors",
@@ -1,15 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockGetLastUsedProviderSettings,
mockGetProviderSettings,
mockResolveSystemPrompt,
mockGetProviderCollection,
mockGetBooleanFlagEnabled,
} = vi.hoisted(() => ({
mockGetLastUsedProviderSettings: vi.fn(),
mockGetProviderSettings: vi.fn(),
mockResolveSystemPrompt: vi.fn(),
mockGetProviderCollection: vi.fn(),
mockGetBooleanFlagEnabled: vi.fn(),
}));
vi.mock("@cline/core", async () => {
@@ -18,8 +20,8 @@ vi.mock("@cline/core", async () => {
return {
...actual,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockGetLastUsedProviderSettings();
getLastUsedProviderSettings(options?: unknown) {
return mockGetLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
@@ -43,6 +45,12 @@ vi.mock("../utils/helpers", () => ({
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
}));
vi.mock("../utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
}),
}));
vi.mock("../commands/auth", async () => {
const actual =
await vi.importActual<typeof import("../commands/auth")>(
@@ -57,6 +65,10 @@ vi.mock("../commands/auth", async () => {
import { buildConnectorStartRequest } from "./session-runtime";
describe("buildConnectorStartRequest", () => {
beforeEach(() => {
mockGetBooleanFlagEnabled.mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
delete process.env.OPENROUTER_API_KEY;
@@ -88,5 +100,64 @@ describe("buildConnectorStartRequest", () => {
expect(request.provider).toBe("openrouter");
expect(request.apiKey).toBe("env-openrouter-key");
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
isClinePassEnabled: false,
});
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
});
});
+5 -1
View File
@@ -16,6 +16,7 @@ import {
import type { CliLoggerAdapter } from "../logging/adapter";
import { resolveSystemPrompt } from "../runtime/prompt";
import { resolveCliSessionMetadata } from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import {
parseLocalRowMetadata,
@@ -62,7 +63,10 @@ export async function buildConnectorStartRequest(input: {
}): Promise<ChatStartSessionRequest> {
const providerSettingsManager = new ProviderSettingsManager();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings();
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
input.options.provider?.trim() ||
lastUsedProviderSettings?.provider ||
+168 -6
View File
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
runAuthCommand: vi.fn(),
}));
const providerSettingsMocks = vi.hoisted(() => ({
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
() => undefined,
),
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
() => undefined,
),
@@ -82,6 +84,11 @@ const historyMocks = vi.hoisted(() => ({
runHistoryExport: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
const historyResumeMocks = vi.hoisted(() => ({
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
async () => undefined,
),
}));
const loggingMocks = vi.hoisted(() => ({
createCliLoggerAdapter: vi.fn(() => ({
core: {
@@ -101,10 +108,13 @@ const hubRuntimeMocks = vi.hoisted(() => ({
}));
const telemetryMocks = vi.hoisted(() => ({
captureCliExtensionActivated: vi.fn(),
identifyCliTelemetryAccount: vi.fn(),
identifyTelemetryAccount: vi.fn(),
getCliTelemetryService: vi.fn(),
disposeCliTelemetryService: vi.fn(async () => {}),
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
}));
function forcePromptModeInput() {
Object.defineProperty(process.stdin, "isTTY", {
@@ -148,8 +158,8 @@ vi.mock("@cline/core", () => {
stop: vi.fn(),
})),
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return providerSettingsMocks.getLastUsedProviderSettings();
getLastUsedProviderSettings(options?: unknown) {
return providerSettingsMocks.getLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
return providerSettingsMocks.getProviderSettings(providerId);
@@ -164,6 +174,12 @@ vi.mock("@cline/core", () => {
};
});
vi.mock("./utils/provider-auth", () => authMocks);
vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
}));
@@ -172,6 +188,7 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
vi.mock("./utils/history-resume", () => historyResumeMocks);
vi.mock("./logging/adapter", () => loggingMocks);
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
vi.mock("./utils/telemetry", () => telemetryMocks);
@@ -191,6 +208,8 @@ describe("runCli lightweight command dispatch", () => {
historyMocks.runHistoryExport.mockResolvedValue(0);
historyMocks.runHistoryUpdate.mockReset();
historyMocks.runHistoryUpdate.mockResolvedValue(0);
historyResumeMocks.spawnHistoryResume.mockReset();
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
sessionMocks.getSessionRow.mockReset();
sessionMocks.getSessionRow.mockResolvedValue({
sessionId: "sess_123",
@@ -246,7 +265,7 @@ describe("runCli lightweight command dispatch", () => {
updateMocks.getPreferredKanbanInstaller.mockReset();
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
telemetryMocks.captureCliExtensionActivated.mockReset();
telemetryMocks.identifyCliTelemetryAccount.mockReset();
telemetryMocks.identifyTelemetryAccount.mockReset();
telemetryMocks.getCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
@@ -719,10 +738,47 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("forces chat view when resuming from history picker", async () => {
it("resumes a history-picked session in a child process", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess_from_history",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it("propagates the child exit code when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("forces chat view when the history-picker child cannot launch", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
@@ -953,6 +1009,76 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("defaults thinking to medium for reasoning-capable selected models", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
llmMocks.resolveProviderConfig.mockResolvedValue({
knownModels: {
"openai/gpt-5": {
id: "openai/gpt-5",
name: "GPT-5",
capabilities: ["tools", "reasoning"],
},
},
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "-m", "openai/gpt-5", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
modelId: "openai/gpt-5",
thinking: true,
reasoningEffort: "medium",
}),
expect.anything(),
);
});
it("keeps thinking disabled when explicitly set to none for reasoning models", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
llmMocks.resolveProviderConfig.mockResolvedValue({
knownModels: {
"openai/gpt-5": {
id: "openai/gpt-5",
name: "GPT-5",
capabilities: ["tools", "reasoning"],
},
},
});
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"-m",
"openai/gpt-5",
"--thinking",
"none",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
modelId: "openai/gpt-5",
thinking: false,
reasoningEffort: undefined,
}),
expect.anything(),
);
});
it("maps --thinking to medium effort", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1004,6 +1130,42 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("keeps persisted disabled reasoning when --thinking is not provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
providerSettingsMocks.getProviderSettings.mockReturnValue({
provider: "cline",
model: "openai/gpt-5",
reasoning: { enabled: false },
});
llmMocks.resolveProviderConfig.mockResolvedValue({
knownModels: {
"openai/gpt-5": {
id: "openai/gpt-5",
name: "GPT-5",
capabilities: ["tools", "reasoning"],
},
},
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
modelId: "openai/gpt-5",
thinking: false,
reasoningEffort: undefined,
}),
expect.anything(),
);
});
it("prefers explicit --thinking over persisted reasoning effort", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
+51 -7
View File
@@ -19,6 +19,10 @@ import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
normalizeAutoApproveArgs,
@@ -57,6 +61,13 @@ export function stdinHasPipedInput(): boolean {
}
}
function modelSupportsReasoning(
knownModels: Config["knownModels"],
modelId: string,
): boolean {
return knownModels?.[modelId]?.capabilities?.includes("reasoning") ?? false;
}
async function createProviderSettingsManager() {
const { ProviderSettingsManager } = await import("@cline/core");
return new ProviderSettingsManager();
@@ -663,6 +674,21 @@ export async function runCli(): Promise<void> {
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
// The history picker already created (and tore down) an OpenTUI renderer
// in this process; starting the interactive TUI here would create a
// second one, which can crash natively during teardown. Resume in a
// fresh `cline --id <session-id>` child process instead.
const { spawnHistoryResume } = await import("./utils/history-resume");
const childExitCode = await spawnHistoryResume({
sessionId: resumeSessionId,
normalizedArgs,
remainingArgs: program.args,
configDir,
});
if (childExitCode !== undefined) {
process.exitCode = childExitCode;
return;
}
args = {
...args,
interactive: true,
@@ -836,8 +862,12 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings();
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
);
@@ -904,8 +934,17 @@ export async function runCli(): Promise<void> {
);
}
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
const selectedModelId =
args.model ??
selectedProviderSettings?.model ??
knownModelIds[0] ??
"anthropic/claude-sonnet-4.6";
const persistedReasoning = selectedProviderSettings?.reasoning;
const persistedReasoningEffort = persistedReasoning?.effort;
const hasPersistedReasoning =
persistedReasoning?.enabled !== undefined ||
persistedReasoning?.effort !== undefined ||
persistedReasoning?.budgetTokens !== undefined;
const reasoningEffortFromSettings =
persistedReasoning?.enabled === false
? "none"
@@ -914,9 +953,18 @@ export async function runCli(): Promise<void> {
: persistedReasoning?.enabled === true
? "medium"
: "none";
const reasoningEffortFromModel = modelSupportsReasoning(
knownModels,
selectedModelId,
)
? "medium"
: "none";
const effectiveReasoningEffort = args.thinkingExplicitlySet
? (args.reasoningEffort ?? "none")
: (args.reasoningEffort ?? reasoningEffortFromSettings);
: (args.reasoningEffort ??
(hasPersistedReasoning
? reasoningEffortFromSettings
: reasoningEffortFromModel));
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -930,11 +978,7 @@ export async function runCli(): Promise<void> {
const config: Config = {
providerId: provider,
modelId:
args.model ??
selectedProviderSettings?.model ??
knownModelIds[0] ??
"anthropic/claude-sonnet-4.6",
modelId: selectedModelId,
apiKey: apiKey ?? "",
knownModels,
systemPrompt: await resolveSystemPrompt({
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
type ChatCommandState,
chatCommandHost,
createChatCommandHost,
} from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import {
@@ -162,4 +163,37 @@ describe("runInteractiveChatCommand", () => {
expect(state.autoApproveTools).toBe(true);
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
});
it("returns plugin command submit prompts as model input", async () => {
const config = makeConfig();
const runtime = makeRuntime();
const onCommandOutput = vi.fn();
const host = createChatCommandHost().register("command", {
names: ["/goal"],
run: async ({ args }, context) => {
await context.reply(`Goal guard set: ${args.join(" ")}`);
await context.submitPrompt?.(args.join(" "));
},
});
const result = await runInteractiveChatCommand({
prompt: "/goal fix tests",
enabled: true,
config,
host,
chatCommandState: makeState(config),
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
stop: () => {},
onCommandOutput,
});
expect(result).toEqual({
handled: false,
input: "fix tests",
commandOutput: "Goal guard set: fix tests",
});
expect(onCommandOutput).toHaveBeenCalledWith("Goal guard set: fix tests");
});
});
@@ -26,7 +26,7 @@ export type InteractiveChatCommandRuntime = Pick<
export type InteractiveChatCommandResult =
| { handled: true; turnResult: InteractiveTurnResult }
| { handled: false; input: string };
| { handled: false; input: string; commandOutput?: string };
function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
return {
@@ -46,6 +46,7 @@ export async function runInteractiveChatCommand(input: {
setInteractiveAutoApprove: (enabled: boolean) => void;
sessionRuntime: InteractiveChatCommandRuntime;
stop: () => void;
onCommandOutput?: (text: string) => void;
}): Promise<InteractiveChatCommandResult> {
let prompt = input.prompt;
const rewrittenTeamPrompt = rewriteTeamPrompt(prompt);
@@ -64,6 +65,7 @@ export async function runInteractiveChatCommand(input: {
}
let commandOutput: string | undefined;
let submitPrompt: string | undefined;
const handled = await maybeHandleChatCommand(prompt, {
enabled: input.enabled,
host: input.host,
@@ -80,6 +82,13 @@ export async function runInteractiveChatCommand(input: {
},
reply: async (text) => {
commandOutput = text;
input.onCommandOutput?.(text);
},
submitPrompt: async (text) => {
const trimmed = text.trim();
if (trimmed) {
submitPrompt = trimmed;
}
},
reset: async () => {
await input.sessionRuntime.resetForNewSession();
@@ -98,6 +107,13 @@ export async function runInteractiveChatCommand(input: {
fork: input.sessionRuntime.forkCurrentSession,
});
if (handled) {
if (submitPrompt) {
return {
handled: false,
input: submitPrompt,
...(commandOutput ? { commandOutput } : {}),
};
}
return {
handled: true,
turnResult: commandTurnResult(commandOutput),
@@ -1,4 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UserInstructionConfigService } from "@cline/core";
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
};
afterEach(async () => {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -76,6 +91,28 @@ describe("interactive config data loader", () => {
return pluginPath;
}
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-mcp-plugin',",
" manifest: { capabilities: ['mcp'] },",
" setup(api) {",
" api.registerMcpServer({",
" name: 'smoke',",
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -311,6 +348,70 @@ Find installable skills.`,
).toBe(true);
});
it("loads plugin-owned MCP servers from settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.mcp.some(
(item) =>
item.name === "smoke" &&
item.pluginName === "settings-mcp-plugin" &&
item.pluginPath === pluginPath &&
item.kind === "mcp",
),
).toBe(true);
});
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -731,6 +832,142 @@ Review with the bundled skill.`,
).toBe(false);
});
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
oauth: {
tokens: {
access_token: "token",
},
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const item: InteractiveConfigItem = {
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
};
await loader.onToggleConfigItem(item);
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
await loader.onToggleConfigItem({ ...item, enabled: false });
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
});
it.skipIf(process.platform === "win32")(
"does not mark plugin disabled when MCP disable write fails",
async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
await chmod(settingsPath, 0o444);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
try {
await expect(
loader.onToggleConfigItem({
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
}),
).rejects.toThrow();
} finally {
await chmod(settingsPath, 0o644);
}
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { disabled?: boolean }>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
},
);
it("surfaces MCP OAuth status and errors", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,7 +1,9 @@
import {
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
} else {
const ownedMcpMutations = disablePluginMcpServersInSettings({
pluginPaths: [item.path],
});
const result = await syncPluginMcpServersToSettings({
pluginPaths: [item.path],
cwd: input.config.cwd,
workspacePath: workspaceRoot(),
providerId: input.config.providerId,
modelId: input.config.modelId,
});
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
throw new Error(
`Failed to sync plugin MCP servers: ${result.failures
.map((failure) => {
const plugin = failure.pluginName ?? failure.pluginPath;
return `${plugin}: ${failure.message}`;
})
.join("; ")}`,
);
}
setDisabledPlugin(item.path, false);
}
return undefined;
}
+24 -1
View File
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
import type {
@@ -427,7 +428,8 @@ export async function runInteractive(
uiEvents.off("pending-prompt-submitted", onPendingPromptSubmitted);
};
},
onSubmit: async (input, mode, delivery, attachments) => {
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -446,6 +448,7 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
@@ -465,12 +468,14 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
const {
prompt: userInput,
userImages,
@@ -507,6 +512,7 @@ export async function runInteractive(
iterations: 0,
finishReason: "queued",
queued: delivery === "queue" || delivery === "steer",
commandOutput,
};
}
if (result.finishReason !== "completed") {
@@ -519,6 +525,7 @@ export async function runInteractive(
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: "aborted",
commandOutput,
};
}
const errorText = result.text.trim();
@@ -532,6 +539,7 @@ export async function runInteractive(
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: result.finishReason,
commandOutput,
};
} catch (error) {
if (isAbortInProgress()) {
@@ -539,6 +547,7 @@ export async function runInteractive(
usage: { inputTokens: 0, outputTokens: 0 },
iterations: 0,
finishReason: "aborted",
commandOutput,
};
}
logCliError(config.logger, "Interactive turn failed", {
@@ -603,6 +612,10 @@ export async function runInteractive(
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
@@ -623,6 +636,16 @@ export async function runInteractive(
},
onAccountChange: async () => {
await sessionRuntime.ensureReady();
await loadClineAccountSnapshot({
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
}).catch((error) => {
logCliError(
config.logger,
"Cline account refresh after account change failed",
{ error },
);
});
await sessionRuntime.restartWithCurrentMessages();
},
onResumeSession: async (sessionId: string) => {
+9
View File
@@ -12,6 +12,8 @@ const createCore = vi.fn();
const getCliTelemetryService = vi.fn(() => undefined);
const resolveSessionBackend = vi.fn();
const listSessionHistoryFromBackend = vi.fn();
const featureFlagsPoll = vi.fn(async () => {});
const featureFlagsDispose = vi.fn(async () => {});
vi.mock("@cline/core", async () => {
const actual =
@@ -49,6 +51,10 @@ describe("createCliCore", () => {
listSessionHistoryFromBackend.mockReset();
createCore.mockResolvedValue({
runtimeAddress: "127.0.0.1:25463",
featureFlags: {
poll: featureFlagsPoll,
dispose: featureFlagsDispose,
},
start: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
@@ -68,6 +74,8 @@ describe("createCliCore", () => {
delete process.env.CLINE_RPC_ADDRESS;
delete process.env.CLINE_SESSION_BACKEND_MODE;
delete process.env.CLINE_VCR;
featureFlagsPoll.mockClear();
featureFlagsDispose.mockClear();
});
afterEach(() => {
@@ -108,6 +116,7 @@ describe("createCliCore", () => {
backendMode: expect.anything(),
}),
);
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
});
it("forces the local backend when requested by the caller", async () => {
+13 -1
View File
@@ -15,6 +15,7 @@ import {
createCliMessagesArtifactUploader,
prepareCliEnterpriseIntegration,
} from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import { getCliTelemetryService } from "../utils/telemetry";
import type { ConversationHistory } from "./export";
@@ -40,6 +41,11 @@ export async function createCliCore(options?: {
const cwd = options?.cwd?.trim() || process.cwd();
const workspaceRoot =
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
const telemetry = getCliTelemetryService(options?.logger);
const featureFlags = getCliFeatureFlagsService({
logger: options?.logger,
telemetry,
});
const core = await ClineCore.create({
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
...(options?.forceLocalBackend !== true
@@ -53,12 +59,18 @@ export async function createCliCore(options?: {
}
: {}),
capabilities: options?.capabilities,
telemetry: getCliTelemetryService(options?.logger),
telemetry,
featureFlags,
logger: options?.logger,
toolPolicies: options?.toolPolicies,
messagesArtifactUploader: createCliMessagesArtifactUploader(),
prepare: prepareCliEnterpriseIntegration,
});
try {
await core.featureFlags.poll();
} catch (error) {
options?.logger?.error?.("Error polling CLI feature flags", { error });
}
options?.logger?.log("CLI core runtime routing selected", {
backendMode: explicitBackendMode ?? "env-managed",
rpcAddress: core.runtimeAddress,
+86
View File
@@ -9,9 +9,15 @@ const coreMocks = vi.hoisted(() => {
return {
getProviderSettings: vi.fn(),
saveProviderSettings: vi.fn(),
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
serviceOptions,
};
});
const telemetryMocks = vi.hoisted(() => ({
identifyTelemetryAccount: vi.fn(),
}));
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
@@ -24,6 +30,15 @@ vi.mock("@cline/core", async (importOriginal) => {
}) {
coreMocks.serviceOptions.push(options);
}
fetchMe() {
return coreMocks.fetchMe();
}
fetchBalance(userId?: string) {
return coreMocks.fetchBalance(userId);
}
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -36,6 +51,10 @@ vi.mock("@cline/core", async (importOriginal) => {
};
});
vi.mock("../utils/telemetry", () => ({
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
}));
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
providerId: "cline",
@@ -78,7 +97,11 @@ describe("createClineAccountService", () => {
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
@@ -163,3 +186,66 @@ describe("createClineAccountService", () => {
);
});
});
describe("loadClineAccountSnapshot", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
apiKey: "account-token",
});
const { loadClineAccountSnapshot } = await import("./cline-account");
coreMocks.fetchMe.mockResolvedValue({
id: "user-1",
email: "user@example.com",
displayName: "User One",
photoUrl: "",
createdAt: "",
updatedAt: "",
organizations: [
{
active: true,
memberId: "member-1",
name: "Acme",
organizationId: "org-1",
roles: ["member"],
},
],
});
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
coreMocks.fetchOrganizationBalance.mockResolvedValue({
balance: 20,
organizationId: "org-1",
});
await loadClineAccountSnapshot({ config: makeConfig() });
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
{
id: "user-1",
email: "user@example.com",
provider: "cline",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
},
expect.any(Object),
);
});
});
+37 -1
View File
@@ -14,12 +14,15 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
import { identifyTelemetryAccount } from "../utils/telemetry";
import type { Config } from "../utils/types";
export const CLINE_CREDITS_DASHBOARD_URL =
"https://app.cline.bot/dashboard/account?tab=credits";
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
const CLINE_PASS_PROVIDER_ID = "cline-pass";
export interface ClineAccountSnapshot {
user: ClineAccountUser;
@@ -167,6 +170,15 @@ export async function loadClineAccountSnapshot(input: {
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance.balance)
: balance.balance;
const accountContext = {
id: user.id,
email: user.email,
provider: "cline",
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
return {
user,
@@ -190,3 +202,27 @@ export async function switchClineAccount(input: {
}
await service.switchAccount(input.organizationId);
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
config: config,
organizationId: null,
});
} catch (error) {
config.logger?.debug("Failed to switch ClinePass to personal account", {
error,
});
}
}
export async function onProviderChange(input: {
config: ClineAccountConfig;
providerId: string;
}): Promise<void> {
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
return onChangeToClinePass(input.config);
}
return;
}
@@ -58,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
};
afterEach(async () => {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((directory) =>
rm(directory, { recursive: true, force: true }),
@@ -107,6 +111,44 @@ describe("mcp manager dialog helpers", () => {
).toBeUndefined();
});
it("does not toggle plugin-owned servers", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "stdio",
command: "node",
},
},
},
},
null,
2,
)}\n`,
);
const result = toggleMcpServer({
name: "docs",
path: settingsPath,
enabled: true,
pluginName: "repo-docs",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain('managed by plugin "repo-docs"');
}
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
undefined,
);
});
it("returns a visible error message when toggling fails", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
@@ -13,6 +13,7 @@ export interface McpEntry {
enabled?: boolean;
description?: string;
lastError?: string;
pluginName?: string;
}
export type McpServerToggleResult =
@@ -36,6 +37,12 @@ export function getMcpManagerEntryStatus(
}
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
if (server.pluginName) {
return {
ok: false,
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
};
}
try {
const currentlyEnabled = server.enabled !== false;
setMcpServerDisabled({
@@ -71,6 +78,7 @@ export function McpManagerContent(
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
const itemCount = servers.length;
const selectedServer = servers[selected];
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
useDialogKeyboard((key) => {
if (key.name === "escape") {
@@ -150,6 +158,7 @@ export function McpManagerContent(
{isSel ? "\u25b8 " : " "}
{enabledIcon}
{srv.name}
{srv.pluginName ? " *" : ""}
</text>
{status && (
<text fg={srv.lastError ? palette.error : "gray"}>
@@ -184,6 +193,12 @@ export function McpManagerContent(
</box>
)}
{hasPluginOwnedServers && (
<text fg="gray" marginTop={1}>
* managed by plugin; disable the plugin to disable the server.
</text>
)}
<text fg="gray" marginTop={1}>
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
</text>
@@ -2,7 +2,6 @@ import {
completeClineDeviceAuth,
getProviderConfigFields,
isOAuthProvider,
listLocalProviders,
loginLocalProvider,
type ProviderConfigFieldKey,
type ProviderConfigFieldRequirement,
@@ -22,6 +21,7 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import {
getDefaultAwsRegion,
+21 -14
View File
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
});
return new Promise((resolve) => {
let settled = false;
let result: number | string = 0;
let resolved = false;
let destroyStarted = false;
let unmounted = false;
const root = createRoot(renderer);
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
root.unmount();
};
const settle = (value: number | string) => {
if (settled) {
return;
}
settled = true;
unmountRoot();
renderer.destroy();
resolve(value);
};
// Resolve only once teardown has finished, so callers never run while
// the renderer is still restoring the terminal.
renderer.on("destroy", () => {
unmountRoot();
if (!settled) {
settled = true;
resolve(0);
if (!resolved) {
resolved = true;
resolve(result);
}
});
const settle = (value: number | string) => {
if (destroyStarted) {
return;
}
destroyStarted = true;
result = value;
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
renderer.destroy();
});
};
root.render(
React.createElement(HistoryStandaloneContent, {
rows: input.rows,
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
enabled: item.enabled,
description: item.description,
lastError: item.loadError,
pluginName: item.pluginName,
}));
}
+29 -22
View File
@@ -74,6 +74,21 @@ function clearReasoningConfig(config: Config): void {
config.reasoningEffort = undefined;
}
function resolveDefaultThinkingLevel(
config: Pick<Config, "modelId" | "reasoningEffort" | "thinking">,
selectedModelId: string,
): ThinkingLevel {
if (config.reasoningEffort) {
return config.reasoningEffort as ThinkingLevel;
}
if (selectedModelId === config.modelId && !config.thinking) {
return "none";
}
return "medium";
}
function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
@@ -179,7 +194,6 @@ async function runProviderChange(
config.providerId = newProviderId;
config.apiKey = newApiKey;
const resolved = await resolveProviderConfig(
newProviderId,
{
@@ -332,23 +346,21 @@ export function useModelSelector(opts: {
await changeProvider();
continue;
}
config.modelId = browseResult;
const browseModel = modelOptions.find(
(m: ModelOption) => m.key === browseResult,
);
if (browseModel?.supportsReasoning) {
const lvl: ThinkingLevel = config.reasoningEffort
? (config.reasoningEffort as ThinkingLevel)
: config.thinking
? "medium"
: "none";
const currentLevel = resolveDefaultThinkingLevel(
config,
browseResult,
);
const pick = await dialog.choice<ThinkingLevel>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ThinkingLevel>) => (
<ThinkingLevelContent
{...ctx}
modelName={browseModel.name}
currentLevel={lvl}
currentLevel={currentLevel}
/>
),
});
@@ -362,6 +374,7 @@ export function useModelSelector(opts: {
}
}
}
config.modelId = browseResult;
if (!browseModel?.supportsReasoning) {
clearReasoningConfig(config);
}
@@ -369,16 +382,14 @@ export function useModelSelector(opts: {
continue;
}
config.modelId = clineResult;
const selectedModel = modelOptions.find(
(m: ModelOption) => m.key === clineResult,
);
if (selectedModel?.supportsReasoning) {
const currentLevel: ThinkingLevel = config.reasoningEffort
? (config.reasoningEffort as ThinkingLevel)
: config.thinking
? "medium"
: "none";
const currentLevel = resolveDefaultThinkingLevel(
config,
clineResult,
);
const thinkingLevel = await dialog.choice<ThinkingLevel>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ThinkingLevel>) => (
@@ -399,6 +410,7 @@ export function useModelSelector(opts: {
}
}
}
config.modelId = clineResult;
if (!selectedModel?.supportsReasoning) {
clearReasoningConfig(config);
}
@@ -427,23 +439,17 @@ export function useModelSelector(opts: {
continue;
}
config.modelId = selectedKey;
const selectedModel = modelOptions.find(
(m: ModelOption) => m.key === selectedKey,
);
if (!selectedModel?.supportsReasoning) {
config.modelId = selectedKey;
clearReasoningConfig(config);
pickingModel = false;
break;
}
const currentLevel: ThinkingLevel = config.reasoningEffort
? (config.reasoningEffort as ThinkingLevel)
: config.thinking
? "medium"
: "none";
const currentLevel = resolveDefaultThinkingLevel(config, selectedKey);
const thinkingLevel = await dialog.choice<ThinkingLevel>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ThinkingLevel>) => (
@@ -466,6 +472,7 @@ export function useModelSelector(opts: {
config.thinking = true;
config.reasoningEffort = thinkingLevel;
}
config.modelId = selectedKey;
pickingModel = false;
}
@@ -327,6 +327,14 @@ export function usePromptInputController(input: {
}
const startedAt = performance.now();
let commandOutputAppended = false;
const appendCommandOutput = (text: string) => {
commandOutputAppended = true;
session.appendEntry({
kind: "status",
text,
});
};
try {
const result = await onSubmit(
promptForSubmit,
@@ -335,8 +343,9 @@ export function usePromptInputController(input: {
activeUserImages.length > 0
? { userImages: activeUserImages }
: undefined,
appendCommandOutput,
);
if (result.commandOutput) {
if (result.commandOutput && !commandOutputAppended) {
session.appendEntry({
kind: "status",
text: result.commandOutput,
+23 -5
View File
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source">,
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
): boolean {
if (item.kind === "mcp") {
return !item.pluginName;
}
return (
item.kind === "skill" ||
item.kind === "plugin" ||
item.kind === "mcp" ||
item.source === "builtin" ||
item.source === "workspace-plugin" ||
item.source === "global-plugin"
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
}
}
function getPluginDisplayName(filePath: string): string {
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
for (let depth = 0; depth < 4; depth++) {
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
for (const filePath of discoverPluginModulePaths(directory)) {
plugins.push({
id: filePath,
name: getPluginDisplayName(filePath),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
kind: "plugin",
@@ -458,6 +462,16 @@ export async function loadInteractiveConfigData(input: {
for (const registration of resolveMcpServerRegistrations({
filePath: mcpSettingsPath,
})) {
const pluginName =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginName === "string"
? registration.metadata.pluginName
: undefined;
const pluginPath =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginPath === "string"
? registration.metadata.pluginPath
: undefined;
mcp.push({
id: registration.name,
name: registration.name,
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
pluginPath: pluginTool.path,
source: pluginTool.source,
description: pluginTool.description,
});
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
};
}
+1
View File
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
if (result.reasoningEffort !== undefined) {
props.config.reasoningEffort = result.reasoningEffort;
}
handleModelChange().then(() => setAppView("home"));
}}
onExit={() => {
+1
View File
@@ -152,6 +152,7 @@ export interface TuiProps {
mode: AgentMode,
delivery?: "queue" | "steer",
attachments?: UserInputAttachments,
onCommandOutput?: (text: string) => void,
) => Promise<InteractiveTurnResult>;
onUpdatePendingPrompt: (input: {
promptId: string;
+3 -1
View File
@@ -107,12 +107,13 @@ describe("copyTextToSystemClipboard", () => {
expect(spawnMock).toHaveBeenNthCalledWith(1, "wl-copy", [], {
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
expect(spawnMock).toHaveBeenNthCalledWith(
2,
"xclip",
["-selection", "clipboard"],
{ stdio: ["pipe", "ignore", "ignore"] },
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
);
expect(failed.getInput()).toBe("selected text");
expect(succeeded.getInput()).toBe("selected text");
@@ -134,6 +135,7 @@ describe("copyTextToSystemClipboard", () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
expect(wlcopy.getInput()).toBe("plain linux");
});
+2
View File
@@ -142,6 +142,8 @@ function runClipboardCommand(
const child = spawn(command.command, command.args, {
stdio: ["pipe", "ignore", "ignore"],
...(command.env ? { env: command.env } : {}),
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let settled = false;
+2
View File
@@ -115,6 +115,8 @@ async function runCommand(
return await new Promise((resolve) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
const chunks: Buffer[] = [];
let total = 0;
@@ -229,3 +229,15 @@ export function getConfigFooterText({
export function getConfigItemDisplayName(name: string): string {
return name;
}
export function getPluginDiagnosticsLoadingText(
tab: InteractiveConfigTab,
): string | undefined {
if (tab === "tools") {
return "Loading plugin tools...";
}
if (tab === "plugins") {
return "Loading plugin diagnostics...";
}
return undefined;
}
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
});
it("does not treat plugin MCP rows as toggleable", () => {
expect(
isToggleableConfigItem(
createItem({
kind: "mcp",
pluginName: "plugin",
source: "workspace-plugin",
}),
),
).toBe(false);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+31 -15
View File
@@ -25,6 +25,7 @@ import {
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -198,6 +199,7 @@ function appendToolGroupRows(
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
rows.push({
kind: "ext",
@@ -245,17 +247,24 @@ function appendToolRows(
appendExtRows(rows, builtinTools);
}
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
const pluginToolItems = items.filter((item) => item.pluginName);
const pluginGroups = groupToolItems(pluginToolItems);
if (pluginGroups.length > 0) {
rows.push({ kind: "head", label: "Plugins" });
appendToolGroupRows(
rows,
pluginGroups,
getSharedToolNames(items.filter((item) => item.pluginName)),
getSharedToolNames(pluginToolItems),
);
}
}
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
return (
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
);
}
function appendSkillRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
@@ -305,11 +314,18 @@ function withOptimisticToggle(
).filter(Boolean),
);
const updateItems = (items: InteractiveConfigItem[]) =>
items.map((candidate) =>
matchesItem(candidate)
? { ...candidate, enabled: nextEnabled }
: candidate,
);
items.map((candidate) => {
if (matchesItem(candidate)) {
return { ...candidate, enabled: nextEnabled };
}
if (
item.kind === "plugin" &&
(candidate.path === item.path || candidate.pluginPath === item.path)
) {
return { ...candidate, enabled: nextEnabled };
}
return candidate;
});
const updateTools = (items: InteractiveConfigItem[]) =>
items.map((candidate) => {
if (matchesItem(candidate)) {
@@ -381,7 +397,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
);
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
props.configData.tools.some((item) => item.pluginName),
hasPluginDiagnostics(props.configData),
);
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
const [pluginToolsError, setPluginToolsError] = useState<
@@ -465,10 +481,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
} else if (activeTab === "tools") {
appendToolRows(r, activeItems);
if (pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
if (pluginToolsLoading && loadingText) {
r.push({
kind: "detail",
text: "Loading plugin tools...",
text: loadingText,
});
}
if (pluginToolsError) {
@@ -499,9 +516,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: "Loading plugin diagnostics...",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
@@ -549,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(
refreshedData.tools.some((tool) => tool.pluginName),
);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsError(undefined);
}
} catch (error) {
@@ -146,5 +146,50 @@ describe("onboarding auth telemetry forwarding", () => {
// emitted by completeClineDeviceAuth, so passing telemetry to the start
// helper would double-emit the event.
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
);
});
it("falls back to displaying the device auth URL when browser open fails", async () => {
hoisted.openMock.mockRejectedValueOnce(new Error("no browser"));
hoisted.startClineDeviceAuth.mockResolvedValueOnce({
deviceCode: "dc",
userCode: "uc",
verificationUri: "https://verify",
verificationUriComplete: "https://verify?user_code=uc",
expiresInSeconds: 600,
pollIntervalSeconds: 5,
});
hoisted.completeClineDeviceAuth.mockResolvedValueOnce({
access: "a",
refresh: "r",
expires: 0,
});
const setStatus = vi.fn();
runDeviceCodeAuthFlow({
providerId: "cline",
providerSettingsManager: makeManager(),
isAborted: () => false,
setUserCode: vi.fn(),
setVerifyUrl: vi.fn(),
setStatus,
setError: vi.fn(),
onComplete: vi.fn(),
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
);
expect(setStatus).toHaveBeenCalledWith(
"Could not open browser. Visit the URL below.",
);
});
});
+10 -3
View File
@@ -89,11 +89,18 @@ export function runDeviceCodeAuthFlow(input: {
startClineDeviceAuth()
.then((result) => {
if (input.isAborted()) return;
const verifyUrl =
result.verificationUriComplete || result.verificationUri;
input.setUserCode(result.userCode);
input.setVerifyUrl(
result.verificationUriComplete || result.verificationUri,
);
input.setVerifyUrl(verifyUrl);
input.setStatus("Enter the code at the URL below");
try {
void open(verifyUrl, { wait: false }).catch(() => {
input.setStatus("Could not open browser. Visit the URL below.");
});
} catch {
input.setStatus("Could not open browser. Visit the URL below.");
}
completeClineDeviceAuth({
deviceCode: result.deviceCode,
@@ -2,7 +2,6 @@ import {
captureProviderConfigured,
getLocalProviderModels,
getProviderConfigFields,
listLocalProviders,
type ProviderConfigFieldKey,
type ProviderConfigFields,
ProviderSettingsManager,
@@ -17,6 +16,7 @@ import {
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
import {
buildClineModelEntries,
+1
View File
@@ -28,6 +28,7 @@ export type ChatCommandContext = {
getState: () => Promise<ChatCommandState> | ChatCommandState;
setState: (next: ChatCommandState) => Promise<void> | void;
reply: (text: string) => Promise<void> | void;
submitPrompt?: (prompt: string) => Promise<void> | void;
reset?: () => Promise<void> | void;
abort?: () => Promise<void> | void;
stop?: () => Promise<void> | void;
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it } from "vitest";
import {
disposeCliFeatureFlagsService,
getCliFeatureFlagsService,
} from "./feature-flags";
describe("CLI feature flags singleton", () => {
afterEach(async () => {
await disposeCliFeatureFlagsService();
});
it("recreates the singleton after disposal", async () => {
const service = getCliFeatureFlagsService();
await disposeCliFeatureFlagsService();
expect(getCliFeatureFlagsService()).not.toBe(service);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { join } from "node:path";
import {
type BasicLogger,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
registerDisposable,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
let cliFeatureFlagsService: FeatureFlagsService | undefined;
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
function resolveCliFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.json");
}
function ensureCliDistinctId(): string {
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
cliFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
ensureCliDistinctId();
return { ...cliFeatureFlagsContext };
}
export function getCliFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!cliFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
cliFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getCliFeatureFlagsContext(),
cacheFilePath: resolveCliFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
registerDisposable(disposeCliFeatureFlagsService);
}
return cliFeatureFlagsService;
}
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
const service = getCliFeatureFlagsService({ logger });
void service.poll().catch((error) => {
logger?.error?.("Error refreshing CLI feature flags", { error });
});
}
export async function disposeCliFeatureFlagsService(): Promise<void> {
if (!cliFeatureFlagsService) {
return;
}
const current = cliFeatureFlagsService;
cliFeatureFlagsService = undefined;
await current.dispose();
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
if (!cliFeatureFlagsService) {
return;
}
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
try {
await cliFeatureFlagsService.poll();
} catch (error) {
logger?.error?.("Error polling CLI feature flags", { error });
}
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { buildHistoryResumeArgs } from "./history-resume";
describe("buildHistoryResumeArgs", () => {
it("replaces the history subcommand with --id", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
).toEqual(["--id", "sess_1"]);
});
it("preserves global flags that precede the subcommand", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: [
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"history",
"--limit",
"5",
],
remainingArgs: ["history", "--limit", "5"],
}),
).toEqual([
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"--id",
"sess_1",
]);
});
it("keeps a global flag value that matches the subcommand alias", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["-m", "h", "h"],
remainingArgs: ["h"],
}),
).toEqual(["-m", "h", "--id", "sess_1"]);
});
it("forwards a config dir passed as a subcommand option", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--config", "/tmp/conf"],
remainingArgs: ["history", "--config", "/tmp/conf"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("does not duplicate a config dir already in the global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config", "/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("recognizes the --config=<dir> spelling in global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config=/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
});
it("returns undefined when remaining args are not a suffix of argv", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--limit", "5"],
remainingArgs: ["history", "--limit", "9"],
}),
).toBeUndefined();
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["extra", "history"],
}),
).toBeUndefined();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { resolveCliLaunchSpec } from "./internal-launch";
export interface HistoryResumeCommand {
launcher: string;
childArgs: string[];
}
export interface BuildHistoryResumeArgsInput {
sessionId: string;
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
normalizedArgs: string[];
/**
* Commander's `program.args` after parsing: the `history` subcommand token
* and everything following it. Must be a suffix of `normalizedArgs`.
*/
remainingArgs: string[];
/**
* Config dir resolved from the full argv. Forwarded explicitly because
* `--config` may have been passed as a `history` subcommand option, which
* would otherwise be dropped with the rest of the subcommand args.
*/
configDir?: string;
}
/**
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
* after a session is picked in `cline history`. Returns undefined when the
* global-flag prefix cannot be derived safely (caller falls back to resuming
* in-process).
*/
export function buildHistoryResumeArgs(
input: BuildHistoryResumeArgsInput,
): string[] | undefined {
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
const splitIndex = normalizedArgs.length - remainingArgs.length;
if (splitIndex < 0) {
return undefined;
}
for (let i = 0; i < remainingArgs.length; i++) {
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
return undefined;
}
}
const globalArgs = normalizedArgs.slice(0, splitIndex);
const args = [...globalArgs];
const hasConfigFlag = globalArgs.some(
(arg) => arg === "--config" || arg.startsWith("--config="),
);
if (configDir && !hasConfigFlag) {
args.push("--config", configDir);
}
args.push("--id", sessionId);
return args;
}
export function buildHistoryResumeCommand(
input: BuildHistoryResumeArgsInput,
): HistoryResumeCommand | undefined {
const childArgs = buildHistoryResumeArgs(input);
if (!childArgs) {
return undefined;
}
const spec = resolveCliLaunchSpec();
if (!spec) {
return undefined;
}
return {
launcher: spec.launcher,
childArgs: [...spec.childArgsPrefix, ...childArgs],
};
}
/**
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
* process with inherited stdio, and returns its exit code. Creating a second
* OpenTUI renderer in the picker's process can crash natively during teardown
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
* interactive TUI must get a process of its own.
*
* Returns undefined when the child cannot be launched; the caller should fall
* back to resuming in-process.
*/
export async function spawnHistoryResume(
input: BuildHistoryResumeArgsInput,
): Promise<number | undefined> {
const command = buildHistoryResumeCommand(input);
if (!command) {
return undefined;
}
const { spawn } = await import("node:child_process");
return await new Promise<number | undefined>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command.launcher, command.childArgs, {
stdio: "inherit",
});
} catch {
resolve(undefined);
return;
}
// The child shares this foreground process group, so terminal-generated
// Ctrl+C already reaches it. Keep the parent alive to reap the child
// without re-forwarding a second signal into the TUI teardown path.
const suppressParentSignal = () => {};
process.on("SIGINT", suppressParentSignal);
process.on("SIGTERM", suppressParentSignal);
const finish = (value: number | undefined) => {
process.off("SIGINT", suppressParentSignal);
process.off("SIGTERM", suppressParentSignal);
resolve(value);
};
child.once("error", () => finish(undefined));
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
});
}
@@ -65,4 +65,55 @@ describe("plugin chat commands", () => {
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
await shutdown?.();
});
it("bridges plugin command submit prompts onto the chat command context", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-plugin-commands-"));
tempRoots.push(tempRoot);
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
await writeFile(
join(pluginsDir, "submit.js"),
[
"export default {",
" name: 'submit-plugin',",
" manifest: { capabilities: ['commands'] },",
" setup(api) {",
" api.registerCommand({",
" name: 'goal',",
" description: 'Set a goal and submit it',",
" handler: async (input) => ({",
" reply: 'goal:' + input,",
" submitPrompt: input",
" })",
" });",
" },",
"};",
].join("\n"),
);
const { host, shutdown } = await createWorkspaceChatCommandHost({
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const reply = vi.fn(async () => undefined);
const submitPrompt = vi.fn(async () => undefined);
const handled = await host.handle("/goal fix tests", {
enabled: true,
getState: async () => ({
enableTools: false,
autoApproveTools: false,
cwd: tempRoot,
workspaceRoot: tempRoot,
}),
setState: async () => undefined,
reply,
submitPrompt,
});
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("goal:fix tests");
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
await shutdown?.();
});
});
+31 -2
View File
@@ -1,5 +1,6 @@
import {
type AgentExtensionCommand,
type AgentExtensionCommandResult,
type BasicLogger,
createContributionRegistry,
resolveAndLoadAgentPlugins,
@@ -45,13 +46,41 @@ function createPluginCommandDefinition(
names: [normalizedName.toLowerCase()],
run: async ({ args }, context) => {
const result = await command.handler?.(args.join(" "));
if (typeof result === "string" && result.trim()) {
await context.reply(result);
const { reply, submitPrompt } = normalizeCommandResult(result);
if (reply) {
await context.reply(reply);
}
if (submitPrompt) {
await context.submitPrompt?.(submitPrompt);
}
},
};
}
function normalizeCommandResult(
result: AgentExtensionCommandResult | undefined,
): { reply?: string; submitPrompt?: string } {
if (typeof result === "string") {
const reply = result.trim();
return reply ? { reply } : {};
}
if (!result || typeof result !== "object") {
return {};
}
const reply =
typeof result.reply === "string" && result.reply.trim()
? result.reply.trim()
: undefined;
const submitPrompt =
typeof result.submitPrompt === "string" && result.submitPrompt.trim()
? result.submitPrompt.trim()
: undefined;
return {
...(reply ? { reply } : {}),
...(submitPrompt ? { submitPrompt } : {}),
};
}
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
getBooleanFlagEnabled: vi.fn(() => true),
}));
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
listLocalProviders: mocks.listLocalProviders,
};
});
vi.mock("./feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
}),
}));
describe("listLocalProviders", () => {
it("passes the ClinePass feature flag into the SDK provider list", async () => {
const { listLocalProviders } = await import("./provider-catalog");
const manager = {} as never;
await listLocalProviders(manager);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
isClinePassEnabled: true,
});
});
});
+14
View File
@@ -0,0 +1,14 @@
import {
listLocalProviders as internalListLocalProviders,
type ProviderSettingsManager,
} from "@cline/core";
import { getCliFeatureFlagsService } from "./feature-flags";
export async function listLocalProviders(
manager: ProviderSettingsManager,
): ReturnType<typeof internalListLocalProviders> {
return await internalListLocalProviders(manager, {
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
}
+3
View File
@@ -18,9 +18,12 @@ export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
const [branchResult, diffResult] = await Promise.allSettled([
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
encoding: "utf8",
// Prevent a console window from flashing on Windows.
windowsHide: true,
}),
execFileAsync("git", ["-C", cwd, "diff", "--shortstat"], {
encoding: "utf8",
windowsHide: true,
}),
]);
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
import {
captureCliExtensionActivated,
identifyCliTelemetryAccount,
identifyTelemetryAccount,
} from "./telemetry";
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
});
});
describe("identifyCliTelemetryAccount", () => {
describe("identifyTelemetryAccount", () => {
beforeEach(() => {
hoisted.identifyAccount.mockClear();
hoisted.getCliTelemetryService.mockClear();
@@ -107,7 +107,7 @@ describe("identifyCliTelemetryAccount", () => {
memberId: "member-7",
provider: "cline",
};
identifyCliTelemetryAccount(account);
identifyTelemetryAccount(account);
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
});
});
+4 -1
View File
@@ -9,6 +9,7 @@ import {
TelemetryLoggerSink,
} from "@cline/core";
import { getCliBuildInfo } from "./common";
import { identifyFeatureFlagsAccount } from "./feature-flags";
import {
markActivationCaptured,
wasActivationCaptured,
@@ -102,11 +103,12 @@ export interface CliTelemetryAccountContext {
* Safe to call multiple times; the latest values win, mirroring the legacy
* singleton-based behavior.
*/
export function identifyCliTelemetryAccount(
export function identifyTelemetryAccount(
account: CliTelemetryAccountContext,
logger?: BasicLogger,
): void {
identifyAccount(getCliTelemetryService(logger), account);
void identifyFeatureFlagsAccount(account, logger);
}
/**
@@ -134,6 +136,7 @@ export function captureCliExtensionActivated(
const telemetry = getCliTelemetryService(logger);
if (account) {
identifyAccount(telemetry, account);
void identifyFeatureFlagsAccount(account, logger);
}
captureExtensionActivated(telemetry);
}
+1 -35
View File
@@ -1,6 +1,5 @@
import * as p from "@clack/prompts";
import { authorizeMcpServerOAuth } from "@cline/core";
import open from "open";
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
import {
addServer,
clearServerOAuth,
@@ -17,16 +16,6 @@ function isCancel(value: unknown): value is symbol {
return p.isCancel(value);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
function transportLabel(t: McpTransport): string {
if (t.type === "stdio") return `stdio: ${t.command}`;
return `${t.type}: ${t.url}`;
@@ -222,29 +211,6 @@ async function collectUrlTransport(
};
}
async function authorizeOAuth(name: string): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: getSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
async function actionAdd(): Promise<void> {
const name = await p.text({
message: "Server name",
+41
View File
@@ -0,0 +1,41 @@
import * as p from "@clack/prompts";
import {
authorizeMcpServerOAuth,
resolveDefaultMcpSettingsPath,
} from "@cline/core";
import open from "open";
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
export async function authorizeMcpServerOAuthWithBrowser(
name: string,
): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: resolveDefaultMcpSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
+2
View File
@@ -68,6 +68,8 @@ async function runCliConnectCommand(args: string[]): Promise<{
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
let stdout = "";
+7 -1
View File
@@ -134,6 +134,12 @@ export function openExternalUrl(url: string): void {
const command =
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
const child = spawn(command, args, { stdio: "ignore", detached: true });
const child = spawn(command, args, {
stdio: "ignore",
detached: true,
// Prevent a console window from flashing on Windows; the launched
// browser/app still opens normally.
windowsHide: true,
});
child.unref();
}
+6 -2
View File
@@ -8,7 +8,7 @@ import {
Llms,
ProviderSettingsManager,
stopLocalHubServerGracefully,
toHubHealthUrl,
toHubStatusUrl,
} from "@cline/core";
import type { HubUINotifyPayload, SessionRecord } from "@cline/shared";
@@ -460,7 +460,11 @@ async function main(): Promise<void> {
const syncHealthState = async (): Promise<void> => {
try {
const response = await fetch(toHubHealthUrl(hubUrl));
const response = await fetch(toHubStatusUrl(hubUrl), {
headers: hubAuthToken
? { authorization: `Bearer ${hubAuthToken}` }
: undefined,
});
if (!response.ok) {
return;
}
+6 -2
View File
@@ -701,7 +701,9 @@ class CoreChatWebviewController implements vscode.Disposable {
const owner = resolveSharedHubOwnerContext();
if (this.hubUrl) {
const healthy = await probeHubServer(this.hubUrl);
const healthy = await probeHubServer(this.hubUrl, {
authToken: this.hubAuthToken,
});
if (healthy?.url) {
return {
url: rememberRecoverableLocalHubUrl(healthy.url, this.hubAuthToken),
@@ -733,7 +735,9 @@ class CoreChatWebviewController implements vscode.Disposable {
): Promise<HubResolution | undefined> {
const discovery = await readHubDiscovery(discoveryPath);
if (!discovery?.url) return undefined;
const healthy = await probeHubServer(discovery.url);
const healthy = await probeHubServer(discovery.url, {
authToken: discovery.authToken,
});
return healthy?.url
? {
url: rememberRecoverableLocalHubUrl(healthy.url, discovery.authToken),
+16 -34
View File
@@ -1,16 +1,16 @@
{
"name": "claude-dev",
"version": "3.89.0",
"version": "3.89.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.89.0",
"version": "3.89.2",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -156,42 +156,24 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"version": "0.50.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.35 <1",
"@anthropic-ai/sdk": ">=0.50.3 <1",
"google-auth-library": "^9.4.2"
}
},
+4 -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.89.0",
"version": "3.89.2",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -389,7 +389,7 @@
"protos": "node scripts/build-proto.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "npm run clean:build && npm run clean:deps",
@@ -486,8 +486,8 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -0,0 +1,43 @@
import "should";
import {
huggingFaceDefaultModelId,
huggingFaceModels,
} from "../../../../shared/api";
import { HuggingFaceHandler } from "../huggingface";
describe("HuggingFaceHandler", () => {
it("uses dynamic Hugging Face model info for models outside the static list", () => {
const modelInfo = {
maxTokens: 8192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Available on providers: test-provider",
};
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
huggingFaceModelInfo: modelInfo,
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: modelInfo,
});
});
it("preserves unknown model IDs when model info is unavailable", () => {
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: huggingFaceModels[huggingFaceDefaultModelId],
});
});
});
@@ -1,56 +1,66 @@
import { HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
import {
huggingFaceDefaultModelId,
huggingFaceModels,
type ModelInfo,
} from "@shared/api";
import { calculateApiCostOpenAI } from "@utils/cost";
import type OpenAI from "openai";
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { createOpenAIClient } from "@/shared/net";
import type { ApiHandler, CommonApiHandlerOptions } from "../";
import { withRetry } from "../retry";
import { convertToOpenAiMessages } from "../transform/openai-format";
import type { ApiStream } from "../transform/stream";
import {
getOpenAIToolParams,
ToolCallProcessor,
} from "../transform/tool-call-processor";
interface HuggingFaceHandlerOptions extends CommonApiHandlerOptions {
huggingFaceApiKey?: string
huggingFaceModelId?: string
huggingFaceModelInfo?: ModelInfo
huggingFaceApiKey?: string;
huggingFaceModelId?: string;
huggingFaceModelInfo?: ModelInfo;
}
export class HuggingFaceHandler implements ApiHandler {
private options: HuggingFaceHandlerOptions
private client: OpenAI | undefined
private cachedModel: { id: HuggingFaceModelId; info: ModelInfo } | undefined
private options: HuggingFaceHandlerOptions;
private client: OpenAI | undefined;
private cachedModel: { id: string; info: ModelInfo } | undefined;
constructor(options: HuggingFaceHandlerOptions) {
this.options = options
this.options = options;
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.huggingFaceApiKey) {
throw new Error("Hugging Face API key is required")
throw new Error("Hugging Face API key is required");
}
try {
this.client = createOpenAIClient({
baseURL: "https://router.huggingface.co/v1",
apiKey: this.options.huggingFaceApiKey,
})
});
} catch (error: any) {
throw new Error(`Error creating Hugging Face client: ${error.message}`)
throw new Error(`Error creating Hugging Face client: ${error.message}`);
}
}
return this.client
return this.client;
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
private async *yieldUsage(
info: ModelInfo,
usage: OpenAI.Completions.CompletionUsage | undefined,
): ApiStream {
if (!usage) {
return
return;
}
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
const inputTokens = usage.prompt_tokens || 0;
const outputTokens = usage.completion_tokens || 0;
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens);
const usageData = {
type: "usage" as const,
@@ -59,21 +69,25 @@ export class HuggingFaceHandler implements ApiHandler {
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: totalCost,
}
};
yield usageData
yield usageData;
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: OpenAITool[],
): ApiStream {
try {
const client = this.ensureClient()
const model = this.getModel()
const client = this.ensureClient();
const model = this.getModel();
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
];
const requestParams = {
model: model.id,
@@ -83,66 +97,71 @@ export class HuggingFaceHandler implements ApiHandler {
stream_options: { include_usage: true },
temperature: 0,
...getOpenAIToolParams(tools),
}
};
const toolCallProcessor = new ToolCallProcessor()
const stream = (await client.chat.completions.create(requestParams)) as any
const toolCallProcessor = new ToolCallProcessor();
const stream = (await client.chat.completions.create(
requestParams,
)) as any;
let _chunkCount = 0
let _totalContent = ""
let _chunkCount = 0;
let _totalContent = "";
for await (const chunk of stream) {
_chunkCount++
const delta = chunk.choices?.[0]?.delta
_chunkCount++;
const delta = chunk.choices?.[0]?.delta;
if (delta?.content) {
_totalContent += delta.content
_totalContent += delta.content;
yield {
type: "text",
text: delta.content,
}
};
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls);
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
yield* this.yieldUsage(model.info, chunk.usage);
}
}
} catch (error: any) {
throw error
throw error;
}
}
getModel(): { id: HuggingFaceModelId; info: ModelInfo } {
getModel(): { id: string; info: ModelInfo } {
// Return cached model if available
if (this.cachedModel) {
return this.cachedModel
return this.cachedModel;
}
const modelId = this.options.huggingFaceModelId
// List all available models for debugging
const _availableModels = Object.keys(huggingFaceModels)
let result: { id: HuggingFaceModelId; info: ModelInfo }
const modelId = this.options.huggingFaceModelId;
let result: { id: string; info: ModelInfo };
if (modelId && modelId in huggingFaceModels) {
const id = modelId as HuggingFaceModelId
const modelInfo = huggingFaceModels[id]
result = { id, info: modelInfo }
const id = modelId as keyof typeof huggingFaceModels;
const modelInfo = huggingFaceModels[id];
result = { id, info: modelInfo };
} else if (modelId) {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
result = {
id: modelId,
info: this.options.huggingFaceModelInfo || defaultInfo,
};
} else {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId]
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
result = {
id: huggingFaceDefaultModelId,
info: defaultInfo,
}
};
}
// Cache the result for future calls
this.cachedModel = result
this.cachedModel = result;
return result
return result;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
import type { Anthropic } from "@anthropic-ai/sdk";
import {
type ClineStorageMessage,
convertClineStorageToAnthropicMessage,
} from "@/shared/messages/content";
/**
* Converts Cline storage messages to Anthropic API format with optional cache control.
@@ -12,7 +15,7 @@ import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/sh
* @returns Array of Anthropic-compatible messages with cache control applied
*/
export function sanitizeAnthropicMessages(
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
clineMessages: ClineStorageMessage[],
supportCache: boolean,
): Array<Anthropic.MessageParam> {
// The latest message will be the new user message, one before will be the assistant message from a previous request,
@@ -21,32 +24,37 @@ export function sanitizeAnthropicMessages(
// know the last message to retrieve from the cache for the current request.
const userMsgIndices = clineMessages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index)
acc.push(index);
}
return acc
}, [] as number[])
return acc;
}, [] as number[]);
// Set to -1 if there are no user messages so the indices are invalid
const indicesLength = userMsgIndices.length ?? -1
const lastUserMsgIndex = userMsgIndices[indicesLength - 1]
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2]
const indicesLength = userMsgIndices.length ?? -1;
const lastUserMsgIndex = userMsgIndices[indicesLength - 1];
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2];
return clineMessages.map((msg, index) => {
const anthropicMsg = convertClineStorageToAnthropicMessage(msg)
const anthropicMsg = convertClineStorageToAnthropicMessage(msg);
// Add cache control to the last two user messages
if (supportCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
return addCacheControl(anthropicMsg)
if (
supportCache &&
(index === lastUserMsgIndex || index === secondLastMsgUserIndex)
) {
return addCacheControl(anthropicMsg);
}
return anthropicMsg
})
return anthropicMsg;
});
}
const isThinkingBlock = (
block: Anthropic.ContentBlockParam,
): block is Anthropic.Messages.ThinkingBlockParam | Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking"
}
): block is
| Anthropic.Messages.ThinkingBlockParam
| Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking";
};
/**
* Adds ephemeral cache control to the last content block of a message.
@@ -55,7 +63,9 @@ const isThinkingBlock = (
* @param message - The Anthropic message to add cache control to
* @returns A new message with cache control added to the last content block
*/
function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessageParam {
function addCacheControl(
message: Anthropic.MessageParam,
): Anthropic.MessageParam {
// Convert string content to array format
if (typeof message.content === "string") {
return {
@@ -67,24 +77,24 @@ function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessagePara
cache_control: { type: "ephemeral" },
} satisfies Anthropic.TextBlockParam,
],
}
};
}
// Handle array content - add cache control to the last block
const content = [...message.content]
const lastIndex = content.length - 1
const content = [...message.content];
const lastIndex = content.length - 1;
if (lastIndex >= 0) {
const lastBlock = content[lastIndex]
const lastBlock = content[lastIndex];
// Only add cache_control to block types that support it (not ThinkingBlockParam)
if (!isThinkingBlock(lastBlock)) {
content[lastIndex] = {
...lastBlock,
cache_control: { type: "ephemeral" },
} satisfies Anthropic.ContentBlockParam
} satisfies Anthropic.ContentBlockParam;
}
}
return { ...message, content }
return { ...message, content };
}
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, GenerateContentResponse, Part } from "@google/genai"
import { ClineStorageMessage } from "@/shared/messages/content"
import type { Anthropic } from "@anthropic-ai/sdk";
import type { Content, GenerateContentResponse, Part } from "@google/genai";
import type { ClineStorageMessage } from "@/shared/messages/content";
// Source: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
// While injecting custom function call blocks into the request is strongly discouraged,
@@ -8,27 +8,29 @@ import { ClineStorageMessage } from "@/shared/messages/content"
// calls and responses that were executed deterministically by the client, or transferring a
// trace from a different model that does not include thought signatures, you can set the following dummy signatures of either
// "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" in the thought signature field to skip validation.
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
export function convertAnthropicContentToGemini(content: string | ClineStorageMessage["content"]): Part[] {
export function convertAnthropicContentToGemini(
content: string | ClineStorageMessage["content"],
): Part[] {
if (typeof content === "string") {
return [{ text: content }]
return [{ text: content }];
}
return content
.flatMap((block): Part | undefined => {
switch (block.type) {
case "text":
return { text: block.text, thoughtSignature: block.signature }
return { text: block.text, thoughtSignature: block.signature };
case "image":
if (block.source.type !== "base64") {
throw new Error("Unsupported image source type")
throw new Error("Unsupported image source type");
}
return {
inlineData: {
data: block.source.data,
mimeType: block.source.media_type,
},
}
};
case "tool_use":
return {
functionCall: {
@@ -37,7 +39,7 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
},
// Thought signature is required, so provide a dummy one if not present
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
}
};
case "tool_result":
return {
functionResponse: {
@@ -46,57 +48,66 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
result: block.content,
},
},
}
};
case "thinking":
return {
text: block.thinking,
thought: true,
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
}
};
default:
return undefined
return undefined;
}
})
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
.filter((part): part is Part => part !== undefined); // Filter out unsupported blocks
}
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
export function convertAnthropicMessageToGemini(
message: ClineStorageMessage,
): Content {
return {
role: message.role === "assistant" ? "model" : "user",
parts: convertAnthropicContentToGemini(message.content),
}
};
}
/*
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
*/
export function unescapeGeminiContent(content: string) {
return content.replace(/\\n/g, "\n").replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\r/g, "\r").replace(/\\t/g, "\t")
return content
.replace(/\\n/g, "\n")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t");
}
export function convertGeminiResponseToAnthropic(response: GenerateContentResponse): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = []
export function convertGeminiResponseToAnthropic(
response: GenerateContentResponse,
): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = [];
const text = response.text
const text = response.text;
if (text) {
content.push({ type: "text", text, citations: null })
content.push({ type: "text", text, citations: null });
}
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
const finishReason = response.candidates?.[0]?.finishReason
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null;
const finishReason = response.candidates?.[0]?.finishReason;
if (finishReason) {
switch (finishReason) {
case "STOP":
stop_reason = "end_turn"
break
stop_reason = "end_turn";
break;
case "MAX_TOKENS":
stop_reason = "max_tokens"
break
stop_reason = "max_tokens";
break;
case "SAFETY":
case "RECITATION":
case "OTHER":
stop_reason = "stop_sequence"
break
stop_reason = "stop_sequence";
break;
}
}
@@ -113,6 +124,7 @@ export function convertGeminiResponseToAnthropic(response: GenerateContentRespon
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
};
}
@@ -1,29 +1,32 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
import type { Anthropic } from "@anthropic-ai/sdk";
import type { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage";
import type { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage";
import type { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage";
import type { UserMessage } from "@mistralai/mistralai/models/components/usermessage";
import { getImageDataUrl } from "@/shared/messages/content";
export type MistralMessage =
| (SystemMessage & { role: "system" })
| (UserMessage & { role: "user" })
| (AssistantMessage & { role: "assistant" })
| (ToolMessage & { role: "tool" })
| (ToolMessage & { role: "tool" });
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
const mistralMessages: MistralMessage[] = []
export function convertToMistralMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): MistralMessage[] {
const mistralMessages: MistralMessage[] = [];
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
mistralMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
});
} else {
if (anthropicMessage.role === "user") {
// Filter to only include text and image blocks
const textAndImageBlocks = anthropicMessage.content.filter(
(part) => part.type === "text" || part.type === "image",
)
);
if (textAndImageBlocks.length > 0) {
mistralMessages.push({
@@ -33,29 +36,31 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
return {
type: "image_url",
imageUrl: {
url: `data:${part.source.media_type};base64,${part.source.data}`,
url: getImageDataUrl(part.source),
},
}
};
}
return { type: "text", text: part.text }
return { type: "text", text: part.text };
}),
})
});
}
} else if (anthropicMessage.role === "assistant") {
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
const textBlocks = anthropicMessage.content.filter(
(part) => part.type === "text",
);
if (textBlocks.length > 0) {
const content = textBlocks.map((part) => part.text).join("\n")
const content = textBlocks.map((part) => part.text).join("\n");
mistralMessages.push({
role: "assistant",
content,
})
});
}
}
}
}
return mistralMessages
return mistralMessages;
}
+90 -75
View File
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Logger } from "@/shared/services/Logger"
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import { Logger } from "@/shared/services/Logger";
const o1SystemPrompt = (systemPrompt: string) => `
# System Prompt
@@ -164,7 +164,7 @@ I've analyzed the project structure, but I need more information to proceed. Let
<ask_followup_question>
<question>Which specific feature would you like me to implement in the example.py file?</question>
</ask_followup_question>
`
`;
export function convertToO1Messages(
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
@@ -176,26 +176,26 @@ export function convertToO1Messages(
acc.push({
role: "user",
content: message.content || "",
})
});
} else if (message.role === "assistant" && message.tool_calls) {
// Convert tool calls to content and remove tool_calls
let content = message.content || ""
let content = message.content || "";
message.tool_calls.forEach((toolCall) => {
if (toolCall.type === "function") {
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`;
}
})
});
acc.push({
role: "assistant",
content: content,
tool_calls: undefined,
})
});
} else {
// Keep other messages as they are
acc.push(message)
acc.push(message);
}
return acc
}, [] as OpenAI.Chat.ChatCompletionMessageParam[])
return acc;
}, [] as OpenAI.Chat.ChatCompletionMessageParam[]);
// Find the index of the last assistant message
// const lastAssistantIndex = findLastIndex(toolsReplaced, (message) => message.role === "assistant")
@@ -207,7 +207,7 @@ export function convertToO1Messages(
content: o1SystemPrompt(systemPrompt),
} as OpenAI.Chat.ChatCompletionUserMessageParam,
...toolsReplaced,
]
];
// If there's an assistant message, insert the system prompt after it
// if (lastAssistantIndex !== -1) {
@@ -226,12 +226,12 @@ export function convertToO1Messages(
// })
// }
return messagesWithSystemPrompt
return messagesWithSystemPrompt;
}
interface ToolCall {
tool: string
tool_input: Record<string, string>
tool: string;
tool_input: Record<string, string>;
}
const toolNames = [
@@ -243,106 +243,116 @@ const toolNames = [
"write_to_file",
"ask_followup_question",
"attempt_completion",
]
];
function parseAIResponse(response: string): {
normalText: string
toolCalls: ToolCall[]
normalText: string;
toolCalls: ToolCall[];
} {
// Create a regex pattern to match any tool call opening tag
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i")
const match = response.match(toolCallPattern)
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i");
const match = response.match(toolCallPattern);
if (!match) {
// No tool calls found
return { normalText: response.trim(), toolCalls: [] }
return { normalText: response.trim(), toolCalls: [] };
}
const toolCallStart = match.index!
const normalText = response.slice(0, toolCallStart).trim()
const toolCallsText = response.slice(toolCallStart)
const toolCallStart = match.index!;
const normalText = response.slice(0, toolCallStart).trim();
const toolCallsText = response.slice(toolCallStart);
const toolCalls = parseToolCalls(toolCallsText)
const toolCalls = parseToolCalls(toolCallsText);
return { normalText, toolCalls }
return { normalText, toolCalls };
}
function parseToolCalls(toolCallsText: string): ToolCall[] {
const toolCalls: ToolCall[] = []
const toolCalls: ToolCall[] = [];
let remainingText = toolCallsText
let remainingText = toolCallsText;
while (remainingText.length > 0) {
const toolMatch = toolNames.find((tool) => new RegExp(`<${tool}`, "i").test(remainingText))
const toolMatch = toolNames.find((tool) =>
new RegExp(`<${tool}`, "i").test(remainingText),
);
if (!toolMatch) {
break // No more tool calls found
break; // No more tool calls found
}
const startTag = `<${toolMatch}`
const endTag = `</${toolMatch}>`
const startIndex = remainingText.indexOf(startTag)
const endIndex = remainingText.indexOf(endTag, startIndex)
const startTag = `<${toolMatch}`;
const endTag = `</${toolMatch}>`;
const startIndex = remainingText.indexOf(startTag);
const endIndex = remainingText.indexOf(endTag, startIndex);
if (endIndex === -1) {
break // Malformed XML, no closing tag found
break; // Malformed XML, no closing tag found
}
const toolCallContent = remainingText.slice(startIndex, endIndex + endTag.length)
remainingText = remainingText.slice(endIndex + endTag.length).trim()
const toolCallContent = remainingText.slice(
startIndex,
endIndex + endTag.length,
);
remainingText = remainingText.slice(endIndex + endTag.length).trim();
const toolCall = parseToolCall(toolMatch, toolCallContent)
const toolCall = parseToolCall(toolMatch, toolCallContent);
if (toolCall) {
toolCalls.push(toolCall)
toolCalls.push(toolCall);
}
}
return toolCalls
return toolCalls;
}
function parseToolCall(toolName: string, content: string): ToolCall | null {
const tool_input: Record<string, string> = {}
const tool_input: Record<string, string> = {};
// Remove the outer tool tags
const innerContent = content.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "").trim()
const innerContent = content
.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "")
.trim();
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
let match: RegExpExecArray | null
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs;
let match: RegExpExecArray | null;
while ((match = paramRegex.exec(innerContent)) !== null) {
const [, paramName, paramValue] = match
const [, paramName, paramValue] = match;
// Preserve newlines and trim only leading/trailing whitespace
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "")
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "");
}
// Validate required parameters
if (!validateToolInput(toolName, tool_input)) {
Logger.error(`Invalid tool call for ${toolName}:`, content)
return null
Logger.error(`Invalid tool call for ${toolName}:`, content);
return null;
}
return { tool: toolName, tool_input }
return { tool: toolName, tool_input };
}
function validateToolInput(toolName: string, tool_input: Record<string, string>): boolean {
function validateToolInput(
toolName: string,
tool_input: Record<string, string>,
): boolean {
switch (toolName) {
case "execute_command":
return "command" in tool_input
return "command" in tool_input;
case "read_file":
case "list_code_definition_names":
case "list_files":
return "path" in tool_input
return "path" in tool_input;
case "search_files":
return "path" in tool_input && "regex" in tool_input
return "path" in tool_input && "regex" in tool_input;
case "write_to_file":
return "path" in tool_input && "content" in tool_input
return "path" in tool_input && "content" in tool_input;
case "ask_followup_question":
return "question" in tool_input
return "question" in tool_input;
case "attempt_completion":
return "result" in tool_input
return "result" in tool_input;
default:
return false
return false;
}
}
@@ -366,8 +376,10 @@ function validateToolInput(toolName: string, tool_input: Record<string, string>)
export function convertO1ResponseToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
const { normalText, toolCalls } = parseAIResponse(openAiMessage.content || "")
const openAiMessage = completion.choices[0].message;
const { normalText, toolCalls } = parseAIResponse(
openAiMessage.content || "",
);
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
@@ -384,14 +396,14 @@ export function convertO1ResponseToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn"
return "end_turn";
case "length":
return "max_tokens"
return "max_tokens";
case "tool_calls":
return "tool_use"
return "tool_use";
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null
return null;
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -400,23 +412,26 @@ export function convertO1ResponseToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
};
if (toolCalls.length > 0) {
anthropicMessage.content.push(
...toolCalls.map((toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
}
}),
)
...toolCalls.map(
(toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
};
},
),
);
}
return anthropicMessage
return anthropicMessage;
}
// Example usage:
@@ -1,64 +1,68 @@
import { Message } from "ollama"
import type { Message } from "ollama";
import {
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineStorageMessage,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
const ollamaMessages: Message[] = []
export function convertToOllamaMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
): Message[] {
const ollamaMessages: Message[] = [];
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
});
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: string[] = []
const toolResultImages: string[] = [];
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
let content: string;
if (typeof toolMessage.content === "string") {
content = toolMessage.content
content = toolMessage.content;
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
return "(see following user message for image)"
toolResultImages.push(getImageDataUrl(part.source));
return "(see following user message for image)";
}
return part.text
return part.text;
})
.join("\n") ?? ""
.join("\n") ?? "";
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
})
})
});
});
// Process non-tool messages
if (nonToolMessages.length > 0) {
@@ -67,49 +71,50 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return `data:${part.source.media_type};base64,${part.source.data}`
return getImageDataUrl(part.source);
}
return part.text
return part.text;
})
.join("\n"),
})
});
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
// Process non-tool messages
let content: string = ""
let content: string = "";
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
return ""; // impossible as the assistant cannot send images
}
return part.text
return part.text;
})
.join("\n")
.join("\n");
}
ollamaMessages.push({
role: "assistant",
content,
})
});
}
}
}
return ollamaMessages
return ollamaMessages;
}
@@ -1,19 +1,19 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProvider } from "@/shared/api"
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import type { ApiProvider } from "@/shared/api";
import {
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
type ClineAssistantRedactedThinkingBlock,
type ClineAssistantThinkingBlock,
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
import { Logger } from "@/shared/services/Logger";
// OpenAI API has a maximum tool call ID length of 40 characters
const MAX_TOOL_CALL_ID_LENGTH = 40
const MAX_TOOL_CALL_ID_LENGTH = 40;
/**
* Determines if a given tool ID follows the OpenAI Responses API format for tool calls.
@@ -23,7 +23,7 @@ const MAX_TOOL_CALL_ID_LENGTH = 40
* @returns True if the tool ID matches the OpenAI Responses API format, false otherwise
*/
function isOpenAIResponseToolId(callId: string): boolean {
return callId.startsWith("fc_") && callId.length === 53
return callId.startsWith("fc_") && callId.length === 53;
}
/**
@@ -37,21 +37,24 @@ function isOpenAIResponseToolId(callId: string): boolean {
* @param provider - The API provider that the OpenAI formatted messages will be sent to
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): 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))}`
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`;
}
if (provider !== "openai-native") {
return toolId
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)
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH);
}
return toolId
return toolId;
}
/**
@@ -65,17 +68,17 @@ function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider)
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
anthropicMessages: Anthropic.Messages.MessageParam[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
openAiMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
});
} else {
// image_url.url is base64 encoded image data
// ensure it contains the content-type of the image: data:image/png;base64,
@@ -86,52 +89,56 @@ export function convertToOpenAiMessages(
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // user cannot send tool_use messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: ClineImageContentBlock[] = []
const toolResultImages: ClineImageContentBlock[] = [];
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
let content: string;
if (typeof toolMessage.content === "string") {
content = toolMessage.content
content = toolMessage.content;
} else if (Array.isArray(toolMessage.content)) {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(part)
return "(see following user message for image)"
toolResultImages.push(part);
return "(see following user message for image)";
}
return part.text
return part.text;
})
.join("\n") ?? ""
.join("\n") ?? "";
} else {
// Handle undefined content
content = ""
content = "";
}
openAiMessages.push({
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: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
tool_call_id: transformToolCallIdForNativeApi(
toolMessage.tool_use_id,
provider,
),
content: content,
})
})
});
});
// If tool results contain images, send as a separate user message
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
@@ -144,9 +151,9 @@ export function convertToOpenAiMessages(
role: "user",
content: toolResultImages.map((part) => ({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
image_url: { url: getImageDataUrl(part.source) },
})),
})
});
}
// Process non-tool messages
@@ -158,106 +165,117 @@ export function convertToOpenAiMessages(
return {
type: "image_url",
image_url: {
url: `data:${part.source.media_type};base64,${part.source.data}`,
url: getImageDataUrl(part.source),
},
}
};
}
return { type: "text", text: part.text }
return { type: "text", text: part.text };
}),
})
});
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
// Process non-tool messages
let content: string | undefined
const reasoningDetails: any[] = []
const thinkingBlock = []
let content: string | undefined;
const reasoningDetails: any[] = [];
const thinkingBlock = [];
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
const anyPart = part as any
const anyPart = part as any;
if (part.type === "text" && anyPart.reasoning_details) {
if (Array.isArray(anyPart.reasoning_details)) {
reasoningDetails.push(...anyPart.reasoning_details)
reasoningDetails.push(...anyPart.reasoning_details);
} else {
reasoningDetails.push(anyPart.reasoning_details)
reasoningDetails.push(anyPart.reasoning_details);
}
}
if (part.type === "thinking" && part.thinking) {
// Reasoning details should have been moved to the text block
thinkingBlock.push(part)
thinkingBlock.push(part);
}
})
});
content = nonToolMessages
.map((part) => {
if (part.type === "text" && part.text) {
return part.text
return part.text;
}
return ""
return "";
})
.join("\n")
.join("\n");
}
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details
const toolId = toolMessage.id
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolId)
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails)
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails)
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] =
toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details;
const toolId = toolMessage.id;
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter(
(detail: any) => detail?.id === toolId,
);
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails);
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any;
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails);
}
}
}
}
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}
})
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
};
});
// Set content to blank when tool_calls are present but content has no text, per OpenAI API spec
const hasToolCalls = tool_calls.length > 0
const hasMeaningfulContent = content !== undefined && content.trim() !== ""
const finalContent = hasMeaningfulContent ? content : hasToolCalls ? null : undefined
const hasToolCalls = tool_calls.length > 0;
const hasMeaningfulContent =
content !== undefined && content.trim() !== "";
const finalContent = hasMeaningfulContent
? content
: hasToolCalls
? null
: undefined;
const consolidatedReasoningDetails =
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails as any) : []
reasoningDetails.length > 0
? consolidateReasoningDetails(reasoningDetails as any)
: [];
openAiMessages.push({
role: "assistant",
@@ -266,86 +284,91 @@ export function convertToOpenAiMessages(
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
// Only include reasoning_details when non-empty; sending [] can trigger provider validation issues.
// @ts-expect-error
reasoning_details: consolidatedReasoningDetails.length > 0 ? consolidatedReasoningDetails : undefined,
})
reasoning_details:
consolidatedReasoningDetails.length > 0
? consolidatedReasoningDetails
: undefined,
});
}
}
}
return openAiMessages
return openAiMessages;
}
// Type for OpenRouter's reasoning detail elements
// https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
type ReasoningDetail = {
// https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string
data?: string // Encrypted reasoning data
signature?: string | null
id?: string | null // Unique identifier for the reasoning detail
type: string; // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string;
data?: string; // Encrypted reasoning data
signature?: string | null;
id?: string | null; // Unique identifier for the reasoning detail
/*
The format of the reasoning detail, with possible values:
"unknown" - Format is not specified
"openai-responses-v1" - OpenAI responses format version 1
"anthropic-claude-v1" - Anthropic Claude format version 1 (default)
*/
format: string //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number // Sequential index of the reasoning detail
}
format: string; //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number; // Sequential index of the reasoning detail
};
// Helper function to convert reasoning_details array to the format OpenRouter API expects
// Takes an array of reasoning detail objects and consolidates them by index
function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
function consolidateReasoningDetails(
reasoningDetails: ReasoningDetail[],
): ReasoningDetail[] {
if (!reasoningDetails || reasoningDetails.length === 0) {
return []
return [];
}
// Group by index
const groupedByIndex = new Map<number, ReasoningDetail[]>()
const groupedByIndex = new Map<number, ReasoningDetail[]>();
for (const detail of reasoningDetails) {
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
// See: https://github.com/cline/cline/issues/8214
if (detail.type === "reasoning.encrypted" && !detail.data) continue
if (detail.type === "reasoning.encrypted" && !detail.data) continue;
const index = detail.index ?? 0
const index = detail.index ?? 0;
if (!groupedByIndex.has(index)) {
groupedByIndex.set(index, [])
groupedByIndex.set(index, []);
}
groupedByIndex.get(index)!.push(detail)
groupedByIndex.get(index)!.push(detail);
}
// Consolidate each group
const consolidated: ReasoningDetail[] = []
const consolidated: ReasoningDetail[] = [];
for (const [index, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = ""
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
let concatenatedText = "";
let signature: string | undefined;
let id: string | undefined;
let format = "unknown";
let type = "reasoning.text";
for (const detail of details) {
if (detail.text) {
concatenatedText += detail.text
concatenatedText += detail.text;
}
// Keep the signature from the last item that has one
if (detail.signature) {
signature = detail.signature
signature = detail.signature;
}
// Keep the id from the last item that has one
if (detail.id) {
id = detail.id
id = detail.id;
}
// Keep format and type from any item (they should all be the same)
if (detail.format) {
format = detail.format
format = detail.format;
}
if (detail.type) {
type = detail.type
type = detail.type;
}
}
@@ -358,12 +381,12 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
id: id,
format: format,
index: index,
}
consolidated.push(consolidatedEntry)
};
consolidated.push(consolidatedEntry);
}
// For encrypted chunks (data), only keep the last one
let lastDataEntry: ReasoningDetail | undefined
let lastDataEntry: ReasoningDetail | undefined;
for (const detail of details) {
if (detail.data) {
lastDataEntry = {
@@ -373,23 +396,25 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
id: detail.id,
format: detail.format,
index: index,
}
};
}
}
if (lastDataEntry) {
consolidated.push(lastDataEntry)
consolidated.push(lastDataEntry);
}
}
return consolidated
return consolidated;
}
// Unique name to use to filter out tool call that cannot be parsed correctly
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_"
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_";
// Convert OpenAI response to Anthropic format
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
export function convertToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
type: "message",
@@ -405,14 +430,14 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn"
return "end_turn";
case "length":
return "max_tokens"
return "max_tokens";
case "tool_calls":
return "tool_use"
return "tool_use";
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null
return null;
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -421,37 +446,40 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
};
try {
if (openAiMessage?.tool_calls?.length) {
const functionCalls = openAiMessage.tool_calls.filter((tc: any) => tc?.type === "function" && tc.function)
const functionCalls = openAiMessage.tool_calls.filter(
(tc: any) => tc?.type === "function" && tc.function,
);
if (functionCalls.length > 0) {
anthropicMessage.content.push(
...functionCalls.map((toolCall: any): Anthropic.ToolUseBlock => {
let parsedInput = {}
let parsedInput = {};
try {
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
parsedInput = JSON.parse(toolCall.function?.arguments || "{}");
} catch (error) {
Logger.error("Failed to parse tool arguments:", error)
Logger.error("Failed to parse tool arguments:", error);
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function?.name || UNIQUE_ERROR_TOOL_NAME,
input: parsedInput,
}
};
}),
)
);
}
return anthropicMessage
return anthropicMessage;
}
} catch (error) {
Logger.error("Error converting OpenAI message to Anthropic format:", error)
Logger.error("Error converting OpenAI message to Anthropic format:", error);
}
return anthropicMessage
return anthropicMessage;
}
/**
@@ -470,43 +498,47 @@ export function sanitizeGeminiMessages(
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
if (!modelId.includes("gemini")) {
return messages
return messages;
}
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
const droppedToolCallIds = new Set<string>();
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
const anyMsg = msg as any;
const toolCalls = anyMsg.tool_calls;
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
const reasoningDetails = anyMsg.reasoning_details;
const hasReasoningDetails =
Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id)
droppedToolCallIds.add(tc.id);
}
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
sanitized.push({
role: "assistant",
content: anyMsg.content,
} as any);
}
continue
continue;
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any
const anyMsg = msg as any;
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue
continue;
}
}
sanitized.push(msg)
sanitized.push(msg);
}
return sanitized
return sanitized;
}
@@ -1,5 +1,13 @@
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
import { ClineStorageMessage } from "@/shared/messages/content"
import type {
ResponseInput,
ResponseInputMessageContentList,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import {
type ClineStorageMessage,
getBase64ImageSource,
getImageDataUrl,
} from "@/shared/messages/content";
/**
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
@@ -75,56 +83,69 @@ export function convertToOpenAIResponsesInput(
_messages: ClineStorageMessage[],
options?: { usePreviousResponseId?: boolean },
): {
input: ResponseInput
previousResponseId?: string
input: ResponseInput;
previousResponseId?: string;
} {
// Chain from the latest stored Responses API assistant message when available.
// When chaining, only send new items after that assistant turn.
let previousResponseId: string | undefined
let messages = _messages
let previousResponseId: string | undefined;
let messages = _messages;
if (options?.usePreviousResponseId) {
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i]
const msg = _messages[i];
// 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
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
previousResponseId = msg.id;
messages = _messages.slice(i + 1);
break;
}
}
}
const allItems: any[] = []
const toolUseIdToCallId = new Map<string, string>()
const allItems: any[] = [];
const toolUseIdToCallId = new Map<string, string>();
for (const m of messages) {
if (typeof m.content === "string") {
allItems.push({ role: m.role, content: [{ type: "input_text", text: m.content }] })
continue
allItems.push({
role: m.role,
content: [{ type: "input_text", text: m.content }],
});
continue;
}
if (m.role === "assistant") {
// For assistant messages, we must ensure reasoning items are IMMEDIATELY followed
// by their corresponding message or function_call. Process the entire assistant
// turn and ensure proper pairing.
const assistantItems: any[] = []
const assistantItems: any[] = [];
for (const part of m.content) {
switch (part.type) {
case "thinking":
case "thinking": {
// Only include reasoning item if it has actual content (thinking text or summary)
// Empty reasoning items cause API errors: "Item 'rs_...' of type 'reasoning' was provided without its required following item"
const hasThinkingContent = part.thinking && part.thinking.trim().length > 0
const hasSummaryContent = part.summary && Array.isArray(part.summary) && part.summary.length > 0
const hasThinkingContent =
part.thinking && part.thinking.trim().length > 0;
const hasSummaryContent =
part.summary &&
Array.isArray(part.summary) &&
part.summary.length > 0;
if (part.call_id && part.call_id.length > 0 && (hasThinkingContent || hasSummaryContent)) {
if (
part.call_id &&
part.call_id.length > 0 &&
(hasThinkingContent || hasSummaryContent)
) {
// Use summary if available, otherwise use thinking text
let summary: any[] = []
let summary: any[] = [];
if (hasSummaryContent) {
// part.summary is already in the correct format from OpenAI Responses API
summary = part.summary as any[]
summary = part.summary as any[];
} else if (hasThinkingContent) {
// Convert thinking text to summary format
summary = [
@@ -132,16 +153,17 @@ export function convertToOpenAIResponsesInput(
type: "summary_text",
text: part.thinking,
},
]
];
}
assistantItems.push({
id: part.call_id,
type: "reasoning",
summary,
} as ResponseReasoningItem)
} as ResponseReasoningItem);
}
break
break;
}
case "redacted_thinking":
// Include reasoning item with encrypted content if it has a call_id
// Even if data is missing, we need to maintain the reasoning-function_call pairing
@@ -150,100 +172,115 @@ export function convertToOpenAIResponsesInput(
id: part.call_id,
type: "reasoning",
summary: [],
}
};
// Only include encrypted_content if data exists
if (part.data) {
reasoningItem.encrypted_content = part.data
reasoningItem.encrypted_content = part.data;
}
assistantItems.push(reasoningItem as ResponseReasoningItem)
assistantItems.push(reasoningItem as ResponseReasoningItem);
}
break
case "text":
break;
case "text": {
// Message ID goes at the message level, not in the content
// The reasoning item and message can have different IDs - they just need to be adjacent
const messageItem: any = {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: part.text }],
}
};
// Set message-level id if available
if (part.call_id) {
messageItem.id = part.call_id
messageItem.id = part.call_id;
}
assistantItems.push(messageItem)
break
case "image":
assistantItems.push(messageItem);
break;
}
case "image": {
// Message ID goes at the message level, not in the content
const imageItem: any = {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
}
content: [
{
type: "output_text",
text: `[image:${getBase64ImageSource(part.source).mediaType}]`,
},
],
};
// Set message-level id if available (though images typically don't have call_id)
if (part.call_id) {
imageItem.id = part.call_id
imageItem.id = part.call_id;
}
assistantItems.push(imageItem)
break
assistantItems.push(imageItem);
break;
}
case "tool_use": {
// Function calls use call_id, not related to reasoning item ID
const call_id = part.call_id || part.id
const call_id = part.call_id || part.id;
if (part.call_id) {
toolUseIdToCallId.set(part.id, part.call_id)
toolUseIdToCallId.set(part.id, part.call_id);
}
assistantItems.push({
type: "function_call",
call_id,
// MAX 53 characters for OpenAI Responses API tool IDs
id: !part.id.startsWith("fc_") ? `fc_${part.id.slice(0, 50)}` : part.id,
id: !part.id.startsWith("fc_")
? `fc_${part.id.slice(0, 50)}`
: part.id,
name: part.name,
arguments: JSON.stringify(part.input ?? {}),
})
break
});
break;
}
}
}
allItems.push(...assistantItems)
allItems.push(...assistantItems);
} else {
// User messages - collect all content
const messageContent: ResponseInputMessageContentList = []
const messageContent: ResponseInputMessageContentList = [];
for (const part of m.content) {
switch (part.type) {
case "text":
messageContent.push({ type: "input_text", text: part.text })
break
messageContent.push({ type: "input_text", text: part.text });
break;
case "image":
messageContent.push({
type: "input_image",
detail: "auto",
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
})
break
image_url: getImageDataUrl(part.source),
});
break;
case "tool_result": {
// Flush any pending message content before adding tool result
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] })
messageContent.length = 0
allItems.push({ role: m.role, content: [...messageContent] });
messageContent.length = 0;
}
const call_id = part.call_id || toolUseIdToCallId.get(part.tool_use_id) || part.tool_use_id
const call_id =
part.call_id ||
toolUseIdToCallId.get(part.tool_use_id) ||
part.tool_use_id;
allItems.push({
type: "function_call_output",
call_id,
output: typeof part.content === "string" ? part.content : JSON.stringify(part.content),
})
break
output:
typeof part.content === "string"
? part.content
: JSON.stringify(part.content),
});
break;
}
}
}
// Flush any remaining user message content
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] })
allItems.push({ role: m.role, content: [...messageContent] });
}
}
}
return { input: allItems, previousResponseId }
return { input: allItems, previousResponseId };
}
+118 -92
View File
@@ -1,13 +1,17 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import {
type ClineAssistantThinkingBlock,
type ClineStorageMessage,
getImageDataUrl,
} from "@/shared/messages/content";
/**
* DeepSeek Reasoner message format with reasoning_content support.
*/
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
reasoning_content?: string
}
reasoning_content?: string;
};
/**
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
@@ -21,43 +25,45 @@ export function addReasoningContent(
// Find last user message index (start of current turn)
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
// so reasoning_content will be added to all assistant messages. This is intentional.
let lastUserIndex = -1
let lastUserIndex = -1;
for (let i = openAiMessages.length - 1; i >= 0; i--) {
if (openAiMessages[i].role === "user") {
lastUserIndex = i
break
lastUserIndex = i;
break;
}
}
// Extract thinking content from original messages, keyed by assistant index
const thinkingByIndex = new Map<number, string>()
let assistantIdx = 0
const thinkingByIndex = new Map<number, string>();
let assistantIdx = 0;
for (const msg of originalMessages) {
if (msg.role === "assistant") {
if (Array.isArray(msg.content)) {
const thinking = msg.content
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
.filter(
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
)
.map((p) => p.thinking)
.join("\n")
.join("\n");
if (thinking) {
thinkingByIndex.set(assistantIdx, thinking)
thinkingByIndex.set(assistantIdx, thinking);
}
}
assistantIdx++
assistantIdx++;
}
}
// Add reasoning_content only to assistant messages in current turn
let aiIdx = 0
let aiIdx = 0;
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
if (msg.role === "assistant") {
const thinking = thinkingByIndex.get(aiIdx++)
const thinking = thinkingByIndex.get(aiIdx++);
if (thinking && i >= lastUserIndex) {
return { ...msg, reasoning_content: thinking }
return { ...msg, reasoning_content: thinking };
}
}
return msg
})
return msg;
});
}
/**
@@ -68,84 +74,104 @@ export function addReasoningContent(
* @param messages Array of Anthropic messages
* @returns Array of OpenAI messages where consecutive messages with the same role are merged together
*/
export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>((merged, message) => {
const lastMessage = merged[merged.length - 1]
let messageContent: string | (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] =
""
let hasImages = false
export function convertToR1Format(
messages: Anthropic.Messages.MessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>(
(merged, message) => {
const lastMessage = merged[merged.length - 1];
let messageContent:
| string
| (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = "";
let hasImages = false;
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = []
if (Array.isArray(message.content)) {
const textParts: string[] = [];
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = [];
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text)
}
if (part.type === "image") {
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
}
})
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text);
}
if (part.type === "image") {
hasImages = true;
imageParts.push({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
});
}
});
if (hasImages) {
const parts: (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
messageContent = parts
} else {
messageContent = textParts.join("\n")
}
} else {
messageContent = message.content
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (typeof lastMessage.content === "string" && typeof messageContent === "string") {
lastMessage.content += `\n${messageContent}`
} else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }]
if (message.role === "assistant") {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"]
lastMessage.content = mergedContent
if (hasImages) {
const parts: (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = [];
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") });
}
parts.push(...imageParts);
messageContent = parts;
} else {
const mergedContent = [...lastContent, ...newContent] as OpenAI.Chat.ChatCompletionUserMessageParam["content"]
lastMessage.content = mergedContent
messageContent = textParts.join("\n");
}
}
} else {
// Adds new message with the correct type based on role
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content: messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
}
merged.push(newMessage)
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content: messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
}
merged.push(newMessage)
messageContent = message.content;
}
}
return merged
}, [])
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (
typeof lastMessage.content === "string" &&
typeof messageContent === "string"
) {
lastMessage.content += `\n${messageContent}`;
} else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }];
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }];
if (message.role === "assistant") {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"];
lastMessage.content = mergedContent;
} else {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionUserMessageParam["content"];
lastMessage.content = mergedContent;
}
}
} else {
// Adds new message with the correct type based on role
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content:
messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
};
merged.push(newMessage);
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content:
messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
};
merged.push(newMessage);
}
}
return merged;
},
[],
);
}
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
import type { Anthropic } from "@anthropic-ai/sdk";
import * as vscode from "vscode";
import { Logger } from "@/shared/services/Logger";
/**
* Safely converts a value into a plain object.
@@ -8,31 +8,31 @@ import { Logger } from "@/shared/services/Logger"
export function asObjectSafe(value: any): object {
// Handle null/undefined
if (!value) {
return {}
return {};
}
try {
// Handle strings that might be JSON
if (typeof value === "string") {
return JSON.parse(value)
return JSON.parse(value);
}
// Handle pre-existing objects
if (typeof value === "object") {
return Object.assign({}, value)
return Object.assign({}, value);
}
return {}
return {};
} catch (error) {
Logger.warn("Cline <Language Model API>: Failed to parse object:", error)
return {}
Logger.warn("Cline <Language Model API>: Failed to parse object:", error);
return {};
}
}
export function convertToVsCodeLmMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): vscode.LanguageModelChatMessage[] {
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = []
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [];
for (const anthropicMessage of anthropicMessages) {
// Handle simple string messages
@@ -41,27 +41,31 @@ export function convertToVsCodeLmMessages(
anthropicMessage.role === "assistant"
? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content)
: vscode.LanguageModelChatMessage.User(anthropicMessage.content),
)
continue
);
continue;
}
// Handle complex message structures
switch (anthropicMessage.role) {
case "user": {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolResultBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
// Process tool messages first then non-tool messages
const contentParts = [
@@ -74,46 +78,55 @@ export function convertToVsCodeLmMessages(
: (toolMessage.content?.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
}
return new vscode.LanguageModelTextPart(part.text)
}) ?? [new vscode.LanguageModelTextPart("")])
return new vscode.LanguageModelTextPart(part.text);
}) ?? [new vscode.LanguageModelTextPart("")]);
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
return new vscode.LanguageModelToolResultPart(
toolMessage.tool_use_id,
toolContentParts,
);
}),
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
}
return new vscode.LanguageModelTextPart(part.text)
return new vscode.LanguageModelTextPart(part.text);
}),
]
];
// Add single user message with all content parts
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts))
break
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.User(contentParts),
);
break;
}
case "assistant": {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolUseBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
// Process tool messages first then non-tool messages
const contentParts = [
@@ -130,20 +143,24 @@ export function convertToVsCodeLmMessages(
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]")
return new vscode.LanguageModelTextPart(
"[Image generation not supported by VSCode LM API]",
);
}
return new vscode.LanguageModelTextPart(part.text)
return new vscode.LanguageModelTextPart(part.text);
}),
]
];
// Add the assistant message to the list of messages
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts))
break
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.Assistant(contentParts),
);
break;
}
}
}
return vsCodeLmMessages
return vsCodeLmMessages;
}
export function convertToAnthropicRole(
@@ -151,18 +168,22 @@ export function convertToAnthropicRole(
): Anthropic.Messages.MessageParam["role"] | null {
switch (vsCodeLmMessageRole) {
case vscode.LanguageModelChatMessageRole.Assistant:
return "assistant"
return "assistant";
case vscode.LanguageModelChatMessageRole.User:
return "user"
return "user";
default:
return null
return null;
}
}
export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelChatMessage): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role)
export function convertToAnthropicMessage(
vsCodeLmMessage: vscode.LanguageModelChatMessage,
): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role);
if (anthropicRole !== "assistant") {
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
throw new Error(
"Cline <Language Model API>: Only assistant messages are supported.",
);
}
return {
@@ -177,7 +198,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
type: "text",
text: part.value,
citations: null,
}
};
}
if (part instanceof vscode.LanguageModelToolCallPart) {
@@ -186,10 +207,10 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
id: part.callId || crypto.randomUUID(),
name: part.name,
input: asObjectSafe(part.input),
}
};
}
return null
return null;
})
.filter((part): part is Anthropic.ContentBlock => part !== null),
stop_reason: null,
@@ -199,6 +220,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
}
};
}
+132 -86
View File
@@ -1,33 +1,33 @@
import { findLastIndex } from "@shared/array"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { ClineStorageMessage } from "@shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import type { ContextManager } from "../context/context-management/ContextManager"
import type { MessageStateHandler } from "../task/message-state"
import type { HookModelInputContext } from "./hook-factory"
import { findLastIndex } from "@shared/array";
import type { ClineMessage } from "@shared/ExtensionMessage";
import type { ClineStorageMessage } from "@shared/messages/content";
import { Logger } from "@/shared/services/Logger";
import type { ContextManager } from "../context/context-management/ContextManager";
import type { MessageStateHandler } from "../task/message-state";
import type { HookModelInputContext } from "./hook-factory";
/**
* Active hook execution state
* Represents a hook process that is currently running
*/
export type HookExecution = {
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
}
hookName: string;
toolName?: string;
messageTs: number;
abortController: AbortController;
};
/**
* Custom error class for hook cancellation
* Used to signal that a hook cancelled an operation
*/
export class HookCancellationError extends Error {
public readonly wasCancelled: boolean
public readonly wasCancelled: boolean;
constructor(wasCancelled: boolean) {
super("Hook cancelled the operation")
this.name = "HookCancellationError"
this.wasCancelled = wasCancelled
super("Hook cancelled the operation");
this.name = "HookCancellationError";
this.wasCancelled = wasCancelled;
}
}
@@ -35,10 +35,10 @@ export class HookCancellationError extends Error {
* Token usage information extracted from an API request message
*/
export interface TokenUsage {
tokensIn: number
tokensOut: number
tokensInCache: number
tokensOutCache: number
tokensIn: number;
tokensOut: number;
tokensInCache: number;
tokensOutCache: number;
}
/**
@@ -46,29 +46,34 @@ export interface TokenUsage {
* @param message The API request message to parse
* @returns Token usage information, or zeros if parsing fails
*/
export function extractTokenUsageFromMessage(message: ClineMessage | undefined): TokenUsage {
export function extractTokenUsageFromMessage(
message: ClineMessage | undefined,
): TokenUsage {
const defaultUsage: TokenUsage = {
tokensIn: 0,
tokensOut: 0,
tokensInCache: 0,
tokensOutCache: 0,
}
};
if (!message?.text) {
return defaultUsage
return defaultUsage;
}
try {
const apiReqInfo = JSON.parse(message.text)
const apiReqInfo = JSON.parse(message.text);
return {
tokensIn: apiReqInfo.tokensIn || 0,
tokensOut: apiReqInfo.tokensOut || 0,
tokensInCache: apiReqInfo.cacheWrites || 0,
tokensOutCache: apiReqInfo.cacheReads || 0,
}
};
} catch (error) {
Logger.error("[PreCompact] Failed to parse API request token usage:", error)
return defaultUsage
Logger.error(
"[PreCompact] Failed to parse API request token usage:",
error,
);
return defaultUsage;
}
}
@@ -76,9 +81,9 @@ export function extractTokenUsageFromMessage(message: ClineMessage | undefined):
* Context files written for hook access
*/
export interface PreCompactContextFiles {
contextJsonPath: string
contextRawPath: string
hookTimestamp: number
contextJsonPath: string;
contextRawPath: string;
hookTimestamp: number;
}
/**
@@ -91,23 +96,32 @@ export async function writePreCompactContextFiles(
taskId: string,
currentContext: ClineStorageMessage[],
): Promise<PreCompactContextFiles> {
const { writeConversationHistoryJson, writeConversationHistoryText } = await import("../storage/disk")
const { writeConversationHistoryJson, writeConversationHistoryText } =
await import("../storage/disk");
// Generate single timestamp for both files to ensure they match
const hookTimestamp = Date.now()
const hookTimestamp = Date.now();
// Write context files for hook access
const contextJsonPath = await writeConversationHistoryJson(taskId, currentContext, hookTimestamp)
const contextRawPath = await writeConversationHistoryText(taskId, currentContext, hookTimestamp)
const contextJsonPath = await writeConversationHistoryJson(
taskId,
currentContext,
hookTimestamp,
);
const contextRawPath = await writeConversationHistoryText(
taskId,
currentContext,
hookTimestamp,
);
return { contextJsonPath, contextRawPath, hookTimestamp }
return { contextJsonPath, contextRawPath, hookTimestamp };
}
/**
* Task state interface for cancellation handling
*/
export interface TaskStateForCancellation {
didFinishAbortingStream: boolean
didFinishAbortingStream: boolean;
}
/**
@@ -117,53 +131,61 @@ export interface TaskStateForCancellation {
export interface PreCompactHookParams {
// Task identification
/** Task identifier */
taskId: string
taskId: string;
/** ULID for telemetry */
ulid: string
ulid: string;
/** Active hook model context */
modelContext: HookModelInputContext
modelContext: HookModelInputContext;
// Conversation state
/** API conversation history */
apiConversationHistory: ClineStorageMessage[]
apiConversationHistory: ClineStorageMessage[];
/** Current deleted range (if any) */
conversationHistoryDeletedRange?: [number, number]
conversationHistoryDeletedRange?: [number, number];
/** Cline messages for extracting token usage */
clineMessages: ClineMessage[]
clineMessages: ClineMessage[];
// Services
/** Context manager for getting truncated messages */
contextManager: ContextManager
contextManager: ContextManager;
/** Message state handler for accessing conversation data */
messageStateHandler: MessageStateHandler
messageStateHandler: MessageStateHandler;
// Compaction metadata
/** Compaction strategy to report in hook data */
compactionStrategy: string
compactionStrategy: string;
/** Optional: Pre-calculated deleted range to report */
deletedRange?: [number, number]
deletedRange?: [number, number];
// UI callbacks
/** Callback to display messages */
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
say: (
type: any,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>;
/** Callback to save state and post to webview */
postStateToWebview: () => Promise<void>
postStateToWebview: () => Promise<void>;
// Hook management callbacks
/** Callback to set active hook execution */
setActiveHookExecution: (hookExecution: HookExecution | undefined) => Promise<void>
setActiveHookExecution: (
hookExecution: HookExecution | undefined,
) => Promise<void>;
/** Callback to clear active hook execution */
clearActiveHookExecution: () => Promise<void>
clearActiveHookExecution: () => Promise<void>;
// Cancellation dependencies
/** Task state object for setting abort flag */
taskState: TaskStateForCancellation
taskState: TaskStateForCancellation;
/** Callback to cancel the task */
cancelTask: () => Promise<void>
cancelTask: () => Promise<void>;
// Configuration
/** Whether hooks are enabled */
hooksEnabled: boolean
hooksEnabled: boolean;
}
/**
@@ -171,7 +193,7 @@ export interface PreCompactHookParams {
*/
export interface PreCompactHookResult {
/** Context modification provided by the hook */
contextModification?: string
contextModification?: string;
}
/**
@@ -184,37 +206,52 @@ export interface PreCompactHookResult {
* @throws HookCancellationError if the hook cancels the operation
* @throws Re-throws other errors after cleanup (caller should handle gracefully)
*/
export async function executePreCompactHookWithCleanup(params: PreCompactHookParams): Promise<PreCompactHookResult> {
const { executeHook } = await import("./hook-executor")
const { cleanupConversationHistoryFile } = await import("../storage/disk")
export async function executePreCompactHookWithCleanup(
params: PreCompactHookParams,
): Promise<PreCompactHookResult> {
const { executeHook } = await import("./hook-executor");
const { cleanupConversationHistoryFile } = await import("../storage/disk");
let contextJsonPath: string | undefined
let contextRawPath: string | undefined
let contextJsonPath: string | undefined;
let contextRawPath: string | undefined;
try {
// Get current active context (respects previous compactions)
// Get current active context (respects previous compactions).
// getTruncatedMessages types its output as Anthropic.MessageParam[], but it slices the Cline-stored
// conversation history (ClineStorageMessage[]) passed in, so narrow it back here.
const currentContext = params.contextManager.getTruncatedMessages(
params.apiConversationHistory,
params.conversationHistoryDeletedRange,
)
) as ClineStorageMessage[];
// Write context files for hook access
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
contextJsonPath = contextFiles.contextJsonPath
contextRawPath = contextFiles.contextRawPath
const contextFiles = await writePreCompactContextFiles(
params.taskId,
currentContext,
);
contextJsonPath = contextFiles.contextJsonPath;
contextRawPath = contextFiles.contextRawPath;
// Extract token usage from the most recent API request
const previousApiReqIndex = findLastIndex(params.clineMessages, (m) => m.say === "api_req_started")
const previousRequest = previousApiReqIndex !== -1 ? params.clineMessages[previousApiReqIndex] : undefined
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } = extractTokenUsageFromMessage(previousRequest)
const previousApiReqIndex = findLastIndex(
params.clineMessages,
(m) => m.say === "api_req_started",
);
const previousRequest =
previousApiReqIndex !== -1
? params.clineMessages[previousApiReqIndex]
: undefined;
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } =
extractTokenUsageFromMessage(previousRequest);
// Extract truncation range - use provided range or extract from conversationHistoryDeletedRange
let deletedRangeStart = 0
let deletedRangeEnd = 0
let deletedRangeStart = 0;
let deletedRangeEnd = 0;
if (params.deletedRange) {
;[deletedRangeStart, deletedRangeEnd] = params.deletedRange
[deletedRangeStart, deletedRangeEnd] = params.deletedRange;
} else if (params.conversationHistoryDeletedRange) {
;[deletedRangeStart, deletedRangeEnd] = params.conversationHistoryDeletedRange
[deletedRangeStart, deletedRangeEnd] =
params.conversationHistoryDeletedRange;
}
// Execute the hook
@@ -245,53 +282,62 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
taskId: params.taskId,
hooksEnabled: params.hooksEnabled,
model: params.modelContext,
})
});
// Handle cancellation from hook
if (preCompactResult.cancel === true) {
// Log cancellation for debugging
const cancellationSource = preCompactResult.wasCancelled ? "user" : "PreCompact hook"
Logger.log(`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`)
const cancellationSource = preCompactResult.wasCancelled
? "user"
: "PreCompact hook";
Logger.log(
`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`,
);
// Internalized cancellation state management (replaces handleCancellation callback)
// Always save state before cancelling, regardless of cancellation source
params.taskState.didFinishAbortingStream = true
await params.messageStateHandler.saveClineMessagesAndUpdateHistory()
params.taskState.didFinishAbortingStream = true;
await params.messageStateHandler.saveClineMessagesAndUpdateHistory();
await params.messageStateHandler.overwriteApiConversationHistory(
params.messageStateHandler.getApiConversationHistory(),
)
await params.postStateToWebview()
);
await params.postStateToWebview();
// Trigger full cancellation flow
await params.cancelTask()
await params.cancelTask();
// Throw error to signal cancellation to caller
throw new HookCancellationError(preCompactResult.wasCancelled)
throw new HookCancellationError(preCompactResult.wasCancelled);
}
// Hook completed successfully - log if context modification provided
if (preCompactResult.contextModification) {
Logger.log(`[PreCompact] Hook provided context modification for task ${params.taskId}`)
Logger.log(
`[PreCompact] Hook provided context modification for task ${params.taskId}`,
);
}
return {
contextModification: preCompactResult.contextModification,
}
};
} catch (error) {
// Re-throw error for caller to handle
throw error
throw error;
} finally {
// Clean up temporary files - always executed regardless of success or error
// Wrap in try-catch to prevent cleanup failures from masking original errors
try {
if (contextJsonPath) {
await cleanupConversationHistoryFile(contextJsonPath)
await cleanupConversationHistoryFile(contextJsonPath);
}
if (contextRawPath) {
await cleanupConversationHistoryFile(contextRawPath)
await cleanupConversationHistoryFile(contextRawPath);
}
} catch (cleanupError) {
Logger.error("[PreCompact] Failed to cleanup context files:", cleanupError)
Logger.error(
"[PreCompact] Failed to cleanup context files:",
cleanupError,
);
// Don't throw - cleanup failure shouldn't mask original error
}
}
+359 -237
View File
@@ -1,22 +1,26 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { EnvironmentMetadataEntry, TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
import { execa } from "@packages/execa"
import { ClineMessage } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { RemoteConfig } from "@shared/remote-config/schema"
import { GlobalState, Settings } from "@shared/storage/state-keys"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { telemetryService } from "@/services/telemetry"
import { McpMarketplaceCatalog } from "@/shared/mcp"
import { Logger } from "@/shared/services/Logger"
import { syncWorker } from "@/shared/services/worker/sync"
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory"
import { StateManager } from "./StateManager"
import type { Anthropic } from "@anthropic-ai/sdk";
import type {
EnvironmentMetadataEntry,
TaskMetadata,
} from "@core/context/context-tracking/ContextTrackerTypes";
import { execa } from "@packages/execa";
import type { ClineMessage } from "@shared/ExtensionMessage";
import type { HistoryItem } from "@shared/HistoryItem";
import type { RemoteConfig } from "@shared/remote-config/schema";
import type { GlobalState, Settings } from "@shared/storage/state-keys";
import { fileExistsAtPath, isDirectory } from "@utils/fs";
import fs from "fs/promises";
import os from "os";
import * as path from "path";
import { HostProvider } from "@/hosts/host-provider";
import { ExtensionRegistryInfo } from "@/registry";
import { telemetryService } from "@/services/telemetry";
import type { McpMarketplaceCatalog } from "@/shared/mcp";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { Logger } from "@/shared/services/Logger";
import { syncWorker } from "@/shared/services/worker/sync";
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory";
import { StateManager } from "./StateManager";
/**
* Atomically write data to a file using temp file + rename pattern.
@@ -28,16 +32,16 @@ import { StateManager } from "./StateManager"
* @param data - The data to write
*/
async function atomicWriteFile(filePath: string, data: string): Promise<void> {
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`;
try {
// Write to temporary file first
await fs.writeFile(tmpPath, data, "utf8")
await fs.writeFile(tmpPath, data, "utf8");
// Rename temp file to target (atomic in most cases)
await fs.rename(tmpPath, filePath)
await fs.rename(tmpPath, filePath);
} catch (error) {
// Clean up temp file if it exists
fs.unlink(tmpPath).catch(() => {})
throw error
fs.unlink(tmpPath).catch(() => {});
throw error;
}
}
@@ -67,7 +71,7 @@ export const GlobalFileNames = {
taskMetadata: "task_metadata.json",
mcpMarketplaceCatalog: "mcp_marketplace_catalog.json",
remoteConfig: (orgId: string) => `remote_config_${orgId}.json`,
}
};
export async function getDocumentsPath(): Promise<string> {
if (process.platform === "win32") {
@@ -76,33 +80,37 @@ export async function getDocumentsPath(): Promise<string> {
"-NoProfile", // Ignore user's PowerShell profile(s)
"-Command",
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
])
const trimmedPath = docsPath.trim()
]);
const trimmedPath = docsPath.trim();
if (trimmedPath) {
return trimmedPath
return trimmedPath;
}
} catch (_err) {
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
Logger.error(
"Failed to retrieve Windows Documents path. Falling back to homedir/Documents.",
);
}
} else if (process.platform === "linux") {
try {
// First check if xdg-user-dir exists
await execa("which", ["xdg-user-dir"])
await execa("which", ["xdg-user-dir"]);
// If it exists, try to get XDG documents path
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
const trimmedPath = stdout.trim()
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"]);
const trimmedPath = stdout.trim();
if (trimmedPath) {
return trimmedPath
return trimmedPath;
}
} catch {
// Log error but continue to fallback
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
Logger.error(
"Failed to retrieve XDG Documents path. Falling back to homedir/Documents.",
);
}
}
// Default fallback for all platforms
return path.join(os.homedir(), "Documents")
return path.join(os.homedir(), "Documents");
}
/**
@@ -115,66 +123,68 @@ export async function getDocumentsPath(): Promise<string> {
* This is intended to eventually replace ~/Documents/Cline as the global config location.
*/
export function getClineHomePath(): string {
return path.join(os.homedir(), ".cline")
return path.join(os.homedir(), ".cline");
}
export async function ensureTaskDirectoryExists(taskId: string): Promise<string> {
return getGlobalStorageDir("tasks", taskId)
export async function ensureTaskDirectoryExists(
taskId: string,
): Promise<string> {
return getGlobalStorageDir("tasks", taskId);
}
export async function ensureRulesDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath()
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules")
const userDocumentsPath = await getDocumentsPath();
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules");
try {
await fs.mkdir(clineRulesDir, { recursive: true })
await fs.mkdir(clineRulesDir, { recursive: true });
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Rules") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
return path.join(os.homedir(), "Documents", "Cline", "Rules"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineRulesDir
return clineRulesDir;
}
export async function ensureWorkflowsDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath()
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows")
const userDocumentsPath = await getDocumentsPath();
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows");
try {
await fs.mkdir(clineWorkflowsDir, { recursive: true })
await fs.mkdir(clineWorkflowsDir, { recursive: true });
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Workflows") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
return path.join(os.homedir(), "Documents", "Cline", "Workflows"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineWorkflowsDir
return clineWorkflowsDir;
}
export async function ensureMcpServersDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath()
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
const userDocumentsPath = await getDocumentsPath();
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP");
try {
await fs.mkdir(mcpServersDir, { recursive: true })
await fs.mkdir(mcpServersDir, { recursive: true });
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "MCP") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
return path.join(os.homedir(), "Documents", "Cline", "MCP"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
}
return mcpServersDir
return mcpServersDir;
}
export async function ensureHooksDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath()
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks")
const userDocumentsPath = await getDocumentsPath();
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks");
try {
await fs.mkdir(clineHooksDir, { recursive: true })
await fs.mkdir(clineHooksDir, { recursive: true });
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Hooks") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
return path.join(os.homedir(), "Documents", "Cline", "Hooks"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineHooksDir
return clineHooksDir;
}
/**
* Returns the global skills directory path (~/.cline/skills) without creating it.
*/
function getClineSkillsDirectoryPath(): string {
return path.join(getClineHomePath(), "skills")
return path.join(getClineHomePath(), "skills");
}
function getAgentSkillsDirectoryPath(): string {
return path.join(os.homedir(), ".agents", "skills")
return path.join(os.homedir(), ".agents", "skills");
}
/**
@@ -182,41 +192,55 @@ function getAgentSkillsDirectoryPath(): string {
* Creates the directory if it doesn't exist.
* This is the opinionated location for new global skills.
*/
export async function ensureAgentSkillsDirectoryExists(options: { isGlobal: boolean; workspacePath?: string }): Promise<string> {
export async function ensureAgentSkillsDirectoryExists(options: {
isGlobal: boolean;
workspacePath?: string;
}): Promise<string> {
const agentSkillsDir = options.isGlobal
? getAgentSkillsDirectoryPath()
: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir)
: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir);
try {
await fs.mkdir(agentSkillsDir, { recursive: true })
await fs.mkdir(agentSkillsDir, { recursive: true });
} catch (_error) {
// Fallback - return the path even if mkdir fails, we'll fail gracefully later
return agentSkillsDir
return agentSkillsDir;
}
return agentSkillsDir
return agentSkillsDir;
}
export type SkillsScanDirectory = {
path: string
source: "project" | "global"
}
path: string;
source: "project" | "global";
};
/**
* Returns the list of skills directories to scan without creating them.
* Order is project directories first, then global directories.
*/
export function getSkillsDirectoriesForScan(cwd: string): SkillsScanDirectory[] {
export function getSkillsDirectoriesForScan(
cwd: string,
): SkillsScanDirectory[] {
return [
{ path: path.join(cwd, GlobalFileNames.clineruleSkillsDir), source: "project" },
{
path: path.join(cwd, GlobalFileNames.clineruleSkillsDir),
source: "project",
},
{ path: path.join(cwd, GlobalFileNames.clineSkillsDir), source: "project" },
{ path: path.join(cwd, GlobalFileNames.claudeSkillsDir), source: "project" },
{ path: path.join(cwd, GlobalFileNames.agentsSkillsDir), source: "project" },
{
path: path.join(cwd, GlobalFileNames.claudeSkillsDir),
source: "project",
},
{
path: path.join(cwd, GlobalFileNames.agentsSkillsDir),
source: "project",
},
{ path: getClineSkillsDirectoryPath(), source: "global" },
{ path: getAgentSkillsDirectoryPath(), source: "global" },
]
];
}
export async function ensureSettingsDirectoryExists(): Promise<string> {
return getGlobalStorageDir("settings")
return getGlobalStorageDir("settings");
}
/**
@@ -224,63 +248,93 @@ export async function ensureSettingsDirectoryExists(): Promise<string> {
* @param settingsDirectoryPath Path to the settings directory
* @returns Path to the MCP settings file
*/
export async function getMcpSettingsFilePath(settingsDirectoryPath: string): Promise<string> {
const mcpSettingsFilePath = path.join(settingsDirectoryPath, GlobalFileNames.mcpSettings)
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
export async function getMcpSettingsFilePath(
settingsDirectoryPath: string,
): Promise<string> {
const mcpSettingsFilePath = path.join(
settingsDirectoryPath,
GlobalFileNames.mcpSettings,
);
const fileExists = await fileExistsAtPath(mcpSettingsFilePath);
if (!fileExists) {
await fs.writeFile(mcpSettingsFilePath, JSON.stringify({ mcpServers: {} }, null, 2))
await fs.writeFile(
mcpSettingsFilePath,
JSON.stringify({ mcpServers: {} }, null, 2),
);
}
return mcpSettingsFilePath
return mcpSettingsFilePath;
}
export async function getSavedApiConversationHistory(taskId: string): Promise<Anthropic.MessageParam[]> {
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
const fileExists = await fileExistsAtPath(filePath)
export async function getSavedApiConversationHistory(
taskId: string,
): Promise<ClineStorageMessage[]> {
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
GlobalFileNames.apiConversationHistory,
);
const fileExists = await fileExistsAtPath(filePath);
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
return []
return [];
}
export async function saveApiConversationHistory(taskId: string, apiConversationHistory: Anthropic.MessageParam[]) {
export async function saveApiConversationHistory(
taskId: string,
apiConversationHistory: Anthropic.MessageParam[],
) {
try {
if (apiConversationHistory.length > 0) {
const fileName = GlobalFileNames.apiConversationHistory
const data = JSON.stringify(apiConversationHistory)
const fileName = GlobalFileNames.apiConversationHistory;
const data = JSON.stringify(apiConversationHistory);
// Queue for remote sync without blocking
syncWorker().enqueue(taskId, fileName, data)
syncWorker().enqueue(taskId, fileName, data);
// Store locally
const filePath = path.join(await ensureTaskDirectoryExists(taskId), fileName)
await atomicWriteFile(filePath, data)
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
fileName,
);
await atomicWriteFile(filePath, data);
}
} catch (error) {
// in the off chance this fails, we don't want to stop the task
Logger.error("Failed to save API conversation history:", error)
Logger.error("Failed to save API conversation history:", error);
}
}
export async function getSavedClineMessages(taskId: string): Promise<ClineMessage[]> {
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
export async function getSavedClineMessages(
taskId: string,
): Promise<ClineMessage[]> {
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
GlobalFileNames.uiMessages,
);
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
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
const data = JSON.parse(await fs.readFile(oldPath, "utf8"));
await fs.unlink(oldPath); // remove old file
return data;
}
return []
return [];
}
export async function saveClineMessages(taskId: string, uiMessages: ClineMessage[]) {
export async function saveClineMessages(
taskId: string,
uiMessages: ClineMessage[],
) {
try {
const taskDir = await ensureTaskDirectoryExists(taskId)
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
await atomicWriteFile(filePath, JSON.stringify(uiMessages))
const taskDir = await ensureTaskDirectoryExists(taskId);
const filePath = path.join(taskDir, GlobalFileNames.uiMessages);
await atomicWriteFile(filePath, JSON.stringify(uiMessages));
} catch (error) {
Logger.error("Failed to save ui messages:", error)
Logger.error("Failed to save ui messages:", error);
}
}
@@ -289,9 +343,11 @@ export async function saveClineMessages(taskId: string, uiMessages: ClineMessage
* This information is used for debugging and task portability.
* Returns metadata without timestamp - timestamp is added by EnvironmentContextTracker.
*/
export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMetadataEntry, "ts">> {
export async function collectEnvironmentMetadata(): Promise<
Omit<EnvironmentMetadataEntry, "ts">
> {
try {
const hostVersion = await HostProvider.env.getHostVersion({})
const hostVersion = await HostProvider.env.getHostVersion({});
return {
os_name: os.platform(),
@@ -300,9 +356,9 @@ export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMeta
host_name: hostVersion.platform || "Unknown",
host_version: hostVersion.version || "Unknown",
cline_version: ExtensionRegistryInfo.version,
}
};
} catch (error) {
Logger.error("Failed to collect environment metadata:", error)
Logger.error("Failed to collect environment metadata:", error);
// Return fallback values if collection fails
return {
os_name: os.platform(),
@@ -311,191 +367,245 @@ export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMeta
host_name: "Unknown",
host_version: "Unknown",
cline_version: "Unknown",
}
};
}
}
export async function getTaskMetadata(taskId: string): Promise<TaskMetadata> {
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.taskMetadata)
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
GlobalFileNames.taskMetadata,
);
try {
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
} catch (error) {
Logger.error("Failed to read task metadata:", error)
Logger.error("Failed to read task metadata:", error);
}
return { files_in_context: [], model_usage: [], environment_history: [] }
return { files_in_context: [], model_usage: [], environment_history: [] };
}
export async function saveTaskMetadata(taskId: string, metadata: TaskMetadata) {
try {
const taskDir = await ensureTaskDirectoryExists(taskId)
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
const taskDir = await ensureTaskDirectoryExists(taskId);
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata);
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2));
} catch (error) {
Logger.error("Failed to save task metadata:", error)
Logger.error("Failed to save task metadata:", error);
}
}
export async function ensureStateDirectoryExists(): Promise<string> {
return getGlobalStorageDir("state")
return getGlobalStorageDir("state");
}
export async function ensureCacheDirectoryExists(): Promise<string> {
return getGlobalStorageDir("cache")
return getGlobalStorageDir("cache");
}
export async function readMcpMarketplaceCatalogFromCache(): Promise<McpMarketplaceCatalog | undefined> {
export async function readMcpMarketplaceCatalogFromCache(): Promise<
McpMarketplaceCatalog | undefined
> {
try {
const mcpMarketplaceCatalogFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.mcpMarketplaceCatalog)
const fileExists = await fileExistsAtPath(mcpMarketplaceCatalogFilePath)
const mcpMarketplaceCatalogFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.mcpMarketplaceCatalog,
);
const fileExists = await fileExistsAtPath(mcpMarketplaceCatalogFilePath);
if (fileExists) {
const fileContents = await fs.readFile(mcpMarketplaceCatalogFilePath, "utf8")
return JSON.parse(fileContents)
const fileContents = await fs.readFile(
mcpMarketplaceCatalogFilePath,
"utf8",
);
return JSON.parse(fileContents);
}
return undefined
return undefined;
} catch (error) {
Logger.error("Failed to read MCP marketplace catalog from cache:", error)
return undefined
Logger.error("Failed to read MCP marketplace catalog from cache:", error);
return undefined;
}
}
export async function writeMcpMarketplaceCatalogToCache(catalog: McpMarketplaceCatalog): Promise<void> {
export async function writeMcpMarketplaceCatalogToCache(
catalog: McpMarketplaceCatalog,
): Promise<void> {
try {
const mcpMarketplaceCatalogFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.mcpMarketplaceCatalog)
await fs.writeFile(mcpMarketplaceCatalogFilePath, JSON.stringify(catalog))
const mcpMarketplaceCatalogFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.mcpMarketplaceCatalog,
);
await fs.writeFile(mcpMarketplaceCatalogFilePath, JSON.stringify(catalog));
} catch (error) {
Logger.error("Failed to write MCP marketplace catalog to cache:", error)
Logger.error("Failed to write MCP marketplace catalog to cache:", error);
}
}
async function getGlobalStorageDir(...subdirs: string[]) {
const fullPath = path.resolve(HostProvider.get().globalStorageFsPath, ...subdirs)
await fs.mkdir(fullPath, { recursive: true })
return fullPath
const fullPath = path.resolve(
HostProvider.get().globalStorageFsPath,
...subdirs,
);
await fs.mkdir(fullPath, { recursive: true });
return fullPath;
}
export async function getTaskHistoryStateFilePath(): Promise<string> {
return path.join(await ensureStateDirectoryExists(), "taskHistory.json")
return path.join(await ensureStateDirectoryExists(), "taskHistory.json");
}
export async function taskHistoryStateFileExists(): Promise<boolean> {
const filePath = await getTaskHistoryStateFilePath()
return fileExistsAtPath(filePath)
const filePath = await getTaskHistoryStateFilePath();
return fileExistsAtPath(filePath);
}
export async function readTaskHistoryFromState(): Promise<HistoryItem[]> {
try {
const filePath = await getTaskHistoryStateFilePath()
const filePath = await getTaskHistoryStateFilePath();
if (!(await fileExistsAtPath(filePath))) {
return []
return [];
}
const contents = await fs.readFile(filePath, "utf8")
const contents = await fs.readFile(filePath, "utf8");
try {
return JSON.parse(contents)
return JSON.parse(contents);
} catch (parseError) {
telemetryService.captureExtensionStorageError(parseError, "parseError_attemptingRecovery")
telemetryService.captureExtensionStorageError(
parseError,
"parseError_attemptingRecovery",
);
const result = await reconstructTaskHistory(false)
const result = await reconstructTaskHistory(false);
if (result && result.reconstructedTasks > 0) {
// Read the reconstructed file
const newContents = await fs.readFile(filePath, "utf8")
return JSON.parse(newContents)
const newContents = await fs.readFile(filePath, "utf8");
return JSON.parse(newContents);
}
// Recovery failed, all we can do is return an empty array or throw an error, thus preventing the app from starting up
// This will wipe out the taskHistory
return []
return [];
}
} catch (error) {
// Filesystem or other errors - throw them for the caller to handle
telemetryService.captureExtensionStorageError(error, "readTaskHistoryFromState")
throw error
telemetryService.captureExtensionStorageError(
error,
"readTaskHistoryFromState",
);
throw error;
}
}
export async function writeTaskHistoryToState(items: HistoryItem[]): Promise<void> {
export async function writeTaskHistoryToState(
items: HistoryItem[],
): Promise<void> {
try {
const filePath = await getTaskHistoryStateFilePath()
await atomicWriteFile(filePath, JSON.stringify(items))
const filePath = await getTaskHistoryStateFilePath();
await atomicWriteFile(filePath, JSON.stringify(items));
} catch (error) {
Logger.error("[Disk] Failed to write task history:", error)
throw error
Logger.error("[Disk] Failed to write task history:", error);
throw error;
}
}
export async function readTaskSettingsFromStorage(taskId: string): Promise<Partial<GlobalState>> {
export async function readTaskSettingsFromStorage(
taskId: string,
): Promise<Partial<GlobalState>> {
try {
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId)
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json")
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId);
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json");
if (await fileExistsAtPath(settingsFilePath)) {
const settingsContent = await fs.readFile(settingsFilePath, "utf8")
return JSON.parse(settingsContent)
const settingsContent = await fs.readFile(settingsFilePath, "utf8");
return JSON.parse(settingsContent);
}
// Return empty object if settings file doesn't exist (new task)
return {}
return {};
} catch (error) {
Logger.error("[Disk] Failed to read task settings:", error)
throw error
Logger.error("[Disk] Failed to read task settings:", error);
throw error;
}
}
export async function writeTaskSettingsToStorage(taskId: string, settings: Partial<Settings>) {
export async function writeTaskSettingsToStorage(
taskId: string,
settings: Partial<Settings>,
) {
try {
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId)
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json")
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId);
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json");
let existingSettings = {}
let existingSettings = {};
if (await fileExistsAtPath(settingsFilePath)) {
const existingSettingsContent = await fs.readFile(settingsFilePath, "utf8")
existingSettings = JSON.parse(existingSettingsContent)
const existingSettingsContent = await fs.readFile(
settingsFilePath,
"utf8",
);
existingSettings = JSON.parse(existingSettingsContent);
}
const updatedSettings = { ...existingSettings, ...settings }
await fs.writeFile(settingsFilePath, JSON.stringify(updatedSettings, null, 2))
const updatedSettings = { ...existingSettings, ...settings };
await fs.writeFile(
settingsFilePath,
JSON.stringify(updatedSettings, null, 2),
);
} catch (error) {
Logger.error("[Disk] Failed to write task settings:", error)
throw error
Logger.error("[Disk] Failed to write task settings:", error);
throw error;
}
}
export async function readRemoteConfigFromCache(organizationId: string): Promise<RemoteConfig | undefined> {
export async function readRemoteConfigFromCache(
organizationId: string,
): Promise<RemoteConfig | undefined> {
try {
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
const fileExists = await fileExistsAtPath(remoteConfigFilePath)
const remoteConfigFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.remoteConfig(organizationId),
);
const fileExists = await fileExistsAtPath(remoteConfigFilePath);
if (fileExists) {
const fileContents = await fs.readFile(remoteConfigFilePath, "utf8")
return JSON.parse(fileContents)
const fileContents = await fs.readFile(remoteConfigFilePath, "utf8");
return JSON.parse(fileContents);
}
return undefined
return undefined;
} catch (error) {
Logger.error("Failed to read remote config from cache:", error)
return undefined
Logger.error("Failed to read remote config from cache:", error);
return undefined;
}
}
export async function writeRemoteConfigToCache(organizationId: string, config: RemoteConfig): Promise<void> {
export async function writeRemoteConfigToCache(
organizationId: string,
config: RemoteConfig,
): Promise<void> {
try {
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
await fs.writeFile(remoteConfigFilePath, JSON.stringify(config))
const remoteConfigFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.remoteConfig(organizationId),
);
await fs.writeFile(remoteConfigFilePath, JSON.stringify(config));
} catch (error) {
Logger.error("Failed to write remote config to cache:", error)
Logger.error("Failed to write remote config to cache:", error);
}
}
export async function deleteRemoteConfigFromCache(organizationId: string): Promise<void> {
export async function deleteRemoteConfigFromCache(
organizationId: string,
): Promise<void> {
try {
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
const fileExists = await fileExistsAtPath(remoteConfigFilePath)
const remoteConfigFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.remoteConfig(organizationId),
);
const fileExists = await fileExistsAtPath(remoteConfigFilePath);
if (fileExists) {
await fs.unlink(remoteConfigFilePath)
await fs.unlink(remoteConfigFilePath);
}
} catch (error) {
Logger.error("Failed to delete remote config from cache:", error)
Logger.error("Failed to delete remote config from cache:", error);
}
}
@@ -504,11 +614,11 @@ export async function deleteRemoteConfigFromCache(organizationId: string): Promi
* Returns undefined if the directory doesn't exist.
*/
export async function getGlobalHooksDir(): Promise<string | undefined> {
const globalHooksDir = await ensureHooksDirectoryExists()
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined
const globalHooksDir = await ensureHooksDirectoryExists();
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined;
}
let runtimeHooksDir: string | undefined
let runtimeHooksDir: string | undefined;
/**
* Sets a runtime hooks directory, typically passed via the --hooks-dir CLI flag.
@@ -516,7 +626,7 @@ let runtimeHooksDir: string | undefined
* when discovering hooks.
*/
export function setRuntimeHooksDir(dir: string | undefined): void {
runtimeHooksDir = dir
runtimeHooksDir = dir;
}
/**
@@ -531,24 +641,24 @@ export function setRuntimeHooksDir(dir: string | undefined): void {
* multi-root workspace may have multiple hooks directories.
*/
export async function getAllHooksDirs(): Promise<string[]> {
const hooksDirs: string[] = []
const hooksDirs: string[] = [];
// Add runtime hooks directory (set by --hooks-dir CLI flag)
if (runtimeHooksDir && (await isDirectory(runtimeHooksDir))) {
hooksDirs.push(runtimeHooksDir)
hooksDirs.push(runtimeHooksDir);
}
// Add global hooks directory (if it exists)
const globalHooksDir = await getGlobalHooksDir()
const globalHooksDir = await getGlobalHooksDir();
if (globalHooksDir) {
hooksDirs.push(globalHooksDir)
hooksDirs.push(globalHooksDir);
}
// Add workspace hooks directories
const workspaceHooksDirs = await getWorkspaceHooksDirs()
hooksDirs.push(...workspaceHooksDirs)
const workspaceHooksDirs = await getWorkspaceHooksDirs();
hooksDirs.push(...workspaceHooksDirs);
return hooksDirs
return hooksDirs;
}
/**
@@ -560,17 +670,20 @@ export async function getWorkspaceHooksDirs(): Promise<string[]> {
const workspaceRootPaths =
StateManager.get()
.getGlobalStateKey("workspaceRoots")
?.map((root) => root.path) || []
?.map((root) => root.path) || [];
return (
await Promise.all(
workspaceRootPaths.map(async (workspaceRootPath) => {
// Look for a .clinerules/hooks folder in this workspace root.
const candidate = path.join(workspaceRootPath, GlobalFileNames.hooksDir)
return (await isDirectory(candidate)) ? candidate : undefined
const candidate = path.join(
workspaceRootPath,
GlobalFileNames.hooksDir,
);
return (await isDirectory(candidate)) ? candidate : undefined;
}),
)
).filter((path): path is string => Boolean(path))
).filter((path): path is string => Boolean(path));
}
/**
@@ -588,17 +701,20 @@ export async function writeConversationHistoryJson(
apiConversationHistory: Anthropic.MessageParam[],
timestamp?: number,
): Promise<string> {
const taskDir = await ensureTaskDirectoryExists(taskId)
const fileTimestamp = timestamp ?? Date.now()
const tempFileName = `conversation_history_${fileTimestamp}.json`
const tempFilePath = path.join(taskDir, tempFileName)
const taskDir = await ensureTaskDirectoryExists(taskId);
const fileTimestamp = timestamp ?? Date.now();
const tempFileName = `conversation_history_${fileTimestamp}.json`;
const tempFilePath = path.join(taskDir, tempFileName);
try {
await atomicWriteFile(tempFilePath, JSON.stringify(apiConversationHistory, null, 2))
return tempFilePath
await atomicWriteFile(
tempFilePath,
JSON.stringify(apiConversationHistory, null, 2),
);
return tempFilePath;
} catch (error) {
Logger.error("Failed to write conversation history JSON for hook:", error)
throw error
Logger.error("Failed to write conversation history JSON for hook:", error);
throw error;
}
}
@@ -608,14 +724,20 @@ export async function writeConversationHistoryJson(
*
* @param filePath The path to the temporary file to delete
*/
export async function cleanupConversationHistoryFile(filePath: string): Promise<void> {
export async function cleanupConversationHistoryFile(
filePath: string,
): Promise<void> {
try {
if (await fileExistsAtPath(filePath)) {
await fs.unlink(filePath)
await fs.unlink(filePath);
}
} catch (error) {
// Silently handle errors - this is cleanup, not critical
Logger.debug("Failed to cleanup conversation history file:", filePath, error)
Logger.debug(
"Failed to cleanup conversation history file:",
filePath,
error,
);
}
}
@@ -634,59 +756,59 @@ export async function writeConversationHistoryText(
conversationHistory: Anthropic.MessageParam[],
timestamp?: number,
): Promise<string> {
const taskDir = await ensureTaskDirectoryExists(taskId)
const fileTimestamp = timestamp ?? Date.now()
const tempFileName = `conversation_history_${fileTimestamp}.txt`
const tempFilePath = path.join(taskDir, tempFileName)
const taskDir = await ensureTaskDirectoryExists(taskId);
const fileTimestamp = timestamp ?? Date.now();
const tempFileName = `conversation_history_${fileTimestamp}.txt`;
const tempFilePath = path.join(taskDir, tempFileName);
try {
// Build the formatted conversation history (excluding system prompt)
let fullContext = "=== CONVERSATION HISTORY ===\n\n"
let fullContext = "=== CONVERSATION HISTORY ===\n\n";
// Format each message in the conversation
for (let i = 0; i < conversationHistory.length; i++) {
const message = conversationHistory[i]
fullContext += `--- Message ${i + 1} (${message.role.toUpperCase()}) ---\n`
const message = conversationHistory[i];
fullContext += `--- Message ${i + 1} (${message.role.toUpperCase()}) ---\n`;
// Handle content which can be a string or array
if (typeof message.content === "string") {
fullContext += message.content
fullContext += message.content;
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
fullContext += block.text
fullContext += block.text;
} else if (block.type === "image") {
fullContext += `[IMAGE: ${block.source?.type || "unknown"}]`
fullContext += `[IMAGE: ${block.source?.type || "unknown"}]`;
} else if (block.type === "tool_use") {
fullContext += `[TOOL USE: ${block.name}]\n`
fullContext += `Input: ${JSON.stringify(block.input, null, 2)}`
fullContext += `[TOOL USE: ${block.name}]\n`;
fullContext += `Input: ${JSON.stringify(block.input, null, 2)}`;
} else if (block.type === "tool_result") {
fullContext += `[TOOL RESULT: ${block.tool_use_id}]\n`
fullContext += `[TOOL RESULT: ${block.tool_use_id}]\n`;
if (typeof block.content === "string") {
fullContext += block.content
fullContext += block.content;
} else if (Array.isArray(block.content)) {
for (const resultBlock of block.content) {
if (resultBlock.type === "text") {
fullContext += resultBlock.text
fullContext += resultBlock.text;
} else if (resultBlock.type === "image") {
fullContext += `[IMAGE]`
fullContext += `[IMAGE]`;
}
}
}
}
fullContext += "\n\n"
fullContext += "\n\n";
}
}
fullContext += "\n"
fullContext += "\n";
}
fullContext += "=== END OF CONTEXT ===\n"
fullContext += "=== END OF CONTEXT ===\n";
await atomicWriteFile(tempFilePath, fullContext)
return tempFilePath
await atomicWriteFile(tempFilePath, fullContext);
return tempFilePath;
} catch (error) {
Logger.error("Failed to write conversation history text for hook:", error)
throw error
Logger.error("Failed to write conversation history text for hook:", error);
throw error;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,33 +1,37 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import type { Anthropic } from "@anthropic-ai/sdk";
/**
* Filters out image blocks from messages since Claude Code doesn't support images.
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
*/
export function filterMessagesForClaudeCode(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
export function filterMessagesForClaudeCode(
messages: Anthropic.Messages.MessageParam[],
): Anthropic.Messages.MessageParam[] {
return messages.map((message) => {
// Handle simple string messages
if (typeof message.content === "string") {
return message
return message;
}
// Handle complex message structures
const filteredContent = message.content.map((block) => {
if (block.type === "image") {
// Replace image blocks with text placeholders
const sourceType = block.source?.type || "unknown"
const mediaType = block.source?.media_type || "unknown"
const sourceType = block.source?.type || "unknown";
const mediaType =
(block.source?.type === "base64" && block.source.media_type) ||
"unknown";
return {
type: "text" as const,
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
}
};
}
return block
})
return block;
});
return {
...message,
content: filteredContent,
}
})
};
});
}
+31 -19
View File
@@ -5107,17 +5107,17 @@ export const mainlandZAiModels = {
export type FireworksModelId = keyof typeof fireworksModels
export const fireworksDefaultModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2p6"
export const fireworksModels = {
"accounts/fireworks/models/kimi-k2p5": {
maxTokens: 256000,
contextWindow: 256000,
"accounts/fireworks/models/kimi-k2p7-code": {
maxTokens: 262000,
contextWindow: 262000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.6,
outputPrice: 3,
inputPrice: 0.95,
outputPrice: 4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.1,
cacheReadsPrice: 0.19,
description:
"Moonshot's flagship open agentic model. Kimi K2.5 unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution.",
"Moonshot's latest open coding model. Kimi K2.7 Code unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution.",
},
"accounts/fireworks/models/kimi-k2p6": {
maxTokens: 262000,
@@ -5143,6 +5143,18 @@ export const fireworksModels = {
description:
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
maxTokens: 262000,
contextWindow: 262000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.9,
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.38,
description:
"Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
},
"accounts/fireworks/models/deepseek-v4-flash": {
maxTokens: 384000,
contextWindow: 1000000,
@@ -5189,16 +5201,16 @@ export const fireworksModels = {
cacheReadsPrice: 0.52,
description: "GLM 5.1 Fast router for high-throughput coding, reasoning, and agentic workflows.",
},
"accounts/fireworks/models/minimax-m2p5": {
maxTokens: 196608,
contextWindow: 196608,
supportsImages: false,
"accounts/fireworks/models/minimax-m3": {
maxTokens: 512000,
contextWindow: 512000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0.03,
description: "MiniMax M2.5 is built for state-of-the-art coding, agentic tool use.",
cacheReadsPrice: 0.06,
description: "MiniMax M3 is built for state-of-the-art coding, agentic tool use, and long-context multimodal tasks.",
},
"accounts/fireworks/models/minimax-m2p7": {
maxTokens: 196608,
@@ -5211,16 +5223,16 @@ export const fireworksModels = {
cacheReadsPrice: 0.06,
description: "MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
},
"accounts/fireworks/models/qwen3p6-plus": {
maxTokens: 65536,
"accounts/fireworks/models/qwen3p7-plus": {
maxTokens: 262144,
contextWindow: 262144,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.5,
outputPrice: 3,
inputPrice: 0.4,
outputPrice: 1.6,
cacheWritesPrice: 0,
cacheReadsPrice: 0.1,
description: "Qwen 3.6 Plus with strong multimodal reasoning, long context support, and function calling.",
cacheReadsPrice: 0.08,
description: "Qwen 3.7 Plus with strong multimodal reasoning, long context support, and function calling.",
},
"accounts/fireworks/models/gpt-oss-120b": {
maxTokens: 32768,
+87 -44
View File
@@ -1,68 +1,84 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineMessageMetricsInfo, ClineMessageModelInfo } from "./metrics"
import type { Anthropic } from "@anthropic-ai/sdk";
import type { ClineMessageMetricsInfo, ClineMessageModelInfo } from "./metrics";
export type ClinePromptInputContent = string
export type ClinePromptInputContent = string;
export type ClineMessageRole = "user" | "assistant"
export type ClineMessageRole = "user" | "assistant";
export interface ClineReasoningDetailParam {
type: "reasoning.text" | string
text: string
signature: string
format: "anthropic-claude-v1" | string
index: number
type: "reasoning.text" | string;
text: string;
signature: string;
format: "anthropic-claude-v1" | string;
index: number;
}
interface ClineSharedMessageParam {
// The id of the response that the block belongs to
call_id?: string
call_id?: string;
}
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"]
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"];
/**
* An extension of Anthropic.MessageParam that includes Cline-specific fields: reasoning_details.
* This ensures backward compatibility where the messages were stored in Anthropic format with additional
* fields unknown to Anthropic SDK.
*/
export interface ClineTextContentBlock extends Anthropic.TextBlockParam, ClineSharedMessageParam {
export interface ClineTextContentBlock
extends Anthropic.TextBlockParam,
ClineSharedMessageParam {
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
reasoning_details?: ClineReasoningDetailParam[]
reasoning_details?: ClineReasoningDetailParam[];
// Thought Signature associates with Gemini
signature?: string
signature?: string;
}
export interface ClineImageContentBlock extends Anthropic.ImageBlockParam, ClineSharedMessageParam {}
export interface ClineImageContentBlock
extends Anthropic.ImageBlockParam,
ClineSharedMessageParam {}
export interface ClineDocumentContentBlock extends Anthropic.DocumentBlockParam, ClineSharedMessageParam {}
export interface ClineDocumentContentBlock
extends Anthropic.DocumentBlockParam,
ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock
extends Anthropic.ToolResultBlockParam,
ClineSharedMessageParam {}
/**
* Assistant only content types
*/
export interface ClineAssistantToolUseBlock extends Anthropic.ToolUseBlockParam, ClineSharedMessageParam {
export interface ClineAssistantToolUseBlock
extends Anthropic.ToolUseBlockParam,
ClineSharedMessageParam {
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
reasoning_details?: unknown[] | ClineReasoningDetailParam[]
reasoning_details?: unknown[] | ClineReasoningDetailParam[];
// Thought Signature associates with Gemini
signature?: string
signature?: string;
}
export interface ClineAssistantThinkingBlock extends Anthropic.ThinkingBlock, ClineSharedMessageParam {
export interface ClineAssistantThinkingBlock
extends Anthropic.ThinkingBlock,
ClineSharedMessageParam {
// The summary items returned by OpenAI response API
// The reasoning details that will be moved to the text block when finalized
summary?: unknown[] | ClineReasoningDetailParam[]
summary?: unknown[] | ClineReasoningDetailParam[];
}
export interface ClineAssistantRedactedThinkingBlock extends Anthropic.RedactedThinkingBlockParam, ClineSharedMessageParam {}
export interface ClineAssistantRedactedThinkingBlock
extends Anthropic.RedactedThinkingBlockParam,
ClineSharedMessageParam {}
export type ClineToolResponseContent = ClinePromptInputContent | Array<ClineTextContentBlock | ClineImageContentBlock>
export type ClineToolResponseContent =
| ClinePromptInputContent
| Array<ClineTextContentBlock | ClineImageContentBlock>;
export type ClineUserContent =
| ClineTextContentBlock
| ClineImageContentBlock
| ClineDocumentContentBlock
| ClineUserToolResultContentBlock
| ClineUserToolResultContentBlock;
export type ClineAssistantContent =
| ClineTextContentBlock
@@ -70,9 +86,9 @@ export type ClineAssistantContent =
| ClineDocumentContentBlock
| ClineAssistantToolUseBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
| ClineAssistantRedactedThinkingBlock;
export type ClineContent = ClineUserContent | ClineAssistantContent
export type ClineContent = ClineUserContent | ClineAssistantContent;
/**
* An extension of Anthropic.MessageParam that includes Cline-specific fields.
@@ -84,24 +100,24 @@ export interface ClineStorageMessage extends Anthropic.MessageParam {
/**
* Response ID associated with this message
*/
id?: string
role: ClineMessageRole
content: ClinePromptInputContent | ClineContent[]
id?: string;
role: ClineMessageRole;
content: ClinePromptInputContent | ClineContent[];
/**
* NOTE: model information used when generating this message.
* Internal use for message conversion only.
* MUST be removed before sending message to any LLM provider.
*/
modelInfo?: ClineMessageModelInfo
modelInfo?: ClineMessageModelInfo;
/**
* LLM operational and performance metrics for this message
* Includes token counts, costs.
*/
metrics?: ClineMessageMetricsInfo
metrics?: ClineMessageMetricsInfo;
/**
* Timestamp of when the message was created
*/
ts?: number
ts?: number;
}
/**
@@ -112,23 +128,50 @@ export function convertClineStorageToAnthropicMessage(
clineMessage: ClineStorageMessage,
provider = "anthropic",
): Anthropic.MessageParam {
const { role, content } = clineMessage
const { role, content } = clineMessage;
// Handle string content - fast path
if (typeof content === "string") {
return { role, content }
return { role, content };
}
// Removes thinking block that has no signature (invalid thinking block that's incompatible with Anthropic API)
const filteredContent = content.filter((b) => b.type !== "thinking" || !!b.signature)
const filteredContent = content.filter(
(b) => b.type !== "thinking" || !!b.signature,
);
// Handle array content - strip Cline-specific fields for non-reasoning_details providers
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider)
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider);
const cleanedContent = shouldCleanContent
? filteredContent.map(cleanContentBlock)
: (filteredContent as Anthropic.MessageParam["content"])
: (filteredContent as Anthropic.MessageParam["content"]);
return { role, content: cleanedContent }
return { role, content: cleanedContent };
}
/**
* Cline stores images as base64, so an image block's source is always a base64 source.
* The Anthropic SDK types the source as a Base64ImageSource | URLImageSource union, so this
* narrows to the base64 variant for the transform layer. URL sources are not produced by Cline,
* so they degrade to empty values rather than throwing.
*/
export function getBase64ImageSource(
source: Anthropic.ImageBlockParam["source"],
): { mediaType: string; data: string } {
if (source.type === "base64") {
return { mediaType: source.media_type, data: source.data };
}
return { mediaType: "", data: "" };
}
/**
* Builds a base64 data URL from an image block's source. See getBase64ImageSource.
*/
export function getImageDataUrl(
source: Anthropic.ImageBlockParam["source"],
): string {
const { mediaType, data } = getBase64ImageSource(source);
return `data:${mediaType};base64,${data}`;
}
/**
@@ -140,19 +183,19 @@ export function cleanContentBlock(block: ClineContent): Anthropic.ContentBlock {
"reasoning_details" in block ||
"call_id" in block ||
"summary" in block ||
(block.type !== "thinking" && "signature" in block)
(block.type !== "thinking" && "signature" in block);
if (!hasClineFields) {
return block as Anthropic.ContentBlock
return block as Anthropic.ContentBlock;
}
// Removes Cline-specific fields & the signature field that's added for Gemini.
const { reasoning_details, call_id, summary, ...rest } = block as any
const { reasoning_details, call_id, summary, ...rest } = block as any;
// Remove signature from non-thinking blocks that were added for Gemini
if (block.type !== "thinking" && rest.signature) {
rest.signature = undefined
rest.signature = undefined;
}
return rest satisfies Anthropic.ContentBlock
return rest satisfies Anthropic.ContentBlock;
}
+1 -1
View File
@@ -98,7 +98,7 @@ export function getReadablePath(cwd: string, relPath?: string): string {
if (isLocatedInPath(cwd, absolutePath)) {
return normalizedRelPath.toPosix()
}
// we are outside the cwd, so show the absolute path (useful for when cline passes in '../../' for example)
// we are outside the cwd, so show the absolute path (useful for when Cline passes in '../../' for example)
return absolutePath.toPosix()
}
@@ -5,19 +5,16 @@ import { type ReactNode, useEffect, useState } from "react"
import { useExtensionState } from "./context/ExtensionStateContext"
export function CustomPostHogProvider({ children }: { children: ReactNode }) {
const { distinctId, version, userInfo, environment } = useExtensionState()
const { distinctId, version, userInfo, environment, telemetrySetting } = useExtensionState()
// Skip PostHog entirely in self-hosted mode or when environment is unknown (safety fallback)
const isSelfHostedOrUnknown = !environment || environment === "selfHosted"
// NOTE: This is a hack to stop recording webview click events temporarily.
// Remove this to re-enable.
// const isTelemetryEnabled = telemetrySetting !== "disabled";
const isTelemetryEnabled = false
const isTelemetryEnabled = telemetrySetting !== "disabled"
const [isActive, setIsActive] = useState(false)
useEffect(() => {
if (isSelfHostedOrUnknown || isActive || !isTelemetryEnabled || !posthogConfig.apiKey) {
if (isSelfHostedOrUnknown || isActive || !posthogConfig.apiKey) {
return
}
// At this point, we know apiKey is defined due to the check above
@@ -27,7 +24,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
ui_host: posthogConfig.uiHost,
disable_session_recording: true,
capture_pageview: false,
capture_dead_clicks: true,
capture_dead_clicks: false,
// Feature flags should work regardless of telemetry opt-out
advanced_disable_decide: false,
// Autocapture should respect telemetry settings
@@ -37,7 +34,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
}, [isSelfHostedOrUnknown])
useEffect(() => {
if (!isTelemetryEnabled || !isActive || !distinctId || !version) {
if (!isActive || !distinctId || !version) {
return
}
@@ -15,7 +15,7 @@ import ContextWindow from "./ContextWindow"
import { FocusChain } from "./FocusChain"
import { highlightText } from "./Highlights"
const IS_DEV = process.env.IS_DEV === '"true"'
const IS_DEV = process.env.IS_DEV === "true"
interface TaskHeaderProps {
task: ClineMessage
tokensIn: number
@@ -41,13 +41,15 @@ export const isChrome = userAgent.indexOf("Chrome") >= 0
export const isSafari = !isChrome && userAgent.indexOf("Safari") >= 0
declare const __NODE_PLATFORM__: string
/**
* Gets the current platform: 'windows', 'mac', or 'linux'
* Defaults to 'linux' if platform cannot be determined
*/
export function getCurrentPlatform() {
// Fallback to linux if platform is not available
switch (process?.platform) {
switch (__NODE_PLATFORM__) {
case "win32":
return "windows"
case "darwin":
+17 -13
View File
@@ -116,19 +116,23 @@ export default defineConfig({
},
define: {
__PLATFORM__: JSON.stringify(platform),
process: JSON.stringify({
platform: JSON.stringify(process?.platform),
env: {
NODE_ENV: JSON.stringify(process?.env?.IS_DEV ? "development" : "production"),
CLINE_ENVIRONMENT: JSON.stringify(process?.env?.CLINE_ENVIRONMENT ?? "production"),
IS_DEV: JSON.stringify(process?.env?.IS_DEV),
IS_TEST: JSON.stringify(process?.env?.IS_TEST),
CI: JSON.stringify(process?.env?.CI),
// PostHog environment variables
TELEMETRY_SERVICE_API_KEY: JSON.stringify(process?.env?.TELEMETRY_SERVICE_API_KEY),
ERROR_SERVICE_API_KEY: JSON.stringify(process?.env?.ERROR_SERVICE_API_KEY),
},
}),
__NODE_PLATFORM__: JSON.stringify(process.platform),
"process.env.CLINE_ENVIRONMENT": JSON.stringify(process.env.CLINE_ENVIRONMENT ?? "production"),
"process.env.IS_DEV": JSON.stringify(process.env.IS_DEV),
"process.env.IS_TEST": JSON.stringify(process.env.IS_TEST),
"process.env.CI": JSON.stringify(process.env.CI),
// PostHog environment variables
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY),
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(process.env.ERROR_SERVICE_API_KEY),
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(process.env.ENABLE_ERROR_AUTOCAPTURE),
// OpenTelemetry environment variables
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(process.env.OTEL_TELEMETRY_ENABLED),
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(process.env.OTEL_METRICS_EXPORTER),
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(process.env.OTEL_LOGS_EXPORTER),
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_PROTOCOL),
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_HEADERS),
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(process.env.OTEL_METRIC_EXPORT_INTERVAL),
},
resolve: {
alias: {
+63 -49
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.22",
"version": "3.0.23",
"bin": {
"cline": "src/index.ts",
},
@@ -45,6 +45,7 @@
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
@@ -371,7 +372,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.46",
"version": "0.0.47",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -380,7 +381,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.46",
"version": "0.0.47",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -407,11 +408,18 @@
},
"devDependencies": {
"@types/ws": "^8.18.1",
"posthog-node": "^5.8.0",
},
"peerDependencies": {
"posthog-node": "^5.8.0",
},
"optionalPeers": [
"posthog-node",
],
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.46",
"version": "0.0.47",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.89",
"@ai-sdk/anthropic": "^3.0.68",
@@ -445,14 +453,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.46",
"version": "0.0.47",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.46",
"version": "0.0.47",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
@@ -468,11 +476,11 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.123", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rL+3Sp9crOlfE7MwguFPS30qVp6HFcr9na0KYMb4CcQdxAIjBJec3EEdCjc94UyRVZWLmgQ6Yr605FmPlFIi0w=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.124", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-h8CrmbSG+8X0C+M/E1M4oiDHYevqwbzAPN+uLRHS0eJaatF2MZ+juNtOHXNOjk7Bsk9mD2RjYMjJO9dFkb9I7Q=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.141", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UGXQeV+z30Gk1/mhKZoXKbYO7Q0U7pg0Nlz44hb2B1FpYRFBu8+ziaI60BdetncaoxPocdNKhP41AD15IA6nJg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.142", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bTWfj0ITBHjAVJHWCA0DB7PO+aDX8bWxTI9hpNAKH7e5uO74URKdi22zlQAOsROEufcLYiAw0LjrYmmXiksErw=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KkdaMjs4C2y+vrZWJE990E3ZxBFiOTHQ94ZlquuIttpphcqJTMxNoIpnKT/4UzMVWXL0BUEE2vs+1UEVXkN8Kg=="],
@@ -484,7 +492,7 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.197", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.195", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-sx5K0pvIWbgduMYGz++s28ldj+hu+GfDpXw9T2kL4n+bhRQpQvrH+jU0z3OqvjMrhD5oz9cGJ7bv/0JQEKXIbw=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.198", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.196", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-ozlxMidzvKXAefvnq95Y34rJ5MipXABIv1bg2RLEnWUBxGrKxNHrYl0fTfi6grTN88wSXMZYaMY2oHMCHDFuJw=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -520,35 +528,35 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1059.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-HW3Oq2rL65tbuqkQzoRrjfF3jauRfra056Xv0K2YtBWt+LaeLrr95D8SlRmgGzXZHwy38FO/w0N1EKjsTYmDKw=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1062.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.17", "@aws-sdk/credential-provider-node": "^3.972.51", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-QA5z/Pl3aTMR3+bmiHoC6MpKYa4FMk/9lNP7k104uKuUsjMqP4ysRa43IwdcbI9sH023T//kSJCLxrxa2CP/Tw=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-WXPvTfG7J2H4Ae6ewhd0285UC+8+9p/pKoibXXQlbXSqHexFLGM0oXHTwDfQEPmrNnvuWPpVjgoAUfW+cUFbXw=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.39", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fp7hiew245BbBiaDGjLDaMXqxbOWnqzuhczWrJq/6/3gDNHtZvkorxHQXYpHYiddetR8sBWe3S49HfZ2BQbYSg=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.41", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-byGPybEQe9ejeyUzhWjtjfh0ctv25HsRx2djF/Tl2j9+DAuAmhjq0NqSRqYZEoSe8vJObXz5RYDtJYAmdupBig=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0+MCOYqeHyADFdeVr5/e1G8JJoWm7/szZAiKssqJS84E9+tO07509YNyQRJ5a7x5wO9YFAV324syrwm6yIcs5w=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-auuhqlnv4PUdfqcdHLKGTdoCceXOuby6WNeCMoxtZmQGWNCibbz95/lSYzNWq9cExN17UlRFqo1nvTcC+zHfEg=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-G+8JuG0CfLcC2IQXFVqMR1+KDF1rksebr+YL6+HHYbNjs/hiwX53ye45sU3pJKljpS2uIXqOrOsicHIv02vWrw=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.49", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/credential-provider-env": "^3.972.43", "@aws-sdk/credential-provider-http": "^3.972.45", "@aws-sdk/credential-provider-login": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.43", "@aws-sdk/credential-provider-sso": "^3.972.48", "@aws-sdk/credential-provider-web-identity": "^3.972.48", "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-83r5MK+PERv9irzky1o5aNbXiLuaLfeB7N8MrktB9USpoebdNtuG0Ek9ieIxpGH1aZ9a0nIaDaLjEr3EmOV3Ng=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+H6h2/1Q+iIJ4w+FPCKM//xwy4C9yADknDTR68+K3PbOtB/uLup3zIH8PUKn6QwnttME4VM4ftSTnbvoDf5wXg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-amPGeF6fcvLInK4Pu2k2Y2jHFR6MpaIKrZrbaf0QUnV3tjzjWh442eifZ2+KcmzFdsqyvyjBqAhq2JNLt1C5gA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.49", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hlyoc+2352BhC+HF91t9avIDJu1+EvQGGltxFB8ADCpAHGYNNLEQAZJIPzw3O/bRiiDSE2B8pdj/fFCXlDTEDg=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.51", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.43", "@aws-sdk/credential-provider-http": "^3.972.45", "@aws-sdk/credential-provider-ini": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.43", "@aws-sdk/credential-provider-sso": "^3.972.48", "@aws-sdk/credential-provider-web-identity": "^3.972.48", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mbhSY3ytXIGMuBoJsWCivk+63dtVlenT6wstUra07Lar4Ln2MVL8/j5zCTIOog+ig5/FlFJ8gcFU4nQZV+Jh4Q=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-r996IYVtQ7rWa5UfSs3fZLT/Dq/SSgH9wv9zahx9lcg6vvaPPQHFpTy45nZajZu/+OUdEQxEjTn9rOMons59mA=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/token-providers": "3.1059.0", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-vd+UqRbiYLvXXH3kTAuTxq+Vdb3rOgg29/FeB35ETsgdJTTB7x3YuqtpRckATsL5bDRXFVQo0uBj0/vxCJ8hHg=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/token-providers": "3.1062.0", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tf0sD47SeTgCDfOWYssctzGgwAuk8/ECjb7bom4wZ7P1om0qE8i2yjniUdvysmANm5haARr35O8vZnTe/UEtpQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xuxBbMorygYsbDV6E/tUGQUgIDfhnzxz8uJYX8rByzehI6gLUu8RPsgOpLmh9YWerqeHZxJbqzgheBSB7tpooQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-YYsumc2oe09gl4l+fjfmR64JDn6+0o4Ql5HMBkMuhFazO1tZlE5NjSnZM3oXHwenPjh2qow0TFgSIVjfWfsojg=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1059.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-cognito-identity": "^3.972.39", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-cSdDFb/O3cQHGg78VxPaNruyy9zGxcoeS7DlwYTdwxmYkE0GXmBRoej5N+q+Av9YWBayZ2n3QsSYhtnUXa/GXw=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1062.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1062.0", "@aws-sdk/core": "^3.974.17", "@aws-sdk/credential-provider-cognito-identity": "^3.972.41", "@aws-sdk/credential-provider-env": "^3.972.43", "@aws-sdk/credential-provider-http": "^3.972.45", "@aws-sdk/credential-provider-ini": "^3.972.49", "@aws-sdk/credential-provider-login": "^3.972.48", "@aws-sdk/credential-provider-node": "^3.972.51", "@aws-sdk/credential-provider-process": "^3.972.43", "@aws-sdk/credential-provider-sso": "^3.972.48", "@aws-sdk/credential-provider-web-identity": "^3.972.48", "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-QS2UT3srjNppZv6mq7V0igqK/ThYKqRWwDscxDsMEmmEE5JqCPPSqFW71aEpkvXaMdgmG8xEpt4RtNHpZ30cTA=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/signature-v4-multi-region": "^3.996.31", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-T5CS1r4P27FjkBYIWwVibWqEuq32BbCga2Z5m5OBSdSdi2wPfW2vl6zLWAB/5MeeyC4s2pY/MY3cj2Gd3rgSkg=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.16", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.17", "@aws-sdk/signature-v4-multi-region": "^3.996.31", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-bGvfDgC2KQePjEmZdltScPPLKFoyjPElAXeZcLfvZ58J1AO283//WGtvp9GdnryLHTi7gis0UoCezqh0vl/nig=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xql+7YBAE7WYb9xJfY0vcAXM8rJXfClmB2wkt+g/EoLUMog0pOb7o741fE96wFqha0uQTEgTo/5lGGguzavxmw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1062.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fvHh53zSm2FoQPgkw9thH5D7sd13bC0nPyuZb+mQJ85l5v7lQnsZ97u6e6YkJJN/LU1Mxm1/DLGrIIRR2L7tZw=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.10", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g=="],
@@ -656,9 +664,9 @@
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.4.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw=="],
"@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="],
"@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="],
"@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="],
"@cline/agents": ["@cline/agents@workspace:sdk/packages/agents"],
@@ -1094,6 +1102,10 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@posthog/core": ["@posthog/core@1.32.3", "", { "dependencies": { "@posthog/types": "1.386.3" } }, "sha512-vwOEMfZvGv5XxNWV7p9I52NSmvFNMhyW2IHpIoUHW5jLkgUrknzJW1H/qxVGSIrNNVQkfsoaDFzDhJdg10pgrA=="],
"@posthog/types": ["@posthog/types@1.386.3", "", {}, "sha512-LqJoiQi2eyWn7rCUgnn+D+F3Efp6+04o72bjSX6kWHx0nFaYNC/nJuAIRliDTY/X7GPIUAaHAcSjbMI/9wfX1Q=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@@ -1302,19 +1314,19 @@
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
"@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
@@ -1336,7 +1348,7 @@
"@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-Ussyv240JxwQP8AmkYdm26wGP/1I8QmIv0ZosgDJDlSzD73FEdj1BOpXMc06VrxX5KxTKhadFNomT2SWutUnpg=="],
@@ -1344,7 +1356,7 @@
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="],
@@ -1662,7 +1674,7 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@6.0.195", "", { "dependencies": { "@ai-sdk/gateway": "3.0.123", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IYZpuVz0boWbpIQYyinfWFrvQ1N0dG+EVB63it45B2YAU/MxxCnwz4zBswjYnPtHnJBedpMPNrwVbeczbl2GKg=="],
"ai": ["ai@6.0.196", "", { "dependencies": { "@ai-sdk/gateway": "3.0.124", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2T45UeqKL4a11KQ14I5i1YYHOvCFrMF478E1k6PVjlQSGUvXSv4xrxIaQbUL4qgv91DADSbddwv3oR49pPAK3g=="],
"ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.4.4", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.2.63" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-iHcup5SHh4Tul1RIi9J+bnpngen8WX66yC3lsz1YlbtwAmRhUEzZUuGKzmFGIN8Pmx9uQrerGfLJdbFxIxKkyw=="],
@@ -1714,7 +1726,7 @@
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="],
"axios": ["axios@1.17.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
@@ -1982,7 +1994,7 @@
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
"dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
@@ -1996,7 +2008,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.366", "", {}, "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.367", "", {}, "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ=="],
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
@@ -2010,7 +2022,7 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"enhanced-resolve": ["enhanced-resolve@5.22.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag=="],
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
@@ -2676,7 +2688,7 @@
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
@@ -2788,6 +2800,8 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"posthog-node": ["posthog-node@5.36.17", "", { "dependencies": { "@posthog/core": "1.32.3" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-ed1LT4a9hhiFJizB6XX7dkYYLVPAFHfUpkQSns7BRxoUyhFnvMq15QENKeAOUEKQgPmnaq2I+xNLdAHN0o9eAA=="],
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -2962,7 +2976,7 @@
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
@@ -3168,7 +3182,7 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.4", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2w/lydkrwhWMv1vCaEhYbzMDhgbwIodHpAHPV0/xKJErRkbjDEUe1EWmvr6Fwb+qhiERjc1EWgAEZaSaF69CpA=="],
"use-stick-to-bottom": ["use-stick-to-bottom@1.1.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-A6vhRIbuQqqkwR9CbbMEP9oZcNaAVknjYL/GR9BnmpSUxwR8ncPx7k4O2CrJriObORKIYgvAsmVWcE+moJDmVg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
@@ -3272,7 +3286,7 @@
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@cline/cline-hub-webview/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@cline/cline-hub-webview/@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
"@cline/code/lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
@@ -3500,7 +3514,7 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
@@ -3576,7 +3590,7 @@
"jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"jsonwebtoken/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"jsonwebtoken/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
@@ -3646,7 +3660,7 @@
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"sharp/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -3666,7 +3680,7 @@
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"webview/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"webview/@types/node": ["@types/node@24.13.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-5vtOqGQr4NJKeEzV441FcOi2MeG9UTWq9LqVLGneDdu4vlX17H8kQ2PA2UmNwCUGPVDj4oBjNhS7ReVEIWJJrg=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -3676,7 +3690,7 @@
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@cline/cline-hub-webview/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@cline/cline-hub-webview/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
@@ -3798,7 +3812,7 @@
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"webview/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"webview/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+8
View File
@@ -1,5 +1,13 @@
# Cline SDK Changelog
## 0.0.47
- Added support for overriding the API base URL
- Enforced a production singleton Cline Hub so only one hub daemon runs, and a stale hub is respawned after an upgrade
- Allowed plugin chat commands to submit prompts to the agent
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 0.0.46
- Added support for configured agents as subagent tools
@@ -193,6 +193,8 @@ function startCommand(
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
const record: JobRecord = {
@@ -192,7 +192,11 @@ async function checkIgnoredByWorkspaceGitignore(
const child = spawn(
"git",
["check-ignore", "--stdin", "-z", "-v", "-n", "--no-index"],
{ cwd: workspaceRoot, stdio: ["pipe", "pipe", "pipe"] },
{
cwd: workspaceRoot,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
);
const stdout: Buffer[] = [];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.46",
"version": "0.0.47",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+42 -1
View File
@@ -442,7 +442,7 @@ describe("AgentRuntime", () => {
it("preserves structured multimodal tool results for the next model request", async () => {
const structuredOutput = [
{ type: "text", text: "Successfully read image" },
{ type: "image", data: "BASE64DATA", mediaType: "image/jpeg" },
{ type: "image", data: "QkFTRTY0REFUQQ==", mediaType: "image/jpeg" },
];
const model = new ScriptedModel([
() => [
@@ -960,6 +960,47 @@ describe("AgentRuntime", () => {
expect(model.requests).toHaveLength(1);
});
it("merges beforeModel options metadata into the model request", async () => {
const model = new ScriptedModel([
(request) => {
expect(request.options?.metadata).toMatchObject({
sessionId: "session-1",
runId: "run-1",
iteration: 1,
});
return [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
model,
modelOptions: { metadata: { existing: true } },
hooks: {
beforeModel: () => ({
options: {
metadata: {
sessionId: "session-1",
runId: "run-1",
iteration: 1,
},
},
}),
},
});
await runtime.run("capture metadata");
expect(model.requests).toHaveLength(1);
expect(model.requests[0]?.options?.metadata).toMatchObject({
existing: true,
sessionId: "session-1",
runId: "run-1",
iteration: 1,
});
});
it("preserves the existing system prompt when prepareTurn returns only messages", async () => {
const compactedMessage: AgentMessage = {
id: "msg_compacted",
+6 -2
View File
@@ -23,7 +23,11 @@ import type {
ToolApprovalResult,
ToolPolicy,
} from "@cline/shared";
import { captureSdkError, estimateTokens } from "@cline/shared";
import {
captureSdkError,
estimateTokens,
mergeModelOptions,
} from "@cline/shared";
import { nanoid } from "nanoid";
// Local `createUID` helper. The clinee source imports this from
@@ -778,7 +782,7 @@ export class AgentRuntime {
if (result?.options) {
request = {
...request,
options: { ...(request.options ?? {}), ...result.options },
options: mergeModelOptions(request.options, result.options),
};
}
}
+5
View File
@@ -50,6 +50,11 @@ const builds: Parameters<typeof Bun.build>[0][] = [
outdir: "./dist/services/telemetry",
...buildConfig,
},
{
entrypoints: ["./src/services/feature-flags/posthog.ts"],
outdir: "./dist/services/feature-flags",
...buildConfig,
},
// The plugin sandbox bootstrap runs in an isolated child process via
// SubprocessSandbox and must be emitted as a separate executable entrypoint.
{
+15 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.46",
"version": "0.0.47",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -30,6 +30,10 @@
"./telemetry": {
"types": "./dist/services/telemetry/index.d.ts",
"import": "./dist/services/telemetry/index.js"
},
"./services/feature-flags/posthog": {
"types": "./dist/services/feature-flags/posthog.d.ts",
"import": "./dist/services/feature-flags/posthog.js"
}
},
"scripts": {
@@ -67,8 +71,17 @@
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"peerDependencies": {
"posthog-node": "^5.8.0"
},
"peerDependenciesMeta": {
"posthog-node": {
"optional": true
}
},
"devDependencies": {
"@types/ws": "^8.18.1"
"@types/ws": "^8.18.1",
"posthog-node": "^5.8.0"
},
"engines": {
"node": ">=22"

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