Sync desktop cloud summary UI slice

Match the corresponding files in #14418 and preserve review history.
This commit is contained in:
John Choi
2026-10-19 12:04:51 -07:00
170 changed files with 17194 additions and 11328 deletions
+30
View File
@@ -312,6 +312,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -346,6 +348,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -482,6 +486,12 @@ jobs:
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Boot the Cline CLI and desktop backend that will actually ship — built
@@ -681,6 +691,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -743,6 +755,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -828,6 +842,12 @@ jobs:
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Same release-time gate as the macOS job; see the comment there.
@@ -1008,6 +1028,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -1027,6 +1049,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
@@ -1099,6 +1123,12 @@ jobs:
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Same release-time gate as the macOS job; see the comment there.
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## [4.1.25]
### Added
- Cline Cloud sessions, rolling out gradually: run a task in a hosted sandbox on a GitHub repo and branch you pick. Turn it on in Settings. Cloud tasks show in Recent and History with their status, notify you when they finish, and keep running after you close your editor.
### Changed
- With no model chosen, the Cline provider now uses Gemini 4 Argon, the new first model in Cline's recommended list. OpenRouter stays on Claude Sonnet 5.5.
- Refreshed the model catalog. Adds the Boundless and Standard Compute providers, and SambaNova now comes from the catalog with MiniMax M3 as its default. The Cline recommended list adds Gemini 4 Argon and drops Grok 4.7, and the Cline free list replaces Step 5 Preview with Nemotron 3.5 Super VL. OpenCode Go no longer lists MiniMax M2.7. Default models change for Cortecs, CrossModel, Eden AI, Google Gemini, GitHub Copilot, Google Vertex AI, LLM Gateway, Ofox, OpenCode Go, and Prime Inference (Gemini 4 Argon), Fireworks AI, Nebius Token Factory, and Requesty (Muse Spark Open), CoreWeave, DigitalOcean, NanoGPT, and Nvidia (Nemotron 3.5 Super VL), Aixy and Neon (Claude Haiku 5.5), Agnes AI, Ambient, IteraCompute, Poe, Scaleway, and SenseNova. If you use one of those providers without pinning a model, expect a different default.
### Fixed
- Long requests to local models (LM Studio, Ollama) no longer fail after 5 minutes of silence with `terminated: BodyTimeoutError` while the server is still processing a large prompt.
- OpenAI Compatible base URLs are used exactly as entered. A host-only URL like `https://abc.com` used to get `/v1` added silently. Host-only URLs saved by earlier versions get `/v1` added once, so existing setups keep working.
- Images and files attached to an answer to one of Cline's questions now reach the model instead of being dropped.
- When a provider error interrupts a response mid-stream, Cline retries it up to 3 times before failing the turn.
- In JetBrains, the Run task card stays above the Auto-approve bar.
- Auto Compact and its strategy setting sit together again in Feature settings.
## [4.1.24]
### Changed
+16
View File
@@ -1,5 +1,21 @@
# Cline CLI Changelog
## 3.0.73
- GPT-6 models (such as `gpt-6-sol` and `gpt-6.1-sol`) work on OpenAI and Azure OpenAI-compatible endpoints. Before, every request was rejected because Cline sent `max_tokens` instead of `max_completion_tokens`
- With no model chosen, the Cline provider uses Claude Sonnet 5.5 again, which is back at the top of Cline's recommended list
- Refreshed the model catalog. Adds the CommandCode and Reflection providers. Default models change for 302.AI (Gemini 4 Argon), Eden AI and Prime Inference (Muse Spark Open), CoreWeave (Reflection Beam 1.0), NanoGPT, Pioneer (Nemotron 3.5 Super VL), and Scaleway (DeepSeek V4.1 Flash)
## 3.0.72
- Plugins now run in a worker instead of directly inside the hub. If this build can't run plugins, Cline says so in `cline config tools`, `cline hub plugins`, and the session-start warnings instead of quietly running them inside the hub. `CLINE_PLUGIN_MODE=in_process` is no longer supported; use `worker` (the default) or `sandbox`
- When a provider error interrupts a response mid-stream, Cline retries it up to 3 times before failing the turn
- Failed model requests now log the provider's request id, which makes support tickets easier to trace
- `cline hub ensure` and `cline hub start` gain `--json` (prints the hub URL and auth token), `--allow-port-fallback`, `--no-connectors`, and `--login-shell-path`. `cline hub` accepts `--discovery-path` to use a dedicated discovery record
- `cline hub` commands no longer count as a CLI activation in usage stats
- With no model chosen, the Cline provider now uses Gemini 4 Argon, the new first model in Cline's recommended list
- Refreshed the model catalog. Adds the Boundless and Standard Compute providers, and SambaNova now comes from the catalog with MiniMax M3 as its default. The Cline recommended list adds Gemini 4 Argon and drops Grok 4.7, and the Cline free list replaces Step 5 Preview with Nemotron 3.5 Super VL. OpenCode Go no longer lists MiniMax M2.7. Default models change for Cortecs, CrossModel, Eden AI, Google Gemini, GitHub Copilot, Google Vertex AI, LLM Gateway, Ofox, OpenCode Go, and Prime Inference (Gemini 4 Argon), Fireworks AI, Nebius Token Factory, and Requesty (Muse Spark Open), CoreWeave, DigitalOcean, NanoGPT, and Nvidia (Nemotron 3.5 Super VL), Aixy and Neon (Claude Haiku 5.5), Agnes AI, Ambient, IteraCompute, Poe, Scaleway, and SenseNova
## 3.0.71
- Plugins now load inside the hub instead of a separate node or bun process, so the CLI no longer needs a JavaScript runtime on your PATH to run plugins. A plugin that fails to load no longer silently loses its tools: Cline prints a warning naming the plugin and the error, `cline config tools --json` lists it as a `plugin_failure` entry, and the new `cline hub plugins` shows each plugin's status and last error (`--reload <plugin>` re-imports one without restarting the hub). Set `CLINE_PLUGIN_MODE=sandbox` to keep the old subprocess behavior
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.71",
"version": "3.0.73",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+14
View File
@@ -1,5 +1,19 @@
# Cline Desktop Changelog
## 0.0.48
- GPT-6 models (such as `gpt-6-sol` and `gpt-6.1-sol`) work on OpenAI and Azure OpenAI-compatible endpoints. Before, every request was rejected because Cline sent `max_tokens` instead of `max_completion_tokens`
- The Cline provider's default model is Claude Sonnet 5.5 again, which is back at the top of Cline's recommended list
- Refreshed the model catalog. Adds the CommandCode and Reflection providers. Default models change for 302.AI (Gemini 4 Argon), Eden AI and Prime Inference (Muse Spark Open), CoreWeave (Reflection Beam 1.0), NanoGPT, Pioneer (Nemotron 3.5 Super VL), and Scaleway (DeepSeek V4.1 Flash)
## 0.0.47
- Marketplace plugins such as goal load again. In 0.0.46 they failed with `Cannot find module '@cline/sdk'` because the packaged app didn't include the SDK packages plugins import
- The Cloud sessions toggle is gone from Settings. Cloud sessions are on wherever they are available
- When a provider error interrupts a response mid-stream, Cline retries it up to 3 times before failing the turn
- The Cline provider's default model is now Gemini 4 Argon, the new first model in Cline's recommended list
- Refreshed the model catalog. Adds the Boundless and Standard Compute providers, and SambaNova now comes from the catalog with MiniMax M3 as its default. The Cline recommended list adds Gemini 4 Argon and drops Grok 4.7, and the Cline free list replaces Step 5 Preview with Nemotron 3.5 Super VL. OpenCode Go no longer lists MiniMax M2.7. Default models change for Cortecs, CrossModel, Eden AI, Google Gemini, GitHub Copilot, Google Vertex AI, LLM Gateway, Ofox, OpenCode Go, and Prime Inference (Gemini 4 Argon), Fireworks AI, Nebius Token Factory, and Requesty (Muse Spark Open), CoreWeave, DigitalOcean, NanoGPT, and Nvidia (Nemotron 3.5 Super VL), Aixy and Neon (Claude Haiku 5.5), Agnes AI, Ambient, IteraCompute, Poe, Scaleway, and SenseNova
## 0.0.46
- A new outline along the left edge of the conversation lists each prompt you sent. Hover a mark to preview the prompt, and click it to jump there
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.46",
"version": "0.0.48",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -32,7 +32,7 @@
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-handoff.test.ts webview/lib/cloud-handoff-follow-up.test.ts webview/lib/cloud-handoff-lifecycle.test.ts webview/lib/cloud-handoff-preparation.test.ts webview/lib/cloud-handoff-ui-state.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/desktop-app-state.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/cloud-handoff.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-handoff.test.ts webview/lib/cloud-handoff-follow-up.test.ts webview/lib/cloud-handoff-lifecycle.test.ts webview/lib/cloud-handoff-ui-state.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/desktop-app-state.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/cloud-handoff.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -82,8 +82,9 @@ const compileDesktopCli = async (
// Bun only trusts its bundled Mozilla roots on macOS/Windows, so TLS to
// intranet endpoints signed by a corporate CA (LiteLLM proxies, MITM
// firewalls) fails with "unable to get local issuer certificate". Bake
// --use-system-ca in so the Hub and the backend running on this runtime
// also trust the OS Keychain/cert store, as the npm wrapper does.
// --use-system-ca in so the Hub also trusts the OS Keychain/cert store,
// as the npm wrapper does. `BUN_BE_BUN=1 cline-cli run` ignores baked-in
// flags, so main.rs passes it again when launching the backend.
execArgv: ["--use-system-ca"],
});
return outfile;
@@ -107,6 +107,7 @@ test("the shipped CLI and backend report telemetry without starting a hub", asyn
otlp_endpoint_host: expect.any(String),
logs_exporter: expect.any(String),
metrics_exporter: expect.any(String),
traces_exporter: expect.any(String),
});
}
}, 120_000);
@@ -200,18 +201,21 @@ async function runStartupScenario(
// The backend bundle on the CLI's runtime, started outside the
// workspace like the packaged app does.
child = Bun.spawn([cli, "run", "--no-env-file", bundle], {
cwd: dirname(bundle),
env: {
...env,
BUN_BE_BUN: "1",
CLINE_DESKTOP_CLI_PATH: cli,
CLINE_DESKTOP_WORKSPACE_ROOT: root,
child = Bun.spawn(
[cli, "run", "--use-system-ca", "--no-env-file", bundle],
{
cwd: dirname(bundle),
env: {
...env,
BUN_BE_BUN: "1",
CLINE_DESKTOP_CLI_PATH: cli,
CLINE_DESKTOP_WORKSPACE_ROOT: root,
},
stdin: "ignore",
stdout,
stderr,
},
stdin: "ignore",
stdout,
stderr,
});
);
let endpoint: string | undefined;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
@@ -46,6 +46,21 @@ describe("telemetryDefineArgs", () => {
const defines = defineMap(telemetryDefineArgs({}));
expect(defines["process.env.OTEL_TELEMETRY_ENABLED"]).toBe('""');
expect(defines["process.env.OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe('""');
expect(defines["process.env.OTEL_TRACES_EXPORTER"]).toBe('""');
});
it("inlines the trace policy vars the Langfuse relay reads at runtime", () => {
const defines = defineMap(
telemetryDefineArgs({
CLINE_TRACE_SAMPLE_PERCENT: "25",
CLINE_TRACE_RECORD_CONTENT: "true",
}),
);
expect(defines["process.env.CLINE_TRACE_SAMPLE_PERCENT"]).toBe('"25"');
expect(defines["process.env.CLINE_TRACE_RECORD_CONTENT"]).toBe('"true"');
const unset = defineMap(telemetryDefineArgs({}));
expect(unset["process.env.CLINE_TRACE_SAMPLE_PERCENT"]).toBe('""');
expect(unset["process.env.CLINE_TRACE_RECORD_CONTENT"]).toBe('""');
});
it("includes API key defines only when the vars are set", () => {
@@ -20,14 +20,20 @@ const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
* (sdk/packages/shared/src/services/telemetry-config.ts), plus the trace
* policy vars the Langfuse relay reads literally
* (sdk/packages/llms/src/services/langfuse-telemetry.ts). Always inlined,
* defaulting to "", so a packaged binary never falls back to runtime env.
* Mirrors BUILD_TIME_INLINED_ENV_VARS in apps/cli/script/compile-binary.ts,
* which bakes the same set into the bundled Cline CLI (the Hub daemon).
*/
const OTEL_ENV_VARS = [
"OTEL_TELEMETRY_ENABLED",
"OTEL_METRICS_EXPORTER",
"OTEL_LOGS_EXPORTER",
"OTEL_TRACES_EXPORTER",
"CLINE_TRACE_SAMPLE_PERCENT",
"CLINE_TRACE_RECORD_CONTENT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
@@ -21,7 +21,6 @@ sidecar/
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
├── cline-auth.ts # Refresh-aware Cline auth token resolution
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
@@ -212,9 +211,7 @@ Supported commands:
| `read_session_hooks` | Session data readers |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
| `get_desktop_settings` | `readDesktopSettings()` |
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
| `get_feature_flags` | PostHog flag snapshot + `isCloudAgentsEnabled()` (env override + `code-cloud-agents` rollout flag) |
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
| `list_cloud_models` | `CloudSessionManager.listModels()` (account-scoped Usage-Billing, ClinePass, and ClineFree catalogs; model IDs use the Cline cloud transport) |
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
@@ -8,7 +8,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionNotFoundError, SqliteSessionStore } from "@cline/core";
import { SessionNotFoundError } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
@@ -25,10 +25,6 @@ import {
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import {
assertSessionDeleteAllowedDuringHandoff,
beginSessionMetadataUpdate,
} from "./cloud-handoff";
import {
getEnvironmentContext,
getSessionRuntimeBinding,
@@ -593,359 +589,6 @@ describe("environment-bound session attach", () => {
});
describe("session forks", () => {
it("blocks the delete command while a persisted cloud handoff is pending", async () => {
const remove = vi.fn();
const sessionId = "pending-handoff-source";
const ctx = {
liveSessions: new Map(),
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId,
metadata: {
handoff: {
status: "pending",
toCloudSessionId: "cloud-pending",
handedOffAt: "2026-08-18T00:00:00.000Z",
dashboardUrl:
"https://app.cline.bot/agents?sessionId=cloud-pending",
},
},
})),
delete: remove,
},
{ sessionIds: [sessionId] },
),
} as unknown as SidecarContext;
const { handleCommand } = await import("./commands");
await expect(
handleCommand(ctx, "delete_chat_session", {
sessionId,
}),
).rejects.toThrow("Cloud handoff is still pending");
expect(remove).not.toHaveBeenCalled();
});
it("blocks local mutations while the handoff request is starting", async () => {
enableCloudHandoffGates();
let releaseGet: ((value: undefined) => void) | undefined;
const sessionId = "starting-handoff-source";
const get = vi
.fn()
.mockImplementationOnce(
async () =>
await new Promise<undefined>((resolve) => {
releaseGet = resolve;
}),
)
.mockResolvedValue(undefined);
const ctx = {
liveSessions: new Map(),
...localRuntimeContext({ get }, { sessionIds: [sessionId] }),
} as unknown as SidecarContext;
const handoff = handleChatSessionCommand(ctx, {
action: "handoff",
sessionId,
config: { environmentId: "local" },
handoffAttemptId: "attempt-a",
fingerprint: {
repoUrl: "https://github.com/cline/cline.git",
branch: "main",
headSha: "abc123",
modelId: "anthropic/claude-sonnet-4.6",
},
});
await vi.waitFor(() => expect(releaseGet).toBeTypeOf("function"));
await expect(
handleChatSessionCommand(ctx, {
action: "handoff",
sessionId,
config: { environmentId: "local" },
handoffAttemptId: "attempt-b",
fingerprint: {
repoUrl: "https://github.com/cline/cline.git",
branch: "main",
headSha: "abc123",
modelId: "anthropic/claude-sonnet-4.6",
},
}),
).rejects.toThrow("A different cloud handoff is already in progress");
await expect(
assertSessionDeleteAllowedDuringHandoff(
ctx,
sessionId,
getSessionRuntimeBinding(ctx),
),
).rejects.toThrow("Wait for the cloud handoff to finish before deleting");
const { handleCommand } = await import("./commands");
await expect(
handleCommand(ctx, "update_chat_session_metadata", {
sessionId,
metadata: { pinned: true },
}),
).rejects.toThrow(
"Wait for the cloud handoff to finish before updating session metadata",
);
get.mockResolvedValue({ sessionId, status: "idle" });
ctx.runtimeBindings.set("ssh-test", {
...getSessionRuntimeBinding(ctx),
environmentId: "ssh-test",
kind: "ssh",
sessionManager: {
...getSessionRuntimeBinding(ctx).sessionManager,
get: vi.fn(async () => undefined),
} as unknown as ReturnType<
typeof getSessionRuntimeBinding
>["sessionManager"],
});
ctx.activeEnvironmentId = "ssh-test";
for (const key of ["sessionId", "session_id"]) {
await expect(
handleCommand(ctx, "update_chat_session_metadata", {
[key]: sessionId,
metadata: { pinned: true },
}),
).rejects.toThrow(
"Wait for the cloud handoff to finish before updating session metadata",
);
}
releaseGet?.(undefined);
await expect(handoff).rejects.toThrow("was not found");
});
it("rejects a checkpoint fork when a handoff starts during its source read", async () => {
enableCloudHandoffGates();
const sessionId = "fork-race-source";
const releases: Array<(value: unknown) => void> = [];
const get = vi.fn(
async () => await new Promise((resolve) => releases.push(resolve)),
);
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
...localRuntimeContext({ get }, { sessionIds: [sessionId] }),
} as unknown as SidecarContext;
const fork = handleChatSessionCommand(ctx, {
action: "fork",
sessionId,
forkBeforeRunCount: 1,
config: { environmentId: "local" },
});
await vi.waitFor(() => expect(releases).toHaveLength(1));
const handoff = handleChatSessionCommand(ctx, {
action: "handoff",
sessionId,
config: { environmentId: "local" },
handoffAttemptId: "attempt-a",
fingerprint: {
repoUrl: "https://github.com/cline/cline.git",
branch: "main",
headSha: "abc123",
modelId: "anthropic/claude-sonnet-4.6",
},
}).catch(() => undefined);
await vi.waitFor(() => expect(releases).toHaveLength(2));
releases[0]?.({
sessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
});
await expect(fork).rejects.toThrow(
"Wait for the cloud handoff to finish before forking",
);
releases[1]?.(undefined);
await handoff;
});
it("blocks handoff until every metadata update releases, ignoring duplicate releases", async () => {
enableCloudHandoffGates();
const sessionId = "metadata-update-source";
const ctx = {
liveSessions: new Map(),
...localRuntimeContext(
{ get: vi.fn(async () => undefined) },
{ sessionIds: [sessionId] },
),
} as unknown as SidecarContext;
const releaseMetadataUpdate = beginSessionMetadataUpdate(ctx, sessionId);
const releaseOtherUpdate = beginSessionMetadataUpdate(ctx, sessionId);
releaseMetadataUpdate();
releaseMetadataUpdate();
await expect(
handleChatSessionCommand(ctx, {
action: "handoff",
sessionId,
config: { environmentId: "local" },
}),
).rejects.toThrow(
"Wait for the session metadata update to finish before handing off",
);
releaseOtherUpdate();
await expect(
handleChatSessionCommand(ctx, { action: "handoff", sessionId }),
).rejects.toThrow("Run handoff preflight again");
});
it("blocks handoff while session deletion is starting", async () => {
enableCloudHandoffGates();
let releaseGet: ((value: undefined) => void) | undefined;
const sessionId = "deleting-handoff-source";
const ctx = {
liveSessions: new Map(),
...localRuntimeContext(
{
get: vi.fn(
async () =>
await new Promise<undefined>((resolve) => {
releaseGet = resolve;
}),
),
},
{ sessionIds: [sessionId] },
),
} as unknown as SidecarContext;
const deletion = assertSessionDeleteAllowedDuringHandoff(
ctx,
sessionId,
getSessionRuntimeBinding(ctx),
);
expect(releaseGet).toBeTypeOf("function");
await expect(
handleChatSessionCommand(ctx, {
action: "handoff",
sessionId,
config: { environmentId: "local" },
handoffAttemptId: "attempt-a",
fingerprint: {
repoUrl: "https://github.com/cline/cline.git",
branch: "main",
headSha: "abc123",
modelId: "anthropic/claude-sonnet-4.6",
},
}),
).rejects.toThrow("Wait for session deletion to finish before handing off");
releaseGet?.(undefined);
const releaseDelete = await deletion;
releaseDelete();
});
it("keeps a persisted pending handoff read-only after restart", async () => {
const send = vi.fn();
const restore = vi.fn();
const sourceSessionId = "pending-handoff-source";
const pendingSession = {
sessionId: sourceSessionId,
status: "idle",
metadata: {
handoff: {
status: "pending",
toCloudSessionId: "cloud-pending",
handedOffAt: "2026-08-18T00:00:00.000Z",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-pending",
},
},
};
const ctx = {
liveSessions: new Map([
[
sourceSessionId,
{
config: { cwd: "/workspace/project" },
messages: [{ role: "user", content: "continue" }],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
},
],
]),
restoringWorkspacePaths: new Set(),
...localRuntimeContext(
{
get: vi.fn(async () => pendingSession),
send,
restore,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
const recovery = "Cloud handoff is still pending";
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId: sourceSessionId,
prompt: "race",
}),
).rejects.toThrow(recovery);
await expect(
handleChatSessionCommand(ctx, {
action: "fork",
sessionId: sourceSessionId,
}),
).rejects.toThrow(recovery);
await expect(
handleChatSessionCommand(ctx, {
action: "reset",
sessionId: sourceSessionId,
}),
).rejects.toThrow(recovery);
await expect(
handleChatSessionCommand(ctx, {
action: "restore_checkpoint",
sessionId: sourceSessionId,
checkpointRunCount: 1,
config: { cwd: "/workspace/project" },
}),
).rejects.toThrow(recovery);
expect(send).not.toHaveBeenCalled();
expect(restore).not.toHaveBeenCalled();
});
it("rejects checkpoint restore after the source completed a cloud handoff", async () => {
const restore = vi.fn();
const sessionId = "handed-off-source";
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId,
metadata: {
handoff: {
status: "complete",
toCloudSessionId: "cloud-target",
handedOffAt: "2026-08-18T00:00:00.000Z",
},
},
})),
restore,
},
{ sessionIds: [sessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "restore_checkpoint",
sessionId,
checkpointRunCount: 1,
config: { cwd: "/workspace/project" },
}),
).rejects.toThrow("Fork locally before restoring a checkpoint");
expect(restore).not.toHaveBeenCalled();
});
it("restores the selected workspace checkpoint before forking for message editing", async () => {
const sourceSessionId = `source-fork-${Date.now()}`;
const sourceMessages = [
@@ -1520,7 +1163,17 @@ describe("session forks", () => {
wsClients: new Set(),
...localRuntimeContext({
restore,
get: vi.fn(async () => undefined),
get: vi.fn(async () => ({
sessionId,
metadata: {
handoff: {
status: "pending",
toCloudSessionId: "cloud-target",
handedOffAt: "2026-08-18T00:00:00.000Z",
},
cloudHandoffIntent: { fingerprint: {} },
},
})),
}),
} as unknown as SidecarContext;
const restoreRequest = {
@@ -1679,6 +1332,40 @@ describe("first-send connection updates", () => {
};
}
it.each([
"pending",
"complete",
"intent",
])("keeps old %s handoff markers inert for local send and reset", async (status) => {
const { ctx, sessionId, get, send } = createContext();
get.mockImplementation(async () => ({
sessionId,
status: "idle",
metadata:
status === "intent"
? { cloudHandoffIntent: { fingerprint: {} } }
: {
handoff: {
status,
toCloudSessionId: "cloud-target",
handedOffAt: "2026-08-18T00:00:00.000Z",
},
},
}));
await expect(
handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "Continue locally.",
}),
).resolves.toMatchObject({ sessionId });
expect(send).toHaveBeenCalledOnce();
await expect(
handleChatSessionCommand(ctx, { action: "reset", sessionId }),
).resolves.toMatchObject({ sessionId });
expect(ctx.liveSessions.has(sessionId)).toBe(false);
});
it("preserves a newer Hub idle event over a running attach reply", async () => {
const { ctx, sessionId } = createContext({ attachedViaHub: true });
const binding = ctx.runtimeBindings.get("local");
@@ -1764,7 +1451,9 @@ describe("first-send connection updates", () => {
});
await expect(
handleChatSessionCommand(ctx, { action: "prepare_handoff", sessionId }),
).rejects.toThrow("Stop the current run");
).rejects.toThrow(
"Wait for the current run to finish before using /cloud.",
);
});
it("uses the attach reply when no subscribed live state exists", async () => {
@@ -2011,32 +1700,8 @@ describe("first-send connection updates", () => {
}
});
it.each([
"available",
"unavailable",
"unavailable with pending handoff",
])("resets attachments unless a handoff owns them (Hub: %s)", async (hubState) => {
const { ctx, sessionId, get } = createContext();
const manifest = vi.spyOn(sessionPaths, "readSessionManifest");
const pendingHandoff = hubState === "unavailable with pending handoff";
if (hubState !== "available") {
get.mockRejectedValue(
Object.assign(new Error("Hub connection closed"), {
name: "HubTransportError",
}),
);
manifest.mockReturnValue({
metadata: pendingHandoff
? {
handoff: {
status: "pending",
toCloudSessionId: "cloud-target",
handedOffAt: "2026-08-18T00:00:00.000Z",
},
}
: {},
});
}
it("removes queued and consumed attachments when resetting", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
@@ -2058,19 +1723,15 @@ describe("first-send connection updates", () => {
["pending_2", [consumedFile]],
]);
const reset = handleChatSessionCommand(ctx, {
await handleChatSessionCommand(ctx, {
action: "reset",
sessionId,
});
if (pendingHandoff)
await expect(reset).rejects.toThrow("Cloud handoff is still pending");
else await reset;
expect(existsSync(queuedFile)).toBe(pendingHandoff);
expect(existsSync(consumedFile)).toBe(pendingHandoff);
expect(ctx.liveSessions.has(sessionId)).toBe(pendingHandoff);
expect(existsSync(queuedFile)).toBe(false);
expect(existsSync(consumedFile)).toBe(false);
expect(ctx.liveSessions.has(sessionId)).toBe(false);
} finally {
manifest.mockRestore();
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
@@ -2166,7 +1827,9 @@ describe("first-send connection updates", () => {
});
await expect(
handleChatSessionCommand(ctx, { action: "prepare_handoff", sessionId }),
).rejects.toThrow("Stop the current run");
).rejects.toThrow(
"Wait for the current run to finish before using /cloud.",
);
});
it("updates a changed connection before sending", async () => {
@@ -2472,139 +2135,12 @@ describe("first-send connection updates", () => {
name: "HubTransportError",
});
it.each([
["send", undefined],
["reset", undefined],
["send", "pending"],
["send", "complete"],
] as const)("uses a saved SQLite row without Hub or manifest (%s, %s)", async (action, status) => {
const dataDir = mkdtempSync(join(tmpdir(), "desktop-hub-loss-sqlite-"));
const previousDbDir = process.env.CLINE_DB_DATA_DIR;
const previousSessionDir = process.env.CLINE_SESSION_DATA_DIR;
vi.stubEnv("CLINE_DB_DATA_DIR", join(dataDir, "db"));
vi.stubEnv("CLINE_SESSION_DATA_DIR", join(dataDir, "sessions"));
const store = new SqliteSessionStore();
const { ctx, get, send, sessionId } = createContext();
store.create({
sessionId,
source: "desktop",
pid: 0,
startedAt: "2026-10-13T00:00:00.000Z",
updatedAt: "2026-10-13T00:00:00.000Z",
status: "completed",
interactive: true,
provider: "cline",
model: "test",
cwd: "/workspace",
workspaceRoot: "/workspace",
enableTools: true,
enableSpawn: false,
enableTeams: false,
isSubagent: false,
metadata: status
? {
handoff: {
status,
toCloudSessionId: "cloud-target",
handedOffAt: "2026-10-13T00:00:00.000Z",
},
}
: {},
});
get.mockRejectedValue(transportLost());
const manifest = vi
.spyOn(sessionPaths, "readSessionManifest")
.mockReturnValue(null);
try {
const request =
action === "send"
? { action, sessionId, prompt: "hello" }
: { action, sessionId };
if (status) {
await expect(handleChatSessionCommand(ctx, request)).rejects.toThrow(
status === "pending"
? "Cloud handoff is still pending"
: "This session continued in Cline Cloud",
);
expect(send).not.toHaveBeenCalled();
} else {
await expect(
handleChatSessionCommand(ctx, request),
).resolves.toMatchObject({ sessionId });
if (action === "send") expect(send).toHaveBeenCalledOnce();
else expect(ctx.liveSessions.has(sessionId)).toBe(false);
}
} finally {
manifest.mockRestore();
store.close();
if (previousDbDir === undefined) delete process.env.CLINE_DB_DATA_DIR;
else process.env.CLINE_DB_DATA_DIR = previousDbDir;
if (previousSessionDir === undefined)
delete process.env.CLINE_SESSION_DATA_DIR;
else process.env.CLINE_SESSION_DATA_DIR = previousSessionDir;
rmSync(dataDir, { recursive: true, force: true });
}
});
it.each([
"pending",
"complete",
"missing manifest",
"other error",
])("does not bypass the send guard for %s during a lookup failure", async (scenario) => {
const { ctx, get, send, sessionId, start } = createContext();
const error =
scenario === "other error"
? new Error("access denied")
: transportLost();
get.mockRejectedValue(error);
const manifest = vi
.spyOn(sessionPaths, "readSessionManifest")
.mockReturnValue(
scenario === "missing manifest"
? null
: {
metadata: {
handoff: {
status: scenario,
toCloudSessionId: "cloud-target",
handedOffAt: "2026-08-18T00:00:00.000Z",
},
},
},
);
try {
const result = handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "hello",
config: { environmentId: "local" },
});
if (scenario === "pending" || scenario === "complete") {
await expect(result).rejects.toThrow(
scenario === "pending"
? "Cloud handoff is still pending"
: "This session continued in Cline Cloud",
);
} else await expect(result).rejects.toBe(error);
expect(send).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
} finally {
manifest.mockRestore();
}
});
it.each([
"send",
"pre-send lookup",
])("rebuilds a session the restarted hub lost during %s and ends the turn interrupted", async (stage) => {
const { ctx, get, send, sessionId, start, updateSessionConnection } =
it("rebuilds a session the restarted hub lost during send and ends the turn interrupted", async () => {
const { ctx, send, sessionId, start, updateSessionConnection } =
createContext();
const manifest = vi
.spyOn(sessionPaths, "readSessionManifest")
.mockReturnValue({ metadata: {} });
if (stage === "pre-send lookup")
get.mockRejectedValueOnce(transportLost());
send.mockRejectedValueOnce(missing(sessionId));
updateSessionConnection.mockRejectedValueOnce(missing(sessionId));
+20 -129
View File
@@ -2,7 +2,6 @@ import { existsSync, readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
buildCloudHandoffDashboardUrl,
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
@@ -19,7 +18,6 @@ import {
ProviderSettingsManager,
projectSessionCompactionState,
RuntimeOAuthTokenManager,
readCloudHandoffMetadata,
readGlobalSettings,
readSessionCheckpointHistory,
resolveProviderApiKeyFromSettings,
@@ -27,8 +25,8 @@ import {
type SessionPendingPrompt,
type SessionRecord,
SessionSource,
SqliteSessionStore,
splitCoreSessionConfig,
summarizeConversation,
toProviderConfig,
trimMessagesBeforeUserRun,
} from "@cline/core";
@@ -41,7 +39,6 @@ import {
type ConsecutiveMistakeLimitContext,
type ConsecutiveMistakeLimitDecision,
formatUserCommandBlock,
getClineEnvironmentConfig,
type SessionPluginIssue,
} from "@cline/shared";
import {
@@ -52,19 +49,13 @@ import {
} from "./attachments";
import { createDesktopExtensionContext } from "./client-context";
import {
assertCloudHandoffCreationResolved,
beginActiveSessionSend,
handleHandoff,
handlePrepareHandoff,
handlePrepareHandoffGit,
hasActiveSessionSend,
isCloudHandoffFollowUpBlocked,
isCloudHandoffInProgress,
} from "./cloud-handoff";
import {
readCloudHandoffFollowUp,
sendWithCloudHandoffFollowUp,
} from "./cloud-handoff-follow-up";
import { sendWithCloudHandoffFollowUp } from "./cloud-handoff-follow-up";
import {
getCloudSessionManager,
isCloudOuterSessionId,
@@ -1666,11 +1657,6 @@ async function handleSend(
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
if (isCloudHandoffInProgress(ctx, sessionId)) {
throw new Error(
"Cloud handoff is in progress. Wait for it to finish before sending another prompt.",
);
}
const finishActiveSend = beginActiveSessionSend(ctx, sessionId);
try {
return await handleSendOnce(ctx, request, sessionId, prompt);
@@ -1679,29 +1665,6 @@ async function handleSend(
}
}
async function readHandoffSessionMetadata(
binding: SessionRuntimeBinding,
sessionId: string,
) {
if (binding.kind !== "local") return undefined;
const persisted = await binding.sessionManager
.get(sessionId)
.catch((error) => {
if (!isHubLossError(error)) throw error;
const manifest = readSessionManifest(sessionId);
if (manifest) return manifest;
const store = new SqliteSessionStore();
try {
const row = store.get(sessionId);
if (!row) throw error;
return row;
} finally {
store.close();
}
});
return persisted?.metadata ?? readSessionMetadata(sessionId);
}
async function handleSendOnce(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
@@ -1715,19 +1678,6 @@ async function handleSendOnce(
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const metadata = await readHandoffSessionMetadata(binding, sessionId);
assertCloudHandoffCreationResolved(metadata);
const handoff = readCloudHandoffMetadata(metadata);
if (handoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${handoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, handoff.toCloudSessionId)}`,
);
}
if (handoff?.status === "complete") {
throw new Error(
`This session continued in Cline Cloud: ${handoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, handoff.toCloudSessionId)}. Fork locally to continue here.`,
);
}
const lockedWorkspaceKey = workspacePathKey(
session?.config ?? request.config,
);
@@ -2103,9 +2053,6 @@ async function handleFork(
): Promise<unknown> {
const sourceSessionId = request.sessionId?.trim();
if (!sourceSessionId) throw new Error("sessionId is required");
if (isCloudHandoffInProgress(ctx, sourceSessionId)) {
throw new Error("Wait for the cloud handoff to finish before forking.");
}
const forkBeforeRunCount = request.forkBeforeRunCount;
if (
forkBeforeRunCount !== undefined &&
@@ -2128,18 +2075,6 @@ async function handleFork(
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
}
const sourceSession = await manager.get(sourceSessionId);
const sourceMetadata =
binding.kind === "local"
? (sourceSession?.metadata ?? readSessionMetadata(sourceSessionId))
: undefined;
if (forkBeforeRunCount !== undefined)
assertCloudHandoffCreationResolved(sourceMetadata);
const sourceHandoff = readCloudHandoffMetadata(sourceMetadata);
if (sourceHandoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${sourceHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, sourceHandoff.toCloudSessionId)}`,
);
}
if (
forkBeforeRunCount !== undefined &&
(sourceSession?.status === "running" || sourceSession?.status === "pending")
@@ -2163,10 +2098,6 @@ async function handleFork(
throw new Error("cwd or workspaceRoot is required to edit a message");
}
return withWorkspaceRestoreLock(ctx, restoreWorkspacePath, () => {
// A handoff may have started while the source was read; once locked, new ones refuse.
if (isCloudHandoffInProgress(ctx, sourceSessionId)) {
throw new Error("Wait for the cloud handoff to finish before forking.");
}
return handleForkUnlocked(
ctx,
request,
@@ -2368,23 +2299,12 @@ async function handleReset(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (sessionId) {
if (isCloudHandoffInProgress(ctx, sessionId)) {
throw new Error("Wait for the cloud handoff to finish before resetting.");
}
const binding = getSessionRuntimeBinding(
ctx,
sessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const pendingHandoff = readCloudHandoffMetadata(
await readHandoffSessionMetadata(binding, sessionId),
);
if (pendingHandoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${pendingHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, pendingHandoff.toCloudSessionId)}`,
);
}
cancelHubReconnect(sessionId);
cancelSidecarMistakeQuestions(ctx, sessionId, "Session reset");
const session = ctx.liveSessions.get(sessionId);
@@ -2410,11 +2330,6 @@ async function handleRestoreCheckpoint(
): Promise<unknown> {
const sourceSessionId = request.sessionId?.trim();
if (!sourceSessionId) throw new Error("sessionId is required");
if (isCloudHandoffInProgress(ctx, sourceSessionId)) {
throw new Error(
"Wait for the cloud handoff to finish before restoring a checkpoint.",
);
}
const runCount = request.checkpointRunCount;
if (
typeof runCount !== "number" ||
@@ -2431,36 +2346,6 @@ async function handleRestoreCheckpoint(
readEnvironmentId(requestedConfig),
);
const manager = binding.sessionManager;
const persisted =
binding.kind === "local" ? await manager.get(sourceSessionId) : undefined;
const handoffMetadata =
binding.kind === "local"
? (persisted?.metadata ?? readSessionMetadata(sourceSessionId))
: undefined;
const completedHandoff = readCloudHandoffMetadata(handoffMetadata);
assertCloudHandoffCreationResolved(handoffMetadata);
if (completedHandoff?.status === "pending") {
throw new Error(
`Cloud handoff is still pending. Retry /cloud or continue here: ${completedHandoff.dashboardUrl ?? buildCloudHandoffDashboardUrl(getClineEnvironmentConfig().appBaseUrl, completedHandoff.toCloudSessionId)}`,
);
}
if (completedHandoff?.status === "complete") {
throw new Error(
"This session continued in Cline Cloud. Fork locally before restoring a checkpoint.",
);
}
// The initial persisted read can race with a handoff beginning. Recheck
// after it resolves before any restore work mutates the source workspace.
if (
isCloudHandoffInProgress(
getEnvironmentContext(ctx, ctx.activeEnvironmentId ?? "local"),
sourceSessionId,
)
) {
throw new Error(
"Wait for the cloud handoff to finish before restoring a checkpoint.",
);
}
const cwd =
(typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) ||
(typeof requestedConfig.workspaceRoot === "string" &&
@@ -2665,9 +2550,24 @@ const ACTION_HANDLERS: Record<
send: handleSend,
prepare_handoff: handlePrepareHandoff,
prepare_handoff_git: (ctx, request) =>
handlePrepareHandoffGit(ctx, request, (cwd, work) =>
withWorkspaceRestoreLock(ctx, cwd, work),
),
handlePrepareHandoffGit(ctx, request, async (messages, config) => {
const connection = buildSessionConnectionUpdate(
await withRemoteProviderCredentials(config),
);
if (!connection.providerId || !connection.modelId)
throw new Error(
"Select a local model before preparing a cloud summary.",
);
return summarizeConversation({
messages,
signal: AbortSignal.timeout(120_000),
config: {
...connection,
providerId: connection.providerId,
modelId: connection.modelId,
},
});
}),
handoff: handleHandoff,
stop: handleStop,
abort: handleAbort,
@@ -2755,15 +2655,6 @@ export async function handleChatSessionCommand(
return await cloud.attach(sessionId);
case "send": {
if (!sessionId) throw new Error("sessionId is required");
const saved = readCloudHandoffFollowUp(sessionId);
if (
saved &&
(await isCloudHandoffFollowUpBlocked(ctx, saved.sourceSessionId))
) {
throw new Error(
"Wait for the cloud handoff to finish. Retry /cloud from the source session if it failed.",
);
}
if (request.attachments?.userFiles?.length) {
throw new Error(
"File attachments are not supported in cloud sessions",
@@ -19,7 +19,13 @@ describe("ensureHubWithDesktopCli", () => {
["hub", "ensure", "--json", "--cwd", "/work"],
{
cwd: "/work",
env: { PATH: "/usr/bin", CLINE_NO_AUTO_UPDATE: "1" },
env: {
PATH: "/usr/bin",
CLINE_NO_AUTO_UPDATE: "1",
CLINE_HUB_LAUNCHER_CLIENT: "cline-desktop",
CLINE_HUB_LAUNCHER_VERSION: expect.stringMatching(/^\d+\.\d+\.\d+/),
CLINE_HUB_LAUNCHER_TRANSPORT: "local",
},
timeoutMs: 60_000,
},
);
@@ -1,5 +1,7 @@
import { execFile } from "node:child_process";
import { setHubDaemonLauncher } from "@cline/core";
import { withHubLauncherIdentity } from "@cline/shared";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
/** Absolute path of the Cline CLI binary the desktop shell bundles and launched us with. */
export const DESKTOP_CLI_PATH_ENV = "CLINE_DESKTOP_CLI_PATH";
@@ -94,7 +96,16 @@ export async function ensureHubWithDesktopCli(
["hub", "ensure", "--json", "--cwd", workspaceRoot],
{
cwd: workspaceRoot,
env: { ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
// The CLI spawns the daemon, so stamp the desktop as its launcher
// here or the hub's telemetry would say the CLI started it.
env: withHubLauncherIdentity(
{ ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
{
client: DESKTOP_CLIENT_CONTEXT.name,
version: DESKTOP_CLIENT_CONTEXT.version,
transport: "local",
},
),
timeoutMs: HUB_ENSURE_TIMEOUT_MS,
},
);
@@ -4,6 +4,7 @@ import {
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -21,6 +22,7 @@ import {
updateCloudHandoffFollowUp,
} from "./cloud-handoff-follow-up";
import { CloudSessionApi, CloudSessionManager } from "./cloud-sessions";
import { handleCommand } from "./commands";
import { createSidecarContext, disposeSidecarContext } from "./context";
import { localRuntimeContext } from "./session-test-helpers";
@@ -67,6 +69,23 @@ it("keeps untrusted session ids inside the recovery directory", () => {
expect(readCloudHandoffFollowUp("../../outside")?.command).toBe("hello");
});
it.each([
{ mode: "unknown" },
{ thinking: "yes" },
{ apiKey: "not-an-option" },
])("rejects malformed initial-task options: %j", (initialTask) => {
saveCloudHandoffFollowUp("target", {
sourceSessionId: "source",
command: "continue",
userImages: [],
});
const directory = join(dataDir, "desktop-handoff-follow-ups");
const path = join(directory, readdirSync(directory)[0]);
const saved = readCloudHandoffFollowUp("target");
writeFileSync(path, JSON.stringify({ ...saved, initialTask }));
expect(() => readCloudHandoffFollowUp("target")).toThrow();
});
it.each([
false,
true,
@@ -254,7 +273,11 @@ it("restores or dismisses only the reviewed recovery copy", () => {
sourceSessionId: "source",
command: "inspect",
userImages: ["image"],
unconfirmed: true,
initialTask: {
mode: "plan" as const,
workspaceRelativePath: "packages/app",
autoApproveTools: false,
},
};
saveCloudHandoffFollowUp("target", saved);
const restored = updateCloudHandoffFollowUp("target", saved, "restore");
@@ -263,18 +286,27 @@ it("restores or dismisses only the reviewed recovery copy", () => {
sourceSessionId: "source",
command: "inspect",
userImages: ["image"],
initialTask: saved.initialTask,
});
expect(readCloudHandoffFollowUp("target")).toEqual(restored);
expect(() => updateCloudHandoffFollowUp("target", saved, "dismiss")).toThrow(
"changed",
);
expect(() =>
updateCloudHandoffFollowUp(
"target",
{ ...saved, command: "changed" },
"dismiss",
),
).toThrow("changed");
expect(readCloudHandoffFollowUp("target")).toEqual(restored);
if (!restored) throw new Error("Missing restored payload");
updateCloudHandoffFollowUp("target", restored, "dismiss");
expect(readCloudHandoffFollowUp("target")).toBeNull();
});
it("retains edits and images as a usable draft when preflight rejects", async () => {
it.each([
false,
true,
])("exposes the full recovery draft only after provisioning settles (rejected: %s)", async (rejected) => {
const ctx = createSidecarContext("/workspace");
saveCloudHandoffFollowUp("target", {
draftId: "draft",
sourceSessionId: "source",
@@ -282,35 +314,60 @@ it("retains edits and images as a usable draft when preflight rejects", async ()
userImages: [],
});
const images = ["data:image/png;base64,aW1hZ2U="];
await expect(
sendWithCloudHandoffFollowUp(
"target",
"edited",
images,
async () => {
throw new Error("preflight failed");
},
"draft",
),
).rejects.toThrow("preflight failed");
expect(readCloudHandoffFollowUp("target")).toEqual({
const provisioning = Promise.withResolvers<void>();
const sending = sendWithCloudHandoffFollowUp(
"target",
"edited",
images,
async (lifecycle) => {
await provisioning.promise;
if (rejected) throw new Error("preflight failed");
lifecycle?.beforeDispatch?.();
return { ok: true };
},
"draft",
);
const saved = {
draftId: "draft",
sourceSessionId: "source",
command: "edited",
userImages: images,
});
};
try {
expect(readCloudHandoffFollowUp("target")).toEqual(saved);
await expect(
handleCommand(ctx, "get_cloud_handoff_follow_up", {
sessionId: "target",
}),
).resolves.toBeNull();
provisioning.resolve();
if (rejected) await expect(sending).rejects.toThrow("preflight failed");
else await expect(sending).resolves.toEqual({ ok: true });
await expect(
handleCommand(ctx, "get_cloud_handoff_follow_up", {
sessionId: "target",
}),
).resolves.toEqual(rejected ? saved : null);
} finally {
provisioning.resolve();
await sending.catch(() => undefined);
await disposeSidecarContext(ctx);
}
});
it.each([
"accepted",
"failed",
"stale draft",
"preflight failed",
"not dispatched",
])("preserves an unseen unqueued draft during an unrelated send (%s)", async (outcome) => {
const saved = {
draftId: "draft",
sourceSessionId: "source",
command: "unseen draft",
userImages: ["image"],
initialTask: { mode: "plan" as const, autoApproveTools: false },
};
saveCloudHandoffFollowUp("target", saved);
const sending = sendWithCloudHandoffFollowUp(
@@ -318,6 +375,8 @@ it.each([
"unrelated",
[],
async (lifecycle) => {
if (outcome === "preflight failed") throw new Error("preflight failed");
if (outcome === "not dispatched") return { ok: true };
lifecycle?.beforeDispatch?.();
if (outcome === "failed") throw new Error("disconnected");
return { ok: true };
@@ -326,8 +385,15 @@ it.each([
);
if (outcome === "failed")
await expect(sending).rejects.toThrow("disconnected");
else if (outcome === "preflight failed")
await expect(sending).rejects.toThrow("preflight failed");
else await sending;
expect(readCloudHandoffFollowUp("target")).toEqual(saved);
const { initialTask: _, ...dispatched } = saved;
expect(readCloudHandoffFollowUp("target")).toEqual(
outcome === "preflight failed" || outcome === "not dispatched"
? saved
: dispatched,
);
});
it("preserves A recovery while an edited B dispatches during A pending", async () => {
@@ -8,8 +8,12 @@ import {
} from "node:fs";
import { dirname, join } from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { CloudSendLifecycle } from "@cline/core/cloud";
import type {
CloudCreationOptions,
CloudSendLifecycle,
} from "@cline/core/cloud";
import { resolveClineDataDir } from "@cline/shared/storage";
import { z } from "zod";
export type CloudHandoffFollowUp = {
draftId: string;
@@ -17,8 +21,19 @@ export type CloudHandoffFollowUp = {
command: string;
userImages: string[];
unconfirmed?: boolean;
initialTask?: CloudCreationOptions;
};
const initialTaskSchema: z.ZodType<CloudCreationOptions> = z
.object({
autoApproveTools: z.boolean().optional(),
thinking: z.boolean().optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
mode: z.enum(["act", "plan", "yolo", "zen"]).optional(),
workspaceRelativePath: z.string().optional(),
})
.strict();
const activeRecoverySends = new Map<string, number>();
function followUpPath(targetSessionId: string): string {
@@ -73,6 +88,8 @@ export function readCloudHandoffFollowUp(
) {
throw new Error("The saved cloud follow-up could not be read.");
}
if (value.initialTask !== undefined)
value.initialTask = initialTaskSchema.parse(value.initialTask);
return value;
}
@@ -80,6 +97,14 @@ export function clearCloudHandoffFollowUp(targetSessionId: string): void {
rmSync(followUpPath(targetSessionId), { force: true });
}
export function getRecoverableCloudHandoffFollowUp(
targetSessionId: string,
): CloudHandoffFollowUp | null {
return activeRecoverySends.has(targetSessionId)
? null
: readCloudHandoffFollowUp(targetSessionId);
}
export function updateCloudHandoffFollowUp(
targetSessionId: string,
expected: CloudHandoffFollowUp,
@@ -113,22 +138,33 @@ export async function sendWithCloudHandoffFollowUp<
draftId?: string,
): Promise<T> {
const saved = readCloudHandoffFollowUp(targetSessionId);
if (!saved) return await send();
const matches = (record: CloudHandoffFollowUp | null) =>
Boolean(
record &&
saved &&
record.draftId === saved.draftId &&
record.sourceSessionId === saved.sourceSessionId &&
record.command.trim() === command.trim() &&
isDeepStrictEqual(record.userImages, userImages),
);
const activeSends = activeRecoverySends.get(targetSessionId) ?? 0;
if (
!saved ||
(!matches(saved) &&
(activeSends > 0 || saved.unconfirmed || saved.draftId !== draftId))
)
return await send();
const ownsDraft =
matches(saved) ||
(activeSends === 0 && !saved.unconfirmed && saved.draftId === draftId);
const lifecycle: CloudSendLifecycle = {
beforeDispatch: () => {
const current = readCloudHandoffFollowUp(targetSessionId);
if (!current) return;
const markUnconfirmed = ownsDraft && matches(current);
if (!current.initialTask && !markUnconfirmed) return;
const { initialTask: _, ...dispatched } = current;
saveCloudHandoffFollowUp(targetSessionId, {
...dispatched,
...(markUnconfirmed ? { unconfirmed: true } : {}),
});
},
};
if (!ownsDraft) return await send(lifecycle);
activeRecoverySends.set(targetSessionId, activeSends + 1);
try {
if (!matches(saved))
@@ -137,16 +173,7 @@ export async function sendWithCloudHandoffFollowUp<
command,
userImages,
});
const result = await send({
beforeDispatch: () => {
const current = readCloudHandoffFollowUp(targetSessionId);
if (matches(current) && current)
saveCloudHandoffFollowUp(targetSessionId, {
...current,
unconfirmed: true,
});
},
});
const result = await send(lifecycle);
if (!result.recoveredAfterDisconnect) {
try {
if (matches(readCloudHandoffFollowUp(targetSessionId)))
@@ -4,6 +4,7 @@ import {
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
@@ -13,6 +14,7 @@ import {
applyHandoffGit,
inspectHandoffGit,
previewHandoffGit,
verifyPublishedHandoffGit,
} from "./cloud-handoff-git";
const transport = vi.hoisted(() => ({
@@ -92,6 +94,13 @@ beforeEach(() => {
afterEach(() => rmSync(temporary, { recursive: true, force: true }));
describe("cloud handoff Git preparation", () => {
it("keeps a symlinked workspace relative to the repository", async () => {
mkdirSync(join(repo, "packages", "app"), { recursive: true });
const linked = join(temporary, "linked");
symlinkSync(repo, linked, "junction");
const plan = await inspectHandoffGit(join(linked, "packages", "app"));
expect(plan.workspaceRelativePath).toBe("packages/app");
});
it("inspects large remotes without exceeding Windows command-line limits", async () => {
const head = git("rev-parse", "HEAD");
execFileSync(
@@ -181,7 +190,7 @@ describe("cloud handoff Git preparation", () => {
["--assume-unchanged", false],
["--skip-worktree", true],
["--assume-unchanged", true],
] as const)("rejects %s (after preview: %s) without changing Git state", async (flag, afterPreview) => {
] as const)("never publishes hidden %s changes (after preview: %s)", async (flag, afterPreview) => {
const credentials = join(repo, "credentials.json");
writeFileSync(credentials, "synthetic baseline\n");
git("add", "credentials.json");
@@ -190,18 +199,29 @@ describe("cloud handoff Git preparation", () => {
writeFileSync(join(repo, "new.txt"), "visible change\n");
const plan = afterPreview ? await inspectHandoffGit(repo) : undefined;
git("update-index", flag, "credentials.json");
const localContent = afterPreview
? "synthetic baseline\n"
: "synthetic local-only value\n";
const localContent = "synthetic local-only value\n";
writeFileSync(credentials, localContent);
expect(git("status", "--porcelain")).toBe("?? new.txt");
const head = git("rev-parse", "HEAD");
const index = readFileSync(join(repo, ".git/index"));
const remoteRefs = git("ls-remote", "origin");
await expect(
plan ? applyHandoffGit(plan) : inspectHandoffGit(repo),
).rejects.toThrow("skip-worktree or assume-unchanged");
if (plan) {
await applyHandoffGit(plan);
expect(
git(
"--git-dir",
transport.remote,
"show",
`${plan.branch}:credentials.json`,
),
).toBe("synthetic baseline");
} else {
await expect(inspectHandoffGit(repo)).rejects.toThrow(
"skip-worktree or assume-unchanged",
);
expect(git("ls-remote", "origin")).toBe(remoteRefs);
}
expect(git("branch", "--show-current")).toBe("main");
expect(git("rev-parse", "HEAD")).toBe(head);
@@ -210,7 +230,6 @@ describe("cloud handoff Git preparation", () => {
expect(readFileSync(join(repo, "new.txt"), "utf8")).toBe(
"visible change\n",
);
expect(git("ls-remote", "origin")).toBe(remoteRefs);
});
it("previews all staged/unstaged/new files from a subdirectory without changing the index or branch, then publishes the exact checkpoint", async () => {
@@ -221,6 +240,7 @@ describe("cloud handoff Git preparation", () => {
mkdirSync(join(repo, "sub"));
const beforeIndex = git("write-tree");
const originalHead = git("rev-parse", "HEAD");
const originalStatus = git("status", "--porcelain");
const plan = await inspectHandoffGit(join(repo, "sub"));
expect(plan.files.map((file) => file.path).sort()).toEqual([
"new file.txt",
@@ -231,53 +251,80 @@ describe("cloud handoff Git preparation", () => {
expect(git("write-tree")).toBe(beforeIndex);
expect(git("branch", "--show-current")).toBe("main");
expect(previewHandoffGit(plan)).not.toHaveProperty("pushUrl");
await applyHandoffGit(plan);
expect(git("status", "--porcelain")).toBe("");
const published = await applyHandoffGit(plan);
expect(git("status", "--porcelain")).toBe(originalStatus);
expect(git("write-tree")).toBe(beforeIndex);
expect(git("branch", "--show-current")).toBe("main");
expect(git("rev-parse", "main")).toBe(originalHead);
expect(git("rev-parse", "HEAD^{tree}")).toBe(plan.treeSha);
expect(git("rev-parse", "HEAD^")).toBe(originalHead);
expect(git("rev-parse", `${published}^{tree}`)).toBe(plan.treeSha);
expect(git("rev-parse", `${published}^`)).toBe(originalHead);
expect(
git("worktree", "list", "--porcelain").match(/^worktree /gm),
).toHaveLength(1);
await verifyPublishedHandoffGit(plan, published);
expect(git("config", `branch.${plan.branch}.merge`)).toBe(
`refs/heads/${plan.branch}`,
);
expect(
git("--git-dir", transport.remote, "show", `${plan.branch}:tracked.txt`),
).toBe("staged");
git(
"--git-dir",
transport.remote,
"update-ref",
`refs/heads/${plan.branch}`,
originalHead,
);
await expect(verifyPublishedHandoffGit(plan, published)).rejects.toThrow(
"published branch changed",
);
});
it("rejects distinct staged and working copies without overwriting either", async () => {
it("publishes the working copy while preserving distinct staged and unstaged edits", async () => {
writeFileSync(join(repo, "tracked.txt"), "staged only\n");
git("add", "tracked.txt");
writeFileSync(join(repo, "tracked.txt"), "working only\n");
const index = git("write-tree");
const refs = git("ls-remote", "origin");
await expect(inspectHandoffGit(repo)).rejects.toThrow(
"staged and unstaged",
);
const plan = await inspectHandoffGit(repo);
await applyHandoffGit(plan);
expect(git("write-tree")).toBe(index);
expect(git("show", ":tracked.txt")).toBe("staged only");
expect(readFileSync(join(repo, "tracked.txt"), "utf8")).toBe(
"working only\n",
);
expect(git("branch", "--show-current")).toBe("main");
expect(git("ls-remote", "origin")).toBe(refs);
expect(
git("--git-dir", transport.remote, "show", `${plan.branch}:tracked.txt`),
).toBe("working only");
});
it.each([
"content",
"index",
"head",
"remote",
])("rejects stale %s consent before changing branches", async (change) => {
it("publishes only the captured files while local commits and edits continue", async () => {
writeFileSync(join(repo, "tracked.txt"), "approved");
const plan = await inspectHandoffGit(repo);
if (change === "content")
writeFileSync(join(repo, "tracked.txt"), "changed with identical status");
if (change === "index") git("add", ".");
if (change === "head")
git("commit", "--allow-empty", "-m", "another commit");
if (change === "remote")
transport.fetchUrl = transport.pushUrl =
"https://github.com/cline/another";
writeFileSync(join(repo, "tracked.txt"), "later committed work");
git("add", ".");
git("commit", "-m", "another commit");
const head = git("rev-parse", "HEAD");
writeFileSync(join(repo, "tracked.txt"), "later staged work");
git("add", ".");
const index = git("write-tree");
writeFileSync(join(repo, "tracked.txt"), "later unstaged work");
await applyHandoffGit(plan);
expect(
git("--git-dir", transport.remote, "show", `${plan.branch}:tracked.txt`),
).toBe("approved");
expect(git("rev-parse", `${plan.branch}^`)).toBe(plan.headSha);
expect(git("rev-parse", "HEAD")).toBe(head);
expect(git("write-tree")).toBe(index);
expect(readFileSync(join(repo, "tracked.txt"), "utf8")).toBe(
"later unstaged work",
);
expect(git("branch", "--show-current")).toBe("main");
});
it("rejects a changed publication destination", async () => {
const plan = await inspectHandoffGit(repo);
transport.fetchUrl = transport.pushUrl = "https://github.com/cline/another";
await expect(applyHandoffGit(plan)).rejects.toThrow(
"changed after confirmation",
);
@@ -306,7 +353,8 @@ describe("cloud handoff Git preparation", () => {
await expect(applyHandoffGit(plan)).rejects.toMatchObject({
message: `Couldn't complete the push. Your checkpoint is saved locally on ${plan.branch}. Check GitHub write access and your connection before retrying.`,
});
expect(git("branch", "--show-current")).toBe(plan.branch);
expect(git("branch", "--show-current")).toBe("main");
expect(git("show", `${plan.branch}:tracked.txt`)).toBe("keep this");
expect(readFileSync(join(repo, "tracked.txt"), "utf8")).toBe("keep this");
expect(git("ls-remote", "origin", `refs/heads/${plan.branch}`)).toBe("");
});
@@ -324,11 +372,31 @@ describe("cloud handoff Git preparation", () => {
{ mode: 0o755 },
);
const plan = await inspectHandoffGit(repo);
await expect(applyHandoffGit(plan)).rejects.toThrow("were not rolled back");
expect(git("ls-remote", "origin", `refs/heads/${plan.branch}`)).toBe("");
expect(readFileSync(join(repo, "tracked.txt"), "utf8")).toBe(
behavior === "reject" ? "approved\n" : "hook change\n",
await expect(applyHandoffGit(plan)).rejects.toThrow(
"local checkout was not changed",
);
expect(git("ls-remote", "origin", `refs/heads/${plan.branch}`)).toBe("");
expect(readFileSync(join(repo, "tracked.txt"), "utf8")).toBe("approved\n");
});
it.each([
"pre-commit",
"pre-push",
])("runs generated %s hooks configured with a relative path", async (hook) => {
writeFileSync(join(repo, ".gitignore"), ".hooks/\n");
git("add", ".gitignore");
git("commit", "-m", "ignore generated hooks");
mkdirSync(join(repo, ".hooks"));
writeFileSync(join(repo, ".hooks", hook), "#!/bin/sh\nexit 1\n", {
mode: 0o755,
});
git("config", "core.hooksPath", ".hooks");
writeFileSync(join(repo, "tracked.txt"), "approved\n");
const plan = await inspectHandoffGit(repo);
await expect(applyHandoffGit(plan)).rejects.toThrow();
expect(git("ls-remote", "origin", `refs/heads/${plan.branch}`)).toBe("");
expect(git("branch", "--show-current")).toBe("main");
expect(readFileSync(join(repo, "tracked.txt"), "utf8")).toBe("approved\n");
});
it.each([
@@ -381,9 +449,16 @@ describe("cloud handoff Git preparation", () => {
const plan = await inspectHandoffGit(repo);
expect(plan.files.map((file) => file.path)).toEqual([".gitignore"]);
await applyHandoffGit(plan);
expect(git("ls-tree", "-r", "--name-only", "HEAD")).not.toContain(
"private.txt",
);
expect(
git(
"--git-dir",
transport.remote,
"ls-tree",
"-r",
"--name-only",
plan.branch,
),
).not.toContain("private.txt");
});
it("refuses an untracked nested repository instead of publishing a gitlink without its files", async () => {
const nested = join(repo, "nested");
@@ -9,6 +9,7 @@ export type HandoffGitPlan = {
id: string;
root: string;
cwd: string;
workspaceRelativePath: string;
repoUrl: string;
pushUrl: string;
remote: string;
@@ -39,13 +40,16 @@ async function git(
env?: Partial<NodeJS.ProcessEnv>;
timeout?: number;
input?: string;
hooksPath?: string;
} = {},
) {
try {
return await new Promise<string>((resolve, reject) => {
const child = execFile(
"git",
args,
options.hooksPath
? ["-c", `core.hooksPath=${options.hooksPath}`, ...args]
: args,
{
cwd,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...options.env },
@@ -86,6 +90,9 @@ function isSensitivePath(path: string): boolean {
export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
const root = await git(cwd, ["rev-parse", "--show-toplevel"]);
const workspaceRelativePath = (
await git(cwd, ["rev-parse", "--show-prefix"])
).replace(/\/$/, "");
if (
(await git(root, ["ls-files", "-v", "-z"]))
.split("\0")
@@ -125,11 +132,6 @@ export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
"Resolve conflicts or changes inside submodules before continuing in cloud.",
);
}
if (files.some(({ status }) => /^[MARCT][MDT]$/.test(status))) {
throw new Error(
"Files with both staged and unstaged changes would lose the staged version. Stage or unstage them before retrying /cloud.",
);
}
if (files.some(({ path }) => isSensitivePath(path))) {
throw new Error(
"Potentially sensitive files need review before cloud preparation. Keep private files out of the changes and unpublished history before retrying /cloud.",
@@ -245,6 +247,7 @@ export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
id: randomUUID(),
root,
cwd: resolve(cwd),
workspaceRelativePath,
repoUrl,
pushUrl,
remote,
@@ -259,24 +262,56 @@ export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
};
}
export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
const current = await inspectHandoffGit(plan.root);
for (const key of [
"root",
"repoUrl",
"pushUrl",
async function assertPublicationDestination(
plan: HandoffGitPlan,
): Promise<void> {
const fetchUrl = await git(plan.root, [
"remote",
"sourceBranch",
"headSha",
"treeSha",
"indexTree",
"status",
] as const) {
if (current[key] !== plan[key])
throw new Error(
"The repository changed after confirmation. Run /cloud again to review the updated changes.",
);
}
"get-url",
"--all",
plan.remote,
]);
const pushUrl = await git(plan.root, [
"remote",
"get-url",
"--push",
"--all",
plan.remote,
]);
if (
normalizeGitHubRemoteUrl(fetchUrl) !== plan.repoUrl ||
pushUrl !== plan.pushUrl
)
throw new Error(
"The remote changed after confirmation. Run /cloud again to review the destination.",
);
}
export async function verifyPublishedHandoffGit(
plan: HandoffGitPlan,
headSha: string,
): Promise<void> {
await assertPublicationDestination(plan);
const published = await git(plan.root, [
"ls-remote",
"--heads",
plan.remote,
`refs/heads/${plan.branch}`,
]);
if (published.split(/\s/)[0] !== headSha)
throw new Error(
"The published branch changed during preparation. Run /cloud again.",
);
}
export async function applyHandoffGit(plan: HandoffGitPlan): Promise<string> {
await assertPublicationDestination(plan);
const hooksPath = await git(plan.root, [
"rev-parse",
"--path-format=absolute",
"--git-path",
"hooks",
]);
if (
(
await git(plan.root, [
@@ -292,17 +327,29 @@ export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
);
}
let pushing = false;
const temporary = await mkdtemp(join(tmpdir(), "cline-handoff-checkout-"));
const checkout = join(temporary, "checkout");
let added = false;
try {
await git(plan.root, ["switch", "-c", plan.branch]);
await git(plan.root, ["read-tree", plan.treeSha]);
if (plan.treeSha !== (await git(plan.root, ["rev-parse", "HEAD^{tree}"]))) {
await git(plan.root, ["commit", "-m", "Cline cloud handoff checkpoint"], {
await git(plan.root, [
"worktree",
"add",
"-b",
plan.branch,
checkout,
plan.headSha,
]);
added = true;
await git(checkout, ["read-tree", "--reset", "-u", plan.treeSha]);
if (plan.treeSha !== (await git(checkout, ["rev-parse", "HEAD^{tree}"]))) {
await git(checkout, ["commit", "-m", "Cline cloud handoff checkpoint"], {
timeout: 10 * 60_000,
hooksPath,
});
}
if (
(await git(plan.root, ["rev-parse", "HEAD^{tree}"])) !== plan.treeSha ||
(await git(plan.root, [
(await git(checkout, ["rev-parse", "HEAD^{tree}"])) !== plan.treeSha ||
(await git(checkout, [
"status",
"--porcelain=v1",
"--untracked-files=all",
@@ -313,12 +360,12 @@ export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
"Files changed during preparation; nothing was pushed. Review the working tree before retrying.",
);
}
const commit = await git(plan.root, ["rev-parse", "HEAD"]);
const commit = await git(checkout, ["rev-parse", "HEAD"]);
if (
(await git(plan.root, ["symbolic-ref", "--short", "HEAD"])) !==
(await git(checkout, ["symbolic-ref", "--short", "HEAD"])) !==
plan.branch ||
(commit !== plan.headSha &&
(await git(plan.root, ["rev-list", "--parents", "-n", "1", commit])) !==
(await git(checkout, ["rev-list", "--parents", "-n", "1", commit])) !==
`${commit} ${plan.headSha}`)
) {
throw new Error(
@@ -328,7 +375,7 @@ export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
// Publish only the approved commit, never a moving HEAD or configured push refspecs.
pushing = true;
await git(
plan.root,
checkout,
[
"-c",
"push.followTags=false",
@@ -338,7 +385,7 @@ export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
plan.pushUrl,
`${commit}:refs/heads/${plan.branch}`,
],
{ timeout: 10 * 60_000 },
{ timeout: 10 * 60_000, hooksPath },
);
pushing = false;
await git(plan.root, [
@@ -351,6 +398,7 @@ export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
`branch.${plan.branch}.merge`,
`refs/heads/${plan.branch}`,
]);
return commit;
} catch (error) {
if (pushing) {
throw new Error(
@@ -358,7 +406,11 @@ export async function applyHandoffGit(plan: HandoffGitPlan): Promise<void> {
);
}
throw new Error(
`${error instanceof Error ? error.message : String(error)} Preparation may have left a checkpoint on ${plan.branch}; your files were not rolled back. Inspect that branch before retrying.`,
`${error instanceof Error ? error.message : String(error)} Your local checkout was not changed. A checkpoint may remain on ${plan.branch}.`,
);
} finally {
if (added)
await git(plan.root, ["worktree", "remove", "--force", checkout]);
await rm(temporary, { recursive: true, force: true });
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,16 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { CloudCreationOptions } from "@cline/core/cloud";
import type { HubEventEnvelope } from "@cline/shared";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { handleChatSessionCommand } from "./chat-session";
import {
readCloudHandoffFollowUp,
saveCloudHandoffFollowUp,
sendWithCloudHandoffFollowUp,
updateCloudHandoffFollowUp,
} from "./cloud-handoff-follow-up";
import {
type CloudSessionApi,
CloudSessionError,
@@ -524,6 +534,151 @@ describe("Cloud sessions sidecar wiring", () => {
await established.dispose();
});
it.each([
"initial",
"old draft",
"no draft",
"unconfirmed",
])("recovers only a saved unsent initial task after a process restart (%s)", async (kind) => {
const dataDir = mkdtempSync(join(tmpdir(), "cloud-initial-recovery-"));
vi.stubEnv("CLINE_DATA_DIR", dataDir);
const initialTask: CloudCreationOptions = {
mode: "plan",
workspaceRelativePath: "packages/app",
autoApproveTools: false,
thinking: false,
reasoningEffort: "high",
};
const saved = {
draftId: "initial-draft",
sourceSessionId: "local-source",
command: "Goal: fix parser. Next: verify empty input.",
userImages: ["data:image/png;base64,aW1hZ2U="],
...(kind === "initial" || kind === "unconfirmed" ? { initialTask } : {}),
};
const api = {
list: async () => [structuredClone(REMOTE_SESSION)],
create: async () => ({
sessionId: REMOTE_SESSION.id,
status: "ready",
sandboxUrl: "pod",
}),
} as unknown as CloudSessionApi;
const original = createFixture({ api, hub: new FakeHubClient(false) });
let replacement: ReturnType<typeof createFixture> | undefined;
try {
await original.manager.create({
repoUrl: "https://github.com/cline/test",
modelId: "model",
...initialTask,
});
if (kind !== "no draft")
saveCloudHandoffFollowUp(REMOTE_SESSION.id, saved);
if (kind === "unconfirmed") {
await expect(
sendWithCloudHandoffFollowUp(
REMOTE_SESSION.id,
saved.command,
saved.userImages,
async (lifecycle) => {
lifecycle?.beforeDispatch?.();
throw new Error("delivery uncertain");
},
),
).rejects.toThrow("delivery uncertain");
expect(readCloudHandoffFollowUp(REMOTE_SESSION.id)).not.toHaveProperty(
"initialTask",
);
}
await disposeSidecarContext(original.ctx);
replacement = createFixture({ api, hub: new FakeHubClient(false) });
const { ctx, manager, hub } = replacement;
if (kind !== "initial") {
await expect(manager.attach(REMOTE_SESSION.id)).rejects.toThrow(
"task is unavailable",
);
await expect(
manager.send(REMOTE_SESSION.id, saved.command),
).rejects.toThrow("task is unavailable");
expect(
hub.commands.filter(({ command }) => command === "session.create"),
).toEqual([]);
if (kind === "unconfirmed") {
const uncertain = readCloudHandoffFollowUp(REMOTE_SESSION.id);
if (!uncertain) throw new Error("Missing uncertain draft");
updateCloudHandoffFollowUp(REMOTE_SESSION.id, uncertain, "restore");
await expect(manager.attach(REMOTE_SESSION.id)).rejects.toThrow(
"task is unavailable",
);
}
return;
}
await manager.attach(REMOTE_SESSION.id);
expect(
hub.commands.filter(({ command }) => command === "session.create"),
).toEqual([]);
const state = ctx.liveSessions.get(REMOTE_SESSION.id);
if (!state) throw new Error("Cloud session was not projected");
Object.assign(state.config, {
autoApproveTools: true,
thinking: true,
reasoningEffort: "low",
});
await sendWithCloudHandoffFollowUp(
REMOTE_SESSION.id,
saved.command,
saved.userImages,
(lifecycle) =>
manager.send(
REMOTE_SESSION.id,
saved.command,
undefined,
undefined,
saved.userImages,
lifecycle,
),
);
expect(
hub.commands.filter(({ command }) => command === "session.create"),
).toEqual([
expect.objectContaining({
payload: expect.objectContaining({
cwd: "/workspace/packages/app",
sessionConfig: expect.objectContaining({
mode: "plan",
thinking: false,
reasoningEffort: "high",
}),
toolPolicies: { "*": { autoApprove: false } },
}),
}),
]);
expect(
hub.commands.filter(({ command }) => command === "session.send_input"),
).toHaveLength(1);
expect(readCloudHandoffFollowUp(REMOTE_SESSION.id)).toBeNull();
await resetCloudSessionManager(ctx);
const established = new CloudSessionManager(ctx, {
api,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "synthetic-token",
createHubClient: () => hub as never,
});
ctx.cloudSessionManager = established;
await expect(established.attach(REMOTE_SESSION.id)).rejects.toThrow(
"task is unavailable",
);
expect(
hub.commands.filter(({ command }) => command === "session.create"),
).toHaveLength(1);
} finally {
if (replacement) await disposeSidecarContext(replacement.ctx);
await disposeSidecarContext(original.ctx);
vi.unstubAllEnvs();
rmSync(dataDir, { recursive: true, force: true });
}
});
it("creates a canonical session with the requested branch and approval policy", async () => {
const create = vi.fn(async () => ({
sessionId: "ses-created",
@@ -10,6 +10,7 @@ import {
} from "@cline/core/cloud";
import { getClineEnvironmentConfig } from "@cline/shared";
import { resolveFreshClineAuthToken } from "./cline-auth";
import { readCloudHandoffFollowUp } from "./cloud-handoff-follow-up";
import {
getEnvironmentContext,
getSidecarContextOwner,
@@ -33,6 +34,7 @@ export class CloudSessionManager extends CloudSessionController {
private readonly ownedIds = new Set<string>();
private readonly approvalSnapshots = new Map<string, string>();
private readonly ctx: SidecarContext;
private readonly initialTaskOptions: Map<string, CloudCreationOptions>;
constructor(ctx: SidecarContext, options: CloudSessionControllerOptions) {
const owner = getSidecarContextOwner(ctx);
let pendingInitialTasks = pendingInitialTasksByContext.get(owner);
@@ -52,6 +54,7 @@ export class CloudSessionManager extends CloudSessionController {
source: "desktop",
},
});
this.initialTaskOptions = pendingInitialTasks;
this.ctx = getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID);
for (const [id, state] of ctx.liveSessions)
if (state.config.executionTarget === "cloud")
@@ -64,6 +67,20 @@ export class CloudSessionManager extends CloudSessionController {
sandboxType: input.sandboxType ?? "resumable",
});
}
private restorePendingInitialTask(
id: string,
): CloudCreationOptions | undefined {
const saved = readCloudHandoffFollowUp(id);
if (!saved?.initialTask || saved.unconfirmed) return undefined;
this.initialTaskOptions.set(id, saved.initialTask);
return saved.initialTask;
}
override async attach(id: string, creationOptions?: CloudCreationOptions) {
return await super.attach(
id,
this.restorePendingInitialTask(id) ?? creationOptions,
);
}
override async listForDiscovery(
options: { timeoutMs?: number } = {},
): Promise<Record<string, unknown>[]> {
@@ -181,9 +198,11 @@ export class CloudSessionManager extends CloudSessionController {
images?: string[],
lifecycle?: CloudSendLifecycle,
) {
// Existing desktop commands update this local preference before lazy inner creation.
// A saved initial task owns its settings even if the viewer has stale defaults.
const initialTask = this.restorePendingInitialTask(id);
const config = this.ctx.liveSessions.get(id)?.config;
if (config)
if (initialTask) this.restoreCreationOptions(id, initialTask);
else if (config)
this.restoreCreationOptions(id, {
...(typeof config.autoApproveTools === "boolean"
? { autoApproveTools: config.autoApproveTools }
@@ -201,6 +220,7 @@ export class CloudSessionManager extends CloudSessionController {
try {
return await super.send(id, prompt, delivery, modelId, images, lifecycle);
} finally {
if (initialTask) this.initialTaskOptions.delete(id);
const snapshot = this.getSnapshot(id);
if (snapshot)
this.project({
@@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getCloudSessionManager } from "./cloud-sessions";
import { handleCommand } from "./commands";
import { createSidecarContext } from "./context";
import { setCloudSessionsEnabled } from "./desktop-settings";
import {
getDesktopFeatureFlagsService,
resetDesktopFeatureFlagsForTesting,
@@ -79,7 +78,7 @@ describe("desktop settings commands", () => {
"list_chat_sessions",
"list_discovered_sessions",
"search_sessions",
])("hides cloud rows from %s unless rollout and user toggle are enabled", async (command) => {
])("hides cloud rows from %s unless the rollout flag is enabled", async (command) => {
const { ctx } = createContext();
for (const target of ["local", "cloud"] as const) {
ctx.liveSessions.set(target, {
@@ -113,16 +112,13 @@ describe("desktop settings commands", () => {
.map((row) => row.sessionId)
.sort();
expect(await rows()).toEqual(["local"]);
expect(discovery).not.toHaveBeenCalled();
setCloudSessionsEnabled(true);
expect(await rows()).toEqual(["cloud", "local"]);
discovery.mockImplementationOnce(async () => {
setCloudSessionsEnabled(false);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
return cloudRows;
});
expect(await rows()).toEqual(["local"]);
setCloudSessionsEnabled(true);
delete process.env.CLINE_CODE_CLOUD_AGENTS;
discovery.mockClear();
flags.hydrateCache({
userId: null,
@@ -134,61 +130,36 @@ describe("desktop settings commands", () => {
expect(ctx.liveSessions.has("cloud")).toBe(true);
});
it("rejects a non-boolean cloud sessions toggle value", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: "yes",
}),
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
expect(events).toEqual([]);
});
it.each([
true,
false,
])("persists toggle %s and refreshes cloud history immediately", async (enabled) => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: enabled,
}),
).resolves.toEqual({ cloudSessionsEnabled: enabled });
expect(events).toEqual([
{ name: "cloud_sessions_changed", payload: { environmentId: "local" } },
{
name: "feature_flags_changed",
payload: {
cloudAgents: false,
cloudAgentsAvailable: false,
environmentId: "local",
},
},
]);
await expect(
handleCommand(ctx, "get_feature_flags", {}),
).resolves.toMatchObject({ cloudAgents: false });
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: enabled });
});
it("reports the env override through the feature gate", async () => {
"0",
"1",
])("keeps cloud-only models out of the local picker with cloud flag %s", async (enabled) => {
const { ctx } = createContext();
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
await expect(
handleCommand(ctx, "get_feature_flags", {}),
).resolves.toMatchObject({
cloudAgents: true,
cloudAgentsAvailable: true,
const fetchMock = vi.fn(async (input: string | URL | Request) => {
if (String(input) === "https://models.dev/api.json") {
return new Response(JSON.stringify({}), { status: 200 });
}
return new Response(
JSON.stringify({
clineCloud: [
{
id: "cline-cloud/cloud-only",
name: "Cloud Only",
},
],
}),
{ status: 200 },
);
});
// The toggle's stored value is reported as-is; the override only
// affects the effective gate.
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: false });
vi.stubGlobal("fetch", fetchMock);
process.env.CLINE_CODE_CLOUD_AGENTS = enabled;
const local = (await handleCommand(ctx, "list_provider_models", {
provider: "cline",
includeCloudModels: true,
})) as { models: Array<{ id: string }> };
expect(
local.models.some((model) => model.id.startsWith("cline-cloud/")),
).toBe(false);
});
});
+145 -199
View File
@@ -92,10 +92,9 @@ import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-acc
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
import { resolveDesktopTelemetryUser } from "./client-context";
import { resolveFreshClineAuthToken } from "./cline-auth";
import { isCloudHandoffFollowUpBlocked } from "./cloud-handoff";
import {
clearCloudHandoffFollowUp,
readCloudHandoffFollowUp,
getRecoverableCloudHandoffFollowUp,
updateCloudHandoffFollowUp,
} from "./cloud-handoff-follow-up";
import {
@@ -132,14 +131,9 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
readDesktopSettings,
setCloudSessionsEnabled,
} from "./desktop-settings";
import { writeDiagnosticsReport } from "./diagnostics";
import {
identifyDesktopFeatureFlagsAccount,
isCloudAgentsAvailable,
isCloudAgentsEnabled,
refreshDesktopFeatureFlags,
} from "./feature-flags";
@@ -2220,8 +2214,6 @@ export async function handleCommand(
};
}
if (command === "get_feature_flags") {
// Resolve flags in the sidecar: available shows the opt-in;
// enabled requires both the rollout flag and the user's opt-in.
const snapshot = await refreshDesktopFeatureFlags({
logger: ctx.logger,
telemetry: ctx.telemetry,
@@ -2232,10 +2224,6 @@ export async function handleCommand(
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
cloudAgentsAvailable: isCloudAgentsAvailable({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
};
}
if (command === "list_cloud_models") {
@@ -2497,17 +2485,8 @@ export async function handleCommand(
) {
const sessionId = String(args?.sessionId ?? "").trim();
if (!sessionId) throw new Error("session id is required");
const saved = readCloudHandoffFollowUp(sessionId);
if (
saved &&
(await isCloudHandoffFollowUpBlocked(ctx, saved.sourceSessionId))
) {
if (command === "get_cloud_handoff_follow_up") return null;
throw new Error(
"Wait for the cloud handoff to finish. Retry /cloud from the source session if it failed.",
);
}
if (command === "get_cloud_handoff_follow_up") return saved;
if (command === "get_cloud_handoff_follow_up")
return getRecoverableCloudHandoffFollowUp(sessionId);
return updateCloudHandoffFollowUp(
sessionId,
args?.expected as Parameters<typeof updateCloudHandoffFollowUp>[1],
@@ -2622,50 +2601,44 @@ export async function handleCommand(
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
throw new Error("metadata patch is required");
}
const { beginSessionMetadataUpdate } = await import("./cloud-handoff");
const releaseMetadataUpdate = beginSessionMetadataUpdate(ctx, sessionId);
try {
// updateSession replaces metadata wholesale in both the session row and
// the manifest, so merge over what each already holds. A null value
// removes the key, which is how callers clear a flag.
const store = new SqliteSessionStore();
const asRecord = (value: unknown): JsonRecord =>
value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: {};
const binding = await getCommandSessionBinding(ctx, sessionId, args);
if (!binding) throw new Error(`Session ${sessionId} not found`);
const existingSession = await binding.sessionManager.get(sessionId);
const existing =
binding.kind === "local" ? store.get(sessionId) : undefined;
const merged: JsonRecord = {
...(binding.kind === "local"
? asRecord(readSessionManifest(sessionId)?.metadata)
: {}),
...asRecord(existingSession?.metadata),
...asRecord(existing?.metadata),
};
for (const [key, value] of Object.entries(patch as JsonRecord)) {
if (value === null) delete merged[key];
else merged[key] = value;
}
const result = await binding.sessionManager.update(sessionId, {
metadata: merged,
});
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
// Annotating a session is not session activity. updateSession stamps
// updated_at, which clients sort and label rows by, so a pin would
// otherwise make an old session look like it just ran.
if (binding.kind === "local" && existing?.updatedAt) {
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
existing.updatedAt,
sessionId,
]);
}
return merged;
} finally {
releaseMetadataUpdate();
// updateSession replaces metadata wholesale in both the session row and
// the manifest, so merge over what each already holds. A null value
// removes the key, which is how callers clear a flag.
const store = new SqliteSessionStore();
const asRecord = (value: unknown): JsonRecord =>
value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: {};
const binding = await getCommandSessionBinding(ctx, sessionId, args);
if (!binding) throw new Error(`Session ${sessionId} not found`);
const existingSession = await binding.sessionManager.get(sessionId);
const existing =
binding.kind === "local" ? store.get(sessionId) : undefined;
const merged: JsonRecord = {
...(binding.kind === "local"
? asRecord(readSessionManifest(sessionId)?.metadata)
: {}),
...asRecord(existingSession?.metadata),
...asRecord(existing?.metadata),
};
for (const [key, value] of Object.entries(patch as JsonRecord)) {
if (value === null) delete merged[key];
else merged[key] = value;
}
const result = await binding.sessionManager.update(sessionId, {
metadata: merged,
});
if (!result.updated) throw new Error(`Session ${sessionId} not found`);
// Annotating a session is not session activity. updateSession stamps
// updated_at, which clients sort and label rows by, so a pin would
// otherwise make an old session look like it just ran.
if (binding.kind === "local" && existing?.updatedAt) {
store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
existing.updatedAt,
sessionId,
]);
}
return merged;
}
if (command === "delete_chat_session" || command === "delete_cli_session") {
const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim();
@@ -2683,139 +2656,127 @@ export async function handleCommand(
}
return true;
}
const { assertSessionDeleteAllowedDuringHandoff } = await import(
"./cloud-handoff"
);
const binding = await getCommandSessionBinding(ctx, sessionId, args);
ctx = getEnvironmentContext(
ctx,
binding?.environmentId ?? LOCAL_ENVIRONMENT_ID,
);
const releaseDelete = await assertSessionDeleteAllowedDuringHandoff(
ctx,
sessionId,
binding ?? getRuntimeBinding(ctx),
);
ctx.logger?.log("Deleting desktop chat session", { command, sessionId });
const store = new SqliteSessionStore();
const row = store.get(sessionId);
const manifest = readSessionManifest(sessionId);
const sessionCwd =
row?.cwd?.trim() ||
(typeof manifest?.cwd === "string" ? manifest.cwd.trim() : "");
let deleted = false;
let deleteError: Error | null = null;
try {
ctx.logger?.log("Deleting desktop chat session", { command, sessionId });
const store = new SqliteSessionStore();
const row = store.get(sessionId);
const manifest = readSessionManifest(sessionId);
const sessionCwd =
row?.cwd?.trim() ||
(typeof manifest?.cwd === "string" ? manifest.cwd.trim() : "");
let deleted = false;
let deleteError: Error | null = null;
try {
if (binding) {
deleted = await binding.sessionManager.delete(sessionId);
} else {
const backend = await resolveSessionBackend({ backendMode: "local" });
const deleteSession = (
backend as {
deleteSession: (
sessionId: string,
cascade?: boolean,
) => Promise<boolean | { deleted: boolean }>;
}
).deleteSession.bind(backend);
const deleteResult = await deleteSession(sessionId, true);
deleted =
typeof deleteResult === "boolean"
? deleteResult
: deleteResult.deleted;
}
} catch (error) {
deleteError = error instanceof Error ? error : new Error(String(error));
if (binding) {
deleted = await binding.sessionManager.delete(sessionId);
} else {
const backend = await resolveSessionBackend({ backendMode: "local" });
const deleteSession = (
backend as {
deleteSession: (
sessionId: string,
cascade?: boolean,
) => Promise<boolean | { deleted: boolean }>;
}
).deleteSession.bind(backend);
const deleteResult = await deleteSession(sessionId, true);
deleted =
typeof deleteResult === "boolean"
? deleteResult
: deleteResult.deleted;
}
if (binding?.kind !== "ssh" && store.delete(sessionId, true)) {
deleted = true;
}
ctx.liveSessions.delete(sessionId);
ctx.sessionEnvironmentIds.delete(sessionId);
if (binding?.kind === "ssh") {
if (!deleted && deleteError) throw deleteError;
if (deleted) {
broadcastEvent(ctx, "session_deleted", {
sessionId,
command,
deleted: true,
});
}
return deleted;
}
const directoryCandidates = new Set<string>([
join(sharedSessionDataDir(), sessionId),
]);
for (const path of [
row?.messagesPath,
typeof manifest?.messages_path === "string"
? manifest.messages_path
: null,
]) {
if (typeof path === "string" && path.trim().length > 0) {
directoryCandidates.add(dirname(path));
}
}
for (const path of [sessionLogPath(sessionId)]) {
if (removePathIfExists(path, { recursive: true })) deleted = true;
}
for (const dir of directoryCandidates) {
if (removePathIfExists(dir, { recursive: true })) deleted = true;
}
for (const path of [
row?.messagesPath,
typeof manifest?.messages_path === "string"
? manifest.messages_path
: null,
join(sharedSessionDataDir(), sessionId, `${sessionId}.json`),
].filter((v): v is string => typeof v === "string" && v.length > 0)) {
if (removePathIfExists(path)) deleted = true;
}
for (const suffix of ["messages.json"]) {
const found = findArtifactUnderDir(
join(sharedSessionDataDir(), rootSessionIdFrom(sessionId)),
`${sessionId}.${suffix}`,
4,
);
if (found && removePathIfExists(found)) deleted = true;
}
if (!deleted && deleteError) {
ctx.logger?.error?.("Failed to delete desktop chat session", {
sessionId,
error: deleteError,
});
throw deleteError;
}
ctx.logger?.log("Desktop chat session delete completed", {
sessionId,
deleted,
});
// A task worktree goes with its task, unless another session still
// lives in (or under) it, e.g. a second thread started while it was
// the workspace. Only the exact `<home>/<id>/<repo>` shape qualifies,
// since removal also deletes the `<id>` parent directory.
const removedWorktree =
deleted &&
isTaskWorktreePath(sessionCwd) &&
!store.list(10_000).some((other) => {
const cwd = other.cwd?.trim() ?? "";
return cwd === sessionCwd || cwd.startsWith(sessionCwd + sep);
})
? await removeTaskWorktree(ctx, sessionCwd)
: undefined;
} catch (error) {
deleteError = error instanceof Error ? error : new Error(String(error));
}
if (binding?.kind !== "ssh" && store.delete(sessionId, true)) {
deleted = true;
}
ctx.liveSessions.delete(sessionId);
ctx.sessionEnvironmentIds.delete(sessionId);
if (binding?.kind === "ssh") {
if (!deleted && deleteError) throw deleteError;
if (deleted) {
broadcastEvent(ctx, "session_deleted", {
sessionId,
command,
deleted: true,
removedWorktree,
});
}
return deleted;
} finally {
releaseDelete();
}
const directoryCandidates = new Set<string>([
join(sharedSessionDataDir(), sessionId),
]);
for (const path of [
row?.messagesPath,
typeof manifest?.messages_path === "string"
? manifest.messages_path
: null,
]) {
if (typeof path === "string" && path.trim().length > 0) {
directoryCandidates.add(dirname(path));
}
}
for (const path of [sessionLogPath(sessionId)]) {
if (removePathIfExists(path, { recursive: true })) deleted = true;
}
for (const dir of directoryCandidates) {
if (removePathIfExists(dir, { recursive: true })) deleted = true;
}
for (const path of [
row?.messagesPath,
typeof manifest?.messages_path === "string"
? manifest.messages_path
: null,
join(sharedSessionDataDir(), sessionId, `${sessionId}.json`),
].filter((v): v is string => typeof v === "string" && v.length > 0)) {
if (removePathIfExists(path)) deleted = true;
}
for (const suffix of ["messages.json"]) {
const found = findArtifactUnderDir(
join(sharedSessionDataDir(), rootSessionIdFrom(sessionId)),
`${sessionId}.${suffix}`,
4,
);
if (found && removePathIfExists(found)) deleted = true;
}
if (!deleted && deleteError) {
ctx.logger?.error?.("Failed to delete desktop chat session", {
sessionId,
error: deleteError,
});
throw deleteError;
}
ctx.logger?.log("Desktop chat session delete completed", {
sessionId,
deleted,
});
// A task worktree goes with its task, unless another session still
// lives in (or under) it, e.g. a second thread started while it was
// the workspace. Only the exact `<home>/<id>/<repo>` shape qualifies,
// since removal also deletes the `<id>` parent directory.
const removedWorktree =
deleted &&
isTaskWorktreePath(sessionCwd) &&
!store.list(10_000).some((other) => {
const cwd = other.cwd?.trim() ?? "";
return cwd === sessionCwd || cwd.startsWith(sessionCwd + sep);
})
? await removeTaskWorktree(ctx, sessionCwd)
: undefined;
if (deleted) {
broadcastEvent(ctx, "session_deleted", {
sessionId,
command,
deleted: true,
removedWorktree,
});
}
return deleted;
}
// ── Workspace file search ─────────────────────────────────────────
@@ -3276,9 +3237,6 @@ export async function handleCommand(
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
if (command === "get_desktop_settings") {
return readDesktopSettings();
}
if (command === "export_diagnostics") {
const sessionIds = Array.isArray(args?.sessionIds)
? args.sessionIds.filter(
@@ -3289,18 +3247,6 @@ export async function handleCommand(
openFileInEditor(dirname(result.path));
return result;
}
if (command === "set_cloud_sessions_enabled") {
if (typeof args?.cloud_sessions_enabled !== "boolean") {
throw new Error("cloud_sessions_enabled must be a boolean");
}
const settings = setCloudSessionsEnabled(args.cloud_sessions_enabled);
broadcastEvent(ctx, "cloud_sessions_changed", {});
broadcastEvent(ctx, "feature_flags_changed", {
cloudAgents: isCloudAgentsEnabled(),
cloudAgentsAvailable: isCloudAgentsAvailable(),
});
return settings;
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
@@ -1,62 +0,0 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
readDesktopSettings,
resolveDesktopSettingsPath,
setCloudSessionsEnabled,
} from "./desktop-settings";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings", () => {
it("defaults cloud sessions to off when no settings file exists", () => {
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("persists the cloud sessions opt-in and reads it back", () => {
expect(setCloudSessionsEnabled(true)).toEqual({
cloudSessionsEnabled: true,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
true,
);
expect(
JSON.parse(readFileSync(resolveDesktopSettingsPath(), "utf8")),
).toMatchObject({ cloudSessionsEnabled: true });
expect(setCloudSessionsEnabled(false)).toEqual({
cloudSessionsEnabled: false,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("treats malformed files and non-boolean values as off", () => {
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
writeFileSync(
resolveDesktopSettingsPath(),
JSON.stringify({ cloudSessionsEnabled: "yes" }),
"utf8",
);
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
});
@@ -1,49 +0,0 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/** Desktop-only preferences kept separate from strict shared global settings. */
export type DesktopSettings = {
/** Opt-in gate for cloud sessions while the feature is in preview. */
cloudSessionsEnabled: boolean;
};
const DEFAULT_SETTINGS: DesktopSettings = {
cloudSessionsEnabled: false,
};
export function resolveDesktopSettingsPath(): string {
return join(resolveClineDataDir(), "settings", "code-settings.json");
}
export function readDesktopSettings(): DesktopSettings {
let raw: string;
try {
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
} catch {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
};
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export function writeDesktopSettings(settings: DesktopSettings): void {
const filePath = resolveDesktopSettingsPath();
mkdirSync(dirname(filePath), { recursive: true });
// Avoid leaving torn settings if the process exits mid-write.
const tempPath = `${filePath}.${process.pid}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
renameSync(tempPath, filePath);
}
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
writeDesktopSettings(next);
return next;
}
@@ -11,7 +11,6 @@ import { arch, homedir, platform, release } from "node:os";
import { join } from "node:path";
import { readGlobalSettings, resolveClineDataDir } from "@cline/core";
import packageJson from "../package.json";
import { readDesktopSettings } from "./desktop-settings";
import { readSessionManifest } from "./paths";
/** How much of each log file (from the end) is included in the report. */
@@ -88,7 +87,6 @@ export function buildDiagnosticsReport(
},
dataDir,
globalSettings: readGlobalSettings(),
desktopSettings: readDesktopSettings(),
};
let text = `${JSON.stringify(report, null, 2)}\n`;
@@ -337,45 +337,32 @@ describe("cloud agents gate", () => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
});
it("is unavailable and disabled while the rollout flag is off", async () => {
const { isCloudAgentsAvailable, isCloudAgentsEnabled } = await import(
"./feature-flags"
);
expect(isCloudAgentsAvailable()).toBe(false);
it("is disabled while the rollout flag is off", async () => {
const { isCloudAgentsEnabled } = await import("./feature-flags");
expect(isCloudAgentsEnabled()).toBe(false);
});
it("does not enable a boolean rollout from a truthy variant payload", async () => {
const { isCloudAgentsAvailable } = await import("./feature-flags");
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue("control");
expect(isCloudAgentsAvailable()).toBe(false);
expect(isCloudAgentsEnabled()).toBe(false);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith(
"code-cloud-agents",
);
});
it("needs both the rollout flag and the user's opt-in to enable", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { setCloudSessionsEnabled } = await import("./desktop-settings");
it("is enabled by the rollout flag alone", async () => {
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getBooleanFlagEnabled.mockImplementation(
(flag: unknown) => flag === "code-cloud-agents",
);
expect(isCloudAgentsAvailable()).toBe(true);
setCloudSessionsEnabled(false);
expect(isCloudAgentsEnabled()).toBe(false);
setCloudSessionsEnabled(true);
expect(isCloudAgentsEnabled()).toBe(true);
});
it("lets the env override force the gate in both directions", async () => {
const { isCloudAgentsEnabled, isCloudAgentsAvailable } = await import(
"./feature-flags"
);
const { isCloudAgentsEnabled } = await import("./feature-flags");
mocks.getFlagPayload.mockReturnValue(undefined);
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
expect(isCloudAgentsAvailable()).toBe(true);
expect(isCloudAgentsEnabled()).toBe(true);
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
expect(isCloudAgentsEnabled()).toBe(false);
@@ -22,7 +22,6 @@ import {
} from "@cline/core/services/feature-flags/posthog";
import { FeatureFlag as SharedFeatureFlag } from "@cline/shared";
import { resolveClineDataDir } from "@cline/shared/storage";
import { readDesktopSettings } from "./desktop-settings";
const FEATURE_FLAG_CODE_CLOUD_AGENTS = SharedFeatureFlag.CODE_CLOUD_AGENTS;
@@ -282,8 +281,8 @@ export function readCloudAgentsEnvOverride(): boolean | undefined {
return undefined;
}
/** Whether the rollout makes cloud sessions available to this install. */
export function isCloudAgentsAvailable(options?: {
/** Whether the rollout enables cloud sessions for this install. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
@@ -293,14 +292,3 @@ export function isCloudAgentsAvailable(options?: {
FEATURE_FLAG_CODE_CLOUD_AGENTS,
);
}
/** Whether cloud sessions are both available and enabled by the user. */
export function isCloudAgentsEnabled(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): boolean {
const override = readCloudAgentsEnvOverride();
if (override !== undefined) return override;
if (!isCloudAgentsAvailable(options)) return false;
return readDesktopSettings().cloudSessionsEnabled;
}
@@ -2,7 +2,6 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
import { writeDesktopSettings } from "./desktop-settings";
import type { SidecarContext } from "./types";
export function localRuntimeContext(
@@ -55,7 +54,6 @@ export function enableCloudHandoffGates(): void {
handoffOptInDataDirs.push(dataDir);
process.env.CLINE_DATA_DIR = dataDir;
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
writeDesktopSettings({ cloudSessionsEnabled: true });
}
export function cleanupCloudHandoffGates(): void {
+1 -3
View File
@@ -46,13 +46,11 @@ export type ChatSessionCommandRequest = {
delivery?: "queue" | "steer";
config?: JsonRecord;
attachments?: ChatTurnAttachments;
/** Opaque preflight result returned by prepare_handoff and revalidated by handoff. */
fingerprint?: JsonRecord;
/** Opaque webview correlation token echoed on handoff progress events. */
handoffAttemptId?: string;
handoffFollowUpId?: string;
gitPreparationId?: string;
/** Optional first prompt to queue after ownership moves to the cloud session. */
/** Optional instruction appended to the locally generated summary. */
nextCommand?: string;
};
@@ -545,7 +545,13 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
resolve_desktop_backend_bundle_path(context),
) {
let mut command = Command::new(cli_path);
command.arg("run").arg("--no-env-file").arg(&bundle_path);
// Under BUN_BE_BUN the CLI's baked-in --use-system-ca does not apply,
// so pass it again or the backend only trusts Bun's bundled roots.
command
.arg("run")
.arg("--use-system-ca")
.arg("--no-env-file")
.arg(&bundle_path);
// Run from the bundle's own directory so the runtime cannot pick up
// a workspace .env or bunfig.toml; the backend moves to the
// workspace root itself (CLINE_DESKTOP_WORKSPACE_ROOT).
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.46",
"version": "0.0.48",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:runtime && bun run dev:web",
+112 -290
View File
@@ -41,9 +41,8 @@ import { ChatInputBar } from "@/components/views/chat/chat-input-bar";
import { ChatMessages } from "@/components/views/chat/chat-messages";
import {
CloudHandoffGitConfirmation,
CloudHandoffProgress,
CloudHandoffReceipt,
CloudHandoffRecoveryNotice,
CloudHandoffStatus,
} from "@/components/views/chat/cloud-handoff";
import { EnvironmentSelector } from "@/components/views/chat/environment-selector";
import { RemoteDirectoryPicker } from "@/components/views/chat/remote-directory-picker";
@@ -86,12 +85,9 @@ import {
type HandoffResult,
isExpectedHandoffSourceActive,
parseHandoffCommand,
readHandoffReceipt,
readPendingHandoffRecovery,
validateHandoffAttachments,
} from "@/lib/cloud-handoff";
import {
matchesCloudHandoffFollowUp,
openWithCloudHandoffFollowUp,
restoreCloudHandoffFollowUp,
shouldPreserveCloudComposer,
@@ -101,16 +97,10 @@ import {
type HandoffLifecycle,
type HandoffOpenResult,
} from "@/lib/cloud-handoff-lifecycle";
import {
type HandoffPreparation,
prepareHandoffWithGit,
} from "@/lib/cloud-handoff-preparation";
import {
type CloudHandoffUiAction,
type CloudHandoffUiState,
cloudHandoffUiReducer,
hasLivePendingHandoff,
resolveHandoffReceipt,
} from "@/lib/cloud-handoff-ui-state";
import { cloudRepositoryLabel } from "@/lib/cloud-repositories";
import {
@@ -281,9 +271,8 @@ function readCloudProvisioningPhase(
: undefined;
}
// create() can spend one 610s window on the original POST and another on
// timeout recovery. Leave room for auth, Hub attach, seeding, and verification
// without waiting forever for a lost transport response.
// Creation and timeout recovery can each take 610s. Allow time for summary
// generation, authentication, Hub attachment, and prompt delivery too.
const HANDOFF_INVOKE_TIMEOUT_MS = 25 * 60_000;
function useCloudProvisioningPhase(
@@ -748,24 +737,7 @@ export default function Home() {
): Promise<HandoffOpenResult | undefined> => {
const revision = ++sessionOpenRevision.current;
const location = activeLocationRef.current;
let draftDelivered = false;
const open = (draft?: string, attachments?: File[], draftId?: string) => {
const targetThread = threads.find(
(thread) =>
thread.environmentId === session.environmentId &&
(thread.sessionId === session.sessionId ||
thread.historySession?.sessionId === session.sessionId),
);
const targetDraft = targetThread && promptDrafts.get(targetThread.id);
draftDelivered =
session.origin !== "cloud" ||
(draft === undefined && attachments === undefined) ||
!shouldPreserveCloudComposer(
targetDraft?.text ?? "",
targetDraft?.attachments.length ?? 0,
targetDraft?.lastRestoredFollowUpId,
draftId,
);
dispatchApp({
type: "open-session",
session,
@@ -777,7 +749,7 @@ export default function Home() {
};
if (session.origin !== "cloud") {
open(initialPromptDraft, initialAttachments);
return { opened: true, draftDelivered };
return { opened: true };
}
try {
const opened = await openWithCloudHandoffFollowUp({
@@ -793,36 +765,18 @@ export default function Home() {
activeLocationRef.current.view,
),
open,
onSavedDraftOpened: async (saved) => {
const source = handoffUiState[saved.sourceSessionId];
if (!draftDelivered || !source || source.status === "progress")
return;
const { retryDraft, retryAttachments } = source;
const matches = await matchesCloudHandoffFollowUp(
retryDraft,
retryAttachments ?? [],
saved,
).catch(() => false);
if (matches)
dispatchHandoffUi({
type: "retry_delivered",
sourceSessionId: saved.sourceSessionId,
retryDraft,
retryAttachments,
});
},
});
return opened ? { opened, draftDelivered } : undefined;
return opened ? { opened } : undefined;
} catch (error) {
toast({
title: "Unable to restore cloud follow-up",
description: error instanceof Error ? error.message : String(error),
variant: "destructive",
});
return { opened: false, draftDelivered: false };
return { opened: false };
}
},
[handoffUiState, promptDrafts, threads],
[],
);
const handleDeleteSession = useCallback(
@@ -1039,7 +993,7 @@ export default function Home() {
variant: "destructive",
});
}
return { opened: false, draftDelivered: false };
return { opened: false };
}
},
[handleOpenSession],
@@ -1050,9 +1004,6 @@ export default function Home() {
},
[handleOpenSessionById],
);
// Keep recovery state across pane changes while using the latest open-session binding.
const openHandoffSessionRef = useRef(handleOpenSessionById);
openHandoffSessionRef.current = handleOpenSessionById;
const handoffLifecycleRef = useRef<HandoffLifecycle | null>(null);
if (handoffLifecycleRef.current === null) {
handoffLifecycleRef.current = createHandoffLifecycle({
@@ -1069,9 +1020,6 @@ export default function Home() {
</ToastAction>
) : undefined,
}),
openSession: (sessionId, options) =>
openHandoffSessionRef.current(sessionId, options),
openExternal: openExternalUrl,
});
}
const handoffLifecycle = handoffLifecycleRef.current;
@@ -1433,7 +1381,6 @@ function ChatThreadPane({
initialAttachments?: File[];
initialPromptDraft?: string;
initialHandoffFollowUpId?: string;
/** Home-level coordinator owning handoff completion/failure ordering. */
handoffLifecycle: Pick<
HandoffLifecycle,
"onRpcStarted" | "onRpcResolved" | "onRpcRejected"
@@ -1554,33 +1501,8 @@ function ChatThreadPane({
sourceSessionId && environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID
? handoffUiState[sourceSessionId]
: undefined;
const handoffProgress = handoffUi?.status === "progress" ? handoffUi : null;
const handoffMetadata = (liveHistorySession ?? historySession)?.metadata;
const pendingHandoffRecovery = readPendingHandoffRecovery(handoffMetadata);
const handoffRetryEligible =
Boolean(pendingHandoffRecovery) ||
handoffUi?.status === "recovery" ||
handoffUi?.status === "recovery_dismissed" ||
handoffUi?.status === "failed" ||
handoffUi?.status === "retry_restored";
const handoffOwnershipPending =
Boolean(pendingHandoffRecovery) || hasLivePendingHandoff(handoffUi);
const dismissedHandoffRecoveryUrl =
handoffUi?.status === "recovery_dismissed" ? handoffUi.dashboardUrl : null;
const handoffRecoveryUrl =
(handoffUi?.status === "recovery" || handoffUi?.status === "retry_restored"
? handoffUi.dashboardUrl
: null) ??
(pendingHandoffRecovery?.dashboardUrl !== dismissedHandoffRecoveryUrl
? pendingHandoffRecovery?.dashboardUrl
: null) ??
null;
const handoffRetry =
(handoffUi?.status === "recovery" ||
handoffUi?.status === "recovery_dismissed" ||
handoffUi?.status === "failed" ||
handoffUi?.status === "retry_restored") &&
(handoffUi.retryDraft !== undefined || handoffUi.retryAttachments?.length)
handoffUi?.status === "failed" || handoffUi?.status === "retry_restored"
? {
draft: handoffUi.retryDraft
? `/cloud ${handoffUi.retryDraft}`
@@ -1588,12 +1510,6 @@ function ChatThreadPane({
attachments: handoffUi.retryAttachments,
}
: null;
const handoffReceipt = resolveHandoffReceipt(
handoffUi,
readHandoffReceipt(handoffMetadata),
);
const handoffExternalPresentation =
handoffUi?.status === "complete" && handoffUi.externalPresentation;
const { user: accountUser, activeOrganization } = useAccount();
const accountUserId = accountUser?.id ?? null;
const openGitHubConnect = useCallback(
@@ -1634,20 +1550,8 @@ function ChatThreadPane({
});
};
fetchFlags();
// Reflect Settings changes without restarting or switching accounts.
const unsubscribe = desktopClient.subscribe(
"feature_flags_changed",
(payload) => {
if (!cancelled) {
setCloudAgentsFlagEnabled(
Boolean((payload as { cloudAgents?: boolean })?.cloudAgents),
);
}
},
);
return () => {
cancelled = true;
unsubscribe();
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [accountUserId]);
@@ -1738,6 +1642,22 @@ function ChatThreadPane({
followUpSessionRef.current = null;
};
}, [refreshFollowUp, sessionId]);
useEffect(
() =>
desktopClient.subscribe("cloud_handoff_progress", (payload) => {
if (
payload &&
typeof payload === "object" &&
"phase" in payload &&
payload.phase === "complete" &&
"sessionId" in payload &&
payload.sessionId === sessionId
) {
void refreshFollowUp().catch(() => {});
}
}),
[refreshFollowUp, sessionId],
);
const updateFollowUp = useCallback(
async (action: "restore" | "dismiss") => {
if (!sessionId || !uncertainFollowUp || updatingFollowUp) return;
@@ -2377,8 +2297,15 @@ function ChatThreadPane({
[isCloudSession, setPendingAttachments],
);
useEffect(() => {
if (!sourceSessionId) return;
const handleRestoreHandoffDraft = useCallback(() => {
if (!sourceSessionId || !handoffRetry) return;
if (draftRef.current.text.trim() || draftRef.current.attachments.length) {
toast({
title: "Your current draft is unchanged",
description: "Send or clear it before restoring the /cloud draft.",
});
return;
}
const restored = restoreHandoffRetry(
handoffRetry ? { sourceSessionId, ...handoffRetry } : undefined,
);
@@ -2389,6 +2316,7 @@ function ChatThreadPane({
});
}
}, [
draftRef,
handoffRetry,
handoffUi?.status,
onHandoffUiAction,
@@ -2418,7 +2346,7 @@ function ChatThreadPane({
action: "handoff",
sessionId: sourceSessionId,
config,
fingerprint: preflight.fingerprint,
gitPreparationId: preflight.gitPreparationId,
handoffAttemptId,
nextCommand: nextCommand || undefined,
attachments:
@@ -2432,7 +2360,6 @@ function ChatThreadPane({
result,
nextCommand,
sourceAttachments,
isThreadActive,
});
} catch (error) {
await handoffLifecycle.onRpcRejected(sourceSessionId, {
@@ -2440,11 +2367,10 @@ function ChatThreadPane({
error,
nextCommand,
sourceAttachments,
isThreadActive,
});
}
},
[config, handoffLifecycle, isThreadActive, onHandoffUiAction],
[config, handoffLifecycle, onHandoffUiAction],
);
const prepareHandoff = useCallback(
@@ -2480,10 +2406,7 @@ function ChatThreadPane({
});
return;
}
const attachmentError = validateHandoffAttachments(
pendingAttachments,
nextCommand,
);
const attachmentError = validateHandoffAttachments(pendingAttachments);
if (attachmentError) {
setPromptInput(nextCommand ? `/cloud ${nextCommand}` : "/cloud");
toast({
@@ -2494,7 +2417,8 @@ function ChatThreadPane({
return;
}
if (handoffStartingRef.current) {
if (handoffStartingRef.current || handoffUi?.status === "progress") {
setPromptInput(nextCommand ? `/cloud ${nextCommand}` : "/cloud");
toast({
title: "Handoff is already starting",
description: "Wait for the current handoff request to finish.",
@@ -2503,60 +2427,57 @@ function ChatThreadPane({
}
handoffStartingRef.current = true;
const sourceAttachments = [...pendingAttachments];
const handoffAttemptId = handoffLifecycle.onRpcStarted(
sourceSessionId,
threadId,
);
const handoffAttemptId = handoffLifecycle.onRpcStarted(sourceSessionId);
setPendingAttachments([]);
try {
setPromptInput("");
onHandoffUiAction({
type: "start",
sourceSessionId,
});
const attachments = await serializeAttachments(sourceAttachments);
const preflight = await prepareHandoffWithGit({
inspect: () =>
desktopClient.invoke<HandoffPreparation>("chat_session_command", {
request: {
action: "prepare_handoff",
sessionId: sourceSessionId,
config,
},
}),
confirm: confirmHandoffGit,
apply: (gitPreparationId) => {
onHandoffUiAction({
type: "progress",
sourceSessionId,
phase: "checking",
message:
"Preparing and publishing the approved Git checkpoint...",
});
return desktopClient.invoke(
"chat_session_command",
{
request: {
action: "prepare_handoff_git",
sessionId: sourceSessionId,
config,
gitPreparationId,
},
},
{ timeoutMs: HANDOFF_INVOKE_TIMEOUT_MS },
);
const { gitPreparation } = await desktopClient.invoke<{
gitPreparation: HandoffGitPreview;
}>(
"chat_session_command",
{
request: {
action: "prepare_handoff",
sessionId: sourceSessionId,
config,
},
},
});
if (!preflight) {
{ timeoutMs: HANDOFF_INVOKE_TIMEOUT_MS },
);
if (!(await confirmHandoffGit(gitPreparation))) {
await handoffLifecycle.onRpcRejected(sourceSessionId, {
handoffAttemptId,
error: new Error("Cloud preparation cancelled."),
nextCommand,
sourceAttachments,
isThreadActive,
silent: true,
});
return;
}
onHandoffUiAction({
type: "progress",
sourceSessionId,
phase: "checking",
message: "Preparing the summary and approved Git checkpoint...",
});
const preflight = await desktopClient.invoke<HandoffPreflight>(
"chat_session_command",
{
request: {
action: "prepare_handoff_git",
sessionId: sourceSessionId,
handoffAttemptId,
config,
gitPreparationId: gitPreparation.id,
},
},
{ timeoutMs: HANDOFF_INVOKE_TIMEOUT_MS },
);
await runHandoff(
preflight,
nextCommand,
@@ -2571,7 +2492,6 @@ function ChatThreadPane({
error,
nextCommand,
sourceAttachments,
isThreadActive,
});
} finally {
handoffStartingRef.current = false;
@@ -2583,7 +2503,7 @@ function ChatThreadPane({
handoffLifecycle,
historySession?.sessionId,
isCloudSession,
isThreadActive,
handoffUi?.status,
pendingAttachments,
promptsInQueue.length,
runHandoff,
@@ -2592,7 +2512,6 @@ function ChatThreadPane({
setPromptInput,
status,
onHandoffUiAction,
threadId,
],
);
@@ -2600,22 +2519,10 @@ function ChatThreadPane({
async (prompt: string) => {
const trimmed = prompt.trim();
const handoff = parseHandoffCommand(trimmed);
// Only reserve /cloud when the Cloud sessions gate is on (or a pending
// handoff needs its retry path); otherwise a user's own workflow
// or skill named "cloud" stays reachable.
if (handoff && (cloudHandoffAvailable || handoffRetryEligible)) {
if (handoff && cloudHandoffAvailable) {
await prepareHandoff(handoff.nextCommand);
return;
}
if (!handoff && handoffOwnershipPending) {
setPromptInput(prompt);
toast({
title: "Cloud handoff is still pending",
description:
"Retry /cloud or use the recovery link before sending another prompt.",
});
return;
}
if (!trimmed && pendingAttachments.length === 0) {
return;
}
@@ -2675,12 +2582,9 @@ function ChatThreadPane({
refreshFollowUp,
sessionId,
setPendingAttachments,
setPromptInput,
sourceSessionId,
threadId,
cloudHandoffAvailable,
handoffRetryEligible,
handoffOwnershipPending,
workIn,
],
);
@@ -2801,16 +2705,13 @@ function ChatThreadPane({
const activeSessionToDelete = hideDeletedSessionUi
? null
: (sessionId ?? visibleHistorySession?.sessionId ?? null);
const handoffDeleteLocked = Boolean(
handoffProgress || handoffOwnershipPending,
);
const requestDeleteSession = useCallback(() => {
if (!activeSessionToDelete || deletingSession || handoffDeleteLocked) {
if (!activeSessionToDelete || deletingSession) {
return;
}
setDeleteConfirmOpen(true);
}, [activeSessionToDelete, deletingSession, handoffDeleteLocked]);
}, [activeSessionToDelete, deletingSession]);
const handleDeleteSession = useCallback(async () => {
if (!activeSessionToDelete || deletingSession) {
@@ -3065,82 +2966,27 @@ function ChatThreadPane({
(prompt: string) => void handleSend(prompt),
[handleSend],
);
const handleOpenHandoffCloud = useCallback(async () => {
const receipt = handoffReceipt;
if (!receipt) {
const handleOpenHandoffProgressLink = useCallback(() => {
if (!handoffUi || !("dashboardUrl" in handoffUi) || !handoffUi.dashboardUrl)
return;
}
if (cloudAgentsEnabled) {
const retryDraft =
handoffUi && "retryDraft" in handoffUi
? handoffUi.retryDraft
: undefined;
const retryAttachments =
handoffUi && "retryAttachments" in handoffUi
? handoffUi.retryAttachments
: undefined;
const outcome = await Promise.resolve(
onOpenSessionById?.(receipt.targetSessionId, {
const { dashboardUrl, sessionId: targetSessionId } = handoffUi;
void (async () => {
if (cloudAgentsEnabled && targetSessionId && onOpenSessionById) {
const opened = await onOpenSessionById(targetSessionId, {
silent: true,
initialPromptDraft: retryDraft,
initialAttachments: retryAttachments,
}),
).catch(() => ({ opened: false, draftDelivered: false }));
if (!outcome) return;
if (outcome.opened) {
if (
outcome.draftDelivered &&
sourceSessionId &&
(retryDraft || retryAttachments?.length)
) {
onHandoffUiAction({
type: "retry_delivered",
sourceSessionId,
retryDraft,
retryAttachments,
});
}
return;
expectedActiveThreadId: threadId,
});
if (!opened || opened.opened) return;
}
if (sourceSessionId) {
onHandoffUiAction({ type: "external", sourceSessionId });
}
}
await openExternalUrl(receipt.dashboardUrl).catch(() =>
await openExternalUrl(dashboardUrl);
})().catch(() =>
toast({
title: "Unable to open the browser",
description: "Copy the recovery link and open it manually.",
description: `Open the cloud task: ${dashboardUrl}`,
variant: "destructive",
}),
);
}, [
cloudAgentsEnabled,
handoffReceipt,
handoffUi,
onHandoffUiAction,
onOpenSessionById,
sourceSessionId,
]);
const handleOpenHandoffProgressLink = useCallback(() => {
const dashboardUrl = handoffProgress?.dashboardUrl ?? handoffRecoveryUrl;
if (dashboardUrl) {
void openExternalUrl(dashboardUrl).catch(() =>
toast({
title: "Unable to open the browser",
description: "Copy the link shown above and open it manually.",
variant: "destructive",
}),
);
}
}, [handoffProgress?.dashboardUrl, handoffRecoveryUrl]);
const handleDismissHandoffRecovery = useCallback(() => {
if (!sourceSessionId || !handoffRecoveryUrl) return;
onHandoffUiAction({
type: "dismiss_recovery",
sourceSessionId,
dashboardUrl: handoffRecoveryUrl,
});
}, [handoffRecoveryUrl, onHandoffUiAction, sourceSessionId]);
}, [handoffUi, cloudAgentsEnabled, onOpenSessionById, threadId]);
const firstUserMessage = messages.find(
(message) => message.role === "user",
@@ -3337,7 +3183,7 @@ function ChatThreadPane({
<ChatInputBar
readOnly={isCloudSessionExpired}
attachments={attachmentList}
cloudHandoffAvailable={cloudHandoffAvailable || handoffRetryEligible}
cloudHandoffAvailable={cloudHandoffAvailable}
environmentId={environmentId}
hasRunningAgents={agentActivity.running > 0}
onAbort={handleAbort}
@@ -3373,47 +3219,27 @@ function ChatThreadPane({
variant={isWelcomeState ? "welcome" : "conversation"}
/>
);
const composer = handoffReceipt ? (
<CloudHandoffReceipt
onForkLocally={() => void handleForkSession()}
onOpenCloud={() => void handleOpenHandoffCloud()}
receipt={handoffReceipt}
showRecoveryUrl={handoffExternalPresentation || !cloudAgentsEnabled}
/>
) : handoffProgress ? (
<CloudHandoffProgress
dashboardUrl={
cloudAgentsEnabled ? undefined : handoffProgress.dashboardUrl
}
message={handoffProgress.message}
onOpenCloud={handleOpenHandoffProgressLink}
phase={handoffProgress.phase}
/>
) : uncertainFollowUp ? (
const composer = (
<div className="w-full">
<CloudHandoffRecoveryNotice
disabled={updatingFollowUp}
onDismiss={() => void updateFollowUp("dismiss")}
onRestoreDraft={() => void updateFollowUp("restore")}
/>
{handoffUi && sourceSessionId && (
<CloudHandoffStatus
state={handoffUi}
onOpenCloud={handleOpenHandoffProgressLink}
onRestoreDraft={handleRestoreHandoffDraft}
onDismiss={() =>
onHandoffUiAction({ type: "dismiss", sourceSessionId })
}
/>
)}
{uncertainFollowUp && (
<CloudHandoffRecoveryNotice
disabled={updatingFollowUp}
onDismiss={() => void updateFollowUp("dismiss")}
onRestoreDraft={() => void updateFollowUp("restore")}
/>
)}
{chatComposer}
</div>
) : handoffRecoveryUrl ||
(pendingHandoffRecovery && !pendingHandoffRecovery.dashboardUrl) ? (
<div className="w-full">
<CloudHandoffRecoveryNotice
dashboardUrl={handoffRecoveryUrl ?? undefined}
onDismiss={
handoffRecoveryUrl ? handleDismissHandoffRecovery : undefined
}
onOpenCloud={
handoffRecoveryUrl ? handleOpenHandoffProgressLink : undefined
}
/>
{chatComposer}
</div>
) : (
chatComposer
);
const cloudConnectUrl =
@@ -3453,9 +3279,7 @@ function ChatThreadPane({
canEditTitle={
Boolean(activeSessionForTitle) && !isProvisioningCloudSession
}
canDeleteSession={
Boolean(activeSessionToDelete) && !handoffDeleteLocked
}
canDeleteSession={Boolean(activeSessionToDelete)}
deletingSession={deletingSession}
diff={isCloudSession ? undefined : headerDiff}
onDeleteSession={requestDeleteSession}
@@ -3504,9 +3328,7 @@ function ChatThreadPane({
messages={displayedMessages}
onEditMessage={isCloudSession ? undefined : handleEditMessage}
onRestoreCheckpoint={
isCloudSession || handoffReceipt
? undefined
: handleRestoreCheckpoint
isCloudSession ? undefined : handleRestoreCheckpoint
}
onForkSession={isCloudSession ? undefined : handleForkSession}
onProceedWhileRunning={
@@ -5,15 +5,10 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CloudHandoffGitConfirmation,
CloudHandoffProgress,
CloudHandoffReceipt,
CloudHandoffRecoveryNotice,
CloudHandoffStatus,
} from "./cloud-handoff";
const { toastMock } = vi.hoisted(() => ({ toastMock: vi.fn() }));
vi.mock("@/hooks/use-toast", () => ({ toast: toastMock }));
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
@@ -22,7 +17,6 @@ afterEach(() => {
root = null;
container?.remove();
container = null;
toastMock.mockReset();
});
function render(node: React.ReactNode) {
@@ -33,25 +27,79 @@ function render(node: React.ReactNode) {
return container;
}
describe("CloudHandoffProgress", () => {
it("shows a truthful phase and an optional early external link", () => {
describe("CloudHandoffStatus", () => {
it.each([
"sending",
"complete",
"warning",
] as const)("offers a user-clicked cloud link during %s", (phase) => {
const onOpenCloud = vi.fn();
const view = render(
<CloudHandoffProgress
dashboardUrl="https://staging-app.cline.bot/agents?sessionId=cloud-1"
<CloudHandoffStatus
state={
phase !== "sending"
? {
status: "complete",
sessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents/cloud-1",
message:
phase === "warning"
? "Check delivery before resending."
: undefined,
}
: {
status: "progress",
phase,
sessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents/cloud-1",
}
}
onOpenCloud={onOpenCloud}
phase="seeding"
onDismiss={vi.fn()}
onRestoreDraft={vi.fn()}
/>,
);
expect(view.textContent).toContain("Transferring the conversation");
expect(view.textContent).toContain("available to watch");
expect(view.textContent).not.toContain("ready to open");
expect(view.textContent).toContain(
phase === "complete"
? "Cloud task ready"
: phase === "warning"
? "Check the cloud task"
: "Sending the cloud task",
);
if (phase === "warning") {
expect(view.textContent).toContain("Check delivery before resending.");
}
expect(onOpenCloud).not.toHaveBeenCalled();
const button = Array.from(view.querySelectorAll("button")).find((item) =>
item.textContent?.includes("Open Cloud"),
item.textContent?.includes("Open cloud task"),
);
act(() => button?.click());
expect(onOpenCloud).toHaveBeenCalledOnce();
});
it("shows preparation failure without automatically restoring the saved draft", () => {
const restore = vi.fn();
const view = render(
<CloudHandoffStatus
state={{
status: "failed",
message: "Summary failed",
retryDraft: "continue",
retryAttachments: [],
}}
onOpenCloud={vi.fn()}
onRestoreDraft={restore}
onDismiss={vi.fn()}
/>,
);
expect(view.textContent).toContain("Summary failed");
expect(restore).not.toHaveBeenCalled();
act(() =>
Array.from(view.querySelectorAll("button"))
.find((button) => button.textContent === "Restore /cloud draft")
?.click(),
);
expect(restore).toHaveBeenCalledOnce();
});
});
describe("CloudHandoffGitConfirmation", () => {
@@ -88,9 +136,7 @@ describe("CloudHandoffGitConfirmation", () => {
expect(details?.open).toBe(false);
expect(details?.textContent).toContain("new.txt");
expect(details?.textContent).toContain("abc local commit");
expect(dialog?.textContent).toContain(
"staging selections will be replaced",
);
expect(dialog?.textContent).toContain("staging stay unchanged");
act(() => details?.querySelector("summary")?.click());
expect(details?.open).toBe(true);
expect(onDecision).not.toHaveBeenCalled();
@@ -122,7 +168,7 @@ describe("CloudHandoffGitConfirmation", () => {
);
const dialog = document.querySelector('[role="alertdialog"]');
expect(dialog?.textContent).not.toContain("changed/new");
expect(dialog?.textContent).not.toContain("staging");
expect(dialog?.textContent).toContain("staging stay unchanged");
expect(dialog?.textContent).not.toContain("will be committed");
expect(dialog?.textContent).toContain(
"1 local commit not confirmed on the remote",
@@ -137,95 +183,7 @@ describe("CloudHandoffGitConfirmation", () => {
});
});
describe("CloudHandoffReceipt", () => {
it("explains the local copy and offers cloud and local-fork actions", () => {
const onOpenCloud = vi.fn();
const onForkLocally = vi.fn();
const view = render(
<CloudHandoffReceipt
onForkLocally={onForkLocally}
onOpenCloud={onOpenCloud}
receipt={{
targetSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
}}
showRecoveryUrl
/>,
);
expect(view.textContent).toContain("read-only history");
expect(view.textContent).toContain("sessionId=cloud-1");
const buttons = Array.from(view.querySelectorAll("button"));
act(() =>
buttons.find((item) => item.textContent?.includes("Open Cloud"))?.click(),
);
act(() =>
buttons
.find((item) => item.textContent?.includes("Fork Locally"))
?.click(),
);
expect(onOpenCloud).toHaveBeenCalledOnce();
expect(onForkLocally).toHaveBeenCalledOnce();
});
it("keeps the dashboard URL out of the in-app receipt", () => {
const view = render(
<CloudHandoffReceipt
onForkLocally={() => undefined}
onOpenCloud={() => undefined}
receipt={{
targetSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
}}
/>,
);
expect(view.textContent).not.toContain("sessionId=cloud-1");
expect(view.textContent).not.toContain("Copy recovery link");
});
it.each([
"unavailable",
"rejected",
] as const)("explains when clipboard copying is %s", async (failure) => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value:
failure === "rejected"
? { writeText: vi.fn().mockRejectedValue(new Error("denied")) }
: undefined,
});
const view = render(
<CloudHandoffReceipt
onForkLocally={() => undefined}
onOpenCloud={() => undefined}
receipt={{
targetSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
}}
showRecoveryUrl
/>,
);
await act(async () => {
Array.from(view.querySelectorAll("button"))
.find((item) => item.textContent?.includes("Copy recovery link"))
?.click();
await Promise.resolve();
});
expect(toastMock).toHaveBeenCalledExactlyOnceWith({
title: "Copy failed",
description: "Use Open Cloud or select the recovery link above.",
variant: "destructive",
});
});
});
describe("CloudHandoffRecoveryNotice", () => {
it("explains unresolved creation without offering a nonexistent cloud target", () => {
const view = render(<CloudHandoffRecoveryNotice />);
expect(view.textContent).toContain("Cloud creation is unconfirmed");
expect(view.textContent).toContain("Retry /cloud to check again");
expect(view.querySelector("button")).toBeNull();
});
it("requires an explicit restore or dismiss for uncertain delivery", () => {
const onRestoreDraft = vi.fn();
const onDismiss = vi.fn();
@@ -252,35 +210,4 @@ describe("CloudHandoffRecoveryNotice", () => {
);
expect(onDismiss).toHaveBeenCalledOnce();
});
it("retains a non-spinning link after handoff progress stops", () => {
const onOpenCloud = vi.fn();
const onDismiss = vi.fn();
const view = render(
<CloudHandoffRecoveryNotice
dashboardUrl="https://app.cline.bot/agents?sessionId=orphan-1"
onDismiss={onDismiss}
onOpenCloud={onOpenCloud}
/>,
);
expect(view.textContent).toContain("Handoff interrupted");
expect(view.textContent).toContain(
"A cloud session was created and may still be available",
);
expect(view.textContent).toContain("sessionId=orphan-1");
expect(view.querySelector(".animate-spin")).toBeNull();
act(() =>
Array.from(view.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Open Cloud"))
?.click(),
);
expect(onOpenCloud).toHaveBeenCalledOnce();
act(() =>
view
.querySelector<HTMLButtonElement>(
'[aria-label="Dismiss handoff recovery"]',
)
?.click(),
);
expect(onDismiss).toHaveBeenCalledOnce();
});
});
@@ -1,6 +1,6 @@
"use client";
import { Cloud, Copy, ExternalLink, GitFork, Loader2, X } from "lucide-react";
import { Cloud, ExternalLink, Loader2, X } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
@@ -13,12 +13,8 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import type {
HandoffProgressPhase,
HandoffReceipt as HandoffReceiptValue,
} from "@/lib/cloud-handoff";
import { HANDOFF_PROGRESS_LABELS } from "@/lib/cloud-handoff";
import type { CloudHandoffUiEntry } from "@/lib/cloud-handoff-ui-state";
import type { HandoffGitPreview } from "../../../../sidecar/cloud-handoff-git";
export function CloudHandoffGitConfirmation({
@@ -39,8 +35,9 @@ export function CloudHandoffGitConfirmation({
<AlertDialogHeader>
<AlertDialogTitle>Continue in cloud?</AlertDialogTitle>
<AlertDialogDescription>
Cline will switch this checkout to a new branch and push it to
GitHub to continue your conversation in cloud.
Cline will push a snapshot to a new GitHub branch and start a cloud
task with a summary of this conversation. Your local branch, files,
and staging stay unchanged. Later messages and edits stay local.
{plan.commits.length > 0 && (
<span className="mt-2 block">
This includes {plan.commits.length} local{" "}
@@ -52,8 +49,7 @@ export function CloudHandoffGitConfirmation({
<span className="mt-2 block">
{plan.files.length} changed/new{" "}
{plan.files.length === 1 ? "file" : "files"} will be committed.
Any staging selections will be replaced. Ignored files are
excluded.
Ignored files are excluded.
</span>
)}
</AlertDialogDescription>
@@ -114,196 +110,127 @@ export function CloudHandoffGitConfirmation({
);
}
export function CloudHandoffProgress({
phase,
message,
dashboardUrl,
export function CloudHandoffStatus({
state,
onOpenCloud,
onRestoreDraft,
onDismiss,
}: {
phase: HandoffProgressPhase;
message?: string;
dashboardUrl?: string;
state: CloudHandoffUiEntry;
onOpenCloud: () => void;
onRestoreDraft: () => void;
onDismiss: () => void;
}) {
const pending = state.status === "progress";
const dashboardUrl = "dashboardUrl" in state ? state.dashboardUrl : undefined;
const label = pending
? state.message?.trim() || HANDOFF_PROGRESS_LABELS[state.phase]
: state.status === "complete"
? state.message
? "Check the cloud task"
: "Cloud task ready"
: "Cloud preparation stopped";
return (
<output
aria-live="polite"
className="mx-auto flex w-full max-w-xl flex-col gap-3 rounded-lg border bg-card p-5 shadow-sm"
className="mb-2 block w-full rounded-md border bg-card px-3"
>
<div className="flex items-center gap-3">
<Loader2 className="size-4 animate-spin text-primary" />
<div>
<p className="text-sm font-medium">
{message?.trim() || HANDOFF_PROGRESS_LABELS[phase]}
</p>
<p className="mt-1 text-xs text-muted-foreground">
You can use the rest of Cline while this finishes.
</p>
</div>
</div>
{dashboardUrl ? (
<div className="rounded-md border bg-muted/30 p-3 text-xs">
<p className="font-medium">
Your cloud session is available to watch while verification
finishes.
</p>
<p className="mt-1 break-all text-muted-foreground">{dashboardUrl}</p>
<div className="flex h-9 min-w-0 items-center gap-2">
{pending ? (
<Loader2
aria-hidden="true"
className="size-3.5 shrink-0 animate-spin text-primary motion-reduce:animate-none"
/>
) : (
<Cloud aria-hidden="true" className="size-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-xs" title={label}>
{label}
</span>
{dashboardUrl && (
<Button
className="mt-2 h-7 gap-1.5 px-2 text-xs"
className="h-7 shrink-0 gap-1.5 px-2 text-xs"
onClick={onOpenCloud}
size="sm"
variant="outline"
variant="ghost"
>
<ExternalLink className="size-3.5" />
Open Cloud
Open cloud task
<ExternalLink aria-hidden="true" className="size-3.5" />
</Button>
)}
{!pending && (
<Button
aria-label="Dismiss cloud handoff status"
className="size-7 shrink-0"
onClick={onDismiss}
size="icon"
variant="ghost"
>
<X aria-hidden="true" className="size-3.5" />
</Button>
)}
</div>
{!pending && (state.message || state.status === "failed") && (
<div className="space-y-2 pb-3">
{state.message && (
<p className="break-words text-xs text-muted-foreground">
{state.message}
</p>
)}
{state.status === "failed" && (
<Button
className="h-7 px-2 text-xs"
onClick={onRestoreDraft}
size="sm"
variant="outline"
>
Restore /cloud draft
</Button>
)}
</div>
) : null}
)}
</output>
);
}
export function CloudHandoffRecoveryNotice({
dashboardUrl,
onOpenCloud,
onDismiss,
onRestoreDraft,
disabled = false,
}: {
dashboardUrl?: string;
onOpenCloud?: () => void;
onDismiss?: () => void;
onRestoreDraft?: () => void;
onDismiss: () => void;
onRestoreDraft: () => void;
disabled?: boolean;
}) {
return (
<Alert className="mx-auto mb-2 w-full max-w-xl pr-10">
<Cloud />
<AlertTitle>
{onRestoreDraft
? "Follow-up delivery is unconfirmed"
: dashboardUrl
? "Handoff interrupted"
: "Cloud creation is unconfirmed"}
</AlertTitle>
<AlertTitle>Prompt delivery is unconfirmed</AlertTitle>
<AlertDescription>
<p>
{onRestoreDraft
? "Check this conversation before restoring the saved draft; it may already have been sent."
: dashboardUrl
? "A cloud session was created and may still be available."
: "Retry /cloud to check again before sending another prompt."}
Check this conversation before restoring the saved draft; it may
already have been sent.
</p>
<div className="mt-2 flex w-full items-start justify-between gap-3">
{dashboardUrl && (
<p className="min-w-0 break-all text-xs">{dashboardUrl}</p>
)}
{(onRestoreDraft || onOpenCloud) && (
<Button
className="h-7 shrink-0 gap-1.5 px-2 text-xs"
onClick={onRestoreDraft ?? onOpenCloud}
disabled={disabled}
size="sm"
variant="outline"
>
{onRestoreDraft ? (
"Restore saved draft"
) : (
<>
<ExternalLink className="size-3.5" />
Open Cloud
</>
)}
</Button>
)}
</div>
</AlertDescription>
{onDismiss && (
<Button
aria-label="Dismiss handoff recovery"
className="absolute right-2 top-2 size-7 text-muted-foreground"
onClick={onDismiss}
className="mt-2 h-7 px-2 text-xs"
onClick={onRestoreDraft}
disabled={disabled}
size="icon"
variant="ghost"
>
<X className="size-4" />
</Button>
)}
</Alert>
);
}
export function CloudHandoffReceipt({
receipt,
onOpenCloud,
onForkLocally,
showRecoveryUrl = false,
}: {
receipt: HandoffReceiptValue;
onOpenCloud: () => void;
onForkLocally: () => void;
showRecoveryUrl?: boolean;
}) {
const copyRecoveryLink = async () => {
try {
if (!navigator.clipboard?.writeText)
throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(receipt.dashboardUrl);
} catch {
toast({
title: "Copy failed",
description: "Use Open Cloud or select the recovery link above.",
variant: "destructive",
});
}
};
return (
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 px-6 py-10 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Cloud className="size-5" />
</div>
<div>
<h2 className="text-base font-semibold">Continued in Cline Cloud</h2>
<p className="mt-1 text-sm text-muted-foreground">
This local session is preserved as read-only history.
</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button className="gap-1.5" onClick={onOpenCloud} size="sm">
<ExternalLink className="size-3.5" />
Open Cloud
</Button>
<Button
className="gap-1.5"
onClick={onForkLocally}
size="sm"
variant="outline"
>
<GitFork className="size-3.5" />
Fork Locally
Restore saved draft
</Button>
</div>
{showRecoveryUrl ? (
<button
className="max-w-full break-all text-xs text-primary underline-offset-4 hover:underline"
onClick={onOpenCloud}
type="button"
>
{receipt.dashboardUrl}
</button>
) : null}
{showRecoveryUrl ? (
<button
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => void copyRecoveryLink()}
type="button"
>
<Copy className="size-3" />
Copy recovery link
</button>
) : null}
</div>
</AlertDescription>
<Button
aria-label="Dismiss handoff recovery"
className="absolute right-2 top-2 size-7 text-muted-foreground"
onClick={onDismiss}
disabled={disabled}
size="icon"
variant="ghost"
>
<X className="size-4" />
</Button>
</Alert>
);
}
@@ -112,52 +112,3 @@ describe("SettingsView font size", () => {
expect(increaseButton?.disabled).toBe(true);
});
});
describe("SettingsView cloud sessions rollout", () => {
it.each([
{
caseName: "the rollout explicitly enables it",
featureFlags: { cloudAgents: false, cloudAgentsAvailable: true },
visible: true,
},
{
caseName: "feature flags are unavailable",
featureFlags: new Error("feature flags unavailable"),
visible: false,
},
])("shows the preview setting only when $caseName", async ({
featureFlags,
visible,
}) => {
invoke.mockImplementation(async (command: string) => {
if (command === "get_feature_flags") {
if (featureFlags instanceof Error) throw featureFlags;
return featureFlags;
}
if (command === "get_desktop_settings") {
return { cloudSessionsEnabled: false };
}
return {
telemetryOptOut: false,
autoUpdateEnabled: true,
};
});
await act(async () => {
root.render(
<SettingsView
onExportDiagnostics={vi.fn()}
onNavigateSection={vi.fn()}
section="General"
/>,
);
});
await vi.waitFor(() =>
expect(
container.querySelector(
'[role="switch"][aria-label="Cloud sessions"]',
) !== null,
).toBe(visible),
);
});
});
@@ -699,32 +699,6 @@ function GeneralSettingsContent({
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false);
const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true);
const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false);
const [cloudSessionsError, setCloudSessionsError] = useState<string | null>(
null,
);
// The environment override can differ from the stored opt-in.
const [cloudSessionsEffective, setCloudSessionsEffective] = useState<
boolean | null
>(null);
// Keep the preview hidden until the rollout service explicitly enables it.
const [cloudSessionsAvailable, setCloudSessionsAvailable] = useState(false);
const refreshCloudSessionsEffective = useCallback(async () => {
try {
const flags = await desktopClient.invoke<{
cloudAgents?: boolean;
cloudAgentsAvailable?: boolean;
}>("get_feature_flags");
setCloudSessionsEffective(Boolean(flags.cloudAgents));
setCloudSessionsAvailable(flags.cloudAgentsAvailable === true);
} catch {
setCloudSessionsEffective(null);
setCloudSessionsAvailable(false);
}
}, []);
useEffect(() => setAppIconLocation(appIconSurface(navigator.userAgent)), []);
useEffect(() => subscribeToAppFontSize(setFontSize), []);
@@ -734,45 +708,21 @@ function GeneralSettingsContent({
setTelemetryError(null);
setAutoUpdateLoading(true);
setAutoUpdateError(null);
setCloudSessionsLoading(true);
setCloudSessionsError(null);
await Promise.all([
(async () => {
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
})(),
(async () => {
try {
const desktopSettings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("get_desktop_settings");
setCloudSessionsEnabled(
Boolean(desktopSettings.cloudSessionsEnabled),
);
} catch (error) {
setCloudSessionsError(
error instanceof Error ? error.message : String(error),
);
} finally {
setCloudSessionsLoading(false);
}
})(),
refreshCloudSessionsEffective(),
]);
}, [refreshCloudSessionsEffective]);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
@@ -821,26 +771,6 @@ function GeneralSettingsContent({
}
};
const updateCloudSessionsEnabled = async (nextValue: boolean) => {
const previousValue = cloudSessionsEnabled;
setCloudSessionsEnabled(nextValue);
setCloudSessionsSaving(true);
setCloudSessionsError(null);
try {
const settings = await desktopClient.invoke<{
cloudSessionsEnabled: boolean;
}>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue });
setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled));
await refreshCloudSessionsEffective();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setCloudSessionsEnabled(previousValue);
setCloudSessionsError(message);
} finally {
setCloudSessionsSaving(false);
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
@@ -1051,46 +981,6 @@ function GeneralSettingsContent({
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
/>
</div>
{cloudSessionsAvailable ? (
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="flex items-center gap-2 text-base font-semibold text-foreground">
Cloud sessions
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-primary">
Preview
</span>
</p>
<p className="text-sm text-muted-foreground">
Run Cline on your GitHub repositories in secure cloud sandboxes.
Adds a Cloud option to the new-session composer. Requires a
Cline account with GitHub connected.
</p>
{cloudSessionsError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Failed to update cloud sessions setting: {cloudSessionsError}
</p>
) : null}
{cloudSessionsEffective !== null &&
!cloudSessionsLoading &&
cloudSessionsEffective !== cloudSessionsEnabled ? (
<p className="mt-2 text-xs text-muted-foreground">
Cloud sessions are currently{" "}
{cloudSessionsEffective ? "enabled" : "disabled"} by the
CLINE_CODE_CLOUD_AGENTS environment override, which takes
precedence over this setting.
</p>
) : null}
</div>
<Switch
aria-label="Cloud sessions"
checked={cloudSessionsEnabled}
disabled={cloudSessionsLoading || cloudSessionsSaving}
onCheckedChange={(checked) =>
void updateCloudSessionsEnabled(checked)
}
/>
</div>
) : null}
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">Telemetry</p>
@@ -336,42 +336,6 @@ describe("useSessionHistory session mapping", () => {
).toMatchObject({ isScheduled: false });
});
it.each([
{
handoff: {
status: "complete",
toCloudSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents/cloud-1",
},
},
{ cloudHandoffIntent: { idempotencyKey: "pending-creation" } },
])("refreshes metadata-only cloud handoff transitions: %j", async (metadata) => {
await act(async () => {
root.render(<HookHarness />);
});
await flush();
await act(async () => {
pendingLists[0].resolve([sessionRow("handoff-source")]);
await Promise.resolve();
});
let refresh: Promise<boolean> | undefined;
await act(async () => {
refresh = current.refreshSessions();
});
await act(async () => {
pendingLists[1].resolve([
{
...sessionRow("handoff-source"),
metadata,
},
]);
await refresh;
});
expect(current.sessions[0]?.metadata).toMatchObject(metadata);
});
it("maps the runner's schedule provenance onto sidebar threads", async () => {
await act(async () => {
root.render(<HookHarness />);
@@ -447,10 +447,6 @@ function areSessionsEquivalent(
getSessionMetadataPinned(a.metadata) !==
getSessionMetadataPinned(b.metadata) ||
a.metadata?.provisioningPhase !== b.metadata?.provisioningPhase ||
JSON.stringify(a.metadata?.handoff) !==
JSON.stringify(b.metadata?.handoff) ||
JSON.stringify(a.metadata?.cloudHandoffIntent) !==
JSON.stringify(b.metadata?.cloudHandoffIntent) ||
!areScheduleInfosEqual(
getSessionMetadataSchedule(a.metadata),
getSessionMetadataSchedule(b.metadata),
@@ -32,16 +32,13 @@ it.each([
it("restores the command and images again after opening without sending", async () => {
vi.mocked(desktopClient.invoke).mockResolvedValue(saved);
const open = vi.fn();
const onSavedDraftOpened = vi.fn();
expect(
await openWithCloudHandoffFollowUp({
targetSessionId: "cloud-target",
canOpen: () => true,
open,
onSavedDraftOpened,
}),
).toBe(true);
expect(onSavedDraftOpened).toHaveBeenCalledExactlyOnceWith(saved);
const [command, images, draftId] = open.mock.calls[0];
expect(draftId).toBe(saved.draftId);
expect(command).toBe(saved.command);
@@ -120,15 +117,12 @@ it("does not offer an unconfirmed send for resubmission, even with stale initial
it("opens an explicit text-only retry without the older saved images", async () => {
vi.mocked(desktopClient.invoke).mockResolvedValue(saved);
const open = vi.fn();
const onSavedDraftOpened = vi.fn();
await openWithCloudHandoffFollowUp({
targetSessionId: "cloud-target",
initialPromptDraft: "new text-only retry",
canOpen: () => true,
open,
onSavedDraftOpened,
});
expect(onSavedDraftOpened).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledExactlyOnceWith(
"new text-only retry",
undefined,
@@ -60,7 +60,6 @@ export async function openWithCloudHandoffFollowUp(options: {
initialAttachments?: File[];
canOpen: () => boolean;
open: (draft?: string, attachments?: File[], draftId?: string) => void;
onSavedDraftOpened?: (saved: CloudHandoffFollowUp) => void | Promise<void>;
}): Promise<boolean> {
let saved: CloudHandoffFollowUp | null = null;
const explicitRetry =
@@ -100,7 +99,6 @@ export async function openWithCloudHandoffFollowUp(options: {
attachments,
saved?.draftId,
);
if (!explicitRetry && saved) await options.onSavedDraftOpened?.(saved);
if (restoreFailed) {
toast({
title: "Cloud opened without the saved follow-up",
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,6 @@ import {
buildHandoffWarningToast,
type HandoffProgressPhase,
type HandoffResult,
shouldOpenHandoffInApp,
} from "./cloud-handoff";
import type { CloudHandoffUiAction } from "./cloud-handoff-ui-state";
import {
@@ -10,37 +9,20 @@ import {
parseCloudSessionError,
} from "./cloud-session-error";
export type HandoffLifecycleToast = {
type HandoffLifecycleToast = {
title: string;
description?: string;
variant?: "destructive";
/** URL for the toast’s "Connect GitHub" action. */
connectUrl?: string;
};
export type HandoffOpenResult = {
opened: boolean;
draftDelivered: boolean;
};
export type HandoffLifecycleEffects = {
export type HandoffOpenResult = { opened: boolean };
type HandoffLifecycleEffects = {
dispatch: (action: CloudHandoffUiAction) => void;
toast: (t: HandoffLifecycleToast) => void;
/** Undefined means navigation was cancelled; retain the draft without browser fallback. */
openSession: (
sessionId: string,
opts: {
silent: true;
initialPromptDraft?: string;
initialAttachments?: File[];
expectedActiveThreadId?: string;
},
) => Promise<HandoffOpenResult | undefined> | HandoffOpenResult | undefined;
openExternal: (url: string) => Promise<void>;
toast: (toast: HandoffLifecycleToast) => void;
};
/** The validated `cloud_handoff_progress` payload (caller checks the shape). */
export type HandoffProgressEventPayload = {
type HandoffProgressEventPayload = {
sourceSessionId: string;
handoffAttemptId?: string;
phase: HandoffProgressPhase;
@@ -53,353 +35,126 @@ export type HandoffProgressEventPayload = {
undeliveredCommand?: string;
};
export type HandoffCompletionRecord = {
targetSessionId: string;
dashboardUrl: string;
externalPresentation: boolean;
warningKind?: "unqueued" | "unconfirmed";
};
export type HandoffRpcResolvedContext = {
type RpcContext = {
handoffAttemptId?: string;
result: HandoffResult;
nextCommand: string;
sourceAttachments: File[];
/**
* Whether the source thread is still the active chat view. Scoped to the
* pane that ran the RPC, so it travels per-call instead of living in the
* effects; the event path never consults it. Absent means "assume active".
*/
isThreadActive?: () => boolean;
};
export type HandoffRpcRejectedContext = {
silent?: boolean;
handoffAttemptId?: string;
error: unknown;
nextCommand: string;
sourceAttachments: File[];
isThreadActive?: () => boolean;
};
/** Coordinates racing completion events and RPC results independently of React rendering. */
export function createHandoffLifecycle(effects: HandoffLifecycleEffects) {
type Attempt = {
order: number;
sourceThreadId?: string;
completion?: HandoffCompletionRecord;
retry?: { command?: string; attachments?: File[] };
openAttempted?: boolean;
};
type Source = {
latest?: Attempt;
accepted?: Attempt;
warningShown?: boolean;
id: string;
target?: { sessionId: string; dashboardUrl: string };
completed: boolean;
};
const attempts = new Map<string, Attempt>();
const sources = new Map<string, Source>();
let nextAttemptOrder = 0;
const sourceFor = (sourceSessionId: string): Source => {
let source = sources.get(sourceSessionId);
if (!source) {
source = {};
sources.set(sourceSessionId, source);
}
return source;
const current = (source: string, id?: string) => {
const attempt = attempts.get(source);
return attempt && attempt.id === id ? attempt : undefined;
};
const attemptFor = (sourceSessionId: string, attemptId?: string): Attempt => {
const key = attemptId ?? sourceSessionId;
let attempt = attempts.get(key);
if (!attempt) {
attempt = { order: 0 };
attempts.set(key, attempt);
}
return attempt;
};
// Missing progress is not rejection: only a positively accepted newer
// attempt can make an older completion stale.
const acceptAttempt = (
sourceSessionId: string,
attemptId?: string,
): boolean => {
const source = sourceFor(sourceSessionId);
if (!attemptId) return !source.latest;
const attempt = attemptFor(sourceSessionId, attemptId);
if (source.accepted && attempt.order < source.accepted.order) return false;
source.accepted = attempt;
return true;
};
const toastFailure = (error: unknown) => {
const rawError = error instanceof Error ? error.message : String(error);
const cloudError = parseCloudSessionError(rawError);
effects.toast({
title: "Handoff failed",
description: humanizeCloudHandoffError(cloudError?.message ?? rawError),
variant: "destructive",
...(cloudError?.connectUrl ? { connectUrl: cloudError.connectUrl } : {}),
});
};
// Both completion carriers use the same recovery and navigation rules.
const reconcileCompletion = async (
sourceSessionId: string,
handoffAttemptId: string | undefined,
completion: HandoffCompletionRecord,
retry: { command?: string; attachments?: File[] } | undefined,
openTarget: boolean,
): Promise<HandoffOpenResult | undefined> => {
const source = sourceFor(sourceSessionId);
const attempt = attemptFor(sourceSessionId, handoffAttemptId);
if (handoffAttemptId && source.accepted !== attempt) return;
attempt.completion = completion;
if (attempt.openAttempted) return;
if (!retry) attempt.retry = undefined;
let outcome: HandoffOpenResult | undefined;
if (openTarget) {
attempt.openAttempted = true;
const { sourceThreadId } = attempt;
outcome = await Promise.resolve()
.then(() =>
effects.openSession(completion.targetSessionId, {
silent: true,
...(retry?.command ? { initialPromptDraft: retry.command } : {}),
...(retry?.attachments?.length
? { initialAttachments: retry.attachments }
: {}),
...(sourceThreadId
? { expectedActiveThreadId: sourceThreadId }
: {}),
}),
)
.catch(() => ({ opened: false, draftDelivered: false }));
if (handoffAttemptId && source.accepted !== attempt) return;
if (outcome?.draftDelivered) attempt.retry = undefined;
}
const newerRetry =
handoffAttemptId && source.latest !== attempt
? source.latest?.retry
: undefined;
const retained =
newerRetry?.command || newerRetry?.attachments?.length
? newerRetry
: outcome?.draftDelivered
? undefined
: retry;
const complete = (
source: string,
attempt: Attempt,
result: HandoffResult,
) => {
if (attempt.completed) return;
attempt.completed = true;
effects.dispatch({
type: "complete",
sourceSessionId,
receipt: {
targetSessionId: completion.targetSessionId,
dashboardUrl: completion.dashboardUrl,
},
externalPresentation: completion.externalPresentation,
...(retained?.command ? { retryDraft: retained.command } : {}),
...(retained?.attachments?.length
? { retryAttachments: retained.attachments }
: {}),
sourceSessionId: source,
sessionId: result.sessionId,
dashboardUrl: result.dashboardUrl,
message: result.warning,
});
return outcome;
const warning = buildHandoffWarningToast(result);
if (warning) effects.toast(warning);
};
const surfaceWarning = (
sourceSessionId: string,
warning: Parameters<typeof buildHandoffWarningToast>[0],
) => {
const toast = buildHandoffWarningToast(warning);
const source = sourceFor(sourceSessionId);
if (toast && !source.warningShown) {
source.warningShown = true;
effects.toast(toast);
}
};
return {
/** Starts a distinct RPC attempt for this source session. */
onRpcStarted(sourceSessionId: string, sourceThreadId?: string): string {
const attemptId = crypto.randomUUID();
const attempt = attemptFor(sourceSessionId, attemptId);
attempt.order = ++nextAttemptOrder;
attempt.sourceThreadId = sourceThreadId;
const source = sourceFor(sourceSessionId);
source.latest = attempt;
source.warningShown = false;
return attemptId;
},
/** Handles a validated `cloud_handoff_progress` event. */
async onEvent(progress: HandoffProgressEventPayload): Promise<void> {
const { sourceSessionId, handoffAttemptId } = progress;
if (!acceptAttempt(sourceSessionId, handoffAttemptId)) return;
const attempt = attemptFor(sourceSessionId, handoffAttemptId);
if (progress.phase === "complete") {
if (attempt.completion) return;
surfaceWarning(sourceSessionId, progress);
if (progress.sessionId?.trim() && progress.dashboardUrl?.trim()) {
const saved = attempt.retry;
const retry =
progress.warningKind === "unqueued" && saved
? {
command: progress.undeliveredCommand ?? saved.command,
attachments: saved.attachments,
}
: undefined;
await reconcileCompletion(
sourceSessionId,
handoffAttemptId,
{
targetSessionId: progress.sessionId.trim(),
dashboardUrl: progress.dashboardUrl,
externalPresentation: progress.destination === "external",
warningKind: progress.warningKind,
},
retry,
progress.destination !== "external" &&
Boolean(retry?.command || retry?.attachments?.length),
);
}
return;
}
effects.dispatch({
type: "progress",
sourceSessionId,
phase: progress.phase,
message: progress.message,
dashboardUrl: progress.dashboardUrl,
onRpcStarted(sourceSessionId: string): string {
const id = crypto.randomUUID();
attempts.set(sourceSessionId, {
id,
completed: false,
});
return id;
},
async onEvent(event: HandoffProgressEventPayload): Promise<void> {
const attempt = current(event.sourceSessionId, event.handoffAttemptId);
if (!attempt || attempt.completed) return;
if (event.sessionId && event.dashboardUrl) {
attempt.target = {
sessionId: event.sessionId,
dashboardUrl: event.dashboardUrl,
};
if (event.phase === "complete") {
complete(event.sourceSessionId, attempt, {
...event,
sessionId: event.sessionId,
dashboardUrl: event.dashboardUrl,
destination: event.destination ?? "in_app",
});
return;
}
}
if (event.phase !== "complete")
effects.dispatch({
type: "progress",
sourceSessionId: event.sourceSessionId,
phase: event.phase,
message: event.message,
dashboardUrl: event.dashboardUrl,
sessionId: event.sessionId,
});
},
/** The success tail of the handoff RPC. Throws when the result carries
* no cloud session, so the caller's catch routes into onRpcRejected. */
async onRpcResolved(
sourceSessionId: string,
ctx: HandoffRpcResolvedContext,
ctx: RpcContext & { result: HandoffResult },
): Promise<void> {
const { result, nextCommand, sourceAttachments } = ctx;
const targetSessionId = result.outerSessionId?.trim();
const dashboardUrl = result.dashboardUrl?.trim();
if (!targetSessionId || !dashboardUrl) {
throw new Error("Cloud handoff did not return a cloud session.");
}
if (!acceptAttempt(sourceSessionId, ctx.handoffAttemptId)) return;
const destination = result.destination ?? "in_app";
const undelivered =
result.warning && result.warningKind !== "unconfirmed"
? {
command: nextCommand.trim() || undefined,
attachments: sourceAttachments,
}
: undefined;
const openInApp = shouldOpenHandoffInApp(
destination,
ctx.isThreadActive?.() ?? true,
);
const outcome = await reconcileCompletion(
sourceSessionId,
ctx.handoffAttemptId,
{
targetSessionId,
dashboardUrl,
externalPresentation: destination === "external",
warningKind: result.warningKind,
},
undelivered,
openInApp,
);
if (outcome?.opened === false && (ctx.isThreadActive?.() ?? true)) {
effects.dispatch({ type: "external", sourceSessionId });
try {
await effects.openExternal(dashboardUrl);
effects.toast({
title: "Opened handoff in your browser",
description:
"The cloud session could not be attached inside Cline.",
});
} catch {
effects.toast({
title: "Unable to open the browser",
description:
"Use the recovery link to open the cloud session manually.",
variant: "destructive",
});
}
} else if (destination === "external") {
await effects.openExternal(dashboardUrl).catch(() =>
effects.toast({
title: "Cloud handoff complete",
description: "Use the recovery link to open the cloud session.",
}),
);
} else if (!openInApp) {
effects.toast({
title: "Cloud handoff complete",
description: "The cloud session is ready in your session list.",
});
}
surfaceWarning(sourceSessionId, {
warning: result.warning,
warningKind: result.warningKind,
undeliveredCommand: undelivered?.command,
});
const attempt = current(sourceSessionId, ctx.handoffAttemptId);
if (!attempt) return;
if (!ctx.result.sessionId?.trim() || !ctx.result.dashboardUrl?.trim())
throw new Error("Cloud task creation did not return a session.");
complete(sourceSessionId, attempt, ctx.result);
},
/** The failure tail of the handoff RPC (its catch block). */
async onRpcRejected(
sourceSessionId: string,
ctx: HandoffRpcRejectedContext,
ctx: RpcContext & { error: unknown; silent?: boolean },
): Promise<void> {
const { error, nextCommand, sourceAttachments } = ctx;
const attempt = attemptFor(sourceSessionId, ctx.handoffAttemptId);
const completed = attempt.completion;
if (completed) {
const retry =
completed.warningKind === "unqueued"
? {
command: nextCommand.trim() || undefined,
attachments: sourceAttachments,
}
: undefined;
await reconcileCompletion(
const attempt = current(sourceSessionId, ctx.handoffAttemptId);
if (!attempt || attempt.completed) return;
if (attempt.target) {
effects.dispatch({
type: "complete",
sourceSessionId,
ctx.handoffAttemptId,
completed,
retry,
Boolean(retry?.command || retry?.attachments?.length) &&
shouldOpenHandoffInApp(
completed.externalPresentation ? "external" : "in_app",
ctx.isThreadActive?.() ?? true,
),
);
...attempt.target,
message:
"Delivery could not be confirmed. Check the cloud task before sending again.",
});
effects.toast({
title: "Handoff completed",
description:
"The connection dropped while reporting the result, but the cloud session is ready.",
title: "Check the cloud task",
description: `Delivery could not be confirmed. Open ${attempt.target.dashboardUrl} before sending again.`,
});
return;
}
attempt.retry = {
...(nextCommand.trim() ? { command: nextCommand.trim() } : {}),
...(sourceAttachments.length > 0
? { attachments: sourceAttachments }
: {}),
};
if (
ctx.handoffAttemptId &&
sourceFor(sourceSessionId).latest !== attempt
) {
return;
}
const raw =
ctx.error instanceof Error ? ctx.error.message : String(ctx.error);
const error = parseCloudSessionError(raw);
const message = humanizeCloudHandoffError(error?.message ?? raw);
effects.dispatch({
type: "failed",
sourceSessionId,
retryDraft: nextCommand,
retryAttachments: sourceAttachments,
message,
retryDraft: ctx.nextCommand,
retryAttachments: ctx.sourceAttachments,
});
if (!ctx.silent) toastFailure(error);
if (!ctx.silent) {
effects.toast({
title: "Handoff failed",
description: message,
variant: "destructive",
...(error?.connectUrl ? { connectUrl: error.connectUrl } : {}),
});
}
},
};
}
@@ -1,80 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { HandoffGitPreview } from "../../sidecar/cloud-handoff-git";
import { prepareHandoffWithGit } from "./cloud-handoff-preparation";
const ready = {
fingerprint: {
repoUrl: "https://github.com/cline/todo-app",
branch: "main",
headSha: "abc",
modelId: "model",
},
repoUrl: "https://github.com/cline/todo-app",
branch: "main",
modelId: "model",
};
const plan: HandoffGitPreview = {
id: "approved-plan",
repoUrl: ready.repoUrl,
remote: "origin",
branch: "cline/handoff-qa",
sourceBranch: "main",
files: [{ path: "file.txt", status: " M" }],
commits: [],
};
describe("prepareHandoffWithGit", () => {
it("keeps the ready path unchanged", async () => {
const inspect = vi.fn().mockResolvedValue(ready),
confirm = vi.fn(),
apply = vi.fn();
expect(await prepareHandoffWithGit({ inspect, confirm, apply })).toBe(
ready,
);
expect(confirm).not.toHaveBeenCalled();
expect(apply).not.toHaveBeenCalled();
});
it("cancellation publishes nothing", async () => {
const inspect = vi.fn().mockResolvedValue({ gitPreparation: plan }),
confirm = vi.fn().mockResolvedValue(false),
apply = vi.fn();
expect(await prepareHandoffWithGit({ inspect, confirm, apply })).toBeNull();
expect(inspect).toHaveBeenCalledOnce();
expect(apply).not.toHaveBeenCalled();
});
it("waits for explicit approval, applies only its token, and verifies again", async () => {
let decide: (value: boolean) => void = () => {};
const inspect = vi
.fn()
.mockResolvedValueOnce({ gitPreparation: plan })
.mockResolvedValueOnce(ready);
const confirm = vi.fn(
() =>
new Promise<boolean>((resolve) => {
decide = resolve;
}),
),
apply = vi.fn();
const result = prepareHandoffWithGit({ inspect, confirm, apply });
await vi.waitFor(() => expect(confirm).toHaveBeenCalledWith(plan));
expect(apply).not.toHaveBeenCalled();
decide(true);
expect(await result).toBe(ready);
expect(apply).toHaveBeenCalledExactlyOnceWith(plan.id);
expect(inspect).toHaveBeenCalledTimes(2);
});
it("does not retry a failed push or a still-blocked verification", async () => {
const inspect = vi.fn().mockResolvedValue({ gitPreparation: plan });
const confirm = vi.fn().mockResolvedValue(true);
const apply = vi.fn().mockRejectedValueOnce(new Error("push failed"));
await expect(
prepareHandoffWithGit({ inspect, confirm, apply }),
).rejects.toThrow("push failed");
expect(inspect).toHaveBeenCalledOnce();
apply.mockResolvedValue(undefined);
await expect(
prepareHandoffWithGit({ inspect, confirm, apply }),
).rejects.toThrow("still needs preparation");
expect(apply).toHaveBeenCalledTimes(2);
});
});
@@ -1,23 +0,0 @@
import type { HandoffGitPreview } from "../../sidecar/cloud-handoff-git";
import type { HandoffPreflight } from "./cloud-handoff";
export type HandoffPreparation =
| HandoffPreflight
| { gitPreparation: HandoffGitPreview };
export async function prepareHandoffWithGit(options: {
inspect: () => Promise<HandoffPreparation>;
confirm: (plan: HandoffGitPreview) => Promise<boolean>;
apply: (id: string) => Promise<unknown>;
}): Promise<HandoffPreflight | null> {
const initial = await options.inspect();
if (!("gitPreparation" in initial)) return initial;
if (!(await options.confirm(initial.gitPreparation))) return null;
await options.apply(initial.gitPreparation.id);
const prepared = await options.inspect();
if ("gitPreparation" in prepared)
throw new Error(
"The repository still needs preparation. Run /cloud again to review it; nothing will be published automatically.",
);
return prepared;
}
@@ -1,363 +1,68 @@
import { describe, expect, it } from "vitest";
import {
cloudHandoffUiReducer,
hasLivePendingHandoff,
resolveHandoffReceipt,
} from "./cloud-handoff-ui-state";
import { expect, it } from "vitest";
import { cloudHandoffUiReducer } from "./cloud-handoff-ui-state";
const RECEIPT = {
targetSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
};
const COMPLETE = {
type: "complete" as const,
sourceSessionId: "local-1",
receipt: RECEIPT,
externalPresentation: false,
};
describe("cloudHandoffUiReducer", () => {
it("recognizes live recovery ownership before history refreshes", () => {
expect(
hasLivePendingHandoff({
status: "recovery",
dashboardUrl: "https://app.cline.bot/agents/cloud-1",
}),
).toBe(true);
expect(
hasLivePendingHandoff({
status: "retry_restored",
dashboardUrl: "https://app.cline.bot/agents/cloud-1",
}),
).toBe(true);
expect(hasLivePendingHandoff({ status: "retry_restored" })).toBe(false);
expect(hasLivePendingHandoff({ status: "failed" })).toBe(false);
it("keeps the cloud link across local sends until dismissed or another handoff starts", () => {
const start = { type: "start", sourceSessionId: "local" } as const;
const state = cloudHandoffUiReducer({}, start);
const complete = cloudHandoffUiReducer(state, {
type: "complete",
sourceSessionId: "local",
sessionId: "cloud",
dashboardUrl: "https://app.cline.bot/agents/cloud",
});
it("keeps a persisted completion receipt alongside live recovery state", () => {
const persisted = {
targetSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents/cloud-1",
};
expect(
resolveHandoffReceipt(
{
status: "recovery",
dashboardUrl: persisted.dashboardUrl,
retryDraft: "continue",
},
persisted,
),
).toBe(persisted);
expect(resolveHandoffReceipt(undefined, persisted)).toBe(persisted);
});
it("clears a live recovery override after its payload reaches the target", () => {
const recovery = {
"local-1": {
status: "recovery" as const,
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
retryDraft: "continue",
retryAttachments: [
new File(["image"], "diagram.png", { type: "image/png" }),
],
},
};
expect(
cloudHandoffUiReducer(recovery, {
type: "retry_delivered",
sourceSessionId: "local-1",
retryDraft: recovery["local-1"].retryDraft,
retryAttachments: recovery["local-1"].retryAttachments,
}),
).toEqual({});
for (const delivered of [
{ ...recovery["local-1"], retryDraft: "older command" },
{ ...recovery["local-1"], retryAttachments: [] },
]) {
expect(
cloudHandoffUiReducer(recovery, {
type: "retry_delivered",
sourceSessionId: "local-1",
retryDraft: delivered.retryDraft,
retryAttachments: delivered.retryAttachments,
}),
).toBe(recovery);
}
});
it("updates progress while preserving the source's recovery URL", () => {
const creating = cloudHandoffUiReducer(
{},
{
type: "progress",
sourceSessionId: "local-1",
phase: "creating",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
},
);
const provisioning = cloudHandoffUiReducer(creating, {
type: "progress",
sourceSessionId: "local-1",
phase: "provisioning",
});
expect(provisioning["local-1"]).toMatchObject({
status: "progress",
phase: "provisioning",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
});
});
it("turns failed external progress into recovery without exposing in-app URLs", () => {
const progress = cloudHandoffUiReducer(
{},
{
type: "progress",
sourceSessionId: "local-1",
phase: "verifying",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
},
);
const recovery = cloudHandoffUiReducer(progress, {
type: "failed",
sourceSessionId: "local-1",
retryDraft: "continue",
});
expect(recovery["local-1"]).toEqual({
status: "recovery",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
retryDraft: "continue",
retryAttachments: undefined,
});
expect(
cloudHandoffUiReducer(recovery, {
type: "retry_restored",
sourceSessionId: "local-1",
})["local-1"],
).toEqual({
status: "retry_restored",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
retryDraft: "continue",
retryAttachments: undefined,
});
expect(
cloudHandoffUiReducer(recovery, {
type: "progress",
sourceSessionId: "local-1",
phase: "seeding",
}),
).toBe(recovery);
const failed = cloudHandoffUiReducer(
{},
{
type: "failed",
sourceSessionId: "local-1",
},
);
expect(
cloudHandoffUiReducer(failed, {
type: "progress",
sourceSessionId: "local-1",
phase: "seeding",
}),
).toBe(failed);
const restored = cloudHandoffUiReducer(failed, {
type: "retry_restored",
sourceSessionId: "local-1",
});
expect(restored["local-1"]).toEqual({
status: "retry_restored",
retryDraft: undefined,
retryAttachments: undefined,
});
expect(
cloudHandoffUiReducer(restored, {
type: "progress",
sourceSessionId: "local-1",
phase: "seeding",
}),
).toBe(restored);
});
it.each([
"failed",
"retry_restored",
] as const)("consumes %s draft and images after a local send", (status) => {
const state = {
"local-1": {
status,
retryDraft: "describe",
retryAttachments: [new File(["image"], "qa.png")],
},
};
expect(
cloudHandoffUiReducer(state, {
type: "local_prompt_delivered",
sourceSessionId: "local-1",
}),
).toEqual({});
});
it.each([
{ status: "progress" as const, phase: "creating" as const },
{
status: "recovery" as const,
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
},
{
status: "retry_restored" as const,
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
retryDraft: "describe",
},
{
status: "complete" as const,
receipt: RECEIPT,
externalPresentation: false,
},
])("retains $status ownership state after a stale local send", (entry) => {
const state = { "local-1": entry };
expect(
cloudHandoffUiReducer(state, {
type: "local_prompt_delivered",
sourceSessionId: "local-1",
}),
).toBe(state);
});
it("lets an explicit retry replace recovery while ignoring late old progress", () => {
const recovery = {
"local-1": {
status: "recovery" as const,
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
},
};
expect(
cloudHandoffUiReducer(recovery, {
type: "progress",
sourceSessionId: "local-1",
phase: "seeding",
}),
).toBe(recovery);
const retry = cloudHandoffUiReducer(recovery, {
type: "start",
sourceSessionId: "local-1",
});
expect(retry["local-1"]).toEqual({
status: "progress",
phase: "checking",
// A retry keeps the recovery URL so an early failure cannot drop
// the only dashboard link held in live state.
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
});
});
it("keeps the completion receipt when a late failure lands after complete", () => {
const completed = cloudHandoffUiReducer({}, COMPLETE);
const retryAttachments = [new File(["edited"], "edited.png")];
expect(Object.keys(completed)).toEqual(["local-1"]);
const withRecovery = cloudHandoffUiReducer(completed, {
type: "failed",
sourceSessionId: "local-1",
retryDraft: "continue",
retryAttachments,
});
expect(withRecovery["local-1"]).toMatchObject({
status: "complete",
receipt: RECEIPT,
retryDraft: "continue",
retryAttachments,
});
expect(
cloudHandoffUiReducer(completed, {
type: "failed",
sourceSessionId: "local-1",
}),
).toBe(completed);
expect(completed["local-1"]).toEqual({
status: "complete",
receipt: RECEIPT,
externalPresentation: false,
});
});
it.each([
{ status: "recovery" as const },
{
status: "retry_restored" as const,
retryDraft: "continue",
retryAttachments: [
new File(["image"], "diagram.png", { type: "image/png" }),
],
},
])("dismisses $status without losing its payload or accepting late progress", (entry) => {
const recovery = {
"local-1": {
...entry,
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
},
};
const dismissed = cloudHandoffUiReducer(recovery, {
type: "dismiss_recovery",
sourceSessionId: "local-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
});
expect(dismissed["local-1"]).toEqual({
...entry,
status: "recovery_dismissed",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
});
expect(
cloudHandoffUiReducer(dismissed, {
type: "progress",
sourceSessionId: "local-1",
phase: "seeding",
}),
).toBe(dismissed);
expect(
cloudHandoffUiReducer(dismissed, {
type: "start",
sourceSessionId: "local-1",
})["local-1"],
).toEqual({
status: "progress",
phase: "checking",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
});
});
it("ignores a stale recovery dismissal after completion", () => {
const completed = cloudHandoffUiReducer({}, COMPLETE);
expect(
cloudHandoffUiReducer(completed, {
type: "dismiss_recovery",
sourceSessionId: "local-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
}),
).toBe(completed);
});
it("keeps the recovery URL when retrying an automatically restored draft", () => {
const restored = cloudHandoffUiReducer(
{
"local-1": {
status: "recovery",
dashboardUrl: RECEIPT.dashboardUrl,
retryDraft: "continue",
},
},
{ type: "retry_restored", sourceSessionId: "local-1" },
);
const retry = cloudHandoffUiReducer(restored, {
type: "start",
sourceSessionId: "local-1",
});
expect(retry["local-1"]).toMatchObject({
status: "progress",
dashboardUrl: RECEIPT.dashboardUrl,
});
expect(complete.local).toMatchObject({
status: "complete",
sessionId: "cloud",
});
expect(
cloudHandoffUiReducer(complete, {
type: "local_prompt_delivered",
sourceSessionId: "local",
}),
).toEqual(complete);
expect(
cloudHandoffUiReducer(complete, {
type: "dismiss",
sourceSessionId: "local",
}),
).toEqual({});
expect(cloudHandoffUiReducer(complete, start)).toEqual(state);
});
it("retains failed input until restored and clears it after a local send", () => {
const attachments = [
new File(["image"], "screen.png", { type: "image/png" }),
];
const failed = cloudHandoffUiReducer(
{},
{
type: "failed",
sourceSessionId: "local",
message: "Summary failed",
retryDraft: "continue",
retryAttachments: attachments,
},
);
const restored = cloudHandoffUiReducer(failed, {
type: "retry_restored",
sourceSessionId: "local",
});
expect(
cloudHandoffUiReducer(failed, {
type: "local_prompt_delivered",
sourceSessionId: "local",
}),
).toEqual(failed);
expect(restored.local).toEqual({
status: "retry_restored",
message: "Summary failed",
retryDraft: "continue",
retryAttachments: attachments,
});
expect(
cloudHandoffUiReducer(restored, {
type: "local_prompt_delivered",
sourceSessionId: "local",
}),
).toEqual({});
});
@@ -1,98 +1,54 @@
import type { HandoffProgressPhase, HandoffReceipt } from "@/lib/cloud-handoff";
import type { HandoffProgressPhase } from "./cloud-handoff";
export type CloudHandoffUiEntry =
| {
status: "progress";
phase: HandoffProgressPhase;
phase: Exclude<HandoffProgressPhase, "complete">;
message?: string;
dashboardUrl?: string;
}
| {
status: "recovery";
dashboardUrl: string;
retryDraft?: string;
retryAttachments?: File[];
}
| {
status: "recovery_dismissed";
dashboardUrl: string;
retryDraft?: string;
retryAttachments?: File[];
}
| { status: "failed"; retryDraft?: string; retryAttachments?: File[] }
| {
status: "retry_restored";
dashboardUrl?: string;
retryDraft?: string;
retryAttachments?: File[];
sessionId?: string;
}
| {
status: "complete";
receipt: HandoffReceipt;
externalPresentation: boolean;
retryDraft?: string;
retryAttachments?: File[];
sessionId: string;
dashboardUrl: string;
message?: string;
}
| {
status: "failed" | "retry_restored";
message: string;
retryDraft: string;
retryAttachments: File[];
};
export type CloudHandoffUiState = Record<string, CloudHandoffUiEntry>;
export function hasLivePendingHandoff(
entry: CloudHandoffUiEntry | undefined,
): boolean {
return (
entry?.status === "recovery" ||
entry?.status === "recovery_dismissed" ||
(entry?.status === "retry_restored" && Boolean(entry.dashboardUrl))
);
}
export function resolveHandoffReceipt(
live: CloudHandoffUiEntry | undefined,
persisted: HandoffReceipt | null,
): HandoffReceipt | null {
if (!live) return persisted;
return live.status === "complete" ? live.receipt : persisted;
}
export type CloudHandoffUiAction =
| {
type: "start";
type: "start" | "dismiss" | "local_prompt_delivered" | "retry_restored";
sourceSessionId: string;
}
| {
type: "complete";
sourceSessionId: string;
sessionId: string;
dashboardUrl: string;
message?: string;
}
| {
type: "progress";
sourceSessionId: string;
phase: Exclude<HandoffProgressPhase, "complete">;
message?: string;
dashboardUrl?: string;
sessionId?: string;
}
| {
type: "failed";
sourceSessionId: string;
retryDraft?: string;
retryAttachments?: File[];
}
| {
type: "complete";
sourceSessionId: string;
receipt: HandoffReceipt;
externalPresentation: boolean;
retryDraft?: string;
retryAttachments?: File[];
}
| { type: "external"; sourceSessionId: string }
| {
type: "dismiss_recovery";
sourceSessionId: string;
dashboardUrl: string;
}
| { type: "retry_restored"; sourceSessionId: string }
| { type: "local_prompt_delivered"; sourceSessionId: string }
| {
type: "retry_delivered";
sourceSessionId: string;
retryDraft?: string;
retryAttachments?: File[];
retryDraft: string;
retryAttachments: File[];
message: string;
};
export function cloudHandoffUiReducer(
@@ -101,155 +57,60 @@ export function cloudHandoffUiReducer(
): CloudHandoffUiState {
const current = state[action.sourceSessionId];
switch (action.type) {
case "start": {
// A retry can fail before progress re-supplies the recovery URL.
const carriedDashboardUrl =
current?.status === "recovery" ||
current?.status === "recovery_dismissed" ||
current?.status === "retry_restored" ||
current?.status === "progress"
? current.dashboardUrl
: undefined;
case "start":
return {
...state,
[action.sourceSessionId]: {
status: "progress",
phase: "checking",
...(carriedDashboardUrl ? { dashboardUrl: carriedDashboardUrl } : {}),
},
[action.sourceSessionId]: { status: "progress", phase: "checking" },
};
}
case "progress":
if (current && current.status !== "progress") {
return state;
}
if (current?.status !== "progress") return state;
return {
...state,
[action.sourceSessionId]: {
status: "progress",
phase: action.phase,
message: action.message,
dashboardUrl: action.dashboardUrl || current?.dashboardUrl,
dashboardUrl: action.dashboardUrl ?? current.dashboardUrl,
sessionId: action.sessionId ?? current.sessionId,
},
};
case "failed": {
// Completion remains authoritative if the RPC transport subsequently fails.
if (current?.status === "complete") {
if (!action.retryDraft && !action.retryAttachments?.length)
return state;
return {
...state,
[action.sourceSessionId]: {
...current,
retryDraft: action.retryDraft,
retryAttachments: action.retryAttachments,
},
};
}
const dashboardUrl =
current?.status === "progress" ? current.dashboardUrl : undefined;
if (dashboardUrl) {
return {
...state,
[action.sourceSessionId]: {
status: "recovery",
dashboardUrl,
retryDraft: action.retryDraft,
retryAttachments: action.retryAttachments,
},
};
}
case "failed":
return {
...state,
[action.sourceSessionId]: {
status: "failed",
message: action.message,
retryDraft: action.retryDraft,
retryAttachments: action.retryAttachments,
},
};
}
case "retry_restored":
return current?.status === "failed"
? {
...state,
[action.sourceSessionId]: { ...current, status: "retry_restored" },
}
: state;
case "complete":
return {
...state,
[action.sourceSessionId]: {
status: "complete",
receipt: action.receipt,
externalPresentation: action.externalPresentation,
...(action.retryDraft ? { retryDraft: action.retryDraft } : {}),
...(action.retryAttachments?.length
? { retryAttachments: action.retryAttachments }
: {}),
},
};
case "external": {
if (current?.status !== "complete") return state;
return {
...state,
[action.sourceSessionId]: {
...current,
externalPresentation: true,
},
};
}
case "dismiss_recovery":
if (current?.status === "complete" || current?.status === "progress") {
return state;
}
return {
...state,
[action.sourceSessionId]: {
status: "recovery_dismissed",
sessionId: action.sessionId,
dashboardUrl: action.dashboardUrl,
...(current && "retryDraft" in current
? { retryDraft: current.retryDraft }
: {}),
...(current && "retryAttachments" in current
? { retryAttachments: current.retryAttachments }
: {}),
message: action.message,
},
};
case "retry_restored":
if (current?.status === "failed" || current?.status === "recovery") {
return {
...state,
[action.sourceSessionId]: {
...current,
status: "retry_restored",
},
};
}
return state;
case "local_prompt_delivered": {
case "local_prompt_delivered":
case "dismiss": {
if (
current?.status !== "failed" &&
!(current?.status === "retry_restored" && !current.dashboardUrl)
action.type === "local_prompt_delivered" &&
current?.status !== "retry_restored"
)
return state;
const next = { ...state };
delete next[action.sourceSessionId];
return next;
}
case "retry_delivered": {
if (!current) return state;
if (
current.status === "progress" ||
current.retryDraft !== action.retryDraft ||
current.retryAttachments !== action.retryAttachments
)
return state;
if (current.status !== "complete") {
const next = { ...state };
delete next[action.sourceSessionId];
return next;
}
return {
...state,
[action.sourceSessionId]: {
status: "complete",
receipt: current.receipt,
externalPresentation: current.externalPresentation,
},
};
}
}
}
@@ -3,9 +3,6 @@ import {
buildHandoffWarningToast,
isExpectedHandoffSourceActive,
parseHandoffCommand,
readHandoffReceipt,
readPendingHandoffRecovery,
shouldOpenHandoffInApp,
validateHandoffAttachments,
} from "./cloud-handoff";
@@ -19,74 +16,14 @@ describe("cloud handoff helpers", () => {
expect(parseHandoffCommand("please /cloud")).toBeNull();
});
it("accepts images only when a cloud command will consume them", () => {
it("accepts invocation images with or without an explicit follow-up", () => {
const image = new File(["image"], "screen.png", { type: "image/png" });
const text = new File(["text"], "notes.txt", { type: "text/plain" });
expect(validateHandoffAttachments([image], "inspect this")).toBeNull();
expect(validateHandoffAttachments([image], "")).toContain("Add a command");
expect(validateHandoffAttachments([text], "inspect this")).toContain(
"notes.txt",
expect(validateHandoffAttachments([image])).toBeNull();
expect(validateHandoffAttachments([text])).toContain("notes.txt");
expect(validateHandoffAttachments(Array(6).fill(image))).toContain(
"up to 5",
);
expect(
validateHandoffAttachments(Array(6).fill(image), "inspect this"),
).toContain("up to 5");
});
it("reads completed handoff metadata into a receipt", () => {
expect(
readHandoffReceipt({
handoff: {
status: "complete",
toCloudSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
},
}),
).toEqual({
targetSessionId: "cloud-1",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-1",
});
expect(readHandoffReceipt({ handoff: { status: "pending" } })).toBeNull();
});
it("reads pending handoff metadata into restart recovery", () => {
const cloudHandoffIntent = {
fingerprint: {
repoUrl: "https://github.com/cline/test",
branch: "main",
headSha: "abc123",
modelId: "test-model",
},
};
expect(readPendingHandoffRecovery({ cloudHandoffIntent })).toEqual({});
expect(readPendingHandoffRecovery({})).toBeNull();
expect(
readPendingHandoffRecovery({
handoff: {
status: "pending",
toCloudSessionId: "cloud-pending",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-pending",
},
}),
).toEqual({
targetSessionId: "cloud-pending",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-pending",
});
expect(
readPendingHandoffRecovery({
handoff: {
status: "complete",
toCloudSessionId: "cloud-complete",
dashboardUrl: "https://app.cline.bot/agents?sessionId=cloud-complete",
},
cloudHandoffIntent,
}),
).toBeNull();
});
it("only focuses an in-app target while its source is still active", () => {
expect(shouldOpenHandoffInApp("in_app", true)).toBe(true);
expect(shouldOpenHandoffInApp("in_app", false)).toBe(false);
expect(shouldOpenHandoffInApp("external", true)).toBe(false);
});
it("only restores a late handoff into the expected active chat thread", () => {
@@ -1,43 +1,44 @@
import type {
CloudHandoffFingerprint,
CloudHandoffProgressPhase,
CloudHandoffResult,
} from "@cline/core";
import {
cloudImageAttachmentError,
isSupportedImageAttachment,
} from "@/lib/image-attachments";
import type { SessionMetadata } from "@/lib/session-history";
export type ParsedHandoffCommand = {
type ParsedHandoffCommand = {
nextCommand: string;
};
export type HandoffPreflight = {
fingerprint: CloudHandoffFingerprint;
gitPreparationId: string;
repoUrl: string;
branch: string;
headSha?: string;
headSha: string;
modelId: string;
};
export type HandoffResult = CloudHandoffResult;
export type HandoffReceipt = {
targetSessionId: string;
export type HandoffResult = {
sourceSessionId: string;
sessionId: string;
dashboardUrl: string;
destination: "in_app" | "external";
warning?: string;
warningKind?: "unqueued" | "unconfirmed";
undeliveredCommand?: string;
};
export type HandoffProgressPhase = CloudHandoffProgressPhase;
export type HandoffProgressPhase =
| "checking"
| "summarizing"
| "creating"
| "provisioning"
| "sending"
| "complete";
export const HANDOFF_PROGRESS_LABELS: Record<HandoffProgressPhase, string> = {
checking: "Checking the local session...",
creating: "Creating the cloud session...",
provisioning: "Preparing the cloud workspace...",
connecting: "Connecting to the cloud agent...",
seeding: "Transferring the conversation...",
verifying: "Verifying the handoff...",
starting: "Starting the cloud task...",
summarizing: "Summarizing the local conversation...",
sending: "Sending the cloud task...",
complete: "Cloud handoff complete.",
};
@@ -54,66 +55,14 @@ export function parseHandoffCommand(
export function validateHandoffAttachments(
files: readonly File[],
nextCommand: string,
): string | null {
const nonImage = files.find((file) => !isSupportedImageAttachment(file));
if (nonImage) {
return `Cloud handoff only supports image attachments. Remove ${nonImage.name} and try again.`;
}
if (files.length > 0 && !nextCommand.trim()) {
return "Add a command after /cloud to send the attached images in cloud.";
}
return cloudImageAttachmentError([...files]) ?? null;
}
function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function readHandoffReceipt(
metadata?: SessionMetadata,
): HandoffReceipt | null {
const raw = metadata?.handoff;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const handoff = raw as Record<string, unknown>;
if (handoff.status !== "complete") {
return null;
}
const targetSessionId = stringField(handoff.toCloudSessionId);
const dashboardUrl = stringField(handoff.dashboardUrl);
return targetSessionId && dashboardUrl
? { targetSessionId, dashboardUrl }
: null;
}
export function readPendingHandoffRecovery(
metadata?: SessionMetadata,
): Partial<HandoffReceipt> | null {
const raw = metadata?.handoff;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
// Creation can be unresolved before a target URL exists.
return metadata?.cloudHandoffIntent ? {} : null;
}
const handoff = raw as Record<string, unknown>;
if (handoff.status !== "pending") {
return null;
}
const targetSessionId = stringField(handoff.toCloudSessionId);
const dashboardUrl = stringField(handoff.dashboardUrl);
return targetSessionId && dashboardUrl
? { targetSessionId, dashboardUrl }
: null;
}
export function shouldOpenHandoffInApp(
destination: HandoffResult["destination"],
isSourceStillActive: boolean,
): boolean {
return destination === "in_app" && isSourceStillActive;
}
export function isExpectedHandoffSourceActive(
expectedThreadId: string | undefined,
activeThreadId: string,
@@ -125,18 +74,14 @@ export function isExpectedHandoffSourceActive(
);
}
export type HandoffWarningToast = {
type HandoffWarningToast = {
title: string;
description: string;
variant?: "destructive";
};
/**
* The single source of truth for how a handoff completion warning is shown,
* shared by the RPC result path and the `cloud_handoff_progress` complete
* event path so both surface the identical toast. Returns null when the
* payload carries no warning. An unconfirmed follow-up must never quote the
* command for resending — it may already be durably queued.
* Do not offer an unconfirmed command for resending. It may already be queued.
*/
export function buildHandoffWarningToast(fields: {
warning?: unknown;
@@ -156,7 +101,7 @@ export function buildHandoffWarningToast(fields: {
return {
title: "Handoff completed with a warning",
description: undeliveredCommand
? `${warning} Your command was kept: "${undeliveredCommand}" — send it from the cloud session.`
? `${warning} The prepared prompt was kept in the cloud session.`
: warning,
...(unconfirmed ? { variant: "destructive" as const } : {}),
};
+16 -2
View File
@@ -1,7 +1,21 @@
import { defineConfig } from "@vscode/test-cli"
import { createHash } from "crypto"
import os from "os"
import path from "path"
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
const extensionDevelopmentPath = path.resolve("./")
// VS Code puts its IPC socket (e.g. `1.14-main.sock`) in the user data dir, and
// macOS caps unix socket paths at 103 chars. The default `.vscode-test/user-data`
// overflows that from deep checkouts such as worktrees (`listen EINVAL`), so on
// macOS use a short dir in the temp folder, keyed by checkout so concurrent runs
// from different worktrees don't hand off to each other's instance.
const launchArgs = ["--disable-extensions"]
if (process.platform === "darwin") {
const checkoutHash = createHash("sha256").update(extensionDevelopmentPath).digest("hex").slice(0, 8)
launchArgs.push(`--user-data-dir=${path.join(os.tmpdir(), `cline-vscode-test-${checkoutHash}`)}`)
}
export default defineConfig({
files: [
@@ -25,6 +39,6 @@ export default defineConfig({
},
workspaceFolder: "test-workspace",
version: vscodeTestVersion,
extensionDevelopmentPath: path.resolve("./"),
launchArgs: ["--disable-extensions"],
extensionDevelopmentPath,
launchArgs,
})
+4 -3
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "4.1.24",
"version": "4.1.25",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
@@ -433,7 +433,7 @@
"@types/vscode": "1.101.0",
"@types/ws": "^8.18.1",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/test-electron": "^3.1.0",
"@vscode/vsce": "^3.6.0",
"c8": "^10.1.3",
"chai": "^4.3.10",
@@ -496,7 +496,7 @@
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"aws4fetch": "^1.0.20",
"axios": "1.18.0",
"axios": "1.20.0",
"better-sqlite3": "^12.4.1",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
@@ -548,6 +548,7 @@
"undici": "^7.29.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"c8": {
@@ -19,6 +19,7 @@ function makeControllerStub(initial: {
const controller = {
stateManager: {
getGlobalSettingsKey: (key: string) => globalState.get(key) ?? {},
getGlobalStateKey: (key: string) => globalState.get(key),
getWorkspaceStateKey: (key: string) => workspaceState.get(key) ?? {},
setGlobalState: (key: string, value: unknown) => globalState.set(key, value),
setWorkspaceState: (key: string, value: unknown) => workspaceState.set(key, value),
@@ -75,8 +76,10 @@ describe("refreshClineRulesToggles workspace layouts", () => {
await fs.mkdir(clineRulesDir, { recursive: true })
const legacyRule = path.join(legacyDir, "a.md")
const newRule = path.join(clineRulesDir, "b.md")
await fs.writeFile(legacyRule, "A")
await fs.writeFile(newRule, "B")
// Disabled rules carry `disabled: true`, which is what the SDK loader
// and the panel read.
await fs.writeFile(legacyRule, "---\ndisabled: true\n---\nA")
await fs.writeFile(newRule, "---\ndisabled: true\n---\nB")
const controller = makeControllerStub({
localClineRulesToggles: {
@@ -0,0 +1,229 @@
import { afterEach, describe, it } from "bun:test"
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
import path from "path"
import type { Controller } from "@/core/controller"
import {
refreshClineRulesToggles,
resolveWritableRuleFile,
setRuleDisabledInFrontmatter,
syncRuleTogglesFromFrontmatter,
} from "../cline-rules"
import { parseYamlFrontmatter, readSdkEnabledState } from "../frontmatter"
const temporaryDirectories: string[] = []
async function makeTempDir(): Promise<string> {
// realpath: on macOS os.tmpdir() is itself a symlink (/var -> /private/var).
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-write-test-")))
temporaryDirectories.push(dir)
return dir
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
})
describe("setRuleDisabledInFrontmatter", () => {
it("round-trips a rule document inside an allowed root", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "project-rule.md")
await fs.writeFile(rulePath, "Follow this rule")
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("written")
const disabled = parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8"))
expect(disabled.data.disabled).to.equal(true)
expect(disabled.body).to.equal("Follow this rule")
expect(await setRuleDisabledInFrontmatter(rulePath, true, [rulesDir])).to.equal("written")
expect(await fs.readFile(rulePath, "utf-8")).to.equal("Follow this rule")
})
it("accepts the legacy single .clinerules file as its own root", async () => {
const workspace = await makeTempDir()
const rulePath = path.join(workspace, ".clinerules")
await fs.writeFile(rulePath, "Single-file rules")
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulePath])).to.equal("written")
expect(parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8")).data.disabled).to.equal(true)
})
it("skips files the SDK loader does not read", async () => {
const rulesDir = await makeTempDir()
const jsonPath = path.join(rulesDir, "settings.json")
await fs.writeFile(jsonPath, '{"a":1}')
expect(await setRuleDisabledInFrontmatter(jsonPath, false, [rulesDir])).to.equal("skipped")
expect(await fs.readFile(jsonPath, "utf-8")).to.equal('{"a":1}')
})
it("skips paths outside the allowed roots, including through symlinks", async () => {
const rulesDir = await makeTempDir()
const elsewhere = await makeTempDir()
const targetPath = path.join(elsewhere, "not-a-rule.md")
await fs.writeFile(targetPath, "Do not touch")
const linkPath = path.join(rulesDir, "linked.md")
await fs.symlink(targetPath, linkPath)
expect(await setRuleDisabledInFrontmatter(targetPath, false, [rulesDir])).to.equal("skipped")
expect(await setRuleDisabledInFrontmatter(linkPath, false, [rulesDir])).to.equal("skipped")
expect(await resolveWritableRuleFile(linkPath, [rulesDir])).to.equal(null)
expect(await fs.readFile(targetPath, "utf-8")).to.equal("Do not touch")
})
it("skips files nested below a rules root, which the SDK loader never reads", async () => {
const rulesDir = await makeTempDir()
const nestedPath = path.join(rulesDir, "nested", "deep.md")
await fs.mkdir(path.dirname(nestedPath), { recursive: true })
await fs.writeFile(nestedPath, "Nested")
expect(await setRuleDisabledInFrontmatter(nestedPath, false, [rulesDir])).to.equal("skipped")
expect(await fs.readFile(nestedPath, "utf-8")).to.equal("Nested")
})
it("reports a failure when the rule file cannot be written", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "read-only.md")
await fs.writeFile(rulePath, "Locked rule")
await fs.chmod(rulePath, 0o444)
try {
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("failed")
expect(await fs.readFile(rulePath, "utf-8")).to.equal("Locked rule")
} finally {
await fs.chmod(rulePath, 0o644)
}
})
it("reports a failure when malformed frontmatter prevents a safe edit", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "broken.md")
const content = "---\npaths: [invalid\n---\nBody"
await fs.writeFile(rulePath, content)
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("failed")
expect(await fs.readFile(rulePath, "utf-8")).to.equal(content)
})
it("reports success without writing when the file already carries the requested state", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "already-off.md")
const content = "---\ndisabled: true\n---\nBody"
await fs.writeFile(rulePath, content)
const before = (await fs.stat(rulePath)).mtimeMs
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("written")
expect((await fs.stat(rulePath)).mtimeMs).to.equal(before)
})
it("rejects relative and missing paths", async () => {
const rulesDir = await makeTempDir()
expect(await setRuleDisabledInFrontmatter("relative.md", false, [rulesDir])).to.equal("skipped")
expect(await setRuleDisabledInFrontmatter(path.join(rulesDir, "missing.md"), false, [rulesDir])).to.equal("skipped")
})
})
describe("syncRuleTogglesFromFrontmatter", () => {
it("shows a toggle saved before the fix as on and leaves its file untouched", async () => {
const rulesDir = await makeTempDir()
const offPath = path.join(rulesDir, "off.md")
const onPath = path.join(rulesDir, "on.md")
await fs.writeFile(offPath, "Was toggled off in state only")
await fs.writeFile(onPath, "Still on")
const toggles = await syncRuleTogglesFromFrontmatter({ [offPath]: false, [onPath]: true }, [rulesDir])
expect(toggles).to.deep.equal({ [offPath]: true, [onPath]: true })
expect(await fs.readFile(offPath, "utf-8")).to.equal("Was toggled off in state only")
})
it("shows a rule as off when its frontmatter disables it", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "hand-edited.md")
const content = "---\ndisabled: true\n---\nDisabled by hand"
await fs.writeFile(rulePath, content)
const toggles = await syncRuleTogglesFromFrontmatter({ [rulePath]: true }, [rulesDir])
expect(toggles[rulePath]).to.equal(false)
expect(await fs.readFile(rulePath, "utf-8")).to.equal(content)
})
it("matches the SDK precedence: disabled: false beats enabled: false", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "both-flags.md")
await fs.writeFile(rulePath, "---\ndisabled: false\nenabled: false\n---\nInjected by the SDK")
const toggles = await syncRuleTogglesFromFrontmatter({ [rulePath]: false }, [rulesDir])
expect(toggles[rulePath]).to.equal(true)
})
it("shows a rule the SDK cannot load as off, whatever the stored toggle says", async () => {
const rulesDir = await makeTempDir()
const malformed = path.join(rulesDir, "malformed.md")
const emptyBody = path.join(rulesDir, "empty-body.md")
const nonBoolean = path.join(rulesDir, "non-boolean.md")
await fs.writeFile(malformed, "---\npaths: [invalid\n---\nBody")
await fs.writeFile(emptyBody, "---\nname: x\n---\n")
await fs.writeFile(nonBoolean, "---\ndisabled: [true]\n---\nBody")
const toggles = await syncRuleTogglesFromFrontmatter({ [malformed]: true, [emptyBody]: true, [nonBoolean]: true }, [
rulesDir,
])
expect(toggles).to.deep.equal({ [malformed]: false, [emptyBody]: false, [nonBoolean]: false })
})
it("shows a rule with non-mapping frontmatter as on, since the SDK loads it", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "divider.md")
await fs.writeFile(rulePath, "---\nJust a divider line\n---\nBody")
const toggles = await syncRuleTogglesFromFrontmatter({ [rulePath]: false }, [rulesDir])
expect(toggles[rulePath]).to.equal(true)
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("written")
expect(readSdkEnabledState(await fs.readFile(rulePath, "utf-8"))).to.equal(false)
})
it("keeps the stored toggle for files it cannot read as rules", async () => {
const rulesDir = await makeTempDir()
const elsewhere = await makeTempDir()
const outside = path.join(elsewhere, "outside.md")
const json = path.join(rulesDir, "data.json")
const broken = path.join(rulesDir, "broken.md")
await fs.writeFile(outside, "Outside")
await fs.writeFile(json, "{}")
await fs.writeFile(broken, "---\npaths: [invalid\n---\nBody")
const toggles = await syncRuleTogglesFromFrontmatter({ [outside]: false, [json]: false, [broken]: false }, [rulesDir])
expect(toggles).to.deep.equal({ [outside]: false, [json]: false, [broken]: false })
})
})
describe("refreshClineRulesToggles", () => {
it("derives workspace toggles from the rule files", async () => {
const workspace = await makeTempDir()
const offPath = path.join(workspace, ".clinerules", "off.md")
const stalePath = path.join(workspace, ".clinerules", "stale.md")
await fs.mkdir(path.dirname(offPath), { recursive: true })
await fs.writeFile(offPath, "---\ndisabled: true\n---\nOff")
await fs.writeFile(stalePath, "Toggled off before the fix")
const workspaceState = new Map<string, unknown>([["localClineRulesToggles", { [stalePath]: false }]])
const controller = {
stateManager: {
getGlobalSettingsKey: () => ({}),
getWorkspaceStateKey: (key: string) => workspaceState.get(key) ?? {},
setGlobalState: () => undefined,
setWorkspaceState: (key: string, value: unknown) => workspaceState.set(key, value),
},
} as unknown as Controller
const { localToggles } = await refreshClineRulesToggles(controller, workspace)
expect(localToggles).to.deep.equal({ [offPath]: false, [stalePath]: true })
expect(await fs.readFile(stalePath, "utf-8")).to.equal("Toggled off before the fix")
})
})
@@ -1,6 +1,12 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
import { parseDocument } from "yaml"
import {
isFrontmatterDisabled,
parseYamlFrontmatter,
readSdkEnabledState,
updateUserInstructionMarkdownDisabledState,
} from "../frontmatter"
describe("parseYamlFrontmatter", () => {
it("returns original content when no frontmatter", () => {
@@ -69,3 +75,215 @@ describe("parseYamlFrontmatter", () => {
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
})
})
describe("updateUserInstructionMarkdownDisabledState", () => {
it("adds disabled frontmatter when a rule is toggled off", () => {
const output = updateUserInstructionMarkdownDisabledState("Follow this rule", false)
expect(output).to.equal("---\ndisabled: true\n---\nFollow this rule")
})
it("preserves existing frontmatter fields when disabling", () => {
const input = ["---", "paths:", " - src/**", "---", "Scoped rule"].join("\n")
const output = updateUserInstructionMarkdownDisabledState(input, false)
expect(output).to.contain("disabled: true")
expect(output).to.contain("paths:")
expect(output).to.contain("Scoped rule")
})
it("removes disabled frontmatter when a rule is toggled back on", () => {
const input = ["---", "disabled: true", "---", "Follow this rule"].join("\n")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal("Follow this rule")
})
it("leaves malformed frontmatter untouched", () => {
const input = ["---", "paths: [invalid", "---", "Rule body"].join("\n")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(input)
})
})
const lines = (...parts: string[]) => parts.join("\n")
/** Every top-level value except the enablement flags, as the SDK parser reads it. */
function otherValues(content: string): Record<string, unknown> {
const block = content.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ""
const data = (parseDocument(block).toJS() ?? {}) as Record<string, unknown>
const { disabled: _disabled, enabled: _enabled, ...rest } = data
return rest
}
function bodyOf(content: string): string {
return parseYamlFrontmatter(content).body
}
describe("updateUserInstructionMarkdownDisabledState preserves authored frontmatter", () => {
it("keeps comments, key order, and quoting when disabling", () => {
const input = lines("---", "# scope this rule", "paths:", " - 'src/**'", 'name: "My rule"', "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(
lines("---", "# scope this rule", "paths:", " - 'src/**'", 'name: "My rule"', "disabled: true", "---", "Body"),
)
})
it("sets an existing disabled value in place, keeping its comment", () => {
const input = lines("---", "disabled: false # important explanation", "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(
lines("---", "disabled: true # important explanation", "paths:", " - src/**", "---", "Body"),
)
})
it("keeps a commented disabled entry on re-enable and only flips its value", () => {
const input = lines("---", "disabled: true # important explanation", "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "disabled: false # important explanation", "paths:", " - src/**", "---", "Body"),
)
})
it("keeps a comment placed before a split value", () => {
const input = lines("---", "disabled:", " # why this is off", " true", "name: x", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "disabled:", " # why this is off", " false", "name: x", "---", "Body"),
)
})
it("keeps an indented comment that follows an inline disabled value", () => {
const input = lines("---", "disabled: true", " # keep this note", "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "disabled: false", " # keep this note", "paths:", " - src/**", "---", "Body"),
)
})
it("removes uncommented disabled and enabled: false entries when re-enabling", () => {
const input = lines("---", "# keep me", "enabled: false", "paths:", " - src/**", "disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "# keep me", "paths:", " - src/**", "---", "Body"),
)
})
it("removes a legacy enabled: false whose value is on the next line", () => {
const input = lines("---", "disabled: true", "enabled:", " false", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal("Body")
})
it("leaves matching text inside other values untouched", () => {
const input = lines("---", 'description: "first', " disabled: true", ' last"', "disabled: true", "---", "Body")
const output = updateUserInstructionMarkdownDisabledState(input, true)
expect(output).to.equal(lines("---", 'description: "first', " disabled: true", ' last"', "---", "Body"))
expect(otherValues(output)).to.deep.equal(otherValues(input))
})
it("refuses frontmatter the SDK parser rejects instead of editing it", () => {
const input = lines("---", 'description: "first', "disabled: true", 'last"', "disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(input)
const duplicate = lines("---", "disabled: true", "disabled: false", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(duplicate, true)).to.equal(duplicate)
})
it("refuses a non-boolean flag, which makes the SDK reject the file", () => {
const flow = lines("---", "disabled: [", " true,", "]", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(flow, false)).to.equal(flow)
expect(updateUserInstructionMarkdownDisabledState(flow, true)).to.equal(flow)
const block = lines("---", "disabled: |", " multi", " line", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(block, false)).to.equal(block)
})
it("disables a document whose frontmatter is not a mapping by adding a block above it", () => {
// The SDK loads such a document as enabled with no metadata (the block
// parses as a plain scalar), so the markdown divider lines are user text.
const input = lines("---", "Just a divider line", "---", "Body")
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal(lines("---", "disabled: true", "---", "---", "Just a divider line", "---", "Body"))
expect(readSdkEnabledState(disabled)).to.equal(false)
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
})
it("appends the flag inside a comment-only block and keeps the comment", () => {
const input = lines("---", "# top comment", "---", "Body")
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal(lines("---", "# top comment", "disabled: true", "---", "Body"))
expect(readSdkEnabledState(disabled)).to.equal(false)
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
})
it("leaves a document the SDK does not load untouched", () => {
const emptyBody = lines("---", "name: x", "---", "")
expect(readSdkEnabledState(emptyBody)).to.equal(undefined)
expect(updateUserInstructionMarkdownDisabledState(emptyBody, false)).to.equal(emptyBody)
expect(updateUserInstructionMarkdownDisabledState(emptyBody, true)).to.equal(emptyBody)
})
it("does not touch a nested disabled key", () => {
const input = lines("---", "meta:", " disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(
lines("---", "meta:", " disabled: true", "disabled: true", "---", "Body"),
)
})
it("recognizes quoted keys and uppercase booleans", () => {
expect(updateUserInstructionMarkdownDisabledState(lines("---", '"disabled": true', "---", "Body"), true)).to.equal("Body")
expect(
updateUserInstructionMarkdownDisabledState(lines("---", "'disabled': false", "name: x", "---", "Body"), false),
).to.equal(lines("---", "'disabled': true", "name: x", "---", "Body"))
for (const spelling of ["False", "FALSE"]) {
const input = lines("---", `enabled: ${spelling}`, "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "paths:", " - src/**", "---", "Body"),
)
}
})
it("preserves CRLF line endings", () => {
const input = "---\r\npaths:\r\n - src/**\r\n---\r\nBody\r\n"
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal("---\r\npaths:\r\n - src/**\r\ndisabled: true\r\n---\r\nBody\r\n")
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
expect(updateUserInstructionMarkdownDisabledState("Body\r\n", false)).to.equal("---\r\ndisabled: true\r\n---\r\nBody\r\n")
})
it("preserves a UTF-8 BOM", () => {
const input = "\uFEFFFollow this rule"
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal("\uFEFF---\ndisabled: true\n---\nFollow this rule")
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
})
it("is a no-op when the document already has the requested state", () => {
const disabled = lines("---", "disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(disabled, false)).to.equal(disabled)
expect(updateUserInstructionMarkdownDisabledState("Body", true)).to.equal("Body")
})
it("never changes another value or the body, in either direction", () => {
const inputs = [
lines(
"---",
"name: n",
"description: |",
" disabled: true",
" enabled: false",
"paths:",
" - src/**",
"---",
"Body",
),
lines("---", "# c", "disabled: false # keep", "tags: [a, b]", "---", "Body", "disabled: true"),
lines("---", 'title: "x: y"', "enabled:", " # legacy", " false", "---", "", "Body"),
]
for (const input of inputs) {
for (const enabled of [true, false]) {
const output = updateUserInstructionMarkdownDisabledState(input, enabled)
expect(otherValues(output), input).to.deep.equal(otherValues(input))
expect(bodyOf(output), input).to.equal(bodyOf(input))
expect(readSdkEnabledState(output), input).to.equal(enabled)
}
}
})
})
describe("isFrontmatterDisabled", () => {
it("uses the SDK precedence: a boolean disabled wins over enabled", () => {
expect(isFrontmatterDisabled({ disabled: true })).to.equal(true)
expect(isFrontmatterDisabled({ enabled: false })).to.equal(true)
expect(isFrontmatterDisabled({ disabled: false, enabled: false })).to.equal(false)
expect(isFrontmatterDisabled({ disabled: true, enabled: true })).to.equal(true)
expect(isFrontmatterDisabled({})).to.equal(false)
})
})
@@ -814,6 +814,38 @@ describe("updateSkillMarkdownDisabledState", () => {
})
})
describe("updateSkillMarkdownDisabledState with the shared source-range editor", () => {
const lines = (...parts: string[]) => parts.join("\n")
it("re-enables a skill whose legacy enabled: false value is on the next line", () => {
const input = lines("---", "name: s", "description: d", "disabled: true", "enabled:", " false", "---", "Body")
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(lines("---", "name: s", "description: d", "---", "Body"))
})
it("leaves matching text inside the description untouched", () => {
const input = lines(
"---",
"name: s",
'description: "first',
" disabled: true",
' last"',
"disabled: true",
"---",
"Body",
)
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.equal(lines("---", "name: s", 'description: "first', " disabled: true", ' last"', "---", "Body"))
expect(parseYamlFrontmatter(output).data.description).to.equal("first disabled: true last")
})
it("keeps a comment on the disabled entry", () => {
const input = lines("---", "name: s", "description: d", "disabled: true # paused for review", "---", "Body")
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(
lines("---", "name: s", "description: d", "disabled: false # paused for review", "---", "Body"),
)
})
})
describe("setSkillDisabledInFrontmatter", () => {
let sandbox: sinon.SinonSandbox
let readFileStub: sinon.SinonStub
@@ -833,6 +865,23 @@ describe("setSkillDisabledInFrontmatter", () => {
afterEach(() => sandbox.restore())
it("does not report success when the edit leaves the skill disabled", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
readFileStub
.withArgs(skillPath, "utf-8")
.resolves(["---", "name: s", "description: d", "disabled: true", "enabled:", " false", "---", "Body"].join("\n"))
const ok = await setSkillDisabledInFrontmatter(skillPath, true)
if (ok) {
const written = String(writeFileStub.firstCall.args[1])
const data = parseYamlFrontmatter(written).data
expect(data.disabled !== true && data.enabled !== false).to.be.true
} else {
expect(writeFileStub.called).to.be.false
}
})
it("writes disabled: true to the SKILL.md when disabling a disk skill", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "description: d", "---", "Body"].join("\n"))
@@ -1,8 +1,20 @@
import { resolveGlobalRulesConfigPaths, resolveWorkspaceRulesConfigPaths } from "@cline/shared/storage"
import { combineRuleToggles, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureRulesDirectoryExists } from "@core/storage/disk"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import { getCwd, getDesktopDir } from "@utils/path"
import * as fs from "fs/promises"
import path from "path"
import { Controller } from "@/core/controller"
import { Logger } from "@/shared/services/Logger"
import { hasRequestedEnabledState, readSdkEnabledState, updateUserInstructionMarkdownDisabledState } from "./frontmatter"
/**
* File types the SDK rule loader actually reads. Anything else that happens to
* live in a rules directory (images, JSON, editor scratch files) is never
* injected, so writing frontmatter into it would only corrupt it.
*/
const RULE_FILE_EXTENSIONS = new Set([".md", ".markdown", ".txt"])
/**
* Sub-directories of `.clinerules` that hold non-rule config and must not be
@@ -14,6 +26,158 @@ const CLINERULES_EXCLUDED_SUBDIRECTORIES: string[][] = [
[".clinerules", "skills"],
]
/**
* The SDK loader reads only the files directly inside a rules directory (or
* the root itself when it is the single legacy `.clinerules` file); nested
* files never reach the model, so they are never written either.
*/
function isRuleFileOfRoot(rootPath: string, filePath: string): boolean {
return filePath === rootPath || path.dirname(filePath) === rootPath
}
/**
* Every global location a rule may live in: the Documents-based directory the
* Rules tab creates files in, plus the locations the shared SDK resolver loads
* from (e.g. ~/.cline/rules).
*/
async function resolveGlobalRuleDirectories(): Promise<string[]> {
return [...new Set([await ensureRulesDirectoryExists(), ...resolveGlobalRulesConfigPaths()])]
}
/**
* Roots a rule toggle is allowed to write into for the given scope: the global
* rule directories, or this window's workspace rule locations.
*/
export async function resolveRuleWriteRoots(scope: "global" | "local"): Promise<string[]> {
if (scope === "global") {
return resolveGlobalRuleDirectories()
}
return resolveWorkspaceRulesConfigPaths(await getCwd(getDesktopDir()))
}
/**
* Resolve a toggle's rule path to the real file we may write, or `null` when
* the path is not a rule document the SDK loads or does not resolve (through
* symlinks) to a direct child of one of the allowed roots.
*/
export async function resolveWritableRuleFile(rulePath: string, allowedRoots: ReadonlyArray<string>): Promise<string | null> {
if (!rulePath || !path.isAbsolute(rulePath)) {
return null
}
const fileName = path.basename(rulePath)
if (fileName !== GlobalFileNames.clineRules && !RULE_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase())) {
return null
}
let realFilePath: string
try {
realFilePath = await fs.realpath(rulePath)
if (!(await fs.stat(realFilePath)).isFile()) {
return null
}
} catch {
return null
}
for (const root of allowedRoots) {
try {
if (isRuleFileOfRoot(await fs.realpath(root), realFilePath)) {
return realFilePath
}
} catch {
// Root does not exist; nothing under it can be written.
}
}
return null
}
/**
* Outcome of persisting a toggle to a rule file:
* - `written`: the file now carries the requested state (or already did);
* - `skipped`: the path is not a rule document the SDK loads, so there was
* nothing to write;
* - `failed`: the file is a rule document but could not be read, written, or
* safely edited (malformed frontmatter), so the SDK will keep loading its
* previous state.
*/
export type RuleFrontmatterWriteResult = "written" | "skipped" | "failed"
/**
* Persist a rule's UI toggle in the frontmatter consumed by the SDK rules
* loader, which reads `disabled` from the rule document itself rather than
* from the extension's toggle state. Only rule documents directly inside
* `allowedRoots` are written.
*/
export async function setRuleDisabledInFrontmatter(
rulePath: string,
enabled: boolean,
allowedRoots: ReadonlyArray<string>,
): Promise<RuleFrontmatterWriteResult> {
const filePath = await resolveWritableRuleFile(rulePath, allowedRoots)
if (!filePath) {
return "skipped"
}
try {
const content = await fs.readFile(filePath, "utf-8")
const updated = updateUserInstructionMarkdownDisabledState(content, enabled)
// The editor returns the document unchanged when it already has the
// requested state and when it cannot edit it safely, and an edit can
// leave a disabling flag it did not recognize. Only a document that ends
// up in the requested state counts as written.
if (!hasRequestedEnabledState(updated, enabled)) {
Logger.warn(`Rule frontmatter at ${filePath} could not be updated; leaving the document untouched`)
return "failed"
}
if (updated !== content) {
await fs.writeFile(filePath, updated)
}
return "written"
} catch (error) {
Logger.warn(`Failed to update rule frontmatter at ${filePath}:`, error)
return "failed"
}
}
/**
* Make the panel's toggles show what the SDK will load. The rule file is the
* only source of truth: frontmatter that disables a rule shows it as off, and
* a file without it shows the rule as on, whatever extension state says.
*
* Toggles saved before the toggle wrote frontmatter (cline/cline#13695) are
* deliberately not written back into their files. Telling such a toggle apart
* from a rule the user has since re-enabled by editing its file would need
* per-rule history that survives multiple windows and hand edits; a stale
* toggle shows as on instead, which is what the SDK has been loading.
*
* A file the SDK cannot load at all shows as off, since nothing from it
* reaches the model. Files outside `allowedRoots` and unreadable files keep
* their stored toggle.
*/
export async function syncRuleTogglesFromFrontmatter(
toggles: ClineRulesToggles,
allowedRoots: ReadonlyArray<string>,
): Promise<ClineRulesToggles> {
const updated: ClineRulesToggles = { ...toggles }
for (const rulePath of Object.keys(toggles)) {
const filePath = await resolveWritableRuleFile(rulePath, allowedRoots)
if (!filePath) {
continue
}
let content: string
try {
content = await fs.readFile(filePath, "utf-8")
} catch {
continue
}
const sdkEnabled = readSdkEnabledState(content)
if (sdkEnabled === undefined) {
Logger.warn(`Rule ${filePath} cannot be loaded by the SDK (unparseable frontmatter or empty body); showing it as off`)
}
updated[rulePath] = sdkEnabled === true
}
return updated
}
/**
* Synchronizes rule toggles across every directory a rule may live in.
* `synchronizeRuleToggles` prunes toggles for files outside the directory it
@@ -46,19 +210,25 @@ export async function refreshClineRulesToggles(
// plus every global location the shared SDK resolver loads rules from
// (e.g. ~/.cline/rules), so the panel shows what actually reaches the model.
const globalClineRulesToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const globalRuleDirectories = [...new Set([globalClineRulesFilePath, ...resolveGlobalRulesConfigPaths()])]
const updatedGlobalToggles = await synchronizeRuleTogglesAcrossDirectories(globalRuleDirectories, globalClineRulesToggles)
const globalRuleDirectories = await resolveGlobalRuleDirectories()
const updatedGlobalToggles = await syncRuleTogglesFromFrontmatter(
await synchronizeRuleTogglesAcrossDirectories(globalRuleDirectories, globalClineRulesToggles),
globalRuleDirectories,
)
controller.stateManager.setGlobalState("globalClineRulesToggles", updatedGlobalToggles)
// Local toggles: both supported workspace layouts — the legacy
// `.clinerules` directory (or single file) and `.cline/rules` — via the
// same shared resolver the SDK runtime loads rules with (cline/cline#14186).
const localClineRulesToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const updatedLocalToggles = await synchronizeRuleTogglesAcrossDirectories(
resolveWorkspaceRulesConfigPaths(workingDirectory),
localClineRulesToggles,
CLINERULES_EXCLUDED_SUBDIRECTORIES,
const localRuleDirectories = resolveWorkspaceRulesConfigPaths(workingDirectory)
const updatedLocalToggles = await syncRuleTogglesFromFrontmatter(
await synchronizeRuleTogglesAcrossDirectories(
localRuleDirectories,
localClineRulesToggles,
CLINERULES_EXCLUDED_SUBDIRECTORIES,
),
localRuleDirectories,
)
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
@@ -1,5 +1,8 @@
import { isDeepStrictEqual } from "node:util"
import { parseRuleConfigFromMarkdown, parseSkillConfigFromMarkdown } from "@cline/core"
import { stripUtf8Bom } from "@cline/shared"
import * as yaml from "js-yaml"
import { isMap, isScalar, parseDocument } from "yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>
@@ -28,6 +31,15 @@ export type FrontmatterParseResult = {
parseError?: string
}
/**
* Matches a leading `---` fenced YAML block. Group 1 is the YAML text, group 2
* the remaining document body. Mirrors the SDK's rule/skill loader regex so both
* sides agree on what counts as frontmatter.
*/
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const UTF8_BOM = "\uFEFF"
/**
* Parse YAML frontmatter from markdown content.
*
@@ -41,8 +53,7 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
const normalizedMarkdown = stripUtf8Bom(markdown)
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = normalizedMarkdown.match(frontmatterRegex)
const match = normalizedMarkdown.match(FRONTMATTER_REGEX)
if (!match) {
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
@@ -57,3 +68,196 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
}
}
/**
* True when the frontmatter marks the document as disabled, with the same
* precedence as the SDK loader: a boolean `disabled` wins, and the legacy
* `enabled: false` only counts when `disabled` is absent. Used by the legacy
* rule loader; the SDK-facing paths below ask the SDK parser instead.
*/
export function isFrontmatterDisabled(data: Record<string, unknown>): boolean {
if (typeof data.disabled === "boolean") {
return data.disabled
}
return data.enabled === false
}
export type UserInstructionKind = "rule" | "skill"
const ENABLEMENT_KEYS: ReadonlyArray<string> = ["disabled", "enabled"]
type SourceRange = [number, number, number]
/**
* Whether the SDK would load this document as enabled (`true`), as disabled
* (`false`), or not at all (`undefined`: frontmatter it cannot parse, a
* non-boolean flag, or an empty body).
*
* This is the SDK's own parser, not a reimplementation of its rules, so the
* Rules panel, the write paths, and the editor below can never disagree with
* what the SDK loads.
*/
export function readSdkEnabledState(content: string, kind: UserInstructionKind = "rule"): boolean | undefined {
try {
const config =
kind === "skill" ? parseSkillConfigFromMarkdown(content, "skill") : parseRuleConfigFromMarkdown(content, "rule")
return config.disabled !== true
} catch {
return undefined
}
}
/**
* True when the SDK would load the document in the requested state. Write
* paths check this on the edited document before writing or reporting
* success, so a toggle never claims a state the SDK will not load.
*/
export function hasRequestedEnabledState(content: string, enabled: boolean, kind: UserInstructionKind = "rule"): boolean {
return readSdkEnabledState(content, kind) === enabled
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
/** The top-level values other than the enablement flags, as the SDK reads them. */
function otherFrontmatterValues(content: string, kind: UserInstructionKind): Record<string, unknown> | undefined {
try {
const config =
kind === "skill" ? parseSkillConfigFromMarkdown(content, "skill") : parseRuleConfigFromMarkdown(content, "rule")
const values = { ...asRecord(config.frontmatter) }
for (const key of ENABLEMENT_KEYS) {
delete values[key]
}
return values
} catch {
return undefined
}
}
/**
* Update the `disabled` frontmatter flag shared by SDK-backed user
* instructions (rules and skills).
*
* The SDK's parser decides every state question (see readSdkEnabledState).
* The frontmatter is then parsed as a YAML document only to find the source
* ranges of the top-level `disabled` and `enabled` entries, and just those
* characters change. Everything else the author wrote, including other
* values, comments, key order, quoting, line endings, and a UTF-8 BOM, stays
* byte for byte.
*
* - Disabling sets an existing `disabled` value to `true`, or appends a
* `disabled: true` entry. A document with no frontmatter, or with
* frontmatter that is not a mapping (which the SDK loads as having no
* metadata), gets a new `disabled: true` block above its existing text.
* - Enabling removes the `disabled` entry and a legacy `enabled: false`; an
* entry that carries a comment keeps its line and has its value set to
* `false` (or `true` for `enabled`) instead, so the comment survives.
* Frontmatter left empty is removed entirely.
* - A document the SDK does not load, or an edit whose result the SDK would
* not load in the requested state or that would change any other value,
* is returned unchanged.
*/
export function updateUserInstructionMarkdownDisabledState(
content: string,
enabled: boolean,
kind: UserInstructionKind = "rule",
): string {
const bom = content.startsWith(UTF8_BOM) ? UTF8_BOM : ""
const text = bom ? content.slice(UTF8_BOM.length) : content
const eol = text.includes("\r\n") ? "\r\n" : "\n"
// Nothing to do for a document the SDK does not load, or one already in
// the requested state.
const currentState = readSdkEnabledState(text, kind)
if (currentState === undefined || currentState === enabled) {
return content
}
const prependDisabledBlock = () => `${bom}---${eol}disabled: true${eol}---${eol}${text}`
const match = text.match(FRONTMATTER_REGEX)
if (!match) {
return enabled ? content : prependDisabledBlock()
}
const [, block, body] = match
const blockStart = text.indexOf("\n") + 1
const document = parseDocument(block)
if (document.errors.length > 0) {
return content
}
// An empty block (whitespace or comments only) is a mapping with no
// entries; the flag is appended inside it so the comments stay in place.
if (document.contents !== null && !isMap(document.contents)) {
// The SDK treats non-mapping frontmatter as no metadata, so the rule is
// enabled and nothing in the block can be edited to disable it.
return enabled ? content : prependDisabledBlock()
}
const before = otherFrontmatterValues(text, kind)
if (!before) {
return content
}
const pairs = isMap(document.contents) ? document.contents.items : []
const rangesFor = (key: string): { key: SourceRange; value: SourceRange } | undefined => {
const pair = pairs.find((item) => isScalar(item.key) && item.key.value === key)
const keyRange = (pair?.key as { range?: SourceRange } | undefined)?.range
const valueRange = (pair?.value as { range?: SourceRange } | null | undefined)?.range
return keyRange && valueRange ? { key: keyRange, value: valueRange } : undefined
}
const edits: Array<{ start: number; end: number; text: string }> = []
const setValue = (key: string, value: string): boolean => {
const ranges = rangesFor(key)
if (!ranges) {
return false
}
edits.push({ start: ranges.value[0], end: ranges.value[1], text: value })
return true
}
const removeEntry = (key: string, fallbackValue: string): boolean => {
const ranges = rangesFor(key)
if (!ranges) {
return false
}
const start = ranges.key[0]
const end = ranges.value[2]
const startsLine = start === 0 || block[start - 1] === "\n"
if (!startsLine || block.slice(start, end).includes("#")) {
return setValue(key, fallbackValue)
}
edits.push({ start, end, text: "" })
return true
}
const flags = asRecord(document.toJS())
if (enabled) {
if (rangesFor("disabled") && flags.disabled !== false && !removeEntry("disabled", "false")) {
return content
}
if (flags.enabled === false && !removeEntry("enabled", "true")) {
return content
}
} else if (rangesFor("disabled")) {
if (!setValue("disabled", "true")) {
return content
}
} else {
edits.push({ start: block.length, end: block.length, text: `${block.length > 0 ? eol : ""}disabled: true` })
}
let nextBlock = block
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
nextBlock = nextBlock.slice(0, edit.start) + edit.text + nextBlock.slice(edit.end)
}
// Removing the last entry leaves the newline that ended the entry before it.
if (!/\r?\n$/.test(block)) {
nextBlock = nextBlock.replace(/\r?\n$/, "")
}
const nextText =
nextBlock.trim() === "" ? body : `${text.slice(0, blockStart)}${nextBlock}${text.slice(blockStart + block.length)}`
if (readSdkEnabledState(nextText, kind) !== enabled || !isDeepStrictEqual(before, otherFrontmatterValues(nextText, kind))) {
return content
}
return `${bom}${nextText}`
}
@@ -3,10 +3,9 @@ import type { GlobalInstructionsFile } from "@shared/remote-config/schema"
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { parseYamlFrontmatter } from "./frontmatter"
import { hasRequestedEnabledState, parseYamlFrontmatter, updateUserInstructionMarkdownDisabledState } from "./frontmatter"
/**
* Update the `disabled` frontmatter flag of a SKILL.md document.
@@ -15,49 +14,12 @@ import { parseYamlFrontmatter } from "./frontmatter"
* skill's enabled state from the SKILL.md frontmatter `disabled` field, not from
* the extension's UI toggle state. Toggling a skill in the VS Code sidebar must
* therefore also write this flag so the change is reflected for the model
* (ENG-1995). This mirrors the SDK's updateSkillMarkdownEnabledState but lives in
* the extension and uses js-yaml (the extension's frontmatter parser).
*
* - enabled=false → sets `disabled: true`.
* - enabled=true → removes `disabled` (and a stale `enabled: false`), dropping
* the frontmatter block entirely if it becomes empty.
*
* Returns the original content unchanged when enabling a document that has no
* frontmatter (nothing to clear).
* (ENG-1995). Rules and skills share one editor; see
* updateUserInstructionMarkdownDisabledState for what it changes and what it
* refuses to edit.
*/
export function updateSkillMarkdownDisabledState(content: string, enabled: boolean): string {
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
if (!hadFrontmatter && enabled) {
return content
}
// parseYamlFrontmatter fails open on malformed YAML: it returns data={} and
// body=<full original content> (frontmatter block included). Serializing here
// would prepend a second `---` block and corrupt the file, so leave it
// untouched and let the user fix the frontmatter.
if (parseError) {
return content
}
if (enabled) {
delete data.disabled
if (data.enabled === false) {
delete data.enabled
}
if (Object.keys(data).length === 0) {
return body
}
return serializeSkillFrontmatter(data, body)
}
data.disabled = true
return serializeSkillFrontmatter(data, body)
}
function serializeSkillFrontmatter(data: Record<string, unknown>, body: string): string {
const yamlText = yaml.dump(data, { schema: yaml.JSON_SCHEMA }).trimEnd()
return `---\n${yamlText}\n---\n${body}`
return updateUserInstructionMarkdownDisabledState(content, enabled, "skill")
}
/**
@@ -71,6 +33,10 @@ export async function setSkillDisabledInFrontmatter(skillMdPath: string, enabled
try {
const content = await fs.readFile(skillMdPath, "utf-8")
const updated = updateSkillMarkdownDisabledState(content, enabled)
if (!hasRequestedEnabledState(updated, enabled, "skill")) {
Logger.warn(`Skill frontmatter at ${skillMdPath} could not be updated; leaving the document untouched`)
return false
}
if (updated !== content) {
await fs.writeFile(skillMdPath, updated)
}
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { RuleScope, ToggleClineRuleRequest } from "@shared/proto/cline/file"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
import { parseYamlFrontmatter, readSdkEnabledState } from "@/core/context/instructions/user-instructions/frontmatter"
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { toggleClineRule } from "../toggleClineRule"
let sandbox: sinon.SinonSandbox
let workspace: string
beforeEach(async () => {
sandbox = sinon.createSandbox()
workspace = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "cline-toggle-rule-test-")))
await fs.mkdir(path.join(workspace, ".clinerules"), { recursive: true })
setVscodeHostProviderMock()
// A fresh array per call: getCwd() consumes the array it receives with shift().
sandbox.stub(HostProvider, "workspace").get(() => ({
getWorkspacePaths: async () => ({ paths: [workspace] }),
}))
})
afterEach(async () => {
sandbox.restore()
HostProvider.reset()
await fs.rm(workspace, { recursive: true, force: true })
})
function createController() {
const localToggles: Record<string, boolean> = {}
const globalState = new Map<string, unknown>([
["globalClineRulesToggles", {}],
["remoteRulesToggles", {}],
])
const workspaceState = new Map<string, unknown>([["localClineRulesToggles", localToggles]])
return {
controller: {
stateManager: {
getGlobalSettingsKey: (key: string) => globalState.get(key) ?? {},
getWorkspaceStateKey: (key: string) => workspaceState.get(key) ?? {},
getGlobalStateKey: (key: string) => globalState.get(key) ?? {},
setGlobalState: (key: string, value: unknown) => globalState.set(key, value),
setWorkspaceState: (key: string, value: unknown) => workspaceState.set(key, value),
},
},
localToggles,
}
}
describe("toggleClineRule", () => {
it("persists a workspace rule toggle in both extension state and the rule file the SDK reads", async () => {
const rulePath = path.join(workspace, ".clinerules", "project-rule.md")
await fs.writeFile(rulePath, "Follow this rule")
const { controller, localToggles } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(false)
expect(parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8")).data.disabled).toBe(true)
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: true }),
)
expect(localToggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe("Follow this rule")
})
it("also writes rules under the .cline/rules layout", async () => {
const rulePath = path.join(workspace, ".cline", "rules", "new-layout.md")
await fs.mkdir(path.dirname(rulePath), { recursive: true })
await fs.writeFile(rulePath, "New layout rule")
const { controller } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8")).data.disabled).toBe(true)
})
it("updates state but leaves the file alone when the path is outside the workspace rule directories", async () => {
const rulePath = path.join(workspace, "README.md")
await fs.writeFile(rulePath, "Not a rule")
const { controller, localToggles } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(false)
expect(await fs.readFile(rulePath, "utf-8")).toBe("Not a rule")
})
it("reverts the state toggle when the rule file cannot be written", async () => {
const rulePath = path.join(workspace, ".clinerules", "read-only.md")
await fs.writeFile(rulePath, "Locked rule")
await fs.chmod(rulePath, 0o444)
const { controller, localToggles } = createController()
try {
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(true)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe("Locked rule")
} finally {
await fs.chmod(rulePath, 0o644)
}
})
it("reverts the state toggle when malformed frontmatter prevents a safe edit", async () => {
const rulePath = path.join(workspace, ".clinerules", "broken.md")
const content = "---\npaths: [invalid\n---\nBody"
await fs.writeFile(rulePath, content)
const { controller, localToggles } = createController()
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(true)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe(content)
})
it("re-enables a rule whose legacy enabled: false value is on the next line", async () => {
const rulePath = path.join(workspace, ".clinerules", "split-enabled.md")
await fs.writeFile(rulePath, ["---", "disabled: true", "enabled:", " false", "---", "Body"].join("\n"))
const { controller, localToggles } = createController()
localToggles[rulePath] = false
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: true }),
)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(readSdkEnabledState(await fs.readFile(rulePath, "utf-8"))).toBe(true)
})
it("reverts instead of reporting success when the SDK would still not load the rule as requested", async () => {
const rulePath = path.join(workspace, ".clinerules", "non-boolean.md")
const content = ["---", "disabled: [", " true,", "]", "---", "Body"].join("\n")
await fs.writeFile(rulePath, content)
const { controller, localToggles } = createController()
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(localToggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe(content)
})
it("does not write frontmatter into non-rule files that happen to live in .clinerules", async () => {
const rulePath = path.join(workspace, ".clinerules", "notes.json")
await fs.writeFile(rulePath, "{}")
const { controller } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(await fs.readFile(rulePath, "utf-8")).toBe("{}")
})
})
@@ -1,3 +1,8 @@
import {
type RuleFrontmatterWriteResult,
resolveRuleWriteRoots,
setRuleDisabledInFrontmatter,
} from "@core/context/instructions/user-instructions/cline-rules"
import { getWorkspaceBasename } from "@core/workspace"
import type { ToggleClineRuleRequest } from "@shared/proto/cline/file"
import { RuleScope, ToggleClineRules } from "@shared/proto/cline/file"
@@ -47,6 +52,34 @@ export async function toggleClineRule(controller: Controller, request: ToggleCli
throw new Error(`Invalid scope: ${scope}`)
}
// The SDK rule loader reads the document's `disabled` frontmatter flag, not
// the extension's toggle state, so file-backed global and workspace rules
// are written as well. The write is confined to rule documents directly
// inside the scope's rule roots; anything else keeps only the state toggle.
// If a rule file cannot be written, the state toggle is reverted so the
// panel keeps showing what the SDK will actually load.
if (scope !== RuleScope.REMOTE) {
let result: RuleFrontmatterWriteResult = "failed"
try {
const allowedRoots = await resolveRuleWriteRoots(scope === RuleScope.GLOBAL ? "global" : "local")
result = await setRuleDisabledInFrontmatter(rulePath, enabled, allowedRoots)
} catch (error) {
Logger.warn(`toggleClineRule: could not persist frontmatter for ${rulePath}`, error)
}
if (result === "failed") {
Logger.warn(`toggleClineRule: reverting toggle for ${rulePath}; the rule file could not be updated`)
if (scope === RuleScope.GLOBAL) {
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
toggles[rulePath] = !enabled
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
} else {
const toggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
toggles[rulePath] = !enabled
controller.stateManager.setWorkspaceState("localClineRulesToggles", toggles)
}
}
}
// Track rule toggle telemetry with current task context
if (controller.task?.ulid) {
// Extract just the filename for privacy (no full paths)
@@ -8,7 +8,7 @@ import { isModelToolEnabledGlobally, readCompactionStrategyGlobally } from "@cli
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { ClineEnv } from "@/config"
import { ExtensionRegistryInfo } from "@/registry"
import { ExtensionRegistryInfo, HostRegistryInfo } from "@/registry"
import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
@@ -98,6 +98,8 @@ export async function getStateToPostToWebview(controller: {
const version = ExtensionRegistryInfo.version
const clineConfig = ClineEnv.config()
const environment = clineConfig.environment
const hostPlatform = HostRegistryInfo.get()?.platform
const hostName = hostPlatform && hostPlatform !== "unknown" ? hostPlatform : undefined
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
@@ -133,6 +135,7 @@ export async function getStateToPostToWebview(controller: {
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
platform,
environment,
hostName,
distinctId,
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "../openai-base-url-migration"
describe("migrateOpenAiCompatibleBaseUrlToV1", () => {
let tempDir: string
let storage: StorageContext
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openai-base-url-migration-"))
storage = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
for (const [baseUrl, expected] of [
["https://example.com", "https://example.com/v1"],
["http://localhost:8000/", "http://localhost:8000/v1"],
]) {
it(`adds /v1 to the host-only base URL ${baseUrl}`, () => {
storage.globalState.update("openAiBaseUrl", baseUrl)
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(expected)
})
}
for (const baseUrl of ["https://example.com/v1", "https://example.com/api/foo", "not a url"]) {
it(`leaves the base URL ${baseUrl} unchanged`, () => {
storage.globalState.update("openAiBaseUrl", baseUrl)
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe(baseUrl)
})
}
it("runs only once, so a host-only URL saved afterwards stays as entered", () => {
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
it("marks itself done when there is no base URL, so a later host-only URL stays as entered", () => {
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
expect(storage.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
it("shares its marker across hosts through globalState.json", () => {
storage.globalState.update("openAiBaseUrl", "https://example.com")
migrateOpenAiCompatibleBaseUrlToV1(storage)
storage.globalState.update("openAiBaseUrl", "https://example.com")
const otherHost = createStorageContext({ clineDir: tempDir, workspacePath: tempDir })
migrateOpenAiCompatibleBaseUrlToV1(otherHost)
expect(otherHost.globalState.get<string>("openAiBaseUrl")).toBe("https://example.com")
})
})
@@ -0,0 +1,29 @@
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
const MIGRATED_KEY = "__openAiBaseUrlV1Migrated"
/**
* Earlier SDK builds sent requests for an OpenAI Compatible base URL with no
* path (`http://localhost:8000`) to `<host>/v1`; the URL is now used as entered.
* Save the `/v1` so existing setups keep reaching the same endpoint.
*
* Runs once across all hosts: the marker lives in the shared globalState.json
* and is set even when there is nothing to rewrite, so a host-only URL saved
* afterwards (in any host) stays as entered.
*/
export function migrateOpenAiCompatibleBaseUrlToV1(storage: StorageContext): void {
if (storage.globalState.get(MIGRATED_KEY)) {
return
}
const baseUrl = storage.globalState.get<string>("openAiBaseUrl")?.trim()
if (baseUrl && URL.canParse(baseUrl)) {
const url = new URL(baseUrl)
if (url.pathname === "/") {
url.pathname = "/v1"
storage.globalState.update("openAiBaseUrl", url.toString().replace(/\/$/, ""))
Logger.info("[Migration] Added /v1 to the host-only OpenAI Compatible base URL")
}
}
storage.globalState.update(MIGRATED_KEY, true)
}
+2
View File
@@ -29,6 +29,7 @@ import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "./core/storage/openai-base-url-migration"
import {
cleanupMcpMarketplaceCatalogFromGlobalState,
cleanupOldApiKey,
@@ -84,6 +85,7 @@ export async function activate(context: vscode.ExtensionContext) {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const storageContext = createStorageContext({ workspacePath })
await exportVSCodeStorageToSharedFiles(context, storageContext)
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
// 4. Register services and perform common initialization
// IMPORTANT: Must be done after host provider is setup and migrations are complete
@@ -4,6 +4,7 @@ import * as sinon from "sinon"
import * as vscode from "vscode"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { VscodeTerminalManager } from "./VscodeTerminalManager"
import { VscodeTerminalProcess } from "./VscodeTerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
function createNeverEndingStream(): AsyncIterable<string> {
@@ -128,6 +129,28 @@ describe("VscodeTerminalManager", () => {
assert.equal(terminalInfo.busy, true)
assert.equal(terminalInfo.pendingCwdChange, undefined)
assert.equal(terminalInfo.cwdResolved, undefined)
sinon.assert.calledOnceWithExactly(terminalInfo.terminal.show as sinon.SinonStub, true)
})
it("reveals the terminal without taking focus when running a command", async () => {
// Reveal-only test: stub the process so no command stream or VS Code
// event listeners outlive the test.
sandbox.stub(VscodeTerminalProcess.prototype, "run").resolves()
const showStub = sandbox.stub()
const terminalInfo: TerminalInfo = {
id: 1,
busy: false,
lastCommand: "",
lastActive: Date.now(),
terminal: {
shellIntegration: {},
show: showStub,
} as unknown as vscode.Terminal,
}
manager.runCommand(terminalInfo as unknown as Parameters<VscodeTerminalManager["runCommand"]>[0], "echo hi")
sinon.assert.calledOnceWithExactly(showStub, true)
})
it("releases a reused terminal reservation when showing it fails", async () => {
@@ -210,7 +210,10 @@ export class VscodeTerminalManager {
Logger.log(`[TerminalManager] Terminal ${vscodeTerminalInfo.id} busy state before: ${vscodeTerminalInfo.busy}`)
try {
vscodeTerminalInfo.terminal.show()
// Reveal the terminal but keep keyboard focus where it is: focusing the
// terminal mid-command would send the user's in-progress chat input to
// the shell.
vscodeTerminalInfo.terminal.show(true)
} catch (error) {
vscodeTerminalInfo.busy = false
throw error
@@ -379,7 +382,8 @@ export class VscodeTerminalManager {
})
// Showing the reused terminal gives VS Code a chance to initialize shell integration.
// runCommand() below waits up to shellIntegrationTimeout for executeCommand before falling back.
availableTerminal.terminal.show()
// preserveFocus keeps the user's keyboard focus (e.g. the chat input) where it was.
availableTerminal.terminal.show(true)
let cwdChangeResult: CwdChangeResult | undefined
try {
+74 -5
View File
@@ -562,9 +562,11 @@ describe("hasWorkspaceCheckpointForMessage", () => {
const readLiveMessages = vi.fn().mockResolvedValue(sdkMessages)
const readMessages = vi.fn().mockResolvedValue(sdkMessages.slice(0, 2))
const restore = vi.fn().mockRejectedValue(new Error("stop at restore"))
const controller = {
// The prototype supplies the private implementation the public method delegates to.
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
trackSessionStart: (operation: () => Promise<unknown>) => operation(),
getActiveSession: () => ({
sessionId: "task-a",
isRunning: false,
@@ -584,7 +586,7 @@ describe("hasWorkspaceCheckpointForMessage", () => {
stateManager: { getGlobalSettingsKey: () => "act" },
sessionConfigBuilder: { build: async () => ({ providerId: "anthropic", apiKey: "key", modelId: "model" }) },
resolveContextMentions: async (text: string) => text,
}
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true)
await expect(
@@ -598,6 +600,68 @@ describe("hasWorkspaceCheckpointForMessage", () => {
expect(readMessages).not.toHaveBeenCalled()
})
it("runs message edits and checkpoint restores inside a tracked session start", async () => {
// Both read the mode setting and swap the session outside the rebuild
// lock, so a mode toggle must be able to wait for them to finish.
let tracking = false
const trackedRestores: boolean[] = []
const restore = vi.fn(async () => {
trackedRestores.push(tracking)
throw new Error("stop at restore")
})
const restoreActiveSession = vi.fn(async () => {
trackedRestores.push(tracking)
return {}
})
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
trackSessionStart: async (operation: () => Promise<unknown>) => {
tracking = true
try {
return await operation()
} finally {
tracking = false
}
},
restoreActiveSession,
getActiveSession: () => ({
sessionId: "task-a",
isRunning: false,
sdkHost: {
get: async () => ({
cwd: "C:/work",
metadata: { checkpoint: { history: [{ ref: "checkpoint-b", createdAt: 1, runCount: 2 }] } },
}),
readLiveMessages: vi.fn().mockResolvedValue(sdkMessages),
readMessages: vi.fn().mockResolvedValue(sdkMessages),
restore,
},
}),
},
taskHistory: { findHistoryItem: async () => undefined },
getWorkspaceRoot: async () => "C:/work",
stateManager: { getGlobalSettingsKey: () => "act" },
sessionConfigBuilder: { build: async () => ({ providerId: "anthropic", apiKey: "key", modelId: "model" }) },
resolveContextMentions: async (text: string) => text,
postStateToWebview: async () => undefined,
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, {
messageTs: 3,
text: "continue, edited",
restoreWorkspace: true,
}),
).rejects.toThrow("stop at restore")
await SdkController.prototype.restoreCheckpoint.call(controller as never, {
checkpointRunCount: 2,
restoreType: "workspace",
})
expect(trackedRestores).toEqual([true, true])
})
it("uses and disposes a temporary host for a history task", async () => {
const tempHost = {
get: vi.fn().mockResolvedValue({
@@ -799,11 +863,16 @@ describe("cloud tasks stay in the cloud", () => {
it("refuses to rebuild a cloud task's conversation as a local session", async () => {
const startNewSession = vi.fn()
const controller = {
// The prototype supplies the private implementation the public method delegates to.
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "ses-cloud", messageStateHandler: { getClineMessages: () => [] } },
sessions: { getActiveSession: () => undefined, startNewSession },
sessions: {
getActiveSession: () => undefined,
startNewSession,
trackSessionStart: (operation: () => Promise<unknown>) => operation(),
},
stateManager,
}
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 1, text: "edited" }),
+21
View File
@@ -1764,6 +1764,18 @@ export class Controller {
images?: string[]
files?: string[]
restoreWorkspace?: boolean
}): Promise<void> {
// Reads the mode setting and replaces the session outside the rebuild
// lock; tracking it makes a concurrent mode toggle wait for the result.
await this.sessions.trackSessionStart(() => this.performEditMessageAndRegenerate(input))
}
private async performEditMessageAndRegenerate(input: {
messageTs: number
text: string
images?: string[]
files?: string[]
restoreWorkspace?: boolean
}): Promise<void> {
const editedText = input.text.trim()
if (!editedText && (input.images?.length ?? 0) === 0 && (input.files?.length ?? 0) === 0) {
@@ -1918,6 +1930,15 @@ export class Controller {
}
async restoreCheckpoint(input: { checkpointRunCount: number; restoreType: ClineCheckpointRestore }): Promise<void> {
// Reads the mode setting and swaps the session outside the rebuild lock;
// tracking it makes a concurrent mode toggle wait for the result.
await this.sessions.trackSessionStart(() => this.performRestoreCheckpoint(input))
}
private async performRestoreCheckpoint(input: {
checkpointRunCount: number
restoreType: ClineCheckpointRestore
}): Promise<void> {
const restoreMessages = input.restoreType === "task" || input.restoreType === "taskAndWorkspace"
const restoreWorkspace = input.restoreType === "workspace" || input.restoreType === "taskAndWorkspace"
const checkpointRunCount = Number(input.checkpointRunCount)
@@ -90,6 +90,12 @@ export interface ActiveSession {
sessionId: string
/** The config used to start the active session. */
startConfig?: Pick<CoreSessionConfig, "providerId" | "modelId">
/**
* The mode the session's tools were built for. It can differ from the
* persisted mode setting while a start or rebuild is in flight, so mode
* switches compare against this rather than the setting.
*/
mode?: Mode
/** The runtime host instance managing this session (VscodeSessionHost) */
sdkHost: SdkSessionHost
/** Unsubscribe function for session events */
@@ -2,6 +2,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { StateManager } from "@/core/storage/StateManager"
import { SdkModeCoordinator, type SdkModeCoordinatorOptions } from "./sdk-mode-coordinator"
import { SdkSessionRebuildScheduler } from "./sdk-session-rebuild-scheduler"
vi.mock("@/shared/services/Logger", () => ({
Logger: {
@@ -21,6 +22,102 @@ describe("SdkModeCoordinator", () => {
vi.clearAllMocks()
})
describe("toggles racing a session start", () => {
it("applies a toggle made while an earlier switch is still replacing the session", async () => {
const { coordinator, options, state } = makeCoordinator({
activeSession: makeActiveSession({ mode: "act" }),
task: makeTask("old-session"),
mode: "act",
serializeRebuilds: true,
})
let current: ActiveSessionFixture | undefined = makeActiveSession({ mode: "act" })
options.sessions.getActiveSession.mockImplementation(() => current)
let finishPlanStart!: () => void
const planStart = new Promise<void>((resolve) => {
finishPlanStart = resolve
})
options.sessions.replaceActiveSession
.mockImplementationOnce(async () => {
// The outgoing session is detached before its replacement starts,
// so no session is active until the plan session is up.
current = undefined
await planStart
current = makeActiveSession({ mode: "plan" })
return { startResult: { sessionId: "old-session" }, sdkHost: current.sdkHost }
})
.mockImplementationOnce(async () => {
current = makeActiveSession({ mode: "act" })
return { startResult: { sessionId: "old-session" }, sdkHost: current.sdkHost }
})
const toPlan = coordinator.togglePlanActMode("plan")
await vi.waitFor(() => expect(options.sessions.replaceActiveSession).toHaveBeenCalledOnce())
const toAct = coordinator.togglePlanActMode("act")
finishPlanStart()
await Promise.all([toPlan, toAct])
expect(state.mode).toBe("act")
expect(options.buildStartSessionInput.mock.calls.map(([, startInput]) => startInput.mode)).toEqual(["plan", "act"])
expect(current?.mode).toBe("act")
// act -> plan -> act before any message was sent cancels out.
expect(coordinator.consumeModeSwitchNotice("old-session")).toBeNull()
})
it("applies a toggle made while a new task's session is still starting", async () => {
const { coordinator, options, state } = makeCoordinator({ mode: "plan", task: makeTask("new-task") })
const planSession = makeActiveSession({ mode: "plan", isRunning: true })
let current: ActiveSessionFixture | undefined
options.sessions.getActiveSession.mockImplementation(() => current)
options.sessions.waitForSessionStarts.mockImplementationOnce(async () => {
// The task's plan session finishes starting and its first turn runs.
current = planSession
})
await coordinator.togglePlanActMode("act")
expect(state.mode).toBe("act")
expect(planSession.sdkHost.abort).toHaveBeenCalledWith("old-session")
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith(
expect.objectContaining({ expectedSession: planSession }),
)
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.anything(), { cwd: "/workspace", mode: "act" })
expect(coordinator.consumeModeSwitchNotice("new-session")).toEqual({ from: "plan", to: "act" })
})
it("rebuilds when the setting already matches but the session was built for the other mode", async () => {
const planSession = makeActiveSession({ mode: "plan" })
const { coordinator, options, state } = makeCoordinator({
activeSession: planSession,
task: makeTask("old-session"),
mode: "act",
})
await coordinator.togglePlanActMode("act")
expect(state.mode).toBe("act")
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith(
expect.objectContaining({ expectedSession: planSession }),
)
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.anything(), { cwd: "/workspace", mode: "act" })
expect(coordinator.consumeModeSwitchNotice("new-session")).toEqual({ from: "plan", to: "act" })
})
it("does not rebuild or announce a switch to the mode the session already runs in", async () => {
const { coordinator, options, state } = makeCoordinator({
activeSession: makeActiveSession({ mode: "plan" }),
task: makeTask("old-session"),
mode: "act",
})
await expect(coordinator.togglePlanActMode("plan")).resolves.toBe(false)
expect(state.mode).toBe("plan")
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.postStateToWebview).toHaveBeenCalledOnce()
expect(coordinator.consumeModeSwitchNotice("old-session")).toBeNull()
})
})
it("preserves pending input by returning false when toggling mode without an active session", async () => {
const { coordinator, state, options } = makeCoordinator({ mode: "act" })
@@ -798,6 +895,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
}),
fireAndForgetSend: vi.fn(),
setRunning: vi.fn(),
waitForSessionStarts: vi.fn().mockResolvedValue(undefined),
},
interactions: {
clearPending: vi.fn(),
@@ -836,6 +934,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
fireAndForgetSend: ReturnType<typeof vi.fn>
replaceActiveSession: ReturnType<typeof vi.fn>
setRunning: ReturnType<typeof vi.fn>
waitForSessionStarts: ReturnType<typeof vi.fn>
}
interactions: SdkModeCoordinatorOptions["interactions"] & {
clearPending: ReturnType<typeof vi.fn>
@@ -863,6 +962,10 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
onAutoContinueFailed: ReturnType<typeof vi.fn>
}
if (input.serializeRebuilds) {
options.rebuilds = new SdkSessionRebuildScheduler({ sessions: options.sessions })
}
return {
coordinator: new SdkModeCoordinator(options),
options,
@@ -880,11 +983,16 @@ interface MakeCoordinatorInput {
}
task: ReturnType<typeof makeTask>
turnPhase: string
/** Use the real rebuild lock instead of a pass-through. */
serializeRebuilds: boolean
}
function makeActiveSession(input: { isRunning?: boolean } = {}) {
type ActiveSessionFixture = ReturnType<typeof makeActiveSession>
function makeActiveSession(input: { isRunning?: boolean; mode?: "act" | "plan" } = {}) {
return {
sessionId: "old-session",
mode: input.mode,
sdkHost: {
abort: vi.fn().mockResolvedValue(undefined),
send: vi.fn(),
@@ -894,6 +1002,7 @@ function makeActiveSession(input: { isRunning?: boolean } = {}) {
unsubscribe: vi.fn(),
startResult: { sessionId: "old-session" },
isRunning: input.isRunning ?? false,
queuedPromptCount: 0,
}
}
+27 -7
View File
@@ -111,17 +111,29 @@ export class SdkModeCoordinator {
}
async togglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
if (currentMode === modeToSwitchTo) {
return false
}
// Decide under the rebuild lock, after any session start in flight has
// settled. Starts and rebuilds detach the outgoing session before the
// replacement is active, so a toggle that looked during that window
// would see no session, flip only the setting, and leave the session
// that finishes starting on the other mode's tools.
return this.options.rebuilds.runExclusive(async () => {
await this.options.sessions.waitForSessionStarts()
return this.performTogglePlanActMode(modeToSwitchTo, chatContent)
})
}
private async performTogglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
const activeSession = this.options.sessions.getActiveSession()
// Cloud sessions are Act-only; the webview disables the toggle, this
// guards the keyboard shortcut and any other caller.
if (activeSession?.sdkHost && "isCloud" in activeSession.sdkHost) {
return false
}
if (currentMode === modeToSwitchTo && (activeSession?.mode ?? modeToSwitchTo) === modeToSwitchTo) {
return false
}
if (activeSession) {
// awaiting_followup is also used for non-plan turns, so it is not
// sufficient evidence that the user has a plan to approve. Require the
@@ -150,7 +162,7 @@ export class SdkModeCoordinator {
const userImages = chatContent?.images?.length ? chatContent.images : undefined
const userFiles = chatContent?.files?.length ? chatContent.files : undefined
const hasUserContent = !!(userPrompt || userImages || userFiles)
const continuationSent = await this.rebuildSessionForMode(modeToSwitchTo, {
const continuationSent = await this.performRebuildSessionForMode(modeToSwitchTo, {
autoContinue,
userContinuationPrompt: autoContinue ? userPrompt : undefined,
userImages: autoContinue ? userImages : undefined,
@@ -191,14 +203,22 @@ export class SdkModeCoordinator {
userFiles?: string[]
},
): Promise<boolean> {
const previousMode = this.options.stateManager.getGlobalSettingsKey("mode")
const activeSession = this.options.sessions.getActiveSession()
// The mode the model is actually running under. Rolling back to it keeps
// the toggle coherent with the session's tools, and it is the "from" side
// of the mode notice.
const previousMode = activeSession?.mode ?? this.options.stateManager.getGlobalSettingsKey("mode")
this.options.stateManager.setGlobalState("mode", newMode)
const activeSession = this.options.sessions.getActiveSession()
if (!activeSession) {
await this.options.postStateToWebview()
return false
}
if (activeSession.mode === newMode) {
// Only the setting had drifted; the session already has this mode's tools.
await this.options.postStateToWebview()
return false
}
const { sdkHost: oldManager, sessionId: oldSessionId } = activeSession
const wasRunning = activeSession.isRunning
@@ -53,6 +53,56 @@ describe("SdkSessionLifecycle", () => {
expect(lifecycle.getActiveSession()).toBeUndefined()
})
it("records the mode the active session was built for", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
const lifecycle = makeLifecycle()
await lifecycle.startNewSession({
config: { providerId: "anthropic", modelId: "claude-sonnet-4", mode: "plan" },
} as StartInput)
expect(lifecycle.getActiveSession()?.mode).toBe("plan")
})
it("waits for tracked session starts, including ones that fail", async () => {
const lifecycle = makeLifecycle()
let finishStart!: () => void
const start = lifecycle.trackSessionStart(
() =>
new Promise<void>((resolve) => {
finishStart = resolve
}),
)
let waited = false
const waiting = lifecycle.waitForSessionStarts().then(() => {
waited = true
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(waited).toBe(false)
finishStart()
await start
await waiting
expect(waited).toBe(true)
// A waiter already in place when a start fails is released, not rejected:
// a mode toggle waiting on a failed new-task start must still go ahead.
let failStart!: (error: Error) => void
const failing = lifecycle.trackSessionStart(
() =>
new Promise<void>((_resolve, reject) => {
failStart = reject
}),
)
const waitingOnFailure = lifecycle.waitForSessionStarts()
failStart(new Error("start failed"))
await expect(failing).rejects.toThrow("start failed")
await expect(waitingOnFailure).resolves.toBeUndefined()
await expect(lifecycle.waitForSessionStarts()).resolves.toBeUndefined()
})
it("stores the provider and model config used to start the active session", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
@@ -7,6 +7,7 @@ import type {
StartSessionResult,
} from "@cline/core"
import { formatModeSwitchNotice, type ModeSwitchNotice } from "@cline/shared"
import type { Mode } from "@shared/storage/types"
import { StateManager } from "@/core/storage/StateManager"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { McpHub } from "@/services/mcp/McpHub"
@@ -57,6 +58,10 @@ export interface SdkSessionLifecycleOptions {
onDidBecomeIdle?: () => void
}
function toSessionMode(mode: string | undefined): Mode | undefined {
return mode === "plan" || mode === "act" ? mode : undefined
}
export class SdkSessionLifecycle {
private activeSession: ActiveSession | undefined
private sharedHost: SdkSessionHost | undefined
@@ -77,9 +82,41 @@ export class SdkSessionLifecycle {
* session idle, or a passive rebuild could replace it mid-turn.
*/
private turnGeneration = 0
/**
* Session starts that read the mode setting outside the rebuild lock (new
* task, edited message, checkpoint restore). Until one settles, the session
* it is building is not active yet, so a mode toggle waits for it instead
* of mistaking "no active session" for "nothing to rebuild".
*/
private readonly pendingStarts = new Set<Promise<void>>()
constructor(private readonly options: SdkSessionLifecycleOptions) {}
/**
* Runs a session start, from reading the mode setting until the new
* session is active and its first turn is sent, as one tracked unit.
*/
async trackSessionStart<T>(operation: () => Promise<T>): Promise<T> {
const run = operation()
const settled = run.then(
() => undefined,
() => undefined,
)
this.pendingStarts.add(settled)
try {
return await run
} finally {
this.pendingStarts.delete(settled)
}
}
/** Resolves once no tracked session start is in flight. */
async waitForSessionStarts(): Promise<void> {
while (this.pendingStarts.size > 0) {
await Promise.all(this.pendingStarts)
}
}
getActiveSession(): ActiveSession | undefined {
return this.activeSession
}
@@ -214,6 +251,7 @@ export class SdkSessionLifecycle {
modelId: startInput.config.modelId,
}
: undefined,
mode: toSessionMode(startInput.config?.mode),
sdkHost,
unsubscribe: hostOverride ? this.subscribeSessionHost(hostOverride) : () => {},
startResult,
@@ -322,6 +360,7 @@ export class SdkSessionLifecycle {
modelId: input.start.config.modelId,
}
: activeSession.startConfig,
mode: toSessionMode(input.start?.config?.mode) ?? activeSession.mode,
startResult: restored.startResult,
isRunning: false,
queuedPromptCount: 0,
@@ -23,6 +23,37 @@ describe("SdkTaskStartCoordinator", () => {
vi.mocked(isDirectory).mockResolvedValue(false)
})
it("starts the session and sends the first prompt inside one tracked session start", async () => {
const { coordinator, options } = makeCoordinator()
let tracking = false
options.sessions.trackSessionStart.mockImplementation(async (operation: () => Promise<unknown>) => {
tracking = true
try {
return await operation()
} finally {
tracking = false
}
})
const trackedCalls: string[] = []
options.sessionConfigBuilder.build.mockImplementation(async () => {
trackedCalls.push(`build:${tracking}`)
return { providerId: "anthropic", modelId: "model", apiKey: "key" }
})
options.sessions.startNewSession.mockImplementation((startInput?: { config?: { sessionId?: string } }) => {
trackedCalls.push(`start:${tracking}`)
return { startResult: { sessionId: startInput?.config?.sessionId ?? "session-123" }, sdkHost: { send: vi.fn() } }
})
options.sessions.fireAndForgetSend.mockImplementation(() => {
trackedCalls.push(`send:${tracking}`)
})
await coordinator.initTask("hello")
// The mode is read when the config is built, so a toggle has to wait from
// there until the session is active and its first turn is on its way.
expect(trackedCalls).toEqual(["build:true", "start:true", "send:true"])
})
it("initializes a new task, emits the task message, and sends the resolved prompt", async () => {
const { coordinator, options, state } = makeCoordinator()
@@ -269,6 +300,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
sdkHost,
})),
fireAndForgetSend: vi.fn(),
trackSessionStart: vi.fn((operation: () => Promise<unknown>) => operation()),
},
messages: {
appendAndEmit: vi.fn(),
@@ -315,6 +347,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
sessions: SdkTaskStartCoordinatorOptions["sessions"] & {
startNewSession: ReturnType<typeof vi.fn>
fireAndForgetSend: ReturnType<typeof vi.fn>
trackSessionStart: ReturnType<typeof vi.fn>
}
messages: SdkTaskStartCoordinatorOptions["messages"] & {
appendAndEmit: ReturnType<typeof vi.fn>
@@ -75,88 +75,92 @@ export class SdkTaskStartCoordinator {
try {
await this.options.clearTask()
const cwd = await this.options.getWorkspaceRoot()
const mode = this.getCurrentMode()
Logger.log(`[SdkController] Building session config: mode=${mode}, cwd=${cwd}`)
const config = await this.options.sessionConfigBuilder.build({
prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
providerId = config.providerId
modelId = config.modelId
// Tracked from the mode read until the first prompt is sent, so a mode
// toggle in between waits for this session instead of missing it.
return await this.options.sessions.trackSessionStart(async () => {
const cwd = await this.options.getWorkspaceRoot()
const mode = this.getCurrentMode()
Logger.log(`[SdkController] Building session config: mode=${mode}, cwd=${cwd}`)
const config = await this.options.sessionConfigBuilder.build({
prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
providerId = config.providerId
modelId = config.modelId
Logger.log(
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
)
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
Logger.warn(
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
Logger.log(
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
)
// No task/session id exists yet, so this preflight auth UI path is
// intentionally not recorded as task-joinable provider error telemetry.
this.options.emitClineAuthError(prompt)
return undefined
}
taskSessionId = config.sessionId?.trim() || createSessionId()
const configWithSessionId = {
...config,
sessionId: taskSessionId,
}
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
Logger.warn(
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
)
// No task/session id exists yet, so this preflight auth UI path is
// intentionally not recorded as task-joinable provider error telemetry.
this.options.emitClineAuthError(prompt)
return undefined
}
const startInput = this.options.buildStartSessionInput(configWithSessionId, {
prompt: prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
taskSessionId = config.sessionId?.trim() || createSessionId()
const configWithSessionId = {
...config,
sessionId: taskSessionId,
}
const task = this.createAndSetTask(taskSessionId)
this.emitInitialTaskMessage(taskSessionId, prompt ?? "", images, files)
const startInput = this.options.buildStartSessionInput(configWithSessionId, {
prompt: prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
// The turn phase was already set to "streaming" (in SdkController.initTask), but the
// webview only learns the phase through a full state post. Ship one now, in parallel
// with the potentially slow session startup below, so the chat shows the thinking
// indicator as soon as the task message lands instead of after startNewSession settles.
this.options.postStateToWebview().catch((error) => {
Logger.error("[SdkController] Failed to post state after emitting initial task message:", error)
})
const task = this.createAndSetTask(taskSessionId)
this.emitInitialTaskMessage(taskSessionId, prompt ?? "", images, files)
const { startResult, sdkHost } = await this.options.sessions.startNewSession(startInput)
if (startResult.sessionId !== taskSessionId) {
Logger.warn(
`[SdkController] SDK returned session id ${startResult.sessionId} after requested id ${taskSessionId}`,
// The turn phase was already set to "streaming" (in SdkController.initTask), but the
// webview only learns the phase through a full state post. Ship one now, in parallel
// with the potentially slow session startup below, so the chat shows the thinking
// indicator as soon as the task message lands instead of after startNewSession settles.
this.options.postStateToWebview().catch((error) => {
Logger.error("[SdkController] Failed to post state after emitting initial task message:", error)
})
const { startResult, sdkHost } = await this.options.sessions.startNewSession(startInput)
if (startResult.sessionId !== taskSessionId) {
Logger.warn(
`[SdkController] SDK returned session id ${startResult.sessionId} after requested id ${taskSessionId}`,
)
task.taskId = startResult.sessionId
taskSessionId = startResult.sessionId
}
const newHistoryItem = this.options.createHistoryItemFromSession(
taskSessionId,
prompt ?? "",
configWithSessionId.modelId,
cwd,
)
task.taskId = startResult.sessionId
taskSessionId = startResult.sessionId
}
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
await this.options.postStateToWebview()
const newHistoryItem = this.options.createHistoryItemFromSession(
taskSessionId,
prompt ?? "",
configWithSessionId.modelId,
cwd,
)
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
await this.options.postStateToWebview()
if (prompt?.trim() || images?.length || files?.length) {
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
const resolvedTask = await this.options.resolveContextMentions(prompt || "")
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
}
if (prompt?.trim() || images?.length || files?.length) {
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
const resolvedTask = await this.options.resolveContextMentions(prompt || "")
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
}
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
return taskSessionId
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
return taskSessionId
})
} catch (error) {
this.options.captureProviderApiError?.({
sessionId: taskSessionId,
@@ -21,4 +21,45 @@ describe("McpHubToolProvider", () => {
expect(callTool.calledOnce).toBe(true)
expect(callTool.firstCall.args[4]).toBe(controller.signal)
})
it("keeps MCP Apps tool metadata", async () => {
const getServers = sinon.stub().returns([
{
name: "weather",
tools: [
{
name: "show_weather",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://weather/view.html" } },
},
{ name: "plain", inputSchema: { type: "object" } },
],
},
])
const provider = new McpHubToolProvider({ getServers } as never)
expect(await provider.listTools("weather")).toEqual([
{
name: "show_weather",
description: undefined,
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://weather/view.html" } },
},
{ name: "plain", description: undefined, inputSchema: { type: "object" } },
])
})
it("forwards resource reads with the agent abort signal", async () => {
const readResource = sinon.stub().resolves({ contents: [] })
const provider = new McpHubToolProvider({ readResource } as never)
const controller = new AbortController()
await provider.readResource({
serverName: "weather",
uri: "ui://weather/view.html",
context: { agentId: "agent", iteration: 1, signal: controller.signal },
})
expect(readResource.firstCall.args).toEqual(["weather", "ui://weather/view.html", controller.signal])
})
})
@@ -1,4 +1,4 @@
import { createMcpTools } from "@cline/core"
import { createMcpTools, type McpResourceReadResult } from "@cline/core"
import type { AgentTool, AgentToolContext } from "@cline/shared"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import type { McpHub } from "@/services/mcp/McpHub"
@@ -11,6 +11,7 @@ interface McpToolDescriptor {
name: string
description?: string
inputSchema: Record<string, unknown>
_meta?: Record<string, unknown>
}
export class McpHubToolProvider {
@@ -31,6 +32,7 @@ export class McpHubToolProvider {
type: "object",
properties: {},
},
...(tool._meta ? { _meta: tool._meta } : {}),
}))
}
@@ -43,6 +45,10 @@ export class McpHubToolProvider {
const ulid = `sdk-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
return this.mcpHub.callTool(request.serverName, request.toolName, request.arguments ?? {}, ulid, request.context?.signal)
}
async readResource(request: { serverName: string; uri: string; context?: AgentToolContext }): Promise<McpResourceReadResult> {
return this.mcpHub.readResource(request.serverName, request.uri, request.context?.signal)
}
}
export interface VscodeExtraToolsOptions {
+25
View File
@@ -16,6 +16,8 @@ import {
ListToolsResultSchema,
McpError,
PromptListChangedNotificationSchema,
type ReadResourceResult,
ReadResourceResultSchema,
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
@@ -1751,6 +1753,29 @@ export class McpHub {
}
}
async readResource(serverName: string, uri: string, signal?: AbortSignal): Promise<ReadResourceResult> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`No connection found for server: ${serverName}`)
}
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
if (!connection.client) {
throw new Error(`Server "${serverName}" is not connected and cannot be used.`)
}
const timeout = resolveMcpServerTimeoutMs(connection.server.config)
try {
return await connection.client.request({ method: "resources/read", params: { uri } }, ReadResourceResultSchema, {
timeout,
signal,
})
} catch (error) {
throw augmentMcpTimeoutError(error, serverName, timeout)
}
}
/**
* RPC variant of toggleToolAutoApprove that returns the updated servers instead of notifying the webview
* @param serverName The name of the MCP server
@@ -0,0 +1,57 @@
import { describe, it } from "bun:test"
import "should"
import sinon from "sinon"
import { McpHub } from "../McpHub"
function createMcpHub(options: { disabled?: boolean; client?: { request: sinon.SinonStub } | null } = {}) {
const client =
options.client === null
? undefined
: (options.client ?? {
request: sinon.stub().resolves({
contents: [
{ uri: "ui://weather/view.html", mimeType: "text/html;profile=mcp-app", text: "<html></html>" },
],
}),
})
const hub = Object.create(McpHub.prototype) as McpHub
;(hub as any).connections = [
{
server: {
name: "weather",
config: JSON.stringify({ type: "stdio", command: "test", timeout: 60 }),
status: "connected",
disabled: options.disabled ?? false,
},
client,
transport: {},
},
]
return { hub, client }
}
describe("McpHub.readResource", () => {
it("sends resources/read with the server timeout and abort signal", async () => {
const { hub, client } = createMcpHub()
const controller = new AbortController()
const result = await hub.readResource("weather", "ui://weather/view.html", controller.signal)
result.contents[0].uri.should.equal("ui://weather/view.html")
const [request, , options] = client!.request.firstCall.args
request.should.deepEqual({ method: "resources/read", params: { uri: "ui://weather/view.html" } })
options.should.deepEqual({ timeout: 60_000, signal: controller.signal })
})
it("rejects unknown, disabled, and disconnected servers", async () => {
await createMcpHub()
.hub.readResource("missing", "ui://x")
.should.be.rejectedWith(/No connection found/)
await createMcpHub({ disabled: true })
.hub.readResource("weather", "ui://x")
.should.be.rejectedWith(/disabled/)
await createMcpHub({ client: null })
.hub.readResource("weather", "ui://x")
.should.be.rejectedWith(/not connected/)
})
})
@@ -86,6 +86,8 @@ export interface ExtensionState {
enableCheckpointsSetting?: boolean
platform: Platform
environment?: Environment
/** The editor Cline runs in, as the host reports it ("Visual Studio Code", "Cursor", "PyCharm Professional"). */
hostName?: string
shouldShowAnnouncement: boolean
taskHistory: HistoryItem[]
telemetrySetting: TelemetrySetting
+1
View File
@@ -35,6 +35,7 @@ export type McpTool = {
description?: string
inputSchema?: object
autoApprove?: boolean
_meta?: Record<string, unknown>
}
export type McpResource = {
+2
View File
@@ -14,6 +14,7 @@ import { retryOperation } from "@utils/retry"
import * as path from "path"
import { initialize, tearDown } from "@/common"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
import { migrateOpenAiCompatibleBaseUrlToV1 } from "@/core/storage/openai-base-url-migration"
import { WebviewProvider } from "@/core/webview"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
@@ -87,6 +88,7 @@ async function main() {
// Create shared file-backed storage
const storageContext = createStorageContext()
migrateOpenAiCompatibleBaseUrlToV1(storageContext)
const webviewProvider = await initialize(storageContext)
// Enable the localhost HTTP server that handles auth redirects.
+49 -9
View File
@@ -29,20 +29,62 @@
// exports (value `undefined`) and overlay the stub on top: stub names keep stub
// behavior, every other valid import links as `undefined`.
//
// Importing the real package here is safe: the preload runs before any test
// file, so this is the only point the real module is linked, and we only read
// its export *names*, never its behavior (the mock shadows it everywhere tests look).
// The real package must never be loaded in this process before it is mocked.
// Mocking an already-loaded module makes bun re-link the module graph, and
// that re-link leaves a slice of `@cline/shared`'s source-tree exports
// undefined (`stripUtf8Bom` among them) for everything under
// sdk/packages/core, which resolves `@cline/shared` to shared/src through
// core's tsconfig `paths`. Real SDK functions re-exported by the stub then
// throw. So the export names come from a child process (cached on the built
// package's mtime), the mock is registered first, and everything that might
// pull core in is imported after it.
import { beforeEach as bunBeforeEach, vi as bunVi, mock } from "bun:test"
import * as realClineCore from "@cline/core"
import * as LlmsModels from "@cline/llms"
import * as clineCoreStub from "./cline-core-vitest-stub"
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
import path from "node:path"
import * as vscodeStub from "./vscode-vitest-stub"
function readRealClineCoreExportNames(): string[] {
const distEntry = path.resolve(import.meta.dir, "../../../../sdk/packages/core/dist/index.js")
const stamp = (() => {
const stat = statSync(distEntry)
return `${stat.mtimeMs}:${stat.size}`
})()
const cacheFile = path.resolve(import.meta.dir, "../../node_modules/.cache/cline-core-export-names.json")
try {
const cached = JSON.parse(readFileSync(cacheFile, "utf-8")) as { stamp: string; names: string[] }
if (cached.stamp === stamp && Array.isArray(cached.names)) {
return cached.names
}
} catch {
// No usable cache; read the names below.
}
const result = Bun.spawnSync({
cmd: ["bun", "-e", "console.log(JSON.stringify(Object.keys(await import('@cline/core'))))"],
cwd: import.meta.dir,
})
if (result.exitCode !== 0) {
throw new Error(`Could not read @cline/core export names: ${result.stderr.toString()}`)
}
const names = JSON.parse(result.stdout.toString()) as string[]
try {
mkdirSync(path.dirname(cacheFile), { recursive: true })
writeFileSync(cacheFile, JSON.stringify({ stamp, names }))
} catch {
// Cache is an optimization only.
}
return names
}
const clineCoreNamespace: Record<string, unknown> = {}
for (const name of Object.keys(realClineCore)) {
for (const name of readRealClineCoreExportNames()) {
clineCoreNamespace[name] = undefined
}
mock.module("@cline/core", () => clineCoreNamespace)
const clineCoreStub = await import("./cline-core-vitest-stub")
Object.assign(clineCoreNamespace, clineCoreStub)
const LlmsModels = await import("@cline/llms")
bunBeforeEach(() => {
clineCoreStub.resetModelsFileState()
@@ -51,8 +93,6 @@ bunBeforeEach(() => {
LlmsModels.resetRegistry()
})
mock.module("@cline/core", () => clineCoreNamespace)
// `vscode`: the stub provides both named exports (Position, Uri, …) and a
// default export (the namespace object). Preserve both shapes so `import * as
// vscode from "vscode"` and `import vscode from "vscode"` both work.
@@ -58,6 +58,10 @@ export function resolveModelsRegistryPath(): string {
export function ensureCustomProvidersLoadedSync(): void {}
export { toClineCoreStartInput } from "../../../../sdk/packages/core/src/cline-core/start-input"
export {
parseRuleConfigFromMarkdown,
parseSkillConfigFromMarkdown,
} from "../../../../sdk/packages/core/src/extensions/config/user-instruction-config-loader"
export { isPrivateModelCatalogProvider } from "../../../../sdk/packages/core/src/services/llms/provider-defaults"
// Real implementation re-exported from the sdk source (same pattern as the
// apply-patch executors below) so store writes are reflected in the live
+2 -35
View File
@@ -1,5 +1,5 @@
import { type ChildProcess, spawnSync } from "node:child_process"
import { existsSync, mkdirSync, mkdtempSync, type PathLike, type RmOptions, readdirSync, rmSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, type PathLike, type RmOptions, readdirSync, rmSync, writeFileSync } from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { type ElectronApplication, expect, type Frame, type Page, test } from "@playwright/test"
@@ -61,37 +61,6 @@ export class E2ETestHelper {
return `${baseName}${projectSuffix}`
}
/**
* Resolves the real VS Code executable for `_electron.launch()`.
*
* `@vscode/test-electron` hardcodes the macOS entry point as
* `Visual Studio Code.app/Contents/MacOS/Electron`, but recent VS Code builds
* ship that binary as `Code` instead, so launching fails with ENOENT.
* See https://github.com/microsoft/vscode-test/issues/355.
*
* When the reported path is missing, fall back to the bundle's real executable
* name(s) so E2E works on both the old and new layouts.
*/
public static resolveVSCodeExecutablePath(executablePath: string): string {
if (existsSync(executablePath)) {
return executablePath
}
if (process.platform !== "darwin") {
return executablePath
}
const macOSDir = path.dirname(executablePath)
for (const candidate of ["Code", "Code - Insiders"]) {
const candidatePath = path.join(macOSDir, candidate)
if (existsSync(candidatePath)) {
return candidatePath
}
}
return executablePath
}
public static async waitUntil(predicate: () => boolean | Promise<boolean>, maxDelay = 10000): Promise<void> {
let delay = 10
const start = Date.now()
@@ -412,9 +381,7 @@ export const e2e = test
})
.extend<{ openVSCode: (workspacePath: string) => Promise<ElectronApplication> }>({
openVSCode: async ({ userDataDir, channel }, use, testInfo) => {
const executablePath = E2ETestHelper.resolveVSCodeExecutablePath(
await downloadAndUnzipVSCode(channel, undefined, new SilentReporter()),
)
const executablePath = await downloadAndUnzipVSCode(channel, undefined, new SilentReporter())
await use(async (workspacePath: string) => {
// Create isolated Cline data directory for this test
+4 -4
View File
@@ -8,7 +8,7 @@
"name": "cline-core",
"version": "0.0.1",
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/grpc-js": "~1.13.6",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.4.1",
"grpc-health-check": "^2.0.2",
@@ -17,9 +17,9 @@
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
"integrity": "sha512-FTXHdOoPbZrBjlVLHuKbDZnsTxXv2BlHF57xw6LuThXacXvtkahEPED0CKMk6obZDf65Hv4k3z62eyPNpvinIg==",
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.7.tgz",
"integrity": "sha512-Q6KuzM6QCvNbVWOxf9gHCedUUByhVWsfg6QTN/Jk0KJooG5WfJC3HkHDiVGjCmQFo6mhovEX+8B4phpIOaCr/A==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
@@ -3,7 +3,7 @@
"version": "0.0.1",
"main": "cline-core.js",
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/grpc-js": "~1.13.6",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.4.1",
"grpc-health-check": "^2.0.2",
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import AutoApproveBar, { CLOUD_AUTO_APPROVE_TOOLTIP } from "./AutoApproveBar"
import AutoApproveBar from "./AutoApproveBar"
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeCheckbox: ({ checked, children, disabled }: { checked: boolean; children: React.ReactNode; disabled: boolean }) => (
@@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({ autoApprovalSettings: mocks.autoApprovalSettings }),
useExtensionState: () => ({ autoApprovalSettings: mocks.autoApprovalSettings, hostName: "PyCharm Professional" }),
}))
vi.mock("./AutoApproveSettingsAPI", () => ({
@@ -61,7 +61,7 @@ describe("AutoApproveBar", () => {
expect(trigger).toHaveTextContent("Auto-approve:Read, Edit, Commands, Web Fetch, MCP")
await user.hover(trigger)
expect(await screen.findByText(CLOUD_AUTO_APPROVE_TOOLTIP)).toBeInTheDocument()
expect(await screen.findByText(/when PyCharm is closed\./)).toBeInTheDocument()
fireEvent.click(trigger)
expect(
@@ -81,7 +81,7 @@ describe("AutoApproveBar", () => {
fireEvent.click(screen.getByLabelText("Close auto-approve settings"))
rerender(<AutoApproveBar />)
expect(screen.getByLabelText("Open auto-approve settings")).toHaveTextContent("Auto-approve:Read")
expect(screen.queryByText(CLOUD_AUTO_APPROVE_TOOLTIP)).not.toBeInTheDocument()
expect(screen.queryByText(/when PyCharm is closed\./)).not.toBeInTheDocument()
expect(mocks.autoApprovalSettings).toEqual(localSettings)
})
})
@@ -2,6 +2,7 @@ import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
import { useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { editorName } from "@/utils/editorName"
import { getAsVar, VSC_TITLEBAR_INACTIVE_FOREGROUND } from "@/utils/vscStyles"
import AutoApproveModal from "./AutoApproveModal"
import { ACTION_METADATA } from "./constants"
@@ -11,11 +12,11 @@ interface AutoApproveBarProps {
cloudAutoApprove?: boolean
}
export const CLOUD_AUTO_APPROVE_TOOLTIP =
"Cloud sessions always auto-approve actions so they can continue running when VS Code is closed. Your local auto-approve settings are unchanged."
const cloudAutoApproveTooltip = (editor: string) =>
`Cloud sessions always auto-approve actions so they can continue running when ${editor} is closed. Your local auto-approve settings are unchanged.`
const AutoApproveBar = ({ style, cloudAutoApprove = false }: AutoApproveBarProps) => {
const { autoApprovalSettings } = useExtensionState()
const { autoApprovalSettings, hostName } = useExtensionState()
const [isModalVisible, setIsModalVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
@@ -121,7 +122,7 @@ const AutoApproveBar = ({ style, cloudAutoApprove = false }: AutoApproveBarProps
{isModalVisible ? <ChevronDownIcon size={16} /> : <ChevronRightIcon size={16} />}
</div>
</TooltipTrigger>
{cloudAutoApprove && <TooltipContent side="top">{CLOUD_AUTO_APPROVE_TOOLTIP}</TooltipContent>}
{cloudAutoApprove && <TooltipContent side="top">{cloudAutoApproveTooltip(editorName(hostName))}</TooltipContent>}
</Tooltip>
<AutoApproveModal
@@ -318,7 +318,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
}, [bannerConfig, banners, clineUser, handleBannerAction, handleBannerDismiss, clinePassPromoBanner])
return (
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
<div className="flex flex-col flex-1 w-full min-h-0 p-0 m-0">
<WhatsNewModal
onBannerAction={handleBannerAction}
onClose={handleCloseWhatsNewModal}
@@ -2,9 +2,11 @@ import { EmptyRequest } from "@shared/proto/cline/common"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { CheckCircle2Icon, CloudIcon, LoaderCircleIcon, RefreshCcwIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useGitHubConnection } from "@/hooks/useGitHubConnection"
import { cn } from "@/lib/utils"
import { CloudServiceClient } from "@/services/grpc-client"
import { editorName } from "@/utils/editorName"
/**
* Account view card answering "is my account set up for cloud sessions?":
@@ -12,6 +14,7 @@ import { CloudServiceClient } from "@/services/grpc-client"
* it can reach. Repository selection itself happens on the home screen.
*/
export function CloudGitHubCard() {
const { hostName } = useExtensionState()
const { connection, loading, refresh } = useGitHubConnection(true)
const [waiting, setWaiting] = useState(false)
@@ -63,7 +66,8 @@ export function CloudGitHubCard() {
</span>
) : (
<span className="text-description">
Connect GitHub to run tasks in isolated cloud sandboxes that keep working after you close VS Code.
Connect GitHub to run tasks in isolated cloud sandboxes that keep working after you close{" "}
{editorName(hostName)}.
</span>
)}
</div>
@@ -19,6 +19,7 @@ export function CloudOnboardingCard({
checking = false,
waiting = false,
errorMessage,
editor,
}: {
variant: CloudOnboardingVariant
onConnect: () => void
@@ -28,6 +29,8 @@ export function CloudOnboardingCard({
/** True after the user opened the connect flow and we are polling for completion. */
waiting?: boolean
errorMessage?: string
/** How the copy names the editor, e.g. "PyCharm" or "your IDE". */
editor: string
}) {
if (variant === "error") {
return (
@@ -59,7 +62,7 @@ export function CloudOnboardingCard({
</div>
<p className="m-0 text-xs leading-relaxed text-description">
Cloud sessions run in an isolated sandbox on Cline's infrastructure. Cline clones your repository, works on a
branch, and keeps going after you close VS Code.
branch, and keeps going after you close {editor}.
</p>
</div>
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
workspaceRepoUrl?: string
},
clineUser: undefined as { uid: string } | undefined,
hostName: undefined as string | undefined,
connection: undefined as
| {
signedIn: boolean
@@ -26,7 +27,7 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({ cloudTaskTarget: mocks.cloudTaskTarget }),
useExtensionState: () => ({ cloudTaskTarget: mocks.cloudTaskTarget, hostName: mocks.hostName }),
}))
vi.mock("@/context/ClineAuthContext", () => ({
@@ -63,6 +64,7 @@ describe("TaskTargetPanel", () => {
)
mocks.cloudTaskTarget = { target: "cloud" }
mocks.clineUser = undefined
mocks.hostName = undefined
mocks.connection = undefined
mocks.getRepositoryBranches.mockResolvedValue({ branches: [] })
mocks.chooseCloudTaskTarget.mockResolvedValue({})
@@ -75,6 +77,14 @@ describe("TaskTargetPanel", () => {
expect(screen.getByRole("button", { name: /Sign in to Cline/i })).toBeInTheDocument()
})
it("names the host editor in the onboarding copy", async () => {
mocks.hostName = "PyCharm Professional"
render(<TaskTargetPanel />)
expect(await screen.findByText(/keeps going after you close PyCharm\./)).toBeInTheDocument()
})
it("shows repository onboarding when GitHub has no accessible repositories", async () => {
mocks.clineUser = { uid: "user-1" }
mocks.connection = { signedIn: true, connected: true, connectUrl: "", repositories: [] }
@@ -8,6 +8,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
import { useGitHubConnection } from "@/hooks/useGitHubConnection"
import { cn } from "@/lib/utils"
import { CloudServiceClient } from "@/services/grpc-client"
import { editorName } from "@/utils/editorName"
import { CloudOnboardingCard, type CloudOnboardingVariant } from "./CloudOnboardingCard"
import { SearchableSelect } from "./SearchableSelect"
@@ -28,7 +29,8 @@ const GITHUB_ICON = (
* itself, so nothing it does in the background can override a click.
*/
export function TaskTargetPanel() {
const { cloudTaskTarget } = useExtensionState()
const { cloudTaskTarget, hostName } = useExtensionState()
const editor = editorName(hostName)
const { clineUser } = useClineAuth()
const { handleSignIn } = useClineSignIn()
const signedIn = !!clineUser?.uid
@@ -155,6 +157,7 @@ export function TaskTargetPanel() {
{onboardingVariant ? (
<CloudOnboardingCard
checking={loading}
editor={editor}
errorMessage={connection?.error}
onConnect={connectGitHub}
onRefresh={() => void refresh()}
@@ -201,7 +204,7 @@ export function TaskTargetPanel() {
</button>
</>
) : (
"Uses a Cline model and Cline credits in an isolated sandbox. Tools are auto-approved so the task can keep going after you close VS Code."
`Uses a Cline model and Cline credits in an isolated sandbox. Tools are auto-approved so the task can keep going after you close ${editor}.`
)}
</p>
</div>
@@ -39,14 +39,6 @@ const agentFeatures: FeatureToggle[] = [
stateKey: "useAutoCondense",
settingKey: "useAutoCondense",
},
{
id: "cloud-sessions",
label: "Cloud",
description:
"Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected.",
stateKey: "cloudSessionsEnabled",
settingKey: "cloudSessionsEnabled",
},
]
const editorFeatures: FeatureToggle[] = [
@@ -175,13 +167,11 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
subagentsEnabled,
worktreesEnabled: worktreesEnabled?.user,
backgroundEditEnabled,
cloudSessionsEnabled,
}
// Visibility lookup for features with feature flags
const featureVisibility: Record<string, boolean | undefined> = {
worktreesEnabled: worktreesEnabled?.featureFlag,
cloudSessionsEnabled: !!cloudSessionsAvailable,
}
return (
@@ -228,6 +218,14 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
label="Web Search"
onChange={(checked) => updateSetting("webSearchEnabled", checked)}
/>
<FeatureRow
checked={cloudSessionsEnabled}
description="Run Cline on your GitHub repositories in secure cloud sandboxes. Adds a Cloud option to the new task composer. Requires a Cline account with GitHub connected."
id="cloud-sessions"
isVisible={!!cloudSessionsAvailable}
label="Cloud"
onChange={(checked) => updateSetting("cloudSessionsEnabled", checked)}
/>
</div>
</div>
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest"
import { editorName } from "../editorName"
describe("editorName", () => {
it.each([
["Visual Studio Code", "VS Code"],
["Visual Studio Code - Insiders", "VS Code Insiders"],
["Cursor", "Cursor"],
["Windsurf", "Windsurf"],
["IntelliJ IDEA Ultimate", "IntelliJ IDEA"],
["PyCharm Professional", "PyCharm"],
["PyCharm Community", "PyCharm"],
["WebStorm", "WebStorm"],
["Android Studio", "Android Studio"],
])("names %s as %s", (hostName, expected) => {
expect(editorName(hostName)).toBe(expected)
})
it.each([undefined, "", " "])("falls back to a generic name for %j", (hostName) => {
expect(editorName(hostName)).toBe("your IDE")
})
})
@@ -0,0 +1,16 @@
/**
* The editor's name as users say it, for copy like "after you close PyCharm".
* Hosts report full product names: VS Code's app name ("Visual Studio Code - Insiders")
* and JetBrains products with their edition ("PyCharm Professional"). Forks like Cursor
* report their own name. Falls back to "your IDE" when the host is unknown.
*/
export function editorName(hostName: string | undefined): string {
const name = hostName?.trim()
if (!name) {
return "your IDE"
}
return name
.replace(/^Visual Studio Code(?: - )?/, "VS Code ")
.replace(/ (?:Ultimate|Community|Professional|Educational)(?: Edition)?$/, "")
.trim()
}
+14 -13
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.71",
"version": "3.0.73",
"bin": {
"cline": "src/index.ts",
},
@@ -165,7 +165,7 @@
},
"apps/examples/desktop-app": {
"name": "@cline/code",
"version": "0.0.46",
"version": "0.0.48",
"dependencies": {
"@ai-sdk/gateway": "^4.0.98",
"@ai-sdk/openai": "^4.0.79",
@@ -363,7 +363,7 @@
},
"apps/vscode": {
"name": "claude-dev",
"version": "4.1.24",
"version": "4.1.25",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@bufbuild/protobuf": "^2.2.5",
@@ -400,7 +400,7 @@
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"aws4fetch": "^1.0.20",
"axios": "1.18.0",
"axios": "1.20.0",
"better-sqlite3": "^12.4.1",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
@@ -452,6 +452,7 @@
"undici": "^7.29.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"yaml": "^2.8.2",
"zod": "^4.3.6",
},
"devDependencies": {
@@ -474,7 +475,7 @@
"@types/vscode": "1.101.0",
"@types/ws": "^8.18.1",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/test-electron": "^3.1.0",
"@vscode/vsce": "^3.6.0",
"c8": "^10.1.3",
"chai": "^4.3.10",
@@ -645,7 +646,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.93",
"version": "0.0.95",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -654,7 +655,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.93",
"version": "0.0.95",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -696,7 +697,7 @@
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.93",
"version": "0.0.95",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^5.0.98",
"@ai-sdk/anthropic": "^4.0.67",
@@ -745,14 +746,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.93",
"version": "0.0.95",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.93",
"version": "0.0.95",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
@@ -836,7 +837,7 @@
"@sap-cloud-sdk/openapi": "4.9.1",
"@sap-cloud-sdk/resilience": "4.9.1",
"@sap-cloud-sdk/util": "4.9.1",
"axios": "1.18.0",
"axios": "1.20.0",
"diff": "8.0.4",
"fast-uri": ">=3.1.4",
"js-yaml": ">=4.3.2 <5",
@@ -2748,7 +2749,7 @@
"@vscode/test-cli": ["@vscode/test-cli@0.0.12", "", { "dependencies": { "@types/mocha": "^10.0.10", "c8": "^10.1.3", "chokidar": "^3.6.0", "enhanced-resolve": "^5.18.3", "glob": "^10.3.10", "minimatch": "^9.0.3", "mocha": "^11.7.4", "supports-color": "^10.2.2", "yargs": "^17.7.2" }, "bin": { "vscode-test": "out/bin.mjs" } }, "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ=="],
"@vscode/test-electron": ["@vscode/test-electron@2.5.2", "", { "dependencies": { "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "jszip": "^3.10.1", "ora": "^8.1.0", "semver": "^7.6.2" } }, "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg=="],
"@vscode/test-electron": ["@vscode/test-electron@3.1.0", "", { "dependencies": { "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "jszip": "^3.10.1", "ora": "^8.1.0", "semver": "^7.6.2" } }, "sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw=="],
"@vscode/vsce": ["@vscode/vsce@3.9.2", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^13.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^10.2.2", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.2.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA=="],
@@ -2872,7 +2873,7 @@
"axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="],
"axios": ["axios@1.18.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw=="],
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="],

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