mirror of
https://github.com/cline/cline.git
synced 2026-09-13 01:39:57 +08:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0aac53a2 | ||
|
|
ad442cbb6a | ||
|
|
225f65cc0e | ||
|
|
8a619a9ea6 | ||
|
|
2e46676952 | ||
|
|
102e08f5f4 | ||
|
|
942f76e8e7 | ||
|
|
57eb181bab | ||
|
|
63e9c99031 | ||
|
|
afab68f3a3 | ||
|
|
d3d3bd8749 | ||
|
|
afab86dcbd | ||
|
|
6d7e745fb7 | ||
|
|
fcd5a9e0fb | ||
|
|
c8afb44368 | ||
|
|
d30cce4cf1 | ||
|
|
3f39c46026 | ||
|
|
274ad3bdb2 | ||
|
|
e6b1c3fbc8 | ||
|
|
5b22490d41 | ||
|
|
d03e88e50b | ||
|
|
02a1bfd0a8 | ||
|
|
4559f12c66 | ||
|
|
6d6f79f948 | ||
|
|
cde49170db | ||
|
|
e703f7af2a | ||
|
|
105d357b12 | ||
|
|
274e0c574f | ||
|
|
98d883bf6e | ||
|
|
6ff6a1ecbb | ||
|
|
0f8d715dc7 | ||
|
|
3d460d7add | ||
|
|
24f66a4673 | ||
|
|
354c80df7b | ||
|
|
8437a01906 | ||
|
|
986f0b010b | ||
|
|
1be2d20349 | ||
|
|
a56af4efaf | ||
|
|
10e5176ed8 | ||
|
|
a5611e8f6d | ||
|
|
66883f584c | ||
|
|
e370732545 | ||
|
|
ee4adda2da | ||
|
|
651829b24a | ||
|
|
00ba2ff7da | ||
|
|
3f03159737 | ||
|
|
3087bd3d32 | ||
|
|
9b59090967 | ||
|
|
7e31fb9e0d | ||
|
|
e0eb0167da | ||
|
|
d5748b2939 | ||
|
|
ffd6a6b1db | ||
|
|
51784a3bf1 | ||
|
|
149abb0ddb | ||
|
|
b3cee3f973 | ||
|
|
c68f553856 | ||
|
|
fd794ce3be | ||
|
|
f5f1071af2 | ||
|
|
4540390096 | ||
|
|
b590e14b91 | ||
|
|
54cc156089 | ||
|
|
6c599d18c3 | ||
|
|
513aacc0e6 | ||
|
|
ff2f860941 | ||
|
|
62a6b5a0b2 | ||
|
|
0caf617b50 | ||
|
|
930575991d | ||
|
|
e40d7d44ae | ||
|
|
e973ce4f33 | ||
|
|
4f25692d70 | ||
|
|
b0bba2e5d6 | ||
|
|
a6e2c0c431 | ||
|
|
d011d049a1 | ||
|
|
e09afced69 | ||
|
|
3f83be51a2 | ||
|
|
1efe5577e1 | ||
|
|
9cbc24d6e5 | ||
|
|
551286d33b | ||
|
|
361ac90978 | ||
|
|
4eb7402334 | ||
|
|
ffb61a865f | ||
|
|
d3616b96ae | ||
|
|
98e5458b09 | ||
|
|
031b8d94e1 | ||
|
|
e5bcba8ef9 | ||
|
|
83182c0d96 | ||
|
|
40ebd09dfa | ||
|
|
7bc18f7e15 | ||
|
|
fad8006730 | ||
|
|
28f35a2ec8 | ||
|
|
1e24807d1a | ||
|
|
a5f90e0d53 | ||
|
|
adabfc6bd5 | ||
|
|
397d6f344f | ||
|
|
8ca3068b73 | ||
|
|
6e6befdb65 | ||
|
|
71536e55aa | ||
|
|
7348ba1847 | ||
|
|
3e96fc6112 | ||
|
|
d84d09c543 | ||
|
|
6f7f817d63 | ||
|
|
c18d9478ba | ||
|
|
574b8eb45e | ||
|
|
81cce3d70e | ||
|
|
e1352fa709 | ||
|
|
394fb04518 | ||
|
|
f1aebbfd5a | ||
|
|
543dd0d818 | ||
|
|
1f2cbbeb9f | ||
|
|
b1a89156d6 | ||
|
|
1d7d9ce5e2 | ||
|
|
78b7c3d8ac | ||
|
|
d626cfb0b5 | ||
|
|
e14f354c59 | ||
|
|
41ba332f0a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Bring back a copy button on turn-final response rows, under a new subtle "Completed" / "Plan" header
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Hide the "View Changes" button on completion rows until there are actually changes to show, instead of rendering it faded and disabled. Turns that changed nothing, non-git workspaces, and repos without commits no longer show a dead button with a misleading tooltip.
|
||||
@@ -92,3 +92,4 @@ apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
apps/examples/desktop-app/.cursor/settings.json
|
||||
|
||||
+137
@@ -1,5 +1,142 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.10]
|
||||
|
||||
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
|
||||
|
||||
### Added
|
||||
|
||||
- Let models that support it search the web during a task, with a toggle in Feature Settings to turn it on. Search calls and their results appear in the conversation and persist across reloads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop two Cline installations on different builds from shutting each other's Hub daemon down in a loop, which killed live sessions with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can decide to retire the other.
|
||||
- Leave a Hub that is still serving sessions in place instead of replacing it mid-handshake; the swap happens once it goes idle.
|
||||
- Reclaim idle plugin sandbox processes instead of leaving them running for the life of the session.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refresh the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board.
|
||||
|
||||
## [4.1.9]
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the editor's foreground color for diff block text, so diffs stay legible in themes where the previous hardcoded color washed them out.
|
||||
- Switch the interface to Inter and Geist Mono.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't discard a successfully refreshed Cline token when the old one was already past expiry, which made the first request after a long idle period fail despite valid credentials.
|
||||
- Stop the legacy-task migration backlog from spamming telemetry, and record a migration outcome only once the seeded session actually persists, so a failed migration is no longer reported as a success.
|
||||
- Report involuntary Cline logouts (a rejected refresh token) instead of clearing credentials silently.
|
||||
|
||||
### Fixed (SDK bundle only)
|
||||
|
||||
These land through SDK v0.0.74 and therefore apply to windows running the SDK bundle, not the legacy one.
|
||||
|
||||
- Fix the Claude Code provider being unusable for agentic work: it now runs its own native tools instead of receiving tool definitions it cannot bridge, anchors the session on your workspace directory, and loads `~/.claude` plus project settings so your permission rules apply.
|
||||
- Reject truncated tool-call JSON instead of silently "repairing" it into wrong arguments.
|
||||
- Fix strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts.
|
||||
- Fix a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough.
|
||||
- Report disjoint per-request token buckets instead of re-counting the whole cached conversation on every request, which inflated per-task totals roughly 5x on cache-heavy sessions.
|
||||
|
||||
## [4.1.8]
|
||||
|
||||
### Added
|
||||
|
||||
- Enter any Vertex model ID by hand, including models the catalog doesn't list yet.
|
||||
- Support Fable 5 on Vertex.
|
||||
|
||||
### Changed
|
||||
|
||||
- Show the full model catalog for every Vertex region instead of filtering the picker down to a hardcoded list of global-endpoint models, which lagged behind every model launch. Picking a model the region doesn't serve now fails at request time with recovery guidance in the error row.
|
||||
- Report Fable 5 cost on Vertex as unknown rather than applying Anthropic's list price, which understated what Vertex actually bills — its rates are region-dependent.
|
||||
- Make the auto-approve menu the single source of truth for unattended runs and remove the Yolo Mode toggle, which was cosmetic: nothing in the approval path read it. Setups that had Yolo Mode (or auto-approve-all) turned on are migrated to auto-approving every action, so they keep running unattended.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respect your configured max output tokens when the compaction summarizer requests a summary.
|
||||
- Remove the stale "Double-Check Completion" feature tip.
|
||||
|
||||
## [4.1.7]
|
||||
|
||||
### Added
|
||||
|
||||
- Restore the "View Changes" button on completion rows, backed by SDK checkpoints, so you can review everything a task touched from the completion card.
|
||||
- Bring back a copy button on turn-final response rows.
|
||||
- Support pre-registered OAuth clients for remote MCP servers, for setups where dynamic client registration isn't available.
|
||||
|
||||
### Changed
|
||||
|
||||
- Fade the "View Changes" button until changes since the last message are confirmed, and hide it entirely when there is nothing to show.
|
||||
- Centralize plugin settings and contributions, with host-aware snapshots and atomic plugin toggles.
|
||||
- Carry execution context in scheduled run reports — readable headers, schedule metadata, durations, and lifecycle error details.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve prompts queued during a turn when that turn is interrupted: they survive aborts, are drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped.
|
||||
- Keep session context durable across aborts and hub restarts, so an interrupted session resumes with the state it had.
|
||||
- Settle the turn phase when a mode switch aborts a running turn.
|
||||
- Report queued-turn failures as `run.failed` instead of letting them complete silently.
|
||||
- Keep a hung MCP server from taking down session creation, and give stdio servers that were never configured a 30-second initialize budget instead of blocking indefinitely.
|
||||
- Surface OAuth authorization for SSE MCP servers on a 401 instead of failing outright.
|
||||
- Route LiteLLM through Chat Completions instead of the Responses API, fixing requests against LiteLLM proxies.
|
||||
- Retry network interruptions that happen mid-stream but before any model output, instead of failing the turn.
|
||||
- Use the configured fetch for Vertex ADC token refreshes, so they work behind proxies and custom transports.
|
||||
- Include files that were untracked when a snapshot was taken in checkpoint diffs, and pick up checkpoints when git is initialized part-way through a session.
|
||||
- Fall back to the session cwd or Desktop for @-mention file search in empty windows.
|
||||
- Never run a foreign compiled plugin-sandbox bootstrap for a source host.
|
||||
|
||||
## [4.1.6]
|
||||
|
||||
### Added
|
||||
|
||||
- Offer `meta/muse-spark-1.2-contributor` on the Cline provider, alongside a refreshed model catalog.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Attribute error telemetry to the model actually in use for a run, so failures are no longer reported against the wrong model.
|
||||
|
||||
## [4.1.5]
|
||||
|
||||
### Added
|
||||
|
||||
- Explain when a free model promotion ends. Requests to a retired free model now show a dedicated notice with a button to pick another model, instead of a generic error with nothing but a Retry prompt.
|
||||
|
||||
### Changed
|
||||
|
||||
- Map reasoning settings onto a shared path across AI SDK providers, so effort levels and enable/disable toggles behave consistently (including on Ollama) instead of relying on per-provider overrides.
|
||||
|
||||
## [4.1.4]
|
||||
|
||||
### Added
|
||||
|
||||
- Recognize Chutes as a provider.
|
||||
- Show skills alongside workflows in the slash command menu, and disambiguate commands that share a name instead of letting one shadow the other.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove model-initiated plan-to-act switching. Switching out of plan mode is now driven by you, not by the model deciding mid-turn.
|
||||
- Hard-block file-editing shell commands in plan mode instead of relying on prompting alone. Read-only investigation still works, but file manipulation, in-place editors, redirection to files, mutating git subcommands, and package installs are refused.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop treating a turn that completes with a plan as a failed turn when a plan-blocked command was its only tool call. The turn no longer ends in the error state with a Retry footer, and toggling to Act correctly re-runs the presented plan instead of appearing to do nothing.
|
||||
- Show tool paths relative to the workspace in the chat view instead of absolute paths.
|
||||
- Reset pending attachments when starting a new task, so images from the previous task no longer carry over.
|
||||
- Surface a clear error when the selected provider has no API key configured, instead of a generic failure.
|
||||
- Refresh MCP tool and resource lists when a server sends a `list_changed` notification, instead of only showing a toast.
|
||||
- Show installed plugins under their real package names instead of all appearing as "index".
|
||||
- Correct the Linux keybinding label in the Plan/Act mode tooltip.
|
||||
- Recover from running out of context instead of failing with a raw provider error — the run compacts and retries once, and the cases that genuinely cannot be recovered explain why.
|
||||
- Retry empty model responses on every provider rather than only Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
|
||||
- Stop Claude 4.6+ and 5.x models being rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id.
|
||||
- Restore Bedrock prompt caching, which reported zero cache reads and writes because the provider sent a cache format Bedrock discards, and route Bedrock foundation models through geo inference profiles.
|
||||
- Send `max_completion_tokens` for reasoning models on OpenAI-compatible endpoints, and substitute image content for models without image support instead of failing the request.
|
||||
- Inherit the MiniMax default model from models.dev, and refresh the bundled catalog, which adds Infomaniak and SCX.ai.
|
||||
- Report the same provider failure once instead of twice in error telemetry, and rate-limit repeated failures from unattended retry loops.
|
||||
|
||||
## [4.1.3]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.55
|
||||
|
||||
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
|
||||
- Added protections for an update landing under CLI 3.0.54 and earlier, whose updater restarts the Hub mid-session and then rejects every replacement, bricking a running session. The newly installed package defuses that path during install instead of leaving it to fire
|
||||
- Fixed two Cline installations on different builds shutting each other's Hub daemon down in a loop, which killed every live session with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can ever decide to retire the other (from SDK v0.0.75)
|
||||
- A newer build no longer replaces a Hub that is still serving sessions — it attaches to it and the swap happens on a later launch, instead of the sessions dying mid-handshake (from SDK v0.0.75)
|
||||
- Removed the "outdated Hub" notice. It reported a state you cannot act on, and the toast was capped narrower than the message, so it rendered cut off before the reassuring half of the sentence at every terminal width. The prompt for a genuine build mismatch, where there is something to do, is unchanged
|
||||
- Streaming assistant markdown no longer flashes back to raw text. Settled headings, links, and code stay rendered as new chunks arrive instead of the whole message being rebuilt and re-highlighted on every chunk, which also stops the transcript from jumping vertically mid-stream
|
||||
- Web search calls and their results from models that run search natively now render in the transcript (from SDK v0.0.75)
|
||||
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session (from SDK v0.0.75)
|
||||
- `cline doctor fix` now reports honestly: processes that survived a kill are separated from ones that appeared while the fix ran, a live parent respawning a daemon is named, and a startup lock held by a running process is reported as held rather than leaked (from SDK v0.0.75)
|
||||
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board (from SDK v0.0.75)
|
||||
|
||||
## 3.0.54
|
||||
|
||||
- Fixed the Claude Code provider being unusable for agentic work: the provider now runs its own native tools instead of receiving tool definitions it cannot bridge, the session is anchored on your workspace directory instead of inheriting the host's cwd, and `~/.claude` plus project settings are loaded so your permission rules apply. File edits under the workspace are auto-approved; command execution stays gated by your own Claude settings (from SDK v0.0.74)
|
||||
- Fixed truncated tool-call JSON being silently "repaired" into wrong arguments — a payload with an unterminated string is now rejected rather than getting an invented terminator (from SDK v0.0.74)
|
||||
- Fixed strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts (from SDK v0.0.74)
|
||||
- Fixed a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough (from SDK v0.0.74)
|
||||
- Managed Hub daemons now upgrade directionally: when another Cline install ships a newer Hub build, the CLI attaches to the newer daemon and prompts you to update and restart instead of the two installs repeatedly retiring each other's daemons. Yolo and sandbox sessions, which never attach to the shared Hub, are not interrupted by that prompt (from SDK v0.0.74)
|
||||
- Fixed the Hub daemon logging an unhandled `hub server close failed` error and exiting non-zero whenever a client was still connected at shutdown (from SDK v0.0.74)
|
||||
- Fixed per-task token totals being inflated roughly 5x on cache-heavy sessions — token telemetry now reports disjoint uncached-input, cache-read, and cache-write buckets instead of re-counting the whole cached conversation on every request (from SDK v0.0.74)
|
||||
- Upgrading the CLI now retires an already-running Hub daemon and respawns it on the new code, instead of the upgraded CLI continuing to talk to a daemon executing the previous release
|
||||
|
||||
## 3.0.53
|
||||
|
||||
- Fixed the CLI reconnecting to a stale Hub daemon after an upgrade. Hub daemons now carry a runtime build fingerprint, so an upgraded CLI retires and respawns a daemon still running older code instead of attaching to it (from SDK v0.0.73)
|
||||
- Fixed compaction being silently skipped on reasoning models. The summarizer no longer hardcodes a 1024-token output cap — it honors your max output tokens setting, defaults to 4096 (lowered when the model reports less), and logs a diagnostic when a summary comes back empty (from SDK v0.0.73)
|
||||
- Added Fable 5 (`claude-fable-5`) to the Vertex model catalog. Pricing is intentionally omitted because Vertex bills region-dependently, so cost shows as unknown rather than wrong (from SDK v0.0.73)
|
||||
- Custom Vertex model IDs are now passed through unchanged, routing Claude-style IDs to the Anthropic-on-Vertex path (from SDK v0.0.73)
|
||||
|
||||
## 3.0.52
|
||||
|
||||
- Added `cline mcp uninstall` for removing an installed MCP server
|
||||
- Schedules now reuse your saved provider settings instead of needing provider configuration of their own
|
||||
- Queued messages are legible on light-theme terminals — they were previously rendered in a color that washed out against a light background
|
||||
- MCP tool results render as readable text in the TUI instead of escaped JSON, and binary payloads survive being expanded instead of being mangled
|
||||
- Malformed tool input/output payloads no longer break rendering — the formatters degrade gracefully instead of throwing
|
||||
- Prompts queued during a turn now survive being interrupted: they are preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of leaving the queue silently dropped (from SDK v0.0.72)
|
||||
- Session context stays durable across aborts and hub restarts, so an interrupted session resumes with the state it had (from SDK v0.0.72)
|
||||
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely (from SDK v0.0.72)
|
||||
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright, and pre-registered OAuth clients are supported for setups without dynamic client registration (from SDK v0.0.72)
|
||||
- LiteLLM requests route through Chat Completions instead of the Responses API, fixing calls against LiteLLM proxies (from SDK v0.0.72)
|
||||
- Network interruptions that happen mid-stream but before any model output are retried instead of failing the turn (from SDK v0.0.72)
|
||||
- Vertex ADC token refreshes use the configured fetch, so they work behind proxies and custom transports (from SDK v0.0.72)
|
||||
- Checkpoint diffs include files that were untracked when the snapshot was taken, and checkpoints are picked up when git is initialized part-way through a session (from SDK v0.0.72)
|
||||
- Scheduled run reports carry execution context — readable headers, schedule metadata, durations, and lifecycle error details (from SDK v0.0.72)
|
||||
|
||||
## 3.0.51
|
||||
|
||||
- Reasoning effort now applies consistently across providers instead of going through per-provider thinking overrides, including Ollama, and asking for reasoning to be off is respected everywhere (from SDK v0.0.71)
|
||||
- `meta/muse-spark-1.2-contributor` is now selectable on the Cline provider, alongside a refreshed model catalog (from SDK v0.0.71)
|
||||
- Error telemetry now reports the model that was actually in use for the run (from SDK v0.0.71)
|
||||
|
||||
## 3.0.50
|
||||
|
||||
- Added user-selectable color themes to the interactive TUI. Pick one with `/theme`, the command palette, or the Theme row in `/settings` — the picker previews each theme live. Built-in themes are Auto (terminal-adaptive, the default), Cline Dark, Cline Light, Tokyo Night, Gruvbox Dark, Nord, Dracula, Catppuccin Mocha, One Dark, Solarized Dark, and Solarized Light. Named themes paint the background, foreground, accents, syntax highlighting, and diff colors, and `CLINE_THEME` overrides the persisted choice at startup
|
||||
- The git branch shown below the prompt now updates when you switch branches from another terminal or your editor, instead of showing whatever was checked out when the TUI started
|
||||
- Telegram slash commands such as `/clear` now reach the connector command host — the Telegram library was intercepting them and they were silently dropped
|
||||
- Racing connector launches no longer collide: an instance is claimed before it opens socket mode, the hub supervises connector processes, and `doctor`/`connect` skip connectors that are already starting. Connector tools are also enabled by default, and the Slack greeting is no longer replayed on reconnect
|
||||
- Auto-approval settings are now honored over ACP
|
||||
- Plan mode now hard-blocks file-editing shell commands instead of relying on prompting alone — `run_commands` stays available for read-only investigation, but file-manipulation commands, in-place editors (`sed -i`, `perl -i`), redirection to files, mutating git subcommands, package installs, and nested command strings (`sh -c`, `eval`, `sudo`) are rejected, on Windows and PowerShell too (from SDK v0.0.70)
|
||||
- A turn that ends with a completed plan is no longer rendered as a failed turn when a plan-blocked command was its only tool call
|
||||
- Running out of context is now recovered from instead of failing with a raw provider error: the run force-compacts and retries once, and the cases that genuinely cannot be recovered report why (from SDK v0.0.70)
|
||||
- Empty model responses are now retried on every provider, not just Ollama — OpenRouter, Cline, and OpenAI-compatible endpoints previously failed the task outright with "Model returned empty response" (from SDK v0.0.70)
|
||||
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported" when they resolve from the offline catalog or from a hand-typed model id (from SDK v0.0.70)
|
||||
- Bedrock prompt caching works again — the provider was sending a cache format Bedrock silently discards, so cache reads and writes were always 0 — and Bedrock foundation models are now routed through geo inference profiles (from SDK v0.0.70)
|
||||
- Reasoning models on OpenAI-compatible endpoints now receive `max_completion_tokens` instead of the rejected `max_tokens`, and requests to models without image support substitute the image content instead of failing (from SDK v0.0.70)
|
||||
- MiniMax now inherits its default model from models.dev, and the model catalog picked up two new providers, Infomaniak and SCX.ai (from SDK v0.0.70)
|
||||
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native AI SDK provider (from SDK v0.0.70)
|
||||
- Error telemetry no longer reports the same provider failure twice, and repeated failures from unattended retry loops are rate-limited (from SDK v0.0.70)
|
||||
|
||||
## 3.0.49
|
||||
|
||||
- `/undo` works again once the agent has used tools — the checkpoint picker counted tool results as user turns, so restore aborted with "Could not find user message for run N"
|
||||
|
||||
+4
-2
@@ -221,13 +221,15 @@ In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/
|
||||
|
||||
Schedule agents on cron-like intervals or external events.
|
||||
|
||||
If `--provider` and `--model` are omitted, schedules use the last configured
|
||||
provider and model. If only `--provider` is given, the schedule uses that
|
||||
provider's saved model.
|
||||
|
||||
```sh
|
||||
cline schedule create "Daily code review" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--prompt "Review PRs opened yesterday and summarize issues." \
|
||||
--workspace /path/to/repo \
|
||||
--provider cline \
|
||||
--model openai/gpt-5.3-codex \
|
||||
--timeout 3600 \
|
||||
--tags automation,review
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.49",
|
||||
"version": "3.0.55",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -17,6 +17,35 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// CLI versions <= 3.0.54 restart the hub daemon after a background
|
||||
// auto-update even while it is serving live sessions, killing those sessions
|
||||
// mid-turn — and their build-fingerprint check then rejects every replacement
|
||||
// hub, bricking the running TUI. That restart code is the *old* version's, so
|
||||
// it cannot be patched here; but it bails out harmlessly when no hub
|
||||
// discovery record exists, and it runs only after this install (and this
|
||||
// script) completes. Setting the record aside protects any attached clients:
|
||||
// a running hub keeps serving its established connections, clients that share
|
||||
// its build fingerprint rebuild the record from a port probe, and the next
|
||||
// fresh launch retires stale hubs regardless of the record.
|
||||
function shieldRunningHubDiscovery() {
|
||||
const explicitPath = process.env.CLINE_HUB_DISCOVERY_PATH?.trim();
|
||||
const dataDir =
|
||||
process.env.CLINE_DATA_DIR?.trim() ||
|
||||
path.join(
|
||||
process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline"),
|
||||
"data",
|
||||
);
|
||||
const recordPath =
|
||||
explicitPath || path.join(dataDir, "locks", "hub", "production.json");
|
||||
if (!fs.existsSync(recordPath)) {
|
||||
return;
|
||||
}
|
||||
const asidePath = `${recordPath}.superseded`;
|
||||
fs.rmSync(asidePath, { force: true });
|
||||
fs.renameSync(recordPath, asidePath);
|
||||
console.log("Set aside hub discovery record for the updated CLI");
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, npm creates .cmd shims from the bin field.
|
||||
@@ -79,6 +108,14 @@ function main() {
|
||||
console.log(`Cached cline binary at ${target}`);
|
||||
}
|
||||
|
||||
try {
|
||||
shieldRunningHubDiscovery();
|
||||
} catch (error) {
|
||||
// Best-effort: without the shield the worst case is the pre-3.0.55
|
||||
// restart-while-busy behavior, never a broken install.
|
||||
console.error(`postinstall: hub discovery shield skipped: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import { isLikelyAuthError, type Message } from "@cline/shared";
|
||||
import { isLikelyAuthError, type MessageWithMetadata } from "@cline/shared";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { subscribeToAgentEvents } from "../runtime/session-events";
|
||||
@@ -100,7 +100,7 @@ interface SessionState {
|
||||
*/
|
||||
fatalError?: Error;
|
||||
/** Messages to inject into the next session manager for conversation continuity. */
|
||||
pendingInitialMessages?: Message[];
|
||||
pendingInitialMessages?: MessageWithMetadata[];
|
||||
}
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
@@ -240,7 +240,7 @@ export class AcpAgent implements Agent {
|
||||
this.isSessionReady();
|
||||
|
||||
let session = this.sessions.get(params.sessionId);
|
||||
let messages: Message[];
|
||||
let messages: MessageWithMetadata[];
|
||||
|
||||
if (session?.sessionManager && session.activeSessionId) {
|
||||
// The session is still live in this connection — replay its current
|
||||
@@ -676,7 +676,7 @@ export class AcpAgent implements Agent {
|
||||
session: SessionState,
|
||||
acpSessionId: string,
|
||||
options?: { resume?: boolean },
|
||||
): Promise<Message[] | undefined> {
|
||||
): Promise<MessageWithMetadata[] | undefined> {
|
||||
if (session.sessionManager) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -695,7 +695,7 @@ export class AcpAgent implements Agent {
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
});
|
||||
|
||||
let initialMessages: Message[] | undefined;
|
||||
let initialMessages: MessageWithMetadata[] | undefined;
|
||||
if (options?.resume) {
|
||||
initialMessages = await sessionManager
|
||||
.readMessages(acpSessionId)
|
||||
|
||||
@@ -225,6 +225,76 @@ describe("translateHistoricalMessage", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays provider model tools with the ordinary ACP tool updates", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Parameters<typeof translateHistoricalMessage>[0]),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "search-1",
|
||||
title: expect.any(String),
|
||||
kind: "search",
|
||||
status: "pending",
|
||||
rawInput: { query: "latest Bun release" },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "search-1",
|
||||
status: "completed",
|
||||
rawOutput: "Bun 1.3.14",
|
||||
},
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Found it" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves structured native web-search results", () => {
|
||||
const nativeResult = {
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
};
|
||||
const updates = translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-native",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: [nativeResult],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Parameters<typeof translateHistoricalMessage>[0]);
|
||||
|
||||
expect(updates[1]).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "search-native",
|
||||
rawOutput: JSON.stringify(nativeResult),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaySessionHistory", () => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import type {
|
||||
AgentSideConnection,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type MessageWithMetadata,
|
||||
type ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
@@ -29,7 +30,7 @@ function isSyntheticUserText(text: string): boolean {
|
||||
export async function replaySessionHistory(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
for (const update of translateHistoricalMessage(message)) {
|
||||
@@ -38,7 +39,17 @@ export async function replaySessionHistory(
|
||||
}
|
||||
}
|
||||
|
||||
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
|
||||
export function translateHistoricalMessage(
|
||||
message: MessageWithMetadata,
|
||||
): SessionUpdate[] {
|
||||
return projectSessionMessagesForDisplay([message]).flatMap(({ message }) =>
|
||||
translateProjectedHistoricalMessage(message),
|
||||
);
|
||||
}
|
||||
|
||||
function translateProjectedHistoricalMessage(
|
||||
message: MessageWithMetadata,
|
||||
): SessionUpdate[] {
|
||||
const blocks: ContentBlock[] =
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text", text: message.content }]
|
||||
@@ -133,8 +144,14 @@ function flattenToolResultContent(
|
||||
return part.text;
|
||||
case "file":
|
||||
return part.content;
|
||||
default:
|
||||
case "image":
|
||||
return "[image]";
|
||||
default:
|
||||
try {
|
||||
return JSON.stringify(part);
|
||||
} catch {
|
||||
return String(part);
|
||||
}
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -17,6 +17,7 @@ const TOOL_KIND_MAP: Record<string, ToolKind> = {
|
||||
WebFetch: "fetch",
|
||||
fetch_web_content: "fetch",
|
||||
WebSearch: "search",
|
||||
web_search: "search",
|
||||
Agent: "think",
|
||||
spawn_agent: "think",
|
||||
NotebookEdit: "edit",
|
||||
|
||||
@@ -767,6 +767,71 @@ Break work into clear steps.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes mcp uninstall and its rm alias", () => {
|
||||
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-mcp-rm-"));
|
||||
tempDirs.push(tempRoot);
|
||||
const settingsPath = path.join(tempRoot, "cline_mcp_settings.json");
|
||||
const writeSettings = () => {
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: { transport: { type: "stdio", command: "node" } },
|
||||
remote: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
customTopLevelKey: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
};
|
||||
const readServers = () =>
|
||||
(
|
||||
JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
}
|
||||
).mcpServers ?? {};
|
||||
|
||||
writeSettings();
|
||||
const uninstallResult = runCli(["mcp", "uninstall", "docs"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(uninstallResult.status).toBe(0);
|
||||
expect(asText(uninstallResult.stdout)).toContain(
|
||||
"Uninstalled MCP server docs.",
|
||||
);
|
||||
expect(Object.keys(readServers())).toEqual(["remote"]);
|
||||
|
||||
writeSettings();
|
||||
const aliasResult = runCli(["mcp", "rm", "remote", "--json"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(aliasResult.status).toBe(0);
|
||||
expect(JSON.parse(asText(aliasResult.stdout).trim())).toEqual({
|
||||
name: "remote",
|
||||
status: "uninstalled",
|
||||
});
|
||||
expect(Object.keys(readServers())).toEqual(["docs"]);
|
||||
|
||||
writeSettings();
|
||||
const missingResult = runCli(["mcp", "remove", "missing"], {
|
||||
env: { ...createIsolatedEnv(), CLINE_MCP_SETTINGS_PATH: settingsPath },
|
||||
});
|
||||
expect(missingResult.status).toBe(1);
|
||||
expect(asText(missingResult.stderr)).toContain(
|
||||
'MCP server "missing" is not installed.',
|
||||
);
|
||||
expect(Object.keys(readServers())).toEqual(["docs", "remote"]);
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockReadSupersededHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockStopLocalHubServerGracefully,
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
),
|
||||
})),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockReadSupersededHubDiscovery: vi.fn(() => undefined as unknown),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
@@ -73,6 +75,7 @@ vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
readSupersededHubDiscovery: mockReadSupersededHubDiscovery,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
listActiveConnectors: mockListActiveConnectors,
|
||||
@@ -186,6 +189,65 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sees the hub through the set-aside record during the shielded update window", async () => {
|
||||
const cwd = "/workspace";
|
||||
// The npm postinstall shield renamed the discovery record aside; the
|
||||
// hub is alive and serving an old client's sessions.
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
mockReadSupersededHubDiscovery.mockReturnValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "shielded-token",
|
||||
pid: 50174,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return { status: 0, stdout: "50174\n" };
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[2] === "--cline-hub-daemon"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "50174 /usr/local/bin/cline --cline-hub-daemon\n",
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockProbeHubServer).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
{
|
||||
authToken: "shielded-token",
|
||||
},
|
||||
);
|
||||
// Without the fallback the live daemon reads as stale and doctor's
|
||||
// advice (\"run doctor fix\") would kill the sessions the shield exists
|
||||
// to protect.
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
hubHealthy: true,
|
||||
staleHubPids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
@@ -624,3 +686,42 @@ describe("doctor supervision reporting", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeProcessesStartedDuringFix", () => {
|
||||
const { describeProcessesStartedDuringFix } = __test__;
|
||||
const liveParents = new Map([
|
||||
[100, 10],
|
||||
[200, 20],
|
||||
]);
|
||||
const resolveLiveParent = (pid: number) => liveParents.get(pid);
|
||||
|
||||
it("says nothing when no process started during the fix", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([], resolveLiveParent),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blames the parent only when every process has a live one", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([100, 200], resolveLiveParent),
|
||||
).toBe(
|
||||
"\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.",
|
||||
);
|
||||
});
|
||||
|
||||
// A process can start on its own mid-repair - a user opening a new session,
|
||||
// say - and telling them to go kill an unrelated parent would be wrong.
|
||||
it("states the facts when no process has a live parent", () => {
|
||||
expect(describeProcessesStartedDuringFix([777], resolveLiveParent)).toBe(
|
||||
"\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.",
|
||||
);
|
||||
});
|
||||
|
||||
it("separates respawns from independent starts in a mixed batch", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([100, 777], resolveLiveParent),
|
||||
).toBe(
|
||||
"\nSome of these were respawned by a live parent (100); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+149
-14
@@ -7,6 +7,7 @@ import {
|
||||
listActiveConnectors,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
readSupersededHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
@@ -384,6 +385,12 @@ async function clearHubStartupArtifacts(
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
clearedDiscovery = 1;
|
||||
}
|
||||
if (options?.clearDiscovery) {
|
||||
// The set-aside copy the npm postinstall shield leaves behind. Once
|
||||
// doctor has deliberately stopped everything, keeping it risks a much
|
||||
// later launch SIGTERMing whatever process has recycled its pid.
|
||||
clearPathIfExists(`${owner.discoveryPath}.superseded`);
|
||||
}
|
||||
return {
|
||||
startupLocks: clearedStartupLocks,
|
||||
discovery: clearedDiscovery,
|
||||
@@ -411,7 +418,25 @@ function resolveCliHubOwnerContext() {
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
// The npm postinstall shield sets the discovery record aside (see
|
||||
// readSupersededHubDiscovery) while an older hub finishes serving its
|
||||
// sessions. Without the fallback, doctor cannot see that hub, classifies
|
||||
// the live daemon as stale, and its "run doctor fix" advice kills the
|
||||
// sessions the shield exists to protect.
|
||||
const recorded = await readHubDiscovery(owner.discoveryPath);
|
||||
// The set-aside record carries only url/token/pid; widen so the two
|
||||
// sources read uniformly below.
|
||||
const discovery:
|
||||
| {
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
port?: number;
|
||||
coreVersion?: string;
|
||||
}
|
||||
| undefined = recorded?.url
|
||||
? recorded
|
||||
: readSupersededHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: undefined;
|
||||
@@ -449,6 +474,80 @@ function formatPidList(label: string, pids: number[]): string {
|
||||
return `${label} ${c.dim}${pids.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
function readParentPid(pid: number): number | undefined {
|
||||
try {
|
||||
const output = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = Number(output.stdout?.trim());
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function liveParentPid(pid: number): number | undefined {
|
||||
const parent = readParentPid(pid);
|
||||
return parent && isProcessRunning(parent) ? parent : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A daemon whose parent is still running was almost certainly just spawned by
|
||||
* that parent, and killing it only invites the parent to spawn another. Naming
|
||||
* the parent points at the process the user actually has to stop.
|
||||
*/
|
||||
function formatDaemonPidList(label: string, pids: number[]): string {
|
||||
if (pids.length === 0) {
|
||||
return `${label} ${c.dim}0${c.reset}`;
|
||||
}
|
||||
const described = pids.map((pid) => {
|
||||
const parent = liveParentPid(pid);
|
||||
return parent ? `${pid} (spawned by ${parent})` : String(pid);
|
||||
});
|
||||
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advice for processes first seen during the fix. Only a process with a live
|
||||
* parent is known to have been respawned by it; anything else may have been
|
||||
* started independently (a user opening a new session mid-repair), so it gets
|
||||
* a statement of fact rather than an instruction to go kill something.
|
||||
*/
|
||||
export function describeProcessesStartedDuringFix(
|
||||
pids: number[],
|
||||
resolveLiveParent: (pid: number) => number | undefined,
|
||||
): string | undefined {
|
||||
if (pids.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const respawned = pids.filter((pid) => resolveLiveParent(pid) !== undefined);
|
||||
if (respawned.length === 0) {
|
||||
return "\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.";
|
||||
}
|
||||
if (respawned.length === pids.length) {
|
||||
return "\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.";
|
||||
}
|
||||
return `\nSome of these were respawned by a live parent (${respawned.join(", ")}); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.`;
|
||||
}
|
||||
|
||||
function formatStartupLockList(
|
||||
label: string,
|
||||
locks: StartupArtifact[],
|
||||
): string {
|
||||
const described = locks
|
||||
.map((lock) => {
|
||||
if (lock.pid === undefined) {
|
||||
return "unreadable";
|
||||
}
|
||||
return lock.stale ? `${lock.pid} (stale)` : `${lock.pid} (held, live)`;
|
||||
})
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (described.length === 0) {
|
||||
return `${label} ${c.dim}0${c.reset}`;
|
||||
}
|
||||
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
|
||||
const pieces = [
|
||||
record.timestamp ?? "unknown-time",
|
||||
@@ -531,6 +630,7 @@ function killPids(pids: number[]): number {
|
||||
export const __test__ = {
|
||||
decideForeignContainer,
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
describeProcessesStartedDuringFix,
|
||||
formatSupervisedConnector,
|
||||
};
|
||||
|
||||
@@ -556,13 +656,8 @@ 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",
|
||||
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(formatDaemonPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(formatStartupLockList("hub startup locks", before.hubStartupLocks));
|
||||
writeln(formatPidList("cli processes", before.staleCliPids));
|
||||
writeln(formatPidList("sidecar processes", before.staleSidecarPids));
|
||||
if (before.activeConnectors.length === 0) {
|
||||
@@ -673,16 +768,56 @@ export async function runDoctorCommand(
|
||||
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
// "Remaining" means a process this run tried to kill and failed to. A
|
||||
// re-scan alone cannot tell that apart from a process that appeared while
|
||||
// the fix was running, and reporting the two together reads as a failure
|
||||
// to kill something that was never targeted.
|
||||
const survived = (targets: number[], remaining: number[]) =>
|
||||
remaining.filter((pid) => targets.includes(pid));
|
||||
const appeared = (targets: number[], remaining: number[]) =>
|
||||
remaining.filter((pid) => !targets.includes(pid));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
"remaining hub listeners",
|
||||
survived(refreshedAfterGracefulStop.listeningPids, after.listeningPids),
|
||||
),
|
||||
);
|
||||
writeln(formatPidList("remaining cli processes", after.staleCliPids));
|
||||
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
|
||||
writeln(
|
||||
formatDaemonPidList(
|
||||
"remaining stale hub daemons",
|
||||
survived(staleHubTargets, after.staleHubPids),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatStartupLockList("remaining hub startup locks", after.hubStartupLocks),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining cli processes",
|
||||
survived(staleCliTargets, after.staleCliPids),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining sidecar processes",
|
||||
survived(staleSidecarTargets, after.staleSidecarPids),
|
||||
),
|
||||
);
|
||||
const spawnedDuringFix = [
|
||||
...appeared(staleHubTargets, after.staleHubPids),
|
||||
...appeared(staleCliTargets, after.staleCliPids),
|
||||
...appeared(staleSidecarTargets, after.staleSidecarPids),
|
||||
];
|
||||
if (spawnedDuringFix.length > 0) {
|
||||
writeln(formatDaemonPidList("started during fix", spawnedDuringFix));
|
||||
const advice = describeProcessesStartedDuringFix(
|
||||
spawnedDuringFix,
|
||||
liveParentPid,
|
||||
);
|
||||
if (advice) {
|
||||
io.writeln(advice);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
runMcpUninstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
@@ -56,6 +60,19 @@ describe("mcp install command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mcp-remote marketplace entries as native remote servers", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "linear",
|
||||
targetArgs: ["npx", "-y", "mcp-remote", "https://mcp.linear.app/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "linear",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
@@ -269,3 +286,131 @@ describe("mcp install command", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp uninstall command", () => {
|
||||
let root = "";
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-mcp-uninstall-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSettings(): string {
|
||||
const settingsPath = join(root, "cline_mcp_settings.json");
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
keep: {
|
||||
transport: { type: "stdio", command: "node" },
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
customTopLevelKey: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
function readSettings(settingsPath: string): Record<string, unknown> & {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
} {
|
||||
return JSON.parse(readFileSync(settingsPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { mcpServers?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("uninstalls the requested server and reports success", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "docs",
|
||||
settingsPath,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(writeln).toHaveBeenCalledWith("Uninstalled MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
|
||||
const written = readSettings(settingsPath);
|
||||
expect(Object.keys(written.mcpServers ?? {})).toEqual(["keep"]);
|
||||
expect(written.mcpServers?.keep).toEqual({
|
||||
transport: { type: "stdio", command: "node" },
|
||||
disabled: true,
|
||||
});
|
||||
expect(written.customTopLevelKey).toBe(true);
|
||||
});
|
||||
|
||||
it("prints uninstall JSON with --json", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "docs",
|
||||
settingsPath,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toEqual({
|
||||
name: "docs",
|
||||
status: "uninstalled",
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports an error and leaves settings intact for an unknown server", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const before = readFileSync(settingsPath, "utf8");
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: "missing",
|
||||
settingsPath,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
'MCP server "missing" is not installed.',
|
||||
);
|
||||
expect(writeln).not.toHaveBeenCalled();
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(before);
|
||||
});
|
||||
|
||||
it("rejects a blank name without rewriting settings", async () => {
|
||||
const settingsPath = writeSettings();
|
||||
const before = readFileSync(settingsPath, "utf8");
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpUninstallCommand({
|
||||
name: " ",
|
||||
settingsPath,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith("MCP server name is required");
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
buildMcpInstallTransport as buildCoreMcpInstallTransport,
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
type McpUninstallOptions as CoreMcpUninstallOptions,
|
||||
type McpUninstallResult as CoreMcpUninstallResult,
|
||||
uninstallMcpServer,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
export { buildMcpInstallTransport, uninstallMcpServer } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
@@ -28,39 +32,6 @@ export interface McpInstallDirectResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
@@ -73,36 +44,20 @@ export function buildMcpInstallDefaults(options: {
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
const { name, transport } = buildCoreMcpInstallTransport(options);
|
||||
if (transport.type === "stdio") {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
type: transport.type,
|
||||
command: [transport.command, ...(transport.args ?? [])]
|
||||
.map(quoteCommandArg)
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
type: transport.type,
|
||||
url: transport.url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,3 +113,42 @@ export async function runMcpInstallCommand(
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpUninstallOptions extends CoreMcpUninstallOptions {
|
||||
io?: McpCommandIo;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface McpUninstallDirectResult extends CoreMcpUninstallResult {}
|
||||
|
||||
export function uninstallMcpServerDirect(
|
||||
options: McpUninstallOptions,
|
||||
): McpUninstallDirectResult {
|
||||
const result: CoreMcpUninstallResult = uninstallMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMcpUninstallCommand(
|
||||
options: McpUninstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const name = options.name?.trim() ?? "";
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const result = uninstallMcpServerDirect({ ...options, name });
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Uninstalled MCP server ${result.name}.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,29 @@ import { createScheduleCommand } from "./schedule";
|
||||
|
||||
const mockSendHubCommand = vi.hoisted(() => vi.fn());
|
||||
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
const mockProviderSettings = vi.hoisted(() => ({
|
||||
lastUsed: undefined as { provider?: string; model?: string } | undefined,
|
||||
providers: {} as Record<string, { provider?: string; model?: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
sendHubCommand: mockSendHubCommand,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockProviderSettings.lastUsed;
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
return mockProviderSettings.providers[providerId];
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
parseHubEndpointOverride: (rawAddress: string | undefined) => {
|
||||
@@ -47,6 +65,8 @@ async function runScheduleCommand(
|
||||
describe("runScheduleCommand list output", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it('prints "No schedules found." for empty non-json list output', async () => {
|
||||
@@ -121,9 +141,158 @@ describe("runScheduleCommand list output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runScheduleCommand create delivery metadata", () => {
|
||||
describe("runScheduleCommand create", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("uses the last used provider and model when both flags are omitted", async () => {
|
||||
mockProviderSettings.lastUsed = {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: (text?: string) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an explicit provider with that provider's configured model", async () => {
|
||||
mockProviderSettings.lastUsed = {
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5.3-codex",
|
||||
};
|
||||
mockProviderSettings.providers.anthropic = {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
};
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_123" } },
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
expect.objectContaining({
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when an explicit provider has no configured model and no model flag", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Health check",
|
||||
"--cron",
|
||||
"0 */6 * * *",
|
||||
"--prompt",
|
||||
"Run tests",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(errors).toEqual([
|
||||
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
|
||||
]);
|
||||
expect(mockSendHubCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
@@ -192,6 +361,8 @@ describe("runScheduleCommand create delivery metadata", () => {
|
||||
describe("runScheduleCommand import", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("preserves exported modelSelection providerId/modelId values", async () => {
|
||||
@@ -257,6 +428,8 @@ describe("runScheduleCommand import", () => {
|
||||
describe("runScheduleCommand export", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProviderSettings.lastUsed = undefined;
|
||||
mockProviderSettings.providers = {};
|
||||
});
|
||||
|
||||
it("writes JSON content to the --to file path", async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
registerScheduleImportCommand,
|
||||
registerScheduleUpdateCommand,
|
||||
} from "./import-export";
|
||||
import { resolveScheduleModelSelection } from "./model-selection";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
export function registerScheduleCommands(
|
||||
@@ -66,8 +66,8 @@ export function registerScheduleCommands(
|
||||
.option("--max-parallel <n>", "Max parallel executions", "1")
|
||||
.option("--metadata-json <json>", "Metadata as JSON object")
|
||||
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
|
||||
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
|
||||
.option("--provider <id>", "Provider ID", "cline")
|
||||
.option("--model <model>", "Model to use")
|
||||
.option("--provider <id>", "Provider ID")
|
||||
.option("--system-prompt <text>", "System prompt override")
|
||||
.option("--tags <list>", "Comma-separated tags")
|
||||
.option("--timeout <seconds>", "Timeout in seconds");
|
||||
@@ -92,12 +92,16 @@ export function registerScheduleCommands(
|
||||
parseJsonObjectFlag(opts.metadataJson),
|
||||
opts,
|
||||
);
|
||||
const modelSelection = resolveScheduleModelSelection({
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
});
|
||||
const created = await client.createSchedule({
|
||||
name,
|
||||
cronPattern: opts.cron,
|
||||
prompt: opts.prompt,
|
||||
provider: opts.provider,
|
||||
model: opts.model,
|
||||
provider: modelSelection.provider,
|
||||
model: modelSelection.model,
|
||||
mode: parseMode(opts.mode) ?? "yolo",
|
||||
workspaceRoot: opts.workspace,
|
||||
cwd: opts.cwd,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import type { Command } from "commander";
|
||||
import { ensureSchedulerHub } from "./client";
|
||||
import {
|
||||
@@ -18,8 +17,13 @@ import {
|
||||
resolveAddress,
|
||||
toPositiveInt,
|
||||
} from "./common";
|
||||
import { resolveScheduleModelSelection } from "./model-selection";
|
||||
import type { CommandIo, ScheduleActionWrapper } from "./types";
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -30,19 +34,16 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
|
||||
!Array.isArray(parsed.modelSelection)
|
||||
? (parsed.modelSelection as Record<string, unknown>)
|
||||
: undefined;
|
||||
const provider = String(
|
||||
modelSelection?.providerId ??
|
||||
parsed.providerId ??
|
||||
parsed.provider ??
|
||||
"cline",
|
||||
).trim();
|
||||
const model = String(
|
||||
modelSelection?.modelId ??
|
||||
parsed.modelId ??
|
||||
parsed.model ??
|
||||
CLINE_DEFAULT_MODEL_ID,
|
||||
).trim();
|
||||
return { provider, model };
|
||||
return resolveScheduleModelSelection({
|
||||
provider:
|
||||
stringValue(modelSelection?.providerId) ??
|
||||
stringValue(parsed.providerId) ??
|
||||
stringValue(parsed.provider),
|
||||
model:
|
||||
stringValue(modelSelection?.modelId) ??
|
||||
stringValue(parsed.modelId) ??
|
||||
stringValue(parsed.model),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerScheduleExportCommand(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
|
||||
export const DEFAULT_SCHEDULE_PROVIDER = "cline";
|
||||
|
||||
interface ProviderSettingsReader {
|
||||
getLastUsedProviderSettings(): ProviderSettings | undefined;
|
||||
getProviderSettings(providerId: string): ProviderSettings | undefined;
|
||||
}
|
||||
|
||||
function trimToUndefined(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveScheduleModelSelection(
|
||||
options: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
providerSettingsManager?: ProviderSettingsReader,
|
||||
): { provider: string; model: string } {
|
||||
const explicitProvider = trimToUndefined(options.provider);
|
||||
const explicitModel = trimToUndefined(options.model);
|
||||
if (explicitProvider && explicitModel) {
|
||||
return { provider: explicitProvider, model: explicitModel };
|
||||
}
|
||||
|
||||
const manager = providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const lastUsedSettings = manager.getLastUsedProviderSettings();
|
||||
const provider =
|
||||
explicitProvider ??
|
||||
trimToUndefined(lastUsedSettings?.provider) ??
|
||||
DEFAULT_SCHEDULE_PROVIDER;
|
||||
const selectedProviderSettings = explicitProvider
|
||||
? manager.getProviderSettings(provider)
|
||||
: lastUsedSettings;
|
||||
const model =
|
||||
explicitModel ??
|
||||
trimToUndefined(selectedProviderSettings?.model) ??
|
||||
(provider === DEFAULT_SCHEDULE_PROVIDER
|
||||
? CLINE_DEFAULT_MODEL_ID
|
||||
: undefined);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(
|
||||
`No model is configured for provider "${provider}". Pass --model or save a model for that provider before creating the schedule.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { provider, model };
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
|
||||
mockEnsureCliHubServer: vi.fn(),
|
||||
const { mockSpawn } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,14 +16,10 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
import {
|
||||
applyDeferredUpdate,
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
ensureCliHubServerAfterUpdate,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
@@ -42,14 +36,6 @@ const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createChildProcessThatCloses(exitCode: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
@@ -265,68 +251,100 @@ describe("hub restart owner selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-update hub launch", () => {
|
||||
describe("deferred auto update", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureCliHubServer.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the freshly installed wrapper instead of the current executable", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
|
||||
const env = {
|
||||
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
CLINE_NO_AUTO_UPDATE: "0",
|
||||
};
|
||||
|
||||
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
["hub", "ensure"],
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
env: {
|
||||
...env,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
await ensureCliHubServerAfterUpdate(
|
||||
"C:\\workspace\\project",
|
||||
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
|
||||
"C:\\workspace\\project",
|
||||
);
|
||||
it("does nothing when no update was recorded", async () => {
|
||||
expect(await applyDeferredUpdate(undefined)).toBe("none");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a failure from the freshly installed CLI", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
|
||||
it("starts the detached install when no hub is discoverable", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = join(root, "production.json");
|
||||
const unref = vi.fn();
|
||||
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
|
||||
|
||||
await expect(
|
||||
ensureCliHubServerAfterUpdate(
|
||||
"/workspace/project",
|
||||
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
|
||||
"linux",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"freshly installed Cline failed to start the hub (exit code 1)",
|
||||
const outcome = await applyDeferredUpdate({
|
||||
command: "npm update -g cline --tag latest --min-release-age=0",
|
||||
});
|
||||
|
||||
expect(outcome).toBe("started");
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm update -g cline --tag latest --min-release-age=0",
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
shell: true,
|
||||
stdio: "ignore",
|
||||
}),
|
||||
);
|
||||
expect(unref).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers while another cli client is attached to the hub", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
const discoveryPath = join(root, "production.json");
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
|
||||
const {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
NodeHubClient,
|
||||
startHubWebSocketServer,
|
||||
} = await import("@cline/core");
|
||||
const server = await startHubWebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
owner: { ownerId: "update-test", discoveryPath },
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const cliClient = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientType: "cli",
|
||||
displayName: "fake attached cli",
|
||||
});
|
||||
try {
|
||||
await cliClient.command("client.list", {});
|
||||
|
||||
expect(await applyDeferredUpdate({ command: "echo update" })).toBe(
|
||||
"deferred",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
|
||||
await cliClient.dispose();
|
||||
const unref = vi.fn();
|
||||
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
|
||||
// The hub unregisters the client when its socket closes; poll
|
||||
// briefly rather than assuming the close is processed instantly.
|
||||
let outcome = "deferred";
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (outcome === "deferred" && Date.now() < deadline) {
|
||||
outcome = await applyDeferredUpdate({ command: "echo update" });
|
||||
}
|
||||
expect(outcome).toBe("started");
|
||||
} finally {
|
||||
await cliClient.dispose().catch(() => undefined);
|
||||
await server.close();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
|
||||
+137
-134
@@ -1,17 +1,14 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
NodeHubClient,
|
||||
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";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -237,50 +234,6 @@ async function runKanbanUpdate(
|
||||
return waitForProcessExit(updateProcess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the hub through the freshly installed CLI after a self-update.
|
||||
*
|
||||
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
|
||||
* remove that cached executable while replacing the package and then block the
|
||||
* postinstall script that recreates it. The current process keeps running from
|
||||
* the unlinked executable, but process.execPath is no longer spawnable. Going
|
||||
* back through the wrapper makes it resolve the newly installed platform
|
||||
* binary instead.
|
||||
*
|
||||
* Windows does not create the bin/.cline cache, and development builds do not
|
||||
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
|
||||
* ensure path.
|
||||
*/
|
||||
export async function ensureCliHubServerAfterUpdate(
|
||||
workspaceRoot: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (!wrapperPath || platform === "win32") {
|
||||
await ensureCliHubServer(workspaceRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(wrapperPath, ["hub", "ensure"], {
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...env,
|
||||
// The fresh CLI only exists to start the hub. Do not let it launch
|
||||
// another background update check while this update is finishing.
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return "";
|
||||
@@ -318,86 +271,40 @@ 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, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let pendingAutoUpdate: ManualUpdateCommand | undefined;
|
||||
let pendingAutoUpdateCheck: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Restart the hub server if one is currently running.
|
||||
* Gracefully asks the running hub process to stop, falls back to process signals,
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!discovery || !health?.url) return;
|
||||
// How long the exit sequence will wait for a still-in-flight startup version
|
||||
// check before giving up on it. Long enough for a typical registry response,
|
||||
// short enough that one-shot commands do not feel it.
|
||||
const UPDATE_CHECK_EXIT_GRACE_MS = 250;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
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, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServerAfterUpdate(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
`[hub] failed to restart server: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Hard cap on the exit-time hub query. The hub client's default connect and
|
||||
// command timeouts add up to tens of seconds against a wedged hub, and this
|
||||
// runs while the user is waiting for their shell prompt back.
|
||||
const CLIENT_COUNT_EXIT_TIMEOUT_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Non-blocking auto-update check for CLI startup.
|
||||
* Spawns a detached install process if a newer version is available.
|
||||
*
|
||||
* Deliberately does NOT install right away: replacing the npm package while
|
||||
* cline processes are running swaps the binary under them — their respawn
|
||||
* paths break on the new build fingerprint — and historically also restarted
|
||||
* the hub daemon out from under live sessions. The check only records that an
|
||||
* update is available; the CLI entrypoint calls applyDeferredUpdate() from
|
||||
* its exit sequence (an explicit process.exit() follows, so a beforeExit hook
|
||||
* would never fire), and the install runs only when no other CLI is attached
|
||||
* to the hub — at that point nothing is running that the swap could hurt.
|
||||
* The next launch picks up the new binary and a fresh hub.
|
||||
*
|
||||
* Skipped for npx, dev, unknown installs. Disable with CLINE_NO_AUTO_UPDATE=1.
|
||||
*/
|
||||
export function autoUpdateOnStartup(): void {
|
||||
@@ -409,35 +316,129 @@ export function autoUpdateOnStartup(): void {
|
||||
getInstallationInfo(version);
|
||||
if (!updateCommand) return;
|
||||
|
||||
void (async () => {
|
||||
pendingAutoUpdateCheck = (async () => {
|
||||
try {
|
||||
const latest = await getLatestVersion(packageName, version);
|
||||
if (!latest || compareVersions(version, latest) >= 0) return;
|
||||
const autoUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
pendingAutoUpdate = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
const child = spawn(autoUpdateCommand.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
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) {
|
||||
await restartHubServerIfRunning();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, silently ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a hub is reachable and another cli* client is attached to it.
|
||||
* Only cli* clients run the npm-installed binary — desktop sidecars and
|
||||
* connectors ship their own — so only they make the swap unsafe. This runs
|
||||
* after the entrypoint's disposeAll(), so this process's own registrations
|
||||
* are closed and any cli client still listed belongs to another process. Errors count as attached:
|
||||
* never install unless the hub positively confirms nothing would be hurt.
|
||||
*/
|
||||
async function otherCliClientsAttached(): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url: discovery.url,
|
||||
authToken: discovery.authToken,
|
||||
clientType: "cli-update-check",
|
||||
displayName: "cline update check",
|
||||
});
|
||||
try {
|
||||
const reply = await client.command("client.list", {}, undefined, {
|
||||
timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS,
|
||||
});
|
||||
const clients =
|
||||
(reply.payload as { clients?: Array<{ clientType?: unknown }> })
|
||||
.clients ?? [];
|
||||
if (
|
||||
clients.some(
|
||||
(entry) =>
|
||||
typeof entry?.clientType === "string" &&
|
||||
entry.clientType.startsWith("cli") &&
|
||||
entry.clientType !== "cli-update-check",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// A TUI's registration can be lost in transport churn while its session
|
||||
// connection survives (observed in review), so an empty client list is
|
||||
// not proof of safety. Cross-check for sessions somebody is attached to.
|
||||
// Participants, not session status: finished sessions can linger idle
|
||||
// forever and must not pin updates, and participant-less scheduled runs
|
||||
// live in the hub process, which a binary swap does not touch.
|
||||
const sessions = await client.command(
|
||||
"session.list",
|
||||
{ limit: 500 },
|
||||
undefined,
|
||||
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
|
||||
);
|
||||
const sessionRecords =
|
||||
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
|
||||
.sessions ?? [];
|
||||
return sessionRecords.some(
|
||||
(session) =>
|
||||
Array.isArray(session?.participants) && session.participants.length > 0,
|
||||
);
|
||||
} finally {
|
||||
await client.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns the recorded update install, detached, if no other CLI would be
|
||||
* affected by the package swap. Fire-and-forget: the install outlives this
|
||||
* process and its postinstall never blocks an exit.
|
||||
*/
|
||||
export async function applyDeferredUpdate(
|
||||
pending?: ManualUpdateCommand,
|
||||
): Promise<"none" | "deferred" | "started"> {
|
||||
if (!pending) {
|
||||
// Short-lived commands can reach exit before the startup version check
|
||||
// resolves; give it a brief grace so one-shot-only usage still updates.
|
||||
if (pendingAutoUpdateCheck) {
|
||||
await Promise.race([
|
||||
pendingAutoUpdateCheck,
|
||||
sleep(UPDATE_CHECK_EXIT_GRACE_MS),
|
||||
]);
|
||||
}
|
||||
pending = pendingAutoUpdate;
|
||||
}
|
||||
if (!pending) {
|
||||
return "none";
|
||||
}
|
||||
// The whole query is bounded: the user is waiting on their prompt, and a
|
||||
// wedged hub must not turn a finished command into a hung one. A timeout
|
||||
// counts as "attached" — never install unless the hub positively confirms.
|
||||
const attached = await Promise.race([
|
||||
otherCliClientsAttached(),
|
||||
sleep(CLIENT_COUNT_EXIT_TIMEOUT_MS).then(() => true),
|
||||
]).catch(() => true);
|
||||
if (attached) {
|
||||
return "deferred";
|
||||
}
|
||||
pendingAutoUpdate = undefined;
|
||||
const child = spawn(pending.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: pending.env ? { ...process.env, ...pending.env } : process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
return "started";
|
||||
}
|
||||
|
||||
export interface CheckForUpdatesOptions {
|
||||
verbose?: boolean;
|
||||
includeKanban?: boolean;
|
||||
@@ -554,7 +555,9 @@ export async function checkForUpdates(
|
||||
const exitCode = await runCliUpdate(manualUpdateCommand);
|
||||
if (exitCode === 0) {
|
||||
installedUpdates.push(`${packageName}@${latestVersion}`);
|
||||
await restartHubServerIfRunning();
|
||||
writeln(
|
||||
`${c.dim}The update takes effect the next time cline starts.${c.reset}`,
|
||||
);
|
||||
} else {
|
||||
writeErr(
|
||||
`Cline update failed (exit code ${exitCode}). Try running: ${manualUpdateCommand.command}`,
|
||||
|
||||
@@ -165,7 +165,6 @@ describe("buildConnectorStartRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("isReusableConnectorSession", () => {
|
||||
it("rejects missing and terminal sessions", () => {
|
||||
expect(isReusableConnectorSession(undefined)).toBe(false);
|
||||
|
||||
@@ -235,9 +235,7 @@ export async function getOrCreateSessionId<
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
...(existingSession?.status
|
||||
? { status: existingSession.status }
|
||||
: {}),
|
||||
...(existingSession?.status ? { status: existingSession.status } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,15 @@ if (!isMainThread) {
|
||||
} finally {
|
||||
await disposeAll();
|
||||
}
|
||||
// The explicit process.exit below means beforeExit never fires, so a
|
||||
// startup-recorded auto-update must be applied here, after all runtime
|
||||
// teardown. It spawns detached and only when no other CLI is attached.
|
||||
try {
|
||||
const { applyDeferredUpdate } = await import("./commands/update");
|
||||
await applyDeferredUpdate();
|
||||
} catch {
|
||||
// Best-effort; never block exit on the updater.
|
||||
}
|
||||
process.exit(exitCode || (process.exitCode as number) || 0);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -505,6 +505,24 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const mcpUninstallCmd = mcpCmd
|
||||
.command("uninstall")
|
||||
.alias("remove")
|
||||
.alias("rm")
|
||||
.description("Uninstall an MCP server by name")
|
||||
.argument("<name>", "MCP server name")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (name: string) => {
|
||||
const opts = mcpUninstallCmd.opts<{
|
||||
json?: boolean;
|
||||
}>();
|
||||
const { runMcpUninstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpUninstallCommand({
|
||||
name,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
const createDoctorRuntimeCommand = async () => {
|
||||
const { createDoctorCommand } = await import("./commands/doctor");
|
||||
|
||||
@@ -1012,7 +1012,9 @@ Review with the bundled skill.`,
|
||||
const linear = data.mcp.find((item) => item.name === "linear");
|
||||
const docs = data.mcp.find((item) => item.name === "docs");
|
||||
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
|
||||
expect(linear?.description).toBe(
|
||||
"streamableHttp, oauth error, timeout 60s",
|
||||
);
|
||||
expect(linear?.loadError).toBe("OAuth authorization failed");
|
||||
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
|
||||
expect(docs?.loadError).toBeUndefined();
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ToolApprovalResult,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { createCliCore } from "../../session/session";
|
||||
import { submitAndExitInTerminal } from "../../utils/approval";
|
||||
import type {
|
||||
@@ -56,11 +56,11 @@ type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type CurrentMessagesRead =
|
||||
| { messages: Message[]; status: "read" }
|
||||
| { messages: Message[]; status: "recovered" }
|
||||
| { messages: Message[]; status: "stale" };
|
||||
| { messages: MessageWithMetadata[]; status: "read" }
|
||||
| { messages: MessageWithMetadata[]; status: "recovered" }
|
||||
| { messages: MessageWithMetadata[]; status: "stale" };
|
||||
type MissingSessionRecovery = {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
@@ -210,7 +210,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
initial: MessageWithMetadata[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
@@ -243,7 +243,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
const startResumedSession = async (
|
||||
resumeId: string,
|
||||
initial: Message[] | undefined,
|
||||
initial: MessageWithMetadata[] | undefined,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
@@ -421,7 +421,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
@@ -659,7 +659,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
const resumeSession = async (
|
||||
sessionId: string,
|
||||
): Promise<MessageWithMetadata[]> => {
|
||||
const manager = await ensureSessionManager();
|
||||
const sessionRecord = await manager.get(sessionId);
|
||||
if (!sessionRecord) {
|
||||
@@ -754,7 +756,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
const getCheckpointData = async (): Promise<
|
||||
| {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
checkpointHistory: CheckpointEntry[];
|
||||
}
|
||||
| undefined
|
||||
@@ -777,7 +779,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restoreCheckpoint = async (
|
||||
runCount: number,
|
||||
restoreWorkspace: boolean,
|
||||
): Promise<{ newSessionId: string; messages: Message[] } | undefined> => {
|
||||
): Promise<
|
||||
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
|
||||
> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager || !activeSessionId) {
|
||||
return undefined;
|
||||
|
||||
@@ -490,6 +490,7 @@ export async function runInteractive(
|
||||
tuiApp?.destroy();
|
||||
});
|
||||
let startupErrorReported = false;
|
||||
let updateCliAfterExit = false;
|
||||
const loadDeferredInitialMessages = resumeSessionId?.trim()
|
||||
? async () => {
|
||||
try {
|
||||
@@ -749,6 +750,10 @@ export async function runInteractive(
|
||||
onExit: () => {
|
||||
tuiApp?.destroy();
|
||||
},
|
||||
onHubUpdateRestart: () => {
|
||||
updateCliAfterExit = true;
|
||||
tuiApp?.destroy();
|
||||
},
|
||||
onRunningChange: (running) => {
|
||||
isRunning = running;
|
||||
if (!running) {
|
||||
@@ -877,4 +882,19 @@ export async function runInteractive(
|
||||
prepareTerminalForPostTuiOutput();
|
||||
writeln(formatInteractiveExitSummary(exitSummary));
|
||||
}
|
||||
if (updateCliAfterExit) {
|
||||
if (!exitSummary) {
|
||||
prepareTerminalForPostTuiOutput();
|
||||
}
|
||||
writeln(
|
||||
"The shared Cline Hub was updated by another Cline installation. Updating this CLI…",
|
||||
);
|
||||
const { checkForUpdates } = await import("../commands/update");
|
||||
const exitCode = await checkForUpdates({ includeKanban: false });
|
||||
writeln(
|
||||
exitCode === 0
|
||||
? "Start cline again to reconnect to the updated Hub."
|
||||
: "Update did not complete. Run 'cline update' manually, then start cline again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
getCoreBuiltinToolCatalog,
|
||||
resolveDisabledToolNames,
|
||||
resolveModelToolSettings,
|
||||
type ToolCatalogEntry,
|
||||
} from "@cline/core";
|
||||
|
||||
@@ -10,8 +11,14 @@ export type { ToolCatalogEntry } from "@cline/core";
|
||||
export function getToolCatalog(
|
||||
availabilityContext?: BuiltinToolAvailabilityContext,
|
||||
): ToolCatalogEntry[] {
|
||||
const modelToolSettings = resolveModelToolSettings();
|
||||
return getCoreBuiltinToolCatalog({
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
enabledModelToolIds: new Set(
|
||||
Object.entries(modelToolSettings)
|
||||
.filter(([, setting]) => setting?.enabled === true)
|
||||
.map(([name]) => name),
|
||||
),
|
||||
...availabilityContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateConversationHTML } from "./export";
|
||||
|
||||
describe("generateConversationHTML", () => {
|
||||
it("renders provider model activity with the ordinary tool HTML", () => {
|
||||
const html = generateConversationHTML(
|
||||
{
|
||||
version: 1,
|
||||
updated_at: "2026-08-13T00:00:00.000Z",
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"session",
|
||||
);
|
||||
|
||||
expect(html).toContain("web_search");
|
||||
expect(html).toContain("latest Bun release");
|
||||
expect(html).toContain('<span class="success">Success</span>');
|
||||
expect(html).toContain("Bun 1.3.14 is current.");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
@@ -30,9 +31,12 @@ export function generateConversationHTML(
|
||||
data: ConversationHistory,
|
||||
fileName: string,
|
||||
): string {
|
||||
const displayMessages = projectSessionMessagesForDisplay(data.messages).map(
|
||||
({ message }) => message,
|
||||
);
|
||||
// Build tool results map
|
||||
const toolResultsMap = new Map<string, ToolResultContent>();
|
||||
data.messages.forEach((msg) => {
|
||||
displayMessages.forEach((msg) => {
|
||||
if (!isStringContent(msg.content)) {
|
||||
msg.content.forEach((block) => {
|
||||
if (block.type === "tool_result") {
|
||||
@@ -43,7 +47,7 @@ export function generateConversationHTML(
|
||||
});
|
||||
|
||||
// Filter messages (same logic as viewer)
|
||||
const filteredMessages = data.messages.filter((msg) => {
|
||||
const filteredMessages = displayMessages.filter((msg) => {
|
||||
if (msg.role === "assistant") return true;
|
||||
if (isStringContent(msg.content)) {
|
||||
return msg.content.trim().length > 0;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page-object helpers for the /settings view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
|
||||
import { expectVisible } from "../terminal.js";
|
||||
|
||||
const TAB_ORDER = [
|
||||
"API",
|
||||
"Auto-approve",
|
||||
"Features",
|
||||
"Account",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
export type SettingsTab = (typeof TAB_ORDER)[number];
|
||||
|
||||
/**
|
||||
* Navigate to a specific settings tab by pressing Right from the API tab (index 0).
|
||||
* Waits for each tab's content to appear before pressing the next key, making
|
||||
* navigation deterministic regardless of machine speed.
|
||||
*/
|
||||
export async function goToSettingsTab(
|
||||
terminal: Terminal,
|
||||
tab: SettingsTab,
|
||||
): Promise<void> {
|
||||
const targetIndex = TAB_ORDER.indexOf(tab);
|
||||
for (let i = 0; i < targetIndex; i++) {
|
||||
terminal.keyRight();
|
||||
// Wait for the next tab's content to appear before pressing again
|
||||
await assertTabContent(terminal, TAB_ORDER[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert the API tab content is visible */
|
||||
export async function assertApiTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Provider:", "Model ID:"]);
|
||||
}
|
||||
|
||||
/** Assert the Auto-approve tab content is visible */
|
||||
export async function assertAutoApproveTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, [
|
||||
"Read project files",
|
||||
"Execute safe commands",
|
||||
"Edit project files",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Assert the Features tab content is visible */
|
||||
export async function assertFeaturesTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, [
|
||||
"Subagents",
|
||||
"Web tools",
|
||||
"Double-check completion",
|
||||
]);
|
||||
}
|
||||
|
||||
/** Assert the Account tab content is visible */
|
||||
export async function assertAccountTab(terminal: Terminal): Promise<void> {
|
||||
// The account tab shows sign-in options when not authenticated to Cline
|
||||
await expectVisible(terminal, /sign in|sign out/i);
|
||||
}
|
||||
|
||||
/** Assert the Other tab content is visible */
|
||||
export async function assertOtherTab(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Preferred language:", "Cline v"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the content for a given tab is visible.
|
||||
* Used internally by goToSettingsTab to confirm navigation landed correctly.
|
||||
*/
|
||||
export async function assertTabContent(
|
||||
terminal: Terminal,
|
||||
tab: SettingsTab,
|
||||
): Promise<void> {
|
||||
switch (tab) {
|
||||
case "API":
|
||||
return assertApiTab(terminal);
|
||||
case "Auto-approve":
|
||||
return assertAutoApproveTab(terminal);
|
||||
case "Features":
|
||||
return assertFeaturesTab(terminal);
|
||||
case "Account":
|
||||
return assertAccountTab(terminal);
|
||||
case "Other":
|
||||
return assertOtherTab(terminal);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
type ClineSubscriptionPlan,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
|
||||
@@ -640,10 +640,23 @@ export function ChatEntryView(props: {
|
||||
)}
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
{/*
|
||||
* internalBlockMode="top-level" keeps each markdown block as its
|
||||
* own renderable. The default coalesced mode merges the whole
|
||||
* message into one block that is torn down and re-highlighted on
|
||||
* every streamed chunk, which flashes already-rendered headings
|
||||
* and links back to raw uncolored markdown while tree-sitter
|
||||
* re-highlights asynchronously. Top-level blocks are reused by
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Config } from "../../../utils/types";
|
||||
import type { DialogDismissKey } from "../../utils/dialog-keys";
|
||||
|
||||
/**
|
||||
* Enter starts the update-and-restart flow; Esc dismisses (the mismatch toast
|
||||
* reminds the user to update manually). Other keys are ignored so the dialog
|
||||
* is not lost to a stray keystroke mid-task.
|
||||
*/
|
||||
export function resolveHubUpdateRequiredKeyAction(
|
||||
key: DialogDismissKey,
|
||||
): "update" | "dismiss" | "ignore" {
|
||||
if (key.name === "return" || key.name === "enter") return "update";
|
||||
if (key.name === "escape") return "dismiss";
|
||||
return "ignore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Yolo and sandbox sessions force the local backend and never attach to the
|
||||
* shared managed Hub (see the forceLocalBackend condition in the interactive
|
||||
* session runtime), so a build mismatch on that Hub is another installation's
|
||||
* concern and must not interrupt these sessions with an update dialog.
|
||||
*/
|
||||
export function shouldWatchManagedHubBuild(
|
||||
config: Pick<Config, "mode" | "sandbox">,
|
||||
): boolean {
|
||||
return config.mode !== "yolo" && config.sandbox !== true;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveHubUpdateRequiredKeyAction,
|
||||
shouldWatchManagedHubBuild,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
describe("hub update required dialog", () => {
|
||||
it("updates on Enter", () => {
|
||||
expect(resolveHubUpdateRequiredKeyAction({ name: "return" })).toBe(
|
||||
"update",
|
||||
);
|
||||
expect(resolveHubUpdateRequiredKeyAction({ name: "enter" })).toBe("update");
|
||||
});
|
||||
|
||||
it("dismisses only on Esc", () => {
|
||||
expect(resolveHubUpdateRequiredKeyAction({ name: "escape" })).toBe(
|
||||
"dismiss",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stray keystrokes so a mid-task keypress cannot lose the prompt", () => {
|
||||
expect(resolveHubUpdateRequiredKeyAction({ name: "a" })).toBe("ignore");
|
||||
expect(resolveHubUpdateRequiredKeyAction({ name: "space" })).toBe("ignore");
|
||||
expect(resolveHubUpdateRequiredKeyAction({ name: "c", ctrl: true })).toBe(
|
||||
"ignore",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWatchManagedHubBuild", () => {
|
||||
it("watches for hub-attached modes", () => {
|
||||
expect(shouldWatchManagedHubBuild({ mode: "act", sandbox: false })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldWatchManagedHubBuild({ mode: "plan", sandbox: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips yolo and sandbox sessions, which force the local backend and never attach to the managed Hub", () => {
|
||||
expect(shouldWatchManagedHubBuild({ mode: "yolo", sandbox: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldWatchManagedHubBuild({ mode: "act", sandbox: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { palette } from "../../palette";
|
||||
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
|
||||
|
||||
export interface HubUpdateRequiredDetails {
|
||||
hubCoreVersion?: string;
|
||||
}
|
||||
|
||||
export function HubUpdateRequiredContent(
|
||||
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
|
||||
) {
|
||||
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
const action = resolveHubUpdateRequiredKeyAction(key);
|
||||
if (action === "ignore") return;
|
||||
if (action === "update") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg="yellow">Cline Hub was updated</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
Another Cline installation restarted the shared Cline Hub
|
||||
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
|
||||
matches this CLI.
|
||||
</text>
|
||||
<text selectable>
|
||||
Update and restart Cline so this CLI and the Hub run the same version
|
||||
again.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Update and restart</text>
|
||||
</box>
|
||||
</box>
|
||||
<text fg={palette.muted}>
|
||||
Press Enter to update and restart, Esc to dismiss
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,10 @@ function QueuedPromptRow(props: {
|
||||
flexGrow={1}
|
||||
/>
|
||||
) : (
|
||||
<text fg={selected ? theme.textOnSelection : undefined} flexGrow={1}>
|
||||
<text
|
||||
fg={selected ? theme.textOnSelection : theme.defaultForeground}
|
||||
flexGrow={1}
|
||||
>
|
||||
{truncatePrompt(item.prompt)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||
import { getMcpDescription } from "./interactive-config";
|
||||
|
||||
describe("getMcpDescription", () => {
|
||||
it("discloses the fast initialize probe for unconfigured stdio servers", () => {
|
||||
it("discloses the default initialize timeout for unconfigured stdio servers", () => {
|
||||
expect(
|
||||
getMcpDescription({
|
||||
name: "local",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
|
||||
});
|
||||
|
||||
it("shows one configured timeout when it also applies to initialize", () => {
|
||||
@@ -40,6 +40,6 @@ describe("getMcpDescription", () => {
|
||||
transport: { type: "stdio", command: "node" },
|
||||
timeoutSeconds: Number.NaN,
|
||||
}),
|
||||
).toBe("stdio, local, request timeout 60s, initialize probe 1.5s");
|
||||
).toBe("stdio, local, request timeout 60s, initialize timeout 3s");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "node:path";
|
||||
import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
hasMcpSettingsFile,
|
||||
@@ -183,7 +184,7 @@ export function getMcpDescription(registration: McpServerRegistration): string {
|
||||
const timeoutDescription =
|
||||
registration.transport.type === "stdio" &&
|
||||
!isMcpTimeoutConfigured(registration.timeoutSeconds)
|
||||
? `request timeout ${timeoutSeconds}s, initialize probe 1.5s`
|
||||
? `request timeout ${timeoutSeconds}s, initialize timeout ${DEFAULT_MCP_CONNECT_TIMEOUT_MS / 1000}s`
|
||||
: `timeout ${timeoutSeconds}s`;
|
||||
return `${registration.transport.type}, ${getMcpAuthLabel(registration)}, ${timeoutDescription}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getCurrentContextSize, summarizeUsageFromMessages } from "@cline/core";
|
||||
import {
|
||||
getCurrentContextSize,
|
||||
type ManagedHubBuildMismatchEvent,
|
||||
summarizeUsageFromMessages,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import type { KeyEvent } from "@opentui/core";
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/react";
|
||||
@@ -34,6 +39,8 @@ import {
|
||||
buildCommandPaletteItems,
|
||||
findCommandPaletteShortcut,
|
||||
} from "./components/dialogs/command-palette-items";
|
||||
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
|
||||
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
|
||||
import {
|
||||
SKILLS_MARKETPLACE_ACTION,
|
||||
SKILLS_MARKETPLACE_URL,
|
||||
@@ -562,6 +569,55 @@ function App(props: TuiProps) {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
|
||||
|
||||
const [hubBuildMismatch, setHubBuildMismatch] =
|
||||
useState<ManagedHubBuildMismatchEvent | null>(null);
|
||||
const hubBuildWatchEnabled = shouldWatchManagedHubBuild(props.config);
|
||||
useEffect(() => {
|
||||
if (!hubBuildWatchEnabled) return;
|
||||
return watchManagedHubBuildMismatch({
|
||||
onMismatch: (mismatch) => setHubBuildMismatch(mismatch),
|
||||
});
|
||||
}, [hubBuildWatchEnabled]);
|
||||
|
||||
const onHubUpdateRestart = props.onHubUpdateRestart;
|
||||
useEffect(() => {
|
||||
if (!hubBuildMismatch) return;
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
if (hubBuildMismatch.reason === "outdated_hub") {
|
||||
// This CLI is already the newer build. The Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving, and it is
|
||||
// replaced on its own at the next launch. Nothing is wrong, nothing is
|
||||
// asked, and nothing the user can act on differs - so say nothing, the
|
||||
// same conclusion the desktop surface reached.
|
||||
//
|
||||
// The classification still earns its keep here: it is what stops the
|
||||
// update-and-restart prompt below from firing at someone who has
|
||||
// nothing to update.
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<HubUpdateRequiredContent {...ctx} hubCoreVersion={hubCoreVersion} />
|
||||
),
|
||||
})
|
||||
.then((update) => {
|
||||
if (update) {
|
||||
(onHubUpdateRestart ?? exitCline)();
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
"Hub still differs from this CLI. Run 'cline update' and restart when convenient.",
|
||||
"info",
|
||||
);
|
||||
refocusTextareaRef.current();
|
||||
})
|
||||
.catch(() => {
|
||||
refocusTextareaRef.current();
|
||||
});
|
||||
}, [dialog, exitCline, hubBuildMismatch, onHubUpdateRestart, showToast]);
|
||||
|
||||
const {
|
||||
appendEntry: appendSessionEntry,
|
||||
replaceEntries: replaceSessionEntries,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TeamEvent,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
Message,
|
||||
MessageWithMetadata,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/shared";
|
||||
@@ -92,7 +92,7 @@ export interface InteractiveTurnResult {
|
||||
}
|
||||
|
||||
export interface ResumedSessionResult {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
totalCost?: number;
|
||||
currentContextSize?: number;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export interface TuiProps {
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
loadDeferredInitialMessages?: () => Promise<ResumedSessionResult>;
|
||||
initialRepoStatus?: RepoStatus;
|
||||
workflowSlashCommands?: InteractiveSlashCommand[];
|
||||
@@ -185,6 +185,11 @@ export interface TuiProps {
|
||||
}) => Promise<PendingPromptMutationResult>;
|
||||
onAbort: () => boolean;
|
||||
onExit: () => void;
|
||||
/**
|
||||
* Exit the TUI and run the CLI self-update afterwards. Invoked when the
|
||||
* user accepts the "Hub was updated by another Cline installation" dialog.
|
||||
*/
|
||||
onHubUpdateRestart?: () => void;
|
||||
onRunningChange: (isRunning: boolean) => void;
|
||||
onTurnErrorReported: (reported: boolean) => void;
|
||||
onAutoApproveChange: (enabled: boolean) => void;
|
||||
@@ -213,12 +218,18 @@ export interface TuiProps {
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
| {
|
||||
messages: MessageWithMetadata[];
|
||||
checkpointHistory: CheckpointEntry[];
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
onRestoreCheckpoint: (
|
||||
runCount: number,
|
||||
restoreWorkspace: boolean,
|
||||
) => Promise<{ newSessionId: string; messages: Message[] } | undefined>;
|
||||
) => Promise<
|
||||
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
|
||||
>;
|
||||
setToolApprover: (
|
||||
approver:
|
||||
| ((request: ToolApprovalRequest) => Promise<ToolApprovalResult>)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { Message, MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
@@ -106,6 +106,7 @@ describe("hydrateSessionMessages", () => {
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "switch_to_act_mode",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: {},
|
||||
@@ -135,6 +136,36 @@ describe("hydrateSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/cline/cline/issues/13036:
|
||||
// persisted sessions with malformed tool inputs must stay resumable.
|
||||
it("hydrates tool calls with malformed inputs without throwing", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "run_commands",
|
||||
input: { command: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
inputSummary: "",
|
||||
rawInput: { command: null },
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves mode undefined for transcripts without user_input wrappers", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "plain old message" },
|
||||
@@ -151,4 +182,87 @@ describe("hydrateSessionMessages", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates provider model tools through the ordinary tool card path", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is the latest stable release.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun stable release" },
|
||||
output: { sources: ["https://bun.sh/blog/bun-v1.3.14"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: { query: "latest Bun stable release" },
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
result: {
|
||||
outputSummary: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
|
||||
rawOutput: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Bun 1.3.14 is the latest stable release.",
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates structured native search output and mirrors live error payloads", () => {
|
||||
const nativeResult = {
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
};
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Search failed.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-native",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: [nativeResult],
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [toolEntry] = hydrateSessionMessages(messages);
|
||||
expect(toolEntry).toMatchObject({
|
||||
kind: "tool_call",
|
||||
toolCallId: "search-native",
|
||||
result: {
|
||||
outputSummary: "",
|
||||
rawOutput: undefined,
|
||||
error: JSON.stringify([nativeResult]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import { type AgentMode, projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type MessageWithMetadata,
|
||||
parseUserInputMode,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
type PersistedMessage = Message & {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
function getDisplayRole(msg: MessageWithMetadata): string | undefined {
|
||||
const role = msg.metadata?.displayRole;
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
@@ -33,13 +29,29 @@ function stringifyToolResult(
|
||||
return block.text;
|
||||
if (block.type === "file" && typeof block.path === "string")
|
||||
return `Attached file: ${block.path}`;
|
||||
return "";
|
||||
if (block.type === "image") return "[image]";
|
||||
try {
|
||||
return JSON.stringify(block);
|
||||
} catch {
|
||||
return String(block);
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
function stringifyToolError(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
try {
|
||||
return JSON.stringify(content) ?? String(content);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateSessionMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
): ChatEntry[] {
|
||||
const entries: ChatEntry[] = [];
|
||||
const toolUseMap = new Map<string, number>();
|
||||
// Mode each entry was produced in, recovered from <user_input mode="...">
|
||||
@@ -49,7 +61,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
// wrappers on session restarts).
|
||||
let mode: AgentMode | undefined;
|
||||
|
||||
for (const msg of messages as PersistedMessage[]) {
|
||||
for (const { message: msg } of projectSessionMessagesForDisplay(messages)) {
|
||||
const displayRole = getDisplayRole(msg);
|
||||
if (displayRole === "system" || displayRole === "status") {
|
||||
continue;
|
||||
@@ -107,6 +119,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (block.type === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
toolCallId: block.id,
|
||||
toolName: block.name,
|
||||
inputSummary: formatToolInput(block.name, block.input),
|
||||
rawInput: block.input,
|
||||
@@ -132,11 +145,16 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
| string
|
||||
| Array<{ type: string; text?: string; path?: string }>,
|
||||
);
|
||||
entry.result = {
|
||||
outputSummary: resultText.slice(0, 500),
|
||||
rawOutput: block.content,
|
||||
error: block.is_error ? resultText : undefined,
|
||||
};
|
||||
const error = block.is_error
|
||||
? stringifyToolError(block.content)
|
||||
: undefined;
|
||||
entry.result = error
|
||||
? { outputSummary: "", rawOutput: undefined, error }
|
||||
: {
|
||||
outputSummary: resultText.slice(0, 500),
|
||||
rawOutput: block.content,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
|
||||
import {
|
||||
buildReadFilesKeys,
|
||||
extractFullOutputText,
|
||||
parseReadFilesInput,
|
||||
} from "./tool-parsing";
|
||||
|
||||
describe("buildReadFilesKeys", () => {
|
||||
it("produces unique keys when the same path is read twice", () => {
|
||||
@@ -32,3 +36,64 @@ describe("buildReadFilesKeys", () => {
|
||||
expect(buildReadFilesKeys([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFullOutputText", () => {
|
||||
it("extracts text with real newlines from the MCP CallToolResult shape", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "text", text: "# Memory\n\nline one" },
|
||||
{ type: "text", text: "line two" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe("# Memory\n\nline one\nline two");
|
||||
});
|
||||
|
||||
it("keeps binary payloads behind placeholders in mixed MCP content", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "text", text: "before" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///a.md", blob: "d29ybGQ=" },
|
||||
},
|
||||
{ type: "resource_link", uri: "file:///b.md", name: "b.md" },
|
||||
{ type: "text", text: "after" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe(
|
||||
"before\n[image: image/png]\naGVsbG8=\n[resource: file:///a.md]\nd29ybGQ=\n[resource_link: file:///b.md]\nafter",
|
||||
);
|
||||
});
|
||||
|
||||
it("chunks base64 payloads into 76-char lines so collapse stays compact", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{ type: "image", data: "A".repeat(160), mimeType: "image/png" },
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)?.split("\n")).toEqual([
|
||||
"[image: image/png]",
|
||||
"A".repeat(76),
|
||||
"A".repeat(76),
|
||||
"A".repeat(8),
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts embedded resource text from MCP content", () => {
|
||||
const raw = {
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "file:///memory.md", text: "resource body\nline 2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractFullOutputText(raw)).toBe("resource body\nline 2");
|
||||
});
|
||||
|
||||
it("falls back to pretty JSON for objects without text content", () => {
|
||||
const raw = { structuredContent: { ok: true } };
|
||||
expect(extractFullOutputText(raw)).toBe(JSON.stringify(raw, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,6 +185,12 @@ export function parseSpawnAgentInput(
|
||||
return { task: input.task };
|
||||
}
|
||||
|
||||
// Base64 payloads are one giant line; chunk to MIME width so GenericOutput's
|
||||
// line-based collapse stays compact and expand shows the full data.
|
||||
function chunkBase64(data: string): string {
|
||||
return data.match(/.{1,76}/g)?.join("\n") ?? data;
|
||||
}
|
||||
|
||||
export function extractFullOutputText(raw: unknown): string | undefined {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
if (typeof raw === "string") return raw;
|
||||
@@ -213,6 +219,51 @@ export function extractFullOutputText(raw: unknown): string | undefined {
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
// MCP tools return {content: [{type: "text", text}, ...]}. Extract the
|
||||
// text so multi-line results keep real newlines instead of being
|
||||
// JSON-escaped into one giant line that floods the terminal (#13038).
|
||||
// Non-text blocks keep their identifying metadata plus their base64
|
||||
// payloads so mixed results are not silently truncated.
|
||||
const content = (raw as { content?: unknown }).content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((part) => {
|
||||
if (!isRecord(part)) return "";
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "resource" && isRecord(part.resource)) {
|
||||
if (typeof part.resource.text === "string") {
|
||||
return part.resource.text;
|
||||
}
|
||||
if (typeof part.resource.blob === "string" && part.resource.blob) {
|
||||
const label =
|
||||
typeof part.resource.uri === "string"
|
||||
? `[resource: ${part.resource.uri}]`
|
||||
: "[resource]";
|
||||
return `${label}\n${chunkBase64(part.resource.blob)}`;
|
||||
}
|
||||
if (typeof part.resource.uri === "string") {
|
||||
return `[resource: ${part.resource.uri}]`;
|
||||
}
|
||||
}
|
||||
if (part.type === "resource_link" && typeof part.uri === "string") {
|
||||
return `[resource_link: ${part.uri}]`;
|
||||
}
|
||||
if (
|
||||
(part.type === "image" || part.type === "audio") &&
|
||||
typeof part.mimeType === "string"
|
||||
) {
|
||||
if (typeof part.data === "string" && part.data) {
|
||||
return `[${part.type}: ${part.mimeType}]\n${chunkBase64(part.data)}`;
|
||||
}
|
||||
return `[${part.type}: ${part.mimeType}]`;
|
||||
}
|
||||
return typeof part.type === "string" ? `[${part.type}]` : "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (parts.length > 0) return parts.join("\n");
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(raw, null, 2);
|
||||
} catch {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("cline-pass-errors", () => {
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
|
||||
@@ -21,11 +21,11 @@ export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
if(!CLI_PROMO_CODE) {
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isCliHookPayload,
|
||||
normalizeAutoApproveArgs,
|
||||
parseArgs,
|
||||
truncate,
|
||||
} from "./helpers";
|
||||
|
||||
type EnvSnapshot = {
|
||||
@@ -374,6 +375,73 @@ describe("format helpers", () => {
|
||||
).toBe("first (+2 more)");
|
||||
expect(formatToolOutput(null)).toBe("");
|
||||
});
|
||||
|
||||
// Regression tests for https://github.com/cline/cline/issues/13036:
|
||||
// malformed tool inputs crossing the model/tool boundary must never
|
||||
// throw from display-only formatters.
|
||||
it("does not crash on run_commands with a null command", () => {
|
||||
expect(formatToolInput("run_commands", { command: null })).toBe("");
|
||||
});
|
||||
|
||||
it("does not crash on run_commands with a non-string command", () => {
|
||||
expect(formatToolInput("run_commands", { command: { nested: true } })).toBe(
|
||||
'{"nested":true}',
|
||||
);
|
||||
expect(formatToolInput("run_commands", { commands: { command: 42 } })).toBe(
|
||||
"42",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid empty-string args in structured command summaries", () => {
|
||||
expect(
|
||||
formatToolInput("run_commands", {
|
||||
commands: [{ command: "grep", args: ["", "pattern", "file.txt"] }],
|
||||
}),
|
||||
).toBe("grep pattern file.txt");
|
||||
expect(
|
||||
formatToolInput("run_commands", {
|
||||
commands: [{ command: "git", args: [null, "status", undefined] }],
|
||||
}),
|
||||
).toBe("git status");
|
||||
});
|
||||
|
||||
it("skips null entries in run_commands command arrays", () => {
|
||||
expect(
|
||||
formatToolInput("run_commands", { commands: [null, "echo hi"] }),
|
||||
).toBe("echo hi");
|
||||
expect(formatToolInput("run_commands", [undefined, "echo hi"])).toBe(
|
||||
"echo hi",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not crash on fetch_web_content with malformed requests", () => {
|
||||
expect(
|
||||
formatToolInput("fetch_web_content", {
|
||||
requests: [null, { url: "https://example.com" }, { url: 42 }, "raw"],
|
||||
}),
|
||||
).toBe("https://example.com, 42");
|
||||
});
|
||||
|
||||
it("falls back to an empty summary for unserializable inputs", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
expect(formatToolInput("unknown_tool", circular)).toBe("");
|
||||
expect(formatToolOutput(circular)).toBe("");
|
||||
expect(
|
||||
formatToolInput("unknown_tool", {
|
||||
toJSON() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("truncates non-string values without throwing", () => {
|
||||
expect(truncate(null, 10)).toBe("");
|
||||
expect(truncate(undefined, 10)).toBe("");
|
||||
expect(truncate(42, 10)).toBe("42");
|
||||
expect(truncate({ nested: true }, 60)).toBe('{"nested":true}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook payload validation and audit logging", () => {
|
||||
|
||||
@@ -53,8 +53,38 @@ export function resolveWorkspaceRoot(cwd: string): string {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function truncate(str: string, maxLen: number): string {
|
||||
const oneLine = str.replace(/\n/g, " ").trim();
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an untrusted runtime value into a display string without ever
|
||||
* throwing. Tool inputs/outputs cross the model/tool boundary, so they may
|
||||
* not match their TypeScript annotations (e.g. `{ command: null }`).
|
||||
*/
|
||||
export function toDisplayString(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return safeJsonStringify(value);
|
||||
}
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(value: unknown, maxLen: number): string {
|
||||
const oneLine = toDisplayString(value).replace(/\n/g, " ").trim();
|
||||
if (oneLine.length <= maxLen) {
|
||||
return oneLine;
|
||||
}
|
||||
@@ -66,14 +96,21 @@ export function formatStructuredCommand(cmd: unknown): string {
|
||||
return cmd;
|
||||
}
|
||||
if (cmd && typeof cmd === "object" && "command" in cmd) {
|
||||
const structured = cmd as { command: string; args?: unknown };
|
||||
const args = Array.isArray(structured.args) ? structured.args : [];
|
||||
const structured = cmd as { command?: unknown; args?: unknown };
|
||||
const command = toDisplayString(structured.command);
|
||||
// Drop only nullish entries: they carry no display value, while an
|
||||
// empty string is a valid argv entry that must stay in the summary.
|
||||
const args = Array.isArray(structured.args)
|
||||
? structured.args
|
||||
.filter((arg) => arg !== null && arg !== undefined)
|
||||
.map(toDisplayString)
|
||||
: [];
|
||||
if (args.length === 0) {
|
||||
return structured.command;
|
||||
return command;
|
||||
}
|
||||
return `${structured.command} ${args.join(" ")}`;
|
||||
return `${command} ${args.join(" ")}`;
|
||||
}
|
||||
return String(cmd);
|
||||
return toDisplayString(cmd);
|
||||
}
|
||||
|
||||
function summarizeRunCommandsInput(input: unknown): string {
|
||||
@@ -82,14 +119,17 @@ function summarizeRunCommandsInput(input: unknown): string {
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(formatStructuredCommand).join("; ");
|
||||
return input.map(formatStructuredCommand).filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (obj.commands !== undefined) {
|
||||
if (Array.isArray(obj.commands)) {
|
||||
return obj.commands.map(formatStructuredCommand).join("; ");
|
||||
return obj.commands
|
||||
.map(formatStructuredCommand)
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
return formatStructuredCommand(obj.commands);
|
||||
}
|
||||
@@ -157,7 +197,11 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
if (Array.isArray(obj.requests)) {
|
||||
return truncate(
|
||||
obj.requests
|
||||
.map((r) => r.url)
|
||||
.map((r) =>
|
||||
r && typeof r === "object" && "url" in r
|
||||
? toDisplayString((r as { url?: unknown }).url)
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
120,
|
||||
@@ -286,7 +330,7 @@ export function formatToolInput(toolName: string, input: unknown): string {
|
||||
return "list";
|
||||
}
|
||||
|
||||
return truncate(JSON.stringify(input), 60);
|
||||
return truncate(input, 60);
|
||||
}
|
||||
|
||||
export function formatToolOutput(output: unknown): string {
|
||||
@@ -330,10 +374,10 @@ export function formatToolOutput(output: unknown): string {
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ") || "Successfully read image"
|
||||
: String(result ?? "");
|
||||
: toDisplayString(result);
|
||||
return truncate(resultStr, 80);
|
||||
}
|
||||
return truncate(JSON.stringify(item), 80);
|
||||
return truncate(item, 80);
|
||||
})
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
@@ -346,7 +390,7 @@ export function formatToolOutput(output: unknown): string {
|
||||
return `${results[0]} (+${results.length - 1} more)`;
|
||||
}
|
||||
|
||||
return truncate(JSON.stringify(output), 100);
|
||||
return truncate(output, 100);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ClineCore } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
export async function loadInteractiveResumeMessages(
|
||||
sessionManager: ClineCore,
|
||||
resumeSessionId?: string,
|
||||
): Promise<Message[] | undefined> {
|
||||
): Promise<MessageWithMetadata[] | undefined> {
|
||||
const target = resumeSessionId?.trim();
|
||||
if (!target) {
|
||||
return undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type McpServerEntry,
|
||||
type McpTransport,
|
||||
removeServer,
|
||||
setServerOAuthClient,
|
||||
toggleServer,
|
||||
updateServer,
|
||||
} from "./settings";
|
||||
@@ -49,6 +50,7 @@ type RemoteAuthMode = "none" | "headers" | "oauth";
|
||||
interface UrlServerConfig {
|
||||
transport: McpTransport;
|
||||
authMode: RemoteAuthMode;
|
||||
oauthClient?: { clientId: string; clientSecret?: string };
|
||||
}
|
||||
|
||||
export interface McpAddDefaults {
|
||||
@@ -196,7 +198,29 @@ async function collectUrlTransport(
|
||||
});
|
||||
if (isCancel(authMode)) return null;
|
||||
|
||||
if (authMode === "oauth" || authMode === "none") {
|
||||
if (authMode === "oauth") {
|
||||
const clientId = await p.text({
|
||||
message: "OAuth client ID (leave empty for dynamic registration)",
|
||||
});
|
||||
if (isCancel(clientId)) return null;
|
||||
const normalizedClientId = (clientId as string).trim();
|
||||
let clientSecret: string | undefined;
|
||||
if (normalizedClientId) {
|
||||
const secret = await p.password({
|
||||
message: "OAuth client secret (leave empty for public clients)",
|
||||
});
|
||||
if (isCancel(secret)) return null;
|
||||
clientSecret = (secret as string).trim() || undefined;
|
||||
}
|
||||
return {
|
||||
transport: { type, url: (url as string).trim() },
|
||||
authMode,
|
||||
oauthClient: normalizedClientId
|
||||
? { clientId: normalizedClientId, clientSecret }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (authMode === "none") {
|
||||
return {
|
||||
transport: { type, url: (url as string).trim() },
|
||||
authMode,
|
||||
@@ -270,6 +294,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
let oauthClient: UrlServerConfig["oauthClient"];
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport(defaults?.command);
|
||||
} else {
|
||||
@@ -279,6 +304,7 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
);
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
oauthClient = config?.oauthClient;
|
||||
}
|
||||
if (!transport) return;
|
||||
|
||||
@@ -286,6 +312,8 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
addServer(serverName, transport);
|
||||
if (authMode !== "oauth") {
|
||||
clearServerOAuth(serverName);
|
||||
} else {
|
||||
setServerOAuthClient(serverName, oauthClient);
|
||||
}
|
||||
p.log.success(`Added "${serverName}" to ${getSettingsPath()}`);
|
||||
if (authMode === "oauth") {
|
||||
@@ -374,18 +402,22 @@ async function actionEdit(): Promise<void> {
|
||||
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
let oauthClient: UrlServerConfig["oauthClient"];
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport();
|
||||
} else {
|
||||
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
oauthClient = config?.oauthClient;
|
||||
}
|
||||
if (!transport) return;
|
||||
|
||||
updateServer(name, transport);
|
||||
if (type === "stdio" || authMode !== "oauth") {
|
||||
clearServerOAuth(name);
|
||||
} else {
|
||||
setServerOAuthClient(name, oauthClient);
|
||||
}
|
||||
p.log.success(`Updated "${name}"`);
|
||||
if (authMode === "oauth") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
clearServerOAuth,
|
||||
loadServers,
|
||||
removeServer,
|
||||
setServerOAuthClient,
|
||||
} from "./settings";
|
||||
|
||||
describe("MCP wizard settings", () => {
|
||||
@@ -139,4 +140,35 @@ describe("MCP wizard settings", () => {
|
||||
|
||||
await expect(readFile(settingsPath, "utf8")).resolves.toBe(before);
|
||||
});
|
||||
|
||||
it("clears OAuth state when the configured client changes", async () => {
|
||||
const settingsPath = await useTempSettingsPath();
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
github: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://api.githubcopilot.com/mcp/",
|
||||
},
|
||||
oauthClient: { clientId: "old-client", clientSecret: "old-secret" },
|
||||
oauth: { tokens: { access_token: "old-token" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
setServerOAuthClient("github", {
|
||||
clientId: "new-client",
|
||||
clientSecret: "new-secret",
|
||||
});
|
||||
|
||||
const [github] = loadServers();
|
||||
expect(github?.oauth).toBeUndefined();
|
||||
expect(github?.oauthClient).toEqual({
|
||||
clientId: "new-client",
|
||||
clientSecret: "new-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
type McpServerOAuthClientConfig,
|
||||
type McpServerOAuthState,
|
||||
McpSettingsUpdateSkippedError,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
@@ -10,6 +11,7 @@ export interface McpServerEntry {
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
disabled?: boolean;
|
||||
oauthClient?: McpServerOAuthClientConfig;
|
||||
oauth?: McpServerOAuthState;
|
||||
}
|
||||
|
||||
@@ -49,6 +51,9 @@ export function loadServers(): McpServerEntry[] {
|
||||
name,
|
||||
transport,
|
||||
disabled: entry.disabled === true,
|
||||
oauthClient: entry.oauthClient as
|
||||
| McpServerOAuthClientConfig
|
||||
| undefined,
|
||||
oauth,
|
||||
};
|
||||
});
|
||||
@@ -138,6 +143,7 @@ export function clearServerOAuth(name: string): void {
|
||||
);
|
||||
}
|
||||
delete existing.oauth;
|
||||
delete existing.oauthClient;
|
||||
servers[name] = existing;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,6 +154,29 @@ export function clearServerOAuth(name: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function setServerOAuthClient(
|
||||
name: string,
|
||||
client: McpServerOAuthClientConfig | undefined,
|
||||
): void {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing)
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
const previous = existing.oauthClient as
|
||||
| McpServerOAuthClientConfig
|
||||
| undefined;
|
||||
if (
|
||||
previous?.clientId !== client?.clientId ||
|
||||
previous?.clientSecret !== client?.clientSecret
|
||||
) {
|
||||
delete existing.oauth;
|
||||
}
|
||||
if (client) existing.oauthClient = client;
|
||||
else delete existing.oauthClient;
|
||||
servers[name] = existing;
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleServer(name: string, disabled: boolean): void {
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
|
||||
import {
|
||||
ensureSchedulerHub,
|
||||
type HubScheduleClient,
|
||||
} from "../../commands/schedule/client";
|
||||
import { resolveAddress } from "../../commands/schedule/common";
|
||||
import { resolveScheduleModelSelection } from "../../commands/schedule/model-selection";
|
||||
import { CRON_PRESETS } from "./cron-presets";
|
||||
|
||||
function isCancel(value: unknown): value is symbol {
|
||||
@@ -211,12 +211,13 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const modelSelection = resolveScheduleModelSelection({ provider, model });
|
||||
const created = (await client.createSchedule({
|
||||
name: (name as string).trim(),
|
||||
cronPattern,
|
||||
prompt: (prompt as string).trim(),
|
||||
provider: provider ?? "cline",
|
||||
model: model ?? CLINE_DEFAULT_MODEL_ID,
|
||||
provider: modelSelection.provider,
|
||||
model: modelSelection.model,
|
||||
mode: mode as "act" | "plan" | "yolo",
|
||||
workspaceRoot: (workspace as string).trim(),
|
||||
systemPrompt,
|
||||
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["src/**/*.e2e.test.ts", "src/tests/**"],
|
||||
// Default 5s is tight on CI: each test uses `resetModules()` + dynamic `import("./main")`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Unit tests (and any subprocess they spawn, via env inheritance) must never
|
||||
// touch the developer's real ~/.cline: a test that reaches core startup can
|
||||
// otherwise spawn a real hub daemon against the real discovery record, or
|
||||
// trigger a real background auto-update. Point everything at a per-worker
|
||||
// temp dir before any test file is imported. Tests that need specific paths
|
||||
// still override these per-test.
|
||||
const isolatedRoot = mkdtempSync(join(tmpdir(), "cline-cli-vitest-"));
|
||||
process.env.CLINE_DIR = join(isolatedRoot, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(isolatedRoot, "data");
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = join(isolatedRoot, "hub-discovery.json");
|
||||
process.env.CLINE_NO_AUTO_UPDATE = "1";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreSessionEvent } from "@cline/core";
|
||||
import type { HubContext } from "./state";
|
||||
|
||||
// agent-events.ts only needs these siblings at runtime; mocking them keeps the
|
||||
// test from loading @cline/core (state-payloads imports it at module scope),
|
||||
// whose transitive provider deps do not survive vitest's module interop.
|
||||
vi.mock("./approvals", () => ({ rejectPendingApprovalsForSession: vi.fn() }));
|
||||
vi.mock("./state-payloads", () => ({ broadcastHubState: vi.fn() }));
|
||||
|
||||
import { handleSessionEvent } from "./agent-events";
|
||||
|
||||
function makeContextWithPeer(sessionId: string): {
|
||||
ctx: HubContext;
|
||||
sent: unknown[];
|
||||
} {
|
||||
const sent: unknown[] = [];
|
||||
const ctx = {
|
||||
peers: new Set([{ selectedSessionId: sessionId }]),
|
||||
sessions: new Map(),
|
||||
send: (_peer: unknown, payload: unknown) => sent.push(payload),
|
||||
sendToSelectedPeers(id: string, payload: unknown) {
|
||||
if (id === sessionId) sent.push(payload);
|
||||
},
|
||||
} as unknown as HubContext;
|
||||
return { ctx, sent };
|
||||
}
|
||||
|
||||
function agentErrorEvent(
|
||||
sessionId: string,
|
||||
recoverable: boolean,
|
||||
): CoreSessionEvent {
|
||||
return {
|
||||
type: "agent_event",
|
||||
payload: {
|
||||
sessionId,
|
||||
event: {
|
||||
type: "error",
|
||||
error: new Error(
|
||||
"1 tool call(s) failed: [run_commands] Command not executed",
|
||||
),
|
||||
recoverable,
|
||||
iteration: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleSessionEvent — agent error events", () => {
|
||||
it("forwards recoverable errors flagged as in-run notices, not turn outcomes", () => {
|
||||
const { ctx, sent } = makeContextWithPeer("session-1");
|
||||
|
||||
handleSessionEvent(ctx, agentErrorEvent("session-1", true));
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "error",
|
||||
text: "1 tool call(s) failed: [run_commands] Command not executed",
|
||||
recoverable: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards non-recoverable errors unflagged", () => {
|
||||
const { ctx, sent } = makeContextWithPeer("session-1");
|
||||
|
||||
handleSessionEvent(ctx, agentErrorEvent("session-1", false));
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "error",
|
||||
text: "1 tool call(s) failed: [run_commands] Command not executed",
|
||||
recoverable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -119,9 +119,16 @@ function forwardAgentEvent(
|
||||
return;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
// Forwarded with the recoverable flag intact: recoverable errors are
|
||||
// in-run notices (the MistakeTracker emits one per recorded mistake,
|
||||
// e.g. a plan-mode guard-blocked run_commands call) and the run keeps
|
||||
// going, so it is up to each peer to decide how to render them — the
|
||||
// turn's outcome is decided by how it actually ends (turn_done /
|
||||
// non-recoverable error).
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "error",
|
||||
text: event.error.message,
|
||||
recoverable: event.recoverable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
@@ -297,7 +294,13 @@ export async function handleDesktopCommand(
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) throw new Error("tool name is required");
|
||||
toggleDisabledTool(toolName);
|
||||
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
|
||||
await ctx.uiClient.toggleSetting({
|
||||
type: "tools",
|
||||
name: toolName,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "set_tool_disabled") {
|
||||
@@ -306,13 +309,30 @@ export async function handleDesktopCommand(
|
||||
.map((name) => String(name ?? "").trim())
|
||||
.filter(Boolean);
|
||||
if (toolNames.length === 0) throw new Error("tool name is required");
|
||||
setDisabledTools(toolNames, args?.disabled === true);
|
||||
for (const name of toolNames) {
|
||||
if (!ctx.uiClient)
|
||||
throw new Error("Hub settings client is not connected");
|
||||
await ctx.uiClient.toggleSetting({
|
||||
type: "tools",
|
||||
name,
|
||||
enabled: args?.disabled !== true,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
}
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "set_plugin_disabled") {
|
||||
const pluginPath = String(args?.path ?? "").trim();
|
||||
if (!pluginPath) throw new Error("plugin path is required");
|
||||
setDisabledPlugin(pluginPath, args?.disabled === true);
|
||||
if (!ctx.uiClient) throw new Error("Hub settings client is not connected");
|
||||
await ctx.uiClient.toggleSetting({
|
||||
type: "plugins",
|
||||
path: pluginPath,
|
||||
enabled: args?.disabled !== true,
|
||||
workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
throw new Error(`unsupported desktop command: ${command}`);
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("preserves tolerant handling of malformed history entries", () => {
|
||||
expect(() => mapHistoryToWebviewMessages([null, 42])).not.toThrow();
|
||||
});
|
||||
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
@@ -228,4 +232,79 @@ describe("mapHistoryToWebviewMessages", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates provider model activities through ordinary tool events", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: { answer: "1.3.14" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
name: "web_search",
|
||||
state: "output-available",
|
||||
input: { query: "latest Bun release" },
|
||||
output: '{"answer":"1.3.14"}',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
text: "Bun 1.3.14 is current.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps id-less history row ids stable as a provider result completes", () => {
|
||||
const source = {
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const pending = mapHistoryToWebviewMessages([source]);
|
||||
const completed = mapHistoryToWebviewMessages([
|
||||
{
|
||||
...source,
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
...source.metadata.modelToolActivities[0],
|
||||
output: "1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pending.at(-1)?.id).toBe("history-0");
|
||||
expect(completed.at(-1)?.id).toBe("history-0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
WebviewActionSessionSummary,
|
||||
WebviewChatMessage,
|
||||
@@ -193,13 +197,17 @@ export function mapHistoryToWebviewMessages(
|
||||
): WebviewChatMessage[] {
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<string, HistoryToolLocation>();
|
||||
const displayHistory = projectSessionMessagesForDisplay(
|
||||
history as MessageWithMetadata[],
|
||||
);
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
for (const entry of displayHistory) {
|
||||
const { message, sourceIndex } = entry;
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const messageKey = asString(record.id) ?? `history-${index}`;
|
||||
message && typeof message === "object"
|
||||
? (message as unknown as Record<string, unknown>)
|
||||
: { content: message };
|
||||
const messageKey = asString(record.id) ?? `history-${sourceIndex}`;
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
let role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type SessionRecord,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type { WebviewConfig, WebviewReasonLevel } from "../webview-protocol";
|
||||
import { rejectPendingApprovalsForSession } from "./approvals";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
@@ -89,7 +89,7 @@ function buildSessionStartInput(
|
||||
teamName?: string;
|
||||
source?: SessionSource;
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
},
|
||||
): ClineCoreStartInput {
|
||||
const mode = options?.mode === "plan" ? "plan" : "act";
|
||||
@@ -137,7 +137,7 @@ function buildStartInputFromSession(
|
||||
session: SessionRecord,
|
||||
options?: {
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
},
|
||||
) {
|
||||
const metadata =
|
||||
@@ -348,7 +348,7 @@ export async function forkPeerSession(
|
||||
try {
|
||||
const rawMessages = (await ctx.cline.readMessages(
|
||||
forkedFromSessionId,
|
||||
)) as Message[];
|
||||
)) as MessageWithMetadata[];
|
||||
if (rawMessages.length === 0) {
|
||||
ctx.send(peer, {
|
||||
type: "fork_error",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { extname, join, basename as pathBasename } from "node:path";
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getPluginDisplayName,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
readGlobalSettings,
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
@@ -99,49 +96,21 @@ export async function listUserInstructionConfigs(
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlugins = (): Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}> => {
|
||||
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
|
||||
const pluginsByPath = new Map<
|
||||
string,
|
||||
{ name: string; path: string; enabled: boolean }
|
||||
>();
|
||||
const directories = resolvePluginConfigSearchPaths(
|
||||
targetWorkspaceRoot,
|
||||
).filter((d) => existsSync(d));
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) continue;
|
||||
pluginsByPath.set(filePath, {
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...pluginsByPath.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
const [rules, workflows, skills, pluginTools] = await Promise.all([
|
||||
const [rules, workflows, skills, settingsSnapshot] = await Promise.all([
|
||||
loadUserInstructionSnapshot("rule"),
|
||||
loadUserInstructionSnapshot("workflow"),
|
||||
loadUserInstructionSnapshot("skill"),
|
||||
listPluginTools({
|
||||
workspacePath: targetWorkspaceRoot,
|
||||
createCoreSettingsService().list({
|
||||
workspaceRoot: targetWorkspaceRoot,
|
||||
cwd: targetWorkspaceRoot,
|
||||
}),
|
||||
]);
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
// Pin spawn/teams availability so this listing matches the desktop
|
||||
// sidecar's (sidecar/commands.ts) even if the preset defaults change.
|
||||
const builtinToolCatalog = getCoreBuiltinToolCatalog({
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
disabledToolIds: disabledTools,
|
||||
});
|
||||
|
||||
@@ -151,7 +120,12 @@ export async function listUserInstructionConfigs(
|
||||
workflows,
|
||||
skills,
|
||||
agents: loadAgents(),
|
||||
plugins: loadPlugins(),
|
||||
plugins: settingsSnapshot.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
enabled: plugin.enabled !== false,
|
||||
contributions: plugin.contributions,
|
||||
})),
|
||||
tools: [
|
||||
...builtinToolCatalog.map((tool) => ({
|
||||
id: tool.id,
|
||||
@@ -163,11 +137,11 @@ export async function listUserInstructionConfigs(
|
||||
source: "builtin",
|
||||
headlessToolNames: tool.headlessToolNames,
|
||||
})),
|
||||
...pluginTools.map((tool) => ({
|
||||
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
||||
...settingsSnapshot.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
enabled: tool.enabled,
|
||||
enabled: tool.enabled !== false,
|
||||
source: tool.source,
|
||||
path: tool.path,
|
||||
pluginName: tool.pluginName,
|
||||
|
||||
@@ -272,7 +272,13 @@ export type WebviewInboundMessage =
|
||||
|
||||
export type WebviewOutboundMessage =
|
||||
| { type: "status"; text: string }
|
||||
| { type: "error"; text: string }
|
||||
/**
|
||||
* `recoverable: true` marks an in-run notice (e.g. a MistakeTracker
|
||||
* mistake such as a plan-mode guard-blocked command) — the run continues,
|
||||
* so peers should not treat it as the turn's outcome. Absent/false means
|
||||
* a genuine failure.
|
||||
*/
|
||||
| { type: "error"; text: string; recoverable?: boolean }
|
||||
| {
|
||||
type: "desktopCommandResult";
|
||||
id: string;
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
@@ -19,7 +20,7 @@
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"ai": "^7",
|
||||
"ai": "^7.0.58",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -807,6 +807,15 @@ export default function Chat({
|
||||
setStatus(message.text);
|
||||
return;
|
||||
case "error":
|
||||
// Recoverable errors are in-run notices (e.g. a plan-mode
|
||||
// guard-blocked command recorded as a model mistake) — the
|
||||
// run continues and any tool failure is already shown on
|
||||
// its tool row, so keep the turn state and transcript
|
||||
// intact. Only genuine failures end the turn.
|
||||
if (message.recoverable) {
|
||||
setStatus(`Recoverable error (run continues): ${message.text}`);
|
||||
return;
|
||||
}
|
||||
setStatus(`Error: ${message.text}`);
|
||||
setSending(false);
|
||||
setHydratingSessionId(undefined);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import {
|
||||
Bot,
|
||||
Code,
|
||||
Copy,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Play,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
@@ -16,6 +17,12 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
@@ -97,6 +104,19 @@ type PluginItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
contributions?: PluginContributions;
|
||||
};
|
||||
|
||||
type PluginContributions = {
|
||||
inspectionStatus?: "available" | "disabled" | "failed";
|
||||
capabilities: string[];
|
||||
tools: string[];
|
||||
skills: string[];
|
||||
rules: string[];
|
||||
hooks: string[];
|
||||
commands: string[];
|
||||
mcpServers: string[];
|
||||
providers: string[];
|
||||
};
|
||||
|
||||
type ToolItem = {
|
||||
@@ -856,6 +876,46 @@ export function CustomizationSectionView({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPluginMenu = (target: LocalUninstallTarget) => {
|
||||
const uninstalling = localUninstallingKeys.has(target.key);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={`More actions for ${target.name ?? "plugin"}`}
|
||||
className="m-0 size-auto shrink-0 p-0 text-muted-foreground"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(target.path ?? "")
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={uninstalling}
|
||||
onClick={() => void uninstallLocalPrimitive(target)}
|
||||
>
|
||||
{uninstalling ? <Spinner /> : <Trash2 className="size-4" />}
|
||||
{uninstalling ? "Uninstalling..." : "Uninstall"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSkillCard = (item: CommandItem) => {
|
||||
const key = `${item.type}:${item.path}`;
|
||||
return (
|
||||
@@ -910,13 +970,32 @@ export function CustomizationSectionView({
|
||||
scope: ItemScope;
|
||||
}) => {
|
||||
const key = plugin.path;
|
||||
const contributionGroups = [
|
||||
{
|
||||
label: "Tools",
|
||||
items:
|
||||
plugin.contributions?.tools ??
|
||||
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
},
|
||||
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
|
||||
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
|
||||
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
|
||||
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
|
||||
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
|
||||
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
|
||||
{
|
||||
label: "Capabilities",
|
||||
items: plugin.contributions?.capabilities ?? [],
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
return (
|
||||
<div
|
||||
<details
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<summary className="flex cursor-pointer list-none items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
@@ -929,61 +1008,61 @@ export function CustomizationSectionView({
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map((tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{renderLocalActionRow({
|
||||
{renderPluginMenu({
|
||||
key,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{plugin.contributions?.inspectionStatus === "disabled" ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Enable this plugin to inspect its dynamic contributions.
|
||||
</p>
|
||||
) : null}
|
||||
{contributionGroups.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 py-2 text-xs font-medium text-foreground">
|
||||
<span className="mr-1">Contributions</span>
|
||||
{contributionGroups.map((group) => (
|
||||
<Badge key={group.label} variant="outline">
|
||||
{group.label} {group.items.length}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid max-h-56 gap-3 overflow-y-auto pt-2 sm:grid-cols-2">
|
||||
{contributionGroups.map((group) => (
|
||||
<div key={group.label} className="min-w-0">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.items.map((item) => (
|
||||
<Badge key={item} variant="secondary">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin contributions found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderLocalActionMessage(key) ? (
|
||||
<div className="mt-3">{renderLocalActionMessage(key)}</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1000,53 +1079,56 @@ export function CustomizationSectionView({
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
{renderPluginMenu({
|
||||
key: plugin.path,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderPluginMatchedMeta = (plugin: PluginItem) => (
|
||||
<p className="min-w-0 truncate text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
);
|
||||
|
||||
const renderPluginMatchedDetails = (plugin: PluginItem) => {
|
||||
const pluginTools = pluginToolsByPluginKey.get(plugin.path) ?? [];
|
||||
if (pluginTools.length === 0) {
|
||||
const contributionGroups = [
|
||||
{
|
||||
label: "Tools",
|
||||
items:
|
||||
plugin.contributions?.tools ??
|
||||
(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
},
|
||||
{ label: "Skills", items: plugin.contributions?.skills ?? [] },
|
||||
{ label: "Rules", items: plugin.contributions?.rules ?? [] },
|
||||
{ label: "Hooks", items: plugin.contributions?.hooks ?? [] },
|
||||
{ label: "Commands", items: plugin.contributions?.commands ?? [] },
|
||||
{ label: "MCP servers", items: plugin.contributions?.mcpServers ?? [] },
|
||||
{ label: "Providers", items: plugin.contributions?.providers ?? [] },
|
||||
{
|
||||
label: "Capabilities",
|
||||
items: plugin.contributions?.capabilities ?? [],
|
||||
},
|
||||
].filter((group) => group.items.length > 0);
|
||||
if (contributionGroups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{pluginTools.map((tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid max-h-56 gap-3 overflow-y-auto sm:grid-cols-2">
|
||||
{contributionGroups.map((group) => (
|
||||
<div key={group.label} className="min-w-0">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.items.map((item) => (
|
||||
<Badge key={item} variant="secondary">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1147,10 +1229,12 @@ export function CustomizationSectionView({
|
||||
renderMatchedControls: () =>
|
||||
renderPluginMatchedControls(item.plugin),
|
||||
renderMatchedDetails:
|
||||
Object.values(item.plugin.contributions ?? {}).some(
|
||||
(values) => values.length > 0,
|
||||
) ||
|
||||
(pluginToolsByPluginKey.get(item.plugin.path) ?? []).length > 0
|
||||
? () => renderPluginMatchedDetails(item.plugin)
|
||||
: undefined,
|
||||
renderMatchedMeta: () => renderPluginMatchedMeta(item.plugin),
|
||||
}),
|
||||
)
|
||||
: catalogPrimitive === "mcp"
|
||||
@@ -1476,7 +1560,6 @@ export function CustomizationSectionView({
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
@@ -1492,13 +1575,9 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
@@ -1513,19 +1592,6 @@ export function CustomizationSectionView({
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1558,7 +1624,6 @@ export function CustomizationSectionView({
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
@@ -1574,13 +1639,9 @@ export function CustomizationSectionView({
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 ml-7 text-xs font-mono text-muted-foreground">
|
||||
{plugin.path}
|
||||
</p>
|
||||
<div className="mt-3 ml-7 flex flex-col gap-2">
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
const isToggling = togglingToolIds.has(tool.id);
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
@@ -1595,19 +1656,6 @@ export function CustomizationSectionView({
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tool.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setToolEnabled(tool);
|
||||
}}
|
||||
disabled={isToggling || !plugin.enabled}
|
||||
aria-label={`Toggle ${tool.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource-variable/geist-mono";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--font-weight-normal: 480;
|
||||
--font-weight-medium: 560;
|
||||
--font-weight-semibold: 640;
|
||||
--font-weight-bold: 640;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.18 0.008 255);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -42,10 +47,10 @@
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Schibsted Grotesk Variable", sans-serif;
|
||||
--font-sans: "Inter Variable", sans-serif;
|
||||
--font-mono:
|
||||
ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono",
|
||||
monospace;
|
||||
"Geist Mono Variable", ui-monospace, "SFMono-Regular", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -197,6 +202,10 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 600;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.12
|
||||
|
||||
- Every tool call now gets its own row in the transcript, with its own icon, status, and expandable detail — no more "Read 3 files · Ran 2 commands" grouping. Commands read like a terminal (`$ bun test`) with their captured output on expand, and edits show their diffs inline, one per hunk.
|
||||
- Running tool rows are highlighted in brand violet and settle to gray when they finish; errors stay red.
|
||||
- File diffs — both in chat rows and the diff panel — now render through a shared syntax-highlighted renderer that follows the app's theme instead of the browser's.
|
||||
- Refreshed session transcript layout, message surfaces, and composer, with new Inter and Geist Mono typography.
|
||||
- The thinking indicator now stays up during quiet stretches of a turn, such as while tool arguments are streaming, so the turn no longer looks frozen.
|
||||
- Message actions (copy, fork, timestamp) no longer crowd the message text, and expanded panels render at full opacity instead of faded.
|
||||
- On the welcome screen the chat input is centered and top-aligned, and prompt suggestions are temporarily hidden.
|
||||
- The first turn of a fresh session no longer wedges the composer on "Agent is working…" forever.
|
||||
- Scheduled runs no longer appear in the session sidebar and history list.
|
||||
- Reconnecting to a stale managed Cline Hub daemon is fixed. When another Cline install ships a newer Hub, the app now prompts to update and restart — and stages the app update first, so it no longer relaunches into the same version and immediately re-prompts.
|
||||
- The Hub daemon now shuts down cleanly instead of exiting with an error when a client is still connected.
|
||||
- The Claude Code provider is usable for agentic work again: sessions are anchored on the workspace folder, your `~/.claude` and project settings are loaded, and file edits under the workspace are allowed instead of every write being refused with no approval prompt.
|
||||
- Truncated tool-call JSON is now rejected instead of being silently "repaired" into wrong arguments.
|
||||
- Fixed strict providers (seen on Vercel with kimi-k3) rejecting a turn with "user message must have content" when a message held only empty text.
|
||||
- Fixed a mid-turn crash on streamed tool calls with non-zero indexes, hit through LiteLLM's Anthropic passthrough.
|
||||
- Compaction now respects your Max Output Tokens setting instead of a hardcoded 1024-token cap — reasoning models were spending the entire budget thinking, so no summary arrived and compaction was skipped every time.
|
||||
- Vertex AI: added Fable 5 and custom model IDs, and the global-region picker no longer hides models from the live catalog.
|
||||
|
||||
## 0.0.11
|
||||
|
||||
- Images can now be pasted straight from the clipboard into the composer.
|
||||
- Opening a folder that isn't a git repo no longer shows git jargon, and the welcome suggestions now adapt to what's actually in the folder instead of assuming a code project.
|
||||
- The folder picker now reports failures instead of doing nothing, and offers a manual path entry as a fallback.
|
||||
- Opening an existing session no longer overwrites the model you had selected.
|
||||
- The diff panel now resolves file paths against the session's working directory, so diffs open correctly for sessions rooted outside the app's own directory.
|
||||
- `/team` prompts now run through the core runtime.
|
||||
- Failed turns surface their error in the transcript instead of leaving the chat blank.
|
||||
- Plugins left behind as empty install directories are no longer listed as installed, and plugin settings and contributions are now managed centrally with atomic toggles.
|
||||
- Fixed a startup script-load error, and webview errors are now attributed to the source URL that caused them.
|
||||
- Signing out is handled as a normal state rather than surfacing as a command error.
|
||||
- Native-feel and performance polish: the browser context menu is suppressed on app chrome (kept for text fields and selections), UI chrome is no longer text-selectable while chat content still is, inner scrollers no longer rubber-band the window, Settings/Sessions/Onboarding/Diff load lazily, the composer no longer flickers the caret on every keystroke, slash commands are cached across menu opens, and Escape closes the provider/model picker.
|
||||
- Tool output no longer nests its own scrollbar.
|
||||
- Prompts queued during a turn now survive being interrupted — they're preserved across aborts, drained after a turn aborts itself, and the stop is surfaced instead of the queue being silently dropped. Queued turns that fail are reported as failures.
|
||||
- Session context stays durable across aborts and hub restarts.
|
||||
- A hung MCP server no longer takes down session creation, and stdio servers that were never configured get a 30-second initialize budget instead of blocking indefinitely.
|
||||
- Remote SSE MCP servers surface an OAuth authorization prompt on a 401 instead of failing outright.
|
||||
- LiteLLM requests route through Chat Completions instead of the Responses API.
|
||||
- Network interruptions mid-stream but before any model output are retried instead of failing the turn.
|
||||
- Checkpoints are picked up when git is initialized part-way through a session, and checkpoint diffs include files that were untracked when the snapshot was taken.
|
||||
- Scheduled run reports carry execution context — schedule metadata, durations, and lifecycle error details.
|
||||
|
||||
## 0.0.10
|
||||
|
||||
- Remote MCP servers can now authenticate with OAuth from Settings → MCP — authorize a server, see its auth status, and cancel or retry a pending authorization. Servers that require a pre-registered OAuth client (client ID/secret) instead of dynamic registration are now supported, and stored tokens are invalidated when a server's client configuration changes.
|
||||
- MCP errors are now shown on the individual server rather than as a page-level error, and a server with invalid configuration is surfaced with its error instead of silently disappearing from the list.
|
||||
- Failed turns no longer fail silently. Sending a message with no model credentials — or any queued turn that fails — now shows an error in the transcript, enriched with the underlying cause and a pointer to Settings → Models.
|
||||
- Fixed the first message of a chat (and some queued messages) rendering twice.
|
||||
- Fixed the composer getting stuck on "Agent is working…" after a turn already finished.
|
||||
- New "Connect a model" notice on the welcome screen when no provider has credentials, with one click to onboarding or model settings. It reacts live as you add credentials, and correctly recognizes Bedrock/Vertex and keyless local endpoints as already connected.
|
||||
- Added "Get an API key" links for popular providers in onboarding and Settings → Models, plus a link to the Cline dashboard from the Cline API key form.
|
||||
- The onboarding welcome step now explains what Cline is.
|
||||
- The stop button is now actually visible and clickable, Esc stops the current turn, and new shortcuts: Cmd/Ctrl+N for a new session, Cmd/Ctrl+, for settings.
|
||||
- Reasoning controls now resolve consistently across AI SDK providers, including Ollama, so effort levels and thinking on/off are honored wherever the provider supports them.
|
||||
- Vertex AI: credential refreshes now use the configured fetch, fixing ADC authentication behind proxies and custom networking.
|
||||
- Refreshed the bundled provider and model catalog.
|
||||
|
||||
## 0.0.9
|
||||
|
||||
- Cline Code now ships as a single universal macOS download that runs natively on both Apple Silicon and Intel — no more picking the right architecture. Existing per-architecture installs migrate to it automatically on their next update.
|
||||
- Session history can now be filtered by where a session came from — Desktop, CLI, extension, or scheduled — from a new filter control in the sidebar.
|
||||
- The composer now shows a token usage ring for the active model's context window, with cumulative cost, and it changes color as you approach the limit.
|
||||
- Skills now appear in the slash command menu alongside workflows, and commands that share a name are disambiguated instead of shadowing each other.
|
||||
- Installed plugins now show their real package names instead of all appearing as "index".
|
||||
- The agent header can be dragged to move the window again, including on read-only titles.
|
||||
- Chat message actions (copy, fork, edit, restore) no longer collide with the descenders of the message's last line.
|
||||
- Application errors are now reported in diagnostics, and the packaged app's telemetry configuration is baked into the sidecar at build time — previously the packaged build shipped with it empty, so no diagnostics were ever sent.
|
||||
- Plan mode now hard-blocks file-editing shell commands rather than relying on prompting alone; read-only investigation still works.
|
||||
- Running out of context is now recovered from automatically — the run compacts and retries once instead of failing with a raw provider error.
|
||||
- Empty model responses are now retried on every provider, not just Ollama, fixing hard "Model returned empty response" failures on OpenRouter, Cline, and OpenAI-compatible endpoints.
|
||||
- Claude 4.6+ and 5.x models are no longer rejected with "thinking.type.enabled is not supported".
|
||||
- Bedrock prompt caching works again — cache reads and writes were always 0 — and Bedrock foundation models now route through geo inference profiles.
|
||||
- Reasoning models on OpenAI-compatible endpoints now get the correct token parameter, and models without image support substitute image content instead of failing.
|
||||
- Refreshed the bundled provider and model catalog, adding Infomaniak and SCX.ai.
|
||||
- Upgraded the model layer to AI SDK 7 and switched Ollama to the native provider.
|
||||
|
||||
## 0.0.8
|
||||
|
||||
- Edit any earlier message in a conversation — the app forks the session at that point, rewinds the workspace to that run's checkpoint, and re-runs from your edited prompt. Restores are transactional and workspace-atomic, so a failed restore won't leave you half-rewound.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -22,7 +22,7 @@
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -31,8 +31,9 @@
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
"@cline/ui": "workspace:*",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@pierre/diffs": "^1.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
@@ -65,13 +66,11 @@
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"next": "16.2.11",
|
||||
@@ -83,7 +82,6 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^1.7.1",
|
||||
"streamdown": "^2.5.0",
|
||||
|
||||
@@ -151,6 +151,8 @@ Supported commands:
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
|
||||
| `list_mcp_servers` | Direct file I/O |
|
||||
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
|
||||
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
|
||||
| `upsert_mcp_server` | Direct file I/O |
|
||||
| `delete_mcp_server` | Direct file I/O |
|
||||
| `get_git_branch` | async `execFile("git", ...)` |
|
||||
|
||||
@@ -17,11 +17,52 @@ import {
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
prewarmWorkspaceMetadata,
|
||||
rewriteDesktopTeamPrompt,
|
||||
shouldUpdateSessionConnection,
|
||||
WORKSPACE_METADATA_PREWARM_TTL_MS,
|
||||
} from "./chat-session";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
describe("rewriteDesktopTeamPrompt", () => {
|
||||
it("rewrites /team for the core runtime", () => {
|
||||
expect(
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
disabledTools: new Set(),
|
||||
}),
|
||||
).toBe(
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects /team when the Teams tool is disabled", () => {
|
||||
expect(() =>
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
disabledTools: new Set(["teams"]),
|
||||
}),
|
||||
).toThrow("Agent teams are disabled");
|
||||
});
|
||||
|
||||
it("rejects /team when the mode's tool preset has no team tools", () => {
|
||||
expect(() =>
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
mode: "yolo",
|
||||
disabledTools: new Set(),
|
||||
}),
|
||||
).toThrow("Agent teams are not available in yolo mode");
|
||||
});
|
||||
|
||||
it("accepts /team in act and plan modes", () => {
|
||||
for (const mode of ["act", "plan", undefined]) {
|
||||
expect(
|
||||
rewriteDesktopTeamPrompt("/team inspect the app", {
|
||||
mode,
|
||||
disabledTools: new Set(),
|
||||
}),
|
||||
).toContain('<user_command slash="team">');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionConnectionUpdate", () => {
|
||||
it("does not clear reasoning settings when config omits reasoning fields", () => {
|
||||
const update = buildSessionConnectionUpdate({
|
||||
@@ -141,6 +182,8 @@ describe("pathless session starts", () => {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
|
||||
expect(input.config).not.toHaveProperty("cwd");
|
||||
expect(input.config).not.toHaveProperty("workspaceRoot");
|
||||
expect(input.config).not.toHaveProperty("enableSpawnAgent");
|
||||
expect(input.config).not.toHaveProperty("enableAgentTeams");
|
||||
return {
|
||||
sessionId: "session-pathless",
|
||||
manifest: {
|
||||
@@ -163,6 +206,10 @@ describe("pathless session starts", () => {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
enableTools: true,
|
||||
// Legacy desktop capability flags must not override the SDK's
|
||||
// current tool preset or global tool customizations.
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
},
|
||||
})) as {
|
||||
sessionId: string;
|
||||
@@ -283,7 +330,6 @@ describe("session forks", () => {
|
||||
expect(result).toEqual({
|
||||
sessionId: "edited-fork",
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
messages: expectedMessages,
|
||||
});
|
||||
expect(ctx.liveSessions.get("edited-fork")?.messages).toEqual(
|
||||
expectedMessages,
|
||||
@@ -1420,6 +1466,25 @@ Follow the desktop send skill instructions.`,
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites a team command when a queued prompt is edited", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "update_pending_prompt",
|
||||
sessionId,
|
||||
promptId: "queued-team",
|
||||
prompt: "/team inspect the app",
|
||||
});
|
||||
|
||||
expect(updatePendingPrompt).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
promptId: "queued-team",
|
||||
prompt:
|
||||
'<user_command slash="team">spawn a team of agents for the following task: inspect the app</user_command>',
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves built-in and unknown slash commands untouched", async () => {
|
||||
const workspace = createWorkspaceWithSkill();
|
||||
const { ctx, send, sessionId } = createContext(workspace);
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
type ClineCoreStartConfig,
|
||||
createSessionCompactionState,
|
||||
createUserInstructionConfigService,
|
||||
getCoreBuiltinToolCatalog,
|
||||
projectSessionCompactionState,
|
||||
readGlobalSettings,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
@@ -16,8 +18,8 @@ import {
|
||||
splitCoreSessionConfig,
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import { buildClineSystemPrompt } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
discardAllTrackedAttachments,
|
||||
@@ -133,6 +135,63 @@ async function expandRuntimeSlashCommand(
|
||||
}
|
||||
}
|
||||
|
||||
type TeamPromptAvailability = {
|
||||
/** Session mode as stored in config; anything but plan/yolo counts as act. */
|
||||
mode?: unknown;
|
||||
disabledTools?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function rewriteDesktopTeamPrompt(
|
||||
prompt: string,
|
||||
availability: TeamPromptAvailability = {},
|
||||
): string {
|
||||
const match = /^\/team\b([\s\S]*)$/i.exec(prompt.trim());
|
||||
if (!match) return prompt;
|
||||
const task = match[1]?.trim();
|
||||
if (!task) {
|
||||
throw new Error(
|
||||
"Usage: /team <task description>. Starts a team of agents for the given task.",
|
||||
);
|
||||
}
|
||||
const disabledTools =
|
||||
availability.disabledTools ??
|
||||
new Set(readGlobalSettings().disabledTools ?? []);
|
||||
if (disabledTools.has("teams")) {
|
||||
throw new Error(
|
||||
"Agent teams are disabled. Enable the Teams tool in Customizations → Tools.",
|
||||
);
|
||||
}
|
||||
// The runtime resolves tool availability from the mode's preset, so a
|
||||
// preset without team tools must reject /team here rather than send the
|
||||
// model an instruction it cannot act on.
|
||||
const mode =
|
||||
availability.mode === "plan" || availability.mode === "yolo"
|
||||
? availability.mode
|
||||
: "act";
|
||||
const teamsAvailable = getCoreBuiltinToolCatalog({ mode }).some(
|
||||
(entry) => entry.id === "teams" && entry.defaultEnabled,
|
||||
);
|
||||
if (!teamsAvailable) {
|
||||
throw new Error(`Agent teams are not available in ${mode} mode.`);
|
||||
}
|
||||
return formatUserCommandBlock(
|
||||
`spawn a team of agents for the following task: ${task}`,
|
||||
"team",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDesktopRuntimePrompt(
|
||||
ctx: SidecarContext,
|
||||
workspacePath: string | undefined,
|
||||
prompt: string,
|
||||
mode?: unknown,
|
||||
): Promise<string> {
|
||||
return rewriteDesktopTeamPrompt(
|
||||
await expandRuntimeSlashCommand(ctx, workspacePath, prompt),
|
||||
{ mode },
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveWorkspaceTurn(session: LiveSession): boolean {
|
||||
return (
|
||||
session.busy ||
|
||||
@@ -223,7 +282,9 @@ export function refreshWorkspaceMetadata(cwd: string): void {
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
function readPersistedChatMessages(
|
||||
sessionId: string,
|
||||
): MessageWithMetadata[] | null {
|
||||
const path = join(
|
||||
sharedSessionDataDir(),
|
||||
sessionId,
|
||||
@@ -232,8 +293,8 @@ function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8").trim()) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
| { messages?: MessageWithMetadata[] }
|
||||
| MessageWithMetadata[];
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
return Array.isArray(parsed.messages) ? parsed.messages : null;
|
||||
} catch {
|
||||
@@ -357,16 +418,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
|
||||
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
|
||||
maxIterations: config.maxIterations ?? config.max_iterations,
|
||||
enableTools: config.enableTools ?? config.enable_tools ?? true,
|
||||
enableSpawnAgent:
|
||||
config.enableSpawn ??
|
||||
config.enableSpawnAgent ??
|
||||
config.enable_spawn ??
|
||||
false,
|
||||
enableAgentTeams:
|
||||
config.enableTeams ??
|
||||
config.enableAgentTeams ??
|
||||
config.enable_teams ??
|
||||
false,
|
||||
...(thinking !== undefined ? { thinking } : {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(thinkingBudgetTokens !== undefined ? { thinkingBudgetTokens } : {}),
|
||||
@@ -620,9 +671,7 @@ async function handleStart(
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages
|
||||
? { initialMessages: initialMessages as Message[] }
|
||||
: {}),
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
@@ -726,7 +775,7 @@ async function startRebuiltSession(
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
compactionState: SessionCompactionState | undefined,
|
||||
): Promise<void> {
|
||||
const projectedMessages = compactionState
|
||||
@@ -856,12 +905,13 @@ async function handleSend(
|
||||
if (session?.transitioningProvider) {
|
||||
throw new Error("A provider switch is already in progress");
|
||||
}
|
||||
// Dispatch the expanded instructions, but keep the raw `/command` token as
|
||||
// the session's display prompt.
|
||||
const expandedPrompt = await expandRuntimeSlashCommand(
|
||||
// Dispatch the expanded or rewritten instructions, but keep the raw
|
||||
// `/command` token as the session's display prompt.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
|
||||
prompt,
|
||||
request.config?.mode ?? session?.config?.mode,
|
||||
);
|
||||
let delivery = request.delivery;
|
||||
if (!delivery && session?.busy) {
|
||||
@@ -929,7 +979,7 @@ async function handleSend(
|
||||
}
|
||||
await manager.send({
|
||||
sessionId,
|
||||
prompt: expandedPrompt,
|
||||
prompt: runtimePrompt,
|
||||
delivery: "queue",
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
@@ -940,7 +990,23 @@ async function handleSend(
|
||||
sessionId,
|
||||
ok: true,
|
||||
queued: true,
|
||||
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
|
||||
// Response snapshot only — deliberately not routed through
|
||||
// applyPendingPrompts. The list was taken at enqueue time and
|
||||
// can already be stale when this response is written (the
|
||||
// runtime may have drained the prompt meanwhile, e.g. the
|
||||
// coerced first prompt of a fresh session); overwriting the
|
||||
// event-maintained session.promptsInQueue and rebroadcasting
|
||||
// prompts_in_queue_state here would resurrect a phantom queue
|
||||
// entry for every connected webview.
|
||||
promptsInQueue: prompts
|
||||
.map(mapPendingPrompt)
|
||||
.filter((item) => item.id)
|
||||
.map(({ id, prompt, steer, attachmentCount }) => ({
|
||||
id,
|
||||
prompt,
|
||||
steer,
|
||||
attachmentCount,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -953,7 +1019,7 @@ async function handleSend(
|
||||
try {
|
||||
result = await manager.send({
|
||||
sessionId,
|
||||
prompt: expandedPrompt,
|
||||
prompt: runtimePrompt,
|
||||
delivery,
|
||||
userImages: request.attachments?.userImages,
|
||||
userFiles,
|
||||
@@ -983,7 +1049,7 @@ async function handleSend(
|
||||
});
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "idle";
|
||||
if (result?.messages) session.messages = result.messages as unknown[];
|
||||
if (result?.messages) session.messages = result.messages;
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
@@ -1181,10 +1247,7 @@ async function handleForkUnlocked(
|
||||
let forkMessages =
|
||||
forkBeforeRunCount === undefined
|
||||
? sourceMessages
|
||||
: trimMessagesBeforeUserRun(
|
||||
sourceMessages as Message[],
|
||||
forkBeforeRunCount,
|
||||
);
|
||||
: trimMessagesBeforeUserRun(sourceMessages, forkBeforeRunCount);
|
||||
const forkMetadata: JsonRecord = {
|
||||
...(sourceMetadata ?? {}),
|
||||
fork: {
|
||||
@@ -1239,7 +1302,7 @@ async function handleForkUnlocked(
|
||||
} else {
|
||||
const started = await manager.start({
|
||||
...startInput,
|
||||
initialMessages: forkMessages as Message[],
|
||||
initialMessages: forkMessages,
|
||||
});
|
||||
newSessionId = started.sessionId;
|
||||
}
|
||||
@@ -1268,7 +1331,6 @@ async function handleForkUnlocked(
|
||||
return {
|
||||
sessionId: newSessionId,
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
messages: forkMessages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1354,14 +1416,8 @@ async function handleRestoreCheckpoint(
|
||||
);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
let messages: unknown[] = restoredMessages;
|
||||
try {
|
||||
const read = await manager.readMessages(sessionId);
|
||||
if (read?.length > 0) messages = read;
|
||||
} catch {}
|
||||
return {
|
||||
sessionId,
|
||||
messages,
|
||||
restoredCheckpoint: restored.checkpoint,
|
||||
};
|
||||
});
|
||||
@@ -1417,18 +1473,19 @@ async function handleUpdatePendingPrompt(
|
||||
throw new Error("prompt is required");
|
||||
}
|
||||
const manager = getSessionManager(ctx);
|
||||
const sessionConfig = ctx.liveSessions.get(sessionId)?.config;
|
||||
// Queued prompts are delivered by the runtime without another pass
|
||||
// through handleSend, so expand a leading slash command here too.
|
||||
const expandedPrompt = await expandRuntimeSlashCommand(
|
||||
// through handleSend, so resolve slash commands here too.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(ctx.liveSessions.get(sessionId)?.config) ??
|
||||
ctx.workspaceRoot,
|
||||
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
|
||||
prompt,
|
||||
sessionConfig?.mode,
|
||||
);
|
||||
const result = await manager.pendingPrompts.update({
|
||||
sessionId,
|
||||
promptId,
|
||||
prompt: expandedPrompt,
|
||||
prompt: runtimePrompt,
|
||||
});
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -112,8 +112,6 @@ async function main() {
|
||||
cwd: cwd,
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
enableSpawn: false,
|
||||
enableTeams: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
|
||||
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: unknown) {
|
||||
clineAccountServiceCtorMock(options);
|
||||
}
|
||||
},
|
||||
executeClineAccountAction: executeClineAccountActionMock,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const FETCH_ME_ARGS = {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
} as const;
|
||||
|
||||
async function runClineAccountCommand(ctx: SidecarContext) {
|
||||
const { handleCommand } = await import("./commands");
|
||||
return handleCommand(ctx, "cline_account", { ...FETCH_ME_ARGS });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clineAccountServiceCtorMock.mockReset();
|
||||
executeClineAccountActionMock.mockReset();
|
||||
getProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
describe("cline_account command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without telemetry or a thrown error", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(result).toEqual({
|
||||
signedIn: false,
|
||||
code: "ACCOUNT_NOT_AUTHENTICATED",
|
||||
});
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(clineAccountServiceCtorMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the account action unchanged when a fresh token resolves", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const user = { id: "user-1", email: "beatrix@cline.bot" };
|
||||
executeClineAccountActionMock.mockResolvedValue(user);
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(result).toBe(user);
|
||||
expect(executeClineAccountActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining(FETCH_ME_ARGS),
|
||||
expect.anything(),
|
||||
);
|
||||
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe("fresh-token");
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the persisted token silently when the refresh fails", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockRejectedValue(
|
||||
new Error("Token refresh failed: 500"),
|
||||
);
|
||||
getProviderSettingsMock.mockReturnValue({
|
||||
auth: { accessToken: "persisted-token" },
|
||||
});
|
||||
executeClineAccountActionMock.mockResolvedValue({ id: "user-1" });
|
||||
|
||||
await runClineAccountCommand(ctx);
|
||||
|
||||
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe(
|
||||
"persisted-token",
|
||||
);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports one auth refresh soft-failure event when the refresh fails and no fallback token exists", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
const refreshError = new Error(
|
||||
'OAuth credentials for provider "cline" are no longer valid. Re-run authentication for this provider.',
|
||||
);
|
||||
refreshError.name = "OAuthReauthRequiredError";
|
||||
resolveProviderApiKeyMock.mockRejectedValue(refreshError);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
|
||||
const result = await runClineAccountCommand(ctx);
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(executeClineAccountActionMock).not.toHaveBeenCalled();
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
expect(capture).toHaveBeenCalledWith({
|
||||
event: "user.auth_refresh_soft_failure",
|
||||
properties: expect.objectContaining({
|
||||
provider: "cline",
|
||||
errorName: "OAuthReauthRequiredError",
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,11 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, extname, isAbsolute, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
ClineAccountActionRequest,
|
||||
CoreSettingsSnapshot,
|
||||
ProviderCapability,
|
||||
ProviderClient,
|
||||
ProviderProtocol,
|
||||
@@ -19,31 +14,30 @@ import type {
|
||||
import {
|
||||
addLocalProvider,
|
||||
ClineAccountService,
|
||||
captureAuthRefreshSoftFailure,
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
getCoreBuiltinToolCatalog,
|
||||
getLocalProviderModels,
|
||||
getPluginDisplayName,
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
parseMcpServerRegistration,
|
||||
probeMcpServerConnection,
|
||||
RuntimeOAuthTokenManager,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveMcpServerRegistration,
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setMcpServerDisabled,
|
||||
setModelToolEnabledGlobally,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
updateLocalProvider,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
@@ -56,6 +50,7 @@ import {
|
||||
} from "@cline/shared";
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import packageJson from "../package.json";
|
||||
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
@@ -72,6 +67,17 @@ import {
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
ensureMcpSettingsFile,
|
||||
readMcpServersResponse,
|
||||
shouldProbeMcpServerAfterUpsert,
|
||||
} from "./mcp";
|
||||
import {
|
||||
cancelMcpOAuthAuthorizationForReason,
|
||||
McpOAuthAuthorizationCancelledError,
|
||||
runCancellableMcpOAuthAuthorization,
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation,
|
||||
} from "./mcp-oauth";
|
||||
import {
|
||||
cancelProviderOAuthLogin,
|
||||
runCancellableProviderOAuthLogin,
|
||||
@@ -95,6 +101,7 @@ import type {
|
||||
JsonRecord,
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
import { pickWorkspaceDirectory } from "./workspace-picker";
|
||||
|
||||
// All child processes in this module run asynchronously: the sidecar is a
|
||||
// single event loop shared by every UI command and streaming chat session, so
|
||||
@@ -162,82 +169,21 @@ function readProviderSettingsUpdate(
|
||||
: {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP settings helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const rawTransportType =
|
||||
transport?.type ?? record.transportType ?? record.type;
|
||||
const transportType = String(
|
||||
rawTransportType ??
|
||||
(typeof transport?.url === "string" || typeof record.url === "string"
|
||||
? "sse"
|
||||
: "stdio"),
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport type + URL a server record actually points at, tolerating both
|
||||
* the nested `transport` shape and legacy flat fields (mirrors
|
||||
* readMcpServersResponse).
|
||||
*/
|
||||
function mcpTransportIdentity(record: JsonRecord): string {
|
||||
function mcpTransportIdentity(name: string, record: JsonRecord): string {
|
||||
try {
|
||||
const resolved = parseMcpServerRegistration(name, record).transport;
|
||||
return resolved.type === "stdio"
|
||||
? "stdio\u0000"
|
||||
: `${resolved.type}\u0000${resolved.url}`;
|
||||
} catch {
|
||||
// Preserve a best-effort identity for malformed entries so the editor can
|
||||
// still repair them without requiring the entire settings file to parse.
|
||||
}
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
@@ -257,20 +203,6 @@ function mcpTransportIdentity(record: JsonRecord): string {
|
||||
return `${normalizedType}\u0000${url}`;
|
||||
}
|
||||
|
||||
function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function removePathIfExists(
|
||||
path: string,
|
||||
options?: { recursive?: boolean },
|
||||
@@ -293,8 +225,10 @@ function removePathIfExists(
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
async function resolveFreshClineAuthToken(
|
||||
ctx: SidecarContext,
|
||||
manager: ProviderSettingsManager,
|
||||
): Promise<string | undefined> {
|
||||
let refreshError: Error | undefined;
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
@@ -303,11 +237,28 @@ async function resolveFreshClineAuthToken(
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the persisted token; the account request surfaces the
|
||||
// auth failure to the caller.
|
||||
} catch (error) {
|
||||
// Fall back to the persisted token; when one exists the account request
|
||||
// surfaces the auth failure to the caller.
|
||||
refreshError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
return resolveLocalClineAuthToken(manager.getProviderSettings("cline"));
|
||||
const persisted = resolveLocalClineAuthToken(
|
||||
manager.getProviderSettings("cline"),
|
||||
);
|
||||
// Never-signed-in resolves to undefined without a refresh attempt and is
|
||||
// silent. A refresh failure with no persisted fallback means credentials
|
||||
// existed but yielded nothing — that is the signal a real auth regression
|
||||
// would show up as, so report exactly one event for it.
|
||||
if (!persisted && refreshError) {
|
||||
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
|
||||
error: refreshError,
|
||||
});
|
||||
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
|
||||
errorName: refreshError.name,
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
});
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function mergePersistedSessionRecord(
|
||||
@@ -731,9 +682,51 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
|
||||
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
|
||||
}
|
||||
|
||||
async function listHubSettings(
|
||||
ctx: SidecarContext,
|
||||
): Promise<CoreSettingsSnapshot> {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const reply = await hubClient.command("settings.list", {
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? "hub command failed: settings.list",
|
||||
);
|
||||
}
|
||||
return reply.payload?.snapshot as CoreSettingsSnapshot;
|
||||
}
|
||||
|
||||
async function toggleHubSetting(
|
||||
ctx: SidecarContext,
|
||||
input: {
|
||||
type: "plugins" | "tools";
|
||||
path?: string;
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): Promise<CoreSettingsSnapshot> {
|
||||
const hubClient = await ensureSharedHubClient(ctx);
|
||||
const reply = await hubClient.command("settings.toggle", {
|
||||
...input,
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? "hub command failed: settings.toggle",
|
||||
);
|
||||
}
|
||||
return reply.payload?.snapshot as CoreSettingsSnapshot;
|
||||
}
|
||||
|
||||
async function listUserInstructionConfigs(
|
||||
workspaceRoot: string,
|
||||
ctx: SidecarContext,
|
||||
settingsSnapshot?: CoreSettingsSnapshot,
|
||||
): Promise<JsonRecord> {
|
||||
const workspaceRoot = ctx.workspaceRoot;
|
||||
const hubSettings = settingsSnapshot ?? (await listHubSettings(ctx));
|
||||
const warnings: string[] = [];
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: { workspacePath: workspaceRoot },
|
||||
@@ -804,40 +797,6 @@ async function listUserInstructionConfigs(
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlugins = (): Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}> => {
|
||||
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
|
||||
const pluginsByPath = new Map<
|
||||
string,
|
||||
{ name: string; path: string; enabled: boolean }
|
||||
>();
|
||||
const directories = resolvePluginConfigSearchPaths(workspaceRoot).filter(
|
||||
(d) => existsSync(d),
|
||||
);
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) {
|
||||
continue;
|
||||
}
|
||||
pluginsByPath.set(filePath, {
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...pluginsByPath.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await userInstructionService.start();
|
||||
} catch (error) {
|
||||
@@ -858,13 +817,14 @@ async function listUserInstructionConfigs(
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
}
|
||||
const pluginTools = await listPluginTools({
|
||||
workspacePath: workspaceRoot,
|
||||
cwd: workspaceRoot,
|
||||
});
|
||||
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
// Pin spawn/teams availability so this listing matches the hub's
|
||||
// (apps/cline-hub/src/server/user-instructions.ts) even if the preset
|
||||
// defaults change.
|
||||
const builtinToolCatalog = getCoreBuiltinToolCatalog({
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
disabledToolIds: disabledTools,
|
||||
});
|
||||
|
||||
@@ -875,7 +835,12 @@ async function listUserInstructionConfigs(
|
||||
skills,
|
||||
runtimeCommands,
|
||||
agents: loadAgents(),
|
||||
plugins: loadPlugins(),
|
||||
plugins: hubSettings.plugins.map((plugin) => ({
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
enabled: plugin.enabled !== false,
|
||||
contributions: plugin.contributions,
|
||||
})),
|
||||
tools: [
|
||||
...builtinToolCatalog.map((tool) => ({
|
||||
id: tool.id,
|
||||
@@ -887,11 +852,11 @@ async function listUserInstructionConfigs(
|
||||
source: "builtin",
|
||||
headlessToolNames: tool.headlessToolNames,
|
||||
})),
|
||||
...pluginTools.map((tool) => ({
|
||||
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
||||
...hubSettings.tools.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
enabled: tool.enabled,
|
||||
enabled: tool.enabled !== false,
|
||||
source: tool.source,
|
||||
path: tool.path,
|
||||
pluginName: tool.pluginName,
|
||||
@@ -907,41 +872,6 @@ async function listUserInstructionConfigs(
|
||||
// Native OS commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Async is load-bearing here: the native picker blocks until the user chooses
|
||||
// a folder, and a synchronous exec would freeze every other sidecar command
|
||||
// (chat streams, history, settings) for however long the dialog stays open.
|
||||
async function pickWorkspaceDirectory(): Promise<string | null> {
|
||||
const platform = process.platform;
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"osascript",
|
||||
[
|
||||
"-e",
|
||||
'set theFolder to choose folder with prompt "Select workspace directory"',
|
||||
"-e",
|
||||
"return POSIX path of theFolder",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Linux — try zenity
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"zenity",
|
||||
["--file-selection", "--directory", "--title=Select workspace directory"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function openFileInEditor(filePath: string): void {
|
||||
const platform = process.platform;
|
||||
const cmd =
|
||||
@@ -1460,11 +1390,19 @@ export async function handleCommand(
|
||||
const operation = String(args?.operation ?? "").trim();
|
||||
if (!operation) throw new Error("operation is required");
|
||||
const manager = new ProviderSettingsManager();
|
||||
// Signed out is an expected state, not a command failure: resolve the
|
||||
// token up front and return a typed result the webview can act on
|
||||
// instead of letting the account service throw a generic error that
|
||||
// would be captured as error telemetry and shown raw to the user.
|
||||
const authToken = await resolveFreshClineAuthToken(ctx, manager);
|
||||
if (!authToken) {
|
||||
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
|
||||
}
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveFreshClineAuthToken(manager),
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
@@ -1588,6 +1526,13 @@ export async function handleCommand(
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_web_search_enabled") {
|
||||
if (typeof args?.web_search_enabled !== "boolean") {
|
||||
throw new Error("web_search_enabled must be a boolean");
|
||||
}
|
||||
setModelToolEnabledGlobally("web_search", args.web_search_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
@@ -1604,22 +1549,78 @@ export async function handleCommand(
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = {
|
||||
...(current as JsonRecord),
|
||||
disabled: Boolean(args?.disabled),
|
||||
};
|
||||
settings.mcpServers = servers;
|
||||
if (command === "authorize_mcp_server_oauth") {
|
||||
const name = String(args?.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
const registration = resolveMcpServerRegistration(name, {
|
||||
filePath: settingsPath,
|
||||
});
|
||||
if (!registration) {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
const wasDisabled = registration.disabled === true;
|
||||
setMcpServerDisabled({ filePath: settingsPath, name, disabled: true });
|
||||
try {
|
||||
await runCancellableMcpOAuthAuthorization(
|
||||
{
|
||||
serverName: name,
|
||||
filePath: settingsPath,
|
||||
openUrl: openUrlInDefaultBrowser,
|
||||
},
|
||||
options?.connection,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof McpOAuthAuthorizationCancelledError)) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation(
|
||||
wasDisabled,
|
||||
error.reason,
|
||||
)
|
||||
) {
|
||||
setMcpServerDisabled({
|
||||
filePath: settingsPath,
|
||||
name,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
setMcpServerDisabled({ filePath: settingsPath, name, disabled: false });
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "cancel_mcp_server_oauth") {
|
||||
const name = String(args?.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
cancelMcpOAuthAuthorizationForReason(name, "user");
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
const name = String(args?.name ?? "").trim();
|
||||
const disabled = Boolean(args?.disabled);
|
||||
const path = ensureMcpSettingsFile();
|
||||
if (disabled) {
|
||||
cancelMcpOAuthAuthorizationForReason(name, "server-disabled");
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: true });
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
const registration = resolveMcpServerRegistration(name, { filePath: path });
|
||||
if (!registration) {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
if (registration.transport.type !== "stdio") {
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: true });
|
||||
const probe = await probeMcpServerConnection({
|
||||
serverName: name,
|
||||
filePath: path,
|
||||
});
|
||||
if (!probe.connected) {
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
}
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: false });
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "upsert_mcp_server") {
|
||||
@@ -1635,6 +1636,8 @@ export async function handleCommand(
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const requestedDisabled = Boolean(input.disabled);
|
||||
const isRemote = transportType !== "stdio";
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
@@ -1645,7 +1648,7 @@ export async function handleCommand(
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: Boolean(input.disabled),
|
||||
disabled: requestedDisabled,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
: {
|
||||
@@ -1654,40 +1657,78 @@ export async function handleCommand(
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: Boolean(input.disabled),
|
||||
disabled: requestedDisabled,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
const path = ensureMcpSettingsFile();
|
||||
updateMcpSettingsFileSync(path, (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
// Preserve machine-managed fields the editor dialog doesn't expose:
|
||||
// oauth tokens for remote servers and plugin-ownership metadata.
|
||||
const sourceName =
|
||||
previousName && servers[previousName] ? previousName : name;
|
||||
const existing = servers[sourceName];
|
||||
const upserted = { ...next };
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
if (upserted.metadata === undefined && record.metadata !== undefined) {
|
||||
upserted.metadata = record.metadata;
|
||||
const { shouldProbeAfterSave } = updateMcpSettingsFileSync(
|
||||
path,
|
||||
(settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
// Preserve machine-managed fields the editor dialog doesn't expose:
|
||||
// oauth tokens for remote servers and plugin-ownership metadata.
|
||||
const existingName =
|
||||
previousName && servers[previousName] ? previousName : name;
|
||||
const existing = servers[existingName];
|
||||
let existingTransportIdentity: string | undefined;
|
||||
let existingWasEnabled = false;
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
existingTransportIdentity = mcpTransportIdentity(
|
||||
existingName,
|
||||
record,
|
||||
);
|
||||
existingWasEnabled = record.disabled !== true;
|
||||
}
|
||||
// OAuth tokens were issued for a specific endpoint; carrying them
|
||||
// onto an edited transport or URL would send the old server's
|
||||
// credentials to a different endpoint.
|
||||
if (
|
||||
record.oauth !== undefined &&
|
||||
mcpTransportIdentity(record) === mcpTransportIdentity(upserted)
|
||||
) {
|
||||
upserted.oauth = record.oauth;
|
||||
const nextTransportIdentity = mcpTransportIdentity(name, next);
|
||||
const transportIdentityUnchanged =
|
||||
existingTransportIdentity === nextTransportIdentity;
|
||||
const shouldProbe = shouldProbeMcpServerAfterUpsert({
|
||||
isRemote,
|
||||
requestedDisabled,
|
||||
existingWasEnabled,
|
||||
transportIdentityUnchanged,
|
||||
});
|
||||
const upserted: JsonRecord = {
|
||||
...next,
|
||||
disabled: requestedDisabled || shouldProbe,
|
||||
};
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as JsonRecord;
|
||||
if (
|
||||
upserted.metadata === undefined &&
|
||||
record.metadata !== undefined
|
||||
) {
|
||||
upserted.metadata = record.metadata;
|
||||
}
|
||||
// OAuth tokens were issued for a specific endpoint; carrying them
|
||||
// onto an edited transport or URL would send the old server's
|
||||
// credentials to a different endpoint.
|
||||
if (record.oauth !== undefined && transportIdentityUnchanged) {
|
||||
upserted.oauth = record.oauth;
|
||||
}
|
||||
if (record.oauthClient !== undefined && transportIdentityUnchanged) {
|
||||
upserted.oauthClient = record.oauthClient;
|
||||
}
|
||||
}
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = upserted;
|
||||
settings.mcpServers = servers;
|
||||
return { shouldProbeAfterSave: shouldProbe };
|
||||
},
|
||||
);
|
||||
if (shouldProbeAfterSave) {
|
||||
const probe = await probeMcpServerConnection({
|
||||
serverName: name,
|
||||
filePath: path,
|
||||
});
|
||||
if (probe.connected) {
|
||||
setMcpServerDisabled({ filePath: path, name, disabled: false });
|
||||
}
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = upserted;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "delete_mcp_server") {
|
||||
@@ -1750,12 +1791,12 @@ export async function handleCommand(
|
||||
|
||||
// ── User instruction configs ──────────────────────────────────────
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
return await listUserInstructionConfigs(ctx);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(ctx.workspaceRoot),
|
||||
await listUserInstructionConfigs(ctx),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
@@ -1777,8 +1818,11 @@ export async function handleCommand(
|
||||
if (!toolName) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
toggleDisabledTool(toolName);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
const snapshot = await toggleHubSetting(ctx, {
|
||||
type: "tools",
|
||||
name: toolName,
|
||||
});
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
if (command === "set_tool_disabled") {
|
||||
const rawNames = Array.isArray(args?.names) ? args.names : [args?.name];
|
||||
@@ -1788,26 +1832,45 @@ export async function handleCommand(
|
||||
if (toolNames.length === 0) {
|
||||
throw new Error("tool name is required");
|
||||
}
|
||||
setDisabledTools(toolNames, args?.disabled === true);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
let snapshot: CoreSettingsSnapshot | undefined;
|
||||
for (const name of toolNames) {
|
||||
snapshot = await toggleHubSetting(ctx, {
|
||||
type: "tools",
|
||||
name,
|
||||
enabled: args?.disabled !== true,
|
||||
});
|
||||
}
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
if (command === "set_plugin_disabled") {
|
||||
const pluginPath = String(args?.path ?? "").trim();
|
||||
if (!pluginPath) {
|
||||
throw new Error("plugin path is required");
|
||||
}
|
||||
setDisabledPlugin(pluginPath, args?.disabled === true);
|
||||
return await listUserInstructionConfigs(ctx.workspaceRoot);
|
||||
const snapshot = await toggleHubSetting(ctx, {
|
||||
type: "plugins",
|
||||
path: pluginPath,
|
||||
enabled: args?.disabled !== true,
|
||||
});
|
||||
return await listUserInstructionConfigs(ctx, snapshot);
|
||||
}
|
||||
|
||||
// ── Native OS commands ────────────────────────────────────────────
|
||||
if (command === "validate_workspace_directory") {
|
||||
const workspacePath = String(args?.path ?? "").trim();
|
||||
if (!workspacePath) return { valid: false };
|
||||
// Support typed/pasted paths like "~/projects/app" from the manual
|
||||
// path-entry fallback; return the resolved path so the caller adopts it.
|
||||
const resolved =
|
||||
workspacePath === "~"
|
||||
? homedir()
|
||||
: workspacePath.startsWith("~/") || workspacePath.startsWith("~\\")
|
||||
? join(homedir(), workspacePath.slice(2))
|
||||
: workspacePath;
|
||||
try {
|
||||
return { valid: statSync(workspacePath).isDirectory() };
|
||||
return { valid: statSync(resolved).isDirectory(), path: resolved };
|
||||
} catch {
|
||||
return { valid: false };
|
||||
return { valid: false, path: resolved };
|
||||
}
|
||||
}
|
||||
if (command === "pick_workspace_directory") {
|
||||
|
||||
@@ -222,6 +222,84 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("announces a queued prompt start once when drain emits both queue events", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
);
|
||||
let onEvent: ((event: unknown) => void) | undefined;
|
||||
createCoreMock.mockResolvedValue({
|
||||
runtimeAddress: "ws://127.0.0.1:25463/hub",
|
||||
subscribe: vi.fn((handler: (event: unknown) => void) => {
|
||||
onEvent = handler;
|
||||
return () => {};
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
});
|
||||
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
await initializeSessionManager(ctx);
|
||||
const session = {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [
|
||||
{
|
||||
id: "prompt-1",
|
||||
prompt: "hi there",
|
||||
steer: false,
|
||||
attachmentCount: 0,
|
||||
},
|
||||
],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
} satisfies LiveSession;
|
||||
ctx.liveSessions.set("session-1", session);
|
||||
|
||||
// PendingPromptService.drain() emits both events for the same prompt:
|
||||
// a queue snapshot with the head removed, then the submitted event.
|
||||
onEvent?.({
|
||||
type: "pending_prompts",
|
||||
payload: { sessionId: "session-1", prompts: [] },
|
||||
});
|
||||
onEvent?.({
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
id: "prompt-1",
|
||||
prompt: "hi there",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const starts = readEvents(ctx).filter(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_queued_prompt_start",
|
||||
);
|
||||
expect(starts).toHaveLength(1);
|
||||
|
||||
// A different prompt id must still be announced.
|
||||
onEvent?.({
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
id: "prompt-2",
|
||||
prompt: "second",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
readEvents(ctx).filter(
|
||||
(message) =>
|
||||
message.event.name === "chat_event" &&
|
||||
(message.event.payload as { stream?: string }).stream ===
|
||||
"chat_queued_prompt_start",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("resolves askQuestion through the websocket request/response protocol", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
|
||||
@@ -45,11 +45,15 @@ function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
const encoded = JSON.stringify({
|
||||
export function encodeSidecarEvent(name: string, payload: unknown): string {
|
||||
return JSON.stringify({
|
||||
type: "event",
|
||||
event: { name, payload },
|
||||
});
|
||||
}
|
||||
|
||||
function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
|
||||
const encoded = encodeSidecarEvent(name, payload);
|
||||
for (const client of ctx.wsClients) {
|
||||
try {
|
||||
client.send(encoded);
|
||||
@@ -325,6 +329,35 @@ function handleAgentEvent(
|
||||
// CoreSessionEvent routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The runtime's queue drain emits a pending_prompts snapshot (head removed)
|
||||
// and a pending_prompt_submitted event for the same prompt back-to-back, and
|
||||
// both are translated here into chat_queued_prompt_start — dedupe by prompt
|
||||
// id or the UI renders the user message twice.
|
||||
function emitQueuedPromptStart(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
session: LiveSession | undefined,
|
||||
input: {
|
||||
promptId: string;
|
||||
prompt: string;
|
||||
attachmentCount: number;
|
||||
userImages?: string[];
|
||||
},
|
||||
): void {
|
||||
if (session) {
|
||||
if (session.lastQueuedPromptStartId === input.promptId) {
|
||||
return;
|
||||
}
|
||||
session.lastQueuedPromptStartId = input.promptId;
|
||||
}
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
serializeQueuedPromptStart(input),
|
||||
);
|
||||
}
|
||||
|
||||
function handleCoreSessionEvent(
|
||||
ctx: SidecarContext,
|
||||
event: CoreSessionEvent,
|
||||
@@ -367,17 +400,12 @@ function handleCoreSessionEvent(
|
||||
previous[0] &&
|
||||
previous[0].id !== mapped[0]?.id
|
||||
) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
serializeQueuedPromptStart({
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
}),
|
||||
);
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId: previous[0].id,
|
||||
prompt: previous[0].prompt,
|
||||
attachmentCount: previous[0].attachmentCount ?? 0,
|
||||
userImages: previous[0].userImages,
|
||||
});
|
||||
}
|
||||
}
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
@@ -386,18 +414,26 @@ function handleCoreSessionEvent(
|
||||
case "pending_prompt_submitted": {
|
||||
const { sessionId, id, prompt, attachmentCount, userImages } =
|
||||
event.payload;
|
||||
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_queued_prompt_start",
|
||||
serializeQueuedPromptStart({
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
}),
|
||||
);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
markQueuedAttachmentsSubmitted(session, id);
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId: id,
|
||||
prompt,
|
||||
attachmentCount: attachmentCount ?? 0,
|
||||
userImages,
|
||||
});
|
||||
// The prompt left the queue; without a fresh snapshot the webview
|
||||
// keeps a stale busy queue and the composer never returns to idle
|
||||
// after the turn completes.
|
||||
if (session) {
|
||||
const remaining = session.promptsInQueue.filter(
|
||||
(item) => item.id !== id,
|
||||
);
|
||||
if (remaining.length !== session.promptsInQueue.length) {
|
||||
session.promptsInQueue = remaining;
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ended": {
|
||||
@@ -467,6 +503,7 @@ export function createSidecarContext(
|
||||
logger: observability.logger,
|
||||
telemetry: observability.telemetry,
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ import { homedir } from "node:os";
|
||||
import {
|
||||
createClineTelemetryServiceConfig,
|
||||
setHomeDirIfUnset,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
broadcastEvent,
|
||||
createSidecarContext,
|
||||
disposeSidecarContext,
|
||||
initializeSessionManager,
|
||||
@@ -132,6 +134,21 @@ async function main() {
|
||||
mode: SIDECAR_MODE,
|
||||
});
|
||||
|
||||
// Another Cline installation (e.g. an updated CLI) can replace the shared
|
||||
// Hub daemon while this app is running. Surface that to the webview so it
|
||||
// can prompt the user to update and restart.
|
||||
watchManagedHubBuildMismatch({
|
||||
onMismatch: (mismatch) => {
|
||||
ctx.hubBuildMismatch = mismatch;
|
||||
observability.logger.log("Managed hub build mismatch detected", {
|
||||
hubBuildId: mismatch.hubBuildId,
|
||||
hubCoreVersion: mismatch.hubCoreVersion,
|
||||
reason: mismatch.reason,
|
||||
});
|
||||
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
|
||||
},
|
||||
});
|
||||
|
||||
// A wildcard bind isn't a dialable address; advertise loopback instead.
|
||||
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
|
||||
const endpoint = `http://${dialHost}:${port}`;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOfficialPluginInstallPath,
|
||||
installMarketplaceEntry,
|
||||
listMarketplaceInstalledEntries,
|
||||
} from "./marketplace";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
const GOAL_ENTRY = {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
};
|
||||
|
||||
let tempClineDir: string;
|
||||
let previousClineDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempClineDir = await mkdtemp(join(tmpdir(), "desktop-marketplace-"));
|
||||
previousClineDir = process.env.CLINE_DIR;
|
||||
process.env.CLINE_DIR = tempClineDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = previousClineDir;
|
||||
}
|
||||
await rm(tempClineDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function goalInstallDir(): string {
|
||||
const path = getOfficialPluginInstallPath("goal");
|
||||
if (!path) {
|
||||
throw new Error("expected an official install path for goal");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("official plugin install detection", () => {
|
||||
it("does not treat a leftover empty install directory as installed", async () => {
|
||||
// Regression: a failed or interrupted install can leave the directory
|
||||
// behind with nothing in it. The next install attempt then returned
|
||||
// "already installed" without running the CLI, so the UI flipped the
|
||||
// entry to Uninstall with no error while nothing actually worked.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: "install exploded",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand }),
|
||||
).rejects.toThrow(/Plugin install failed/);
|
||||
expect(spawnCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes --force so a retry can reclaim the leftover directory", async () => {
|
||||
// Without --force the CLI refuses to replace the existing path
|
||||
// ("Plugin is already installed at ... Use --force to replace it."),
|
||||
// so every retry from the UI would fail against the stale directory.
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Goal.",
|
||||
});
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).toContain("--force");
|
||||
});
|
||||
|
||||
it("does not pass --force for a clean first install", async () => {
|
||||
const spawnCommand = vi.fn(async (_command: string, _args: string[]) => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry({ entry: GOAL_ENTRY }, { spawnCommand });
|
||||
|
||||
expect(spawnCommand.mock.calls[0]?.[1]).not.toContain("--force");
|
||||
});
|
||||
|
||||
it("still short-circuits when the directory contains a plugin module", async () => {
|
||||
const installDir = goalInstallDir();
|
||||
await mkdir(join(installDir, "package"), { recursive: true });
|
||||
await writeFile(
|
||||
join(installDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "goal",
|
||||
private: true,
|
||||
cline: { plugins: [{ paths: ["./package/index.ts"] }] },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
const result = await installMarketplaceEntry(
|
||||
{ entry: GOAL_ENTRY },
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "installed",
|
||||
message: "Goal is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes partial install directories from the installed entries list", async () => {
|
||||
await mkdir(goalInstallDir(), { recursive: true });
|
||||
|
||||
const empty = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
|
||||
plugins: [],
|
||||
} as JsonRecord);
|
||||
expect(empty.installedKeys).toEqual([]);
|
||||
|
||||
const installDir = goalInstallDir();
|
||||
await mkdir(join(installDir, "package"), { recursive: true });
|
||||
await writeFile(
|
||||
join(installDir, "package", "index.ts"),
|
||||
"export default {};",
|
||||
);
|
||||
const populated = listMarketplaceInstalledEntries(
|
||||
{ entries: [GOAL_ENTRY] },
|
||||
{ plugins: [] } as JsonRecord,
|
||||
);
|
||||
expect(populated.installedKeys).toEqual(["plugin:goal"]);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import {
|
||||
discoverPluginModulePaths,
|
||||
resolveClineDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
@@ -584,7 +587,9 @@ function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
export function getOfficialPluginInstallPath(
|
||||
source: string,
|
||||
): string | undefined {
|
||||
const slug = source.trim();
|
||||
if (!isOfficialPluginSlug(slug)) return undefined;
|
||||
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
|
||||
@@ -597,12 +602,33 @@ function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "plugin") return false;
|
||||
type OfficialPluginInstallState = "installed" | "partial" | "missing";
|
||||
|
||||
function getOfficialPluginInstallState(
|
||||
entry: MarketplaceInstallInput,
|
||||
): OfficialPluginInstallState {
|
||||
if (entry.type !== "plugin") return "missing";
|
||||
const [source] = entry.install.args ?? [];
|
||||
if (!source) return false;
|
||||
if (!source) return "missing";
|
||||
const installPath = getOfficialPluginInstallPath(source);
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
if (!installPath || !existsSync(installPath)) return "missing";
|
||||
// A bare directory is not an install: a failed or interrupted install can
|
||||
// leave the directory behind with no plugin inside, and treating that as
|
||||
// installed makes the next install attempt "succeed" silently ("already
|
||||
// installed") while nothing actually works. Require a loadable plugin
|
||||
// module before reporting the entry as installed; a directory without one
|
||||
// is "partial" so the installer can reclaim it with --force.
|
||||
try {
|
||||
return discoverPluginModulePaths(installPath).length > 0
|
||||
? "installed"
|
||||
: "partial";
|
||||
} catch {
|
||||
return "partial";
|
||||
}
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return getOfficialPluginInstallState(entry) === "installed";
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
@@ -805,7 +831,8 @@ async function installPlugin(
|
||||
"Plugin marketplace installs currently support exactly one source argument.",
|
||||
);
|
||||
}
|
||||
if (isOfficialPluginInstalled(entry)) {
|
||||
const installState = getOfficialPluginInstallState(entry);
|
||||
if (installState === "installed") {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
@@ -819,6 +846,12 @@ async function installPlugin(
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
// Reclaim a leftover directory from a failed or interrupted install:
|
||||
// without --force the CLI refuses to replace the existing path and
|
||||
// every retry from the UI would fail the same way. This is safe
|
||||
// because the state check just confirmed the directory contains no
|
||||
// loadable plugin module.
|
||||
...(installState === "partial" ? ["--force"] : []),
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
AuthorizeMcpServerOAuthOptions,
|
||||
AuthorizeMcpServerOAuthResult,
|
||||
} from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cancelMcpOAuthAuthorization,
|
||||
cancelMcpOAuthAuthorizationForReason,
|
||||
cancelMcpOAuthAuthorizationsForOwner,
|
||||
McpOAuthAuthorizationCancelledError,
|
||||
runCancellableMcpOAuthAuthorization,
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation,
|
||||
} from "./mcp-oauth";
|
||||
|
||||
function waitForAbort(
|
||||
options: AuthorizeMcpServerOAuthOptions,
|
||||
): Promise<AuthorizeMcpServerOAuthResult> {
|
||||
return new Promise((_, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("desktop MCP OAuth authorization", () => {
|
||||
it("starts browser authorization only through the explicit runner", async () => {
|
||||
const authorize = vi.fn(
|
||||
async (
|
||||
options: AuthorizeMcpServerOAuthOptions,
|
||||
): Promise<AuthorizeMcpServerOAuthResult> => ({
|
||||
serverName: options.serverName,
|
||||
authorized: true,
|
||||
message: "authorized",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runCancellableMcpOAuthAuthorization({ serverName: "github" }, undefined, {
|
||||
authorize,
|
||||
}),
|
||||
).resolves.toMatchObject({ authorized: true });
|
||||
expect(authorize).toHaveBeenCalledOnce();
|
||||
expect(authorize.mock.calls[0]?.[0].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("cancels a pending callback when the server is turned off", async () => {
|
||||
const pending = runCancellableMcpOAuthAuthorization(
|
||||
{ serverName: "github" },
|
||||
undefined,
|
||||
{ authorize: waitForAbort },
|
||||
);
|
||||
|
||||
expect(
|
||||
cancelMcpOAuthAuthorizationForReason("github", "server-disabled"),
|
||||
).toBe(true);
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
name: "McpOAuthAuthorizationCancelledError",
|
||||
reason: "server-disabled",
|
||||
});
|
||||
expect(cancelMcpOAuthAuthorization("github")).toBe(false);
|
||||
});
|
||||
|
||||
it("restores a previously enabled server only for an explicit user cancel", () => {
|
||||
expect(shouldRestoreEnabledStateAfterOAuthCancellation(false, "user")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldRestoreEnabledStateAfterOAuthCancellation(false, "server-disabled"),
|
||||
).toBe(false);
|
||||
expect(shouldRestoreEnabledStateAfterOAuthCancellation(true, "user")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an abandoned flow when its webview connection closes", async () => {
|
||||
const owner = {};
|
||||
const pending = runCancellableMcpOAuthAuthorization(
|
||||
{ serverName: "linear" },
|
||||
owner,
|
||||
{ authorize: waitForAbort },
|
||||
);
|
||||
|
||||
expect(cancelMcpOAuthAuthorizationsForOwner({})).toBe(0);
|
||||
expect(cancelMcpOAuthAuthorizationsForOwner(owner)).toBe(1);
|
||||
await expect(pending).rejects.toBeInstanceOf(
|
||||
McpOAuthAuthorizationCancelledError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
type AuthorizeMcpServerOAuthOptions,
|
||||
type AuthorizeMcpServerOAuthResult,
|
||||
authorizeMcpServerOAuth,
|
||||
} from "@cline/core";
|
||||
|
||||
export type McpOAuthCancellationReason =
|
||||
| "user"
|
||||
| "server-disabled"
|
||||
| "superseded"
|
||||
| "owner-closed";
|
||||
|
||||
export function shouldRestoreEnabledStateAfterOAuthCancellation(
|
||||
wasDisabled: boolean,
|
||||
reason: McpOAuthCancellationReason,
|
||||
): boolean {
|
||||
return !wasDisabled && reason === "user";
|
||||
}
|
||||
|
||||
type PendingMcpOAuthAuthorization = {
|
||||
controller: AbortController;
|
||||
owner?: object;
|
||||
cancellationReason?: McpOAuthCancellationReason;
|
||||
};
|
||||
|
||||
const pendingAuthorizations = new Map<string, PendingMcpOAuthAuthorization>();
|
||||
|
||||
export class McpOAuthAuthorizationCancelledError extends Error {
|
||||
constructor(
|
||||
serverName: string,
|
||||
readonly reason: McpOAuthCancellationReason,
|
||||
) {
|
||||
super(`MCP OAuth authorization was cancelled for "${serverName}".`);
|
||||
this.name = "McpOAuthAuthorizationCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export type McpOAuthAuthorizationDependencies = {
|
||||
authorize: (
|
||||
options: AuthorizeMcpServerOAuthOptions,
|
||||
) => Promise<AuthorizeMcpServerOAuthResult>;
|
||||
};
|
||||
|
||||
const defaultDependencies: McpOAuthAuthorizationDependencies = {
|
||||
authorize: authorizeMcpServerOAuth,
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the lifetime of a desktop MCP browser authorization. Only an explicit
|
||||
* call to this function can open the browser; ordinary server enable/connect
|
||||
* attempts only persist the SDK's `authorizationRequired` status.
|
||||
*/
|
||||
export async function runCancellableMcpOAuthAuthorization(
|
||||
options: Omit<AuthorizeMcpServerOAuthOptions, "signal">,
|
||||
owner?: object,
|
||||
dependencies: McpOAuthAuthorizationDependencies = defaultDependencies,
|
||||
): Promise<AuthorizeMcpServerOAuthResult> {
|
||||
const serverName = options.serverName.trim();
|
||||
if (!serverName) {
|
||||
throw new Error("MCP server name cannot be empty.");
|
||||
}
|
||||
|
||||
cancelMcpOAuthAuthorizationForReason(serverName, "superseded");
|
||||
const entry: PendingMcpOAuthAuthorization = {
|
||||
controller: new AbortController(),
|
||||
owner,
|
||||
};
|
||||
pendingAuthorizations.set(serverName, entry);
|
||||
|
||||
try {
|
||||
try {
|
||||
return await dependencies.authorize({
|
||||
...options,
|
||||
serverName,
|
||||
signal: entry.controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (entry.controller.signal.aborted) {
|
||||
throw new McpOAuthAuthorizationCancelledError(
|
||||
serverName,
|
||||
entry.cancellationReason ?? "superseded",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
if (pendingAuthorizations.get(serverName) === entry) {
|
||||
pendingAuthorizations.delete(serverName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels the pending browser callback flow for one MCP server, if present. */
|
||||
export function cancelMcpOAuthAuthorization(serverName: string): boolean {
|
||||
return cancelMcpOAuthAuthorizationForReason(serverName, "user");
|
||||
}
|
||||
|
||||
export function cancelMcpOAuthAuthorizationForReason(
|
||||
serverName: string,
|
||||
reason: McpOAuthCancellationReason,
|
||||
): boolean {
|
||||
const entry = pendingAuthorizations.get(serverName);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
entry.cancellationReason = reason;
|
||||
entry.controller.abort();
|
||||
pendingAuthorizations.delete(serverName);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Cancels MCP OAuth flows started by a webview connection that went away. */
|
||||
export function cancelMcpOAuthAuthorizationsForOwner(owner: object): number {
|
||||
let cancelled = 0;
|
||||
for (const [serverName, entry] of pendingAuthorizations) {
|
||||
if (entry.owner === owner) {
|
||||
entry.cancellationReason = "owner-closed";
|
||||
entry.controller.abort();
|
||||
pendingAuthorizations.delete(serverName);
|
||||
cancelled += 1;
|
||||
}
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { handleCommand } from "./commands";
|
||||
import {
|
||||
buildMcpServersResponse,
|
||||
shouldProbeMcpServerAfterUpsert,
|
||||
} from "./mcp";
|
||||
import type { JsonRecord, SidecarContext } from "./types";
|
||||
|
||||
function createContext(workspaceRoot: string): SidecarContext {
|
||||
return {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set(),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("desktop MCP settings", () => {
|
||||
it("keeps valid and malformed entries visible independently", () => {
|
||||
const response = buildMcpServersResponse("/tmp/cline_mcp_settings.json", {
|
||||
mcpServers: {
|
||||
linear: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", "https://mcp.linear.app/mcp"],
|
||||
disabled: true,
|
||||
oauth: {
|
||||
authorizationRequired: true,
|
||||
lastError: "OAuth authorization required",
|
||||
},
|
||||
},
|
||||
broken: {},
|
||||
},
|
||||
});
|
||||
const servers = response.servers as JsonRecord[];
|
||||
|
||||
expect(servers).toHaveLength(2);
|
||||
expect(servers[0]).toMatchObject({
|
||||
name: "linear",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
disabled: true,
|
||||
oauthStatus: {
|
||||
authorizationRequired: true,
|
||||
lastError: "OAuth authorization required",
|
||||
},
|
||||
});
|
||||
expect(servers[1]).toMatchObject({
|
||||
name: "broken",
|
||||
transportType: "stdio",
|
||||
});
|
||||
expect(String(servers[1]?.configurationError)).toContain(
|
||||
'Invalid MCP server "broken"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not probe an unchanged enabled remote server after editing", () => {
|
||||
expect(
|
||||
shouldProbeMcpServerAfterUpsert({
|
||||
isRemote: true,
|
||||
requestedDisabled: false,
|
||||
existingWasEnabled: true,
|
||||
transportIdentityUnchanged: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldProbeMcpServerAfterUpsert({
|
||||
isRemote: true,
|
||||
requestedDisabled: false,
|
||||
existingWasEnabled: true,
|
||||
transportIdentityUnchanged: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an unchanged enabled remote server enabled when saving metadata", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "desktop-mcp-settings-"));
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
try {
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
linear: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "http://127.0.0.1:1/mcp",
|
||||
},
|
||||
},
|
||||
broken: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const response = (await handleCommand(
|
||||
createContext(tempRoot),
|
||||
"upsert_mcp_server",
|
||||
{
|
||||
input: {
|
||||
name: "linear",
|
||||
previousName: "linear",
|
||||
transportType: "streamableHttp",
|
||||
url: "http://127.0.0.1:1/mcp",
|
||||
disabled: false,
|
||||
metadata: { source: "edited" },
|
||||
},
|
||||
},
|
||||
)) as JsonRecord;
|
||||
const servers = response.servers as JsonRecord[];
|
||||
expect(servers.find((server) => server.name === "linear")).toMatchObject({
|
||||
disabled: false,
|
||||
metadata: { source: "edited" },
|
||||
});
|
||||
expect(servers.find((server) => server.name === "broken")).toHaveProperty(
|
||||
"configurationError",
|
||||
);
|
||||
|
||||
const written = JSON.parse(await readFile(settingsPath, "utf8"));
|
||||
expect(written.mcpServers.linear.disabled).toBe(false);
|
||||
} finally {
|
||||
if (previousSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
|
||||
}
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,148 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import {
|
||||
getMcpServerOAuthStatus,
|
||||
parseMcpServerRegistration,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function shouldProbeMcpServerAfterUpsert(options: {
|
||||
isRemote: boolean;
|
||||
requestedDisabled: boolean;
|
||||
existingWasEnabled: boolean;
|
||||
transportIdentityUnchanged: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
options.isRemote &&
|
||||
!options.requestedDisabled &&
|
||||
!(options.existingWasEnabled && options.transportIdentityUnchanged)
|
||||
);
|
||||
}
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
return buildMcpServersResponse(settingsPath, parsed);
|
||||
}
|
||||
|
||||
export function buildMcpServersResponse(
|
||||
settingsPath: string,
|
||||
parsed: JsonRecord,
|
||||
): JsonRecord {
|
||||
const serversValue = parsed.mcpServers;
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? (serversValue as JsonRecord)
|
||||
: {};
|
||||
const entries = Object.entries(servers).map(([name, body]) => {
|
||||
const record =
|
||||
body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as JsonRecord)
|
||||
: {};
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
let registration: ReturnType<typeof parseMcpServerRegistration> | undefined;
|
||||
let configurationError: string | undefined;
|
||||
try {
|
||||
registration = parseMcpServerRegistration(name, body);
|
||||
} catch (error) {
|
||||
configurationError =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const resolvedTransport = registration?.transport;
|
||||
const rawTransportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "",
|
||||
).trim();
|
||||
const hasRawUrl =
|
||||
typeof transport?.url === "string" || typeof record.url === "string";
|
||||
const fallbackTransportType =
|
||||
rawTransportType === "sse"
|
||||
? "sse"
|
||||
: rawTransportType === "streamableHttp" || rawTransportType === "http"
|
||||
? "streamableHttp"
|
||||
: hasRawUrl
|
||||
? "sse"
|
||||
: "stdio";
|
||||
const oauthStatus = registration
|
||||
? getMcpServerOAuthStatus(registration)
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
transportType: resolvedTransport?.type ?? fallbackTransportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
command: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.command
|
||||
: undefined
|
||||
: typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
args: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.args
|
||||
: undefined
|
||||
: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.cwd
|
||||
: undefined
|
||||
: typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
env: resolvedTransport
|
||||
? resolvedTransport.type === "stdio"
|
||||
? resolvedTransport.env
|
||||
: undefined
|
||||
: transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
url: resolvedTransport
|
||||
? resolvedTransport.type !== "stdio"
|
||||
? resolvedTransport.url
|
||||
: undefined
|
||||
: typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
headers: resolvedTransport
|
||||
? resolvedTransport.type !== "stdio"
|
||||
? resolvedTransport.headers
|
||||
: undefined
|
||||
: transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
metadata: registration?.metadata ?? record.metadata,
|
||||
...(configurationError ? { configurationError } : {}),
|
||||
oauthStatus: oauthStatus
|
||||
? {
|
||||
supported: oauthStatus.oauthSupported,
|
||||
configured: oauthStatus.oauthConfigured,
|
||||
authorizationRequired: oauthStatus.authorizationRequired,
|
||||
lastError: oauthStatus.lastError,
|
||||
lastAuthenticatedAt: oauthStatus.lastAuthenticatedAt,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
|
||||
@@ -129,6 +129,91 @@ describe("desktop error telemetry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards bounded source attribution for uncaught webview errors", async () => {
|
||||
const server = createTestServer();
|
||||
const { handler, capture } = createTelemetryHandler();
|
||||
const response = await handler(
|
||||
new Request("http://127.0.0.1:3126/telemetry/error", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "webview.uncaught_error",
|
||||
errorMessage: "Unexpected token '<'",
|
||||
errorType: "SyntaxError",
|
||||
handled: false,
|
||||
transportState: "connecting",
|
||||
sourceUrl: `tauri://localhost/_vercel/insights/script.js?${"q".repeat(600)}`,
|
||||
lineno: 1,
|
||||
colno: 1,
|
||||
stack: `SyntaxError: Unexpected token '<'\n${"x".repeat(600)}`,
|
||||
}),
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(202);
|
||||
expect(capture).toHaveBeenCalledWith({
|
||||
event: "sdk.error",
|
||||
properties: expect.objectContaining({
|
||||
component: "desktop",
|
||||
operation: "webview.uncaught_error",
|
||||
error_type: "SyntaxError",
|
||||
error_message: "Unexpected token '<'",
|
||||
handled: false,
|
||||
transportState: "connecting",
|
||||
lineno: 1,
|
||||
colno: 1,
|
||||
}),
|
||||
});
|
||||
const properties = capture.mock.calls[0]?.[0]?.properties as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(properties.sourceUrl).toHaveLength(500);
|
||||
expect(
|
||||
String(properties.sourceUrl).startsWith(
|
||||
"tauri://localhost/_vercel/insights/script.js",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(properties.stack).toHaveLength(500);
|
||||
});
|
||||
|
||||
it("drops malformed source attribution fields", async () => {
|
||||
const server = createTestServer();
|
||||
const { handler, capture } = createTelemetryHandler();
|
||||
const response = await handler(
|
||||
new Request("http://127.0.0.1:3126/telemetry/error", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
origin: "tauri://localhost",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: "webview.uncaught_error",
|
||||
errorMessage: "boom",
|
||||
sourceUrl: 42,
|
||||
lineno: "one",
|
||||
colno: null,
|
||||
stack: " ",
|
||||
}),
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(202);
|
||||
const properties = capture.mock.calls[0]?.[0]?.properties as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect("sourceUrl" in properties).toBe(false);
|
||||
expect("lineno" in properties).toBe(false);
|
||||
expect("colno" in properties).toBe(false);
|
||||
expect("stack" in properties).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects error reports from untrusted origins", async () => {
|
||||
const server = createTestServer();
|
||||
const { handler, capture } = createTelemetryHandler();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { captureSdkError } from "@cline/shared";
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { handleCommand } from "./commands";
|
||||
import { sendEvent } from "./context";
|
||||
import { encodeSidecarEvent, sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
import {
|
||||
BunRuntime,
|
||||
@@ -113,8 +114,16 @@ type DesktopClientErrorReport = {
|
||||
command?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
transportState?: unknown;
|
||||
sourceUrl?: unknown;
|
||||
lineno?: unknown;
|
||||
colno?: unknown;
|
||||
stack?: unknown;
|
||||
};
|
||||
|
||||
// Bound for free-form attribution strings (source URLs, stack traces);
|
||||
// matches ERROR_REPORT_FIELD_LIMIT in webview/lib/desktop-client.ts.
|
||||
const ERROR_REPORT_FIELD_LIMIT = 500;
|
||||
|
||||
function captureDesktopError(
|
||||
ctx: SidecarContext,
|
||||
operation: string,
|
||||
@@ -254,6 +263,24 @@ export function createFetchHandler(
|
||||
if (typeof report.transportState === "string") {
|
||||
context.transportState = report.transportState.slice(0, 30);
|
||||
}
|
||||
if (typeof report.sourceUrl === "string" && report.sourceUrl.trim()) {
|
||||
context.sourceUrl = report.sourceUrl.slice(
|
||||
0,
|
||||
ERROR_REPORT_FIELD_LIMIT,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof report.lineno === "number" &&
|
||||
Number.isFinite(report.lineno)
|
||||
) {
|
||||
context.lineno = report.lineno;
|
||||
}
|
||||
if (typeof report.colno === "number" && Number.isFinite(report.colno)) {
|
||||
context.colno = report.colno;
|
||||
}
|
||||
if (typeof report.stack === "string" && report.stack.trim()) {
|
||||
context.stack = report.stack.slice(0, ERROR_REPORT_FIELD_LIMIT);
|
||||
}
|
||||
captureDesktopError(
|
||||
ctx,
|
||||
operation,
|
||||
@@ -300,6 +327,11 @@ function createWebSocketHandler(ctx: SidecarContext) {
|
||||
pid: process.pid,
|
||||
mode: SIDECAR_MODE,
|
||||
});
|
||||
// Replay a pending mismatch so webviews that connect (or reload)
|
||||
// after detection still prompt the user to update and restart.
|
||||
if (ctx.hubBuildMismatch) {
|
||||
ws.send(encodeSidecarEvent("hub_build_mismatch", ctx.hubBuildMismatch));
|
||||
}
|
||||
},
|
||||
async message(ws: SidecarWebSocketClient, raw: string) {
|
||||
let request: DesktopTransportRequest;
|
||||
@@ -331,10 +363,11 @@ function createWebSocketHandler(ctx: SidecarContext) {
|
||||
},
|
||||
close(ws: SidecarWebSocketClient) {
|
||||
ctx.wsClients.delete(ws);
|
||||
// OAuth logins are interactive: if the connection that started one
|
||||
// goes away (webview reload, transport drop), cancel it so the
|
||||
// abandoned browser flow can never persist credentials later.
|
||||
// Browser OAuth flows are interactive: if the connection that started
|
||||
// one goes away (webview reload, transport drop), cancel its callback
|
||||
// wait so the sidecar cannot retain an abandoned authorization attempt.
|
||||
cancelProviderOAuthLoginsForOwner(ws);
|
||||
cancelMcpOAuthAuthorizationsForOwner(ws);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { SqliteSessionStore } from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
/**
|
||||
* Child agents of a chat session: `spawn_agent` subagent runs and team-task
|
||||
@@ -31,14 +32,14 @@ export type SessionAgentRecord = {
|
||||
const TEAM_TASK_MARKER = "__teamtask__";
|
||||
const LAST_ACTION_LIMIT = 160;
|
||||
|
||||
function readMessagesFile(path: string): unknown[] | null {
|
||||
function readMessagesFile(path: string): MessageWithMetadata[] | null {
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
| { messages?: MessageWithMetadata[] }
|
||||
| MessageWithMetadata[];
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -64,7 +65,9 @@ function resolveChildMessagesPath(record: {
|
||||
* nothing has been written yet. Lets the ordinary session-reading path open a
|
||||
* subagent session without knowing where child artifacts live.
|
||||
*/
|
||||
export function readChildSessionMessages(sessionId: string): unknown[] | null {
|
||||
export function readChildSessionMessages(
|
||||
sessionId: string,
|
||||
): MessageWithMetadata[] | null {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,30 @@ import { describe, expect, it } from "vitest";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
it("continues past malformed persisted entries", async () => {
|
||||
const sessionId = `malformed-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [null, 42, { role: "assistant", content: "Still here" }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "Still here",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves each stored message timestamp across projected blocks", async () => {
|
||||
const sessionId = `timestamp-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_282;
|
||||
@@ -178,7 +202,7 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
{ liveSessions } as unknown as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
@@ -222,7 +246,7 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
{ liveSessions } as unknown as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
@@ -253,4 +277,115 @@ describe("readSessionMessages", () => {
|
||||
meta: { runCount: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("projects provider model activities through the ordinary tool payload", async () => {
|
||||
const sessionId = `provider-tool-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: { answer: "1.3.14" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as unknown as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(projected).toHaveLength(2);
|
||||
expect(projected[0]).toMatchObject({
|
||||
role: "tool",
|
||||
meta: {
|
||||
toolName: "web_search",
|
||||
hookEventName: "history_tool_result",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(String(projected[0]?.content))).toMatchObject({
|
||||
toolName: "web_search",
|
||||
input: { query: "latest Bun release" },
|
||||
result: '{"answer":"1.3.14"}',
|
||||
});
|
||||
expect(projected[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
});
|
||||
const sorted = [...projected].sort((left, right) => {
|
||||
const time = Number(left.createdAt) - Number(right.createdAt);
|
||||
return time || String(left.id).localeCompare(String(right.id));
|
||||
});
|
||||
expect(sorted.map((message) => message.role)).toEqual([
|
||||
"tool",
|
||||
"assistant",
|
||||
]);
|
||||
expect(Number(projected[0]?.createdAt)).toBeLessThan(
|
||||
Number(projected[1]?.createdAt),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the absolute run anchor for a truncated tool-only provider turn", async () => {
|
||||
const sessionId = `provider-tool-run-anchor-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "First prompt" },
|
||||
{ role: "assistant", content: "First response" },
|
||||
{ role: "user", content: "Second prompt" },
|
||||
{
|
||||
id: "tool-only-assistant",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-tool-only",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
1,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(projected).toHaveLength(1);
|
||||
expect(projected[0]).toMatchObject({
|
||||
role: "tool",
|
||||
meta: {
|
||||
runCount: 2,
|
||||
toolName: "web_search",
|
||||
hookEventName: "history_tool_result",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getUserRunSpan, resolveMessageDisplayRole } from "@cline/core";
|
||||
import { validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
getUserRunSpan,
|
||||
projectSessionMessagesForDisplay,
|
||||
resolveMessageDisplayRole,
|
||||
} from "@cline/core";
|
||||
import { type MessageWithMetadata, validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
readSessionManifest,
|
||||
sharedSessionMessagesPath,
|
||||
@@ -144,15 +148,17 @@ function extractImageBlock(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
export function readPersistedChatMessages(
|
||||
sessionId: string,
|
||||
): MessageWithMetadata[] | null {
|
||||
const path = sharedSessionMessagesPath(sessionId);
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
| { messages?: MessageWithMetadata[] }
|
||||
| MessageWithMetadata[];
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -336,6 +342,12 @@ export async function readSessionMessages(
|
||||
: (ctx.liveSessions.get(sessionId)?.messages ?? []);
|
||||
const max = Math.max(1, maxMessages);
|
||||
const start = Math.max(0, messages.length - max);
|
||||
const displayMessages = projectSessionMessagesForDisplay(
|
||||
messages.slice(start),
|
||||
).map((entry) => ({
|
||||
message: entry.message,
|
||||
sourceIndex: start + entry.sourceIndex,
|
||||
}));
|
||||
const baseTs = nowMs() - messages.length;
|
||||
const out: JsonRecord[] = [];
|
||||
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
|
||||
@@ -346,7 +358,7 @@ export async function readSessionMessages(
|
||||
if (!rawMessage || typeof rawMessage !== "object") {
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const message = rawMessage as unknown as JsonRecord;
|
||||
const metadata = readMessageMetadata(message);
|
||||
userRunCount += getUserRunSpan({
|
||||
role: normalizeRole(message.role),
|
||||
@@ -355,13 +367,24 @@ export async function readSessionMessages(
|
||||
});
|
||||
}
|
||||
|
||||
for (let idx = start; idx < messages.length; idx += 1) {
|
||||
const rawMessage = messages[idx];
|
||||
let previousCreatedAt: number | undefined;
|
||||
for (const projectedMessage of displayMessages) {
|
||||
const idx = projectedMessage.sourceIndex;
|
||||
const rawMessage = projectedMessage.message;
|
||||
if (!rawMessage || typeof rawMessage !== "object") {
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
const message = rawMessage as unknown as JsonRecord;
|
||||
const storedCreatedAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
// Provider activity projects to messages immediately before its owning
|
||||
// assistant answer. Give projected messages a stable chronological order
|
||||
// even when they share the source timestamp: the webview sorts timestamp
|
||||
// ties by id, which would otherwise put the answer before its tool card.
|
||||
const createdAt =
|
||||
previousCreatedAt === undefined
|
||||
? storedCreatedAt
|
||||
: Math.max(storedCreatedAt, previousCreatedAt + 1);
|
||||
previousCreatedAt = createdAt;
|
||||
let textMeta = extractMessageUsageMeta(message);
|
||||
const storedMeta = extractStoredMessageMeta(message);
|
||||
if (storedMeta) {
|
||||
@@ -511,6 +534,9 @@ export async function readSessionMessages(
|
||||
isError,
|
||||
);
|
||||
target.meta = {
|
||||
...(target.meta && typeof target.meta === "object"
|
||||
? (target.meta as JsonRecord)
|
||||
: {}),
|
||||
toolName,
|
||||
hookEventName: "history_tool_result",
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@ import type {
|
||||
BasicLogger,
|
||||
ClineCore,
|
||||
ITelemetryService,
|
||||
ManagedHubBuildMismatchEvent,
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -48,7 +50,7 @@ export type PromptInQueue = {
|
||||
|
||||
export type LiveSession = {
|
||||
config: JsonRecord;
|
||||
messages: unknown[];
|
||||
messages: MessageWithMetadata[];
|
||||
promptsInQueue: PromptInQueue[];
|
||||
busy: boolean;
|
||||
startedAt: number;
|
||||
@@ -60,6 +62,8 @@ export type LiveSession = {
|
||||
attachedViaHub?: boolean;
|
||||
/** Materialized attachment files for prompts still waiting in the queue. */
|
||||
queuedAttachmentFiles?: Map<string, string[]>;
|
||||
/** Last prompt id announced via chat_queued_prompt_start, to dedupe emits. */
|
||||
lastQueuedPromptStartId?: string;
|
||||
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
|
||||
consumedAttachmentFiles?: Map<string, string[]>;
|
||||
};
|
||||
@@ -117,6 +121,11 @@ export type SidecarContext = {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
/**
|
||||
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
|
||||
* replayed to webviews that connect after the event fired.
|
||||
*/
|
||||
hubBuildMismatch: ManagedHubBuildMismatchEvent | null;
|
||||
};
|
||||
export type BunRuntimeApi = {
|
||||
serve: (options: unknown) => { port: number; stop?: () => void };
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FOLDER_PICKER_UNAVAILABLE_MESSAGE,
|
||||
normalizePickedDirectory,
|
||||
type PickerExec,
|
||||
pickWorkspaceDirectory,
|
||||
} from "./workspace-picker";
|
||||
|
||||
/** Builds an error shaped like execFile's rejection values. */
|
||||
function execError(props: { code?: unknown; signal?: unknown }): Error {
|
||||
return Object.assign(new Error("Command failed"), props);
|
||||
}
|
||||
|
||||
const CANCEL = execError({ code: 1 });
|
||||
const MISSING = execError({ code: "ENOENT" });
|
||||
const LAUNCH_FAILURE = execError({ code: "EACCES" });
|
||||
const CRASH = execError({ code: null, signal: "SIGSEGV" });
|
||||
|
||||
/**
|
||||
* Fake exec that responds per backend name and records the invocation order.
|
||||
* A function outcome resolves with its return value as stdout; an Error
|
||||
* outcome rejects.
|
||||
*/
|
||||
function fakeExec(outcomes: Record<string, string | Error>): {
|
||||
exec: PickerExec;
|
||||
calls: string[];
|
||||
} {
|
||||
const calls: string[] = [];
|
||||
const exec: PickerExec = (command) => {
|
||||
calls.push(command);
|
||||
const outcome = outcomes[command];
|
||||
if (outcome === undefined)
|
||||
return Promise.reject(execError({ code: "ENOENT" }));
|
||||
if (outcome instanceof Error) return Promise.reject(outcome);
|
||||
return Promise.resolve({ stdout: outcome });
|
||||
};
|
||||
return { exec, calls };
|
||||
}
|
||||
|
||||
describe("pickWorkspaceDirectory (linux)", () => {
|
||||
it("returns the zenity selection", async () => {
|
||||
const { exec } = fakeExec({ zenity: "/home/user/projects/app\n" });
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
|
||||
"/home/user/projects/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the user cancels the zenity dialog", async () => {
|
||||
const { exec, calls } = fakeExec({ zenity: CANCEL });
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBeNull();
|
||||
expect(calls).toEqual(["zenity"]);
|
||||
});
|
||||
|
||||
it("falls back to kdialog when zenity is missing", async () => {
|
||||
const { exec, calls } = fakeExec({
|
||||
zenity: MISSING,
|
||||
kdialog: "/home/user/projects/app\n",
|
||||
});
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
|
||||
"/home/user/projects/app",
|
||||
);
|
||||
expect(calls).toEqual(["zenity", "kdialog"]);
|
||||
});
|
||||
|
||||
// Regression: launch failures (EACCES, EMFILE, ENOMEM, ...) used to be
|
||||
// classified as user cancellation, which skipped the kdialog fallback and
|
||||
// silently swallowed the error.
|
||||
it("falls back to kdialog when zenity fails to launch", async () => {
|
||||
const { exec, calls } = fakeExec({
|
||||
zenity: LAUNCH_FAILURE,
|
||||
kdialog: "/home/user/projects/app\n",
|
||||
});
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).resolves.toBe(
|
||||
"/home/user/projects/app",
|
||||
);
|
||||
expect(calls).toEqual(["zenity", "kdialog"]);
|
||||
});
|
||||
|
||||
it("throws a descriptive error when every backend fails to launch", async () => {
|
||||
const { exec } = fakeExec({ zenity: LAUNCH_FAILURE, kdialog: CRASH });
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).rejects.toThrow(
|
||||
"zenity failed to launch (EACCES); kdialog was terminated by signal SIGSEGV",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws the unavailable message when no backend is installed", async () => {
|
||||
const { exec } = fakeExec({});
|
||||
await expect(pickWorkspaceDirectory(exec, "linux")).rejects.toThrow(
|
||||
FOLDER_PICKER_UNAVAILABLE_MESSAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickWorkspaceDirectory (darwin)", () => {
|
||||
it("returns the chosen folder", async () => {
|
||||
const { exec } = fakeExec({ osascript: "/Users/user/projects/app/\n" });
|
||||
await expect(pickWorkspaceDirectory(exec, "darwin")).resolves.toBe(
|
||||
"/Users/user/projects/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when the user cancels", async () => {
|
||||
const { exec } = fakeExec({ osascript: CANCEL });
|
||||
await expect(pickWorkspaceDirectory(exec, "darwin")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("throws when osascript fails for a reason other than cancel", async () => {
|
||||
const { exec } = fakeExec({ osascript: execError({ code: "EMFILE" }) });
|
||||
await expect(pickWorkspaceDirectory(exec, "darwin")).rejects.toThrow(
|
||||
"osascript failed to launch (EMFILE)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePickedDirectory", () => {
|
||||
it("strips trailing whitespace and separators", () => {
|
||||
expect(normalizePickedDirectory("/home/user/app/\n")).toBe(
|
||||
"/home/user/app",
|
||||
);
|
||||
expect(normalizePickedDirectory("C:\\Users\\me\\app\\\\")).toBe(
|
||||
"C:\\Users\\me\\app",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the filesystem root intact", () => {
|
||||
expect(normalizePickedDirectory("/\n")).toBe("/");
|
||||
});
|
||||
|
||||
it("returns null for empty output", () => {
|
||||
expect(normalizePickedDirectory(" \n")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const FOLDER_PICKER_UNAVAILABLE_MESSAGE =
|
||||
"No system folder picker found (zenity or kdialog). Type or paste a folder path in the workspace selector instead.";
|
||||
|
||||
/** Minimal exec surface so tests can simulate picker backends failing. */
|
||||
export type PickerExec = (
|
||||
command: string,
|
||||
args: string[],
|
||||
) => Promise<{ stdout: string }>;
|
||||
|
||||
const defaultExec: PickerExec = (command, args) =>
|
||||
execFileAsync(command, args, { encoding: "utf8" });
|
||||
|
||||
interface ExecErrorShape {
|
||||
code?: unknown;
|
||||
signal?: unknown;
|
||||
}
|
||||
|
||||
function execErrorShape(error: unknown): ExecErrorShape {
|
||||
return typeof error === "object" && error !== null
|
||||
? (error as ExecErrorShape)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** A binary that isn't installed rejects at spawn time with code "ENOENT". */
|
||||
export function isPickerCommandMissing(error: unknown): boolean {
|
||||
return execErrorShape(error).code === "ENOENT";
|
||||
}
|
||||
|
||||
// zenity, kdialog, and osascript all exit with status 1 when the user
|
||||
// dismisses the dialog, which execFile reports as an error with a numeric
|
||||
// `code` and no `signal`. Launch failures (EACCES, EMFILE, ENOMEM, ...)
|
||||
// carry a string code instead, and crashes carry a `signal` — neither of
|
||||
// those means the user cancelled, so they must not be swallowed.
|
||||
export function isPickerCancellation(error: unknown): boolean {
|
||||
const { code, signal } = execErrorShape(error);
|
||||
return !signal && code === 1;
|
||||
}
|
||||
|
||||
export function describePickerFailure(name: string, error: unknown): string {
|
||||
const { code, signal } = execErrorShape(error);
|
||||
if (signal) return `${name} was terminated by signal ${String(signal)}`;
|
||||
if (typeof code === "number") return `${name} exited with code ${code}`;
|
||||
if (typeof code === "string") return `${name} failed to launch (${code})`;
|
||||
return `${name} failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
||||
export function folderPickerFailedMessage(failures: string[]): string {
|
||||
return `The folder picker could not be opened (${failures.join("; ")}). Type or paste a folder path in the workspace selector instead.`;
|
||||
}
|
||||
|
||||
export function normalizePickedDirectory(stdout: string): string | null {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return null;
|
||||
// Dialogs occasionally return trailing separators (typed paths, GTK
|
||||
// location bar); strip them so downstream normalization matches catalog
|
||||
// entries.
|
||||
const withoutTrailing = trimmed.replace(/(?<=.)[\\/]+$/, "");
|
||||
return withoutTrailing || null;
|
||||
}
|
||||
|
||||
// Async is load-bearing here: the native picker blocks until the user chooses
|
||||
// a folder, and a synchronous exec would freeze every other sidecar command
|
||||
// (chat streams, history, settings) for however long the dialog stays open.
|
||||
//
|
||||
// Contract: resolves to a path, resolves to null only when the user cancels,
|
||||
// and throws when no picker backend could be opened — whether the binaries
|
||||
// are missing or they failed to launch — so the UI can surface a manual
|
||||
// path-entry fallback instead of a silent no-op.
|
||||
export async function pickWorkspaceDirectory(
|
||||
exec: PickerExec = defaultExec,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<string | null> {
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const { stdout } = await exec("osascript", [
|
||||
"-e",
|
||||
'set theFolder to choose folder with prompt "Select workspace directory"',
|
||||
"-e",
|
||||
"return POSIX path of theFolder",
|
||||
]);
|
||||
return normalizePickedDirectory(stdout);
|
||||
} catch (error) {
|
||||
if (isPickerCancellation(error)) return null;
|
||||
// osascript ships with macOS, so anything else is a real failure.
|
||||
throw new Error(
|
||||
folderPickerFailedMessage([describePickerFailure("osascript", error)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Linux — try zenity, then kdialog. A backend that is missing or fails to
|
||||
// launch falls through to the next candidate; only a clean cancel exit
|
||||
// from a dialog that actually opened is treated as user cancellation.
|
||||
const backends: { name: string; args: string[] }[] = [
|
||||
{
|
||||
name: "zenity",
|
||||
args: [
|
||||
"--file-selection",
|
||||
"--directory",
|
||||
"--title=Select workspace directory",
|
||||
],
|
||||
},
|
||||
{ name: "kdialog", args: ["--getexistingdirectory", homedir()] },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const backend of backends) {
|
||||
try {
|
||||
const { stdout } = await exec(backend.name, backend.args);
|
||||
return normalizePickedDirectory(stdout);
|
||||
} catch (error) {
|
||||
if (isPickerCancellation(error)) return null;
|
||||
if (!isPickerCommandMissing(error)) {
|
||||
failures.push(describePickerFailure(backend.name, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
failures.length > 0
|
||||
? folderPickerFailedMessage(failures)
|
||||
: FOLDER_PICKER_UNAVAILABLE_MESSAGE,
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,12 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.1", features = ["image-png", "tray-icon"] }
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tokio = { version = "1", features = ["sync", "time"] }
|
||||
rfd = "0.15"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSImage", "NSMenu", "NSMenuItem", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -9,6 +9,8 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::menu::{Menu, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
@@ -24,6 +26,12 @@ const TRAY_OPEN_MENU_ID: &str = "tray-open";
|
||||
const TRAY_NEW_SESSION_MENU_ID: &str = "tray-new-session";
|
||||
const TRAY_SETTINGS_MENU_ID: &str = "tray-settings";
|
||||
const TRAY_QUIT_MENU_ID: &str = "tray-quit";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_IN_MENU_ID: &str = "view-zoom-in";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_OUT_MENU_ID: &str = "view-zoom-out";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_RESET_MENU_ID: &str = "view-zoom-reset";
|
||||
const DESKTOP_MENU_ACTION_PENDING_EVENT: &str = "desktop-menu-action-pending";
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -81,6 +89,12 @@ impl Default for UpdateStatus {
|
||||
#[derive(Default)]
|
||||
struct UpdateState {
|
||||
status: Mutex<UpdateStatus>,
|
||||
// Serializes whole updater cycles. The periodic loop and the on-demand
|
||||
// check_for_update_now command run the same check/download/stage cycle;
|
||||
// without exclusion, overlapping cycles can download the same bundle
|
||||
// concurrently and the later one can overwrite a freshly staged "ready"
|
||||
// with "idle"/"error" decided from its stale pre-await snapshot.
|
||||
cycle: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
@@ -147,9 +161,11 @@ fn set_update_status(
|
||||
}
|
||||
|
||||
async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
|
||||
let _cycle = state.cycle.lock().await;
|
||||
// An update that already finished downloading only needs a restart; keep
|
||||
// reporting "ready" instead of flipping back to transient states unless a
|
||||
// newer version shows up.
|
||||
// newer version shows up. Reading it under the cycle lock makes the
|
||||
// snapshot authoritative for this whole cycle.
|
||||
let ready_version = state.ready_version();
|
||||
if ready_version.is_none() {
|
||||
set_update_status(app, state, "checking", None, None);
|
||||
@@ -689,6 +705,20 @@ fn restart_to_apply_update(
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Run one updater check/download/stage cycle immediately instead of waiting
|
||||
/// for the next background interval, and report the resulting status. Used by
|
||||
/// flows that need an update staged right now (e.g. the "Cline Hub was
|
||||
/// updated" prompt), where restarting without a staged update would just
|
||||
/// relaunch the same version.
|
||||
#[tauri::command]
|
||||
async fn check_for_update_now(
|
||||
app: tauri::AppHandle,
|
||||
update_state: State<'_, Arc<UpdateState>>,
|
||||
) -> Result<UpdateStatus, String> {
|
||||
check_and_install_update(&app, update_state.inner()).await;
|
||||
Ok(update_state.snapshot())
|
||||
}
|
||||
|
||||
/// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in
|
||||
/// webview/lib/app-icon.ts. Every non-default id has a matching bundled
|
||||
/// resource at icons/dock/<id>.png.
|
||||
@@ -786,6 +816,101 @@ fn queue_desktop_menu_action(app: &tauri::AppHandle, action: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn application_menu_action(menu_id: &str) -> Option<&'static str> {
|
||||
match menu_id {
|
||||
VIEW_ZOOM_IN_MENU_ID => Some("zoom-in"),
|
||||
VIEW_ZOOM_OUT_MENU_ID => Some("zoom-out"),
|
||||
VIEW_ZOOM_RESET_MENU_ID => Some("zoom-reset"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn setup_application_menu(app: &tauri::App) -> tauri::Result<()> {
|
||||
let menu = Menu::default(app.handle())?;
|
||||
let zoom_in = MenuItem::with_id(app, VIEW_ZOOM_IN_MENU_ID, "Zoom In", true, None::<&str>)?;
|
||||
let zoom_out = MenuItem::with_id(
|
||||
app,
|
||||
VIEW_ZOOM_OUT_MENU_ID,
|
||||
"Zoom Out",
|
||||
true,
|
||||
Some("CmdOrCtrl+-"),
|
||||
)?;
|
||||
let zoom_reset = MenuItem::with_id(
|
||||
app,
|
||||
VIEW_ZOOM_RESET_MENU_ID,
|
||||
"Actual Size",
|
||||
true,
|
||||
Some("CmdOrCtrl+0"),
|
||||
)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
let mut view_menu = None;
|
||||
for item in menu.items()? {
|
||||
if let MenuItemKind::Submenu(submenu) = item {
|
||||
if submenu.text()? == "View" {
|
||||
view_menu = Some(submenu);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(view_menu) = view_menu {
|
||||
view_menu.prepend_items(&[&zoom_in, &zoom_out, &zoom_reset, &separator])?;
|
||||
} else {
|
||||
let view_menu =
|
||||
Submenu::with_items(app, "View", true, &[&zoom_in, &zoom_out, &zoom_reset])?;
|
||||
menu.append(&view_menu)?;
|
||||
}
|
||||
|
||||
app.set_menu(menu)?;
|
||||
set_macos_menu_key_equivalent("View", "Zoom In", "+")?;
|
||||
app.on_menu_event(|app, event| {
|
||||
if let Some(action) = application_menu_action(event.id().as_ref()) {
|
||||
queue_desktop_menu_action(app, action);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_menu_key_equivalent(
|
||||
menu_title: &str,
|
||||
item_title: &str,
|
||||
key_equivalent: &str,
|
||||
) -> tauri::Result<()> {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEventModifierFlags};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let missing = |description: &str| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("native menu item not found: {description}"),
|
||||
)
|
||||
};
|
||||
let mtm = MainThreadMarker::new()
|
||||
.ok_or_else(|| std::io::Error::other("native menu setup must run on the main thread"))?;
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let main_menu = app.mainMenu().ok_or_else(|| missing("main menu"))?;
|
||||
let menu_item = main_menu
|
||||
.itemWithTitle(&NSString::from_str(menu_title))
|
||||
.ok_or_else(|| missing(menu_title))?;
|
||||
let submenu = menu_item
|
||||
.submenu()
|
||||
.ok_or_else(|| missing(&format!("{menu_title} submenu")))?;
|
||||
let item = submenu
|
||||
.itemWithTitle(&NSString::from_str(item_title))
|
||||
.ok_or_else(|| missing(item_title))?;
|
||||
|
||||
// Tauri 2.11's accelerator parser cannot represent the `+` character.
|
||||
// Set the AppKit key equivalent directly so the menu displays and handles ⌘+.
|
||||
item.setKeyEquivalent(&NSString::from_str(key_equivalent));
|
||||
item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
|
||||
let status = MenuItem::new(app, "Status: Healthy", false, None::<&str>)?;
|
||||
let running_sessions = MenuItem::new(app, "0 sessions running", false, None::<&str>)?;
|
||||
@@ -884,6 +1009,8 @@ fn main() {
|
||||
.manage(Arc::new(UpdateState::default()))
|
||||
.manage(DesktopMenuActionState::default())
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
setup_application_menu(app)?;
|
||||
setup_tray_icon(app)?;
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
@@ -924,6 +1051,7 @@ fn main() {
|
||||
open_mcp_settings_file,
|
||||
get_update_status,
|
||||
restart_to_apply_update,
|
||||
check_for_update_now,
|
||||
set_app_icon,
|
||||
drain_desktop_menu_actions,
|
||||
set_tray_status
|
||||
@@ -961,6 +1089,23 @@ mod tests {
|
||||
assert!(state.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_menu_ids_map_to_zoom_actions() {
|
||||
assert_eq!(
|
||||
application_menu_action(VIEW_ZOOM_IN_MENU_ID),
|
||||
Some("zoom-in")
|
||||
);
|
||||
assert_eq!(
|
||||
application_menu_action(VIEW_ZOOM_OUT_MENU_ID),
|
||||
Some("zoom-out")
|
||||
);
|
||||
assert_eq!(
|
||||
application_menu_action(VIEW_ZOOM_RESET_MENU_ID),
|
||||
Some("zoom-reset")
|
||||
);
|
||||
assert_eq!(application_menu_action("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_status_prioritizes_update_progress_over_hub_health() {
|
||||
let status = |state: &str| UpdateStatus {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.12",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import "@fontsource-variable/geist-mono";
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@@ -14,11 +14,42 @@
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
/* Keep scroll momentum inside the app: without this, hitting the end
|
||||
* of an inner scroller rubber-bands the whole window like a web page. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native-app chrome: labels, buttons, and panel text must not highlight
|
||||
* when the user drags across them. Content the user genuinely reads and
|
||||
* copies — chat messages, code, diffs, form fields — opts back in below.
|
||||
*/
|
||||
body {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable="true"] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Selectable content surfaces: chat message bodies (incl. markdown and
|
||||
* code blocks), reasoning/tool panels, and diff text. */
|
||||
.cline-chat-message-content,
|
||||
.cline-markdown,
|
||||
.cline-chat-selectable,
|
||||
pre,
|
||||
code {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -103,21 +134,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Softens the welcome <-> conversation swap: the hero and the message grid
|
||||
* replace each other in a single commit, which otherwise reads as a hard
|
||||
* white flash. Plays whenever the element (re)becomes visible — display:none
|
||||
* resets animations, so toggling Tailwind's `hidden` re-triggers it. */
|
||||
@keyframes cline-view-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cline-view-enter {
|
||||
animation: cline-view-enter 180ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-chat-streaming-title {
|
||||
background: none;
|
||||
-webkit-text-fill-color: currentcolor;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tool panels scroll on both axes, but a horizontal thumb inside a short
|
||||
* disclosure eats a whole detail row. Keep the axis scrollable (wheel, trackpad,
|
||||
* keyboard, drag-select) and hide only its bar; the vertical one stays visible.
|
||||
* `:horizontal` is the one selector that separates the two axes — the standard
|
||||
* `scrollbar-width: none` would take out both. */
|
||||
.cline-chat-scroll-x-bare::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
.cline-view-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared reveal rule is `.cline-chat-message:hover`, so hovering anywhere in
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user