Compare commits

...
Author SHA1 Message Date
Saoud Rizwan d718dd16f8 fix(ui): derive diff options from the FileDiff component, not FileDiffProps
@pierre/diffs 1.4 added a second required type parameter to FileDiffProps
while keeping defaults on the FileDiff component itself, so FileDiffProps<undefined>
fails with TS2314 under any 1.4.x. The package is declared as ^1.3.0, and the
release pipeline deletes bun.lock and re-resolves, so CI built against 1.4.2
while the committed lockfile pinned 1.3.6 locally.

Deriving the options type from ComponentProps<typeof FileDiff> compiles against
both 1.3.x and 1.4.x, and stops the emitted .d.ts from re-exporting a peer
dependency type whose arity changes between minors.
2026-09-14 22:37:16 -07:00
Saoud Rizwan 128ec277d5 test(llms): decouple Bedrock geo-profile cases from the generated catalog
deepseek.r1-v1:0 was retired from the Bedrock catalog upstream, so the two
cases asserting a us. prefix for it started failing once the release regen
picked up the new catalog. The resolver is correct: it only prefixes a geo
profile when the catalog confirms that variant exists. Inject hasCatalogModel
in both cases so they state their catalog premise explicitly, matching the
isolation added for the fallback cases in #14017.
2026-09-14 22:12:18 -07:00
Saoud Rizwan 18477898e0 chore(cli): release v3.0.62 2026-09-14 21:36:49 -07:00
Saoud Rizwan 9f19f047d2 chore(sdk): release v0.0.83 2026-09-14 21:36:41 -07:00
Saoud RizwanandSaoud Rizwan e21b5903c2 fix(llms): default Cline Pass to a subscribed-tier model (#14141)
firstGeneratedModelId took the first entry of the cline-pass catalog, which
is release-date ordered and mixes cline-pass/*, cline-free/* and :free
models, so the default drifted to whichever free model shipped most
recently. Restrict the default to cline-pass/* ids, falling back to the
previous behavior when the catalog has none, and add a regression test
that asserts the tier rather than a specific model id.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 21:04:06 -07:00
Bee 17529c8cb1 feat(core): add SSH remote environments (#14116)
* feat(core): add SSH remote environments

* fix(core): harden SSH lifecycle and helper package exports

* fix(core): use current SSH identity for pending cleanup

* docs(core): clarify SSH destination invariants during cleanup

* fix(core): recover SSH cleanup after remote Hub crashes

* test(core): make SSH regression coverage portable on Windows

* fix(core): leave account connectors untouched by SSH Hubs

* fix(core): restore missing SSH helpers for pending cleanup
2026-09-14 19:58:02 -07:00
Dominic Cooney 36905f11cc fix(core): refine PowerShell shell guidance (#14055) 2026-09-15 11:11:29 +09:00
BeeandClaude Fable 5.1 722b640c28 feat(agents): retry transient provider errors and verify execution in yolo mode (#14125)
* refactor(core): improve yolo mode

update submit_and_exit tool and yolo mode prompt

* feat(agents): retry transient provider errors before failing a run

A model turn that fails with a transient, provider-returned error is now
re-issued up to 3 times with exponential backoff (1s/2s/4s, capped at 15s)
before the error ends the run. Previously a single OpenRouter
"Provider returned error" (typically a forwarded 429) aborted the whole run
with exit 1, which cost the agent most of its runs against rate-limited
models.

Retryability is decided from the AI SDK's own typed signal rather than
message matching:
- isRetryableProviderError prefers APICallError.isRetryable, unwraps
  RetryError, and walks AISDKError.cause; for non-typed errors it falls
  back to the HTTP status, and finally to the single documented
  "Provider returned error" provider quirk.
- Because the agent loop only sees a flattened error string, the flag is
  computed at captureStreamError (where the structured error is still in
  hand) and threaded through a new errorRetryable field on the finish
  event, mirroring the existing errorClass path.

Non-retryable failures (auth, context-window overflow, other 4xx) and turns
that already produced tool calls are never retried, so a turn that would
otherwise succeed is unchanged. The backoff is abort-safe.

* llms: increase max retries limit to 5

Pass maxRetries=5 to AI SDK model calls (the SDK default is 2). The SDK
retries the initial request on 429/5xx/network failures with exponential
backoff that honors retry-after headers. Errors a provider emits mid-stream
(OpenRouter's "Provider returned error" after a 200) never reach this layer,
so the agent loop keeps its own turn-level retry; the two are complementary.

* fix(llms): judge RetryError retryability by its final attempt only

When the final error inside an AI SDK RetryError was not a typed instance,
isRetryableProviderError fell back to a structural walk over the whole
wrapper, including the earlier attempts the SDK had already retried away.
An earlier 429 could therefore make a final plain 400, or a statusless
transport failure, look retryable.

The structural fallback is now a standalone helper and, for a RetryError, runs
on the final attempt alone. The outer fallback for non-wrapped errors is
unchanged.

* fix(agents): only retry provider errors when the attempt left nothing behind

Tighten the transient-provider-error retry so a re-issued request can never
duplicate or repeat what the failed attempt already did:

- Do not retry once the attempt streamed any content (text, reasoning, media,
  or local tool calls). Those deltas were already emitted and there is no
  event to retract them, so a second stream would show the output twice.
  Previously only local tool calls blocked the retry.
- Do not retry once the attempt recorded provider-executed tool activity.
  That activity lives in message metadata rather than content, so the old
  content-only check missed it and a retry could run the side effects again.
- Reset lastError, lastErrorClass, lastErrorRetryable, and lastErrorReported
  at the start of every turn and before every provider-error retry. The
  AgentModel contract allows a finish event with reason "error" and no error
  payload; such an event previously inherited the class and retryability of
  an earlier attempt. Overflow recovery's own inner request is left alone,
  since its "nothing to compact" error reports the first attempt's message.

* fix(llms): keep request-start retries in one layer

The turn-level retry unwrapped the AI SDK's RetryError and, when its final
attempt looked transient, re-ran the turn. The SDK had already spent its
request-start retries with retry-after-aware backoff, so the two counts
multiplied: up to 6 SDK attempts times 4 turn attempts for one persistent 429.

Decide turn-level retryability with a dedicated helper that treats a
RetryError as terminal and otherwise defers to isRetryableProviderError.
Each failure class now has exactly one retrying layer: request-start failures
belong to the SDK's maxRetries; pre-output socket deaths and empty responses
to withEmptyResponseRetry, whose first doStream runs outside its retry loop
so it never re-runs request-start rejections; and mid-stream provider errors,
which the SDK never retries, to the turn-level retry alone. Document that
ownership next to MODEL_REQUEST_MAX_RETRIES.

* fix(llms): guard the RetryError check in isRetryableBeyondSdkRetries

`RetryError.isInstance` throws when the "ai" module is only partially
available, which is the case in test files that mock it with a subset of
exports. Wrap the check like the other typed checks in this file so the
classifier falls through instead of crashing the stream error handler.

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

* test(llms): expect errorRetryable on error finish events

Error finish events now carry `errorRetryable` alongside `errorClass`.
Update the exact-shape assertions in gateway.test.ts to include it; every
covered case is a non-retryable failure, so the expected value is false.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 02:36:37 +02:00
Saoud Rizwan 067b865f97 fix(cli): improve desktop migration notice readability 2026-09-14 17:31:20 -07:00
Saoud RizwanandSaoud Rizwan c925a9c94c fix(readme): point desktop app download at cline.bot/desktop (#14133)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 17:20:54 -07:00
Saoud RizwanandClaude Opus 5 899e8f3da4 chore(cli): drop "(beta)" from the Cline Desktop notice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uun2bwaGxA8mPL17b6jG3u
2026-09-14 16:14:06 -07:00
Saoud RizwanandSaoud Rizwan e10183f2a8 Add Cline Desktop launch notice to the CLI (#14123)
* Add Cline Desktop launch CTA to extension home banner and CLI startup notice

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Show Cline Desktop CTA on all platforms

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* Drop hardcoded extension desktop banner in favor of remote banner

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 16:11:36 -07:00
Saoud RizwanandSaoud Rizwan 00daef8f89 desktop: fix model name flashing when the composer picker opens (#14118)
* desktop: only refresh the live model list when the picker opens

Opening the composer model picker re-ran the whole load effect, which
first replaced providerModels/modelDetails with the bundled catalog and
then restored the live list once loadProviderModels resolved. For a
frame in between the trigger resolved against the bundled catalog,
flashing the raw model id (or a stale name) before snapping back.

Refresh only the active provider's live list on open, via a shared
applyProviderModels helper that the load effect and the
subscribeToProviderModels listener already duplicated.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* desktop: mark reasoning capability as catalog-backed after a picker refresh

The load effect flips reasoningCapabilitySource to "catalog" once live
models land; the picker-open refresh path should too, so a session that
started offline (source stuck at "fallback") trusts the live reasoning
data once a later refresh succeeds.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 15:56:14 -07:00
Saoud RizwanandSaoud Rizwan 94980446c9 fix(hub): stop forwarding per-chunk stream events to remote onEvent hooks (#14120)
The hub proxies every AgentRuntimeEvent to a client-contributed onEvent
hook as a capability round trip carrying the full session snapshot, and
the agent loop awaits it. For a streaming model that meant one ~200-300 KB
serialization, a persisted capability.requested row, four hub log lines,
and a blocking IPC hop per token (#14091).

Skip assistant-text-delta, assistant-reasoning-delta, and tool-updated in
the hook proxy; no client consumes them through a remote hook. Every other
event still reaches the hook unchanged.

Also set synchronous=NORMAL on the hub event log so the remaining
per-chunk delta rows stop costing an fsync each; WAL still syncs at
checkpoints and the log survives a process crash for reconnect replay.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 13:25:18 -07:00
Saoud RizwanandSaoud Rizwan c2ac5feecb fix(desktop): keep retrying the backend connection when the sidecar is slow to start (#14119)
* fix(desktop): keep retrying the backend connection when the sidecar is slow to start

The webview asked the Tauri shell for the sidecar endpoint exactly once. When
the sidecar took longer than the shell's 15s poll (hub startup lock plus hub
daemon boot can exceed that on a slow Windows machine), the command returned
"desktop backend endpoint not ready" and the UI parked on "Desktop backend
unavailable" until the app was relaunched, where the same race repeated.

Schedule a reconnect with backoff after any failed connect, and drop the
cached endpoint on each attempt so a sidecar the shell respawned (which
issues a new approval token) is dialed with its current endpoint.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): keep the existing flat reconnect delay

Drop the consecutive-attempt backoff so the reconnect-after-drop path stays
identical to before apart from re-resolving the endpoint.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 13:24:51 -07:00
Saoud RizwanandSaoud Rizwan 97afeaccf0 fix(desktop): don't submit composer on IME composition Enter (#14114)
On macOS with a Chinese/Japanese IME, the Enter that commits the current
composition also reached the composer's Enter-to-submit handler and sent
the message. Skip keydown handling while a composition is in progress
(isComposing, or WebKit's post-compositionend Enter with keyCode 229) so
Enter and arrow keys are left to the IME.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 12:15:15 -07:00
Saoud RizwanandSaoud Rizwan b4b980d9a1 desktop: refresh the composer model list when the picker opens (#14111)
The composer's ModelSelector loaded a provider's model list once, on
mount and on provider change, so the Recommended/Free tiers stamped by
the SDK feed stayed frozen until an app restart. The CLI and extension
refresh on picker open; do the same here via a new SearchCombobox
onOpen hook. The sidecar caches the feed and catalog, so repeat opens
are cheap.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 11:41:52 -07:00
Saoud RizwanandSaoud Rizwan 9de01af92d desktop: ClinePass and free model value prop in onboarding (#14107)
Sign in with Cline card now lists the three benefits (free model
promotions, ClinePass for generous usage across open weights models, no
API key needed). After a Cline sign-in the done step shows the current
free models from list_cline_recommended_models and a Get ClinePass link.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-09-14 10:35:14 -07:00
John Choi d896a0ac84 feat(desktop): add cloud session REST client (#13855)
* feat(sdk): support authenticated remote Hub connections

* fix(sdk): pin compatible SAP connectivity

* chore(sdk): defer SAP smoke fix to main

* fix(sdk): preserve hub connection failures

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* feat(desktop): add cloud session foundations

* fix(desktop): require boolean cloud rollout flag

* test(desktop): run sidecar suite in pull requests

* ci: test desktop changes in stacked pull requests

* fix(desktop): scope cloud model catalog rollout

* fix(sdk): clear feature flags on identity change

* test(hub): reconnect approval session owner

* feat(desktop): add cloud session REST client

* test(desktop): cover cloud session REST client

* docs(sdk): clarify remote Hub connection headers

* docs(hub): describe approval recovery

* docs(llms): describe cloud catalog opt-in

* test(sdk): tighten hub header coverage

* test(hub): remove redundant approval setup

* chore(desktop): trim cloud foundation scaffolding

* test(desktop): trim cloud REST coverage

* test(desktop): consolidate cloud API cases

* fix(hub): preserve approval recovery for existing clients

* test(core): batch root history fixture inserts

* test(sdk): await daemon health after discovery publication

* feat(hub): recover pending session approvals

* fix(hub): scope approval recovery to attached clients

* test(hub): reconnect approval session owner

* docs(hub): describe approval recovery

* test(hub): remove redundant approval setup

* fix(hub): preserve approval recovery for existing clients

* refactor(hub): drop cloud-only pending approval API

* chore(desktop): trim redundant cloud session comments

* chore(desktop): trim redundant cloud session comments

* fix(desktop): preserve cached flags and share account auth

* fix(desktop): return cloud session identity before provisioning completes

* test(desktop): simplify cloud create recovery fixtures

* fix(desktop): reject invalid cloud history snapshots
2026-09-14 09:45:53 -07:00
Etisha GargandRenee Huang f80dd1eeff Cline desktop page (#14102)
* docs: cline desktop page

* docs: move cline desktop page to usage section

- Relocate page from getting-started/ to usage/ and move nav entry to top of Usage group
- Remove Plan/Act mode bullet (not supported in Cline Desktop)

---------

Co-authored-by: Renee Huang <renee@cline.bot>
2026-09-14 08:02:57 -07:00
Saoud Rizwan 19ddebb3b9 Update terminology from MCP servers to MCPs 2026-09-13 20:44:53 -07:00
89 changed files with 16410 additions and 4030 deletions
+2 -2
View File
@@ -63,7 +63,7 @@ Cline as a native app for macOS and Windows.
Run agent sessions in any folder, schedule
routines, and manage models, plugins, and MCP servers.
<a href="https://github.com/cline/cline/releases?q=desktop-v&expanded=true">Download for macOS and Windows</a>
<a href="https://cline.bot/desktop">Download for macOS and Windows</a>
<br><br>
</td>
@@ -182,7 +182,7 @@ const deployTool = createTool({
const agent = new Agent({ tools: [deployTool], /* ... */ })
```
...or use [MCP servers](https://github.com/modelcontextprotocol) to connect to databases, query APIs, manage cloud infrastructure, and interact with external systems. Use [community-built servers](https://github.com/modelcontextprotocol/servers) or ask Cline to create custom tools on the fly. In the CLI, manage servers with `cline mcp`.
...or use [MCPs](https://github.com/modelcontextprotocol) to connect to databases, query APIs, manage cloud infrastructure, and interact with external systems. Use [community-built servers](https://github.com/modelcontextprotocol/servers) or ask Cline to create custom tools on the fly. In the CLI, manage servers with `cline mcp`.
## Multi-Agent Teams
+28
View File
@@ -1,5 +1,33 @@
# Cline CLI Changelog
## 3.0.62
- Introducing Cline Desktop: a native app for working with open-weight models, with task import from Claude Code and Codex, scheduled runs, web search and voice input, and a marketplace for plugins, MCP servers, and skills. The CLI now shows a one-time startup notice pointing at cline.bot/desktop. At most one notice appears per launch, and `CLINE_DISABLE_CLINE_PASS_NOTICE=1` still suppresses all of them
- Agent Plugins are now managed through the Hub. Packages under `~/.agents/plugins/*` are discovered and validated, their skills are exposed through the skills tool as `plugin-name:skill-name`, and their MCP servers start without touching `cline_mcp_settings.json`. The config screen lists them separately from Cline Plugins and Space toggles them. Workspace `.agents/plugins` directories are deliberately not scanned, so opening a repo cannot implicitly start repo-controlled MCP servers
- A model turn that dies mid-stream with a transient provider error is now retried up to 3 times with backoff instead of failing the run — a single forwarded 429 from OpenRouter previously ended the run with exit 1. A turn that already streamed output is never retried, so nothing is duplicated
- Streaming output is no longer throttled by the Hub. Every streamed token was proxied to client hooks as a round trip carrying a full copy of the session, with the agent loop waiting on it
- Checkpoints no longer stall every message in workspaces with large untracked directories. Each turn re-hashed every untracked file before the model call, which on multi-GB workspaces blocked messages for seconds to minutes; a persistent per-session snapshot index now lets git skip unchanged files
- Fixed `run_commands` hanging until timeout after a command that backgrounds a child process. The command had finished, but the backgrounded child held the output pipes open
- Fixed `cline` being OOM-killed when run from your home directory. Typing an `@` mention indexed every file under `$HOME` and re-ranked it on each keystroke
- Fixed `apply_patch` silently overwriting an existing file when the model used "Add File" on a path that already exists
- Fixed PowerShell commands that the model wrapped in another `powershell -Command "..."` being parsed twice, which stripped `$_` out of pipelines and produced an error per enumerated item while still reporting success. The tool description now also names which PowerShell edition is in use and tells the model not to wrap commands
- API keys pasted with an invisible character (BOM, zero-width space) are now cleaned before being saved. They were stored corrupted and the provider's 401 was indistinguishable from a wrong key
- Signing in with Claude Code no longer demands an API key it never reads. It authenticates from the `claude` CLI's own credential store, but onboarding treated it as an API-key provider and dropped you into the sign-in wizard; the workaround was storing a dummy key. A missing `claude` on PATH now warns instead of blocking, since a configured path, bundled binary, or npx also works. OpenCode gets the same local-CLI treatment
- Web search is now on by default in non-yolo sessions on models that support it
- A running Hub on the same core version as your CLI no longer prompts you to update it. Anyone with both the desktop app and the CLI installed saw a "Cline Hub was updated" dialog on every launch that could never resolve
- Scheduled runs no longer stall behind each other — one long turn blocked dispatch of every other schedule. Parallelism limits are now enforced at claim time, work resumed after system sleep is no longer started twice, and a schedule created without a timezone uses your local one instead of an implicit default
- Session history no longer goes blank when a session spawns many subagents. Child rows crowded out the roots, hiding the parent session and everything older
- Fixed TUI toasts being clipped to their first line. The Hub messages that use them are all longer than that, so the keep-Hub reminder never showed the `cline hub upgrade` command it exists to deliver
- Cline Pass now defaults to a subscribed model instead of a free one. Its model list mixes both tiers and the default was whichever model shipped most recently, so subscribers who never picked a model were put on the free tier
- Cline Pass and free models now show zero cost instead of the upstream market price for requests you are not billed per token for
- Model pickers now fall back to the full Recommended, Free, and Subscribed tiers when the models endpoint is unreachable. Previously the offline fallback was six hardcoded models with no subscribed tier at all
- The OpenAI Codex (ChatGPT subscription) model list no longer includes models the backend rejects, and Codex context limits are applied to every Codex model instead of being inherited from the OpenAI API catalog, which was inflating the context budget and the usage math
- Model lists for all shared-catalog providers now refresh from the live catalog, so newly published models appear without a CLI update, with timeouts so a hung provider endpoint cannot stall the list
- Cline Pass now shows as a configured provider after sign-in — it stores credentials under Cline, so one sign-in configured both but only Cline appeared
- Fixed OpenCode Go serving several wire protocols behind one URL while every model was sent over the OpenAI chat-completions adapter
- Collapsed a nested `undici@5.29.0` (CVE-2026-1525) onto 7.x. The earlier remediation's version-scoped override key was silently ignored by Bun, so a vulnerable copy survived
- Refreshed the model catalog. Adds four providers (Infer by Flow7, Melious, NaN, and Wallaby) and takes the bundled catalog from 5,788 to 6,079 models. This is a wide refresh: the resolved default model changes for 44 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Nebius, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go. Gemini and Vertex now resolve to Gemini 3.8 Flash, GitHub Copilot and Vivgrid to GPT-6 Astra, and NVIDIA to GLM 5.3 Flash. If you use any provider without pinning a model, expect a different default
## 3.0.61
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.61",
"version": "3.0.62",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+16 -18
View File
@@ -1,18 +1,17 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { useDialogPalette } from "../tui/hooks/use-theme";
import {
type DialogDismissKey,
isAnyKeyDismiss,
} from "../tui/utils/dialog-keys";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
import open from "../utils/open";
import type { CliMigrationNotice } from "./notice";
/**
* Enter opens the subscription page; any other (unmodified) key dismisses the
* Enter opens the notice's page; any other (unmodified) key dismisses the
* dialog; modifier-held keys are ignored.
*
* The dialog used to be dismissible only with Esc, but Esc is the least
@@ -20,7 +19,7 @@ import type { CliMigrationNotice } from "./notice";
* timeout disambiguation, and Windows console input layers are known to
* swallow it), which left users stuck behind the promo with no way out.
* Modifier-held keys are ignored so that holding Cmd/Ctrl to click the
* subscription link never dismisses the dialog mid-click.
* link never dismisses the dialog mid-click.
*/
export function resolveMigrationNoticeKeyAction(
key: DialogDismissKey,
@@ -36,27 +35,26 @@ export function MigrationNoticeContent(
) {
const { dialogId, notice, resolve } = props;
const palette = useDialogPalette();
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
const openSubscriptionPage = useCallback(() => {
setStatus("Opening ClinePass in your browser...");
void open(subscriptionUrl, { wait: false })
const openNoticePage = useCallback(() => {
setStatus("Opening in your browser...");
void open(notice.url, { wait: false })
.then(() => {
setStatus("Opened ClinePass in your browser.");
setStatus("Opened in your browser.");
})
.catch(() => {
setStatus(
"Could not open the browser automatically. Use the URL below.",
);
});
}, [subscriptionUrl]);
}, [notice.url]);
useDialogKeyboard((key) => {
const action = resolveMigrationNoticeKeyAction(key);
if (action === "ignore") return;
if (action === "open") {
openSubscriptionPage();
openNoticePage();
return;
}
resolve(true);
@@ -66,20 +64,20 @@ export function MigrationNoticeContent(
<box flexDirection="column" paddingX={1} gap={1}>
<text fg={palette.act}>{notice.title}</text>
<box flexDirection="column">
<text selectable>
ClinePass is a $9.99/month subscription plan to get access to the
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
{notice.body.split("\n").map((line) => (
<text key={line} selectable>
{line}
</text>
))}
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
<a href={notice.url}>{notice.url}</a>
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Open ClinePass</text>
<text fg={palette.textOnSelection}>{notice.openLabel}</text>
</box>
</box>
{status && <text fg={palette.muted}>{status}</text>}
+38 -5
View File
@@ -55,11 +55,42 @@ describe("migration notice", () => {
);
});
it("does not show after the notice is marked as shown", () => {
it("does not show after every notice is marked as shown", () => {
const dataDir = createTempDataDir();
markClineCliMigrationNoticeShown(dataDir);
markClineCliMigrationNoticeShown(dataDir, "cline-cli-desktop-launch");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
it("shows the desktop launch notice once the ClinePass intro was shown", () => {
const dataDir = createTempDataDir();
markClineCliMigrationNoticeShown(dataDir);
const notice = getClineCliMigrationNotice(dataDir);
expect(notice?.id).toBe("cline-cli-desktop-launch");
expect(notice?.url).toBe("https://cline.bot/desktop");
});
it("shows only one notice per launch", () => {
const dataDir = createTempDataDir();
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
"cline-cli-cline-pass-intro",
);
});
it("marks the desktop launch notice as shown by id", () => {
const dataDir = createTempDataDir();
markClineCliMigrationNoticeShown(dataDir);
markClineCliMigrationNoticeShown(dataDir, "cline-cli-desktop-launch");
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain('"cline-cli-cline-pass-intro": true');
expect(rawState).toContain('"cline-cli-desktop-launch": true');
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
@@ -85,7 +116,7 @@ describe("migration notice", () => {
).toBeUndefined();
});
it("does not show when ClinePass is already the active provider", () => {
it("does not show the ClinePass intro when ClinePass is already the active provider", () => {
const dataDir = createTempDataDir();
expect(
@@ -93,8 +124,8 @@ describe("migration notice", () => {
dataDir,
{},
{ activeProviderId: "cline-pass" },
),
).toBeUndefined();
)?.id,
).toBe("cline-cli-desktop-launch");
});
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
@@ -141,6 +172,8 @@ describe("migration notice", () => {
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain("cline-cli-cline-pass-intro");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
expect(getClineCliMigrationNotice(dataDir)?.id).not.toBe(
"cline-cli-cline-pass-intro",
);
});
});
+46 -11
View File
@@ -1,20 +1,54 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
const NOTICE_ID = "cline-cli-cline-pass-intro";
export const CLINE_PASS_NOTICE_ID = "cline-cli-cline-pass-intro";
const DESKTOP_NOTICE_ID = "cline-cli-desktop-launch";
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
// Historically named for the ClinePass promo; disables every startup notice.
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
const DESKTOP_APP_URL = "https://cline.bot/desktop";
export interface CliMigrationNotice {
id: string;
title: string;
body: string;
url: string;
openLabel: string;
}
export interface CliMigrationNoticeOptions {
activeProviderId?: string;
}
function getClinePassNotice(): CliMigrationNotice {
return {
id: CLINE_PASS_NOTICE_ID,
title: "Try ClinePass",
body: "ClinePass is a $9.99/month subscription plan to get access to the latest open-weight coding models with enough quota for day-to-day work, at a much lower cost than paying API costs directly.",
url: getCliSubscriptionUrl(),
openLabel: "Open ClinePass",
};
}
function getDesktopNotice(): CliMigrationNotice {
return {
id: DESKTOP_NOTICE_ID,
title: "Introducing Cline Desktop",
body: [
"A native app for working with open weights models. Use it with ClinePass and our free models, or BYOK.",
"- Import tasks from Claude Code and Codex",
"- Run Cline on a regular schedule",
"- Use web search tool and voice input",
"- Browse Marketplace for plugins, MCPs, and skills",
"Available for macOS and Windows.",
].join("\n"),
url: DESKTOP_APP_URL,
openLabel: "Get Cline Desktop",
};
}
interface CliNoticeState {
shown: Record<string, boolean>;
}
@@ -84,32 +118,33 @@ export function getClineCliMigrationNotice(
if (disableNotice && !forceNotice) {
return undefined;
}
// At most one notice per launch, oldest first, so a user who has already
// dismissed the ClinePass intro sees the desktop launch on their next start.
if (
shouldSuppressClineCliMigrationNoticeForActiveProvider(
!shouldSuppressClineCliMigrationNoticeForActiveProvider(
options.activeProviderId,
env,
)
) &&
(forceNotice || !noticeState.shown[CLINE_PASS_NOTICE_ID])
) {
return undefined;
return getClinePassNotice();
}
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
return undefined;
if (!noticeState.shown[DESKTOP_NOTICE_ID]) {
return getDesktopNotice();
}
return {
id: NOTICE_ID,
title: "Try ClinePass",
};
return undefined;
}
export function markClineCliMigrationNoticeShown(
dataDir = resolveClineDataDir(),
noticeId = CLINE_PASS_NOTICE_ID,
): void {
const noticePath = resolveCliNoticeStatePath(dataDir);
const noticeState = readNoticeState(noticePath);
const nextState: CliNoticeState = {
shown: {
...noticeState.shown,
[NOTICE_ID]: true,
[noticeId]: true,
},
};
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
+7 -1
View File
@@ -793,6 +793,9 @@ describe("runCli lightweight command dispatch", () => {
const notice = {
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
body: "ClinePass body",
url: "https://app.cline.bot/dashboard/subscription?personal=true",
openLabel: "Open ClinePass",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
process.argv = ["bun", "src/index.ts"];
@@ -816,7 +819,7 @@ describe("runCli lightweight command dispatch", () => {
await options?.onInitialNoticeShown?.(notice);
expect(
migrationNoticeMocks.markClineCliMigrationNoticeShown,
).toHaveBeenCalledTimes(1);
).toHaveBeenCalledWith(undefined, notice.id);
});
it("passes the active ClinePass provider into the migration notice gate", async () => {
@@ -1177,6 +1180,9 @@ describe("runCli lightweight command dispatch", () => {
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue({
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
body: "ClinePass body",
url: "https://app.cline.bot/dashboard/subscription?personal=true",
openLabel: "Open ClinePass",
});
process.argv = ["bun", "src/index.ts", "history"];
+2 -2
View File
@@ -1198,8 +1198,8 @@ export async function runCli(): Promise<void> {
activeProviderId: provider,
});
if (initialNotice) {
markInitialNoticeShown = () => {
markClineCliMigrationNoticeShown();
markInitialNoticeShown = (notice) => {
markClineCliMigrationNoticeShown(undefined, notice.id);
};
}
}
+5 -1
View File
@@ -15,7 +15,10 @@ import {
useDialogState,
} from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
import {
CLINE_PASS_NOTICE_ID,
shouldSuppressClineCliMigrationNoticeForActiveProvider,
} from "../kanban-migration/notice";
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
import {
isSameRepoStatus,
@@ -552,6 +555,7 @@ function App(props: TuiProps) {
if (initialNoticeShownRef.current) return;
if (appView !== "home") return;
if (
notice.id === CLINE_PASS_NOTICE_ID &&
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
) {
initialNoticeShownRef.current = true;
@@ -0,0 +1,820 @@
import { describe, expect, it, vi } from "vitest";
import {
CloudSessionApi,
CloudSessionError,
type CloudSessionRecord,
} from "./cloud-sessions";
const REMOTE_SESSION: CloudSessionRecord = {
id: "ses-outer",
status: "ready",
sandboxUrl: "https://pod.example/hub",
repoContext: { repoUrl: "https://github.com/cline/test" },
metadata: { modelId: "anthropic/claude-sonnet-5" },
createdAt: "2026-08-05T10:00:00.000Z",
updatedAt: "2026-08-05T10:01:00.000Z",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function jwtFor(subject: string, nonce: string): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return (
"workos:" +
encode({ alg: "none" }) +
"." +
encode({ sub: subject, nonce }) +
".sig"
);
}
describe("CloudSessionApi", () => {
it("resolves a fresh bearer token for every REST request", async () => {
const tokens = ["workos:first", "workos:second"];
const authorizations: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example/",
appBaseUrl: "https://app.example/",
getAuthToken: async () => tokens.shift(),
fetch: async (_input, init) => {
authorizations.push(
new Headers(init?.headers).get("Authorization") ?? "",
);
return jsonResponse({ success: true, data: [] });
},
});
await api.list();
await api.list();
expect(authorizations).toEqual([
"Bearer workos:first",
"Bearer workos:second",
]);
});
it("uses the dashboard create body and includes branch only when requested", async () => {
const bodies: Array<Record<string, unknown>> = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) => {
bodies.push(JSON.parse(String(init?.body)));
return jsonResponse(
{ success: true, data: { sessionId: "ses-1", sandboxUrl: "pod" } },
201,
);
},
});
await api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
branch: "feature/login-fix",
});
await api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect(bodies[0]).toMatchObject({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
branch: "feature/login-fix",
title: expect.stringMatching(/^__cline_create_request__:/),
});
expect(bodies[1]).toMatchObject({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
title: expect.stringMatching(/^__cline_create_request__:/),
});
expect(bodies[1]).not.toHaveProperty("branch");
});
it("treats a missing history snapshot (404) as null, not an empty archive", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async () => new Response("not found", { status: 404 }),
});
expect(await api.history("ses-1")).toBeNull();
});
it("accepts v1 history and rejects malformed snapshots instead of returning empty history", async () => {
const messages = [{ role: "user", content: "Hello" }];
let snapshot: unknown = { version: 1, messages };
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async () => jsonResponse(snapshot),
});
expect(await api.history("ses-1")).toEqual(messages);
snapshot = { version: 1, messages: [] };
expect(await api.history("ses-1")).toEqual([]);
for (const invalid of [
null,
{ version: 1 },
{ version: 2, messages: [] },
]) {
snapshot = invalid;
await expect(api.history("ses-1")).rejects.toMatchObject({
code: "request_failed",
detail: "Invalid archived session history",
});
}
});
it("returns the real id before polling readiness and reports provisioning phases", async () => {
vi.useFakeTimers();
const tokens = ["workos:create", "workos:create", "workos:new-account"];
const authorizations: string[] = [];
let statusCalls = 0;
const phases: Array<string | undefined> = [];
try {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => tokens.shift(),
fetch: async (input, init) => {
authorizations.push(
new Headers(init?.headers).get("Authorization") ?? "",
);
const url = new URL(String(input));
if (init?.method === "POST") {
return jsonResponse(
{
success: true,
data: { sessionId: "ses-1", status: "provisioning" },
},
201,
);
}
expect(url.pathname).toBe("/api/v1/session/ses-1/status");
statusCalls += 1;
return jsonResponse({
success: true,
data: {
sessionId: "ses-1",
status: statusCalls === 1 ? "provisioning" : "ready",
phase: statusCalls === 1 ? "cloning_repo" : "ready",
},
});
},
});
const created = await api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
expect(created).toMatchObject({
sessionId: "ses-1",
status: "provisioning",
});
expect(statusCalls).toBe(0);
const ready = api.waitUntilReady(
created.sessionId,
new AbortController().signal,
({ phase }) => phases.push(phase),
);
await vi.waitFor(() => expect(statusCalls).toBe(1));
await vi.advanceTimersByTimeAsync(3_000);
await expect(ready).resolves.toBeUndefined();
expect(statusCalls).toBe(2);
expect(phases).toEqual(["cloning_repo", "ready"]);
expect(authorizations).toEqual([
"Bearer workos:create",
"Bearer workos:create",
"Bearer workos:create",
]);
expect(tokens).toEqual(["workos:new-account"]);
} finally {
vi.useRealTimers();
}
});
it("refreshes an expired provisioning token without switching accounts", async () => {
const original = jwtFor("user-1", "original");
const refreshed = jwtFor("user-1", "refreshed");
const tokens = [original, refreshed];
const authorizations: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => tokens.shift(),
fetch: async (_input, init) => {
const authorization =
new Headers(init?.headers).get("Authorization") ?? "";
authorizations.push(authorization);
if (authorization === `Bearer ${original}`) {
return jsonResponse(
{ success: false, error: "authentication required" },
401,
);
}
return jsonResponse({
success: true,
data: { sessionId: "ses-1", status: "ready" },
});
},
});
await expect(
api.waitUntilReady("ses-1", new AbortController().signal),
).resolves.toBeUndefined();
expect(authorizations).toEqual([
`Bearer ${original}`,
`Bearer ${refreshed}`,
]);
});
it("does not switch accounts while refreshing provisioning auth", async () => {
const original = jwtFor("user-1", "original");
const otherAccount = jwtFor("user-2", "refreshed");
const tokens = [original, otherAccount];
let statusCalls = 0;
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => tokens.shift(),
fetch: async () => {
statusCalls += 1;
return jsonResponse(
{ success: false, error: "authentication required" },
401,
);
},
});
await expect(
api.waitUntilReady("ses-1", new AbortController().signal),
).rejects.toMatchObject({ code: "authentication_required" });
expect(statusCalls).toBe(1);
});
it("returns a recovered real id without waiting for provisioning", async () => {
const requests: string[] = [];
let recoveryTitle = "";
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:fresh",
fetch: async (input, init) => {
requests.push(
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
);
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
return jsonResponse({ success: false, error: "gateway" }, 500);
}
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-recovered",
title: recoveryTitle,
status: "provisioning",
sandboxUrl: "",
},
],
});
},
});
await expect(
api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
}),
).resolves.toMatchObject({
sessionId: "ses-recovered",
status: "provisioning",
});
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
});
it("recovers the real id after the create request times out", async () => {
vi.useFakeTimers();
const requests: string[] = [];
let recoveryTitle = "";
try {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
createTimeoutMs: 100,
getAuthToken: async () => "workos:fresh",
fetch: async (input, init) => {
requests.push(
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
);
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
return await new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener(
"abort",
() => reject(init.signal?.reason),
{ once: true },
);
});
}
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-recovered",
title: recoveryTitle,
status: "provisioning",
sandboxUrl: "",
},
],
});
},
});
const creating = api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
});
await vi.advanceTimersByTimeAsync(100);
await expect(creating).resolves.toMatchObject({
sessionId: "ses-recovered",
});
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
} finally {
vi.useRealTimers();
}
});
it("returns a failed recovered session without hiding its real id", async () => {
let recoveryTitle = "";
const requests: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:fresh",
fetch: async (input, init) => {
requests.push(
`${init?.method ?? "GET"} ${new URL(String(input)).pathname}`,
);
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
return jsonResponse({ success: false, error: "gateway" }, 500);
}
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
title: recoveryTitle,
status: "failed",
},
],
});
},
});
await expect(
api.create({
modelId: REMOTE_SESSION.metadata.modelId ?? "",
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
}),
).resolves.toMatchObject({ sessionId: "ses-outer", status: "failed" });
expect(requests).toEqual(["POST /api/v1/session", "GET /api/v1/session"]);
});
it("recovers a create accepted before a raw network failure", async () => {
let recoveryTitle = "";
let listCalls = 0;
const now = new Date().toISOString();
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) => {
if (init?.method === "POST") {
recoveryTitle = String(JSON.parse(String(init.body)).title);
throw new TypeError("fetch failed");
}
listCalls += 1;
return jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-recovered",
title: recoveryTitle,
createdAt: now,
updatedAt: now,
},
],
});
},
});
await expect(
api.create({
requestId: "request-a",
modelId: REMOTE_SESSION.metadata.modelId ?? "",
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
}),
).resolves.toMatchObject({ sessionId: "ses-recovered" });
expect(listCalls).toBe(1);
});
it("does not recover another process's identical session", async () => {
const now = new Date().toISOString();
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) =>
init?.method === "POST"
? jsonResponse({ success: false, error: "gateway timeout" }, 500)
: jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
id: "ses-other-process",
title: "__cline_create_request__:other-request",
createdAt: now,
updatedAt: now,
},
],
}),
});
await expect(
api.create({
requestId: "this-request",
modelId: REMOTE_SESSION.metadata.modelId ?? "",
repoUrl: REMOTE_SESSION.repoContext.repoUrl ?? "",
}),
).rejects.toMatchObject({ code: "request_failed" });
});
it("hides temporary create request titles from session lists", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async () =>
jsonResponse({
success: true,
data: [
{
...REMOTE_SESSION,
title: "__cline_create_request__:request-a",
},
],
}),
});
await expect(api.list()).resolves.toEqual([
expect.objectContaining({
id: "ses-outer",
title: undefined,
metadata: expect.objectContaining({
createRequestTitle: "__cline_create_request__:request-a",
}),
}),
]);
});
it("returns a stable, environment-aware GitHub connection error", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://staging-app.example/",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "GitHub is not connected" }, 412),
});
const error = await api
.create({ modelId: "model", repoUrl: "https://github.com/cline/test" })
.catch((caught) => caught);
expect(error).toBeInstanceOf(CloudSessionError);
expect(error.code).toBe("github_not_connected");
expect(error.message).toBe(
'CLOUD_SESSION_ERROR:{"code":"github_not_connected","message":"GitHub is not connected","connectUrl":"https://staging-app.example/dashboard/integrations"}',
);
});
it("routes organization GitHub setup to organization integrations", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://staging-app.example/",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "GitHub is not connected" }, 412),
});
const error = await api
.create({
modelId: "model",
repoUrl: "https://github.com/cline/test",
organizationId: "org-cline-bot",
})
.catch((caught) => caught);
expect(error).toBeInstanceOf(CloudSessionError);
expect(error.connectUrl).toBe(
"https://staging-app.example/dashboard/organization/integrations",
);
});
it("lists connected GitHub repositories and their branches", async () => {
const requestedPaths: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async (input) => {
const path = new URL(String(input)).pathname;
requestedPaths.push(path);
if (path.endsWith("/branches")) {
return jsonResponse({
success: true,
data: [{ name: "main" }, { name: "feature/cloud" }],
});
}
return jsonResponse({
success: true,
data: [
{
id: 42,
name: "cline",
full_name: "cline/cline",
html_url: "https://github.com/cline/cline",
clone_url: "https://github.com/cline/cline.git",
default_branch: "main",
},
],
});
},
});
expect(await api.listRepositories()).toEqual({
connected: true,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [
{
id: 42,
name: "cline",
fullName: "cline/cline",
url: "https://github.com/cline/cline",
defaultBranch: "main",
},
],
});
expect(await api.listBranches(42)).toEqual({
available: true,
branches: ["main", "feature/cloud"],
nextToken: "",
});
expect(requestedPaths).toEqual([
"/api/v1/integrations/github/repositories",
"/api/v1/integrations/github/repositories/42/branches",
]);
});
it("reads paginated branch responses and forwards search cursors", async () => {
let requestedUrl = "";
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async (input) => {
requestedUrl = String(input);
return jsonResponse({
success: true,
data: {
items: [{ name: "feature/cloud" }],
nextToken: "next/page",
},
});
},
});
expect(
await api.listBranches(42, undefined, {
cursor: "search cursor",
query: "feature/cloud",
}),
).toEqual({
available: true,
branches: ["feature/cloud"],
nextToken: "next/page",
});
const url = new URL(requestedUrl);
expect(url.pathname).toBe(
"/api/v1/integrations/github/repositories/42/branches",
);
expect(url.searchParams.get("query")).toBe("feature/cloud");
expect(url.searchParams.get("cursor")).toBe("search cursor");
});
it("filters legacy branch responses while backends roll out", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({
success: true,
data: [{ name: "main" }, { name: "feature/cloud" }],
}),
});
expect(await api.listBranches(42, undefined, { query: "FEATURE" })).toEqual(
{
available: true,
branches: ["feature/cloud"],
nextToken: "",
},
);
});
it("falls back to the repository default when the branch API is unavailable", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "route not found" }, 404),
});
expect(await api.listBranches(42)).toEqual({
available: false,
branches: [],
});
});
it("uses organization-scoped repository and branch endpoints", async () => {
const requestedPaths: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async (input) => {
const path = new URL(String(input)).pathname;
requestedPaths.push(path);
return jsonResponse({ success: true, data: [] });
},
});
expect(await api.listRepositories("org-cline-bot")).toMatchObject({
connected: true,
connectUrl: "https://app.example/dashboard/organization/integrations",
});
await api.listBranches(42, "org-cline-bot");
expect(requestedPaths).toEqual([
"/api/v1/organizations/org-cline-bot/integrations/github/repositories",
"/api/v1/organizations/org-cline-bot/integrations/github/repositories/42/branches",
]);
});
it("refuses ambiguous recovery for overlapping identical create requests", async () => {
const now = new Date().toISOString();
const record = (id: string, createdAt: string) => ({
id,
title: "__cline_create_request__:same-request",
status: "running",
sandboxUrl: `pod-${id}`,
repoContext: { repoUrl: "https://github.com/cline/test" },
metadata: { modelId: "anthropic/claude-sonnet-5" },
createdAt,
updatedAt: createdAt,
});
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) =>
init?.method === "POST"
? jsonResponse({ success: false, error: "gateway timeout" }, 500)
: jsonResponse({
success: true,
data: [
record("ses-newer", now),
record("ses-older", new Date(Date.now() - 1_000).toISOString()),
],
}),
});
const input = {
requestId: "same-request",
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
};
const error = await api.create(input).catch((caught) => caught);
expect(error).toMatchObject({ code: "request_failed" });
expect(String(error)).toContain("ambiguous result");
});
it("reports provisioning failure without deleting the known session", async () => {
const authorizations: string[] = [];
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:create",
fetch: async (input, init) => {
authorizations.push(
new Headers(init?.headers).get("Authorization") ?? "",
);
expect(new URL(String(input)).pathname).toBe(
"/api/v1/session/ses-failed/status",
);
return jsonResponse({
success: true,
data: {
sessionId: "ses-failed",
status: "failed",
statusReason: "clone failed",
},
});
},
});
await expect(
api.waitUntilReady("ses-failed", new AbortController().signal),
).rejects.toMatchObject({ code: "session_failed", detail: "clone failed" });
expect(authorizations).toEqual(["Bearer workos:create"]);
});
it("turns a generic forbidden response into actionable account guidance", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "forbidden" }, 403),
});
const error = await api
.create({ modelId: "model", repoUrl: "https://github.com/cline/test" })
.catch((caught) => caught);
expect(error).toBeInstanceOf(CloudSessionError);
expect(error.code).toBe("request_failed");
expect(error.status).toBe(403);
expect(error.message).toContain(
"Switch to Personal or another organization in Settings → Account",
);
});
it("does not run list recovery after a fast client-side rejection", async () => {
let listRequests = 0;
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example",
getAuthToken: async () => "sk_test",
fetch: async (_input, init) => {
if (init?.method === "POST") {
return jsonResponse({ success: false, error: "invalid branch" }, 422);
}
listRequests += 1;
return jsonResponse({ success: true, data: [] });
},
});
await expect(
api.create({
modelId: "anthropic/claude-sonnet-5",
repoUrl: "https://github.com/cline/test",
}),
).rejects.toThrow(/invalid branch/);
expect(listRequests).toBe(0);
});
it("returns the GitHub connection action when no integration exists", async () => {
const api = new CloudSessionApi({
apiBaseUrl: "https://api.example",
appBaseUrl: "https://app.example/",
getAuthToken: async () => "workos:test",
fetch: async () =>
jsonResponse({ success: false, error: "not connected" }, 404),
});
expect(await api.listRepositories()).toEqual({
connected: false,
connectUrl: "https://app.example/dashboard/integrations",
repositories: [],
});
});
});
@@ -0,0 +1,712 @@
import { randomUUID } from "node:crypto";
import { decodeJwtPayload } from "@cline/shared";
import type {
CloudBranchListOptions,
CloudBranchListResult,
CloudRepositoryListResult,
} from "../webview/lib/cloud-repositories";
const CREATE_TIMEOUT_MS = 610_000;
const PROVISIONING_POLL_MS = 3_000;
const REQUEST_TIMEOUT_MS = 15_000;
const CLOUD_ERROR_PREFIX = "CLOUD_SESSION_ERROR:";
const CREATE_REQUEST_TITLE_PREFIX = "__cline_create_request__:";
type FetchLike = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;
export type CloudSessionRecord = {
id: string;
status: string;
title?: string;
sandboxUrl: string;
repoContext: { repoUrl?: string; branch?: string };
metadata: {
modelId?: string;
statusReason?: string;
createRequestTitle?: string;
};
expiredAt?: string | null;
createdAt: string;
updatedAt: string;
};
export type CloudProvisioningOutcome =
| { status: "provisioning" }
| { status: "ready"; sessionId: string }
| { status: "failed"; message: string };
export function deriveCloudSessionTitle(prompt: string): string {
return (prompt.trim().split("\n")[0] ?? "").trim().slice(0, 72);
}
export type CreateCloudSessionInput = {
/** Stable client-planned id for single-flighting one chat's start request. */
requestId?: string;
modelId: string;
repoUrl: string;
initialPrompt?: string;
branch?: string;
autoApproveTools?: boolean;
thinking?: boolean;
reasoningEffort?: "low" | "medium" | "high" | "xhigh";
/** Omit for a personal session; otherwise scopes billing to this org. */
organizationId?: string;
};
export type {
CloudBranchListOptions,
CloudBranchListResult,
CloudRepositoryListResult,
CloudRepositoryOption,
} from "../webview/lib/cloud-repositories";
type CloudSessionApiOptions = {
apiBaseUrl: string;
appBaseUrl: string;
getAuthToken: () => Promise<string | undefined>;
fetch?: FetchLike;
createTimeoutMs?: number;
};
type CloudErrorCode =
| "authentication_required"
| "github_not_connected"
| "session_not_found"
| "session_expired"
| "session_failed"
| "request_failed";
export class CloudSessionError extends Error {
constructor(
readonly code: CloudErrorCode,
readonly detail: string,
readonly connectUrl?: string,
/** HTTP status of the failed request, when one was received. */
readonly status?: number,
) {
super(
`${CLOUD_ERROR_PREFIX}${JSON.stringify({ code, message: detail, connectUrl })}`,
);
this.name = "CloudSessionError";
}
}
type ApiResponse<T> = {
success?: boolean;
data?: T;
error?: string;
};
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function createRequestTitle(requestId: string): string {
return `${CREATE_REQUEST_TITLE_PREFIX}${requestId}`.slice(0, 255);
}
function isCreateRequestTitle(title: string | undefined): boolean {
return title?.startsWith(CREATE_REQUEST_TITLE_PREFIX) === true;
}
type CreationAuth = {
token: string;
subject?: string;
};
type RequestAuth = string | CreationAuth;
function authSubject(token: string): string | undefined {
const payload = decodeJwtPayload(token.replace(/^workos:/, ""));
return typeof payload?.sub === "string" && payload.sub.trim()
? payload.sub.trim()
: undefined;
}
function waitForProvisioningPoll(signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(signal.reason);
};
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, PROVISIONING_POLL_MS);
signal.addEventListener("abort", onAbort, { once: true });
});
}
function readApiError(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const error = (payload as { error?: unknown }).error;
if (typeof error === "string" && error.trim()) {
return error.trim();
}
}
return fallback;
}
function cloudErrorForResponse(
status: number,
payload: unknown,
appBaseUrl: string,
githubConnectUrl?: string,
): CloudSessionError {
const message = readApiError(
payload,
`Cloud session request failed (${status})`,
);
if (status === 401) {
return new CloudSessionError("authentication_required", message);
}
if (status === 404) {
return new CloudSessionError("session_not_found", message);
}
if (status === 410) {
return new CloudSessionError("session_expired", message);
}
if (status === 412) {
return new CloudSessionError(
"github_not_connected",
message,
githubConnectUrl ??
`${trimTrailingSlash(appBaseUrl)}/dashboard/integrations`,
);
}
if (status === 403 && message.trim().toLowerCase() === "forbidden") {
return new CloudSessionError(
"request_failed",
"Your active account or organization cannot create cloud sessions. Switch to Personal or another organization in Settings → Account, then try again.",
undefined,
status,
);
}
return new CloudSessionError("request_failed", message, undefined, status);
}
export type CloudProvisioningPhase =
| "provisioning"
| "cloning_repo"
| "agent_starting"
| "ready"
| "failed";
function parseCloudProvisioningPhase(
value: unknown,
): CloudProvisioningPhase | undefined {
switch (value) {
case "provisioning":
case "cloning_repo":
case "agent_starting":
case "ready":
case "failed":
return value;
default:
return undefined;
}
}
export class CloudSessionApi {
private readonly apiBaseUrl: string;
private readonly appBaseUrl: string;
private readonly fetchImpl: FetchLike;
private readonly createTimeoutMs: number;
constructor(private readonly options: CloudSessionApiOptions) {
this.apiBaseUrl = trimTrailingSlash(options.apiBaseUrl);
this.appBaseUrl = trimTrailingSlash(options.appBaseUrl);
this.fetchImpl = options.fetch ?? fetch;
this.createTimeoutMs = options.createTimeoutMs ?? CREATE_TIMEOUT_MS;
}
private async request<T>(
path: string,
init: RequestInit = {},
githubConnectUrl?: string,
auth?: RequestAuth,
): Promise<T> {
let refreshed = false;
while (true) {
const token =
typeof auth === "string"
? auth
: (auth?.token ?? (await this.options.getAuthToken()));
if (!token?.trim()) {
throw new CloudSessionError(
"authentication_required",
"Sign in to Cline before starting a cloud session.",
);
}
const response = await this.fetchImpl(`${this.apiBaseUrl}${path}`, {
...init,
signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/json",
Authorization: `Bearer ${token.trim()}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const payload =
response.status === 204
? undefined
: await response.json().catch(() => undefined);
if (!response.ok) {
if (
response.status === 401 &&
typeof auth === "object" &&
!refreshed &&
(await this.refreshCreationAuth(auth))
) {
refreshed = true;
continue;
}
throw cloudErrorForResponse(
response.status,
payload,
this.appBaseUrl,
githubConnectUrl,
);
}
return (payload as ApiResponse<T> | undefined)?.data as T;
}
}
private async refreshCreationAuth(auth: CreationAuth): Promise<boolean> {
if (!auth.subject) return false;
const freshToken = (await this.options.getAuthToken())?.trim();
if (
!freshToken ||
freshToken === auth.token ||
authSubject(freshToken) !== auth.subject
) {
return false;
}
auth.token = freshToken;
return true;
}
async list(organizationId?: string): Promise<CloudSessionRecord[]> {
return await this.listWithToken(organizationId);
}
private async listWithToken(
organizationId?: string,
auth?: RequestAuth,
preserveCreateRequestTitle = false,
): Promise<CloudSessionRecord[]> {
const query = organizationId?.trim()
? `?organizationId=${encodeURIComponent(organizationId.trim())}`
: "";
const rows =
(await this.request<CloudSessionRecord[]>(
`/api/v1/session${query}`,
{},
undefined,
auth,
)) ?? [];
// Keep malformed account records from breaking discovery or recovery.
return rows.flatMap((row) => {
if (!row || typeof row !== "object" || typeof row.id !== "string") {
return [];
}
return [
{
...row,
title:
!preserveCreateRequestTitle && isCreateRequestTitle(row.title)
? undefined
: row.title,
repoContext:
row.repoContext && typeof row.repoContext === "object"
? row.repoContext
: {},
metadata: {
...(row.metadata && typeof row.metadata === "object"
? row.metadata
: {}),
...(!preserveCreateRequestTitle && isCreateRequestTitle(row.title)
? { createRequestTitle: row.title }
: {}),
},
},
];
});
}
async listRepositories(
organizationId?: string,
): Promise<CloudRepositoryListResult> {
const normalizedOrganizationId = organizationId?.trim();
const connectUrl = normalizedOrganizationId
? `${this.appBaseUrl}/dashboard/organization/integrations`
: `${this.appBaseUrl}/dashboard/integrations`;
const path = normalizedOrganizationId
? `/api/v1/organizations/${encodeURIComponent(normalizedOrganizationId)}/integrations/github/repositories`
: "/api/v1/integrations/github/repositories";
try {
const repositories =
(await this.request<
Array<{
id?: unknown;
name?: unknown;
full_name?: unknown;
html_url?: unknown;
clone_url?: unknown;
default_branch?: unknown;
}>
>(path)) ?? [];
return {
connected: true,
connectUrl,
repositories: repositories.flatMap((repository) => {
const id = Number(repository.id);
const url = String(
repository.html_url ?? repository.clone_url ?? "",
).trim();
if (!Number.isSafeInteger(id) || id <= 0 || !url) return [];
const name = String(repository.name ?? "").trim();
return [
{
id,
name,
fullName: String(repository.full_name ?? (name || url)).trim(),
url,
defaultBranch: String(repository.default_branch ?? "").trim(),
},
];
}),
};
} catch (error) {
if (
error instanceof CloudSessionError &&
error.code === "session_not_found"
) {
return { connected: false, connectUrl, repositories: [] };
}
throw error;
}
}
async listBranches(
repositoryId: number,
organizationId?: string,
options: CloudBranchListOptions = {},
): Promise<CloudBranchListResult> {
if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0) {
throw new CloudSessionError(
"request_failed",
"Select a GitHub repository before loading branches.",
);
}
const normalizedOrganizationId = organizationId?.trim();
const path = normalizedOrganizationId
? `/api/v1/organizations/${encodeURIComponent(normalizedOrganizationId)}/integrations/github/repositories/${repositoryId}/branches`
: `/api/v1/integrations/github/repositories/${repositoryId}/branches`;
const search = new URLSearchParams();
const query = options.query?.trim();
const cursor = options.cursor?.trim();
if (query) search.set("query", query);
if (cursor) search.set("cursor", cursor);
const requestPath = search.size > 0 ? `${path}?${search}` : path;
try {
const payload = await this.request<
| Array<{ name?: unknown }>
| {
items?: Array<{ name?: unknown }>;
nextToken?: unknown;
}
>(requestPath);
const branches = Array.isArray(payload)
? payload
: Array.isArray(payload?.items)
? payload.items
: [];
const normalizedQuery = query?.toLowerCase();
return {
available: true,
branches: branches.flatMap((branch) => {
const name = String(branch.name ?? "").trim();
return name &&
(!Array.isArray(payload) ||
!normalizedQuery ||
name.toLowerCase().includes(normalizedQuery))
? [name]
: [];
}),
nextToken: Array.isArray(payload)
? ""
: String(payload?.nextToken ?? "").trim(),
};
} catch (error) {
if (
error instanceof CloudSessionError &&
error.code === "session_not_found"
) {
return { available: false, branches: [] };
}
throw error;
}
}
async status(
sessionId: string,
options: { authToken?: string; signal?: AbortSignal } = {},
): Promise<{
sessionId?: string;
status?: string;
phase?: CloudProvisioningPhase;
statusReason?: string;
}> {
return await this.request(
`/api/v1/session/${encodeURIComponent(sessionId)}/status`,
{ signal: options.signal },
undefined,
options.authToken,
);
}
async create(input: CreateCloudSessionInput): Promise<{
sessionId: string;
status: string;
sandboxUrl: string;
cleanupAuthToken: string;
}> {
const initialAuthToken = (await this.options.getAuthToken())?.trim();
if (!initialAuthToken) {
throw new CloudSessionError(
"authentication_required",
"Sign in to Cline before starting a cloud session.",
);
}
const creationAuth: CreationAuth = {
token: initialAuthToken,
subject: authSubject(initialAuthToken),
};
const recoveryTitle = createRequestTitle(
input.requestId?.trim() || randomUUID(),
);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.createTimeoutMs);
try {
const created = await this.request<{
sessionId: string;
sandboxUrl?: string;
status?: string;
}>(
"/api/v1/session",
{
method: "POST",
body: JSON.stringify({
modelId: input.modelId,
repoUrl: input.repoUrl,
title: recoveryTitle,
...(input.branch?.trim() ? { branch: input.branch.trim() } : {}),
...(input.organizationId?.trim()
? { organizationId: input.organizationId.trim() }
: {}),
}),
signal: controller.signal,
},
input.organizationId?.trim()
? `${this.appBaseUrl}/dashboard/organization/integrations`
: undefined,
creationAuth,
);
const sessionId = created?.sessionId?.trim();
if (!sessionId) {
throw new CloudSessionError(
"request_failed",
"The cloud session service returned no session id.",
);
}
return {
sessionId,
status: created.status?.trim() || "provisioning",
sandboxUrl: created.sandboxUrl?.trim() ?? "",
cleanupAuthToken: creationAuth.token,
};
} catch (error) {
// Recover only failures that may have followed an accepted POST.
const mayStillBeProvisioning =
controller.signal.aborted ||
!(error instanceof CloudSessionError) ||
(error instanceof CloudSessionError &&
error.code === "request_failed" &&
(error.status === undefined || error.status >= 500));
if (mayStillBeProvisioning) {
const requestedBranch = input.branch?.trim();
// The title carries the request identity because the API lacks idempotency.
const candidates = (
await this.listWithToken(
input.organizationId,
creationAuth,
true,
).catch(() => [])
).filter(
(session) =>
session.title === recoveryTitle &&
session.repoContext.repoUrl === input.repoUrl &&
session.metadata.modelId === input.modelId &&
(!requestedBranch ||
session.repoContext.branch === requestedBranch),
);
if (candidates.length > 1) {
throw new CloudSessionError(
"request_failed",
"Cloud session creation had an ambiguous result. Check your cloud session list before trying again.",
);
}
const recovered = candidates[0];
if (recovered) {
return {
sessionId: recovered.id,
status: recovered.status,
sandboxUrl: recovered.sandboxUrl,
cleanupAuthToken: creationAuth.token,
};
}
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async waitUntilReady(
sessionId: string,
signal: AbortSignal,
onStatus?: (status: { phase?: CloudProvisioningPhase }) => void,
): Promise<void> {
signal = AbortSignal.any([
signal,
AbortSignal.timeout(this.createTimeoutMs),
]);
const token = await this.options.getAuthToken();
const authToken = token
? { token, subject: authSubject(token) }
: undefined;
while (!signal.aborted) {
let result:
| {
sessionId?: string;
status?: string;
phase?: CloudProvisioningPhase;
statusReason?: string;
}
| undefined;
try {
result = await this.request(
`/api/v1/session/${encodeURIComponent(sessionId)}/status`,
{
signal: AbortSignal.any([
signal,
AbortSignal.timeout(REQUEST_TIMEOUT_MS),
]),
},
undefined,
authToken,
);
} catch (error) {
if (signal.aborted) throw error;
if (
error instanceof CloudSessionError &&
error.code !== "request_failed"
) {
throw error;
}
await waitForProvisioningPoll(signal);
continue;
}
const status = result?.status?.trim().toLowerCase();
onStatus?.({ phase: parseCloudProvisioningPhase(result?.phase) });
if (status === "ready" || status === "active") return;
if (status === "failed") {
throw new CloudSessionError(
"session_failed",
result?.statusReason?.trim() ||
"The cloud sandbox could not be prepared.",
);
}
if (status !== "provisioning") {
throw new CloudSessionError(
"request_failed",
"The cloud session service returned an unexpected provisioning status.",
);
}
await waitForProvisioningPoll(signal);
}
throw signal.reason;
}
async delete(sessionId: string, authToken?: string): Promise<void> {
await this.deleteWithAuth(sessionId, authToken);
}
private async deleteWithAuth(
sessionId: string,
auth?: RequestAuth,
): Promise<void> {
await this.request(
`/api/v1/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" },
undefined,
auth,
);
}
async updateTitle(
sessionId: string,
title: string,
): Promise<CloudSessionRecord> {
return await this.request<CloudSessionRecord>(
`/api/v1/session/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
body: JSON.stringify({ title }),
},
);
}
/** Raw archived snapshot; null distinguishes a missing archive from []. */
async history(sessionId: string): Promise<unknown[] | null> {
const token = await this.options.getAuthToken();
if (!token?.trim()) {
throw new CloudSessionError(
"authentication_required",
"Sign in to Cline to load this session's history.",
);
}
const response = await this.fetchImpl(
`${this.apiBaseUrl}/api/v1/session/${encodeURIComponent(sessionId)}/history`,
{
headers: {
Accept: "application/json",
Authorization: `Bearer ${token.trim()}`,
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
},
);
if (response.status === 404) {
return null;
}
const payload = await response.json().catch(() => undefined);
if (!response.ok) {
throw cloudErrorForResponse(response.status, payload, this.appBaseUrl);
}
if (payload?.version !== 1 || !Array.isArray(payload.messages)) {
throw new CloudSessionError(
"request_failed",
"Invalid archived session history",
);
}
return payload.messages;
}
}
@@ -291,6 +291,40 @@ describe("ChatInputBar", () => {
expect(onSend).toHaveBeenCalledWith("What is this?");
});
it("does not send when Enter commits an IME composition", async () => {
const onSend = vi.fn();
await renderVoiceComposer({ onSend, prompt: "你好" });
const textarea = container.querySelector("textarea");
await act(async () => {
textarea?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
isComposing: true,
bubbles: true,
}),
);
});
// WebKit fires the committing Enter after compositionend with
// isComposing false but keyCode 229.
await act(async () => {
textarea?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
keyCode: 229,
bubbles: true,
}),
);
});
expect(onSend).not.toHaveBeenCalled();
await act(async () => {
textarea?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
expect(onSend).toHaveBeenCalledWith("你好");
});
it("allows a parent session with a running child agent to be stopped", async () => {
const onAbort = vi.fn();
await renderVoiceComposer({
@@ -1557,8 +1591,12 @@ describe("ChatInputBar", () => {
'[aria-label^="Model:"]',
);
expect(modelTrigger?.textContent).toContain("Claude Opus 5");
expect(loadProviderModelsMock).toHaveBeenCalledTimes(1);
await act(async () => modelTrigger?.click());
// Opening re-fetches so the tiers reflect the current feed.
expect(loadProviderModelsMock).toHaveBeenCalledTimes(2);
expect(loadProviderModelsMock).toHaveBeenLastCalledWith("cline");
const panel = document.querySelector('[role="dialog"]');
expect(panel?.textContent).toContain("Recommended");
expect(panel?.textContent).toContain("Free");
@@ -1817,6 +1855,34 @@ describe("ChatInputBar", () => {
).toEqual(selection);
});
it("keeps the live model name while the picker-open refresh is in flight", async () => {
mockBundledCatalog();
loadProviderModelsMock.mockResolvedValue([flash, kimi]);
await renderComposer({ model: kimi.id, provider: "cline-pass" });
const modelTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label^="Model:"]',
);
await vi.waitFor(() => {
expect(modelTrigger?.textContent).toContain(kimi.name);
});
loadProviderModelCatalogMock.mockClear();
let resolveModels!: (models: ProviderModel[]) => void;
loadProviderModelsMock.mockReturnValue(
new Promise<ProviderModel[]>((resolve) => {
resolveModels = resolve;
}),
);
await act(async () => modelTrigger?.click());
// Only the live list is re-fetched; re-applying the bundled catalog
// (which lacks kimi) would flash the raw id in the trigger.
expect(modelTrigger?.textContent).toContain(kimi.name);
expect(loadProviderModelsMock).toHaveBeenLastCalledWith("cline-pass");
expect(loadProviderModelCatalogMock).not.toHaveBeenCalled();
await act(async () => resolveModels([flash, kimi]));
expect(modelTrigger?.textContent).toContain(kimi.name);
});
it.each([
"catalog refresh",
"new chat",
@@ -1263,6 +1263,12 @@ function ChatInputBarImpl({
}
}}
onKeyDown={(e) => {
// While an IME (e.g. Chinese/Japanese) is composing, Enter
// commits the composition and arrows move between candidates,
// so leave those keys to the IME. WebKit can fire the committing
// Enter after compositionend with isComposing already false but
// the legacy keyCode 229, hence the second check.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
// Slash command menu takes priority when open.
if (slashOpen && filteredSlashCommands.length > 0) {
if (e.key === "ArrowDown") {
@@ -1642,6 +1648,43 @@ const ModelSelector = memo(function ModelSelector({
readModelSelectionStorageFromWindow(),
);
const [mobileOpen, setMobileOpen] = useState(false);
const applyProviderModels = useCallback(
(providerId: string, models: ProviderModel[]) => {
setProviderModels((current) => ({
...current,
[providerId]: models.map((entry) => entry.id),
}));
setProviderReasoningModels((current) => ({
...current,
[providerId]: models
.filter((entry) => entry.supportsReasoning)
.map((entry) => entry.id),
}));
setModelDetails((current) => ({
...current,
[providerId]: models,
}));
setEnabledProviderIds((current) =>
current.includes(providerId) ? current : [...current, providerId],
);
},
[],
);
// Re-fetch only the live list on picker open so the Recommended/Free tiers
// stay current. Re-running the full load would first re-apply the bundled
// catalog and briefly flash a stale name in the trigger.
const refreshActiveProviderModels = useCallback(() => {
if (!normalizedProvider) return;
loadProviderModels(normalizedProvider)
.then((models) => {
if (models.length === 0) return;
applyProviderModels(normalizedProvider, models);
setReasoningCapabilitySource("catalog");
})
.catch(() => {
// Keep the current list when the refresh fails.
});
}, [applyProviderModels, normalizedProvider]);
const visibleProviderModels = useMemo(() => {
const next: Record<string, string[]> = {};
for (const providerId of enabledProviderIds) {
@@ -1830,28 +1873,8 @@ const ModelSelector = memo(function ModelSelector({
if (cancelled || models.length === 0) {
return;
}
const modelIds = models.map((entry) => entry.id);
const reasoningModelIds = models
.filter((entry) => entry.supportsReasoning)
.map((entry) => entry.id);
setProviderModels((current) => ({
...current,
[normalizedProvider]: modelIds,
}));
setProviderReasoningModels((current) => ({
...current,
[normalizedProvider]: reasoningModelIds,
}));
setModelDetails((current) => ({
...current,
[normalizedProvider]: models,
}));
applyProviderModels(normalizedProvider, models);
setReasoningCapabilitySource("catalog");
setEnabledProviderIds((current) =>
current.includes(normalizedProvider)
? current
: [...current, normalizedProvider],
);
} catch {
// Keep the catalog values when provider-specific loading fails.
}
@@ -1861,30 +1884,13 @@ const ModelSelector = memo(function ModelSelector({
return () => {
cancelled = true;
};
}, [normalizedProvider]);
}, [applyProviderModels, normalizedProvider]);
useEffect(() => {
return subscribeToProviderModels((providerId, models) => {
const normalizedId = normalizeProviderId(providerId);
setProviderModels((current) => ({
...current,
[normalizedId]: models.map((entry) => entry.id),
}));
setProviderReasoningModels((current) => ({
...current,
[normalizedId]: models
.filter((entry) => entry.supportsReasoning)
.map((entry) => entry.id),
}));
setModelDetails((current) => ({
...current,
[normalizedId]: models,
}));
setEnabledProviderIds((current) =>
current.includes(normalizedId) ? current : [...current, normalizedId],
);
applyProviderModels(normalizeProviderId(providerId), models);
});
}, []);
}, [applyProviderModels]);
// The remembered selection (what new sessions default to) is only written
// from the explicit picker handlers below. Mirroring every provider/model
@@ -2064,6 +2070,7 @@ const ModelSelector = memo(function ModelSelector({
className={triggerClassName}
disabled={isBusy || visibleModelPicker.options.length === 0}
emptyText="No models found."
onOpen={refreshActiveProviderModels}
onValueChange={(value) => {
handleModelSelect(value);
if (closeMobileMenu) setMobileOpen(false);
@@ -15,10 +15,13 @@ import {
sortProvidersForApiKeySetup,
} from "./onboarding-view";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
const { invoke, openExternalUrl } = vi.hoisted(() => ({
invoke: vi.fn(),
openExternalUrl: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke, subscribe: vi.fn(() => () => {}) },
openExternalUrl: vi.fn(),
openExternalUrl,
}));
const GITHUB_STEP_ENABLED_FLAGS = {
@@ -220,6 +223,15 @@ describe("OnboardingView", () => {
const clineOption = container.querySelector(
'[data-onboarding-option="cline"]',
);
expect(
Array.from(clineOption?.querySelectorAll("li") ?? []).map((item) =>
item.textContent?.trim(),
),
).toEqual([
"Regular free model promotions",
"Subscribe to ClinePass for generous usage across the best open weights models like DeepSeek, Kimi, and GLM",
"No API key needed",
]);
const apiKeyOption = container.querySelector(
'[data-onboarding-option="api-key"]',
);
@@ -389,6 +401,16 @@ describe("OnboardingView", () => {
if (command === "get_feature_flags") {
return GITHUB_STEP_ENABLED_FLAGS;
}
if (command === "list_cline_recommended_models") {
return {
recommended: [],
free: [
{ id: "deepseek/deepseek-v4-flash", name: "deepseek-v4-flash" },
{ id: "cline-free/solar-pro4", name: "Solar Pro 4" },
],
clinePass: [],
};
}
return {};
});
await render();
@@ -408,6 +430,19 @@ describe("OnboardingView", () => {
// The redesigned completion step places transparent content over a static,
// wide version of the hero grid.
expect(container.textContent).toContain("You're all set");
// A Cline sign-in ends on the current free models plus the ClinePass upsell.
const clineModels = container.querySelector(
"[data-onboarding-cline-models]",
);
expect(clineModels?.textContent).toContain("Free models");
expect(clineModels?.textContent).toContain("deepseek-v4-flash");
expect(clineModels?.textContent).toContain("Solar Pro 4");
await act(async () => {
buttonByText("Get ClinePass").click();
});
expect(openExternalUrl).toHaveBeenCalledWith(
"https://app.cline.bot/onboarding/individual-plan",
);
const doneGrid = container.querySelector<HTMLElement>(
'[data-welcome-hero-variant="grid-only"]',
);
@@ -3,6 +3,7 @@
import { AgentWelcomeHero, Button, IconButton } from "@cline/ui";
import {
ArrowLeft,
Check,
CheckCircle2,
ChevronDown,
ExternalLink,
@@ -51,6 +52,18 @@ import {
import { cn } from "@/lib/utils";
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
const CLINE_PASS_SUBSCRIBE_URL =
"https://app.cline.bot/onboarding/individual-plan";
const CLINE_SIGN_IN_BENEFITS = [
"Regular free model promotions",
"Subscribe to ClinePass for generous usage across the best open weights models like DeepSeek, Kimi, and GLM",
"No API key needed",
];
type ClineRecommendedModelsResponse = {
free?: { id: string; name?: string; description?: string }[];
};
export const GITHUB_ONBOARDING_FEATURE_FLAG = "code-onboarding-github";
@@ -229,7 +242,7 @@ function SetupOptionHeader({
title,
}: {
accessory?: React.ReactNode;
description: string;
description: React.ReactNode;
icon: React.ReactNode;
title: string;
}) {
@@ -240,7 +253,7 @@ function SetupOptionHeader({
</span>
<div className="min-w-0 mt-1 max-[720px]:col-span-3 max-[720px]:col-start-1 max-[720px]:row-start-2 max-[720px]:mt-0">
<h4 className="text-lg font-semibold text-foreground">{title}</h4>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<div className="mt-2 text-sm text-muted-foreground">{description}</div>
</div>
{accessory ? (
<div className="mt-1 max-[720px]:col-start-3 max-[720px]:row-start-1 max-[720px]:mt-0">
@@ -560,7 +573,19 @@ function ConnectStep({
Recommended
</Badge>
}
description="Latest models with regular free promos. No API keys needed."
description={
<ul className="flex flex-col gap-1">
{CLINE_SIGN_IN_BENEFITS.map((benefit) => (
<li className="flex gap-2" key={benefit}>
<Check
aria-hidden="true"
className="mt-0.5 size-3.5 shrink-0 text-primary"
/>
<span>{benefit}</span>
</li>
))}
</ul>
}
icon={<ClineLogo className="size-5" />}
title="Sign in with Cline"
/>
@@ -992,6 +1017,80 @@ function ImportHistoryStep({
);
}
/**
* Shown after a Cline sign-in: the free models available right now (from the
* same feed as the composer's Free tier, bundled fallback offline) and the
* ClinePass upsell. The feed is display-only here; the user picks a model in
* the composer.
*/
function ClineModelsSummary() {
const [freeModels, setFreeModels] = useState<
NonNullable<ClineRecommendedModelsResponse["free"]>
>([]);
useEffect(() => {
let cancelled = false;
desktopClient
.invoke<ClineRecommendedModelsResponse>("list_cline_recommended_models")
.then((response) => {
if (!cancelled) {
setFreeModels(response?.free ?? []);
}
})
.catch(() => {
// The upsell still renders without the model list.
});
return () => {
cancelled = true;
};
}, []);
return (
<div
className="mt-6 w-full rounded-xl border border-border bg-background p-5 text-left"
data-onboarding-cline-models
>
{freeModels.length > 0 ? (
<>
<h2 className="text-sm font-semibold text-foreground">Free models</h2>
<p className="mt-1 text-xs text-muted-foreground">
Try with limited usage at no cost.
</p>
<ul className="mt-3 flex flex-wrap gap-1.5">
{freeModels.map((model) => (
<li key={model.id}>
<Badge variant="outline">
{model.name?.trim() || model.id}
</Badge>
</li>
))}
</ul>
<div className="my-4 border-t border-border" />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-foreground">ClinePass</h2>
<p className="mt-1 text-xs text-muted-foreground">
Generous usage across the best open weights models like DeepSeek,
Kimi, and GLM.
</p>
</div>
<Button
onClick={() => void openExternalUrl(CLINE_PASS_SUBSCRIBE_URL)}
size="sm"
tone="neutral"
type="button"
variant="surface"
>
Get ClinePass
<ExternalLink className="size-3.5" />
</Button>
</div>
</div>
);
}
function DoneStep({
connection,
onFinish,
@@ -1011,6 +1110,7 @@ function DoneStep({
? `${connection.providerName} is connected.`
: "Your Cline account is connected."}
</p>
{connection?.kind === "cline" ? <ClineModelsSummary /> : null}
<Button
className="mt-8 w-full max-w-64"
onClick={onFinish}
@@ -0,0 +1,64 @@
export type CloudRepositoryOption = {
id: number;
name: string;
fullName: string;
url: string;
defaultBranch: string;
};
export type CloudRepositoryListResult = {
connected: boolean;
connectUrl: string;
repositories: CloudRepositoryOption[];
};
export type CloudBranchListResult = {
available: boolean;
branches: string[];
nextToken?: string;
};
export type CloudBranchListOptions = {
cursor?: string;
query?: string;
};
export function normalizeCloudRepositoryUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
}
export function cloudRepositoryLabel(repoUrl: string, fallback = ""): string {
const parts = normalizeCloudRepositoryUrl(repoUrl)
.replace(/\.git$/i, "")
.split(/[/:]/)
.filter(Boolean);
return parts.slice(-2).join("/") || fallback;
}
export function isGitHubRepositoryUrl(value: string): boolean {
const normalized = normalizeCloudRepositoryUrl(value);
if (!normalized) return false;
try {
const url = new URL(normalized);
const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
return (
url.protocol === "https:" &&
url.hostname.toLowerCase() === "github.com" &&
parts.length === 2 &&
parts.every(Boolean)
);
} catch {
return false;
}
}
export function preferredCloudBranch(
branches: string[],
defaultBranch: string,
): string {
const preferred = defaultBranch.trim();
if (preferred && branches.includes(preferred)) return preferred;
if (branches.includes("main")) return "main";
if (branches.includes("master")) return "master";
return branches[0]?.trim() ?? preferred;
}
@@ -66,6 +66,8 @@ class FakeWebSocket {
}
const sockets: FakeWebSocket[] = [];
// First reconnect delay: RECONNECT_BASE_DELAY_MS * 2 ** 1.
const RECONNECT_FIRST_DELAY_MS = 800;
const originalWebSocket = globalThis.WebSocket;
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => new Response(null, { status: 202 }));
@@ -364,6 +366,70 @@ describe("DesktopClient command deadlines", () => {
});
});
describe("DesktopClient endpoint resolution", () => {
const tauriInvoke = vi.fn<(command: string) => Promise<string>>();
beforeEach(() => {
tauriInvoke.mockReset();
delete (window as unknown as Record<string, unknown>)
.__SIDECAR_WS_ENDPOINT__;
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
vi.doMock("@tauri-apps/api/core", () => ({ invoke: tauriInvoke }));
});
afterEach(() => {
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
vi.doUnmock("@tauri-apps/api/core");
});
it("keeps retrying when the Tauri shell reports the backend endpoint is not ready yet", async () => {
tauriInvoke
.mockRejectedValueOnce(new Error("desktop backend endpoint not ready"))
.mockResolvedValue("ws://127.0.0.1:3126/transport?approval_token=fresh");
const { desktopClient } = await import("./desktop-client");
const states: string[] = [];
desktopClient.subscribeTransportState((state) => states.push(state));
await vi.waitFor(() => expect(states).toContain("unavailable"));
expect(desktopClient.getTransportError()).toContain(
"desktop backend endpoint not ready",
);
expect(sockets).toHaveLength(0);
await vi.advanceTimersByTimeAsync(RECONNECT_FIRST_DELAY_MS);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
expect(tauriInvoke).toHaveBeenCalledTimes(2);
expect(sockets[0]?.url).toBe(
"ws://127.0.0.1:3126/transport?approval_token=fresh",
);
sockets[0]?.open();
expect(desktopClient.getTransportState()).toBe("connected");
expect(desktopClient.getTransportError()).toBeNull();
});
it("re-resolves the endpoint when reconnecting after the transport drops", async () => {
tauriInvoke
.mockResolvedValueOnce("ws://127.0.0.1:3126/transport?approval_token=old")
.mockResolvedValueOnce(
"ws://127.0.0.1:3126/transport?approval_token=new",
);
const { desktopClient } = await import("./desktop-client");
desktopClient.subscribeTransportState(() => undefined);
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0]?.open();
sockets[0]?.close();
expect(desktopClient.getTransportState()).toBe("reconnecting");
await vi.advanceTimersByTimeAsync(RECONNECT_FIRST_DELAY_MS);
await vi.waitFor(() => expect(sockets).toHaveLength(2));
expect(tauriInvoke).toHaveBeenCalledTimes(2);
expect(sockets[1]?.url).toBe(
"ws://127.0.0.1:3126/transport?approval_token=new",
);
});
});
describe("writeDesktopDebugLog", () => {
it.each([
"debug",
@@ -423,8 +423,15 @@ class DesktopClient {
RECONNECT_BASE_DELAY_MS * 2 ** Math.min(attempt, 4),
RECONNECT_MAX_DELAY_MS,
);
// Re-resolve the endpoint on every attempt: a sidecar the Tauri shell
// respawned listens on the same port but issues a new approval token,
// and one that was still booting only publishes its endpoint later.
this.endpoint = null;
resolvedEndpointCache = null;
this.reconnectTimer = setTimeout(() => {
void this.ensureConnected(true);
void this.ensureConnected(true).catch(() => {
// The failure path already scheduled the next attempt.
});
}, delayMs);
}
@@ -487,6 +494,11 @@ class DesktopClient {
if (!this.hasConnectedOnce) {
this.setTransportState("unavailable");
}
// A failed connect usually means the sidecar is still booting
// (the Tauri endpoint command gives up after a fixed poll) or is
// being respawned by the shell's health check. Keep trying rather
// than parking the UI on "unavailable" until the app is relaunched.
this.scheduleReconnect();
throw error;
})
.finally(() => {
@@ -119,11 +119,14 @@ describe("createVscodeRunCommandsTool", () => {
// A profile change takes effect at the next description read (the
// model-request boundary), without a session rebuild.
mocks.getGlobalSettingsKey.mockReturnValue("powershell-7")
expect(tool.description).toContain("Microsoft PowerShell (pwsh.exe)")
expect(tool.description).toContain("PowerShell (pwsh.exe)")
expect(tool.description).not.toMatch(/PowerShell (?:Core|\d)/)
expect(tool.description).toContain("quote paths and arguments for pwsh.exe")
expect(tool.description).toContain("another pwsh.exe -Command invocation")
mocks.getGlobalSettingsKey.mockReturnValue("powershell-legacy")
expect(tool.description).toContain("Windows PowerShell (powershell.exe)")
expect(tool.description).toContain("quote paths and arguments for powershell.exe")
expect(tool.description).toContain("another powershell.exe -Command invocation")
})
@@ -145,7 +148,7 @@ describe("createVscodeRunCommandsTool", () => {
expect(getOrCreateTerminal).toHaveBeenLastCalledWith("C:\\workspace", "powershell-legacy")
const nextDescription = tool.description
expect(nextDescription).toContain("Microsoft PowerShell (pwsh.exe)")
expect(nextDescription).toContain("PowerShell (pwsh.exe)")
expect(nextDescription).not.toBe(legacyDescription)
expect(tool.description).toBe(nextDescription)
manager.runCommand = () => createFakeTerminalProcess({ lines: ["ok"] })
+6 -6
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.61",
"version": "3.0.62",
"bin": {
"cline": "src/index.ts",
},
@@ -639,7 +639,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.82",
"version": "0.0.83",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -648,7 +648,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.82",
"version": "0.0.83",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -686,7 +686,7 @@
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.82",
"version": "0.0.83",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^5.0.50",
"@ai-sdk/anthropic": "^4.0.36",
@@ -734,14 +734,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.82",
"version": "0.0.83",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.82",
"version": "0.0.83",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

+1
View File
@@ -105,6 +105,7 @@
{
"group": "Usage",
"pages": [
"usage/cline-desktop",
"usage/ide",
"usage/tui",
{
+213
View File
@@ -0,0 +1,213 @@
---
title: "Cline Desktop"
description: "An open source app for open weight models, with parallel sessions, scheduled tasks, flexible model choice, and imports from other coding agents."
---
**Cline Desktop is an open-source app for open-weight models.** It gives you a dedicated workspace to run and manage agent sessions outside your IDE.
Cline Desktop brings the same Cline agent harness for open weight models into a dedicated workspace. You can run multiple sessions in parallel, schedule recurring tasks, choose your provider and model, discover skills, MCP, plugins through the Marketplace, and import existing sessions from supported coding agents to continue in Cline.
<Card title="Download Cline Desktop" icon="download" href="https://cline.bot/desktop">
Download Cline Desktop for macOS or Windows. Windows support is currently in beta.
</Card>
## Getting started
To start using Cline Desktop:
1. Open the workspace you want Cline to work with, and select the branch you want the session to use.
2. Select the provider and model of your choice.
4. Start a new session and describe the task you want Cline to complete.
Cline Desktop keeps model choice open. You can:
- Access 300+ models through the Cline provider
- Use open-weight models through ClinePass
- Connect supported providers with your own API keys
- Run supported local models, or
- Continue tasks from Claude Code and Codex with an open-weight model.
<Note>
You can also get started with the free models available in Cline without connecting a paid provider. The available free models can change over time, so check the app for the current options.
</Note>
## Use Cline Desktop for coding and non-coding tasks
Cline Desktop gives you a workspace for running different kinds of agent work side by side. Keep coding, research, documentation, and recurring tasks in separate sessions so each retains its own context.
For example, you can have one agent implement a feature while another researches an API, reviews documents, prepares a report, or tracks changes over time.
## What you can do in Cline Desktop
### 1. Import workflows from other agents
If you already have context in another coding agent, you can import that workflow into Cline instead of starting from scratch. Cline Desktop supports importing conversations from supported agents such as Claude Code and Codex.
To import a workflow:
1. Open the **Import** section in Cline Desktop settings.
<Frame>
<img
src="/assets/desktop/desktop-import-workflows.png"
alt="Cline Desktop import workflows screen showing supported conversations available for import"
/>
</Frame>
2. Click on *Import Sessions* and choose the conversation you want to bring into Cline.
<Frame>
<img
src="/assets/desktop/desktop-import-select-sessions.png"
alt="Cline Desktop import workflows screen showing supported conversations available for import"
/>
</Frame>
3. Import the selected conversation.
4. Open the imported session and continue with your next instruction.
The imported conversation gives Cline the earlier context, while the continued task uses the provider and model you have configured in Cline.
For example, if you started debugging an issue in Claude Code, you can import that conversation and continue from the existing context with an open-weight model in Cline.
### 2. Automate recurring work with Schedule
Schedule lets Cline run agent tasks automatically at a specific time or on a recurring schedule.
You can use it for work you would otherwise need to start manually, such as morning PR review, a nightly repo check, or a weekly documentation update.
#### Create a schedule from the UI
To create a schedule:
1. Open **Schedule** in Cline Desktop.
<Frame>
<img
src="/assets/desktop/desktop-new-schedule.png"
alt="Cline Desktop Schedule page with the New Schedule button"
/>
</Frame>
2. Configure the schedule:
- Give it a name.
- Choose the frequency, date, and time.
- Enter the prompt Cline should run.
- Select the provider, model, and workspace.
- Optionally add a system prompt, timeout, or tags.
<Frame>
<img
src="/assets/desktop/desktop-schedule-config.png"
alt="Cline Desktop schedule configuration showing timing, prompt, provider, model, workspace, and optional settings"
/>
</Frame>
6. Save the schedule.
You can create one-time or recurring scheduled tasks depending on the workflow.
#### Create a schedule from a conversation
You can also create a schedule by describing the automation directly to Cline.
For example:
> Set up a daily automation that reviews new issues and pull requests in this repository and gives me a summary of anything that needs my attention.
This is useful when you already know what you want to automate and would rather describe it naturally than configure the schedule manually.
### 3. Use web search
Cline Desktop can use web search to bring current information into a task, such as checking documentation, researching a topic, or comparing options.
Web search is enabled by default for new sessions. You can manage it from **Settings** using the **Web Search** toggle.
<Frame>
<img
src="/assets/desktop/desktop-web-search-settings.png"
alt="Cline Desktop settings showing the Web Search toggle enabled"
/>
</Frame>
<Note>
Only providers with built-in web search honor this setting. Other providers ignore it. Changes to this setting apply to new sessions.
</Note>
Web search currently works without additional setup with:
- Cline (usage billing)
- ClinePass
- OpenAI subscription
- Anthropic
- Google Gemini
Availability depends on whether the selected model supports built-in web search.
### 4. Use voice input
Voice input lets you speak your prompt instead of typing it. This is useful for longer instructions, talking through a problem, or quickly getting an idea into Cline.
Voice input requires a compatible speech-to-text provider.
To set it up:
1. Open the **Voice Input** in Cline Desktop settings and select **Open Model Providers**.
<Frame>
<img
src="/assets/desktop/desktop-voice-open-providers.png"
alt="Cline Desktop Voice settings showing the Open Model Providers button"
/>
</Frame>
2. Choose a provider with a supported transcription model, such as ElevenLabs, and add your API key.
<Frame>
<img
src="/assets/desktop/desktop-voice-provider.png"
alt="Cline Desktop API Providers settings showing a transcription provider being configured"
/>
</Frame>
3. Return to **Voice Input**, enable it, and select your provider and transcription model.
<Frame>
<img
src="/assets/desktop/desktop-voice-enable.png"
alt="Cline Desktop Voice settings showing voice input enabled with a provider and transcription model selected"
/>
</Frame>
4. Once configured, the microphone appears in the chat composer. Select it to start speaking your prompt.
### 5. Extend and customize Cline
Use **Customize → Installed** to view and manage the capabilities available to Cline Desktop.
The Installed view acts as an inventory for **tools, plugins, skills, rules, MCP servers, and hooks**. From here, you can inspect what is available, search within each category, and enable or disable supported capabilities.
<Frame>
<img
src="/assets/desktop/desktop-customize-installed.png"
alt="Cline Desktop Customize view showing tools, plugins, skills, rules, MCP servers, and hooks"
/>
</Frame>
#### Discover more in Marketplace
Use **Marketplace** to find and add new capabilities to Cline.
You can browse or search across **skills, MCP servers, and plugins**, and filter by categories such as software development, data and analytics, productivity, research and docs, and more.
<Frame>
<img
src="/assets/desktop/desktop-marketplace.png"
alt="Cline Desktop Marketplace showing skills, MCP servers, plugins, search, and category filters"
/>
</Frame>
## Related
1. [ClinePass](/getting-started/clinepass)
2. [Cline Free Models](/getting-started/free-models)
3. [Local models](/running-models-locally/overview)
+17
View File
@@ -883,3 +883,20 @@ The following workspace apps are internal and not published as SDK packages:
- `apps/cli` — CLI implementation
- `apps/webview` — VS Code webview
- `apps/examples` — example plugins and integrations
### SSH environments
`core/src/remote` owns the reusable SSH environment service and standalone remote
helper entrypoint. Clients use `RemoteEnvironmentService.connect` to obtain an
authenticated loopback endpoint, then instantiate the ordinary `ClineCore` remote
backend. The helper uploads are content-addressed and the remote Hub binds only
to loopback. SSH forwards that endpoint to a local ephemeral port. The helper's
explicit discovery record is separate from the remote account's default Hub.
Desktop retains presentation, packaged-resource lookup, and its environment-to-
runtime bindings. Settings and the chat environment selector call the shared
service; each runtime binding supplies the same session/approval/event APIs.
Workspace and session reads route by environment identity. System-prompt
bootstrap happens on the remote host when the caller omits a prompt, so local
filesystem metadata is not embedded in remote sessions. Login-shell PATH
resolution also lives in core and is reused by the helper and desktop startup.
+38
View File
@@ -1,5 +1,43 @@
# Cline SDK Changelog
## 0.0.83
- Hub-managed Agent Plugins. Packages under `~/.agents/plugins/*` on the hub host are discovered and validated from their root `plugin.json`; valid skills under `skills/` are exposed through the skills tool as `plugin-name:skill-name`, and stdio, Streamable HTTP, and legacy SSE servers from `mcp.json` are started without touching `cline_mcp_settings.json`. Workspace `.agents/plugins` directories are deliberately not scanned, so opening a repo cannot implicitly start repo-controlled MCP servers; extra roots require an explicit `agentPluginPaths`. Enablement lives in hub settings keyed by manifest name and publishes `settings.changed`, so clients no longer need their own loader or enablement store. Two bugs fixed along the way: `settings.toggle({type: "skills"})` wrote a `disabled` key into a plugin skill's SKILL.md frontmatter, which the strict Agent Skills parser then rejected so the skill silently vanished until hand-edited; and `InMemoryMcpManager.dispose()` aborted on the first `disconnect()` rejection, leaking every remaining server's process
- A model turn that dies mid-stream with a transient provider error is now retried up to 3 times with exponential backoff instead of failing the whole run — a single forwarded 429 previously aborted the run outright. Retryability is read from the AI SDK's typed signals, and a turn is never retried once it has streamed any text, reasoning, media, or tool call, so nothing is duplicated. Model calls also now allow 5 SDK-level retries for request-start 429/5xx/network failures, up from 2
- Streaming is no longer throttled by hook forwarding. The hub proxied every runtime event to client-contributed `onEvent` hooks as a capability round trip carrying the full session snapshot, with the agent loop awaiting it — so each streamed token cost a few hundred KB of serialization, a persisted row, and a blocking IPC hop. Per-chunk text, reasoning, and tool-update deltas are no longer forwarded to remote `onEvent` hooks; every other event still reaches hooks unchanged. The hub event log also moved to `synchronous = NORMAL`, dropping one fsync per appended delta
- Checkpoints no longer re-hash unchanged untracked files on every turn. Each turn built a throwaway git index, so git re-read every untracked file before each model call — with multi-GB untracked data in the workspace this blocked every message for seconds to minutes. A persistent per-session snapshot index lets git's stat cache skip unchanged files, so from the second turn a snapshot costs about one git process. Snapshot contents are byte-identical to before. A corrupt index now heals with one rebuild-and-retry that also clears a stale `index.lock`, where previously a git process killed mid-add degraded that session to HEAD-only checkpoints permanently
- `run_commands` no longer hangs when a command backgrounds a child. The executor settled on the child's `close` event, which waits for the stdio pipes to drain; a backgrounded process (`cmd &`, `nohup`) holds the inherited write-ends open, so a finished command hung until the timeout killed the whole process tree. It now also settles on `exit` after a one-second grace period, reporting the exit code and the output collected so far
- `apply_patch` "Add File" against a path that already exists is now rejected instead of silently overwriting it. Only UPDATE and DELETE targets were pre-loaded, so the parser's "File already exists" guard never saw ADD targets and the patch destroyed the file's contents with no error and no diff of what was lost
- Nested PowerShell invocations are now unwrapped instead of being double-parsed. Command text is fed to PowerShell through a stdin bootstrap that the outer shell parses as PowerShell source, so a command written as `powershell -Command "... $_ ..."` had its argument interpolated before the nested shell ran — pipelines using `$_` emitted one error per enumerated item, an error flood that looked like a hang, while the child still exited 0. Unwrapping is applied only when it is semantics-preserving: same PowerShell edition, `-NoProfile`, and an entirely quoted `-Command` tail; everything else passes through byte-identical
- The `run_commands` tool description now names the actual PowerShell edition in use — `Windows PowerShell (powershell.exe)` versus `PowerShell (pwsh.exe)` or `PowerShell (pwsh)` — quotes its guidance against the resolved executable, and tells the model to write commands directly rather than wrapping them in another `-Command` or `/c` invocation. It also no longer claims "in Windows environment" when `pwsh` is the configured shell on macOS or Linux
- No file index is built when the workspace root is the home directory or a filesystem root. Running from `$HOME` and typing an `@` mention listed every file under home and re-ranked the whole index on each keystroke, driving the process to many GB of RSS until it was OOM-killed. Paths are canonicalized first so symlinked or differently-cased spellings still hit the guard
- Credential fields are now stripped of Unicode control and format characters and surrounding whitespace before being saved. A key pasted with an invisible character (BOM, zero-width space, bidi mark) was stored corrupted and the provider returned a 401 indistinguishable from a genuinely wrong key, while masked rendering hid the corruption
- The `editor` tool's error for a null `old_text` now names the file, says whether the parameter was null or omitted, and spells out the recovery. Models that fill optional parameters with null hit a terse "required" message and re-sent the identical call until the loop detector hard-stopped the run
- Provider-native web search is now enabled by default in non-yolo sessions on supported provider/model combinations, instead of requiring `tools.web_search.enabled = true`. An explicit `false` still opts out, and the settings loader fails closed: web search stays disabled when a global settings file exists but cannot be read or parsed
- Session history no longer hides root sessions behind their children. The list over-fetched a fixed window of rows and filtered out subagent and team-task children client-side; since children sort after the root that spawned them, one session with more children than the window hid itself and every older root. Listing now widens until the requested page fills, and the backends filter child rows in SQL
- Schedules no longer stall each other. The cron runner awaited the whole claimed batch, so one long agent turn blocked dispatch of every other schedule; dispatch is now decoupled from execution, and per-schedule and global parallelism are enforced inside the SQLite claim transaction so multiple connections cannot exceed a schedule's limit. Locally active claims are renewed each tick, so work resumed after system sleep is not reclaimed and started twice, and terminal status is written before the markdown report so a failed report write cannot replay a completed turn
- Recurring schedules created without a timezone now persist the host's local IANA timezone rather than an implicit default, and the scheduling tool prompts tell the model to omit the timezone instead of guessing
- Automation event acceptance is now atomic. The event log row was committed before specs were matched and runs materialized, so a failure mid-fan-out left the event permanently deduplicated and impossible to redeliver, and other connections could observe partially materialized runs. Acceptance now runs in one write transaction, and failures roll back and propagate to the caller for redelivery
- New `RemoteEnvironmentService` runs sessions on an SSH host while the client stays local. `connect()` inspects the remote host, uploads a self-contained helper binary to `~/.cline/remote/`, starts a loopback-bound Hub there, and opens an SSH tunnel to it; the returned endpoint and token are passed to `ClineCore.create({ backendMode: "remote" })`, after which tools, sessions, and persistence all execute remotely. SSH runs with `BatchMode=yes` and `StrictHostKeyChecking=yes`, so the host key must already be known and password authentication is not supported; profiles are stored at mode 0600 and hold identity-file paths only, never secrets. Remote targets are Linux and macOS on x64/arm64, and embedders must supply a helper binary cross-compiled for the target. This is an SDK building block — no CLI command or desktop UI is wired to it yet, and it is inert unless an embedder constructs the service
- A session started without an explicit `systemPrompt` now gets a default Cline prompt built on the execution host, rather than running with none. Callers that pass their own prompt are unaffected
- Hub daemons can now be started with connector management disabled, so an SSH-spawned Hub does not adopt or restart the account's Slack and Telegram connectors. Locally started hubs are unaffected
- Hub clients can now authenticate a remote connection with HTTP headers via a `resolveConnectionHeaders()` resolver, re-resolved on every reconnect so short-lived credentials can refresh. It is mutually exclusive with the local hub-token subprotocol. `NodeHubClient.connect()` was also reworked so concurrent calls share one in-flight promise and a stale registration or connect timeout can no longer clobber a newer attempt
- A hub running the same core version as the client no longer prompts to update. Two artifacts of the same release cut from different commits never share a build fingerprint, so anyone with both the desktop app and the CLI installed got a "Cline Hub was updated" dialog on every launch whose "Update and restart" looped on "no app update available". Genuinely different core releases still prompt
- Sessions seeded with history (forks, checkpoint restores) now persist their live idle status instead of defaulting to "running", which left a restored session showing as busy forever
- Sessions imported from Claude Code, Codex, or opencode now summarize the foreign history on first resume rather than replaying tool calls the current agent cannot make. The summary is persisted so it runs once, the canonical transcript is left intact, and a failed attempt falls back to the raw history. Imported sessions also record an import origin that is stamped on their telemetry, and resuming a session no longer overwrites stored import or automation provenance with a default "user" origin
- Session import source directories now resolve correctly on Windows profiles where `USERPROFILE` disagrees with what Node reports, and honor `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `XDG_DATA_HOME`
- Cline Pass and free models now report zero cost. The backend reports upstream market cost on those responses and it was passed straight through, so subscription and free usage showed dollar figures for requests that are not billed per token
- Offline clients now fall back to generated Recommended, Free, and Subscribed model lists built at release time. The fallback was a hand-maintained literal of six models with an empty Cline Pass tier, so when the recommended-models endpoint was unreachable, subscribers got no subscribed tier at all
- Langfuse tracing is now limited to the Cline and Cline Pass providers. The provider argument was ignored, so in Langfuse-configured environments prompts and responses from third-party and BYOK providers were exported too
- OpenCode Go models are now routed to the right wire protocol. One base URL serves several protocols, but every model was sent over the OpenAI chat-completions adapter, so Anthropic-protocol and OpenAI Responses models on that endpoint failed or misbehaved. Requests also now carry the sticky session header and a Cline user agent
- Claude Code and OpenCode are now declared local-auth providers with a named local CLI, so hosts route them to a local-CLI readiness screen instead of demanding an API key they never read — previously the workaround was storing a dummy key, and the desktop app rendered an OAuth button for OpenCode that did nothing
- The OpenAI Codex (ChatGPT subscription) model list no longer gets overwritten with the full OpenAI API catalog by hosts that query provider models, which also lost the Codex context caps. The Codex backend's 400K/272K/128K budget is now clamped onto every Codex model rather than just one, so newer models no longer inherit the API catalog's 1M limits and produce over-long requests and wrong context math. Retired ChatGPT-account models were dropped from eligibility and the default moved to `gpt-5.6-terra`
- Live model catalogs now refresh for every shared-catalog provider, not just Cline and Cline Pass — other providers' lists were whatever was bundled at build time, so newly published models never appeared without a package update. The catalog fetch and the model-source fetch are both bounded by timeouts, so a hung models endpoint can no longer stall the provider list indefinitely
- Providers are now reported as configured based on the provider their credentials are stored under. Cline Pass signs in as Cline and never writes its own entry, so a new Cline Pass user saw only one configured provider despite one sign-in configuring both. The "(free)" suffix now also applies to free-tier models whose ids come from upstream rather than Cline's own namespace
- `@cline/ui` gains a shared `Switch` built on a native checkbox, and `SearchCombobox` keeps section headers visible while searching and can refresh its options each time it opens. Migrators should note the `Switch` has no `data-state`/`asChild` hooks and tests must read the native `checked` property rather than `aria-checked`
- Cline Pass now defaults to a subscribed-tier model. Its provider list mixes subscribed (`cline-pass/*`) and free models, and the default was simply the first entry in a release-date-ordered list, so it drifted onto whichever free model had shipped most recently — a subscriber who never picked a model was put on the free tier rather than the one they pay for. The default is now restricted to `cline-pass/*` ids
- Refreshed the model catalog. Adds four providers (Infer by Flow7, Melious, NaN, and Wallaby) and takes the bundled catalog from 5,788 to 6,079 models. This is a wide refresh: the resolved default model changes for 44 providers, most of them landing on DeepSeek V4.1 Flash — among them Hugging Face, Fireworks, Requesty, Nebius, Cortecs, CrossModel, DigitalOcean, Eden AI, and OpenCode Go. Gemini and Vertex now resolve to Gemini 3.8 Flash, GitHub Copilot and Vivgrid to GPT-6 Astra, and NVIDIA to GLM 5.3 Flash. If you use any provider without pinning a model, expect a different default
## 0.0.82
- Fixed tool calling being silently disabled for gateway models whose catalog entry declares no capabilities. Three separate producers built gateway model definitions with their own hand-written capability translations and had drifted; the builtin-provider path emitted `["text"]` where the others emitted `undefined`, and that list read as an authoritative denial that stripped every tool definition from requests to Dify, SAP AI Core, opencode, and the Codex CLI. One shared translator now serves every producer
+67
View File
@@ -0,0 +1,67 @@
## SSH remote environments
`RemoteEnvironmentService` (exported by `@cline/core` and `@cline/sdk`) owns SSH
profiles, connection testing, helper installation, authenticated loopback tunnels,
remote commands, status changes, and cleanup. It runs in the client's Node host;
browser clients expose this API through their host transport. No desktop code is
required. OpenSSH config aliases, identity files, and ssh-agent authentication are
supported. Connections use batch mode and require an already-trusted host key in
OpenSSH known_hosts (or `knownHostsPath`). Before first use, verify the server
fingerprint through a trusted channel and enroll it using your SSH client. Unknown
or changed keys are rejected before inspection, upload, or execution.
```ts
import { ClineCore, RemoteEnvironmentService } from "@cline/core";
const environments = new RemoteEnvironmentService({
helperBinaryDirectory: "/opt/my-client/remote-helpers",
onStatusChange: (status) => console.log(status),
});
const profile = await environments.upsert({ name: "Build host", host: "builder" });
const connection = await environments.connect(profile.id);
const core = await ClineCore.create({
clientName: "my-client",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
},
});
try {
// The ordinary session, tools, approvals, and event APIs execute on this host.
// Supply provider credentials in the session config, as for other remote hubs.
console.log(await core.list());
} finally {
await core.dispose();
await environments.dispose();
}
```
The service also exposes `list`, `upsert`, `delete`, `test`, `disconnect`, `run`,
`getConnection`, `getActive`, `activateConnection`, and `getStatuses`.
`onConnectionLost` lets clients retire runtime bindings after a tunnel fails.
Each service instance has a unique remote Hub discovery record, so another
client connecting to the same host cannot stop its Hub.
Connect/disconnect/profile mutations are serialized; concurrent connects reuse
one tunnel. Dispose the `ClineCore` runtime before disconnecting its environment.
Do not expose the connection's authentication token to a browser or logs.
Profiles default to `~/.cline/data/settings/remote-environments.json`, written
atomically with mode 0600. They contain identity-file paths, never private keys.
Options include `profilesPath`, `sshPath`, `knownHostsPath`, process timeouts,
`helperBinaryPath`, and `helperBinaryDirectory`. The corresponding helper/SSH
configuration variables are `CLINE_REMOTE_HELPER_BINARY`,
`CLINE_REMOTE_HELPER_DIRECTORY`, `CLINE_SSH_PATH`, and
`CLINE_SSH_KNOWN_HOSTS_FILE`.
Clients package a matching self-contained helper using the
`@cline/core/remote/helper-entry` executable entrypoint, compiled with Bun for the remote OS and
architecture. Use `remoteHelperBinaryFilename({ platform, arch })` for the
filename (`cline-remote-helper-<target-triple>`). Linux and macOS on x64/arm64
are supported. Helpers must include the same SDK build as the client; missing
helpers produce an explicit error, without installing a runtime from the network.
The helper implements `--remote-hub-ensure --cwd <path> --discovery-path <path>`
and the core detached-daemon sentinel. Agent tools and persistence run remotely;
the host only manages SSH and forwards the authenticated hub connection.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.82",
"version": "0.0.83",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -504,6 +504,158 @@ describe("AgentRuntime", () => {
expect(model.requests).toHaveLength(1);
});
it("retries a transient provider error with backoff before failing", async () => {
vi.useFakeTimers();
try {
// Initial attempt + 3 retries = 4 requests, all failing transiently.
const model = new ScriptedModel(
Array.from({ length: 4 }, () => () => [
{
type: "finish" as const,
reason: "error" as const,
error: "Provider returned error",
},
]),
);
const runtime = new AgentRuntime({ model });
const runPromise = runtime.run("Hi");
await vi.runAllTimersAsync();
const result = await runPromise;
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("Provider returned error");
expect(model.requests).toHaveLength(4);
} finally {
vi.useRealTimers();
}
});
it("recovers when a retried provider error later succeeds", async () => {
vi.useFakeTimers();
try {
const model = new ScriptedModel([
() => [
{
type: "finish" as const,
reason: "error" as const,
error: "Provider returned error",
},
],
() => [
{ type: "text-delta" as const, text: "recovered" },
{ type: "finish" as const, reason: "stop" as const },
],
]);
const runtime = new AgentRuntime({ model });
const runPromise = runtime.run("Hi");
await vi.runAllTimersAsync();
const result = await runPromise;
expect(result.status).toBe("completed");
expect(result.outputText).toBe("recovered");
expect(model.requests).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
it("does not retry a non-transient provider error (honors errorRetryable=false)", async () => {
const model = new ScriptedModel([
() => [
{
type: "finish",
reason: "error",
error: "Provider returned error",
// Boundary says this specific failure is not retryable; the flag
// wins over the message-based fallback.
errorRetryable: false,
},
],
]);
const runtime = new AgentRuntime({ model });
const result = await runtime.run("Hi");
expect(result.status).toBe("failed");
expect(model.requests).toHaveLength(1);
});
it("does not retry when the failed attempt already streamed visible output", async () => {
const model = new ScriptedModel([
() => [
{ type: "text-delta", text: "partial answer" },
{
type: "finish",
reason: "error",
error: "Provider returned error",
},
],
]);
const runtime = new AgentRuntime({ model });
const result = await runtime.run("Hi");
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("Provider returned error");
expect(model.requests).toHaveLength(1);
});
it("does not retry when the failed attempt ran a provider-executed tool", async () => {
const model = new ScriptedModel([
() => [
{
type: "tool-call-delta",
toolCallId: "prov_1",
toolName: "Bash",
input: { command: "make clean" },
execution: "provider",
},
{
type: "finish",
reason: "error",
error: "Provider returned error",
},
],
]);
const runtime = new AgentRuntime({ model });
const result = await runtime.run("Hi");
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("Provider returned error");
expect(model.requests).toHaveLength(1);
});
it("does not carry retryability from an earlier attempt into an error-less finish", async () => {
vi.useFakeTimers();
try {
const model = new ScriptedModel([
() => [
{
type: "finish",
reason: "error",
error: "Provider returned error",
},
],
// Valid per the AgentModel contract: an error finish with no payload.
() => [{ type: "finish", reason: "error" }],
]);
const runtime = new AgentRuntime({ model });
const runPromise = runtime.run("Hi");
await vi.runAllTimersAsync();
const result = await runPromise;
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("Model stream failed");
expect(model.requests).toHaveLength(2);
} finally {
vi.useRealTimers();
}
});
it("fails with an actionable message when overflow recovery has nothing to compact", async () => {
const model = new ScriptedModel([
() => [
+162 -1
View File
@@ -2,6 +2,7 @@ import {
classifyProviderError,
createGateway,
type GatewayProviderSettings,
isRetryableProviderError,
} from "@cline/llms";
import type {
AgentAfterToolResult,
@@ -52,6 +53,22 @@ import { nanoid } from "nanoid";
const MAX_TOKENS_INCOMPLETE_TURN_MESSAGE =
"Model reached the maximum output token limit before completing the turn";
/**
* How many times to retry a model turn that failed with a transient,
* provider-side error (rate limits, 5xx, network hiccups, OpenRouter's
* generic "Provider returned error"). The initial attempt is not counted, so
* a value of 3 means up to 4 total requests for one turn. Retrying only
* transient errors and never auth, context-overflow, or other client errors
* (see {@link isRetryableProviderError}) keeps well-behaved providers on
* their existing single-request path, so this does not change behavior for
* models whose endpoints do not throw transient errors.
*/
const PROVIDER_ERROR_MAX_RETRIES = 3;
/** Base backoff before the first retry; doubled each subsequent attempt. */
const PROVIDER_ERROR_RETRY_BASE_DELAY_MS = 1_000;
/** Upper bound on any single backoff wait. */
const PROVIDER_ERROR_RETRY_MAX_DELAY_MS = 15_000;
/**
* Terminal message when a context-window overflow cannot be recovered because
* there is no conversation history to compact the system prompt, tools, and
@@ -500,6 +517,14 @@ export class AgentRuntime {
usage: cloneUsage(DEFAULT_USAGE),
lastError: undefined as string | undefined,
lastErrorClass: undefined as ProviderErrorClass | undefined,
/**
* Whether the last provider failure was transient and worth retrying,
* carried from the model boundary via `errorRetryable` on the `finish`
* event (the AI SDK's typed `isRetryable` flag). Undefined when no such
* signal was provided, in which case the agent loop classifies from the
* flattened `lastError` message instead.
*/
lastErrorRetryable: undefined as boolean | undefined,
/**
* Whether the model layer already recorded `sdk.error` telemetry for
* `lastError` (from `errorReported` on the stream's `finish` event).
@@ -586,6 +611,7 @@ export class AgentRuntime {
this.state.usage = cloneUsage(DEFAULT_USAGE);
this.state.lastError = undefined;
this.state.lastErrorClass = undefined;
this.state.lastErrorRetryable = undefined;
this.state.lastErrorReported = false;
this.state.messages = cloneMessages(messages);
this.config = {
@@ -700,6 +726,7 @@ export class AgentRuntime {
this.state.pendingToolCalls = [];
this.state.lastError = undefined;
this.state.lastErrorClass = undefined;
this.state.lastErrorRetryable = undefined;
this.state.lastErrorReported = false;
this.state.usage = cloneUsage(DEFAULT_USAGE);
this.overflowRecoveryAttempted = false;
@@ -737,8 +764,11 @@ export class AgentRuntime {
iteration: this.state.iteration,
});
// A fresh error slate per turn: nothing from a previous turn may leak
// into this turn's error classification or retry decision.
this.resetLastError();
const { message, finishReason } =
await this.generateAssistantMessageWithOverflowRecovery();
await this.generateAssistantMessageWithProviderRetry();
if (finishReason === "aborted") {
throw this.normalizeAbortError();
}
@@ -952,6 +982,132 @@ export class AgentRuntime {
}
}
/**
* Run a model turn, retrying transient provider/API failures with backoff.
*
* A turn whose model stream fails with a retryable provider error (rate
* limit, 5xx, network hiccup, or OpenRouter's generic "Provider returned
* error") is re-issued up to {@link PROVIDER_ERROR_MAX_RETRIES} times, with
* exponential backoff between attempts, before the error is allowed to
* propagate and end the run. Non-retryable errors (auth, context-window
* overflow, other client errors) and any attempt that already produced
* visible output or provider tool activity are returned unchanged for the
* caller to handle, so this only adds
* resilience and never changes behavior for a turn that would otherwise
* succeed. Context-window overflow recovery still runs inside each attempt.
*/
private async generateAssistantMessageWithProviderRetry(): Promise<{
message: AgentMessage;
finishReason: AgentModelFinishReason;
}> {
let attempt = 0;
for (;;) {
const turn = await this.generateAssistantMessageWithOverflowRecovery();
if (
attempt >= PROVIDER_ERROR_MAX_RETRIES ||
!this.isRetryableProviderErrorTurn(turn)
) {
return turn;
}
attempt += 1;
const providerError = this.state.lastError;
// The failed attempt's error is captured for the notice above; clear it
// so the next attempt's finish event is judged on its own.
this.resetLastError();
const delayMs = Math.min(
PROVIDER_ERROR_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1),
PROVIDER_ERROR_RETRY_MAX_DELAY_MS,
);
await this.emit({
type: "status-notice",
snapshot: this.snapshot(),
message: `provider error — retrying (attempt ${attempt}/${PROVIDER_ERROR_MAX_RETRIES})`,
metadata: {
kind: "provider_error_retry",
reason: "provider_error_retry",
phase: "started",
iteration: this.state.iteration,
attempt,
maxRetries: PROVIDER_ERROR_MAX_RETRIES,
delayMs,
providerError,
},
});
await this.abortableDelay(delayMs);
}
}
/**
* True when a turn failed with a transient provider error that a retry
* could plausibly recover, and the failed attempt left nothing behind that
* a second stream would duplicate or repeat:
* - no content at all (text, reasoning, media, or local tool calls): those
* deltas were already emitted to the UI and there is no event to retract
* them, so re-streaming would show the output twice;
* - no provider-executed tool activity (recorded in message metadata, not
* content): re-issuing the request could run those side effects again;
* - not an auth or context-window failure, which the same request cannot fix.
*/
private isRetryableProviderErrorTurn(turn: {
message: AgentMessage;
finishReason: AgentModelFinishReason;
}): boolean {
if (turn.finishReason !== "error") {
return false;
}
if (turn.message.content.length > 0) {
return false;
}
const modelToolActivities = turn.message.metadata?.modelToolActivities;
if (Array.isArray(modelToolActivities) && modelToolActivities.length > 0) {
return false;
}
const errorClass = this.state.lastErrorClass;
if (errorClass === "auth" || errorClass === "context_window_exceeded") {
return false;
}
// Set from the model boundary's typed `isRetryable` flag when available,
// otherwise classified from the flattened message in the finish handler.
return this.state.lastErrorRetryable === true;
}
/**
* Clear the last-error fields. Called at the start of every turn and before
* every provider-error retry, so a `finish` event that omits `error` (allowed
* by the public AgentModel contract) cannot inherit the class or retryability
* of an earlier attempt. Deliberately not called inside overflow recovery,
* whose "nothing to compact" error reports the first attempt's provider
* message.
*/
private resetLastError(): void {
this.state.lastError = undefined;
this.state.lastErrorClass = undefined;
this.state.lastErrorRetryable = undefined;
this.state.lastErrorReported = false;
}
/**
* Sleep for `ms`, rejecting early with the abort error if the run is
* aborted while waiting, so a retry backoff never blocks cancellation.
*/
private async abortableDelay(ms: number): Promise<void> {
this.throwIfAborted();
const signal = this.abortController?.signal;
await new Promise<void>((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(this.normalizeAbortError());
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
/**
* Run a model turn, recovering once per run from a provider-rejected
* context-window overflow: force a compaction through `prepareTurn` and
@@ -1309,6 +1465,11 @@ export class AgentRuntime {
// stays eligible for overflow recovery.
this.state.lastErrorClass =
event.errorClass ?? classifyProviderError(event.error);
// Prefer the boundary's typed `isRetryable` signal; fall back to
// classifying the flattened message for models that do not carry
// it.
this.state.lastErrorRetryable =
event.errorRetryable ?? isRetryableProviderError(event.error);
this.state.lastErrorReported = event.errorReported === true;
}
break;
+15 -4
View File
@@ -21,10 +21,13 @@ const runtimeBuildId = resolveSdkRuntimeBuildId(
// Keep declared runtime packages external so they are not duplicated inside each
// bundled entrypoint and installed again from package.json.
const external = Object.keys({
...(packageJson.dependencies ?? {}),
...(packageJson.peerDependencies ?? {}),
});
const external = [
"@cline/core/hub/daemon-entry",
...Object.keys({
...(packageJson.dependencies ?? {}),
...(packageJson.peerDependencies ?? {}),
}),
];
const sourcemap = Bun.env.CLINE_SOURCEMAPS === "1" ? "linked" : "none";
// minify: true keeps identifier mangling active even when sourcemaps are enabled.
@@ -47,6 +50,14 @@ const buildConfig = {
} as const;
const builds: Parameters<typeof Bun.build>[0][] = [
{
entrypoints: [
"./src/remote/remote-helper.ts",
"./src/remote/remote-helper-entry.ts",
],
outdir: "./dist/remote",
...buildConfig,
},
// Build main exports separately to avoid Bun bundler output path conflicts
{
entrypoints: ["./src/index.ts"],
+9 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.82",
"version": "0.0.83",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -34,6 +34,14 @@
"./services/feature-flags/posthog": {
"types": "./dist/services/feature-flags/posthog.d.ts",
"import": "./dist/services/feature-flags/posthog.js"
},
"./remote/helper-entry": {
"types": "./dist/remote/remote-helper-entry.d.ts",
"import": "./dist/remote/remote-helper-entry.js"
},
"./remote/helper": {
"types": "./dist/remote/remote-helper.d.ts",
"import": "./dist/remote/remote-helper.js"
}
},
"scripts": {
@@ -21,3 +21,12 @@ for (const entrypoint of entrypoints) {
throw new Error(`runtime build identity was not embedded in ${entrypoint}`);
}
}
// Every public subpath must ship both its runtime file and its declarations.
const manifest = JSON.parse(
readFileSync(join(corePackageRoot, "package.json"), "utf8"),
) as { exports: Record<string, { types: string; import: string }> };
for (const entry of Object.values(manifest.exports)) {
for (const path of [entry.types, entry.import])
readFileSync(join(corePackageRoot, path));
}
@@ -486,10 +486,16 @@ describe("run_commands tool description", () => {
])("names Windows PowerShell and its redundant wrapper for %s", (shell) => {
const description = buildRunCommandsDescription(shell, true);
expect(description).toContain("Windows PowerShell (powershell.exe)");
expect(description).toContain(
"quote paths and arguments for powershell.exe",
);
expect(description).not.toContain(
"quote paths and arguments for Windows PowerShell",
);
expect(description).toContain(
"do not wrap them in another powershell.exe -Command invocation",
);
expect(description).not.toContain("Microsoft PowerShell");
expect(description).not.toContain("PowerShell (pwsh.exe)");
expect(description).toContain("use ';' to sequence commands");
expect(description).toContain("in Windows environment");
});
@@ -498,9 +504,14 @@ describe("run_commands tool description", () => {
"pwsh",
"pwsh.exe",
"C:\\Program Files\\PowerShell\\7\\PWSH.EXE",
])("names Microsoft PowerShell and its redundant wrapper for %s", (shell) => {
])("names PowerShell and its redundant wrapper for %s", (shell) => {
const description = buildRunCommandsDescription(shell, true);
expect(description).toContain("Microsoft PowerShell (pwsh.exe)");
expect(description).toContain("PowerShell (pwsh.exe)");
expect(description).toContain("quote paths and arguments for pwsh.exe");
expect(description).not.toContain(
"quote paths and arguments for PowerShell",
);
expect(description).not.toMatch(/PowerShell (?:Core|\d)/);
expect(description).toContain(
"do not wrap them in another pwsh.exe -Command invocation",
);
@@ -511,12 +522,13 @@ describe("run_commands tool description", () => {
expect(description).not.toContain("Windows PowerShell");
});
it("describes pwsh on Unix without claiming a Windows host or a version", () => {
it("describes PowerShell on Unix without claiming a version or Windows host", () => {
const description = buildRunCommandsDescription("/usr/bin/pwsh", false);
expect(description).toContain("Microsoft PowerShell (pwsh)");
expect(description).toContain("PowerShell (pwsh)");
expect(description).toContain("quote paths and arguments for pwsh");
expect(description).toContain("another pwsh -Command invocation");
expect(description).not.toContain("Windows");
expect(description).not.toMatch(/PowerShell \d/);
expect(description).not.toMatch(/PowerShell (?:Core|\d)/);
});
it("names cmd.exe with '&&' sequencing for cmd shells", () => {
@@ -579,13 +591,12 @@ describe("run_commands tool description", () => {
expect(tool.description).toBe(windowsPowerShellDescription);
shell = "pwsh.exe";
const microsoftPowerShellDescription = tool.description;
expect(microsoftPowerShellDescription).toContain("Microsoft PowerShell");
expect(microsoftPowerShellDescription).not.toBe(
windowsPowerShellDescription,
);
const powerShellDescription = tool.description;
const pwshExecutable = process.platform === "win32" ? "pwsh.exe" : "pwsh";
expect(powerShellDescription).toContain(`PowerShell (${pwshExecutable})`);
expect(powerShellDescription).not.toBe(windowsPowerShellDescription);
shell = "C:\\Program Files\\PowerShell\\7\\PWSH.EXE";
expect(tool.description).toBe(microsoftPowerShellDescription);
expect(tool.description).toBe(powerShellDescription);
// The property must survive the shallow copy the runtime performs when
// building AgentToolDefinitions for a model request.
@@ -444,7 +444,7 @@ export function buildRunCommandsDescription(
edition === "windows"
? `Windows PowerShell (${executable})`
: edition === "core"
? `Microsoft PowerShell (${executable})`
? `PowerShell (${executable})`
: executable;
const wrapper = `${executable} ${shellKind === "powershell" ? "-Command" : "/c"}`;
const sequencingOperator = shellKind === "powershell" ? "';'" : "'&&'";
@@ -452,7 +452,7 @@ export function buildRunCommandsDescription(
`Run non-interactive shell commands from the root of the workspace${isWindows ? " in Windows environment" : ""}. ` +
RUN_COMMANDS_SHARED_INSTRUCTIONS +
`Output beyond ~${Math.round(MAX_COMMAND_OUTPUT_CHARS / 1000)}k characters is middle-truncated (start and end preserved); filter output when you need specific sections. ` +
`Commands run through ${shellName}; quote paths and arguments for ${shellName} and use ${sequencingOperator} to sequence commands. ` +
`Commands run through ${shellName}; quote paths and arguments for ${executable} and use ${sequencingOperator} to sequence commands. ` +
`Write commands directly; do not wrap them in another ${wrapper} invocation. ` +
"Only start another shell when you intentionally need a different shell or a separate process. " +
"Include multiple commands in the same call when they are independent and safe to run concurrently. When independent reads, searches, or edits are also needed, call those tools in the same response."
@@ -844,8 +844,10 @@ export function createSubmitAndExitTool(
description:
"Submit the final answer and exit the conversation. " +
"For example, submit a summary of the investigation and confirm the issue is resolved. " +
"You should only submit once all necessary steps are completed. " +
"Make sure to verify your output matches the expected format, data types, and file locations specified. " +
"You should only submit once all necessary steps are completed AND you have verified the result by execution. " +
"Before calling this, re-read the task, then confirm from your own tool output that every required file exists at the specified path and that its contents match the expected format, data types, and values. " +
"If the task provides tests, run them and confirm they pass; if it does not, run your own solution end to end and read the output back as evidence. " +
"Do not submit on the assumption that your solution works — submit because you have observed evidence that it does. " +
"Provide a summary of the investigation and confirm the issue is resolved.",
inputSchema: zodToJsonSchema(SubmitInputSchema),
lifecycle: {
@@ -15,6 +15,8 @@ const context: AgentToolContext = {
iteration: 1,
};
const pwshExecutable = process.platform === "win32" ? "pwsh.exe" : "pwsh";
function createSuccessfulChildProcess(): ChildProcessWithoutNullStreams {
const child = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
@@ -62,10 +64,10 @@ describe("createBuiltinTools shell configuration", () => {
"Commands run through Windows PowerShell (powershell.exe)",
},
{
name: "Microsoft PowerShell executable",
name: "pwsh executable",
options: { shell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" },
expectedShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
expectedDescription: "Commands run through Microsoft PowerShell",
expectedDescription: `Commands run through PowerShell (${pwshExecutable})`,
},
{
name: "top-level shell precedence",
@@ -279,12 +279,12 @@ export const SubmitInputSchema = z.object({
.string()
.min(10)
.describe(
"Summarization of the investigation, steps taken, and resolution status to submit at the end of the session. Before submitting, read the problem again along with any provided test's assertions carefully and confirm your fix produces the expected output.",
"Summarization of the investigation, steps taken, and resolution status to submit at the end of the session. Before submitting, re-read the task requirements together with any provided tests or assertions, and confirm from your own tool output — not from assumption — that your solution produces the expected results at the expected file locations.",
),
verified: z
.boolean()
.describe(
`Have you verified that the issue is resolved to the best of your knowledge, including updating and creating all the requested files and items? 'True' if you have completed the investigation and taken all necessary steps to resolve the issue.\n'False' if you have done all you can but cannot resolve the issue or if you are stuck and cannot proceed further. =\nIMPORTANT: You must run the specific failing test(s) mentioned in the issue or test patch and include the test output in your reasoning. If the test still fails after your fix, you must revise. Do NOT submit with 'true' unless the test output shows the test passing.`,
`Have you verified that the issue is resolved to the best of your knowledge, including updating and creating all the requested files and items? 'True' if you have completed the investigation and gathered concrete evidence that every requirement is met.\n'False' if you have done all you can but cannot resolve the issue or if you are stuck and cannot proceed further.\nIMPORTANT: verify by EXECUTION, not by assumption. Before setting 'true' you must have observed evidence in this session that your solution works:\n- If the task provides or references specific tests, run those exact test(s) and include the passing output in your reasoning. If they still fail, revise and re-run.\n- If no tests are provided, construct your own check: actually run the program, script, or command you produced; confirm every required output file exists at the exact path requested; and confirm its contents match the required format, data types, and values. Read the output back as evidence.\nPhrases like "assume it works", "should be correct", or "probably fine" are NOT verification. Do NOT set 'true' unless your tool output shows the requirements are met; if you cannot obtain such evidence, set 'false'.`,
),
});
@@ -184,6 +184,24 @@ describe("hub daemon entry", () => {
expect(mockReconnectDaemonConnectors).toHaveBeenCalledOnce();
});
it("leaves account connectors untouched when connector management is disabled", async () => {
process.argv = ["node", "entry.js", "--no-connectors"];
vi.spyOn(process, "on").mockImplementation(() => process);
const { ConnectorSupervisor, getActiveConnectorSupervisor } = await import(
"../../services/connectors/connector-supervisor"
);
const adopt = vi.spyOn(
ConnectorSupervisor.prototype,
"adoptRunningConnectors",
);
const { hubDaemonReady } = await import("./entry");
await hubDaemonReady;
expect(mockStartHubWebSocketServer).toHaveBeenCalledOnce();
expect(adopt).not.toHaveBeenCalled();
expect(mockReconnectDaemonConnectors).not.toHaveBeenCalled();
expect(getActiveConnectorSupervisor()).toBeUndefined();
});
it("does not signal readiness before the WebSocket server is listening", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
tempDirs.push(cwd);
+20 -8
View File
@@ -79,10 +79,12 @@ async function startHubWebSocketServerWithBindRetry(
function parseArgs(argv: string[]): {
cwd: string;
manageConnectors: boolean;
host?: string;
port?: number;
pathname?: string;
} {
let manageConnectors = true;
let cwd = process.cwd();
let host: string | undefined;
let port: number | undefined;
@@ -91,6 +93,10 @@ function parseArgs(argv: string[]): {
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[index + 1];
if (arg === "--no-connectors") {
manageConnectors = false;
continue;
}
if (arg === "--cwd" && value) {
cwd = value;
index += 1;
@@ -115,7 +121,7 @@ function parseArgs(argv: string[]): {
}
}
return { cwd, host, port, pathname };
return { cwd, host, port, pathname, manageConnectors };
}
/**
@@ -275,10 +281,14 @@ async function main(): Promise<void> {
// Owns connector processes for this hub's lifetime: one instance per
// (channel, instanceId), reaping and backoff restarts when they die.
const supervisor = new ConnectorSupervisor({
cleanupInstance: (channel, instanceId) =>
cleanupConnectorInstanceViaCli(channel, instanceId),
});
// Dedicated SSH Hubs share account storage with the CLI Hub, but must not
// adopt its connectors, restart them, or accept connector start/stop commands.
const supervisor = options.manageConnectors
? new ConnectorSupervisor({
cleanupInstance: (channel, instanceId) =>
cleanupConnectorInstanceViaCli(channel, instanceId),
})
: undefined;
setActiveConnectorSupervisor(supervisor);
shutdownCoordinator = createHubDaemonShutdownCoordinator({
@@ -289,7 +299,7 @@ async function main(): Promise<void> {
// on purpose so a hub restart does not disconnect Slack/Telegram, and
// the next hub adopts them from their state files.
try {
supervisor.dispose();
supervisor?.dispose();
} catch (error) {
errors.push(error);
} finally {
@@ -342,8 +352,10 @@ async function main(): Promise<void> {
// Adopt first: connectors that outlived the previous hub have to be known
// before recovery runs, so they are restarted onto this hub's session
// instead of being started a second time alongside themselves.
supervisor.adoptRunningConnectors();
await reconnectDaemonConnectors();
if (supervisor) {
supervisor.adoptRunningConnectors();
await reconnectDaemonConnectors();
}
} catch (error) {
const message =
error instanceof Error ? error.stack || error.message : String(error);
+18 -2
View File
@@ -197,7 +197,10 @@ describe("ensureDetachedHubServer", () => {
}
});
it("does not use port 0 for default production startup", async () => {
it.each([
true,
false,
])("preserves production startup options with manageConnectors=%s", async (manageConnectors) => {
process.env.CLINE_CONNECTOR_CLI_LAUNCH = JSON.stringify({
launcher: "bun",
connectArgsPrefix: ["/workspace/apps/cli/src/index.ts", "connect"],
@@ -217,7 +220,9 @@ describe("ensureDetachedHubServer", () => {
});
const { ensureDetachedHubServer } = await import(".");
const result = await ensureDetachedHubServer("/workspace");
const result = await ensureDetachedHubServer("/workspace", {
manageConnectors,
});
const spawnCalls = (spawn as unknown as { mock: { calls: unknown[][] } })
.mock.calls;
const spawnArgs = spawnCalls[0]?.[1] as string[] | undefined;
@@ -237,6 +242,7 @@ describe("ensureDetachedHubServer", () => {
expect(spawnArgs).toContain("--port");
expect(spawnArgs).toContain("25463");
expect(spawnArgs).not.toContain("0");
expect(spawnArgs?.includes("--no-connectors")).toBe(!manageConnectors);
expect(spawnOptions?.env?.[CLINE_RUN_AS_HUB_DAEMON_ENV]).toBe("1");
expect(spawnOptions?.env?.CLINE_CONNECTOR_CLI_LAUNCH).toBe(
process.env.CLINE_CONNECTOR_CLI_LAUNCH,
@@ -284,6 +290,16 @@ describe("ensureDetachedHubServer", () => {
}
});
it("passes disabled connector management to the detached daemon", async () => {
const { spawnDetachedHubServer } = await import(".");
spawnDetachedHubServer("/workspace", { manageConnectors: false });
expect(spawn).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining(["--no-connectors"]),
expect.any(Object),
);
});
it("does not spawn another detached daemon from inside the hub daemon process", async () => {
process.env[CLINE_RUN_AS_HUB_DAEMON_ENV] = "1";
+23 -12
View File
@@ -46,6 +46,12 @@ import {
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
export interface DetachedHubOptions extends HubEndpointOverrides {
allowPortFallback?: boolean;
/** Disable account-wide connector supervision for session-only Hubs. Defaults to true. */
manageConnectors?: boolean;
}
const HUB_STARTUP_TIMEOUT_MS = 8_000;
const HUB_STARTUP_POLL_MS = 200;
const HUB_RETIRE_TIMEOUT_MS = 3_000;
@@ -363,7 +369,7 @@ function resolveDaemonEntryPath(): string {
function resolveLaunchCommand(
workspaceRoot: string,
endpoint: HubEndpointOverrides,
endpoint: DetachedHubOptions,
): {
launcher: string;
args: string[];
@@ -387,7 +393,13 @@ function resolveLaunchCommand(
];
return {
launcher: execPath,
args: [...entryArgs, "--cwd", workspaceRoot, ...endpointArgs(endpoint)],
args: [
...entryArgs,
"--cwd",
workspaceRoot,
...endpointArgs(endpoint),
...(endpoint.manageConnectors === false ? ["--no-connectors"] : []),
],
cwd: workspaceRoot,
env: {
...withResolvedClineBuildEnv(process.env),
@@ -411,7 +423,7 @@ function isTextFileBusyError(error: unknown): boolean {
export function spawnDetachedHubServer(
workspaceRoot: string,
endpoint: HubEndpointOverrides = {},
endpoint: DetachedHubOptions = {},
): void {
if (isHubDaemonProcess()) {
return;
@@ -438,7 +450,7 @@ export function spawnDetachedHubServer(
export async function spawnDetachedHubServerWithRetry(
workspaceRoot: string,
endpoint: HubEndpointOverrides = {},
endpoint: DetachedHubOptions = {},
): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
@@ -456,7 +468,7 @@ export async function spawnDetachedHubServerWithRetry(
export function prewarmDetachedHubServer(
workspaceRoot: string,
endpoint: HubEndpointOverrides & { allowPortFallback?: boolean } = {},
endpoint: DetachedHubOptions = {},
): void {
if (isHubDaemonProcess()) {
return;
@@ -474,9 +486,7 @@ export interface DetachedHubResolution {
async function ensureDetachedHubServerLocked(
owner: HubOwnerContext,
workspaceRoot: string,
endpointOverrides: HubEndpointOverrides & {
allowPortFallback?: boolean;
} = {},
endpointOverrides: DetachedHubOptions = {},
): Promise<DetachedHubResolution> {
const hasExplicitEndpoint =
endpointOverrides.host !== undefined ||
@@ -654,7 +664,10 @@ async function ensureDetachedHubServerLocked(
const spawnEndpoint = shouldUseFallbackPort
? { ...endpoint, port: 0 }
: endpoint;
await spawnDetachedHubServerWithRetry(workspaceRoot, spawnEndpoint);
await spawnDetachedHubServerWithRetry(workspaceRoot, {
...spawnEndpoint,
manageConnectors: endpointOverrides.manageConnectors,
});
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
@@ -717,9 +730,7 @@ async function ensureDetachedHubServerLocked(
export async function ensureDetachedHubServer(
workspaceRoot: string,
endpointOverrides: HubEndpointOverrides & {
allowPortFallback?: boolean;
} = {},
endpointOverrides: DetachedHubOptions = {},
): Promise<DetachedHubResolution> {
const owner = resolveDefaultHubOwnerContext();
return await withHubStartupLock(owner.discoveryPath, async () =>
@@ -268,6 +268,61 @@ describe("hub client runtime capabilities", () => {
);
});
it("does not proxy per-chunk stream events to remote onEvent hooks", async () => {
const request = vi.fn(async () => ({}));
const runtime = createHubClientContributionRuntime({
sessionId: "session-1",
targetClientId: "client-1",
contributions: [
{ kind: "hook", name: "onEvent", capabilityName: "hook.onEvent" },
],
requestCapability: request,
});
const snapshot = {
agentId: "agent-1",
runId: "conv-1",
status: "running" as const,
iteration: 1,
messages: [],
pendingToolCalls: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
};
const onEvent = runtime.localRuntime.hooks?.onEvent;
for (let index = 0; index < 100; index += 1) {
await onEvent?.({
type: "assistant-reasoning-delta",
snapshot,
iteration: 1,
text: "think",
accumulatedText: "think".repeat(index + 1),
});
}
await onEvent?.({
type: "message-added",
snapshot,
message: {
id: "msg-1",
role: "user",
content: [{ type: "text", text: "hello" }],
createdAt: 0,
},
});
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"session-1",
"hook.onEvent",
{ context: expect.objectContaining({ type: "message-added" }) },
"client-1",
);
});
it("rebuilds user instruction services from a client snapshot", async () => {
const request = vi.fn(async () => ({
snapshot: {
@@ -1,6 +1,7 @@
import type {
AgentExtension,
AgentHooks,
AgentRuntimeEvent,
AgentTool,
AgentToolContext,
ConsecutiveMistakeLimitDecision,
@@ -503,6 +504,17 @@ function createToolProxies(
}));
}
// Per-chunk stream events never leave the hub as hook traffic. A proxied
// onEvent call serializes the full runtime snapshot, persists it as a
// capability event, and blocks the agent loop on a client round trip, so
// forwarding every streamed token turned a reasoning model's output rate into
// hundreds of KB of IPC and SQLite writes per chunk (#14091).
const STREAMING_EVENT_TYPES = new Set<AgentRuntimeEvent["type"]>([
"assistant-text-delta",
"assistant-reasoning-delta",
"tool-updated",
]);
function createHookProxies(
sessionId: string,
targetClientId: string,
@@ -516,6 +528,12 @@ function createHookProxies(
const contribution = available.get(name);
if (!contribution) continue;
hooks[name] = async (ctx: unknown) => {
if (
name === "onEvent" &&
STREAMING_EVENT_TYPES.has((ctx as AgentRuntimeEvent).type)
) {
return undefined;
}
const response = await requestCapability(
sessionId,
contribution.capabilityName,
@@ -71,7 +71,10 @@ export class HubEventLogStore {
// Every streaming chunk lands here as an INSERT; WAL keeps those
// appends from serializing against replay reads, and the busy timeout
// matches the other SQLite stores instead of failing fast on contention.
// NORMAL drops the fsync per appended chunk (WAL still syncs at
// checkpoints); this replay aid survives a process crash either way.
this.db.exec("PRAGMA journal_mode = WAL;");
this.db.exec("PRAGMA synchronous = NORMAL;");
this.db.exec("PRAGMA busy_timeout = 5000;");
this.db.exec(`
CREATE TABLE IF NOT EXISTS hub_events (
+2
View File
@@ -984,6 +984,8 @@ export {
ToolPresets,
truncateCommandOutput,
} from "./extensions/tools";
export * from "./remote/remote-environments";
export { ensureLoginShellPath } from "./remote/shell-path";
export {
applyClineFeaturedModels,
type ClineRecommendedModel,
@@ -0,0 +1,958 @@
import * as childProcess from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type RemoteCommandResult,
type RemoteEnvironmentDependencies,
RemoteEnvironmentService,
type RemoteEnvironmentServiceOptions,
type RemoteTunnelProcess,
runRemoteProcess,
} from "./remote-environments";
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawn: vi.fn(actual.spawn) };
});
class FakeTunnel extends EventEmitter implements RemoteTunnelProcess {
public readonly pid = 4242;
public exitCode: number | null = null;
public killed = false;
public kill(): boolean {
this.killed = true;
return true;
}
}
interface Invocation {
executable: string;
args: string[];
options: { timeoutMs: number; inputFile?: string };
}
function success(stdout = "", stderr = ""): RemoteCommandResult {
return { stdout, stderr, exitCode: 0 };
}
function inspection(
platform: string,
arch: string,
home: string,
prefix = "",
): RemoteCommandResult {
return success(
`${prefix}\0CLINE_REMOTE_INSPECT_V1\0${platform}\0${arch}\0${home}\0`,
);
}
describe("RemoteEnvironmentService", () => {
let testDirectory: string;
let profilesPath: string;
beforeEach(async () => {
testDirectory = await mkdtemp(join(tmpdir(), "cline-remote-environments-"));
profilesPath = join(
testDirectory,
"data",
"settings",
"remote-environments.json",
);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(testDirectory, { recursive: true, force: true });
});
function createService(
overrides: Partial<RemoteEnvironmentDependencies> = {},
options: Omit<
RemoteEnvironmentServiceOptions,
"profilesPath" | "dependencies"
> = {},
): RemoteEnvironmentService {
let id = 0;
return new RemoteEnvironmentService({
...options,
profilesPath,
dependencies: {
now: () => new Date("2026-08-06T12:00:00.000Z"),
randomId: () => `test-id-${++id}`,
requestHubShutdown: async () => true,
...overrides,
},
});
}
it("waits for stream close and captures diagnostics after process exit", async () => {
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
stdin: null,
});
vi.mocked(childProcess.spawn).mockReturnValueOnce(
child as unknown as childProcess.ChildProcess,
);
const result = runRemoteProcess("ssh", [], { timeoutMs: 2000 });
child.stderr.write("gcloud NumPy warning\n");
child.emit("exit", 23, null);
child.stderr.write("remote helper failed\n");
child.emit("close", 23, null);
await expect(result).resolves.toEqual({
exitCode: 23,
stdout: "",
stderr: "gcloud NumPy warning\nremote helper failed\n",
});
});
it("captures delayed stderr from a real subprocess", async () => {
const result = await runRemoteProcess(
process.execPath,
[
"-e",
'process.stderr.write("early\\n"); setTimeout(() => { process.stderr.write("late\\n", () => { process.exitCode = 23; }); }, 40);',
],
{ timeoutMs: 5000 },
);
expect(result).toEqual({
exitCode: 23,
stdout: "",
stderr: "early\nlate\n",
});
});
// Inherited descendant pipe handles in this fixture require POSIX.
it.skipIf(process.platform === "win32")(
"drains inherited ProxyCommand output after SSH exits",
async () => {
const lateDiagnosticProgram =
'const { spawn } = require("node:child_process");' +
'spawn(process.execPath, ["-e", "setTimeout(() => process.stderr.write(\\"remote helper failed\\\\n\\"), 40)"], { stdio: ["ignore", "ignore", 2] });' +
'process.stderr.write("gcloud NumPy warning\\n");' +
"process.exit(23);";
const result = await runRemoteProcess(
process.execPath,
["-e", lateDiagnosticProgram],
{ timeoutMs: 2_000 },
);
expect(result).toMatchObject({ exitCode: 23, stdout: "" });
expect(result.stderr).toContain("gcloud NumPy warning");
expect(result.stderr).toContain("remote helper failed");
},
);
it.each([
"stdout",
"stderr",
])("bounds captured %s and terminates a noisy process", async (stream) => {
await expect(
runRemoteProcess(
process.execPath,
[
"-e",
`process.on('SIGTERM', () => {}); setInterval(() => process.${stream}.write('x'.repeat(8192)), 1)`,
],
{ timeoutMs: 5000, maxOutputBytes: 16384 },
),
).rejects.toThrow("output exceeded 16384 bytes");
});
it("waits for SIGKILL when a timed-out process ignores SIGTERM", async () => {
const pidFile = join(testDirectory, "process.pid");
await expect(
runRemoteProcess(
process.execPath,
[
"-e",
`require('fs').writeFileSync(process.argv[1], String(process.pid)); process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`,
pidFile,
],
{ timeoutMs: 500 },
),
).rejects.toThrow("timed out");
const pid = Number(await readFile(pidFile, "utf8"));
expect(() => process.kill(pid, 0)).toThrow();
});
it("cleans up when the upload input cannot be opened", async () => {
await expect(
runRemoteProcess(process.execPath, ["-e", "process.stdin.resume()"], {
timeoutMs: 5000,
inputFile: join(testDirectory, "missing"),
}),
).rejects.toThrow("ENOENT");
});
it.each([
"connect",
"delete",
])("recovers lost-Hub cleanup with a deleted helper before %s", async (action) => {
const commands: string[] = [];
const tunnels: FakeTunnel[] = [];
let offline = false;
let requiredIdentity: string | undefined;
let helperMissing = false;
let uploads = 0;
const dependencies: Partial<RemoteEnvironmentDependencies> = {
runProcess: async (_executable, args, options) => {
const command = args.at(-1) ?? "";
commands.push(command);
if (offline) throw new Error("Network unavailable");
if (options.inputFile) {
uploads += 1;
helperMissing = false;
}
if (helperMissing && command.includes("'test' '-x'"))
return { stdout: "", stderr: "", exitCode: 1 };
if (helperMissing && command.includes("--remote-hub-stop"))
throw new Error("Helper missing");
if (requiredIdentity && !args.includes(requiredIdentity)) {
throw new Error("Obsolete SSH identity");
}
if (command.includes("uname -s"))
return inspection("Linux", "x86_64", "/home/dev");
if (command.includes("--remote-hub-ensure"))
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}',
);
return success();
},
resolveHelperBinary: async () => "/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 41000 + tunnels.length,
spawnTunnel: () => {
const tunnel = new FakeTunnel();
tunnels.push(tunnel);
return tunnel;
},
waitForTunnel: async () => undefined,
};
const service = createService(dependencies);
const first = await service.upsert({ name: "First", host: "same-account" });
const second = await service.upsert({
name: "Second",
host: "same-account",
});
await service.connect(first.id);
await service.connect(second.id);
const ensures = commands.filter((command) =>
command.includes("--remote-hub-ensure"),
);
expect(ensures).toHaveLength(2);
expect(ensures[0]).not.toBe(ensures[1]);
offline = true;
tunnels[0].emit("exit", 255, null);
await expect(service.dispose()).rejects.toThrow("Network unavailable");
expect(await readdir(`${profilesPath}.cleanup`)).toHaveLength(1);
offline = false;
const restarted = createService(dependencies);
requiredIdentity = "/keys/rotated";
helperMissing = true;
await restarted.upsert({ ...first, identityFile: requiredIdentity });
if (action === "connect") await restarted.connect(first.id);
else expect(await restarted.delete(first.id)).toBe(true);
expect(uploads).toBe(1);
await restarted.dispose();
expect(await readdir(`${profilesPath}.cleanup`)).toEqual([]);
const stop = commands
.filter((command) => command.includes("--remote-hub-stop"))
.at(-1);
expect(stop?.split("'--discovery-path' ")[1]).toBe(
ensures[0]?.split("'--discovery-path' ")[1],
);
});
it("persists profiles atomically with private permissions and updates in place", async () => {
const service = createService();
const created = await service.upsert({
name: " Build box ",
host: "build.example.com",
user: "alice",
port: 2222,
identityFile: "~/.ssh/build_ed25519",
});
expect(created).toMatchObject({
id: "test-id-1",
name: "Build box",
host: "build.example.com",
createdAt: "2026-08-06T12:00:00.000Z",
updatedAt: "2026-08-06T12:00:00.000Z",
});
// Windows exposes synthetic mode bits; file access is governed by ACLs.
if (process.platform !== "win32") {
expect((await stat(profilesPath)).mode & 0o777).toBe(0o600);
}
const stored = JSON.parse(await readFile(profilesPath, "utf8"));
expect(stored).toEqual({ version: 1, profiles: [created] });
const updated = await service.upsert({
...created,
name: "Build box renamed",
});
expect(updated.id).toBe(created.id);
expect(updated.createdAt).toBe(created.createdAt);
expect(await service.list()).toEqual([updated]);
await expect(
service.upsert({ ...updated, host: "other.example.com" }),
).rejects.toThrow("Create a new remote environment instead");
expect(await service.list()).toEqual([updated]);
const reloaded = createService();
expect(await reloaded.list()).toEqual([updated]);
});
it("validates profile fields before persisting them", async () => {
const service = createService();
await expect(
service.upsert({ name: "bad", host: "-oProxyCommand=bad" }),
).rejects.toThrow("SSH host");
await expect(
service.upsert({ name: "bad", host: "host", port: 70_000 }),
).rejects.toThrow("between 1 and 65535");
await expect(service.list()).resolves.toEqual([]);
});
it("tests SSH connectivity with safe non-interactive OpenSSH options", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
return inspection("Linux", "x86_64", "/home/alice");
},
});
const profile = await service.upsert({
name: "Remote",
host: "ssh-alias",
user: "alice",
port: 2202,
identityFile: "/keys/remote key",
});
await expect(service.test(profile.id)).resolves.toMatchObject({
profileId: profile.id,
state: "available",
remotePlatform: "linux",
remoteArch: "x64",
remoteHome: "/home/alice",
});
expect(invocations).toHaveLength(1);
expect(invocations[0]?.executable).toBe("ssh");
expect(invocations[0]?.args).toEqual(
expect.arrayContaining([
"-o",
"BatchMode=yes",
"ConnectTimeout=10",
"StrictHostKeyChecking=yes",
"-p",
"2202",
"-i",
"/keys/remote key",
"alice@ssh-alias",
]),
);
expect(invocations[0]?.args.at(-1)).toContain("uname -s");
});
it("leaves the SSH port unset so an OpenSSH config alias can choose it", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
return inspection("Linux", "x86_64", "/home/alice");
},
});
const profile = await service.upsert({
name: "Configured host",
host: "pi-from-ssh-config",
});
await service.test(profile.id);
expect(invocations[0]?.args).not.toContain("-p");
expect(invocations[0]?.args.at(-2)).toBe("pi-from-ssh-config");
});
it("parses framed inspection data after noisy SSH startup output", async () => {
const service = createService({
runProcess: async () => ({
...inspection(
"Linux",
"aarch64",
"/home/pi",
"Welcome to the Pi server\n.bashrc says hello\n",
),
stderr: "gcloud: NumPy is not installed; tunnel may be slower.\n",
}),
});
const profile = await service.upsert({ name: "Pi", host: "pi" });
await expect(service.test(profile.id)).resolves.toMatchObject({
state: "available",
remotePlatform: "linux",
remoteArch: "arm64",
remoteHome: "/home/pi",
});
});
it("bootstraps the exact helper and creates a loopback-only SSH tunnel", async () => {
const invocations: Invocation[] = [];
const tunnel = new FakeTunnel();
const spawnTunnel = vi.fn(() => tunnel);
const waitForTunnel = vi.fn(async () => undefined);
const requestHubShutdown = vi.fn(async () => {
expect(tunnel.killed).toBe(false);
return true;
});
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/dev");
}
if (command.includes("'test' '-x'")) {
return { stdout: "", stderr: "", exitCode: 1 };
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"remote-secret"}\n',
);
}
return success();
},
resolveHelperBinary: async ({ platform, arch }) => {
expect({ platform, arch }).toEqual({
platform: "linux",
arch: "arm64",
});
return "/opt/cline/code-sidecar-linux-arm64";
},
fileReadable: async () => true,
hashFile: async () => "abcdef0123456789fedcba9876543210",
reservePort: async () => 43117,
spawnTunnel,
waitForTunnel,
requestHubShutdown,
});
const profile = await service.upsert({
name: "ARM builder",
host: "arm-builder",
});
const [connection, concurrentConnection] = await Promise.all([
service.connect(profile.id),
service.connect(profile.id),
]);
expect(concurrentConnection).toEqual(connection);
expect(spawnTunnel).toHaveBeenCalledTimes(1);
expect(connection).toMatchObject({
profile,
profileId: profile.id,
state: "connected",
endpoint: "ws://127.0.0.1:43117/hub",
authToken: "remote-secret",
workspaceRoot: "/home/dev",
homeDir: "/home/dev",
platform: "linux",
arch: "arm64",
remoteHubUrl: "ws://127.0.0.1:25463/hub",
localPort: 43117,
});
expect(service.getActive()).toEqual(connection);
expect(service.getConnection(profile.id)).toEqual(connection);
const upload = invocations.find(
(invocation) => invocation.options.inputFile,
);
expect(upload).toMatchObject({
executable: "ssh",
options: { inputFile: "/opt/cline/code-sidecar-linux-arm64" },
});
expect(upload?.args.at(-1)).toContain("umask 077; cat >");
const ensure = invocations.find((invocation) =>
invocation.args.at(-1)?.includes("--remote-hub-ensure"),
);
expect(ensure?.args.at(-1)).toContain("'/home/dev'");
expect(ensure?.args.at(-1)).toMatch(
/\/home\/dev\/\.cline\/data\/remote\/[a-f0-9-]+\.json/,
);
expect(spawnTunnel).toHaveBeenCalledWith(
"ssh",
expect.arrayContaining([
"-N",
"ExitOnForwardFailure=yes",
"-L",
"127.0.0.1:43117:127.0.0.1:25463",
"arm-builder",
]),
);
expect(waitForTunnel).toHaveBeenCalledWith(43117, tunnel, 10_000);
await expect(service.disconnect()).resolves.toBe(true);
expect(requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:43117/hub",
"remote-secret",
);
expect(tunnel.killed).toBe(true);
expect(service.getActive()).toBeUndefined();
});
it.each([
"reserve",
"spawn",
"ready",
])("cleans up the owned remote Hub when %s fails", async (stage) => {
const commands: string[] = [];
const tunnel = new FakeTunnel();
const fail = () => {
throw new Error("tunnel setup failed");
};
const service = createService({
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
commands.push(command);
if (command.includes("uname -s"))
return inspection("Linux", "aarch64", "/home/dev");
if (command.includes("--remote-hub-ensure"))
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"secret"}',
);
return success();
},
resolveHelperBinary: async () => "/helper",
fileReadable: async () => true,
hashFile: async () => "abcdef0123456789fedcba9876543210",
reservePort: async () => (stage === "reserve" ? fail() : 43117),
spawnTunnel: () => (stage === "spawn" ? fail() : tunnel),
waitForTunnel: async () => {
if (stage === "ready") fail();
},
});
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(service.connect(profile.id)).rejects.toThrow(
"tunnel setup failed",
);
const ensure = commands.find((command) =>
command.includes("--remote-hub-ensure"),
);
const stop = commands.find((command) =>
command.includes("--remote-hub-stop"),
);
expect(stop).toBeDefined();
expect(stop?.split("'--discovery-path' ")[1]).toBe(
ensure?.split("'--discovery-path' ")[1],
);
expect(tunnel.killed).toBe(stage === "ready");
expect(service.getConnection(profile.id)).toBeUndefined();
});
it("bounds Hub shutdown before closing the SSH tunnel", async () => {
const tunnel = new FakeTunnel();
const requestHubShutdown = vi.fn(
async () => await new Promise<boolean>(() => undefined),
);
const service = createService(
{
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/pi");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 43_000,
spawnTunnel: () => tunnel,
waitForTunnel: async () => undefined,
requestHubShutdown,
},
{ hubShutdownTimeoutMs: 5 },
);
const profile = await service.upsert({ name: "Pi", host: "pi" });
await service.connect(profile.id);
await expect(service.disconnect(profile.id)).resolves.toBe(true);
expect(requestHubShutdown).toHaveBeenCalledOnce();
expect(tunnel.killed).toBe(true);
});
it("reuses an already-installed content-addressed helper", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Darwin", "arm64", "/Users/dev");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://localhost:29000/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/Applications/Cline.app/sidecar",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 40000,
spawnTunnel: () => new FakeTunnel(),
waitForTunnel: async () => undefined,
});
const profile = await service.upsert({
name: "Mac",
host: "mac",
});
await service.connect(profile.id);
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
false,
);
});
it("allows renaming but keeps a profile's SSH destination immutable", async () => {
const tunnel = new FakeTunnel();
const spawnTunnel = vi.fn(() => tunnel);
const service = createService({
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/pi");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 42_000,
spawnTunnel,
waitForTunnel: async () => undefined,
});
const created = await service.upsert({
name: "Pi",
host: "pi-from-ssh-config",
user: "pi",
identityFile: "/keys/pi_ed25519",
});
const first = await service.connect(created.id);
const renamed = await service.upsert({
...created,
name: "Pi renamed",
});
expect(service.getConnection(created.id)?.profile).toEqual(renamed);
await expect(service.connect(created.id)).resolves.toMatchObject({
localPort: first.localPort,
profile: renamed,
});
expect(spawnTunnel).toHaveBeenCalledTimes(1);
for (const destinationUpdate of [
{ host: "different-host" },
{ user: "different-user" },
{ port: 22 },
]) {
await expect(
service.upsert({ ...renamed, ...destinationUpdate }),
).rejects.toThrow("Create a new remote environment instead");
}
await expect(
service.upsert({
...renamed,
identityFile: "/keys/replacement_ed25519",
}),
).rejects.toThrow("Disconnect the remote environment");
expect(service.getConnection(created.id)).toMatchObject({
localPort: first.localPort,
profile: renamed,
});
expect(await service.list()).toEqual([renamed]);
expect(spawnTunnel).toHaveBeenCalledTimes(1);
expect(tunnel.killed).toBe(false);
await service.disconnect(created.id);
const updatedIdentity = await service.upsert({
...renamed,
identityFile: "/keys/replacement_ed25519",
});
expect(await service.list()).toEqual([updatedIdentity]);
});
it("fails clearly when no helper exists for the remote target and never downloads one", async () => {
const invocations: Invocation[] = [];
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
return args.at(-1)?.includes("uname -s")
? inspection("Linux", "aarch64", "/home/dev")
: success();
},
resolveHelperBinary: async () => undefined,
});
const profile = await service.upsert({
name: "Remote",
host: "remote",
});
await expect(service.connect(profile.id)).rejects.toThrow(
"unsupported in SSH: no compatible remote helper binary",
);
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
false,
);
});
it("keeps the active tunnel alive when a replacement connection fails", async () => {
const tunnels: FakeTunnel[] = [];
let nextPort = 41_000;
const service = createService({
runProcess: async (_executable, args) => {
const destination = args.at(-2);
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", "/home/dev");
}
if (command.includes("--remote-hub-ensure")) {
return destination === "host-b"
? { stdout: "", stderr: "bootstrap failed", exitCode: 1 }
: success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => nextPort++,
spawnTunnel: () => {
const tunnel = new FakeTunnel();
tunnels.push(tunnel);
return tunnel;
},
waitForTunnel: async () => undefined,
});
const hostA = await service.upsert({
name: "Host A",
host: "host-a",
});
const hostB = await service.upsert({
name: "Host B",
host: "host-b",
});
const firstConnection = await service.connect(hostA.id);
await expect(service.connect(hostB.id)).rejects.toThrow("bootstrap failed");
expect(service.getActive()).toEqual(firstConnection);
expect(service.getConnection(hostA.id)).toEqual(firstConnection);
expect(tunnels[0]?.killed).toBe(false);
});
it("keeps the previous tunnel until the runtime switch commits", async () => {
const tunnels: FakeTunnel[] = [];
let nextPort = 42_000;
const requestHubShutdown = vi.fn(async () => true);
const service = createService({
runProcess: async (_executable, args) => {
const destination = args.at(-2) ?? "host-a";
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "aarch64", `/home/${destination}`);
}
if (command.includes("--remote-hub-ensure")) {
return success(
`{"url":"ws://127.0.0.1:25463/hub","authToken":"${destination}-token"}\n`,
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => nextPort++,
spawnTunnel: () => {
const tunnel = new FakeTunnel();
tunnels.push(tunnel);
return tunnel;
},
waitForTunnel: async () => undefined,
requestHubShutdown,
});
const hostA = await service.upsert({ name: "Host A", host: "host-a" });
const hostB = await service.upsert({ name: "Host B", host: "host-b" });
const connectionA = await service.connect(hostA.id);
const connectionB = await service.connect(hostB.id);
expect(service.getActive()).toEqual(connectionB);
expect(service.getConnection(hostA.id)).toEqual(connectionA);
expect(service.getConnection(hostB.id)).toEqual(connectionB);
expect(tunnels[0]?.killed).toBe(false);
expect(requestHubShutdown).not.toHaveBeenCalled();
expect(service.activateConnection(hostA.id)).toBe(true);
expect(service.getActive()).toEqual(connectionA);
expect(service.activateConnection("missing-host")).toBe(false);
expect(service.getActive()).toEqual(connectionA);
expect(service.activateConnection(hostB.id)).toBe(true);
expect(await service.disconnect(hostA.id)).toBe(true);
expect(tunnels[0]?.killed).toBe(true);
expect(tunnels[1]?.killed).toBe(false);
expect(service.getConnection(hostB.id)).toEqual(connectionB);
expect(service.getActive()).toEqual(connectionB);
});
it("reports tunnel loss separately from ordinary connection errors", async () => {
const tunnel = new FakeTunnel();
const onConnectionLost = vi.fn();
const requestHubShutdown = vi.fn(async () => true);
const service = new RemoteEnvironmentService({
profilesPath,
onConnectionLost,
dependencies: {
now: () => new Date("2026-08-06T12:00:00.000Z"),
randomId: () => "lost-profile",
runProcess: async (_executable, args) => {
const command = args.at(-1) ?? "";
if (command.includes("uname -s")) {
return inspection("Linux", "x86_64", "/home/dev");
}
if (command.includes("--remote-hub-ensure")) {
return success(
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
);
}
return success();
},
resolveHelperBinary: async () => "/opt/cline/helper",
fileReadable: async () => true,
hashFile: async () => "0123456789abcdef",
reservePort: async () => 41_000,
spawnTunnel: () => tunnel,
waitForTunnel: async () => undefined,
requestHubShutdown,
},
});
const profile = await service.upsert({
name: "Remote",
host: "remote",
});
await service.connect(profile.id);
tunnel.emit("exit", 255, null);
expect(service.getActive()).toBeUndefined();
expect(onConnectionLost).toHaveBeenCalledWith(
expect.objectContaining({
profileId: profile.id,
state: "error",
}),
);
// Cleanup uses a fresh SSH connection, never the failed local tunnel.
await service.dispose();
expect(requestHubShutdown).not.toHaveBeenCalled();
});
it("quotes command arguments and rejects non-zero remote commands", async () => {
const invocations: Invocation[] = [];
let failCommand = false;
const service = createService({
runProcess: async (executable, args, options) => {
invocations.push({ executable, args, options });
if (args.at(-1)?.includes("uname -s")) {
return inspection("Linux", "x86_64", "/home/dev");
}
return failCommand
? { stdout: "", stderr: "permission denied", exitCode: 13 }
: success("ok");
},
});
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(
service.run(profile.id, {
command: "printf",
args: ["%s", "a'b; touch /tmp/not-created"],
cwd: "/tmp/a b",
}),
).resolves.toEqual({ stdout: "ok", stderr: "", exitCode: 0 });
expect(invocations.at(-1)?.args.at(-1)).toBe(
`cd '/tmp/a b' && exec 'printf' '%s' 'a'"'"'b; touch /tmp/not-created'`,
);
failCommand = true;
await expect(
service.run(profile.id, { command: "false", args: [] }),
).rejects.toThrow("permission denied");
});
it("does not let a ProxyCommand warning mask the remote helper diagnostic", async () => {
const service = createService({
runProcess: async () => ({
stdout: "Timed out waiting for detached hub startup.\n",
stderr: "gcloud: NumPy is not installed; tunnel may be slower.\n",
exitCode: 255,
}),
});
const profile = await service.upsert({ name: "GCP", host: "gcp-iap" });
const error = await service
.run(profile.id, { command: "remote-helper", args: [] })
.catch((failure: unknown) => failure);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("exit 255");
expect((error as Error).message).toContain(
"gcloud: NumPy is not installed",
);
expect((error as Error).message).toContain(
"Timed out waiting for detached hub startup",
);
});
it("deletes profiles", async () => {
const service = createService();
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(service.delete(profile.id)).resolves.toBe(true);
await expect(service.delete(profile.id)).resolves.toBe(false);
await expect(service.list()).resolves.toEqual([]);
});
it("turns SSH failures into an error status during connection tests", async () => {
const service = createService({
runProcess: async () => ({
stdout: "",
stderr: "Host key verification failed",
exitCode: 255,
}),
});
const profile = await service.upsert({ name: "Remote", host: "remote" });
await expect(service.test(profile.id)).resolves.toMatchObject({
state: "error",
message: expect.stringContaining("Host key verification failed"),
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
import { runRemoteHelperEntrypoint } from "./remote-helper";
// Executable entrypoint; importing remote/helper never runs the CLI.
void (async () => {
if (!(await runRemoteHelperEntrypoint()))
throw new Error("A remote helper command is required");
})().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
@@ -0,0 +1,196 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import {
type RemoteHelperDependencies,
runRemoteHelperEntrypoint,
} from "./remote-helper";
function createDependencies(
overrides: Partial<RemoteHelperDependencies> = {},
): {
dependencies: RemoteHelperDependencies;
output: string[];
} {
const output: string[] = [];
return {
output,
dependencies: {
readHubDiscovery: vi.fn(async () => undefined),
clearHubDiscoveryIfOwned: vi.fn(async () => true),
probeProcess: vi.fn(),
requestHubShutdown: vi.fn(async () => true),
ensureDetachedHubServer: vi.fn(async () => ({
url: "ws://127.0.0.1:25463/hub",
authToken: "desktop-owner-token",
})),
claimHubDaemonProcess: vi.fn(() => false),
loadHubDaemon: vi.fn(async () => undefined),
ensureLoginShellPath: vi.fn(async () => ({
status: "skipped" as const,
reason: "test",
})),
setHomeDirIfUnset: vi.fn(),
homeDir: () => "/home/pi",
cwd: () => "/home/pi",
env: {},
writeOutput: (value) => output.push(value),
...overrides,
},
};
}
describe("remote helper entrypoint", () => {
it("imports the built public helper without executing its CLI", () => {
const result = execFileSync(
process.execPath,
[
"--input-type=module",
"-e",
`const before = process.exitCode; const helper = await import('@cline/core/remote/helper'); if (process.exitCode !== before || typeof helper.runRemoteHelperEntrypoint !== 'function') throw new Error('Import side effect'); console.log('imported');`,
],
{
cwd: fileURLToPath(new URL("../../", import.meta.url)),
encoding: "utf8",
},
);
expect(result.trim()).toBe("imported");
});
it("starts only the explicitly owned desktop Hub discovery record", async () => {
const { dependencies, output } = createDependencies();
const discoveryPath = "/home/pi/.cline/data/remote/desktop-hub.json";
await expect(
runRemoteHelperEntrypoint(
[
"code-sidecar",
"--remote-hub-ensure",
"--cwd",
"/home/pi",
"--discovery-path",
discoveryPath,
],
dependencies,
),
).resolves.toBe(true);
expect(dependencies.env.CLINE_HUB_DISCOVERY_PATH).toBe(discoveryPath);
expect(dependencies.ensureDetachedHubServer).toHaveBeenCalledWith(
"/home/pi",
{
host: "127.0.0.1",
port: 0,
pathname: "/hub",
allowPortFallback: true,
manageConnectors: false,
},
);
expect(JSON.parse(output.join(""))).toMatchObject({
url: "ws://127.0.0.1:25463/hub",
authToken: "desktop-owner-token",
cwd: "/home/pi",
});
});
it("refuses bootstrap without an explicit discovery owner", async () => {
const { dependencies } = createDependencies();
await expect(
runRemoteHelperEntrypoint(
["code-sidecar", "--remote-hub-ensure"],
dependencies,
),
).rejects.toThrow("--discovery-path is required");
});
it("stops only the explicitly owned Hub using its authentication token", async () => {
const discoveryPath = "/home/pi/.cline/data/remote/owned.json";
const { dependencies } = createDependencies({
readHubDiscovery: vi.fn(
async () =>
({
url: "ws://127.0.0.1:1234/hub",
authToken: "owner-token",
}) as Awaited<
ReturnType<RemoteHelperDependencies["readHubDiscovery"]>
>,
),
});
await expect(
runRemoteHelperEntrypoint(
["helper", "--remote-hub-stop", "--discovery-path", discoveryPath],
dependencies,
),
).resolves.toBe(true);
expect(dependencies.readHubDiscovery).toHaveBeenCalledWith(discoveryPath);
expect(dependencies.requestHubShutdown).toHaveBeenCalledWith(
"ws://127.0.0.1:1234/hub",
"owner-token",
);
});
it.each([
"dead",
"alive",
"denied",
])("handles %s Hub processes during cleanup", async (state) => {
const discoveryPath = "/home/pi/.cline/data/remote/owned.json";
const { dependencies } = createDependencies({
readHubDiscovery: vi.fn(
async () =>
({
hubId: "owned-hub",
pid: 1234,
url: "ws://127.0.0.1:1234/hub",
authToken: "owner-token",
}) as Awaited<
ReturnType<RemoteHelperDependencies["readHubDiscovery"]>
>,
),
probeProcess: vi.fn(() => {
if (state !== "alive")
throw Object.assign(new Error(state), {
code: state === "dead" ? "ESRCH" : "EPERM",
});
}),
requestHubShutdown: vi.fn(async () => {
throw new Error("Connection refused");
}),
});
const result = runRemoteHelperEntrypoint(
["helper", "--remote-hub-stop", "--discovery-path", discoveryPath],
dependencies,
);
if (state === "dead") {
await expect(result).resolves.toBe(true);
expect(dependencies.clearHubDiscoveryIfOwned).toHaveBeenCalledWith(
discoveryPath,
"owned-hub",
);
expect(dependencies.requestHubShutdown).not.toHaveBeenCalled();
} else {
await expect(result).rejects.toThrow("Connection refused");
expect(dependencies.clearHubDiscoveryIfOwned).not.toHaveBeenCalled();
}
});
it("refuses shutdown without an explicit discovery owner", async () => {
const { dependencies } = createDependencies();
await expect(
runRemoteHelperEntrypoint(["helper", "--remote-hub-stop"], dependencies),
).rejects.toThrow("--discovery-path is required");
expect(dependencies.readHubDiscovery).not.toHaveBeenCalled();
});
it("hosts the detached daemon when the one-shot sentinel is claimed", async () => {
const loadHubDaemon = vi.fn(async () => undefined);
const { dependencies } = createDependencies({
claimHubDaemonProcess: () => true,
loadHubDaemon,
});
await expect(
runRemoteHelperEntrypoint(["code-sidecar"], dependencies),
).resolves.toBe(true);
expect(loadHubDaemon).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,138 @@
import { homedir } from "node:os";
import { claimHubDaemonProcess } from "@cline/shared";
import { setHomeDirIfUnset } from "@cline/shared/storage";
import { requestHubShutdown } from "../hub/client";
import { ensureDetachedHubServer } from "../hub/daemon";
import { clearHubDiscoveryIfOwned, readHubDiscovery } from "../hub/discovery";
import { ensureLoginShellPath } from "./shell-path";
export type RemoteHelperDependencies = {
readHubDiscovery: typeof readHubDiscovery;
clearHubDiscoveryIfOwned: typeof clearHubDiscoveryIfOwned;
probeProcess: (pid: number) => void;
requestHubShutdown: typeof requestHubShutdown;
ensureDetachedHubServer: typeof ensureDetachedHubServer;
claimHubDaemonProcess: typeof claimHubDaemonProcess;
loadHubDaemon: () => Promise<unknown>;
ensureLoginShellPath: typeof ensureLoginShellPath;
setHomeDirIfUnset: typeof setHomeDirIfUnset;
homeDir: () => string;
cwd: () => string;
env: NodeJS.ProcessEnv;
writeOutput: (output: string) => void;
};
const defaultDependencies: RemoteHelperDependencies = {
readHubDiscovery,
clearHubDiscoveryIfOwned,
probeProcess: (pid) => {
process.kill(pid, 0);
},
requestHubShutdown,
ensureDetachedHubServer,
claimHubDaemonProcess,
loadHubDaemon: () => import("@cline/core/hub/daemon-entry"),
ensureLoginShellPath,
setHomeDirIfUnset,
homeDir: homedir,
cwd: () => process.cwd(),
env: process.env,
writeOutput: (output) => process.stdout.write(output),
};
function readArgument(argv: string[], name: string): string | undefined {
const index = argv.indexOf(name);
const value = index >= 0 ? argv[index + 1] : undefined;
return value?.trim() || undefined;
}
function configureDedicatedDiscovery(
argv: string[],
dependencies: RemoteHelperDependencies,
): string {
const discoveryPath = readArgument(argv, "--discovery-path");
if (!discoveryPath) {
throw new Error("--discovery-path is required for remote Hub management");
}
// This explicit owner record is the safety boundary: the remote helper never
// reads or shuts down the user's default CLI-owned Hub discovery record.
dependencies.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
return discoveryPath;
}
export async function runRemoteHubEnsure(
argv = process.argv,
dependencies: RemoteHelperDependencies = defaultDependencies,
): Promise<void> {
dependencies.setHomeDirIfUnset(dependencies.homeDir());
await dependencies.ensureLoginShellPath();
const cwd = readArgument(argv, "--cwd") ?? dependencies.cwd();
configureDedicatedDiscovery(argv, dependencies);
const result = await dependencies.ensureDetachedHubServer(cwd, {
host: "127.0.0.1",
port: 0,
pathname: "/hub",
allowPortFallback: true,
manageConnectors: false,
});
dependencies.writeOutput(
`${JSON.stringify({
...result,
cwd,
platform: process.platform,
arch: process.arch,
})}\n`,
);
}
/**
* Handles the SSH bootstrap command and the detached-daemon sentinel. The
* standalone helper is compiled for the target host and contains no client UI
* server or command router. Client executables may also use this entrypoint
* to support the daemon sentinel.
*/
export async function runRemoteHelperEntrypoint(
argv = process.argv,
dependencies: RemoteHelperDependencies = defaultDependencies,
): Promise<boolean> {
if (argv.includes("--remote-hub-stop")) {
const discoveryPath = configureDedicatedDiscovery(argv, dependencies);
const hub = await dependencies.readHubDiscovery(discoveryPath);
// A crash or reboot can leave discovery pointing at a dead Hub. Only
// ESRCH proves the process is gone; permission/probe errors and live
// processes must still go through authenticated shutdown.
if (
hub &&
typeof hub.pid === "number" &&
Number.isInteger(hub.pid) &&
hub.pid > 0
) {
try {
dependencies.probeProcess(hub.pid);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ESRCH") {
await dependencies.clearHubDiscoveryIfOwned(discoveryPath, hub.hubId);
return true;
}
}
}
if (
hub &&
!(await dependencies.requestHubShutdown(hub.url, hub.authToken))
) {
throw new Error("Remote Hub shutdown failed");
}
return true;
}
if (argv.includes("--remote-hub-ensure")) {
await runRemoteHubEnsure(argv, dependencies);
return true;
}
// Claim rather than read: consuming the sentinel keeps daemon-hosted
// sessions from handing it to every process they spawn.
if (dependencies.claimHubDaemonProcess()) {
await dependencies.loadHubDaemon();
return true;
}
return false;
}
@@ -0,0 +1,272 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
defaultShellFor,
ensureLoginShellPath,
extractMarkedPath,
loginShellFor,
mergePaths,
resolveLoginShellPath,
shellInvocation,
} from "./shell-path";
const isWindows = process.platform === "win32";
const posixTest = it.skipIf(isWindows);
const MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const MARKER_END = "__CLINE_SIDECAR_PATH_END__";
let tempDirs: string[] = [];
/**
* Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c <cmd>`,
* so the command to run arrives as $4. The default body mimics a login shell
* whose profile prepends Homebrew before running the command.
*/
function writeFakeShell(
script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"',
name = "fake-shell",
): string {
const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-"));
tempDirs.push(dir);
const shellPath = join(dir, name);
writeFileSync(shellPath, `#!/bin/sh\n${script}\n`);
chmodSync(shellPath, 0o755);
return shellPath;
}
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
});
describe("extractMarkedPath", () => {
it("extracts the PATH between markers", () => {
expect(
extractMarkedPath(
`${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`,
),
).toBe("/opt/homebrew/bin:/usr/bin");
});
it("ignores shell profile noise around the markers", () => {
const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`;
expect(extractMarkedPath(output)).toBe("/usr/local/bin");
});
it("returns undefined when markers are missing or empty", () => {
expect(extractMarkedPath("no markers here")).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined();
});
});
describe("mergePaths", () => {
it("puts shell entries first and keeps current-only entries", () => {
expect(
mergePaths(
["/opt/homebrew/bin", "/usr/bin", "/bin"].join(delimiter),
["/usr/bin", "/bin", "/custom/bin"].join(delimiter),
),
).toBe(
["/opt/homebrew/bin", "/usr/bin", "/bin", "/custom/bin"].join(delimiter),
);
});
it("drops duplicate and empty entries", () => {
expect(
mergePaths(
["/a", "", "/b", "/a"].join(delimiter),
["/b", "/c", ""].join(delimiter),
),
).toBe(["/a", "/b", "/c"].join(delimiter));
});
});
describe("defaultShellFor", () => {
it("uses zsh on macOS and bash elsewhere", () => {
expect(defaultShellFor("darwin")).toBe("/bin/zsh");
expect(defaultShellFor("linux")).toBe("/bin/bash");
});
});
describe("loginShellFor", () => {
posixTest("returns the passwd-database shell when one exists", () => {
// The test runner's uid has a passwd entry, so $SHELL must lose.
const shell = loginShellFor(process.platform, {
SHELL: "/env/should-not-win",
});
expect(shell.startsWith("/")).toBe(true);
expect(shell).not.toBe("/env/should-not-win");
});
});
describe("shellInvocation", () => {
it("uses separate login+interactive flags for posix-style shells", () => {
expect(shellInvocation("/bin/zsh", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
});
it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => {
expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-tcsh",
});
expect(shellInvocation("/bin/csh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-csh",
});
});
});
// These integration fixtures execute real POSIX shell scripts.
describe.skipIf(isWindows)("resolveLoginShellPath", () => {
it("captures PATH from the shell", async () => {
const shell = writeFakeShell();
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("reads PATH from the environment, not the shell's own expansion", async () => {
// Mimics fish: its "$PATH" expansion would space-join the entries,
// but the printf runs inside /bin/sh, which reads the exported
// colon-delimited PATH env var — so the shell's expansion rules
// never apply. This fake shell never evals the command text; it
// only exports PATH and runs the command via sh, like fish would.
const shell = writeFakeShell(
'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"',
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("resolves undefined when the shell prints garbage", async () => {
const shell = writeFakeShell('echo "no markers"');
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
});
it("resolves undefined when the shell is missing", async () => {
await expect(
resolveLoginShellPath("/nonexistent/shell"),
).resolves.toBeUndefined();
});
it("bounds noisy shell output", async () => {
const shell = writeFakeShell(
"while :; do printf 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n'; done",
);
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
});
it("times out hung shells without rejecting", async () => {
const shell = writeFakeShell("sleep 60");
await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined();
});
it("invokes csh-family shells without login/interactive flags", async () => {
// A csh stand-in that rejects any first flag other than -c.
const shell = writeFakeShell(
'[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"',
"tcsh",
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
});
describe("ensureLoginShellPath", () => {
posixTest("merges the login shell PATH into env.PATH", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(result).toEqual({
status: "applied",
pathEntries: 3,
shell,
});
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin");
});
posixTest(
"falls back to the default shell when $SHELL can't resolve",
async () => {
const fallbackShell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell,
});
expect(result.status).toBe("applied");
expect(result).toMatchObject({ shell: fallbackShell });
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
},
);
it("leaves PATH untouched when every shell fails", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell: "/nonexistent/other-shell",
});
expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" });
expect(env.PATH).toBe("/usr/bin");
});
it("skips on windows", async () => {
const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
const result = await ensureLoginShellPath({ platform: "win32", env });
expect(result).toEqual({ status: "skipped", reason: "windows" });
expect(env.PATH).toBe("C:\\Windows");
});
it("skips when the escape hatch is set", async () => {
const env: NodeJS.ProcessEnv = {
PATH: "/usr/bin",
CLINE_SIDECAR_SKIP_SHELL_PATH: "1",
};
const result = await ensureLoginShellPath({ platform: "darwin", env });
expect(result.status).toBe("skipped");
expect(env.PATH).toBe("/usr/bin");
});
posixTest("never exposes the resolved PATH in its result", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin");
});
posixTest("resolves against a real shell end to end", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/bin" };
const result = await ensureLoginShellPath({
platform: "linux",
env,
userShell: "/bin/sh",
});
expect(result.status).toBe("applied");
expect(env.PATH).toContain("/bin");
});
});
+255
View File
@@ -0,0 +1,255 @@
/**
* Login-shell PATH resolution for the desktop sidecar.
*
* When the Tauri app is launched from Finder/the Dock on macOS, it inherits
* launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the
* user's shell PATH. The sidecar and every process it spawns for the agent
* (bash tool, MCP servers) then can't find tools like `gh` that live in
* /opt/homebrew/bin or other shell-profile-added directories, even though
* the same task works from the CLI in a terminal.
*
* At startup we ask the user's login shell for its PATH and merge it into
* process.env.PATH, so child processes see the same PATH a terminal would.
*/
import { spawn } from "node:child_process";
import { userInfo } from "node:os";
import { basename, delimiter } from "node:path";
const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__";
/**
* Kept well under the Tauri shell's 5s endpoint-readiness poll: this
* resolution overlaps sidecar startup but is awaited before the server
* starts, so a pathological shell profile must not eat the whole window.
*/
const SHELL_TIMEOUT_MS = 2_000;
/**
* The command every shell is asked to run. $PATH expansion happens inside
* POSIX sh not the user's shell so shells with different expansion rules
* (fish would space-join "$PATH") still produce a colon-delimited value; sh
* reads the PATH environment variable the login shell exported.
*/
const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`;
/**
* Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched
* (e.g. if a broken shell profile makes resolution misbehave).
*/
const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH";
export function defaultShellFor(platform: NodeJS.Platform): string {
return platform === "darwin" ? "/bin/zsh" : "/bin/bash";
}
/**
* The user's configured login shell. The account database is authoritative:
* a GUI-launched process has no parent shell, so $SHELL may be unset there.
* userInfo() reads getpwuid(), which on macOS goes through DirectoryServices
* the same source `dscl . -read /Users/$USER UserShell` reports and on
* Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform
* default are fallbacks for environments with no passwd entry.
*/
export function loginShellFor(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): string {
try {
const shell = userInfo().shell?.trim();
if (shell) {
return shell;
}
} catch {
// No passwd entry for the current uid (some containers) — fall through.
}
return env.SHELL?.trim() || defaultShellFor(platform);
}
export interface ShellInvocation {
args: string[];
/**
* argv[0] the shell should see. A leading dash is the historical "you
* are a login shell" signal, used where -l can't be passed as a flag.
*/
argv0?: string;
}
/**
* How to invoke a shell so it sources its profiles and runs a command.
* csh/tcsh accept -l only as the sole flag, so they're marked login via the
* argv[0] dash convention instead (sources ~/.login on top of the always-read
* ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile
* Homebrew's shellenv) plus interactive (-i, ~/.zshrc nvm-style version
* managers) as separate flags.
*/
export function shellInvocation(
shell: string,
command: string,
): ShellInvocation {
const kind = basename(shell);
if (kind === "csh" || kind === "tcsh") {
return { args: ["-c", command], argv0: `-${kind}` };
}
return { args: ["-i", "-l", "-c", command] };
}
/**
* Extract the PATH value printed between the sentinel markers, ignoring any
* noise a shell profile writes to stdout around it.
*/
export function extractMarkedPath(output: string): string | undefined {
const start = output.indexOf(PATH_MARKER_START);
if (start === -1) {
return undefined;
}
const end = output.indexOf(PATH_MARKER_END, start);
if (end === -1) {
return undefined;
}
const value = output.slice(start + PATH_MARKER_START.length, end).trim();
return value.length > 0 ? value : undefined;
}
/**
* Merge the login shell's PATH with the current one: shell entries first (so
* profile-managed dirs like /opt/homebrew/bin win), then any current entries
* the shell PATH doesn't already contain (so explicitly-injected dirs from
* the launching environment aren't lost). Duplicates are dropped.
*/
export function mergePaths(shellPath: string, currentPath: string): string {
const entries = [
...shellPath.split(delimiter),
...currentPath.split(delimiter),
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return Array.from(new Set(entries)).join(delimiter);
}
/**
* Run the user's shell with its profiles sourced and capture its PATH.
* Resolves to undefined on any failure (missing shell, timeout, profile
* error) callers should treat that as "keep the current PATH".
*/
export function resolveLoginShellPath(
shell: string,
timeoutMs = SHELL_TIMEOUT_MS,
): Promise<string | undefined> {
return new Promise((resolve) => {
const invocation = shellInvocation(shell, PRINT_PATH_COMMAND);
const child = spawn(shell, invocation.args, {
argv0: invocation.argv0,
stdio: ["ignore", "pipe", "ignore"],
detached: true,
});
let output = "";
let outputBytes = 0;
const killShell = () => {
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch {
child.kill("SIGKILL");
}
};
let settled = false;
const settle = (value: string | undefined) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
child.stdout?.destroy();
killShell();
resolve(value);
};
const timeout = setTimeout(() => {
try {
if (child.pid) {
process.kill(-child.pid, "SIGKILL");
}
} catch {
child.kill("SIGKILL");
}
settle(undefined);
}, timeoutMs);
child.stdout?.on("data", (data: Buffer) => {
if (settled) return;
outputBytes += data.length;
if (outputBytes > 64 * 1024) {
settle(undefined);
return;
}
output += data.toString("utf8");
});
child.on("error", () => settle(undefined));
child.on("close", () => settle(extractMarkedPath(output)));
});
}
/**
* Resolve the login shell's PATH and merge it into process.env.PATH. The
* shell comes from the account database (see loginShellFor); if it can't
* produce a PATH (exotic shell, broken profile), retry once with the
* platform default shell before giving up.
*
* No-op on Windows (the GUI PATH comes from the registry there) and when
* CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the
* returned status but never block startup. The result never contains the
* resolved PATH itself so it is safe to log verbatim.
*/
export async function ensureLoginShellPath(options?: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
/** Test seam: overrides passwd/$SHELL discovery of the user's shell. */
userShell?: string;
/** Test seam: overrides the platform-default fallback shell. */
fallbackShell?: string;
}): Promise<
| { status: "applied"; pathEntries: number; shell: string }
| { status: "skipped"; reason: string }
| { status: "failed"; shell: string }
> {
const platform = options?.platform ?? process.platform;
const env = options?.env ?? process.env;
if (platform === "win32") {
return { status: "skipped", reason: "windows" };
}
if (env[SKIP_ENV_VAR]?.trim()) {
return { status: "skipped", reason: SKIP_ENV_VAR };
}
const userShell = options?.userShell ?? loginShellFor(platform, env);
const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform);
const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS;
// The fallback gets half the budget so the combined worst case stays
// bounded even when both shells hang (see SHELL_TIMEOUT_MS).
const attempts: Array<[shell: string, timeoutMs: number]> =
userShell === fallbackShell
? [[userShell, baseTimeoutMs]]
: [
[userShell, baseTimeoutMs],
[fallbackShell, baseTimeoutMs / 2],
];
for (const [shell, timeoutMs] of attempts) {
const shellPath = await resolveLoginShellPath(shell, timeoutMs);
if (!shellPath) {
continue;
}
const merged = mergePaths(shellPath, env.PATH ?? "");
env.PATH = merged;
return {
status: "applied",
pathEntries: merged.split(delimiter).length,
shell,
};
}
return { status: "failed", shell: userShell };
}
@@ -13,7 +13,7 @@ import {
type FeatureFlag,
FeatureFlagDefaultValue,
} from "@cline/shared";
import { CORE_TELEMETRY_EVENTS } from "../..";
import { CORE_TELEMETRY_EVENTS } from "../telemetry/core-events";
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000;
const DEFAULT_PERSISTENT_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -273,6 +273,95 @@ describe("prepareLocalRuntimeBootstrap", () => {
});
});
it.each([
{ label: "missing", systemPrompt: undefined },
{ label: "blank", systemPrompt: " \n\t" },
])("builds the default system prompt from execution-host workspace context when the prompt is $label", async ({
systemPrompt,
}) => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const workspaceRoot = mkdtempSync(
join(tmpdir(), "remote-bootstrap-prompt-"),
);
const input = createStartInput();
const config = input.config as Omit<
typeof input.config,
"mode" | "systemPrompt"
> & {
mode: "act" | "plan";
rules?: string;
systemPrompt?: string;
};
config.cwd = workspaceRoot;
config.workspaceRoot = workspaceRoot;
if (systemPrompt === undefined) {
delete config.systemPrompt;
} else {
config.systemPrompt = systemPrompt;
}
config.mode = "plan";
config.rules = "# Remote Rule\n\nOnly inspect the execution host.";
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
sessionId: "sess-remote-prompt",
providerSettingsManager: createProviderSettingsManager() as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(bootstrap.config.systemPrompt).toContain(
`1. Platform: ${process.platform}`,
);
expect(bootstrap.config.systemPrompt).toContain(
`4. Working Directory: ${workspaceRoot}`,
);
expect(bootstrap.config.systemPrompt).toContain(
"# Workspace Configuration",
);
expect(bootstrap.config.systemPrompt).toContain(workspaceRoot);
expect(bootstrap.config.systemPrompt).toContain(
"Only inspect the execution host.",
);
expect(bootstrap.config.systemPrompt).toContain("# Plan Mode");
expect(bootstrap.runtimeBuilderInput.config.systemPrompt).toBe(
bootstrap.config.systemPrompt,
);
});
it("preserves an explicit system prompt exactly", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const input = createStartInput();
const explicitPrompt = " Use the caller-owned prompt verbatim. \n";
input.config.systemPrompt = explicitPrompt;
const config = input.config as typeof input.config & { rules?: string };
config.rules = "This rule belongs only in a generated prompt.";
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
sessionId: "sess-explicit-prompt",
providerSettingsManager: createProviderSettingsManager() as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(bootstrap.config.systemPrompt).toBe(explicitPrompt);
});
it("filters globally disabled plugin tools before extension setup", async () => {
vi.resetModules();
resetModulesAfterEach = true;
@@ -13,7 +13,10 @@ import type {
ToolApprovalResult,
WorkspaceInfo,
} from "@cline/shared";
import { hasRuntimeConfigExtension } from "@cline/shared";
import {
buildClineSystemPrompt,
hasRuntimeConfigExtension,
} from "@cline/shared";
import { version as corePackageVersion } from "../../package.json";
import {
type AgentPluginPackageDiagnostic,
@@ -157,6 +160,27 @@ function hasConfigExtension(
return hasRuntimeConfigExtension(extensions, kind);
}
function resolveBootstrapSystemPrompt(
config: CoreSessionConfig,
workspaceInfo: WorkspaceInfo,
workspaceMetadata: string,
): string {
if (config.systemPrompt?.trim()) {
return config.systemPrompt;
}
return buildClineSystemPrompt({
ide: "Terminal Shell",
workspaceRoot: workspaceInfo.rootPath,
workspaceName: workspaceInfo.hint,
metadata: workspaceMetadata,
rules: config.rules,
mode: config.mode,
providerId: config.providerId,
platform: process.platform || "unknown",
});
}
function buildProviderConfig(
config: CoreSessionConfig,
sessionId: string,
@@ -516,6 +540,11 @@ export async function prepareLocalRuntimeBootstrap(
...baseConfig,
providerConfig,
workspaceMetadata,
systemPrompt: resolveBootstrapSystemPrompt(
baseConfig,
workspaceInfo,
workspaceMetadata,
),
hooks,
};
const toolPolicies =
@@ -9,7 +9,6 @@ import {
} from "node:fs";
import { basename, dirname } from "node:path";
import { resolveProviderSettingsPath } from "@cline/shared/storage";
import { getLiveModelsCatalog } from "../..";
import { getProviderAuthHandler } from "../../auth/provider-auth-registry";
import { hashSecret, sdkDebug } from "../../logging/early-logger";
import {
@@ -25,6 +24,7 @@ import {
type VoiceInputSettings,
VoiceInputSettingsSchema,
} from "../../types/provider-settings";
import { getLiveModelsCatalog } from "../llms/provider-defaults";
import {
ensureCustomProvidersLoadedSync,
registerConfiguredProvidersFromSettings,
+2
View File
@@ -11,6 +11,8 @@
},
"include": [
"src/index.ts",
"src/remote/remote-helper.ts",
"src/remote/remote-helper-entry.ts",
"src/hub/index.ts",
"src/hub/daemon/entry.ts",
"src/services/telemetry/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/llms",
"version": "0.0.82",
"version": "0.0.83",
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
"repository": {
"type": "git",
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,12 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload =
},
],
free: [
{
id: "cline-free/deepseek-v4.1-flash",
name: "Deepseek-v4.1-Flash",
description: "Fast and efficient with 1M context window ",
tags: [],
},
{
id: "cline-free/muse-spark-1.3-contributor",
name: "Muse Spark 1.3 Contributor",
@@ -39,12 +45,6 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload =
"Metas multimodal reasoning model for experimentation, learning, and early-stage agentic, multi-agent, and coding workflows.",
tags: [],
},
{
id: "deepseek/deepseek-v4-flash",
name: "deepseek-v4-flash",
description: "Fast and efficient with 1M context window ",
tags: [],
},
{
id: "z-ai/glm-5.3-flash",
name: "glm-5.3-flash",
@@ -58,13 +58,6 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload =
"Strong model for office productivity, document-intensive work, and coding.",
tags: [],
},
{
id: "cline-free/longcat-2.0",
name: "LongCat-2.0",
description:
"A next-generation trillion-parameter model built for agentic coding",
tags: [],
},
{
id: "poolside/laguna-s-2.1:free",
name: "laguna-s-2.1:free",
@@ -92,9 +85,9 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload =
tags: [],
},
{
id: "cline-pass/glm-5.3-flash",
name: "cline-pass/glm-5.3-flash",
description: "Latest natively multimodal model in the GLM-5 series",
id: "cline-pass/deepseek-v4.1-flash",
name: "cline-pass/deepseek-v4.1-flash",
description: "Smarter and more efficient, with 1M context window",
tags: [],
},
{
@@ -105,21 +98,9 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload =
tags: [],
},
{
id: "cline-pass/deepseek-v4-flash",
name: "cline-pass/deepseek-v4-flash",
description: "Fast and efficient with 1M context window",
tags: [],
},
{
id: "cline-pass/qwen3.7-plus",
name: "cline-pass/qwen3.7-plus",
description: "Fast multimodal agent model with vision and video input",
tags: [],
},
{
id: "cline-pass/minimax-m3",
name: "cline-pass/minimax-m3",
description: "Frontier coding and agent model with 1M context window",
id: "cline-pass/glm-5.3-flash",
name: "cline-pass/glm-5.3-flash",
description: "Latest natively multimodal model in the GLM-5 series",
tags: [],
},
{
@@ -128,24 +109,42 @@ export const GENERATED_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsPayload =
description: "Latest Kimi model specialized for agentic coding",
tags: [],
},
{
id: "cline-pass/kimi-k2.6",
name: "cline-pass/kimi-k2.6",
description: "Strong multimodal model for long-horizon agent tasks",
tags: [],
},
{
id: "cline-pass/glm-5.3",
name: "cline-pass/glm-5.3",
description: "Z-AI's new top open-weights model",
tags: [],
},
{
id: "cline-pass/kimi-k2.6",
name: "cline-pass/kimi-k2.6",
description: "Strong multimodal model for long-horizon agent tasks",
tags: [],
},
{
id: "cline-pass/deepseek-v4-flash",
name: "cline-pass/deepseek-v4-flash",
description: "Fast and efficient with 1M context window",
tags: [],
},
{
id: "cline-pass/minimax-m3",
name: "cline-pass/minimax-m3",
description: "Frontier coding and agent model with 1M context window",
tags: [],
},
{
id: "cline-pass/qwen3.7-max",
name: "cline-pass/qwen3.7-max",
description: "Flagship agent model with 1M context window",
tags: [],
},
{
id: "cline-pass/qwen3.7-plus",
name: "cline-pass/qwen3.7-plus",
description: "Fast multimodal agent model with vision and video input",
tags: [],
},
{
id: "cline-pass/mimo-v2.5-pro",
name: "cline-pass/mimo-v2.5-pro",
+1
View File
@@ -93,6 +93,7 @@ export {
isClinePassLimitMessage,
isProviderApiLine,
isRegisteredHandlerAsync,
isRetryableProviderError,
normalizeProviderId,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
type ProviderApiLine,
+4 -1
View File
@@ -35,7 +35,10 @@ import {
type ProviderConfig,
} from "./providers/types";
export { classifyProviderError } from "./providers/error-classification";
export {
classifyProviderError,
isRetryableProviderError,
} from "./providers/error-classification";
export {
ClineFreeModelLimitError,
ClineNotSubscribedError,
+35 -1
View File
@@ -44,7 +44,10 @@ import {
} from "ai";
import { nanoid } from "nanoid";
import type { AiSdkTelemetryDecision } from "../services/langfuse-telemetry";
import { classifyProviderError } from "./error-classification";
import {
classifyProviderError,
isRetryableBeyondSdkRetries,
} from "./error-classification";
import { extractErrorMessage } from "./format";
import { createRetryEmptyResponseMiddleware } from "./middleware/retry-empty-response";
import {
@@ -1059,6 +1062,23 @@ function getNestedUsageValue(
return getNumericValue(current) ?? 0;
}
/**
* AI SDK request-level retries for each model call (the SDK default is 2). The
* SDK retries the *initial* request on transient failures 429/5xx/network
* with exponential backoff that honors `retry-after` headers. It never sees an
* error the provider emits *mid-stream* (OpenRouter's "Provider returned error"
* arrives as a stream part after a 200), so the agent loop keeps its own
* turn-level retry for those.
*
* Each failure class has exactly one retrying layer, so the counts never
* multiply: request-start failures belong to this setting (a `RetryError` is
* terminal for the turn-level retry, see `isRetryableBeyondSdkRetries`);
* pre-output socket deaths and empty responses belong to
* `withEmptyResponseRetry`, which never sees request-start rejections; and
* mid-stream provider errors belong to the turn-level retry alone.
*/
const MODEL_REQUEST_MAX_RETRIES = 5;
type UsagePath = readonly [string] | readonly [string, string];
const REASONING_TOKEN_PATHS: UsagePath[] = [
@@ -1409,6 +1429,16 @@ function extractGoogleThoughtMetadata(
interface CapturedStreamError {
message: string;
errorClass: ProviderErrorClass;
/**
* Whether the agent loop's turn-level retry may re-run this turn, decided
* while the structured error is still in hand and forwarded as
* `errorRetryable` on the `finish` event (the flattened message the agent
* loop receives cannot carry it). Transient by the AI SDK's own typed
* `isRetryable` flag, except that a `RetryError` is terminal: the SDK
* already spent its request-start retries, and the turn-level retry must
* not multiply them.
*/
retryable: boolean;
/**
* This layer already recorded `sdk.error` telemetry for the failure.
* Forwarded as `errorReported` on the `finish` event so the agent loop
@@ -1421,6 +1451,7 @@ function captureStreamError(error: unknown): CapturedStreamError {
return {
message: extractErrorMessage(error),
errorClass: classifyProviderError(error),
retryable: isRetryableBeyondSdkRetries(error),
};
}
@@ -1939,6 +1970,7 @@ async function* emitAiSdkEvents(
reason: streamError ? "error" : mapFinishReason(finishReason, sawToolCalls),
error: streamError?.message,
errorClass: streamError?.errorClass,
errorRetryable: streamError?.retryable,
errorReported: streamError?.reported,
};
}
@@ -2266,6 +2298,7 @@ function createAiSdkProvider(
...(useSystemOption ? { system: systemPrompt } : {}),
...(tools ? { tools } : {}),
abortSignal: request.signal,
maxRetries: MODEL_REQUEST_MAX_RETRIES,
experimental_repairToolCall: repairMalformedToolCall as never,
telemetry: {
...aiSdkTelemetry,
@@ -2376,6 +2409,7 @@ function createAiSdkProvider(
reason: "error",
error: msg,
errorClass: captured.errorClass,
errorRetryable: captured.retryable,
errorReported: reported || captured.reported,
};
}
@@ -248,6 +248,16 @@ describe("cline-pass builtin spec", () => {
expect(model.pricing).toBeDefined();
}
});
it("defaults to a subscribed-tier model, not a free one", async () => {
const models = await getModelsForProvider("cline-pass");
const provider = await getProvider("cline-pass");
expect(provider?.defaultModelId).toMatch(/^cline-pass\//);
expect(
Object.keys(models).some((id) => !id.startsWith("cline-pass/")),
).toBe(true);
});
});
describe("built-in provider metadata", () => {
+8 -8
View File
@@ -398,17 +398,17 @@ function generatedModels(providerId: string): Record<string, ModelInfo> {
}
function firstGeneratedModelId(providerId: string): string {
// Use the catalog's authored order, not release-date order. The cline-pass
// block mirrors the recommended-models endpoint, which lists the intended
// default subscription model first — the newest model is not necessarily a
// safe default.
// The generated list is release-date ordered and mixes tiers (cline-pass/*,
// cline-free/*, :free). Only a subscribed-tier model is a safe default;
// fall back to the first entry only when the catalog has none.
const generatedModelList = Object.keys(
getGeneratedModelsForProvider(providerId),
);
if (!generatedModelList.length) {
return "";
}
return generatedModelList[0];
return (
generatedModelList.find((id) => id.startsWith(`${providerId}/`)) ??
generatedModelList[0] ??
""
);
}
function pickAnthropicModel(match: (id: string) => boolean): ModelInfo {
@@ -5,7 +5,11 @@ import {
TypeValidationError,
} from "ai";
import { describe, expect, it } from "vitest";
import { classifyProviderError } from "./error-classification";
import {
classifyProviderError,
isRetryableBeyondSdkRetries,
isRetryableProviderError,
} from "./error-classification";
describe("classifyProviderError", () => {
describe("context_window_exceeded", () => {
@@ -400,3 +404,151 @@ describe("classifyProviderError", () => {
});
});
});
describe("isRetryableProviderError", () => {
const apiCallError = (statusCode: number, message = "error") =>
new APICallError({
message,
url: "https://api.example.com/v1/chat/completions",
requestBodyValues: {},
statusCode,
responseBody: JSON.stringify({ error: { message } }),
});
describe("retryable", () => {
it("retries a typed APICallError 429 via the SDK's isRetryable flag", () => {
expect(isRetryableProviderError(apiCallError(429, "rate limited"))).toBe(
true,
);
});
it("retries a typed APICallError 503", () => {
expect(isRetryableProviderError(apiCallError(503))).toBe(true);
});
it("unwraps a RetryError whose final attempt was a 429", () => {
const last = apiCallError(429, "rate limited");
const error = new RetryError({
message: "Failed after 3 attempts",
reason: "maxRetriesExceeded",
errors: [last],
});
expect(isRetryableProviderError(error)).toBe(true);
});
it("retries a gateway-forwarded 500 carried as a JSON message string", () => {
expect(
isRetryableProviderError(
JSON.stringify({ error: { message: "boom", code: 500 } }),
),
).toBe(true);
});
it("retries OpenRouter's bare mid-stream 'Provider returned error' string", () => {
expect(isRetryableProviderError("Provider returned error")).toBe(true);
});
});
describe("not retryable", () => {
it("does not retry a credential rejection (401)", () => {
expect(
isRetryableProviderError(apiCallError(401, "Invalid API Key")),
).toBe(false);
});
it("does not retry a context-window overflow (400)", () => {
expect(
isRetryableProviderError(
apiCallError(
400,
"This model's maximum context length is 40960 tokens",
),
),
).toBe(false);
});
it("does not retry other client errors (404)", () => {
expect(
isRetryableProviderError(apiCallError(404, "model not found")),
).toBe(false);
});
it("does not retry a bare transport failure with no status", () => {
expect(isRetryableProviderError("fetch failed: socket closed")).toBe(
false,
);
});
it("returns false for undefined", () => {
expect(isRetryableProviderError(undefined)).toBe(false);
});
});
describe("a RetryError is judged by its final attempt only", () => {
const retryErrorEndingIn = (last: Error) =>
new RetryError({
message: "Failed after 3 attempts",
reason: "maxRetriesExceeded",
errors: [apiCallError(429, "rate limited"), last],
});
it("does not let an earlier 429 make a final plain 400 retryable", () => {
const last = Object.assign(new Error("invalid request"), {
statusCode: 400,
});
expect(isRetryableProviderError(retryErrorEndingIn(last))).toBe(false);
});
it("does not let an earlier 429 make a final statusless transport failure retryable", () => {
expect(
isRetryableProviderError(
retryErrorEndingIn(new Error("connection reset by peer")),
),
).toBe(false);
});
it("still retries when the final attempt itself is a 5xx", () => {
const last = Object.assign(new Error("upstream unavailable"), {
statusCode: 503,
});
expect(isRetryableProviderError(retryErrorEndingIn(last))).toBe(true);
});
});
});
describe("isRetryableBeyondSdkRetries", () => {
const apiCallError = (statusCode: number, message = "error") =>
new APICallError({
message,
url: "https://api.example.com/v1/chat/completions",
requestBodyValues: {},
statusCode,
responseBody: JSON.stringify({ error: { message } }),
});
it("treats a RetryError as terminal even when its final attempt was transient", () => {
const exhausted = new RetryError({
message: "Failed after 6 attempts",
reason: "maxRetriesExceeded",
errors: [apiCallError(429, "rate limited"), apiCallError(503)],
});
expect(isRetryableProviderError(exhausted)).toBe(true);
expect(isRetryableBeyondSdkRetries(exhausted)).toBe(false);
});
it("still retries a transient failure the SDK did not retry", () => {
expect(isRetryableBeyondSdkRetries(apiCallError(429, "rate limited"))).toBe(
true,
);
expect(isRetryableBeyondSdkRetries("Provider returned error")).toBe(true);
});
it("still refuses permanent failures", () => {
expect(
isRetryableBeyondSdkRetries(apiCallError(401, "Invalid API Key")),
).toBe(false);
expect(isRetryableBeyondSdkRetries("fetch failed: socket closed")).toBe(
false,
);
});
});
@@ -307,3 +307,155 @@ export function classifyProviderError(error: unknown): ProviderErrorClass {
}
return verdictFromSignals(signals);
}
/**
* HTTP statuses that are transient and retryable: request timeout / conflict /
* too-early, rate limiting, and the 5xx server-failure family (incl. the
* widely used 529 "overloaded"). This mirrors the AI SDK's own retry policy
* and is the fallback only for errors that are not typed AI SDK instances;
* typed errors defer to {@link APICallError.isRetryable}. Any other 4xx is the
* caller's own request being rejected and must not be retried.
*/
const RETRYABLE_STATUSES = new Set([
408, 409, 425, 429, 500, 502, 503, 504, 529,
]);
/**
* The sole message fallback. OpenRouter forwards an upstream failure mid-stream
* as a bare "Provider returned error" string with no HTTP status and no typed
* error to inspect, so there is nothing else to key on. Every other decision
* comes from the AI SDK's typed `isRetryable` flag or the HTTP status not
* from matching free-form message text.
*/
const PROVIDER_RETURNED_ERROR_PATTERN = /provider returned error/i;
/**
* Retryability taken from a real AI SDK error instance via its own typed
* `isRetryable` flag, rather than re-deriving it the maintainable path that
* stays correct as the SDK evolves. Returns `undefined` when the error is not
* a recognized instance, so {@link isRetryableProviderError} falls back to the
* structural walk.
*/
function isRetryableTypedError(
error: unknown,
depth: number,
): boolean | undefined {
if (depth > MAX_WALK_DEPTH) {
return undefined;
}
if (RetryError.isInstance(error)) {
// The SDK already retried and gave up; only the final attempt decides
// whether another attempt at our layer is worthwhile. Earlier attempts
// were retried away (typically rate limits) and must not vote, so an
// unclassifiable final error is judged structurally on its own rather
// than by walking the whole wrapper.
const last = error.lastError ?? error.errors[error.errors.length - 1];
if (last == null) {
return undefined;
}
return (
isRetryableTypedError(last, depth + 1) ?? isRetryableFromSignals(last)
);
}
if (APICallError.isInstance(error)) {
return error.isRetryable === true;
}
if (AISDKError.isInstance(error)) {
return isRetryableTypedError(error.cause, depth + 1);
}
return undefined;
}
/**
* Structural retryability for a value that is not a typed AI SDK error: a
* flattened message, a gateway-forwarded JSON payload, or the final attempt
* inside a RetryError. HTTP status decides first; message text only for the
* one documented statusless provider quirk.
*/
function isRetryableFromSignals(value: unknown): boolean {
const signals: ErrorSignals = {
messages: [],
statuses: new Set(),
codes: new Set(),
};
try {
collectSignals(value, signals, new Set(), 0);
} catch {
return false;
}
const statuses = [...signals.statuses];
// Never retry credential rejections or a definitive context-window overflow:
// the same request will fail again.
if (statuses.some((status) => AUTH_STATUSES.has(status))) {
return false;
}
if ([...signals.codes].some((code) => CONTEXT_WINDOW_CODES.has(code))) {
return false;
}
// A transient HTTP status (incl. any 5xx) is retryable.
if (
statuses.some(
(status) =>
RETRYABLE_STATUSES.has(status) || (status >= 500 && status <= 599),
)
) {
return true;
}
// Any other visible 4xx is a non-retryable client error.
if (statuses.some((status) => status >= 400 && status < 500)) {
return false;
}
// No typed error and no status: the one provider quirk we special-case.
return signals.messages.some((message) =>
PROVIDER_RETURNED_ERROR_PATTERN.test(message),
);
}
/**
* Decide whether a provider/API error is a transient failure worth retrying
* with backoff, as opposed to a permanent failure a retry cannot fix
* (credential rejections, context-window overflow, other client-side 4xx
* errors). Prefers the AI SDK's own typed `isRetryable` signal; for
* non-instances (already-flattened messages or gateway-forwarded JSON) it
* falls back to the HTTP status, and finally to the single documented
* "Provider returned error" provider quirk. Accepts either a raw structured
* error or a flattened message string.
*/
export function isRetryableProviderError(error: unknown): boolean {
// Prefer the AI SDK's own typed retryability signal.
try {
const typed = isRetryableTypedError(error, 0);
if (typed !== undefined) {
return typed;
}
} catch {
// Fall through to the structural walk.
}
return isRetryableFromSignals(error);
}
/**
* Retryability as seen by the agent loop's turn-level retry, which must not
* stack on retries another layer already spent. The AI SDK owns request-start
* failures: it retries them itself with `retry-after`-aware backoff and, once
* exhausted, surfaces a `RetryError`. Re-running such a turn would multiply
* the SDK's attempts by the agent's, so a `RetryError` is terminal here even
* when its final attempt looks transient. Everything else (most importantly a
* provider error emitted mid-stream, which the SDK never retries) is judged by
* {@link isRetryableProviderError}.
*/
export function isRetryableBeyondSdkRetries(error: unknown): boolean {
// Guarded like the other typed checks: `RetryError.isInstance` throws when
// the "ai" module is only partially available (tests mock it with a subset
// of exports), and the classifier below is the correct fallback then.
try {
if (RetryError.isInstance(error)) {
return false;
}
} catch {
// Fall through to the classifier.
}
return isRetryableProviderError(error);
}
@@ -1500,6 +1500,7 @@ describe("sdk-gateway", () => {
reason: "error",
error: `Image media exceeds the ${DEFAULT_MAX_IMAGE_ENCODED_BYTES} byte encoded limit`,
errorClass: "unknown",
errorRetryable: false,
},
]);
});
@@ -2333,6 +2334,7 @@ describe("sdk-gateway", () => {
reason: "error",
error: "OpenAI image generation tool returned no supported image output",
errorClass: "unknown",
errorRetryable: false,
});
});
@@ -2548,6 +2550,7 @@ describe("sdk-gateway", () => {
reason: "error",
error: "Invalid API key",
errorClass: "unknown",
errorRetryable: false,
});
});
@@ -2763,6 +2766,7 @@ describe("sdk-gateway", () => {
reason: "error",
error: "Invalid API key",
errorClass: "unknown",
errorRetryable: false,
},
]);
});
@@ -2803,6 +2807,7 @@ describe("sdk-gateway", () => {
reason: "error",
error: "prompt is too long: 213462 tokens > 200000 maximum",
errorClass: "context_window_exceeded",
errorRetryable: false,
});
});
@@ -2839,6 +2844,7 @@ describe("sdk-gateway", () => {
reason: "error",
error: "Instructions are required",
errorClass: "unknown",
errorRetryable: false,
});
});
@@ -82,6 +82,7 @@ export const GENERATED_PROVIDER_IDS = [
"impossibl",
"inception",
"inceptron",
"infer",
"inference",
"inferx",
"infomaniak",
@@ -106,6 +107,7 @@ export const GENERATED_PROVIDER_IDS = [
"lucidquery",
"lynkr",
"meganova",
"melious",
"meta",
"minimax",
"minimax-cn",
@@ -196,6 +198,7 @@ export const GENERATED_PROVIDER_IDS = [
"volcengine-coding-plan",
"vultr",
"wafer.ai",
"wallaby",
"wandb",
"xai",
"xiaomi",
@@ -24,7 +24,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "302ai",
defaultModelId: "gpt-6-astra",
defaultModelId: "deepseek-flash",
apiKeyEnv: ["302AI_API_KEY"],
docsUrl: "https://doc.302.ai",
defaults: {
@@ -66,7 +66,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "above",
defaultModelId: "glm-5.3-flash",
defaultModelId: "deepseek-v4-flash",
apiKeyEnv: ["ABOVE_API_KEY"],
docsUrl: "https://above.dev/docs",
defaults: {
@@ -80,7 +80,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "agentrouter",
defaultModelId: "claude-opus-5",
defaultModelId: "glm-5.3",
apiKeyEnv: ["AGENTROUTER_API_KEY"],
docsUrl: "https://agentrouter.org/docs/opencode.html",
defaults: {
@@ -136,7 +136,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "aihubmix",
defaultModelId: "glm-5.3-flash",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["AIHUBMIX_API_KEY"],
docsUrl: "https://docs.aihubmix.com",
},
@@ -161,7 +161,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "aki-io",
defaultModelId: "qwen3.8-27b",
defaultModelId: "glm5.3-754b",
apiKeyEnv: ["AKI_IO_API_KEY"],
docsUrl: "https://aki.io/docs/",
defaults: {
@@ -247,7 +247,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "alibaba-token-plan-cn",
defaultModelId: "qwen3.8-flash",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["ALIBABA_TOKEN_PLAN_API_KEY"],
docsUrl:
"https://www.alibabacloud.com/help/zh/model-studio/token-plan-overview",
@@ -277,7 +277,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "amd",
defaultModelId: "Qwen3.8-Flash-Next",
defaultModelId: "DeepSeek-V4.1-Flash",
apiKeyEnv: ["AMD_API_KEY"],
docsUrl: "https://developer.amd.com.cn/radeon/tokenfactory",
defaults: {
@@ -372,7 +372,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "baseten",
defaultModelId: "zai-org/GLM-5.3-Flash",
defaultModelId: "deepseek-ai/DeepSeek-V4.1-Flash",
apiKeyEnv: ["BASETEN_API_KEY"],
docsUrl: "https://docs.baseten.co/inference/model-apis/overview",
defaults: {
@@ -431,7 +431,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "bothub",
defaultModelId: "nemotron-3-ultra-550b-a55b:free",
defaultModelId: "muse-spark-1.3-contributor",
apiKeyEnv: ["BOTHUB_API_KEY"],
docsUrl: "https://bothub.ru/models",
defaults: {
@@ -498,7 +498,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "cline-pass",
defaultModelId: "cline-pass/glm-5.3-flash",
defaultModelId: "cline-pass/deepseek-v4.1-flash",
apiKeyEnv: ["CLINE_API_KEY"],
docsUrl: "https://docs.cline.bot/getting-started/clinepass",
defaults: {
@@ -541,7 +541,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "coralbricks",
defaultModelId: "glm-5.3-fp4",
defaultModelId: "glm-5.3-flash-fp4",
apiKeyEnv: ["CORAL_API_KEY"],
docsUrl: "https://www.coralbricks.ai/docs",
defaults: {
@@ -555,7 +555,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "cortecs",
defaultModelId: "gemini-3.8-flash",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["CORTECS_API_KEY"],
docsUrl: "https://api.cortecs.ai/v1/models",
defaults: {
@@ -583,7 +583,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "crossmodel",
defaultModelId: "openai/gpt-6-astra",
defaultModelId: "deepseek/deepseek-v4.1-flash",
apiKeyEnv: ["CROSSMODEL_API_KEY"],
docsUrl: "https://www.crossmodel.ai/docs",
defaults: {
@@ -640,7 +640,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "deepseek",
defaultModelId: "deepseek-v4-flash-vision-exp",
defaultModelId: "deepseek-flash",
apiKeyEnv: ["DEEPSEEK_API_KEY"],
docsUrl: "https://api-docs.deepseek.com/quick_start/pricing",
defaults: {
@@ -654,7 +654,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "digitalocean",
defaultModelId: "openai-gpt-6-astra",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["DIGITALOCEAN_ACCESS_TOKEN"],
docsUrl:
"https://docs.digitalocean.com/products/gradient-ai-platform/details/models/",
@@ -725,7 +725,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "edenai",
defaultModelId: "openai/gpt-6-astra",
defaultModelId: "deepinfra/deepseek-ai/DeepSeek-V4.1-Flash",
apiKeyEnv: ["EDENAI_API_KEY"],
docsUrl: "https://docs.edenai.co",
defaults: {
@@ -739,7 +739,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "empiriolabs",
defaultModelId: "muse-spark-1-3",
defaultModelId: "deepseek-v4-1-flash",
apiKeyEnv: ["EMPIRIOLABS_API_KEY"],
docsUrl: "https://docs.empiriolabs.ai",
defaults: {
@@ -781,7 +781,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "fireworks",
defaultModelId: "accounts/fireworks/routers/glm-5p3-fast",
defaultModelId: "accounts/fireworks/models/deepseek-v4p1-flash",
apiKeyEnv: ["FIREWORKS_API_KEY"],
docsUrl: "https://fireworks.ai/docs/",
defaults: {
@@ -809,7 +809,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "friendli",
defaultModelId: "zai-org/GLM-5.3",
defaultModelId: "zai-org/GLM-5.3-Flash",
apiKeyEnv: ["FRIENDLI_TOKEN"],
docsUrl:
"https://friendli.ai/docs/guides/serverless_endpoints/introduction",
@@ -882,7 +882,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "greenpt",
defaultModelId: "glm-5.3-flash",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["GREENPT_API_KEY"],
docsUrl: "https://docs.greenpt.ai",
defaults: {
@@ -949,7 +949,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "huggingface",
defaultModelId: "zai-org/GLM-5.3-Flash",
defaultModelId: "deepseek-ai/DeepSeek-V4.1-Flash",
apiKeyEnv: ["HF_TOKEN"],
docsUrl: "https://huggingface.co/docs/inference-providers",
defaults: {
@@ -963,7 +963,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "hyper",
defaultModelId: "kimi-k2-thinking",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["HYPER_API_KEY"],
docsUrl: "https://hyper.charm.land",
defaults: {
@@ -1005,7 +1005,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "inception",
defaultModelId: "mercury-2",
defaultModelId: "mercury-2.5",
apiKeyEnv: ["INCEPTION_API_KEY"],
docsUrl: "https://platform.inceptionlabs.ai/docs",
defaults: {
@@ -1026,6 +1026,20 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
baseUrl: "https://api.inceptron.io/v1",
},
},
{
id: "infer",
name: "Infer by Flow7",
description: "Infer by Flow7 model provider from models.dev",
family: "openai",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "infer",
defaultModelId: "infer/gpt-6-astra:official",
apiKeyEnv: ["INFER_API_KEY"],
docsUrl: "https://infer.flow7.org/opencode",
defaults: {
baseUrl: "https://infer.flow7.org/v1",
},
},
{
id: "inference",
name: "Inference",
@@ -1148,7 +1162,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "kilo",
defaultModelId: "inception/mercury-2.5",
defaultModelId: "~deepseek/deepseek-flash-latest",
apiKeyEnv: ["KILO_API_KEY"],
docsUrl: "https://kilo.ai",
defaults: {
@@ -1162,7 +1176,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "anthropic",
capabilities: ["tools", "reasoning"],
modelsProviderId: "kimi-for-coding",
defaultModelId: "k3",
defaultModelId: "kimi-for-coding",
apiKeyEnv: ["KIMI_API_KEY"],
docsUrl: "https://www.kimi.com/code/docs/en/kimi-code/models.html",
defaults: {
@@ -1247,7 +1261,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "llmgateway",
defaultModelId: "gpt-6-astra",
defaultModelId: "atria-dawn-preview",
apiKeyEnv: ["LLMGATEWAY_API_KEY"],
docsUrl: "https://llmgateway.io/docs",
defaults: {
@@ -1261,7 +1275,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "llmgateway-providers",
defaultModelId: "azure/gpt-6-astra",
defaultModelId: "atria/atria-dawn-preview",
apiKeyEnv: ["LLMGATEWAY_API_KEY"],
docsUrl: "https://llmgateway.io/docs",
defaults: {
@@ -1366,6 +1380,20 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
baseUrl: "https://api.meganova.ai/v1",
},
},
{
id: "melious",
name: "Melious",
description: "Melious model provider from models.dev",
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "melious",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["MELIOUS_API_KEY"],
docsUrl: "https://melious.ai/docs/reference/models",
defaults: {
baseUrl: "https://api.melious.ai/v1",
},
},
{
id: "meta",
name: "Meta",
@@ -1581,7 +1609,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "nan",
defaultModelId: "glm5.3-flash",
defaultModelId: "deepseek-v4-flash",
apiKeyEnv: ["NAN_API_KEY"],
docsUrl: "https://nan.builders/docs/models",
defaults: {
@@ -1595,7 +1623,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "nano-gpt",
defaultModelId: "agnes-3.0-flash",
defaultModelId: "openai/gpt-astra-latest",
apiKeyEnv: ["NANO_GPT_API_KEY"],
docsUrl: "https://docs.nano-gpt.com",
defaults: {
@@ -1707,7 +1735,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "nvidia",
defaultModelId: "deepseek-ai/deepseek-v4-pro-0813",
defaultModelId: "z-ai/glm-5.3-flash",
apiKeyEnv: ["NVIDIA_API_KEY"],
docsUrl: "https://docs.api.nvidia.com/nim/",
defaults: {
@@ -1721,7 +1749,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "ofox",
defaultModelId: "openai/gpt-6-astra",
defaultModelId: "deepseek/deepseek-v4.1-flash",
apiKeyEnv: ["OFOX_API_KEY"],
docsUrl: "https://ofox.ai/docs",
defaults: {
@@ -1735,7 +1763,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "ollama",
defaultModelId: "glm-5.3-flash",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["OLLAMA_API_KEY"],
docsUrl: "https://docs.ollama.com/cloud",
defaults: {
@@ -1774,7 +1802,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "opencode-go",
defaultModelId: "omen-alpha",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["OPENCODE_API_KEY"],
docsUrl: "https://opencode.ai/docs/zen",
defaults: {
@@ -1802,7 +1830,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "openrouter",
defaultModelId: "inception/mercury-2.5",
defaultModelId: "~deepseek/deepseek-flash-latest",
apiKeyEnv: ["OPENROUTER_API_KEY"],
docsUrl: "https://openrouter.ai/models",
defaults: {
@@ -1985,7 +2013,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "requesty",
defaultModelId: "gpt-6-astra",
defaultModelId: "deepseek-v4.1-flash",
apiKeyEnv: ["REQUESTY_API_KEY"],
docsUrl: "https://requesty.ai/solution/llm-routing/models",
defaults: {
@@ -2041,7 +2069,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "sapaicore",
defaultModelId: "gpt-5.6-luna",
defaultModelId: "gemini-3.5-flash-lite",
apiKeyEnv: ["AICORE_SERVICE_KEY"],
docsUrl: "https://help.sap.com/docs/sap-ai-core",
},
@@ -2383,7 +2411,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "tinfoil",
defaultModelId: "glm-5-3-flash",
defaultModelId: "deepseek-v4-1-flash",
apiKeyEnv: ["TINFOIL_API_KEY"],
docsUrl: "https://docs.tinfoil.sh",
defaults: {
@@ -2397,7 +2425,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "together",
defaultModelId: "zai-org/GLM-5.3-Flash",
defaultModelId: "deepseek-ai/DeepSeek-V4.1-Flash",
apiKeyEnv: ["TOGETHER_API_KEY"],
docsUrl: "https://docs.together.ai/docs/serverless-models",
},
@@ -2517,7 +2545,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "vancine",
defaultModelId: "hy4-preview",
defaultModelId: "deepseek-flash",
apiKeyEnv: ["VANCINE_API_KEY"],
docsUrl: "https://vancine.com/docs",
defaults: {
@@ -2531,7 +2559,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "vercel-ai-gateway",
defaultModelId: "deepseek/deepseek-v4.1-flash-beta",
defaultModelId: "deepseek/deepseek-v4.1-flash",
apiKeyEnv: ["AI_GATEWAY_API_KEY"],
docsUrl:
"https://github.com/vercel/ai/tree/5eb85cc45a259553501f535b8ac79a77d0e79223/packages/gateway",
@@ -2586,7 +2614,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
modelsProviderId: "volcengine-coding-plan",
defaultModelId: "glm-5.3",
defaultModelId: "glm-5.3-flash",
apiKeyEnv: ["ARK_CODING_PLAN_API_KEY"],
docsUrl: "https://www.volcengine.com/docs/82379/1928261",
defaults: {
@@ -2621,6 +2649,20 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
baseUrl: "https://pass.wafer.ai/v1",
},
},
{
id: "wallaby",
name: "Wallaby",
description: "Wallaby model provider from models.dev",
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "wallaby",
defaultModelId: "moonshotai/kimi-k3",
apiKeyEnv: ["WALLABY_API_KEY"],
docsUrl: "https://wallabytoken.com/docs",
defaults: {
baseUrl: "https://api.wallabytoken.com/v1",
},
},
{
id: "wandb",
name: "Weights & Biases",
+15 -2
View File
@@ -281,12 +281,22 @@ describe("resolveBedrockModelId", () => {
});
it("prefixes other profile-only foundation models with confirmed variants", () => {
// Keep catalog presence explicit: models come and go from the generated
// catalog (deepseek.r1-v1:0 was retired from Bedrock) without changing
// the resolver's contract that a confirmed variant is prefixed.
const hasCatalogModel = (modelId: string) =>
modelId === "us.deepseek.r1-v1:0" ||
modelId === "us.meta.llama4-maverick-17b-instruct-v1:0";
expect(
resolveBedrockModelId("deepseek.r1-v1:0", { region: "us-west-2" }),
resolveBedrockModelId("deepseek.r1-v1:0", {
region: "us-west-2",
hasCatalogModel,
}),
).toBe("us.deepseek.r1-v1:0");
expect(
resolveBedrockModelId("meta.llama4-maverick-17b-instruct-v1:0", {
region: "us-east-1",
hasCatalogModel,
}),
).toBe("us.meta.llama4-maverick-17b-instruct-v1:0");
});
@@ -456,12 +466,15 @@ describe("resolveBedrockModelId", () => {
useGlobalInference: true,
}),
).toBe("us.anthropic.claude-sonnet-4-6");
// Models without a known global variant degrade to the geo profile.
// Models without a known global variant degrade to the geo profile. The
// geo variant is asserted explicitly so the case does not depend on the
// generated catalog still carrying this model.
expect(
resolveBedrockModelId("deepseek.r1-v1:0", {
region: "us-west-2",
useCrossRegionInference: true,
useGlobalInference: true,
hasCatalogModel: (modelId) => modelId === "us.deepseek.r1-v1:0",
}),
).toBe("us.deepseek.r1-v1:0");
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/sdk",
"description": "Cline SDK - user-facing alias for @cline/core",
"version": "0.0.82",
"version": "0.0.83",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/shared",
"version": "0.0.82",
"version": "0.0.83",
"description": "Shared utilities, types, and schemas for Cline packages",
"repository": {
"type": "git",
+8
View File
@@ -315,6 +315,14 @@ export type AgentModelEvent =
reason: AgentModelFinishReason;
error?: string;
errorClass?: ProviderErrorClass;
/**
* Whether the underlying provider error was transient and worth
* retrying, decided at the model boundary from the AI SDK's typed
* `isRetryable` flag while the structured error is still in hand
* (`error` is a flattened string, so the agent loop cannot re-derive
* this). When absent, the agent loop classifies from the message.
*/
errorRetryable?: boolean;
/**
* The model layer already recorded `sdk.error` telemetry for this
* failure at its own error boundary. `error` is a flattened string,
+4 -5
View File
@@ -1,10 +1,7 @@
import type { WorkspaceContext } from "../extensions/context";
import { isClineProvider } from "../providers/utils";
import type { WorkspaceInfo } from "../session/workspace";
import {
DEFAULT_CLINE_SYSTEM_PROMPT,
YOLO_CLINE_SYSTEM_PROMPT,
} from "./system";
import { DEFAULT_CLINE_SYSTEM_PROMPTS } from "./system";
const WORKSPACE_CONFIGURATION_MARKER = "# Workspace Configuration";
@@ -185,7 +182,9 @@ export function buildClineSystemPrompt(
}
const basePrompt =
mode === "yolo" ? YOLO_CLINE_SYSTEM_PROMPT : DEFAULT_CLINE_SYSTEM_PROMPT;
mode === "yolo"
? DEFAULT_CLINE_SYSTEM_PROMPTS.YOLO
: DEFAULT_CLINE_SYSTEM_PROMPTS.ACT;
// Mode semantics ride in the rules slot so every host emits them without
// composing its own copy. Order matches what the CLI historically built by
@@ -1,4 +1,4 @@
export const DEFAULT_CLINE_SYSTEM_PROMPT = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
export const CLINE_SYSTEM_PROMPT_ACT_MODE = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
Review each question carefully and answer it with detailed, accurate information.
@@ -34,35 +34,3 @@ When you have completed the task, please provide a summary of what you did and a
If user asked a simple question without any coding context, answer it directly without using any tools.
{{CLINE_RULES}}
{{CLINE_METADATA}}`;
export const YOLO_CLINE_SYSTEM_PROMPT = `You are Cline, a careful and helpful coding agent that works in the background.
You are tasked to solve an issue reported by the user who you cannot communicate with directly.
Your goal is to utilize the tools at your disposal to investigate and answer the question according to user's instructions with the aim to verify that the issue is resolved.
RULES:
- Always match output format exactly as shown in examples or existing files.
- Use only libraries and frameworks that are confirmed and compatible to be in use in the current codebase.
- Provide complete and functional code without omissions or placeholders.
- Always show your planning process without repeating yourself before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's request.
- Always use absolute paths when referring to files.
- You can call multiple tools in a single response. Before using tools, identify every independent read, search, command, or edit needed for the next step and emit all of those tool calls now, either as multiple tool calls or as one batched input for tools that accept arrays. Do not wait for one independent result before requesting another. Do not split independent reads, searches, checks, or edits across separate turns.
- Good parallelism examples: read all known relevant files in one read_files call; run independent inspection commands in one run_commands call; emit independent read_files, search_codebase, and run_commands calls together in one response; emit multiple editor calls together when editing different files or non-overlapping regions.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Environment you are running in:
<env>
1. Platform: {{PLATFORM_NAME}}
2. Date: {{CURRENT_DATE}}
3. IDE: {{IDE_NAME}}
4. Working Directory: {{CWD}}
</env>
IMPORTANT:
- When the user describes a bug, unexpected behavior, or provides a bug report, your primary goal is to produce a correct fix in the source code that resolves the issue.
- A correct fix means the underlying behavior is fixed not just the symptoms addressed superficially.
- After applying your fix, you must run the relevant test suite to confirm your changes actually resolve the problem. If tests fail, analyze the failures, revise your fix, and re-run until tests pass.
- Do not consider the task complete until the test suite related to the files you have touched passes.
- Always includes tool calls in your response until the task is completed. You should only end the task when all the requirements are met by calling the 'submit_and_exit' tool.
- Response without the submit_and_exit tool call will considered not completed and the task will continue.
{{CLINE_RULES}}
{{CLINE_METADATA}}`;
@@ -0,0 +1,9 @@
import { CLINE_SYSTEM_PROMPT_ACT_MODE } from "./act";
import { CLINE_SYSTEM_PROMPT_YOLO_MODE } from "./yolo";
export const DEFAULT_CLINE_SYSTEM_PROMPTS = {
ACT: CLINE_SYSTEM_PROMPT_ACT_MODE,
YOLO: CLINE_SYSTEM_PROMPT_YOLO_MODE,
};
export const DEFAULT_CLINE_SYSTEM_PROMPT = CLINE_SYSTEM_PROMPT_ACT_MODE;
@@ -0,0 +1,35 @@
export const CLINE_SYSTEM_PROMPT_YOLO_MODE = `You are Cline, a careful and helpful coding agent that works in the background.
You are tasked to solve an issue reported by the user who you cannot communicate with directly.
Your goal is to utilize the tools at your disposal to investigate and answer the question according to user's instructions with the aim to verify that the issue is resolved.
RULES:
- Always match output format exactly as shown in examples or existing files.
- Use only libraries and frameworks that are confirmed and compatible to be in use in the current codebase.
- Provide complete and functional code without omissions or placeholders.
- Always show your planning process without repeating yourself before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's request.
- Always use absolute paths when referring to files.
- You can call multiple tools in a single response. Before using tools, identify every independent read, search, command, or edit needed for the next step and emit all of those tool calls now, either as multiple tool calls or as one batched input for tools that accept arrays. Do not wait for one independent result before requesting another. Do not split independent reads, searches, checks, or edits across separate turns.
- Good parallelism examples: read all known relevant files in one read_files call; run independent inspection commands in one run_commands call; emit independent read_files, search_codebase, and run_commands calls together in one response; emit multiple editor calls together when editing different files or non-overlapping regions.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Environment you are running in:
<env>
1. Platform: {{PLATFORM_NAME}}
2. Date: {{CURRENT_DATE}}
3. IDE: {{IDE_NAME}}
4. Working Directory: {{CWD}}
</env>
IMPORTANT:
- When the user describes a bug, unexpected behavior, or provides a bug report, your primary goal is to produce a correct fix in the source code that resolves the issue.
- A correct fix means the underlying behavior is fixed not just the symptoms addressed superficially.
- Verify by execution, never by assumption. Before considering any task done, gather concrete evidence from your own tool output that every requirement is satisfied:
- If a test suite, tests, or assertions are provided or referenced, run them and confirm they pass. If they fail, analyze the failures, revise, and re-run until they pass.
- If no tests are provided, construct your own verification: actually run the program, script, or command you produced; confirm every required output file exists at the exact path requested; and confirm its contents match the expected format, data types, and values described in the task. Read the output back to confirm.
- Treat "this should work", "assume it works", or "probably correct" as a signal that you have NOT verified yet go run the check instead of finishing.
- Do not consider the task complete until you have observed evidence that all stated requirements are met.
- Always includes tool calls in your response until the task is completed. You should only end the task when all the requirements are met by calling the 'submit_and_exit' tool.
- When you call 'submit_and_exit', set 'verified' to true only if your tool output shows the requirements are met; otherwise set it to false.
- Response without the submit_and_exit tool call will considered not completed and the task will continue.
{{CLINE_RULES}}
{{CLINE_METADATA}}`;
@@ -11,8 +11,9 @@
*/
import { parseDiffFromFile } from "@pierre/diffs";
import { FileDiff, type FileDiffProps } from "@pierre/diffs/react";
import { FileDiff } from "@pierre/diffs/react";
import {
type ComponentProps,
type CSSProperties,
useEffect,
useMemo,
@@ -20,7 +21,11 @@ import {
useState,
} from "react";
type DiffOptions = NonNullable<FileDiffProps<undefined>["options"]>;
// Derived from the component rather than FileDiffProps: the props interface
// gained a second required type parameter in @pierre/diffs 1.4 while the
// component kept defaults for both, so naming the interface directly pins us
// to one minor of an optional peer dependency declared as ^1.3.0.
type DiffOptions = NonNullable<ComponentProps<typeof FileDiff>["options"]>;
export type ToolFileDiffProps = {
/** File path; used for the header-less language inference. */
@@ -35,6 +35,8 @@ export interface SearchComboboxProps {
emptyText?: string;
loading?: boolean;
loadingText?: string;
/** Called when the panel opens, so callers can refresh stale options. */
onOpen?: () => void;
onValueChange: (value: string) => void;
options: SearchComboboxOption[];
/** Panel width as a CSS length (default "16rem"). */
@@ -86,6 +88,7 @@ export function SearchCombobox({
emptyText = "No results",
loading = false,
loadingText = "Loading…",
onOpen,
onValueChange,
options,
panelWidth = "16rem",
@@ -333,7 +336,10 @@ export function SearchCombobox({
.filter(Boolean)
.join(" ")}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onClick={() => {
if (!open) onOpen?.();
setOpen(!open);
}}
ref={triggerRef}
title={displayedValue}
type="button"
@@ -33,10 +33,12 @@ const options = [
describe("SearchCombobox", () => {
it("filters and selects an option", async () => {
const onValueChange = vi.fn();
const onOpen = vi.fn();
await act(async () =>
root.render(
<SearchCombobox
ariaLabel="Repository"
onOpen={onOpen}
onValueChange={onValueChange}
options={options}
value="cline"
@@ -46,7 +48,9 @@ describe("SearchCombobox", () => {
const trigger = container.querySelector("button");
expect(trigger?.getAttribute("aria-label")).toBe("Repository: cline/cline");
expect(onOpen).not.toHaveBeenCalled();
await act(async () => trigger?.click());
expect(onOpen).toHaveBeenCalledTimes(1);
const search = container.querySelector("input");
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(