diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbd3674c58..0c6674722a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,3 +34,14 @@ updates: - "version-update:semver-major" - "version-update:semver-minor" - "version-update:semver-patch" + + # Pinned skills CLI release (devDependency of @cline/core). `cline skill` + # and the marketplace skill fallback run this exact version through npx, + # so version updates are wanted here, unlike the entries above: each bump + # goes through CI and review before it reaches users. + - package-ecosystem: "bun" + directory: "/" + schedule: + interval: "weekly" + allow: + - dependency-name: "skills" diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index c0bfb990cb..717b462c0f 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -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. diff --git a/.github/workflows/sdk-test.yml b/.github/workflows/sdk-test.yml index c0fc6c1170..825b9409db 100644 --- a/.github/workflows/sdk-test.yml +++ b/.github/workflows/sdk-test.yml @@ -104,6 +104,10 @@ jobs: bun -F @cline/code test:sidecar bun -F @cline/code test:sidecar-startup + - name: Run Windows compiled plugin resource tests + if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'windows-latest' }} + run: bun -F @cline/code test:sidecar-startup --test-name-pattern "standalone CLI" + - name: Run Desktop Settings UI Tests if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }} run: bun -F @cline/code test:settings-ui diff --git a/README.md b/README.md index 4327adec0c..b3ebd4840b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ npm install @cline/sdk |---------|------------|--------------|--------------| | **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) | | **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) | -| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) | +| **VS Code Extension** | The Marketplace extension and extension host integration. | [`apps/vscode/`](https://github.com/cline/cline/tree/main/apps/vscode) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) | | **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) | | **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - | | **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - | diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 34dc05ac1d..982732b1a5 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # 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` diff --git a/apps/cli/README.md b/apps/cli/README.md index 2feead9d02..9f091c088c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -291,6 +291,12 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `-- - `cline hub` - Manage the local hub daemon - `cline kanban` - Run the external `kanban` app, installing it first when needed +Compiled CLI binaries embed the plugin bootstrap, SDK modules, and dashboard +assets. They can start the Hub, load SDK plugins, and serve the dashboard without +a separate Node/Bun installation or adjacent runtime files. Resources are read directly from the binary’s read-only Bun filesystem; no +runtime cache is extracted. `--plugin-sandbox` is an internal subprocess dispatch +flag, not a user command. + ## Hub dashboard Run `cline hub` to list hub subcommands. To start the browser dashboard: diff --git a/apps/cli/bun.mts b/apps/cli/bun.mts index e7dad9846e..a5fb94d9a6 100644 --- a/apps/cli/bun.mts +++ b/apps/cli/bun.mts @@ -1,9 +1,9 @@ import { - copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, + rmSync, statSync, } from "node:fs"; import { dirname, join } from "node:path"; @@ -136,16 +136,13 @@ if (result.logs.length > 0) { } } -const coreBootstrapPath = join( +const corePluginRuntimePath = join( rootDir, - "../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js", + "../../sdk/packages/core/dist/plugin-runtime", ); -const cliBootstrapPath = join( - rootDir, - "./dist/extensions/plugin-sandbox-bootstrap.js", -); -mkdirSync(dirname(cliBootstrapPath), { recursive: true }); -copyFileSync(coreBootstrapPath, cliBootstrapPath); +const cliPluginRuntimePath = join(rootDir, "dist/plugin-runtime"); +rmSync(cliPluginRuntimePath, { recursive: true, force: true }); +cpSync(corePluginRuntimePath, cliPluginRuntimePath, { recursive: true }); if (existsSync(hubWebviewDistPath)) { mkdirSync(dirname(cliHubWebviewDistPath), { recursive: true }); diff --git a/apps/cli/package.json b/apps/cli/package.json index 490c623c4f..8944d30ba4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@cline/cli", "displayName": "cline", - "version": "3.0.72", + "version": "3.0.73", "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", "type": "module", "publishConfig": { diff --git a/apps/cli/script/build.ts b/apps/cli/script/build.ts index cf25cf511c..9d5f01b589 100644 --- a/apps/cli/script/build.ts +++ b/apps/cli/script/build.ts @@ -1,15 +1,6 @@ #!/usr/bin/env bun -import { - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - statSync, -} from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { type AddressInfo, createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -65,7 +56,7 @@ if (optionsError) { process.exit(1); } -await $`rm -rf dist`; +rmSync(join(cliDir, "dist"), { recursive: true, force: true }); // Pre-install all platform variants of native packages so cross-compilation // can resolve them. Without this, Bun only has the host platform's native @@ -83,48 +74,6 @@ if (!buildOptions.skipSdkBuild) { await $`bun -F @cline/cli build`.cwd(rootDir); } -const hubWebviewSource = join(cliDir, "../cline-hub/src/webview"); -const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview"); -const hubWebviewIndex = join(hubWebviewDist, "index.html"); - -function newestFileMtimeMs(dir: string): number { - let newest = 0; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if ( - entry.name === "node_modules" || - entry.name === "dist" || - entry.name === ".turbo" - ) { - continue; - } - const path = join(dir, entry.name); - if (entry.isDirectory()) { - newest = Math.max(newest, newestFileMtimeMs(path)); - } else if (entry.isFile()) { - newest = Math.max(newest, statSync(path).mtimeMs); - } - } - return newest; -} - -function shouldBuildHubWebview(): boolean { - if (!existsSync(hubWebviewIndex)) { - return true; - } - try { - return ( - newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs - ); - } catch { - return true; - } -} - -if (shouldBuildHubWebview()) { - console.log("Building Cline Hub webview..."); - await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir); -} - const binaries: Record = {}; function findFreePort(): Promise { @@ -211,7 +160,19 @@ for (const item of targets) { console.log(` Passed: hub core ${status.coreVersion}`); } finally { await $`${outfile} hub stop`.env(smokeEnv).quiet().nothrow(); - rmSync(smokeHome, { recursive: true, force: true }); + try { + rmSync(smokeHome, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 100, + }); + } catch (cleanupError) { + console.warn( + ` Warning: failed to remove smoke test dir ${smokeHome}:`, + cleanupError, + ); + } } } catch (e) { console.error(` Smoke test FAILED for ${name}:`, e); @@ -219,26 +180,6 @@ for (const item of targets) { } } - // Copy plugin sandbox bootstrap if it exists - const bootstrapSrc = join( - rootDir, - "sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js", - ); - if (existsSync(bootstrapSrc)) { - const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`); - mkdirSync(bootstrapDir, { recursive: true }); - const content = readFileSync(bootstrapSrc); - await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content); - } - - if (existsSync(hubWebviewDist)) { - const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`); - mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), { - recursive: true, - }); - cpSync(hubWebviewDist, hubWebviewDest, { recursive: true }); - } - // Generate platform package.json await Bun.write( join(cliDir, `dist/${dirName}/package.json`), diff --git a/apps/cli/script/compile-binary.ts b/apps/cli/script/compile-binary.ts index ae89733df1..1a7fc444d9 100644 --- a/apps/cli/script/compile-binary.ts +++ b/apps/cli/script/compile-binary.ts @@ -3,6 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, realpathSync, rmSync, @@ -10,6 +11,7 @@ import { import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { $ } from "bun"; +import { buildDashboardRuntimeDirectory } from "./dashboard-runtime"; const cliDir = resolve(import.meta.dir, ".."); const rootDir = resolve(cliDir, "../.."); @@ -94,6 +96,8 @@ export interface CompileCliBinaryOptions { export async function compileCliBinary( input: CompileCliBinaryOptions, ): Promise { + const pluginRuntime = join(rootDir, "sdk/packages/core/dist/plugin-runtime"); + const dashboardRuntime = await buildDashboardRuntimeDirectory(); const parserWorker = findOpenTuiParserWorker(); const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix"; const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/"; @@ -106,15 +110,13 @@ export async function compileCliBinary( // layer in containerized environments (virtiofs, overlayfs). const entrypoint = join(cliDir, "src/index.ts"); const scratchRoot = process.platform === "win32" ? tmpdir() : "/tmp"; - const tmpDir = join( - scratchRoot, - `cline-build-${input.bunTarget}-${process.pid}`, + const tmpDir = mkdtempSync( + join(scratchRoot, `cline-build-${input.bunTarget}-`), ); const tmpOutfile = join( tmpDir, input.outfile.endsWith(".exe") ? "cline.exe" : "cline", ); - mkdirSync(tmpDir, { recursive: true }); const previousCwd = process.cwd(); process.chdir(scratchRoot); @@ -123,11 +125,13 @@ export async function compileCliBinary( const autoload = input.autoloadLaunchDirectoryConfig !== false; result = await Bun.build({ entrypoints: [entrypoint, parserWorker], + root: rootDir, splitting: true, compile: { target: input.bunTarget, outfile: tmpOutfile, - // Plugins import SDK packages from disk at runtime; let Bun resolve + assets: [pluginRuntime, dashboardRuntime], + // Plugins import SDK packages from the runtime tree; let Bun resolve // their package exports without falling back to transpiling the SDK. autoloadPackageJson: true, ...(input.execArgv?.length ? { execArgv: input.execArgv } : {}), @@ -137,26 +141,31 @@ export async function compileCliBinary( external: ["@anthropic-ai/vertex-sdk"], define: { OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath, + // compile.assets keeps each directory tree under its basename. + __CLINE_PLUGIN_RUNTIME_DIR__: JSON.stringify( + `${bunfsRoot}plugin-runtime`, + ), + __CLINE_DASHBOARD_RUNTIME_DIR__: JSON.stringify(`${bunfsRoot}webview`), // Inline telemetry/OTEL env vars at build time so the compiled // binary ships with production telemetry configuration baked in. ...buildInlinedEnvDefines(), }, throw: false, }); + if (!result.success) { + for (const log of result.logs) { + console.error(log); + } + throw new Error(`CLI build failed for ${input.bunTarget}`); + } + + mkdirSync(dirname(input.outfile), { recursive: true }); + copyFileSync(tmpOutfile, input.outfile); + if (process.platform !== "win32") { + chmodSync(input.outfile, 0o755); + } } finally { process.chdir(previousCwd); - } - - if (!result.success) { - for (const log of result.logs) { - console.error(log); - } rmSync(tmpDir, { recursive: true, force: true }); - throw new Error(`CLI build failed for ${input.bunTarget}`); } - - mkdirSync(dirname(input.outfile), { recursive: true }); - copyFileSync(tmpOutfile, input.outfile); - chmodSync(input.outfile, 0o755); - rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/apps/cli/script/dashboard-runtime.ts b/apps/cli/script/dashboard-runtime.ts new file mode 100644 index 0000000000..5bb61de034 --- /dev/null +++ b/apps/cli/script/dashboard-runtime.ts @@ -0,0 +1,20 @@ +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { $ } from "bun"; + +let directory: Promise | undefined; + +/** Build once per platform-build invocation; the compiler embeds this tree unchanged. */ +export function buildDashboardRuntimeDirectory(): Promise { + directory ??= buildDirectory(); + return directory; +} + +async function buildDirectory(): Promise { + const root = resolve(import.meta.dir, "../../.."); + await $`bun -F @cline/cline-hub build:webview`.cwd(root).quiet(); + const directory = join(root, "apps/cline-hub/dist/webview"); + if (!existsSync(join(directory, "index.html"))) + throw new Error("Dashboard build produced no index.html"); + return directory; +} diff --git a/apps/cli/src/commands/dashboard.test.ts b/apps/cli/src/commands/dashboard.test.ts index 387fc33bdf..16e8ef5862 100644 --- a/apps/cli/src/commands/dashboard.test.ts +++ b/apps/cli/src/commands/dashboard.test.ts @@ -1,5 +1,5 @@ import { mkdirSync, mkdtempSync } from "node:fs"; -import { arch, platform, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runDashboardCommand, waitForProcessShutdown } from "./dashboard"; @@ -20,7 +20,6 @@ const ENV_KEYS = [ "PUBLIC_URL", "ROOM_SECRET", "CLINE_HUB_WEBVIEW_DIST_DIR", - "CLINE_WRAPPER_PATH", ] as const; const originalEnv = Object.fromEntries( @@ -149,48 +148,6 @@ describe("runDashboardCommand", () => { expect(openUrl).not.toHaveBeenCalled(); }); - it("finds webview assets from the published wrapper package layout", async () => { - const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-")); - const wrapperPath = join(root, "node_modules", "cline", "bin", "cline"); - const platformName = platform() === "win32" ? "windows" : platform(); - const webviewDistDir = join( - root, - "node_modules", - "cline", - "node_modules", - "@cline", - `cli-${platformName}-${arch()}`, - "cline-hub", - "webview", - ); - mkdirSync(join(wrapperPath, ".."), { recursive: true }); - mkdirSync(webviewDistDir, { recursive: true }); - process.env.CLINE_WRAPPER_PATH = wrapperPath; - delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR; - let observedWebviewDistDir: string | undefined; - - const exitCode = await runDashboardCommand({ - openBrowser: false, - io: { - writeln: () => {}, - writeErr: () => {}, - }, - startServer: async () => { - observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR; - return { - listenUrl: "http://127.0.0.1:8787/", - publicUrl: "http://127.0.0.1:8787", - inviteUrl: "http://127.0.0.1:8787", - stop: vi.fn(), - }; - }, - waitForShutdown: async () => {}, - }); - - expect(exitCode).toBe(0); - expect(observedWebviewDistDir).toBe(webviewDistDir); - }); - it("settles shutdown when server stop rejects", async () => { const shutdown = waitForProcessShutdown({ listenUrl: "http://127.0.0.1:8787/", diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts index 72496e4e4c..ac5418daef 100644 --- a/apps/cli/src/commands/dashboard.ts +++ b/apps/cli/src/commands/dashboard.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; -import { arch, platform } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveEmbeddedDashboard } from "../utils/embedded-dashboard"; import { configureSandboxEnvironment } from "../utils/helpers"; import open from "../utils/open"; import { c } from "../utils/output"; @@ -97,49 +97,22 @@ function resolveDefaultWebviewDistDir(): string | undefined { if (process.env[WEBVIEW_DIST_ENV]?.trim()) { return undefined; } + const embedded = resolveEmbeddedDashboard(); + if (embedded) { + return embedded; + } const moduleDir = dirname(fileURLToPath(import.meta.url)); const candidates = [ - ...resolveInstalledPlatformPackageWebviewCandidates(), // Source checkout: apps/cli/src/commands/dashboard.ts join(moduleDir, "../../../cline-hub/dist/webview"), // Node bundle: apps/cli/dist/index.js join(moduleDir, "cline-hub/webview"), - // Compiled platform package: apps/cli/dist//bin/cline - join(dirname(process.execPath), "../cline-hub/webview"), ]; return candidates.find((candidate) => existsSync(candidate)); } -function resolveInstalledPlatformPackageWebviewCandidates(): string[] { - const packageName = resolvePlatformPackageName(); - const starts = [ - process.env.CLINE_WRAPPER_PATH - ? dirname(process.env.CLINE_WRAPPER_PATH) - : undefined, - dirname(process.execPath), - ].filter((value): value is string => !!value?.trim()); - const candidates: string[] = []; - for (const start of starts) { - let current = start; - for (;;) { - candidates.push( - join(current, "node_modules", packageName, "cline-hub/webview"), - ); - const parent = dirname(current); - if (parent === current) break; - current = parent; - } - } - return candidates; -} - -function resolvePlatformPackageName(): string { - const platformName = platform() === "win32" ? "windows" : platform(); - return `@cline/cli-${platformName}-${arch()}`; -} - async function startDefaultDashboardServer(): Promise { const { startClineHubDashboardServer } = await import("@cline/cline-hub"); return await startClineHubDashboardServer(); diff --git a/apps/cli/src/commands/skill.test.ts b/apps/cli/src/commands/skill.test.ts index 1213748f36..b3bc96b3b7 100644 --- a/apps/cli/src/commands/skill.test.ts +++ b/apps/cli/src/commands/skill.test.ts @@ -1,15 +1,20 @@ +import { SKILLS_CLI_PACKAGE } from "@cline/core"; import { describe, expect, it } from "vitest"; import { buildSkillsArgs } from "./skill"; describe("buildSkillsArgs", () => { it("runs the skills package through npx with -y", () => { - expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]); + expect(buildSkillsArgs(["list"])).toEqual([ + "-y", + SKILLS_CLI_PACKAGE, + "list", + ]); }); it("injects --agent cline for install-style subcommands", () => { expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "add", "owner/repo", "--agent", @@ -23,7 +28,7 @@ describe("buildSkillsArgs", () => { it("aliases uninstall to the skills remove subcommand", () => { expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "remove", "my-skill", "--agent", @@ -48,7 +53,7 @@ describe("buildSkillsArgs", () => { buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]), ).toEqual([ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "--agent", "cursor", "add", @@ -56,7 +61,13 @@ describe("buildSkillsArgs", () => { ]); expect( buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]), - ).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]); + ).toEqual([ + "-y", + SKILLS_CLI_PACKAGE, + "--agent=cursor", + "remove", + "my-skill", + ]); }); it("does not scope non-install subcommands to cline", () => { @@ -67,7 +78,7 @@ describe("buildSkillsArgs", () => { it("scopes remove-style subcommands to cline", () => { expect(buildSkillsArgs(["remove"])).toEqual([ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "remove", "--agent", "cline", @@ -83,6 +94,6 @@ describe("buildSkillsArgs", () => { }); it("forwards an empty arg list unchanged", () => { - expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]); + expect(buildSkillsArgs([])).toEqual(["-y", SKILLS_CLI_PACKAGE]); }); }); diff --git a/apps/cli/src/commands/skill.ts b/apps/cli/src/commands/skill.ts index 934fc878fa..24a15a6167 100644 --- a/apps/cli/src/commands/skill.ts +++ b/apps/cli/src/commands/skill.ts @@ -1,4 +1,5 @@ import { type SpawnOptions, spawn } from "node:child_process"; +import { SKILLS_CLI_PACKAGE } from "@cline/core"; export interface SkillCommandIo { writeln: (text?: string) => void; @@ -7,9 +8,9 @@ export interface SkillCommandIo { // `cline skill` is a thin wrapper around the open skills CLI // (https://www.npmjs.com/package/skills). We run it through `npx` so users -// don't need a separate global install. Pin the version here if we ever need to -// lock behavior to a known-good release. -const SKILLS_PACKAGE = "skills@latest"; +// don't need a separate global install. The release is pinned in @cline/core +// (sdk/packages/core/package.json) and bumped by Dependabot. +const SKILLS_PACKAGE = SKILLS_CLI_PACKAGE; // Subcommands that write skill files into an agent's skills directory. For a // `cline skill` command we default these to Cline unless the user picked their diff --git a/apps/cli/src/commands/update.ts b/apps/cli/src/commands/update.ts index b7f415c14b..35aef9da81 100644 --- a/apps/cli/src/commands/update.ts +++ b/apps/cli/src/commands/update.ts @@ -7,6 +7,7 @@ import { resolveProductionHubOwnerContext, resolveSharedHubOwnerContext, } from "@cline/core"; +import { hasActiveHubSessions } from "@cline/core/hub"; import { resolveClineBuildEnv } from "@cline/shared"; import { version } from "../../package.json"; import { c, writeErr, writeln } from "../utils/output"; @@ -381,6 +382,7 @@ async function otherCliClientsAttached(): Promise { undefined, { timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS }, ); + if (hasActiveHubSessions(sessions.payload)) return true; const sessionRecords = (sessions.payload as { sessions?: Array<{ participants?: unknown }> }) .sessions ?? []; diff --git a/apps/cli/src/connectors/adapters/discord.ts b/apps/cli/src/connectors/adapters/discord.ts index b034c9a711..8398f27857 100644 --- a/apps/cli/src/connectors/adapters/discord.ts +++ b/apps/cli/src/connectors/adapters/discord.ts @@ -1071,10 +1071,7 @@ class DiscordConnector extends ConnectorBase< }); await userInstructionService.start().catch(() => undefined); const commandCwd = startRequest.cwd || process.cwd(); - const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ - cwd: commandCwd, - workspaceRoot: startRequest.workspaceRoot || commandCwd, - }); + const { url: rpcAddress, authToken: rpcAuthToken } = await ensureCliHubServer( startRequest.workspaceRoot || startRequest.cwd || process.cwd(), @@ -1097,6 +1094,11 @@ class DiscordConnector extends ConnectorBase< }, }); await client.connect(); + const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ + commands: client.pluginCommands, + cwd: commandCwd, + workspaceRoot: startRequest.workspaceRoot || commandCwd, + }); this.writeConnectorState(statePath, { userName: options.userName, applicationId: options.applicationId, diff --git a/apps/cli/src/connectors/adapters/gchat.ts b/apps/cli/src/connectors/adapters/gchat.ts index aff82b57f2..5f1d4032d2 100644 --- a/apps/cli/src/connectors/adapters/gchat.ts +++ b/apps/cli/src/connectors/adapters/gchat.ts @@ -577,10 +577,7 @@ class GoogleChatConnector extends ConnectorBase< }); await userInstructionService.start().catch(() => undefined); const commandCwd = startRequest.cwd || process.cwd(); - const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ - cwd: commandCwd, - workspaceRoot: startRequest.workspaceRoot || commandCwd, - }); + const { url: rpcAddress, authToken: rpcAuthToken } = await ensureCliHubServer( startRequest.workspaceRoot || startRequest.cwd || process.cwd(), @@ -602,6 +599,11 @@ class GoogleChatConnector extends ConnectorBase< }, }); await client.connect(); + const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ + commands: client.pluginCommands, + cwd: commandCwd, + workspaceRoot: startRequest.workspaceRoot || commandCwd, + }); this.writeConnectorState(statePath, { userName: options.userName, pid: process.pid, diff --git a/apps/cli/src/connectors/adapters/linear.ts b/apps/cli/src/connectors/adapters/linear.ts index ff38da9e99..6cd4ada559 100644 --- a/apps/cli/src/connectors/adapters/linear.ts +++ b/apps/cli/src/connectors/adapters/linear.ts @@ -600,10 +600,7 @@ class LinearConnector extends ConnectorBase< }); await userInstructionService.start().catch(() => undefined); const commandCwd = startRequest.cwd || process.cwd(); - const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ - cwd: commandCwd, - workspaceRoot: startRequest.workspaceRoot || commandCwd, - }); + const { url: rpcAddress, authToken: rpcAuthToken } = await ensureCliHubServer( startRequest.workspaceRoot || startRequest.cwd || process.cwd(), @@ -625,6 +622,11 @@ class LinearConnector extends ConnectorBase< }, }); await client.connect(); + const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ + commands: client.pluginCommands, + cwd: commandCwd, + workspaceRoot: startRequest.workspaceRoot || commandCwd, + }); this.writeConnectorState(statePath, { userName: options.userName, pid: process.pid, diff --git a/apps/cli/src/connectors/adapters/slack.ts b/apps/cli/src/connectors/adapters/slack.ts index e163e7a870..197d74ed4f 100644 --- a/apps/cli/src/connectors/adapters/slack.ts +++ b/apps/cli/src/connectors/adapters/slack.ts @@ -874,10 +874,7 @@ class SlackConnector extends ConnectorBase< }); await userInstructionService.start().catch(() => undefined); const commandCwd = startRequest.cwd || process.cwd(); - const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ - cwd: commandCwd, - workspaceRoot: startRequest.workspaceRoot || commandCwd, - }); + const { url: rpcAddress, authToken: rpcAuthToken } = await ensureCliHubServer( startRequest.workspaceRoot || startRequest.cwd || process.cwd(), @@ -899,6 +896,11 @@ class SlackConnector extends ConnectorBase< }, }); await client.connect(); + const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ + commands: client.pluginCommands, + cwd: commandCwd, + workspaceRoot: startRequest.workspaceRoot || commandCwd, + }); this.writeConnectorState(statePath, { claimId: claim.claimId, userName: options.userName, diff --git a/apps/cli/src/connectors/adapters/telegram.ts b/apps/cli/src/connectors/adapters/telegram.ts index 0ecfb80820..88fba51063 100644 --- a/apps/cli/src/connectors/adapters/telegram.ts +++ b/apps/cli/src/connectors/adapters/telegram.ts @@ -804,10 +804,7 @@ class TelegramConnector extends ConnectorBase< }); await userInstructionService.start().catch(() => undefined); const commandCwd = startRequest.cwd || process.cwd(); - const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ - cwd: commandCwd, - workspaceRoot: startRequest.workspaceRoot || commandCwd, - }); + const { url: rpcAddress, authToken: rpcAuthToken } = await ensureCliHubServer( startRequest.workspaceRoot || startRequest.cwd || process.cwd(), @@ -829,6 +826,11 @@ class TelegramConnector extends ConnectorBase< }, }); await client.connect(); + const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ + commands: client.pluginCommands, + cwd: commandCwd, + workspaceRoot: startRequest.workspaceRoot || commandCwd, + }); this.writeConnectorState(statePath, { botUsername: options.botUsername, botId: readTelegramBotId(options.botToken), diff --git a/apps/cli/src/connectors/adapters/whatsapp.ts b/apps/cli/src/connectors/adapters/whatsapp.ts index d2dc9624c3..df741485b6 100644 --- a/apps/cli/src/connectors/adapters/whatsapp.ts +++ b/apps/cli/src/connectors/adapters/whatsapp.ts @@ -569,10 +569,7 @@ class WhatsAppConnector extends ConnectorBase< }); await userInstructionService.start().catch(() => undefined); const commandCwd = startRequest.cwd || process.cwd(); - const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ - cwd: commandCwd, - workspaceRoot: startRequest.workspaceRoot || commandCwd, - }); + const { url: rpcAddress, authToken: rpcAuthToken } = await ensureCliHubServer( startRequest.workspaceRoot || startRequest.cwd || process.cwd(), @@ -597,6 +594,11 @@ class WhatsAppConnector extends ConnectorBase< }, }); await client.connect(); + const { host: chatCommandHost } = await createWorkspaceChatCommandHost({ + commands: client.pluginCommands, + cwd: commandCwd, + workspaceRoot: startRequest.workspaceRoot || commandCwd, + }); this.writeConnectorState(statePath, { instanceKey, userName: options.userName, diff --git a/apps/cli/src/connectors/connector-host.test.ts b/apps/cli/src/connectors/connector-host.test.ts index 1654e4a9ce..e8bbe86684 100644 --- a/apps/cli/src/connectors/connector-host.test.ts +++ b/apps/cli/src/connectors/connector-host.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { SentMessage } from "chat"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands"; import { enqueueThreadTurn } from "./chat-runtime"; import { handleConnectorUserTurn } from "./connector-host"; @@ -178,6 +179,64 @@ describe("handleConnectorUserTurn", () => { } }); + it.each([ + "discord", + "gchat", + "linear", + "slack", + "telegram", + "whatsapp", + ])("routes %s plugin commands with each thread's current session and workspace", async (transport) => { + const dir = mkdtempSync(join(tmpdir(), "connector-command-context-")); + tempDirs.push(dir); + const run = vi.fn(async () => ({ reply: "handled" })); + const { host } = await createWorkspaceChatCommandHost({ + cwd: dir, + commands: { + run, + list: vi + .fn() + .mockResolvedValue({ status: "ready", commands: [{ name: "echo" }] }), + }, + }); + await Promise.all( + ["one", "two"].map(async (id) => { + const workspace = join(dir, id); + const { thread, posts } = createThread({ + sessionId: id, + cwd: workspace, + workspaceRoot: workspace, + }); + await handleConnectorUserTurn({ + thread: thread as never, + client: {} as never, + pendingApprovals: new Map(), + baseStartRequest: baseStartRequest() as never, + clientId: "client", + explicitSystemPrompt: undefined, + logger: { + core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() }, + } as never, + transport, + botUserName: "cline", + requestStop: vi.fn(), + bindingsPath: join(dir, `${id}.json`), + systemRules: "rules", + errorLabel: transport, + getSessionMetadata: () => ({}), + reusedLogMessage: "reused", + text: "/echo hello", + chatCommandHost: host, + }); + expect(run).toHaveBeenCalledWith({ + sessionId: id, + workspacePath: workspace, + prompt: "/echo hello", + }); + expect(messageText(posts.at(-1))).toContain("handled"); + }), + ); + }); it("posts no greeting when the adapter configures none", async () => { // Slack deliberately configures no first-contact message: the greeting is // gated on per-thread state, so a restart or a cleared history replayed it diff --git a/apps/cli/src/connectors/connector-host.ts b/apps/cli/src/connectors/connector-host.ts index b104e34202..5db5695434 100644 --- a/apps/cli/src/connectors/connector-host.ts +++ b/apps/cli/src/connectors/connector-host.ts @@ -628,6 +628,7 @@ export async function handleConnectorUserTurn< input.forceDisableTools, ); return { + sessionId: effectiveCurrent.sessionId, enableTools: effectiveCurrent.enableTools ?? input.baseStartRequest.enableTools, autoApproveTools: diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 3b4d9e7eb5..f4a028da1d 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { isMainThread } from "node:worker_threads"; +import { setPluginSandboxLauncher } from "@cline/core"; import { buildTelemetrySelfcheckReport, claimHubDaemonProcess, @@ -32,8 +33,20 @@ disableCurrentDirectoryExecutableSearch(); // Before any personality probes the local hub over 127.0.0.1. ensureLoopbackProxyBypass(); +if (Bun.isStandaloneExecutable) { + setPluginSandboxLauncher({ + command: process.execPath, + argsPrefix: ["--plugin-sandbox"], + }); +} + if (!isMainThread) { // Worker imports of the bundled CLI entrypoint should not start the CLI. +} else if (process.argv.includes("--plugin-sandbox")) { + // Boot as the compiled host so the child mounts the same read-only resources. + const bootstrap = process.argv[process.argv.indexOf("--plugin-sandbox") + 1]; + if (!bootstrap) throw new Error("Missing plugin sandbox bootstrap path"); + await import(bootstrap); } else if (process.argv.includes("--telemetry-selfcheck")) { // Inspect the config embedded in this executable without starting a hub, // loading user settings, or initializing telemetry exporters. diff --git a/apps/cli/src/runtime/interactive/session-runtime.test.ts b/apps/cli/src/runtime/interactive/session-runtime.test.ts index d4944913de..7af7eaabcc 100644 --- a/apps/cli/src/runtime/interactive/session-runtime.test.ts +++ b/apps/cli/src/runtime/interactive/session-runtime.test.ts @@ -144,6 +144,7 @@ function makeManager() { }); return { start, + pluginCommands: { list: vi.fn(), run: vi.fn() }, stop: vi.fn(async () => {}), send: vi.fn(), getAccumulatedUsage: vi.fn(), @@ -242,6 +243,65 @@ describe("createInteractiveSessionRuntime", () => { subscribeToPendingPromptEventsMock.mockReturnValue(() => {}); }); + it.each([ + undefined, + "resumed-session", + ])("shares initialization between plugin catalog discovery and session startup (%s)", async (resumeSessionId) => { + const manager = makeManager(); + manager.get.mockResolvedValue(undefined); + manager.getAccumulatedUsage.mockResolvedValue(undefined); + const runtime = await makeRuntime(manager, { resumeSessionId }); + if (resumeSessionId) + loadInteractiveResumeMessagesMock.mockResolvedValue([ + { role: "user", content: "resume" }, + ]); + const gate = deferred(); + createCliCoreMock.mockReturnValue(gate.promise); + const commands = runtime.getPluginCommandsApi(); + const ready = runtime.ensureReady(); + expect(createCliCoreMock).toHaveBeenCalledTimes(1); + gate.resolve(manager); + expect(await commands).toBe(manager.pluginCommands); + await ready; + expect(manager.start).toHaveBeenCalledTimes(1); + expect(subscribeToAgentEventsMock).toHaveBeenCalledTimes(1); + await runtime.cleanup(); + expect(manager.dispose).toHaveBeenCalledTimes(1); + }); + + it("retries failed shared initialization", async () => { + const manager = makeManager(); + const runtime = await makeRuntime(manager); + createCliCoreMock.mockRejectedValueOnce(new Error("startup failed")); + await expect(runtime.getPluginCommandsApi()).rejects.toThrow( + "startup failed", + ); + expect(await runtime.getPluginCommandsApi()).toBe(manager.pluginCommands); + expect(createCliCoreMock).toHaveBeenCalledTimes(2); + await runtime.cleanup(); + }); + + it("awaits and disposes plugin-only initialization during cleanup", async () => { + const manager = makeManager(); + const runtime = await makeRuntime(manager); + const gate = deferred(); + createCliCoreMock.mockReturnValue(gate.promise); + const commands = expect(runtime.getPluginCommandsApi()).rejects.toThrow( + "shutdown requested", + ); + const cleanup = runtime.cleanup(); + gate.resolve(manager); + await commands; + await cleanup; + expect(manager.dispose).toHaveBeenCalledExactlyOnceWith( + "cli_interactive_startup_cancelled", + ); + await expect(runtime.getPluginCommandsApi()).rejects.toThrow( + "shutdown requested", + ); + expect(createCliCoreMock).toHaveBeenCalledTimes(1); + }); + it("manual compact updates the active session sidecar without restarting", async () => { const sessionId = "sess-active"; const messages = [ @@ -518,6 +578,7 @@ describe("createInteractiveSessionRuntime", () => { getAccumulatedUsage: vi.fn(), }; createCliCoreMock.mockResolvedValue(manager); + const onActiveSessionChange = vi.fn(); const { createInteractiveSessionRuntime } = await importRuntime(); const runtime = createInteractiveSessionRuntime({ config: createConfig(), @@ -532,11 +593,13 @@ describe("createInteractiveSessionRuntime", () => { onTeamEvent: vi.fn(), onPendingPrompts: vi.fn(), onPendingPromptSubmitted: vi.fn(), + onActiveSessionChange, }); await runtime.ensureReady(); expect(manager.start).toHaveBeenCalledOnce(); expect(runtime.getActiveSessionId()).toBe("session-1"); + expect(onActiveSessionChange).toHaveBeenCalledTimes(1); await runtime.resetForNewSession(); @@ -544,11 +607,13 @@ describe("createInteractiveSessionRuntime", () => { expect(manager.start).toHaveBeenCalledOnce(); expect(runtime.getActiveSessionId()).toBe(""); expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined); + expect(onActiveSessionChange).toHaveBeenCalledTimes(2); await runtime.ensureReady(); expect(manager.start).toHaveBeenCalledTimes(2); expect(runtime.getActiveSessionId()).toBe("session-2"); + expect(onActiveSessionChange).toHaveBeenCalledTimes(3); }); it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => { diff --git a/apps/cli/src/runtime/interactive/session-runtime.ts b/apps/cli/src/runtime/interactive/session-runtime.ts index d0abae388f..a282249a17 100644 --- a/apps/cli/src/runtime/interactive/session-runtime.ts +++ b/apps/cli/src/runtime/interactive/session-runtime.ts @@ -111,8 +111,10 @@ export function createInteractiveSessionRuntime(input: { onTeamEvent: (event: TeamEvent) => void; onPendingPrompts: (event: PendingPromptSnapshot) => void; onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent) => void; + onActiveSessionChange?: () => void; }) { let sessionManager: CliCore | undefined; + let sessionManagerPromise: Promise | undefined; let runtimeHooks: RuntimeHooks | undefined; let unsubscribeAgent = () => {}; let unsubscribePendingPrompts = () => {}; @@ -134,6 +136,7 @@ export function createInteractiveSessionRuntime(input: { const clearActiveSession = (): void => { activeSessionId = ""; setActiveCliSession(undefined); + input.onActiveSessionChange?.(); }; const applyStartedSession = (started: StartedSession): void => { @@ -141,6 +144,7 @@ export function createInteractiveSessionRuntime(input: { manifest: started.manifest, }); activeSessionId = started.sessionId; + input.onActiveSessionChange?.(); for (const message of formatPluginIssueWarnings(started.pluginIssues)) { input.onAgentEvent({ type: "notice", @@ -151,10 +155,7 @@ export function createInteractiveSessionRuntime(input: { } }; - const ensureSessionManager = async (): Promise => { - if (sessionManager) { - return sessionManager; - } + const createSessionManager = async (): Promise => { const manager = await createCliCore({ // Interactive startup must never wait for a detached hub daemon to boot. // `auto` uses an already-compatible hub when one is immediately available, @@ -205,6 +206,20 @@ export function createInteractiveSessionRuntime(input: { return manager; }; + const ensureSessionManager = (): Promise => { + if (sessionManager) return Promise.resolve(sessionManager); + if (shutdownRequested) + return Promise.reject( + new Error("interactive runtime shutdown requested"), + ); + if (!sessionManagerPromise) { + sessionManagerPromise = createSessionManager().finally(() => { + sessionManagerPromise = undefined; + }); + } + return sessionManagerPromise; + }; + const buildSessionConfig = (): Config => { if (!runtimeHooks) { throw new Error("interactive runtime hooks are unavailable"); @@ -885,6 +900,7 @@ export function createInteractiveSessionRuntime(input: { shutdownRequested = true; let exitSummary: InteractiveExitSummary | undefined; try { + await sessionManagerPromise?.catch(() => {}); await startupPromise?.catch(() => {}); await missingSessionRecoveryPromise?.catch(() => {}); } finally { @@ -899,7 +915,11 @@ export function createInteractiveSessionRuntime(input: { await stopCurrentSession(); } finally { if (sessionManager) { - await sessionManager.dispose("cli_interactive_shutdown"); + try { + await sessionManager.dispose("cli_interactive_shutdown"); + } finally { + sessionManager = undefined; + } } } return exitSummary; @@ -908,6 +928,8 @@ export function createInteractiveSessionRuntime(input: { }; return { + getPluginCommandsApi: async () => + (await ensureSessionManager()).pluginCommands, ensureReady, listCoreSettings, toggleCoreSettings, diff --git a/apps/cli/src/runtime/run-interactive.ts b/apps/cli/src/runtime/run-interactive.ts index 037e09179a..2f123566da 100644 --- a/apps/cli/src/runtime/run-interactive.ts +++ b/apps/cli/src/runtime/run-interactive.ts @@ -194,44 +194,49 @@ export async function runInteractive( userInstructionService, ); let interactiveChatCommandHost = chatCommandHost; - let pluginChatCommandHostLoaded = false; - let pluginChatSlashCommands: InteractiveSlashCommand[] = []; - let pluginChatCommandHostShutdown: (() => Promise) | undefined; - let pluginChatCommandHostPromise: - | Promise + let pluginHostPromise: + | ReturnType | undefined; - const ensurePluginChatCommandHost = async (): Promise< - InteractiveSlashCommand[] - > => { - if (pluginChatCommandHostLoaded) { - return pluginChatSlashCommands; - } - pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({ - cwd: config.cwd, - workspaceRoot: config.workspaceRoot, - logger: config.logger, - }) - .then(({ host, pluginSlashCommands, shutdown }) => { - interactiveChatCommandHost = host; - pluginChatCommandHostShutdown = shutdown; - pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({ - name: cmd.name, - instructions: "", - description: cmd.description ?? "Plugin command", - })); - return pluginChatSlashCommands; + const ensurePluginHost = () => { + pluginHostPromise ??= sessionRuntime + .getPluginCommandsApi() + .then((commands) => + createWorkspaceChatCommandHost({ + cwd: config.cwd, + workspaceRoot: config.workspaceRoot, + logger: config.logger, + commands, + getSessionId: () => sessionRuntime.getActiveSessionId(), + }), + ) + .then((host) => { + interactiveChatCommandHost = host.host; + return host; }) - .finally(() => { - pluginChatCommandHostLoaded = true; - pluginChatCommandHostPromise = undefined; + .catch((error) => { + pluginHostPromise = undefined; + throw error; }); - return await pluginChatCommandHostPromise; + return pluginHostPromise; }; const loadAdditionalSlashCommands = async (): Promise< InteractiveSlashCommand[] - > => await ensurePluginChatCommandHost(); - const shouldTryPluginChatCommands = (prompt: string): boolean => { - return prompt.trimStart().startsWith("/"); + > => { + const host = await ensurePluginHost(); + return (await host.listCommands()).map((command) => ({ + ...command, + description: command.description ?? "Plugin command", + instructions: "", + })); + }; + // Session commands come from the active session's plugins, so a session + // change can change the catalog. + const activeSessionListeners = new Set<() => void>(); + const subscribeAdditionalSlashCommands = (listener: () => void) => { + activeSessionListeners.add(listener); + return () => { + activeSessionListeners.delete(listener); + }; }; const enableChatCommands = true; @@ -297,6 +302,9 @@ export async function runInteractive( onPendingPromptSubmitted: (event) => { uiEvents.emit("pending-prompt-submitted", event); }, + onActiveSessionChange: () => { + for (const listener of activeSessionListeners) listener(); + }, }); const configDataLoader = createInteractiveConfigDataLoader({ config, @@ -385,11 +393,6 @@ export async function runInteractive( try { exitSummary = await sessionRuntime.cleanup(); } finally { - await pluginChatCommandHostPromise?.catch(() => []); - await pluginChatCommandHostShutdown?.().catch(() => { - // Best effort cleanup for plugin command discovery sandbox. - }); - pluginChatCommandHostShutdown = undefined; setActiveRuntimeAbort(undefined); setActiveRuntimeCleanup(undefined); } @@ -525,6 +528,7 @@ export async function runInteractive( initialRepoStatus, workflowSlashCommands, loadAdditionalSlashCommands, + subscribeAdditionalSlashCommands, loadWelcomeLine: async () => await resolveClineWelcomeLine({ config, @@ -579,7 +583,8 @@ export async function runInteractive( isRunning = true; } - let chatCommandResult = await runInteractiveChatCommand({ + if (input.trimStart().startsWith("/")) await ensurePluginHost(); + const chatCommandResult = await runInteractiveChatCommand({ prompt: input, enabled: enableChatCommands, config, @@ -594,27 +599,7 @@ export async function runInteractive( if (chatCommandResult.handled) { return chatCommandResult.turnResult; } - if ( - shouldTryPluginChatCommands(input) && - !pluginChatCommandHostLoaded - ) { - await ensurePluginChatCommandHost(); - chatCommandResult = await runInteractiveChatCommand({ - prompt: input, - enabled: enableChatCommands, - config, - host: interactiveChatCommandHost, - chatCommandState, - autoApproveAllRef, - setInteractiveAutoApprove, - sessionRuntime, - stop: () => tuiApp?.destroy(), - onCommandOutput, - }); - if (chatCommandResult.handled) { - return chatCommandResult.turnResult; - } - } + input = chatCommandResult.input; commandOutput = chatCommandResult.commandOutput; zeroTurnCost = await shouldZeroClineFreeModelCost(config); diff --git a/apps/cli/src/tui/commands/slash-command-registry.ts b/apps/cli/src/tui/commands/slash-command-registry.ts index 438c28129e..31b51ad6bc 100644 --- a/apps/cli/src/tui/commands/slash-command-registry.ts +++ b/apps/cli/src/tui/commands/slash-command-registry.ts @@ -206,6 +206,13 @@ export function buildSlashCommandRegistry(input: { }); } + for (const command of input.additionalSlashCommands ?? []) { + const entry = entryFromRuntimeCommand(command, "plugin"); + if (entry) { + addEntry(byName, entry); + } + } + for (const command of input.workflowSlashCommands ?? []) { const source = command.kind === "skill" || command.kind === "workflow" @@ -217,13 +224,6 @@ export function buildSlashCommandRegistry(input: { } } - for (const command of input.additionalSlashCommands ?? []) { - const entry = entryFromRuntimeCommand(command, "plugin"); - if (entry) { - addEntry(byName, entry); - } - } - return { entries: [...byName.values()], byName, diff --git a/apps/cli/src/tui/components/chat-entry.tsx b/apps/cli/src/tui/components/chat-entry.tsx index 462ae92d93..68b9b3266e 100644 --- a/apps/cli/src/tui/components/chat-entry.tsx +++ b/apps/cli/src/tui/components/chat-entry.tsx @@ -201,10 +201,8 @@ function formatToolParams( case "skills": { if (rawInput && typeof rawInput === "object" && "skill" in rawInput) { const s = String((rawInput as { skill: unknown }).skill); - const args = - "args" in rawInput - ? ` ${String((rawInput as { args: unknown }).args)}` - : ""; + const rawArgs = (rawInput as { args?: unknown }).args; + const args = rawArgs == null || rawArgs === "" ? "" : ` ${rawArgs}`; const full = `${s}${args}`; return full.length > 70 ? `${full.slice(0, 70)}...` : full; } diff --git a/apps/cli/src/tui/hooks/use-slash-commands.test.ts b/apps/cli/src/tui/hooks/use-slash-commands.test.ts new file mode 100644 index 0000000000..3eddd12757 --- /dev/null +++ b/apps/cli/src/tui/hooks/use-slash-commands.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSlashCommands } from "./use-slash-commands"; + +const reactMock = vi.hoisted(() => ({ + cleanups: [] as Array<() => void>, + setCommands: vi.fn(), +})); +vi.mock("react", () => ({ + useState: (initial: unknown) => [initial, reactMock.setCommands], + useMemo: (compute: () => unknown) => compute(), + useEffect: (effect: () => (() => void) | undefined) => { + const cleanup = effect(); + if (cleanup) reactMock.cleanups.push(cleanup); + }, +})); +const first = [{ name: "first", description: "First", instructions: "" }]; +const next = [{ name: "next", description: "Next", instructions: "" }]; +function useTestSlashCommands( + load: NonNullable< + Parameters[0]["loadAdditionalSlashCommands"] + >, + subscribe?: Parameters< + typeof useSlashCommands + >[0]["subscribeAdditionalSlashCommands"], +) { + useSlashCommands({ + workflowSlashCommands: [], + loadAdditionalSlashCommands: load, + subscribeAdditionalSlashCommands: subscribe, + canFork: false, + }); +} +function unmount() { + for (const cleanup of reactMock.cleanups.splice(0)) cleanup(); +} +beforeEach(() => { + reactMock.setCommands.mockClear(); +}); +afterEach(() => { + unmount(); +}); +describe("plugin slash command catalog lifecycle", () => { + it("reloads when notified and keeps the last catalog when a reload fails", async () => { + let notify = () => {}; + const stop = vi.fn(); + const load = vi + .fn() + .mockResolvedValueOnce(first) + .mockRejectedValueOnce(new Error("hub unavailable")) + .mockResolvedValueOnce(next); + useTestSlashCommands(load, (listener) => { + notify = listener; + return stop; + }); + await vi.waitFor(() => + expect(reactMock.setCommands).toHaveBeenLastCalledWith(first), + ); + notify(); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + await Promise.resolve(); + expect(reactMock.setCommands).toHaveBeenCalledTimes(1); + notify(); + await vi.waitFor(() => + expect(reactMock.setCommands).toHaveBeenLastCalledWith(next), + ); + unmount(); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it("ignores an older load that resolves after a newer one", async () => { + let notify = () => {}; + let resolveOlder!: (commands: typeof first) => void; + const load = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOlder = resolve; + }), + ) + .mockResolvedValueOnce(next); + useTestSlashCommands(load, (listener) => { + notify = listener; + return () => {}; + }); + notify(); + await vi.waitFor(() => + expect(reactMock.setCommands).toHaveBeenLastCalledWith(next), + ); + resolveOlder(first); + await Promise.resolve(); + expect(reactMock.setCommands).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/src/tui/hooks/use-slash-commands.ts b/apps/cli/src/tui/hooks/use-slash-commands.ts index 8da19c0405..8d10b8a16e 100644 --- a/apps/cli/src/tui/hooks/use-slash-commands.ts +++ b/apps/cli/src/tui/hooks/use-slash-commands.ts @@ -10,9 +10,15 @@ import type { TuiProps } from "../types"; export function useSlashCommands(input: { workflowSlashCommands: TuiProps["workflowSlashCommands"]; loadAdditionalSlashCommands: TuiProps["loadAdditionalSlashCommands"]; + subscribeAdditionalSlashCommands: TuiProps["subscribeAdditionalSlashCommands"]; canFork: boolean; }) { - const { workflowSlashCommands, loadAdditionalSlashCommands, canFork } = input; + const { + workflowSlashCommands, + loadAdditionalSlashCommands, + subscribeAdditionalSlashCommands, + canFork, + } = input; const [additionalSlashCommands, setAdditionalSlashCommands] = useState< TuiProps["workflowSlashCommands"] | undefined >(loadAdditionalSlashCommands ? [] : undefined); @@ -23,21 +29,24 @@ export function useSlashCommands(input: { return; } let cancelled = false; - void loadAdditionalSlashCommands() - .then((commands) => { - if (!cancelled) { - setAdditionalSlashCommands(commands); - } - }) - .catch(() => { - if (!cancelled) { - setAdditionalSlashCommands([]); - } - }); + let requestId = 0; + const refresh = () => { + const id = ++requestId; + void loadAdditionalSlashCommands() + .then((commands) => { + if (!cancelled && id === requestId) + setAdditionalSlashCommands(commands); + }) + // Keep the last catalog; the next refresh tries again. + .catch(() => {}); + }; + refresh(); + const unsubscribe = subscribeAdditionalSlashCommands?.(refresh); return () => { cancelled = true; + unsubscribe?.(); }; - }, [loadAdditionalSlashCommands]); + }, [loadAdditionalSlashCommands, subscribeAdditionalSlashCommands]); const registry = useMemo(() => { return buildSlashCommandRegistry({ diff --git a/apps/cli/src/tui/root.tsx b/apps/cli/src/tui/root.tsx index efa191e3c0..110fd092d0 100644 --- a/apps/cli/src/tui/root.tsx +++ b/apps/cli/src/tui/root.tsx @@ -146,6 +146,7 @@ function App(props: TuiProps) { } = useSlashCommands({ workflowSlashCommands, loadAdditionalSlashCommands: props.loadAdditionalSlashCommands, + subscribeAdditionalSlashCommands: props.subscribeAdditionalSlashCommands, canFork: canForkSession, }); diff --git a/apps/cli/src/tui/types.ts b/apps/cli/src/tui/types.ts index 9db60f31c3..16a70d3186 100644 --- a/apps/cli/src/tui/types.ts +++ b/apps/cli/src/tui/types.ts @@ -157,6 +157,8 @@ export interface TuiProps { initialRepoStatus?: RepoStatus; workflowSlashCommands?: InteractiveSlashCommand[]; loadAdditionalSlashCommands?: () => Promise; + /** Notifies when additional commands may have changed (the active session changed). */ + subscribeAdditionalSlashCommands?: (listener: () => void) => () => void; loadWelcomeLine?: () => Promise; loadClineAccount: () => Promise; loadIndividualSubscriptionPlans?: () => Promise; diff --git a/apps/cli/src/utils/chat-commands.ts b/apps/cli/src/utils/chat-commands.ts index 7aa8d50cf7..8ab4ec7e73 100644 --- a/apps/cli/src/utils/chat-commands.ts +++ b/apps/cli/src/utils/chat-commands.ts @@ -3,6 +3,7 @@ import { resolve } from "node:path"; import { resolveWorkspaceRoot } from "./helpers"; export type ChatCommandState = { + sessionId?: string; enableTools: boolean; autoApproveTools: boolean; cwd: string; @@ -69,6 +70,7 @@ type ParsedChatCommand = { trimmed: string; command: string; args: string[]; + argumentsText: string; state: ChatCommandState; }; @@ -83,6 +85,19 @@ export type ChatCommandDefinition = { export class ChatCommandHost { private readonly definitions: ChatCommandDefinition[]; + private fallback?: ( + parsed: ParsedChatCommand, + context: ChatCommandContext, + ) => Promise; + setFallback( + handler: ( + parsed: ParsedChatCommand, + context: ChatCommandContext, + ) => Promise, + ): this { + this.fallback = handler; + return this; + } constructor(definitions: ChatCommandDefinition[] = []) { this.definitions = [...definitions]; @@ -101,7 +116,9 @@ export class ChatCommandHost { } clone(): ChatCommandHost { - return new ChatCommandHost(this.definitions); + const host = new ChatCommandHost(this.definitions); + host.fallback = this.fallback; + return host; } async handle(input: string, context: ChatCommandContext): Promise { @@ -130,13 +147,14 @@ export class ChatCommandHost { trimmed, command, args, + argumentsText: trimmed.slice(commandRaw.length), state: await context.getState(), }; const matched = this.definitions.find((definition) => definition.names.includes(parsed.command), ); if (!matched) { - return false; + return (await this.fallback?.(parsed, context)) ?? false; } if (matched.isAvailable && !matched.isAvailable(context)) { return false; diff --git a/apps/cli/src/utils/embedded-dashboard.ts b/apps/cli/src/utils/embedded-dashboard.ts new file mode 100644 index 0000000000..9adb8ca097 --- /dev/null +++ b/apps/cli/src/utils/embedded-dashboard.ts @@ -0,0 +1,7 @@ +declare const __CLINE_DASHBOARD_RUNTIME_DIR__: string | undefined; + +export function resolveEmbeddedDashboard(): string | undefined { + return typeof __CLINE_DASHBOARD_RUNTIME_DIR__ === "undefined" + ? undefined + : __CLINE_DASHBOARD_RUNTIME_DIR__; +} diff --git a/apps/cli/src/utils/plugin-chat-commands.test.ts b/apps/cli/src/utils/plugin-chat-commands.test.ts index 48ba1ea3c2..35ea3851f6 100644 --- a/apps/cli/src/utils/plugin-chat-commands.test.ts +++ b/apps/cli/src/utils/plugin-chat-commands.test.ts @@ -1,13 +1,16 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ClineCore, type PluginCommandsApi } from "@cline/core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createWorkspaceChatCommandHost } from "./plugin-chat-commands"; describe("plugin chat commands", () => { const tempRoots: string[] = []; + const cores: ClineCore[] = []; afterEach(async () => { + await Promise.all(cores.splice(0).map((core) => core.dispose())); await Promise.all( tempRoots.map((dir) => rm(dir, { recursive: true, force: true })), ); @@ -36,20 +39,22 @@ describe("plugin chat commands", () => { ].join("\n"), ); - const { host, pluginSlashCommands, shutdown } = - await createWorkspaceChatCommandHost({ - cwd: tempRoot, - workspaceRoot: tempRoot, - }); + const core = await ClineCore.create({ backendMode: "local" }); + cores.push(core); + const { host, listCommands } = await createWorkspaceChatCommandHost({ + commands: core.pluginCommands, + cwd: tempRoot, + workspaceRoot: tempRoot, + }); const reply = vi.fn(async () => undefined); // Filter to only our test plugin to ignore any discovered system plugins - const testCommands = pluginSlashCommands.filter( + const testCommands = (await listCommands()).filter( (cmd) => cmd.name === "echo", ); expect(testCommands).toEqual([{ name: "echo", description: "Echo input" }]); - const handled = await host.handle("/echo hello plugin", { + const handled = await host.handle("/echo hello plugin\nnext line", { enabled: true, getState: async () => ({ enableTools: false, @@ -62,8 +67,7 @@ describe("plugin chat commands", () => { }); expect(handled).toBe(true); - expect(reply).toHaveBeenCalledWith("echo:hello plugin"); - await shutdown?.(); + expect(reply).toHaveBeenCalledWith("echo:hello plugin\nnext line"); }); it("bridges plugin command submit prompts onto the chat command context", async () => { @@ -91,7 +95,10 @@ describe("plugin chat commands", () => { ].join("\n"), ); - const { host, shutdown } = await createWorkspaceChatCommandHost({ + const core = await ClineCore.create({ backendMode: "local" }); + cores.push(core); + const { host } = await createWorkspaceChatCommandHost({ + commands: core.pluginCommands, cwd: tempRoot, workspaceRoot: tempRoot, }); @@ -114,6 +121,93 @@ describe("plugin chat commands", () => { expect(handled).toBe(true); expect(reply).toHaveBeenCalledWith("goal:fix tests"); expect(submitPrompt).toHaveBeenCalledWith("fix tests"); - await shutdown?.(); + }); + + it.each([ + "/skill explain", + "/workflow run", + ])("lets %s reach instruction expansion when plugin discovery fails", async (prompt) => { + const commands: PluginCommandsApi = { + list: vi.fn().mockRejectedValue(new Error("hub unavailable")), + run: vi.fn().mockRejectedValue(new Error("must not execute")), + }; + const logger = { log: vi.fn(), debug: vi.fn() }; + const { host } = await createWorkspaceChatCommandHost({ + commands, + cwd: "/tmp/project", + logger, + }); + const handled = await host.handle(prompt, { + enabled: true, + getState: () => ({ + cwd: "/tmp/project", + workspaceRoot: "/tmp/project", + enableTools: false, + autoApproveTools: false, + }), + setState: vi.fn(), + reply: vi.fn(), + }); + expect(handled).toBe(false); + expect(commands.run).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalled(); + }); + + it("does not execute commands absent from a partial catalog", async () => { + const commands: PluginCommandsApi = { + list: vi.fn().mockResolvedValue({ + status: "error", + commands: [{ name: "echo" }], + error: "sibling failed", + }), + run: vi.fn(), + }; + const { host } = await createWorkspaceChatCommandHost({ + commands, + cwd: "/tmp/project", + }); + expect( + await host.handle("/workflow run", { + enabled: true, + getState: () => ({ + cwd: "/tmp/project", + workspaceRoot: "/tmp/project", + enableTools: false, + autoApproveTools: false, + }), + setState: vi.fn(), + reply: vi.fn(), + }), + ).toBe(false); + expect(commands.run).not.toHaveBeenCalled(); + }); + + it("preserves execution errors for a command owned by a healthy plugin", async () => { + const commands: PluginCommandsApi = { + list: vi.fn().mockResolvedValue({ + status: "error", + commands: [{ name: "echo" }], + error: "sibling failed", + }), + run: vi.fn().mockRejectedValue(new Error("handler failed")), + }; + const { host } = await createWorkspaceChatCommandHost({ + commands, + cwd: "/tmp/project", + }); + await expect( + host.handle("/echo input", { + enabled: true, + getState: () => ({ + cwd: "/tmp/project", + workspaceRoot: "/tmp/project", + enableTools: false, + autoApproveTools: false, + }), + setState: vi.fn(), + reply: vi.fn(), + }), + ).rejects.toThrow("handler failed"); + expect(commands.run).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/cli/src/utils/plugin-chat-commands.ts b/apps/cli/src/utils/plugin-chat-commands.ts index 9e3b29f7da..ae5633ab77 100644 --- a/apps/cli/src/utils/plugin-chat-commands.ts +++ b/apps/cli/src/utils/plugin-chat-commands.ts @@ -1,50 +1,59 @@ -import { - type BasicLogger, - createPluginCommandService, - type PluginSlashCommand, +import type { + BasicLogger, + PluginCommandCatalog, + PluginCommandsApi, } from "@cline/core"; -import { type ChatCommandHost, chatCommandHost } from "./chat-commands"; - -export type { PluginSlashCommand }; - -export interface WorkspaceChatCommandHostResult { - host: ChatCommandHost; - // Plugin-registered commands surfaced as slash commands for TUI autocomplete. - pluginSlashCommands: PluginSlashCommand[]; - shutdown?: () => Promise; -} +import { chatCommandHost } from "./chat-commands"; export async function createWorkspaceChatCommandHost(input: { cwd: string; workspaceRoot?: string; logger?: BasicLogger; -}): Promise { - const service = createPluginCommandService({ - cwd: input.cwd, + commands: PluginCommandsApi; + getSessionId?: () => string | undefined; +}) { + const commands = input.commands; + const target = () => ({ workspacePath: input.workspaceRoot?.trim() || input.cwd, - logger: input.logger, + sessionId: input.getSessionId?.() || undefined, }); - // Load failures are logged by the service and yield no commands. - const pluginSlashCommands = await service.listCommands(); - if (pluginSlashCommands.length === 0) { - await service.shutdown(); - return { host: chatCommandHost, pluginSlashCommands: [] }; - } - - const host = chatCommandHost.clone(); - for (const command of pluginSlashCommands) { - host.register("command", { - names: [`/${command.name}`], - run: async ({ args }, context) => { - const result = await service.run(command.name, args.join(" ")); - if (result?.reply) { - await context.reply(result.reply); - } - if (result?.submitPrompt) { - await context.submitPrompt?.(result.submitPrompt); - } - }, + const host = chatCommandHost.clone().setFallback(async (parsed, context) => { + const commandTarget = { + workspacePath: parsed.state.workspaceRoot || parsed.state.cwd, + sessionId: parsed.state.sessionId ?? input.getSessionId?.(), + }; + let catalog: PluginCommandCatalog; + try { + catalog = await commands.list(commandTarget); + } catch (error) { + input.logger?.log("Plugin command discovery failed", { + error, + severity: "warn", + }); + return false; + } + if ( + !catalog.commands.some( + (command) => command.name === parsed.command.slice(1), + ) + ) + return false; + const result = await commands.run({ + ...commandTarget, + prompt: `${parsed.command}${parsed.argumentsText}`, }); - } - return { host, pluginSlashCommands, shutdown: service.shutdown }; + if (!result) return false; + if (result.reply) await context.reply(result.reply); + if (result.submitPrompt) await context.submitPrompt?.(result.submitPrompt); + return true; + }); + return { + host, + async listCommands() { + const catalog = await commands.list(target()); + if (catalog.status === "error") + input.logger?.log(`Plugin commands unavailable: ${catalog.error}`); + return catalog.commands; + }, + }; } diff --git a/apps/cline-hub/src/server/marketplace.test.ts b/apps/cline-hub/src/server/marketplace.test.ts index 66d1929c1a..7835d71981 100644 --- a/apps/cline-hub/src/server/marketplace.test.ts +++ b/apps/cline-hub/src/server/marketplace.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { SKILLS_CLI_PACKAGE } from "@cline/core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildMarketplaceMcpInput, @@ -171,7 +172,7 @@ describe("marketplace installer", () => { expect(spawnCommand).toHaveBeenCalledWith("npx", [ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "add", "vercel-labs/agent-skills", "--skill", @@ -314,7 +315,7 @@ describe("marketplace installer", () => { }); expect(spawnCommand).toHaveBeenCalledWith("npx", [ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "remove", "cline-sdk", "-g", diff --git a/apps/cline-hub/src/server/marketplace.ts b/apps/cline-hub/src/server/marketplace.ts index 1f740745a2..040f29e517 100644 --- a/apps/cline-hub/src/server/marketplace.ts +++ b/apps/cline-hub/src/server/marketplace.ts @@ -22,6 +22,7 @@ import { type MarketplaceEntryInput, resolveSkillsConfigSearchPaths, resolveWorkflowsConfigSearchPaths, + SKILLS_CLI_PACKAGE, uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry, uninstallPlugin as uninstallLocalPlugin, } from "@cline/core"; @@ -763,7 +764,7 @@ async function installSkill( ensureGlobalSkillsDirWritable(); const result = await spawnCommand("npx", [ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "add", ...(entry.install.args ?? []), "-g", diff --git a/apps/examples/desktop-app/CHANGELOG.md b/apps/examples/desktop-app/CHANGELOG.md index 2cf8b83051..11e8a49536 100644 --- a/apps/examples/desktop-app/CHANGELOG.md +++ b/apps/examples/desktop-app/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index 5de808aeb3..1d57acfe3e 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -1,6 +1,6 @@ { "name": "@cline/code", - "version": "0.0.47", + "version": "0.0.48", "private": true, "scripts": { "build:ui": "bun -F @cline/ui build", @@ -108,6 +108,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@cline/sdk": "workspace:*", "@tailwindcss/postcss": "^4.2.0", "@tauri-apps/cli": "^2.0.0", "@types/bun": "^1.4.2", diff --git a/apps/examples/desktop-app/scripts/build-desktop-runtime.ts b/apps/examples/desktop-app/scripts/build-desktop-runtime.ts index 9eab55fe78..f9ff299f57 100644 --- a/apps/examples/desktop-app/scripts/build-desktop-runtime.ts +++ b/apps/examples/desktop-app/scripts/build-desktop-runtime.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { cpSync, existsSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { $ } from "bun"; import { @@ -95,6 +95,13 @@ const bundleDesktopBackend = async (): Promise => { // from Finder/the Dock has no OTEL_* env at runtime, so without this the // backend silently ships with telemetry disabled. Verify with // `BUN_BE_BUN=1 cline-cli run --telemetry-selfcheck`. + // The script runner does not mount the CLI's bunfs. Ship the same runtime + // artifact beside the backend, just as npm and VS Code hosts do. + const runtime = `${APP_ROOT}/${BIN_DIR}/desktop-backend/plugin-runtime`; + rmSync(runtime, { recursive: true, force: true }); + cpSync(`${REPO_ROOT}/sdk/packages/core/dist/plugin-runtime`, runtime, { + recursive: true, + }); const defines = telemetryDefineArgs(); await $`bun build ./sidecar/index.ts --target bun --minify ${defines} --outfile ${DESKTOP_BACKEND_BUNDLE}`; }; @@ -141,53 +148,6 @@ const buildUniversalMacCli = async (): Promise => { await $`lipo -info ${outfile}`; }; -// Plugin installs drop their @cline/* dependencies because the host provides -// them, and the plugin worker resolves those from node_modules beside its -// bootstrap. Nothing like that exists next to the compiled CLI, so ship -// self-contained builds there. Splitting keeps one shared copy of the SDK, -// placed in core so every chunk sits inside a `"type": "module"` package. -const bundlePluginSdkPackages = async (): Promise => { - const outdir = `${BIN_DIR}/extensions/node_modules/@cline`; - const packagesRoot = "../../../sdk/packages"; - const packages = await Promise.all( - ["sdk", "core", "agents", "llms", "shared"].map(async (name) => { - const root = `${packagesRoot}/${name}`; - const manifest = (await Bun.file(`${root}/package.json`).json()) as { - exports: Record; - }; - // Type-only exports (e.g. @cline/shared/types) have no JS to ship. - const exports = Object.fromEntries( - Object.entries(manifest.exports) - .map(([path, entry]) => [path, entry.import] as const) - .filter(([, file]) => existsSync(`${root}/${file}`)), - ); - return { root, name, exports }; - }), - ); - await $`rm -rf ${outdir}`; - const result = await Bun.build({ - entrypoints: packages.flatMap(({ root, exports }) => - Object.values(exports).map((file) => `${root}/${file}`), - ), - root: packagesRoot, - outdir, - naming: { chunk: "core/dist/[name]-[hash].[ext]" }, - target: "node", - format: "esm", - splitting: true, - minify: true, - }); - if (!result.success) { - throw new AggregateError(result.logs, "plugin SDK bundle failed"); - } - for (const { name, exports } of packages) { - await Bun.write( - `${outdir}/${name}/package.json`, - JSON.stringify({ name: `@cline/${name}`, type: "module", exports }), - ); - } -}; - const hasOpenTuiNativeVariant = (os: string, arch: string): boolean => existsSync(`${REPO_ROOT}/node_modules/.bun`) && [ @@ -203,10 +163,7 @@ const main = async () => { await $`bun run build:sdk`.cwd(REPO_ROOT); process.chdir(APP_ROOT); const targetTriple = await resolveTargetTriple(); - await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR} ${BIN_DIR}/extensions`; - // Plugin workers load this bootstrap from disk in the backend and Hub. - await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js ${BIN_DIR}/extensions/plugin-sandbox-bootstrap.js`; - await bundlePluginSdkPackages(); + await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR}`; // Every build cross-compiles the Linux SSH copies (and macOS builds both // architectures), which needs the target platforms' OpenTUI native // packages in addition to the host's. diff --git a/apps/examples/desktop-app/scripts/desktop-startup.test.ts b/apps/examples/desktop-app/scripts/desktop-startup.test.ts index f80c5980a1..b7d5567260 100644 --- a/apps/examples/desktop-app/scripts/desktop-startup.test.ts +++ b/apps/examples/desktop-app/scripts/desktop-startup.test.ts @@ -2,9 +2,13 @@ import { afterAll, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { closeSync, + copyFileSync, + cpSync, + mkdirSync, mkdtempSync, openSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -68,6 +72,16 @@ function resolveBackendBundle(): string { { encoding: "utf8", timeout: 120_000 }, ); expect(build.status, build.stderr || String(build.error)).toBe(0); + cpSync( + fileURLToPath( + new URL( + "../../../../sdk/packages/core/dist/plugin-runtime", + import.meta.url, + ), + ), + join(outdir, "plugin-runtime"), + { recursive: true }, + ); bundledBackend = join(outdir, "index.js"); } return bundledBackend; @@ -107,6 +121,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); @@ -331,3 +346,233 @@ for (const discoveryContents of ["{ invalid json", "{}"]) { ); }, 120_000); } + +test.each([ + "worker", + "sandbox", +])("standalone CLI loads an SDK plugin without Node (%s)", async (mode) => { + const cli = await resolveCliBinary(); + const root = mkdtempSync(join(tmpdir(), "cline-plugin-installed-")); + try { + const directory = join(root, ".cline", "plugins"); + mkdirSync(directory, { recursive: true }); + // Occupy the old cache path: startup must succeed without extraction. + writeFileSync(join(root, ".cline", "runtime"), "no extraction"); + const marker = join(root, "plugin-result.json"); + // Copy the raw TypeScript fixture so SDK imports must resolve from the + // packaged runtime, outside the monorepo and without prebundling. + copyFileSync( + fileURLToPath( + new URL("./fixtures/embedded-plugin/smoke.ts", import.meta.url), + ), + join(directory, "smoke.ts"), + ); + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test( + key, + ), + ), + ); + const result = spawnSync(cli, ["config", "tools", "--json"], { + cwd: root, + encoding: "utf8", + timeout: 30000, + env: { + ...env, + HOME: root, + USERPROFILE: root, + CLINE_DIR: join(root, ".cline"), + PATH: root, + JITI_FS_CACHE: "false", + CLINE_PLUGIN_MODE: mode, + CLINE_TEST_PLUGIN_RESULT_PATH: marker, + TMPDIR: root, + CLINE_NO_AUTO_UPDATE: "1", + }, + }); + expect(result.status, result.stderr || String(result.error)).toBe(0); + expect( + JSON.parse(result.stdout).some( + (tool: { name: string }) => tool.name === "embedded_ping", + ), + ).toBe(true); + expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe( + "no extraction", + ); + expect(JSON.parse(readFileSync(marker, "utf8"))).toEqual({ + pong: true, + // The native resolver expands Windows 8.3 short names (RUNNER~1), as + // the child's process.execPath does. + execPath: realpathSync.native(cli), + isMainThread: mode === "sandbox", + bunRuntimeFlag: null, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}, 120000); + +test.each([ + "worker", + "sandbox", +])("packaged desktop backend loads its on-disk SDK runtime (%s)", async (mode) => { + const cli = await resolveCliBinary(); + const outdir = dirname(resolveBackendBundle()); + const probe = join(outdir, "plugin-runtime-probe.js"); + const built = await Bun.build({ + entrypoints: [ + fileURLToPath( + new URL("./fixtures/desktop-plugin-runtime.ts", import.meta.url), + ), + ], + target: "bun", + outdir, + naming: "plugin-runtime-probe.js", + }); + expect(built.success, built.logs.map(String).join("\n")).toBe(true); + const root = mkdtempSync(join(tmpdir(), "cline-desktop-plugin-")); + try { + const plugins = join(root, ".cline", "plugins"); + mkdirSync(plugins, { recursive: true }); + copyFileSync( + fileURLToPath( + new URL("./fixtures/embedded-plugin/desktop.ts", import.meta.url), + ), + join(plugins, "desktop.ts"), + ); + writeFileSync(join(root, ".cline", "runtime"), "no extraction"); + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test( + key, + ), + ), + ); + const result = spawnSync( + cli, + ["run", "--use-system-ca", "--no-env-file", probe], + { + cwd: root, + encoding: "utf8", + timeout: 30000, + env: { + ...env, + PATH: root, + HOME: root, + USERPROFILE: root, + CLINE_DIR: join(root, ".cline"), + BUN_BE_BUN: "1", + CLINE_DESKTOP_CLI_PATH: cli, + CLINE_PLUGIN_MODE: mode, + JITI_FS_CACHE: "false", + }, + }, + ); + expect(result.status, result.stderr || String(result.error)).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + commands: [{ name: "packaged-ping" }], + result: { reply: "pong" }, + }); + expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe( + "no extraction", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}, 120000); + +test("standalone CLI serves the dashboard and its assets without external files or Node", async () => { + const cli = await resolveCliBinary(); + const root = mkdtempSync(join(tmpdir(), "cline-standalone-dashboard-")); + mkdirSync(join(root, ".cline")); + writeFileSync(join(root, ".cline", "runtime"), "no extraction"); + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + !/^(CLINE_|BUN_BE_BUN|NODE_PATH|HOST$|PORT$|ROOM_SECRET$|PUBLIC_URL$)/.test( + key, + ), + ), + ); + const port = await reserveLoopbackPort(); + const isolatedEnv = { + ...env, + HOME: root, + USERPROFILE: root, + CLINE_DIR: join(root, ".cline"), + CLINE_HUB_DISCOVERY_PATH: join(root, "hub.json"), + CLINE_HUB_PORT: String(await reserveLoopbackPort()), + CLINE_NO_AUTO_UPDATE: "1", + PATH: root, + }; + const child = Bun.spawn( + [ + cli, + "dashboard", + "--no-open", + "--host", + "127.0.0.1", + "--port", + String(port), + "--cwd", + root, + ], + { cwd: root, env: isolatedEnv, stdout: "ignore", stderr: "pipe" }, + ); + try { + const url = `http://127.0.0.1:${port}`; + let response: Response | undefined; + const deadline = Date.now() + 20000; + while (Date.now() < deadline) { + try { + response = await fetch(url); + if (response.status === 200) break; + } catch {} + if (child.exitCode !== null) + throw new Error(await new Response(child.stderr).text()); + await Bun.sleep(100); + } + expect(response?.status).toBe(200); + if (!response) throw new Error("Dashboard did not start"); + expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe( + "no extraction", + ); + const html = await response.text(); + expect(html).toContain(" match[1]); + expect(assets.length).toBeGreaterThan(0); + for (const asset of assets) { + if (!asset) throw new Error("Missing dashboard asset URL"); + const resource = await fetch(new URL(asset, url)); + expect(resource.status).toBe(200); + expect(resource.headers.get("content-type")).toMatch( + asset.endsWith(".css") ? /text\/css/ : /(?:javascript|ecmascript)/, + ); + expect((await resource.arrayBuffer()).byteLength).toBeGreaterThan(0); + } + } finally { + child.kill("SIGTERM"); + await child.exited; + spawnSync(cli, ["hub", "stop"], { + cwd: root, + env: isolatedEnv, + timeout: 10000, + }); + // The stopped Hub can hold its cwd briefly on Windows (EBUSY). + try { + rmSync(root, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); + } catch { + /* The runner discards its temp directory anyway. */ + } + } +}, 120000); diff --git a/apps/examples/desktop-app/scripts/fixtures/desktop-plugin-runtime.ts b/apps/examples/desktop-app/scripts/fixtures/desktop-plugin-runtime.ts new file mode 100644 index 0000000000..fd06b19e42 --- /dev/null +++ b/apps/examples/desktop-app/scripts/fixtures/desktop-plugin-runtime.ts @@ -0,0 +1,26 @@ +import { ClineCore, resolvePluginRuntimeDirectory } from "@cline/core"; +import { adoptDesktopCliRuntime } from "../../sidecar/cli-runtime"; + +async function main() { + adoptDesktopCliRuntime(); + const runtime = resolvePluginRuntimeDirectory(); + if (!runtime?.includes("desktop-backend")) + throw new Error(`Expected the packaged backend runtime, got ${runtime}`); + const core = await ClineCore.create({ backendMode: "local" }); + try { + const workspacePath = process.cwd(); + const { commands } = await core.pluginCommands.list({ workspacePath }); + const result = await core.pluginCommands.run({ + workspacePath, + prompt: "/packaged-ping", + }); + process.stdout.write(JSON.stringify({ commands, result })); + } finally { + await core.dispose(); + } +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/examples/desktop-app/scripts/fixtures/embedded-plugin/desktop.ts b/apps/examples/desktop-app/scripts/fixtures/embedded-plugin/desktop.ts new file mode 100644 index 0000000000..3bca448694 --- /dev/null +++ b/apps/examples/desktop-app/scripts/fixtures/embedded-plugin/desktop.ts @@ -0,0 +1,12 @@ +import type { AgentPlugin } from "@cline/sdk"; +import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser"; + +export default { + name: "packaged-backend-smoke", + manifest: { capabilities: ["commands"] }, + setup(api) { + if (typeof CLINE_DEFAULT_MODEL_ID !== "string") + throw new Error("Missing SDK browser exports"); + api.registerCommand({ name: "packaged-ping", handler: () => "pong" }); + }, +} satisfies AgentPlugin; diff --git a/apps/examples/desktop-app/scripts/fixtures/embedded-plugin/smoke.ts b/apps/examples/desktop-app/scripts/fixtures/embedded-plugin/smoke.ts new file mode 100644 index 0000000000..4802ba5fa1 --- /dev/null +++ b/apps/examples/desktop-app/scripts/fixtures/embedded-plugin/smoke.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { isMainThread } from "node:worker_threads"; +import { getAllProviders } from "@cline/llms/browser"; +import { + type AgentPlugin, + createTool, + resolvePluginRuntimeDirectory, +} from "@cline/sdk"; +import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser"; +import { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "@cline/shared/types"; + +export default { + name: "embedded-smoke", + manifest: { capabilities: ["tools"] }, + async setup(api) { + if ( + typeof CLINE_DEFAULT_MODEL_ID !== "string" || + typeof getAllProviders !== "function" + ) { + throw new Error("Missing browser SDK exports"); + } + if ( + !AUTH_ERROR_PATTERNS.includes("unauthorized") || + !isLikelyAuthError(new Error("401 Unauthorized")) + ) { + throw new Error("Missing shared/types runtime exports"); + } + if (!process.execArgv.includes("--use-system-ca")) { + throw new Error("Plugin runtime lost compiled system CA trust"); + } + const runtime = resolvePluginRuntimeDirectory(); + const bunfsRoot = + process.platform === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"; + if (!runtime?.replaceAll("\\", "/").startsWith(bunfsRoot)) { + throw new Error(`Expected the embedded runtime, got ${runtime}`); + } + const marker = process.env.CLINE_TEST_PLUGIN_RESULT_PATH; + if (!marker) { + throw new Error("Missing CLINE_TEST_PLUGIN_RESULT_PATH"); + } + const nested = spawnSync(process.execPath, ["--telemetry-selfcheck"], { + env: process.env, + encoding: "utf8", + timeout: 10000, + }); + if (nested.status !== 0) throw new Error(nested.stderr); + JSON.parse(nested.stdout); + const tool = createTool({ + name: "embedded_ping", + description: "Packaged SDK tool", + inputSchema: { type: "object", properties: {} }, + execute: async () => ({ + pong: true, + execPath: process.execPath, + isMainThread, + bunRuntimeFlag: process.env.BUN_BE_BUN ?? null, + }), + }); + writeFileSync( + marker, + JSON.stringify( + await tool.execute({}, { agentId: "embedded-smoke", iteration: 0 }), + ), + ); + api.registerTool(tool); + }, +} satisfies AgentPlugin; diff --git a/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts b/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts index 92701cfa05..c6b1bce71e 100644 --- a/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts +++ b/apps/examples/desktop-app/scripts/telemetry-define-args.test.ts @@ -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", () => { diff --git a/apps/examples/desktop-app/scripts/telemetry-define-args.ts b/apps/examples/desktop-app/scripts/telemetry-define-args.ts index 6bc705f652..cb8816349f 100644 --- a/apps/examples/desktop-app/scripts/telemetry-define-args.ts +++ b/apps/examples/desktop-app/scripts/telemetry-define-args.ts @@ -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", diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index cc58a1c476..757c61a550 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -87,6 +87,11 @@ Hub daemon is that same CLI binary. Any Hub the backend itself has to start later goes through the same binary (`setHubDaemonLauncher`). Startup discovery and locking ensure concurrent clients converge on one Hub. +The backend ships Core’s complete `plugin-runtime` artifact beside its script. +The standalone CLI embeds the same artifact in its read-only Bun filesystem. +The script runner does not mount that filesystem, so Desktop uses its packaged +copy without extracting resources at startup. + Every create, restart, fork, and restore also attaches the serializable Desktop `ExtensionContext.client` and current `ExtensionContext.user`. Core forwards that context across the Hub transport and scopes the daemon-owned telemetry diff --git a/apps/examples/desktop-app/sidecar/chat-session.test.ts b/apps/examples/desktop-app/sidecar/chat-session.test.ts index c434944bc9..15d73a5728 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.test.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.test.ts @@ -9,7 +9,8 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { SessionNotFoundError } from "@cline/core"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveClineDir, setClineDir } from "@cline/shared/storage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { materializeUserFiles } from "./attachments"; import { buildSessionConnectionUpdate, @@ -116,7 +117,7 @@ function localRuntimeContext( workspaceRoot, sessionManager, hubClient: { - command: vi.fn(async () => undefined), + command: vi.fn(async () => ({ ok: true, payload: {} })), }, unsubscribeSessionEvents: () => {}, }, @@ -2233,14 +2234,30 @@ describe("workspace metadata prewarming", () => { describe("runtime slash command expansion on send", () => { const tempRoots: string[] = []; + let originalClineDir: string; + beforeEach(() => { + originalClineDir = resolveClineDir(); + const settingsRoot = mkdtempSync( + join(tmpdir(), "desktop-command-settings-"), + ); + tempRoots.push(settingsRoot); + setClineDir(settingsRoot); + }); afterEach(() => { + setClineDir(originalClineDir); for (const dir of tempRoots) { rmSync(dir, { recursive: true, force: true }); } tempRoots.length = 0; }); + function goalCatalog(workspacePath: string) { + return { + catalog: { workspacePath, status: "ready", commands: [{ name: "goal" }] }, + }; + } + function createWorkspaceWithSkill(): string { const workspace = mkdtempSync(join(tmpdir(), "desktop-slash-send-")); tempRoots.push(workspace); @@ -2328,15 +2345,95 @@ Follow the desktop send workflow instructions.`, expect(session.prompt).toBe("/desktop-send-skill write the docs"); }); - it("still sends a skill command when a workspace plugin fails to load", async () => { + it.each([ + "local", + "ssh", + ] as const)("dispatches plugin commands through the owning %s hub", async (kind) => { const workspace = createWorkspaceWithSkill(); - const pluginsDir = join(workspace, ".cline", "plugins"); - mkdirSync(pluginsDir, { recursive: true }); - writeFileSync( - join(pluginsDir, "broken.js"), - "export default { name: 'broken', manifest: { capabilities: ['bogus'] }, setup() {} };", - ); const { ctx, send, sessionId } = createContext(workspace); + const binding = ctx.runtimeBindings.get("local"); + if (!binding) throw new Error("Missing test binding"); + binding.kind = kind; + const liveSession = ctx.liveSessions.get(sessionId); + if (liveSession) liveSession.config.apiKey = "test-key"; + binding.sessionManager.updateSessionConnection = vi.fn(async () => {}); + const command = vi.fn(async (name: string) => ({ + version: "v1" as const, + ok: true, + payload: + name === "plugins.commands.list" + ? goalCatalog(workspace) + : { + result: { + reply: "Goal set", + submitPrompt: "expanded goal\nnext line", + }, + }, + })); + binding.hubClient.command = command; + const result = await handleChatSessionCommand(ctx, { + action: "send", + sessionId, + prompt: "/goal original", + }); + expect(result).toMatchObject({ + result: { finishReason: "completed", text: "done" }, + }); + expect(command).toHaveBeenCalledWith( + "plugins.commands.run", + expect.objectContaining({ + workspacePath: workspace, + sessionId, + prompt: "/goal original", + }), + sessionId, + { timeoutMs: null, retryAfterDispatch: false }, + ); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ prompt: "expanded goal\nnext line" }), + ); + }); + + it("targets the session's workspace root when its cwd is a subdirectory", async () => { + const workspace = createWorkspaceWithSkill(); + const { ctx, session, sessionId } = createContext(workspace); + Object.assign(session.config, { + cwd: join(workspace, "packages", "app"), + workspaceRoot: workspace, + }); + const binding = ctx.runtimeBindings.get("local"); + if (!binding) throw new Error("Missing test binding"); + const command = vi.fn(async (name: string) => ({ + version: "v1" as const, + ok: true, + payload: + name === "plugins.commands.list" + ? goalCatalog(workspace) + : { result: { reply: "Goal set" } }, + })); + binding.hubClient.command = command; + await handleChatSessionCommand(ctx, { + action: "send", + sessionId, + prompt: "/goal original", + }); + expect(command).toHaveBeenCalledWith( + "plugins.commands.run", + expect.objectContaining({ workspacePath: workspace, sessionId }), + sessionId, + { timeoutMs: null, retryAfterDispatch: false }, + ); + }); + + it("still sends a skill command when the plugin catalog cannot be read", async () => { + const workspace = createWorkspaceWithSkill(); + const { ctx, send, sessionId } = createContext(workspace); + const binding = ctx.runtimeBindings.get("local"); + if (!binding) throw new Error("Missing test binding"); + const command = vi.fn(async () => { + throw new Error("hub connection closed"); + }); + binding.hubClient.command = command; await handleChatSessionCommand(ctx, { action: "send", @@ -2344,6 +2441,12 @@ Follow the desktop send workflow instructions.`, prompt: "/desktop-send-skill write the docs", }); + expect(command).not.toHaveBeenCalledWith( + "plugins.commands.run", + expect.anything(), + expect.anything(), + expect.anything(), + ); expect(send).toHaveBeenCalledWith( expect.objectContaining({ prompt: "/desktop-send-skill write the docs", diff --git a/apps/examples/desktop-app/sidecar/chat-session.ts b/apps/examples/desktop-app/sidecar/chat-session.ts index 92e5f772af..ddf6ab1168 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.ts @@ -1658,8 +1658,8 @@ async function handleSend( const workspacePath = readWorkspacePath(session?.config ?? request.config) ?? ctx.localWorkspaceRoot; - // Plugin slash commands (`api.registerCommand`) run here in the sidecar, - // as in the CLI: the handler's reply goes to the webview as a toast and + // Plugin slash commands execute in the session runtime. + // The handler's reply goes to the webview as a toast and // only its `submitPrompt` (if any) reaches the model. const commandName = prompt.match(/^\/(\S+)/)?.[1]?.toLowerCase(); if (commandName === "compact") { @@ -1686,10 +1686,13 @@ async function handleSend( return { sessionId, ok: true, commandHandled: true }; } const pluginCommand = - commandName && - !BUILTIN_SLASH_COMMAND_NAMES.has(commandName) && - binding.kind !== "ssh" - ? await runPluginSlashCommand(ctx, { workspacePath, prompt }) + commandName && !BUILTIN_SLASH_COMMAND_NAMES.has(commandName) + ? await runPluginSlashCommand(ctx, { + workspacePath, + prompt, + sessionId, + environmentId: binding.environmentId, + }) : undefined; if (pluginCommand) { if (pluginCommand.reply) { @@ -1706,15 +1709,15 @@ async function handleSend( // Dispatch the expanded or rewritten instructions, but keep the raw // `/command` token as the session's display prompt. const runtimePrompt = - binding.kind === "ssh" + pluginCommand?.submitPrompt ?? + (binding.kind === "ssh" ? prompt - : (pluginCommand?.submitPrompt ?? - (await resolveDesktopRuntimePrompt( + : await resolveDesktopRuntimePrompt( ctx, workspacePath, prompt, request.config?.mode ?? session?.config?.mode, - ))); + )); let delivery = request.delivery; if (!delivery && session?.busy) { delivery = "queue"; diff --git a/apps/examples/desktop-app/sidecar/cli-runtime.test.ts b/apps/examples/desktop-app/sidecar/cli-runtime.test.ts index 175ae78714..b1d26617aa 100644 --- a/apps/examples/desktop-app/sidecar/cli-runtime.test.ts +++ b/apps/examples/desktop-app/sidecar/cli-runtime.test.ts @@ -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, }, ); @@ -45,8 +51,11 @@ describe("adoptDesktopCliRuntime", () => { }; const chdir = vi.fn(); const setLauncher = vi.fn(); + const setPluginLauncher = vi.fn(); - expect(adoptDesktopCliRuntime({ env, chdir, setLauncher })).toEqual({ + expect( + adoptDesktopCliRuntime({ env, chdir, setLauncher, setPluginLauncher }), + ).toEqual({ cliPath: "/Applications/Cline.app/Contents/MacOS/cline-cli", }); @@ -56,6 +65,10 @@ describe("adoptDesktopCliRuntime", () => { "/Applications/Cline.app/Contents/MacOS/cline-cli", ); expect(chdir).toHaveBeenCalledWith("/Users/dev/project"); + expect(setPluginLauncher).toHaveBeenCalledWith({ + command: env.CLINE_DESKTOP_CLI_PATH, + argsPrefix: ["--plugin-sandbox"], + }); expect(setLauncher).toHaveBeenCalledWith({ command: "/Applications/Cline.app/Contents/MacOS/cline-cli", }); @@ -64,11 +77,20 @@ describe("adoptDesktopCliRuntime", () => { it("keeps the default Hub launcher when no bundled CLI is provided", () => { const chdir = vi.fn(); const setLauncher = vi.fn(); + const setPluginLauncher = vi.fn(); - expect(adoptDesktopCliRuntime({ env: {}, chdir, setLauncher })).toEqual({ + expect( + adoptDesktopCliRuntime({ + env: {}, + chdir, + setLauncher, + setPluginLauncher, + }), + ).toEqual({ cliPath: undefined, }); expect(chdir).not.toHaveBeenCalled(); expect(setLauncher).not.toHaveBeenCalled(); + expect(setPluginLauncher).not.toHaveBeenCalled(); }); }); diff --git a/apps/examples/desktop-app/sidecar/cli-runtime.ts b/apps/examples/desktop-app/sidecar/cli-runtime.ts index 3f46da4ef9..98b906d0b1 100644 --- a/apps/examples/desktop-app/sidecar/cli-runtime.ts +++ b/apps/examples/desktop-app/sidecar/cli-runtime.ts @@ -1,5 +1,7 @@ import { execFile } from "node:child_process"; -import { setHubDaemonLauncher } from "@cline/core"; +import { setHubDaemonLauncher, setPluginSandboxLauncher } 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"; @@ -25,6 +27,7 @@ export function adoptDesktopCliRuntime( env?: NodeJS.ProcessEnv; chdir?: (directory: string) => void; setLauncher?: typeof setHubDaemonLauncher; + setPluginLauncher?: typeof setPluginSandboxLauncher; } = {}, ): { cliPath?: string } { const env = options.env ?? process.env; @@ -41,6 +44,10 @@ export function adoptDesktopCliRuntime( const cliPath = resolveDesktopCliPath(env); if (cliPath) { setLauncher({ command: cliPath }); + (options.setPluginLauncher ?? setPluginSandboxLauncher)({ + command: cliPath, + argsPrefix: ["--plugin-sandbox"], + }); } return { cliPath }; } @@ -94,7 +101,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, }, ); diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts index 9e7e355164..9193f0bc8d 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions-state.test.ts @@ -1,4 +1,8 @@ -import { HubCommandError, HubTransportError } from "@cline/core"; +import { + HubCommandError, + HubTransportError, + type NodeHubClient, +} from "@cline/core"; import type { HubEventEnvelope } from "@cline/shared"; import { describe, expect, it, vi } from "vitest"; import { @@ -55,8 +59,11 @@ async function createFixture() { name: string, _payload?: unknown, _sessionId?: string, - _options?: unknown, - ) => reply(name), + options?: Parameters[3], + ) => { + options?.beforeDispatch?.(); + return reply(name); + }, ); const connection: Awaited< ReturnType @@ -106,6 +113,51 @@ const transportError = () => new HubTransportError("hub_connection_closed", "socket closed"); describe("CloudSessionManager state", () => { + it("honors the dispatch hook before sending through the desktop adapter", async () => { + const { manager, command, reply, updateTitle, live } = + await createFixture(); + const dispatch = vi.fn(); + command.mockImplementation(async (name, _payload, _sessionId, options) => { + options?.beforeDispatch?.(); + if (name === "session.send_input") dispatch(); + return reply(name); + }); + const beforeDispatch = vi.fn<() => void>(() => { + throw new Error("Could not save continuation"); + }); + + await expect( + manager.send( + "ses-outer", + "Continue the task", + undefined, + undefined, + undefined, + { + beforeDispatch, + }, + ), + ).rejects.toThrow("Could not save continuation"); + expect(beforeDispatch).toHaveBeenCalledOnce(); + expect(dispatch).not.toHaveBeenCalled(); + expect(updateTitle).not.toHaveBeenCalled(); + expect(live.messages).toEqual([]); + + beforeDispatch.mockImplementation(() => {}); + await manager.send( + "ses-outer", + "Continue the task", + undefined, + undefined, + undefined, + { + beforeDispatch, + }, + ); + expect(beforeDispatch).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledOnce(); + }); + it("keeps a pending send cancelled when deletion clears its abort token", async () => { const { manager, connection, ensureAttached, command } = await createFixture(); diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.ts index 35b01c957a..7da70bc951 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.ts @@ -1,6 +1,7 @@ import { ClineAccountService, ProviderSettingsManager } from "@cline/core"; import { type CloudCreationOptions, + type CloudSendLifecycle, CloudSessionApi, CloudSessionController, type CloudSessionControllerOptions, @@ -178,6 +179,7 @@ export class CloudSessionManager extends CloudSessionController { delivery?: "queue" | "steer", modelId?: string, images?: string[], + lifecycle?: CloudSendLifecycle, ) { // Existing desktop commands update this local preference before lazy inner creation. const config = this.ctx.liveSessions.get(id)?.config; @@ -197,7 +199,7 @@ export class CloudSessionManager extends CloudSessionController { : {}), }); try { - return await super.send(id, prompt, delivery, modelId, images); + return await super.send(id, prompt, delivery, modelId, images, lifecycle); } finally { const snapshot = this.getSnapshot(id); if (snapshot) diff --git a/apps/examples/desktop-app/sidecar/commands-settings.test.ts b/apps/examples/desktop-app/sidecar/commands-settings.test.ts index d03998b50e..51db1d546b 100644 --- a/apps/examples/desktop-app/sidecar/commands-settings.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-settings.test.ts @@ -13,7 +13,12 @@ import { getDesktopFeatureFlagsService, resetDesktopFeatureFlagsForTesting, } from "./feature-flags"; -import type { SidecarContext } from "./types"; +import { recordHubActivity } from "./hub-admin"; +import { + LOCAL_ENVIRONMENT_ID, + type SessionRuntimeBinding, + type SidecarContext, +} from "./types"; function createContext(): { ctx: SidecarContext; @@ -46,6 +51,42 @@ afterEach(() => { }); describe("desktop settings commands", () => { + it("only returns hub notification content to trusted desktop connections", async () => { + const { ctx } = createContext(); + const command = vi + .fn() + .mockResolvedValue({ ok: true, payload: { clients: [] } }); + ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, { + hubClient: { command, getUrl: () => "ws://127.0.0.1:1234/hub" }, + } as unknown as SessionRuntimeBinding); + recordHubActivity(ctx, { + event: "ui.notify", + payload: { title: "Task completed", body: "Private task reply" }, + }); + await expect(handleCommand(ctx, "get_hub_status")).rejects.toThrow( + "hub status requires a trusted desktop connection", + ); + await expect( + handleCommand(ctx, "get_hub_status", undefined, { + connection: { send: vi.fn(), data: { canApproveTools: false } }, + }), + ).rejects.toThrow("hub status requires a trusted desktop connection"); + expect(command).not.toHaveBeenCalled(); + const result = await handleCommand(ctx, "get_hub_status", undefined, { + connection: { send: vi.fn(), data: { canApproveTools: true } }, + }); + expect(command).toHaveBeenCalledWith("client.list"); + expect(result).toMatchObject({ + events: [{ title: "Task completed", detail: "Private task reply" }], + }); + }); + + it("rejects hub restarts from untrusted connections", async () => { + const { ctx } = createContext(); + await expect(handleCommand(ctx, "restart_hub")).rejects.toThrow( + "trusted desktop connection", + ); + }); it("lists only current gateway transcription models for voice input", async () => { const { ctx } = createContext(); vi.stubGlobal( diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 45662858b4..448c9f526a 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -132,6 +132,7 @@ import { isCloudAgentsEnabled, refreshDesktopFeatureFlags, } from "./feature-flags"; +import { getHubStatus, restartHub } from "./hub-admin"; import { clearLegacyProviderCredentials } from "./legacy-provider-credentials"; import { installMarketplaceEntryForDesktopCommand, @@ -161,10 +162,7 @@ import { sessionLogPath, sharedSessionDataDir, } from "./paths"; -import { - getPluginCommandService, - warmPluginCommandService, -} from "./plugin-commands"; +import { listPluginCommands } from "./plugin-commands"; import { getPullRequestStatus } from "./pull-request"; import { capturePullRequestEvent } from "./pull-request-telemetry"; import { resolveDesktopRemoteHelper } from "./remote-helper"; @@ -2231,6 +2229,20 @@ export async function handleCommand( return ""; } + if (command === "get_hub_status") { + if (!options?.connection?.data?.canApproveTools) { + throw new Error("hub status requires a trusted desktop connection"); + } + return await getHubStatus(ctx, await ensureSharedHubClient(ctx)); + } + if (command === "restart_hub") { + if (!options?.connection?.data?.canApproveTools) { + throw new Error("hub restart requires a trusted desktop connection"); + } + await restartHub(ctx, await ensureSharedHubClient(ctx)); + return await getHubStatus(ctx, await ensureSharedHubClient(ctx)); + } + // ── Managed hub upgrade ─────────────────────────────────────────── if (command === "hub_upgrade") { // Replacing the shared Hub interrupts other clients' sessions, so it @@ -3542,22 +3554,17 @@ export async function handleCommand( String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot, ); } - if (command === "warm_plugin_commands") { - // The webview reports whichever local workspace it has adopted so the - // plugin sandbox is loaded before the slash menu first needs it. - const binding = getCommandRuntimeBinding(ctx, args); - const workspacePath = String(args?.workspacePath ?? "").trim(); - if (binding.kind === "local" && workspacePath) { - warmPluginCommandService(ctx, workspacePath); - } - return { environmentId: binding.environmentId }; - } if (command === "list_plugin_commands") { // Same workspace the session will execute in (handleSend), so the menu // only offers commands that can actually run there. const workspacePath = String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot; - return await getPluginCommandService(ctx, workspacePath).listCommands(); + return await listPluginCommands( + ctx, + workspacePath, + typeof args?.sessionId === "string" ? args.sessionId : undefined, + requestedEnvironmentId(args), + ); } if (command === "list_marketplace_installed_entries") { return listMarketplaceInstalledEntries( diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index a5e6d5513f..7755b85409 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -242,6 +242,33 @@ describe("Code sidecar runtime capabilities", () => { expect(connectMock).toHaveBeenCalledOnce(); }); + it("records only local observer events, excluding cloud events projected through a local context", async () => { + const { createSidecarContext, ensureSharedHubClient, handleHubLiveEvent } = + await import("./context"); + const { getHubStatus } = await import("./hub-admin"); + const ctx = createSidecarContext("/workspace/project"); + const client = await ensureSharedHubClient(ctx); + const observer = subscribeMock.mock.calls[0][0]; + observer({ + event: "hub.client.registered", + payload: { displayName: "Local client" }, + }); + // CloudSessionManager projects cloud hub events through this same handler + // with a local environment context; context identity is not event origin. + for (let i = 0; i < 105; i++) { + handleHubLiveEvent(ctx, { + event: "ui.notify", + payload: { title: "Cloud run", body: "Cloud content" }, + }); + } + const status = await getHubStatus(ctx, client); + expect(status.events).toHaveLength(1); + expect(status.events[0]).toMatchObject({ + title: "Client connected", + detail: "Local client", + }); + }); + it("returns indexed search results without listing every session", async () => { const { handleCommand } = await import("./commands"); const { createSidecarContext } = await import("./context"); diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index 04289d340b..bc2fedcf29 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -30,6 +30,7 @@ import { disposeDesktopFeatureFlagsService, getDesktopFeatureFlagsService, } from "./feature-flags"; +import { recordHubActivity } from "./hub-admin"; import { sessionLogPath } from "./paths"; import type { LiveSession, @@ -1528,6 +1529,7 @@ export async function ensureSharedHubClient( try { await client.connect(); client.subscribe((event) => { + recordHubActivity(getSidecarContextOwner(ctx), event); handleHubLiveEvent( getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID), event, diff --git a/apps/examples/desktop-app/sidecar/hub-admin.test.ts b/apps/examples/desktop-app/sidecar/hub-admin.test.ts new file mode 100644 index 0000000000..b2b857f225 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/hub-admin.test.ts @@ -0,0 +1,134 @@ +import type { NodeHubClient } from "@cline/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SidecarContext } from "./types"; + +const mocks = vi.hoisted(() => ({ + ensure: vi.fn(), + probe: vi.fn(), + shutdown: vi.fn(), +})); +vi.mock("@cline/core", () => ({ + ensureDetachedHubServer: mocks.ensure, + probeHubServer: mocks.probe, + requestHubShutdown: mocks.shutdown, +})); + +import { getHubStatus, recordHubActivity, restartHub } from "./hub-admin"; + +const ctx = { localWorkspaceRoot: "/workspace" } as SidecarContext; +const command = vi.fn(); +const client = { + command, + getUrl: () => "ws://127.0.0.1:1234", +} as unknown as NodeHubClient; + +beforeEach(() => { + vi.resetAllMocks(); + command.mockResolvedValue({ ok: true, payload: { clients: [] } }); + mocks.shutdown.mockResolvedValue(true); + mocks.probe + .mockResolvedValue(undefined) + .mockResolvedValueOnce({ hubId: "original" }); + mocks.ensure.mockResolvedValue({ url: "ws://127.0.0.1:5678" }); +}); + +afterEach(() => vi.useRealTimers()); + +describe("hub administration", () => { + it("bounds activity and excludes streamed content and client metadata", async () => { + const context = {} as SidecarContext; + for (let i = 0; i < 105; i++) + recordHubActivity(context, { + event: "hub.client.registered", + payload: { displayName: `Client ${i}` }, + }); + recordHubActivity(context, { + event: "message.delta", + payload: { text: "private content" }, + }); + command.mockResolvedValue({ + ok: true, + payload: { + clients: [ + { + clientId: "one", + clientType: "cli", + connectedAt: 1, + metadata: { secret: "hidden" }, + }, + ], + }, + }); + const status = await getHubStatus(context, client); + expect(status.events).toHaveLength(100); + expect(status.events[0].detail).toBe("Client 104"); + expect(status.clients[0]).not.toHaveProperty("metadata"); + expect((await getHubStatus({} as SidecarContext, client)).events).toEqual( + [], + ); + }); + it("shares concurrent restarts and reconnects after starting the replacement", async () => { + const order: string[] = []; + mocks.shutdown.mockImplementation(async () => { + order.push("stop"); + return true; + }); + mocks.ensure.mockImplementation(async () => { + order.push("start"); + }); + command.mockImplementation(async () => { + order.push("reconnect"); + return { ok: true }; + }); + await Promise.all([restartHub(ctx, client), restartHub(ctx, client)]); + expect(order).toEqual(["stop", "start", "reconnect"]); + expect(mocks.ensure).toHaveBeenCalledWith("/workspace"); + }); + it("does not spawn a replacement when shutdown is rejected, and allows retry", async () => { + mocks.shutdown.mockResolvedValueOnce(false); + await expect(restartHub(ctx, client)).rejects.toThrow("did not accept"); + expect(mocks.ensure).not.toHaveBeenCalled(); + mocks.probe.mockResolvedValueOnce({ hubId: "original" }); + await expect(restartHub(ctx, client)).resolves.toBeUndefined(); + }); + it("accepts a replacement started by another client at the same address", async () => { + mocks.probe.mockResolvedValue({ hubId: "replacement" }); + await expect(restartHub(ctx, client)).resolves.toBeUndefined(); + expect(mocks.probe).toHaveBeenCalledTimes(2); + expect(mocks.shutdown).toHaveBeenCalledTimes(1); + expect(mocks.ensure).toHaveBeenCalledOnce(); + expect(command).toHaveBeenCalledWith("client.list"); + }); + it("waits for the original hub to retire before ensuring a replacement", async () => { + vi.useFakeTimers(); + mocks.probe.mockResolvedValueOnce({ hubId: "original" }); + const pending = restartHub(ctx, client); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.ensure).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + await expect(pending).resolves.toBeUndefined(); + expect(mocks.ensure).toHaveBeenCalledOnce(); + }); + it("times out if the original hub stays alive", async () => { + vi.useFakeTimers(); + mocks.probe.mockResolvedValue({ hubId: "original" }); + const pending = expect(restartHub(ctx, client)).rejects.toThrow( + "did not stop in time", + ); + await vi.advanceTimersByTimeAsync(15_000); + await pending; + expect(mocks.ensure).not.toHaveBeenCalled(); + }); + it("does not stop a hub whose instance cannot be identified", async () => { + mocks.probe.mockReset().mockResolvedValue({}); + await expect(restartHub(ctx, client)).rejects.toThrow("Unable to identify"); + expect(mocks.shutdown).not.toHaveBeenCalled(); + }); + it("reports client-list errors", async () => { + command.mockResolvedValue({ + ok: false, + error: { message: "Disconnected" }, + }); + await expect(getHubStatus(ctx, client)).rejects.toThrow("Disconnected"); + }); +}); diff --git a/apps/examples/desktop-app/sidecar/hub-admin.ts b/apps/examples/desktop-app/sidecar/hub-admin.ts new file mode 100644 index 0000000000..79516b466f --- /dev/null +++ b/apps/examples/desktop-app/sidecar/hub-admin.ts @@ -0,0 +1,117 @@ +import { + ensureDetachedHubServer, + type NodeHubClient, + probeHubServer, + requestHubShutdown, +} from "@cline/core"; +import type { HubClientRecord } from "@cline/shared"; +import type { HubActivity, HubStatus } from "../webview/lib/hub-status"; +import type { SidecarContext } from "./types"; + +const activity = new WeakMap(); +let nextId = 0; +let restartPending: Promise | undefined; + +export function recordHubActivity( + ctx: SidecarContext, + event: { + event: string; + sessionId?: string; + payload?: Record; + }, +): void { + const titles: Record = { + "hub.client.registered": "Client connected", + "hub.client.disconnected": "Client disconnected", + "session.created": "Session started", + "session.detached": "Session detached", + "run.started": "Run started", + "run.completed": "Run completed", + "run.failed": "Run failed", + "run.aborted": "Run stopped", + "ui.notify": "Notification", + }; + const title = titles[event.event]; + if (!title) return; + const payload = event.payload ?? {}; + const detail = [ + payload.displayName, + payload.clientType, + payload.clientId, + event.sessionId, + ].find((value) => typeof value === "string") as string | undefined; + const entry: HubActivity = { + id: ++nextId, + timestamp: Date.now(), + title: + event.event === "ui.notify" && typeof payload.title === "string" + ? payload.title + : title, + detail: + event.event === "ui.notify" && typeof payload.body === "string" + ? payload.body + : (detail ?? ""), + }; + activity.set(ctx, [entry, ...(activity.get(ctx) ?? [])].slice(0, 100)); +} + +export async function getHubStatus( + ctx: SidecarContext, + client: NodeHubClient, +): Promise { + const reply = await client.command("client.list"); + if (!reply.ok) + throw new Error(reply.error?.message ?? "Unable to list hub clients"); + const clients = (reply.payload?.clients ?? []) as HubClientRecord[]; + return { + url: client.getUrl(), + clients: clients.map( + ({ clientId, clientType, displayName, connectedAt }) => ({ + clientId, + clientType, + displayName, + connectedAt, + }), + ), + events: activity.get(ctx) ?? [], + }; +} + +export function restartHub( + ctx: SidecarContext, + client: NodeHubClient, +): Promise { + if (restartPending) return restartPending; + restartPending = (async () => { + const url = client.getUrl(); + const original = await probeHubServer(url, { + signal: AbortSignal.timeout(2000), + }); + if (!original?.hubId) + throw new Error("Unable to identify the hub to restart. Try again."); + if (!(await requestHubShutdown(url))) + throw new Error("The hub did not accept the restart request."); + const deadline = Date.now() + 15_000; + while (true) { + const current = await probeHubServer(url, { + signal: AbortSignal.timeout(2000), + }); + // Reconnecting clients may have already started a replacement here. + if (!current || current.hubId !== original.hubId) break; + if (Date.now() >= deadline) + throw new Error("The hub did not stop in time. Try again."); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await ensureDetachedHubServer(ctx.localWorkspaceRoot); + // Existing desktop clients rediscover the daemon on their next command. + await client.command("client.list").then((reply) => { + if (!reply.ok) + throw new Error( + reply.error?.message ?? "Unable to reconnect to the hub", + ); + }); + })().finally(() => { + restartPending = undefined; + }); + return restartPending; +} diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index be7dfcd550..5fbb67d9da 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -26,7 +26,6 @@ import { initializeSessionManager, } from "./context"; import { createDesktopObservability } from "./observability"; -import { configurePluginBootstrap } from "./plugin-bootstrap"; import { resolveWorkspaceRoot } from "./paths"; import { startServer } from "./server"; import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types"; @@ -255,8 +254,6 @@ async function runEntrypoint(): Promise { setClineClientIdentity(DESKTOP_CLIENT_CONTEXT); disableCurrentDirectoryExecutableSearch(); - // Before the bundled CLI starts the Hub daemon and loads any plugin. - configurePluginBootstrap(); // Before the Hub daemon and agent-spawned processes inherit this env. ensureLoopbackProxyBypass(); await main(cliPath); diff --git a/apps/examples/desktop-app/sidecar/marketplace.test.ts b/apps/examples/desktop-app/sidecar/marketplace.test.ts index b6b437eb27..24b009c446 100644 --- a/apps/examples/desktop-app/sidecar/marketplace.test.ts +++ b/apps/examples/desktop-app/sidecar/marketplace.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { installPlugin } from "@cline/core"; +import { installPlugin, SKILLS_CLI_PACKAGE } from "@cline/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getOfficialPluginInstallPath, @@ -229,7 +229,7 @@ describe("skill install failures", () => { ); expect(spawnCommand).toHaveBeenCalledWith("npx", [ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "add", "https://github.com/cline/skills.git", "-g", diff --git a/apps/examples/desktop-app/sidecar/marketplace.ts b/apps/examples/desktop-app/sidecar/marketplace.ts index e630c25e59..beba3e0921 100644 --- a/apps/examples/desktop-app/sidecar/marketplace.ts +++ b/apps/examples/desktop-app/sidecar/marketplace.ts @@ -25,6 +25,7 @@ import { parseMcpInstallArgs, resolveSkillsConfigSearchPaths, resolveWorkflowsConfigSearchPaths, + SKILLS_CLI_PACKAGE, uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry, uninstallPlugin as uninstallLocalPlugin, } from "@cline/core"; @@ -781,7 +782,7 @@ async function installSkill( ensureGlobalSkillsDirWritable(); const result = await spawnCommand("npx", [ "-y", - "skills@latest", + SKILLS_CLI_PACKAGE, "add", ...(entry.install.args ?? []), "-g", diff --git a/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts b/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts deleted file mode 100644 index 1b50d672af..0000000000 --- a/apps/examples/desktop-app/sidecar/plugin-bootstrap.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core"; -import { expect, it } from "vitest"; -import { - configurePluginBootstrap, - resolveDesktopPluginBootstrap, -} from "./plugin-bootstrap"; - -function withLayout( - resourceDirectory: (root: string) => string, - execPath: (root: string) => string, - check: (bootstrap: string, execPath: string) => void, -): void { - const root = mkdtempSync(join(tmpdir(), "cline-plugin-bootstrap-")); - try { - const directory = join(resourceDirectory(root), "bin", "extensions"); - mkdirSync(directory, { recursive: true }); - const bootstrap = join(directory, "plugin-sandbox-bootstrap.js"); - writeFileSync(bootstrap, "// bootstrap"); - check(bootstrap, execPath(root)); - } finally { - rmSync(root, { recursive: true, force: true }); - } -} - -it("finds the bootstrap in the macOS app bundle resources", () => { - withLayout( - (root) => join(root, "Cline.app", "Contents", "Resources"), - (root) => join(root, "Cline.app", "Contents", "MacOS", "cline-cli"), - (bootstrap, execPath) => { - expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( - bootstrap, - ); - }, - ); -}); - -it("finds the bootstrap in the Linux and Windows resource layouts", () => { - withLayout( - (root) => join(root, "usr", "lib", "Cline"), - (root) => join(root, "usr", "bin", "cline-cli"), - (bootstrap, execPath) => { - expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( - bootstrap, - ); - }, - ); - withLayout( - (root) => root, - (root) => join(root, "cline-cli.exe"), - (bootstrap, execPath) => { - expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe( - bootstrap, - ); - }, - ); -}); - -it("keeps an explicitly configured bootstrap path", () => { - const env = { [CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]: "/custom/bootstrap.js" }; - configurePluginBootstrap(env); - expect(env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]).toBe("/custom/bootstrap.js"); -}); diff --git a/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts b/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts deleted file mode 100644 index fb08251387..0000000000 --- a/apps/examples/desktop-app/sidecar/plugin-bootstrap.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core"; -import { linuxResourceCandidates } from "./remote-helper"; - -const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js"); - -/** - * Finds the plugin worker bootstrap the app bundle ships as a Tauri resource. - * The backend and CLI-managed Hub need an on-disk bootstrap to run plugins - * in workers; without it they would fall back to loading plugins in-process. - */ -export function resolveDesktopPluginBootstrap( - options: { execPath?: string; cwd?: string } = {}, -): string | undefined { - const executableDirectory = dirname(options.execPath ?? process.execPath); - const cwd = options.cwd ?? process.cwd(); - return [ - join(executableDirectory, BUNDLED_BOOTSTRAP), - join(executableDirectory, "..", "Resources", BUNDLED_BOOTSTRAP), - ...linuxResourceCandidates(executableDirectory, BUNDLED_BOOTSTRAP), - join(cwd, "src-tauri", BUNDLED_BOOTSTRAP), - ].find(existsSync); -} - -/** Exposes the bundled bootstrap to core and to the Hub daemon this spawns. */ -export function configurePluginBootstrap(env = process.env): void { - if (env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]) return; - const bootstrap = resolveDesktopPluginBootstrap(); - if (bootstrap) env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV] = bootstrap; -} diff --git a/apps/examples/desktop-app/sidecar/plugin-commands.test.ts b/apps/examples/desktop-app/sidecar/plugin-commands.test.ts deleted file mode 100644 index c0bb6c2905..0000000000 --- a/apps/examples/desktop-app/sidecar/plugin-commands.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { listCommandsMock, runMock, createPluginCommandServiceMock } = - vi.hoisted(() => { - const listCommandsMock = vi.fn( - async (): Promise> => [], - ); - const runMock = vi.fn( - async ( - _name: string, - _input: string, - ): Promise<{ reply?: string; submitPrompt?: string } | undefined> => - undefined, - ); - return { - listCommandsMock, - runMock, - createPluginCommandServiceMock: vi.fn(() => ({ - listCommands: listCommandsMock, - run: runMock, - shutdown: vi.fn(), - })), - }; - }); - -vi.mock("@cline/core", async () => { - const actual = - await vi.importActual("@cline/core"); - return { - ...actual, - createPluginCommandService: createPluginCommandServiceMock, - }; -}); - -import { handleCommand } from "./commands"; -import { - resetPluginCommandServicesForTests, - runPluginSlashCommand, - WARMUP_MAX_ATTEMPTS, - WARMUP_RETRY_DELAY_MS, -} from "./plugin-commands"; -import type { SidecarContext } from "./types"; - -const WORKSPACE = "/projects/app"; - -function createContext(kind: "local" | "ssh" = "local"): SidecarContext { - return { - activeEnvironmentId: "env", - localWorkspaceRoot: "/local/launch-root", - runtimeBindings: new Map([ - [ - "env", - { - kind, - environmentId: "env", - workspaceRoot: "/local/launch-root", - }, - ], - ]), - } as unknown as SidecarContext; -} - -async function flushPromises(): Promise { - for (let i = 0; i < 5; i += 1) { - await Promise.resolve(); - } -} - -describe("plugin command warmup", () => { - beforeEach(() => { - vi.useFakeTimers(); - resetPluginCommandServicesForTests(); - listCommandsMock.mockReset(); - listCommandsMock.mockResolvedValue([]); - runMock.mockReset(); - createPluginCommandServiceMock.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("loads the workspace's plugin commands once ahead of the slash menu", async () => { - listCommandsMock.mockResolvedValue([{ name: "goal" }]); - const ctx = createContext(); - await expect( - handleCommand(ctx, "warm_plugin_commands", { workspacePath: WORKSPACE }), - ).resolves.toEqual({ environmentId: "env" }); - await flushPromises(); - - // The service is keyed on the reported workspace, not the launch root. - expect(createPluginCommandServiceMock).toHaveBeenCalledTimes(1); - expect(createPluginCommandServiceMock).toHaveBeenCalledWith( - expect.objectContaining({ cwd: WORKSPACE, workspacePath: WORKSPACE }), - ); - expect(listCommandsMock).toHaveBeenCalledTimes(1); - - // Re-reporting the same workspace does not reload a warmed host. - await handleCommand(ctx, "warm_plugin_commands", { - workspacePath: WORKSPACE, - }); - await flushPromises(); - expect(listCommandsMock).toHaveBeenCalledTimes(1); - - // The slash menu and the command runner reuse the warmed service. - await expect( - handleCommand(ctx, "list_plugin_commands", { workspacePath: WORKSPACE }), - ).resolves.toEqual([{ name: "goal" }]); - expect(createPluginCommandServiceMock).toHaveBeenCalledTimes(1); - }); - - it("recovers from a failed cold load and makes the command available", async () => { - // First load times out inside the service (which reports it as an - // empty command set), the retry succeeds. - listCommandsMock - .mockResolvedValueOnce([]) - .mockResolvedValue([{ name: "goal", description: "Set a goal" }]); - runMock.mockResolvedValue({ reply: "Goal set: ship it" }); - const ctx = createContext(); - - await handleCommand(ctx, "warm_plugin_commands", { - workspacePath: WORKSPACE, - }); - await flushPromises(); - expect(listCommandsMock).toHaveBeenCalledTimes(1); - - // Re-reporting the workspace while the retry is pending does not stack - // extra loads. - await handleCommand(ctx, "warm_plugin_commands", { - workspacePath: WORKSPACE, - }); - await flushPromises(); - expect(listCommandsMock).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS); - await flushPromises(); - expect(listCommandsMock).toHaveBeenCalledTimes(2); - - // Discovery and execution now work without the user reopening the menu. - await expect( - handleCommand(ctx, "list_plugin_commands", { workspacePath: WORKSPACE }), - ).resolves.toEqual([{ name: "goal", description: "Set a goal" }]); - await expect( - runPluginSlashCommand(ctx, { - workspacePath: WORKSPACE, - prompt: "/goal ship it", - }), - ).resolves.toEqual({ reply: "Goal set: ship it" }); - expect(runMock).toHaveBeenCalledWith("goal", " ship it"); - - // Once warmed, no further retries are scheduled. - await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS * 2); - expect(listCommandsMock).toHaveBeenCalledTimes(3); - }); - - it("stops retrying after a bounded number of attempts", async () => { - listCommandsMock.mockRejectedValue(new Error("sandbox boom")); - const ctx = createContext(); - await handleCommand(ctx, "warm_plugin_commands", { - workspacePath: WORKSPACE, - }); - await flushPromises(); - for (let i = 0; i < WARMUP_MAX_ATTEMPTS + 2; i += 1) { - await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS); - await flushPromises(); - } - expect(listCommandsMock).toHaveBeenCalledTimes(WARMUP_MAX_ATTEMPTS); - }); - - it("does not spawn a sandbox for remote workspaces", async () => { - const ctx = createContext("ssh"); - await handleCommand(ctx, "warm_plugin_commands", { - workspacePath: "/srv/code", - }); - await flushPromises(); - expect(createPluginCommandServiceMock).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/examples/desktop-app/sidecar/plugin-commands.ts b/apps/examples/desktop-app/sidecar/plugin-commands.ts index af615ea7ff..8fdba9ed5f 100644 --- a/apps/examples/desktop-app/sidecar/plugin-commands.ts +++ b/apps/examples/desktop-app/sidecar/plugin-commands.ts @@ -1,120 +1,80 @@ import { - createPluginCommandService, + createHubPluginCommandsApi, type PluginCommandResult, - type PluginCommandService, + parsePluginCommand, } from "@cline/core"; +import { getSessionRuntimeBinding } from "./context"; import type { SidecarContext } from "./types"; -// One service per workspace; it keeps the plugin sandboxes alive between -// commands and reloads them itself when the plugin set changes. -const servicesByWorkspace = new Map(); +/** + * The runtime rejects a session command whose workspace differs from the + * session's `workspaceRoot ?? cwd`, so target the live session the same way. + */ +function sessionWorkspacePath( + ctx: SidecarContext, + sessionId: string | undefined, + fallback: string, +): string { + const config = sessionId + ? ctx.liveSessions.get(sessionId)?.config + : undefined; + for (const value of [config?.workspaceRoot, config?.cwd]) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return fallback; +} -export function getPluginCommandService( +export async function listPluginCommands( ctx: SidecarContext, workspacePath: string, -): PluginCommandService { - let service = servicesByWorkspace.get(workspacePath); - if (!service) { - service = createPluginCommandService({ - cwd: workspacePath, - workspacePath, - logger: ctx.logger, - }); - servicesByWorkspace.set(workspacePath, service); - } - return service; + sessionId?: string, + environmentId?: string, +) { + const client = getSessionRuntimeBinding( + ctx, + sessionId, + environmentId, + ).hubClient; + return createHubPluginCommandsApi(client).list({ + workspacePath: sessionWorkspacePath(ctx, sessionId, workspacePath), + sessionId, + }); } -// A failed load (sandbox startup timeout, I/O hiccup) yields an empty command -// list and is cached by the service for 30s, so space retries past that -// window. Legitimately empty results make these retries a cheap no-op since -// the service keeps its loaded host. -export const WARMUP_RETRY_DELAY_MS = 35_000; -export const WARMUP_MAX_ATTEMPTS = 4; - -type WarmupState = { - attempts: number; - done: boolean; - inFlight: boolean; - retry?: ReturnType; -}; -const warmups = new Map(); - -/** - * Spawn the plugin sandbox for a workspace ahead of the first slash menu - * open (the CLI does the same at TUI mount), so the menu lists plugin - * commands immediately instead of paying the cold spawn on the first `/`. - * The webview calls this for whichever workspace it has adopted. A load that - * comes back empty is retried in the background a few times so a slow cold - * start recovers without the user reopening the menu. - */ -export function warmPluginCommandService( - ctx: SidecarContext, - workspacePath: string, -): void { - let state = warmups.get(workspacePath); - if (!state) { - state = { attempts: 0, done: false, inFlight: false }; - warmups.set(workspacePath, state); - } - if ( - state.done || - state.inFlight || - state.retry || - state.attempts >= WARMUP_MAX_ATTEMPTS - ) { - return; - } - state.attempts += 1; - state.inFlight = true; - void getPluginCommandService(ctx, workspacePath) - .listCommands() - .then( - (commands) => commands.length > 0, - (error) => { - ctx.logger?.debug?.("plugin command warmup failed", { error }); - return false; - }, - ) - .then((loaded) => { - state.inFlight = false; - if (loaded) { - state.done = true; - return; - } - if (state.attempts >= WARMUP_MAX_ATTEMPTS) { - return; - } - state.retry = setTimeout(() => { - state.retry = undefined; - warmPluginCommandService(ctx, workspacePath); - }, WARMUP_RETRY_DELAY_MS); - state.retry.unref?.(); - }); -} - -/** Test hook: forget warm-up progress and cached services. */ -export function resetPluginCommandServicesForTests(): void { - for (const state of warmups.values()) { - clearTimeout(state.retry); - } - warmups.clear(); - servicesByWorkspace.clear(); -} - -/** - * Run a plugin-registered slash command for the leading `/name` token of a - * prompt. Returns undefined when no enabled plugin declares the command so - * the prompt continues through the normal path. - */ export async function runPluginSlashCommand( ctx: SidecarContext, - input: { workspacePath: string; prompt: string }, + input: { + workspacePath: string; + prompt: string; + sessionId: string; + environmentId?: string; + }, ): Promise { - const match = input.prompt.match(/^\/(\S+)([\s\S]*)$/); - if (!match?.[1]) return undefined; - return await getPluginCommandService(ctx, input.workspacePath).run( - match[1], - match[2] ?? "", + const commands = createHubPluginCommandsApi( + getSessionRuntimeBinding(ctx, input.sessionId, input.environmentId) + .hubClient, ); + const target = { + workspacePath: sessionWorkspacePath( + ctx, + input.sessionId, + input.workspacePath, + ), + sessionId: input.sessionId, + }; + // A failed catalog read must not block skills and workflows sharing the + // slash syntax. Only a listed plugin command may fail the send. + try { + const name = parsePluginCommand(input.prompt)?.name; + const catalog = await commands.list(target); + if (!catalog.commands.some((command) => command.name === name)) + return undefined; + } catch (error) { + ctx.logger?.log("Plugin command discovery failed", { + error, + severity: "warn", + }); + return undefined; + } + return commands.run({ ...target, prompt: input.prompt }); } diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index c6bda06075..77886fa5d3 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cline", - "version": "0.0.47", + "version": "0.0.48", "identifier": "bot.cline.app", "build": { "beforeDevCommand": "bun run build:runtime && bun run dev:web", @@ -46,8 +46,7 @@ "icons/app/*.png", "icons/app/macos/*.png", "bin/remote-helpers/*", - "bin/desktop-backend/*", - "bin/extensions/**/*" + "bin/desktop-backend/**/*" ], "icon": [ "icons/32x32.png", diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index 9406775719..759261eaa8 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -1756,22 +1756,6 @@ function ChatThreadPane({ [environmentId, onPickRemoteWorkspaceDirectory, remoteEnvironment], ); - // Let the sidecar load the workspace's plugin sandbox now so the slash - // menu lists plugin commands without paying the cold spawn on first open. - useEffect(() => { - if (!activeWorkspaceCwd) { - return; - } - void desktopClient - .invoke("warm_plugin_commands", { - workspacePath: activeWorkspaceCwd, - environmentId, - }) - .catch(() => { - // Best effort; the menu falls back to loading on open. - }); - }, [activeWorkspaceCwd, environmentId]); - useEffect(() => { void refreshGitBranch(); if (!activeWorkspaceCwd) { @@ -2521,6 +2505,7 @@ function ChatThreadPane({ const composer = ( [0]["attachments"]; model?: string; @@ -211,13 +213,17 @@ async function renderVoiceComposer({ readOnly?: boolean; executionTarget?: "cloud" | "local"; onAttachFiles?: Parameters[0]["onAttachFiles"]; + sessionId?: string; } = {}) { await act(async () => { root.render( { }); describe("ChatInputBar", () => { + it("retains skills and workflows when plugin discovery rejects", async () => { + vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => { + if (name === "list_plugin_commands") throw new Error("hub unavailable"); + return { + runtimeCommands: [ + { + name: "publish-skill", + kind: "skill", + description: "Publish skill", + }, + { + name: "publish-workflow", + kind: "workflow", + description: "Publish workflow", + }, + ], + }; + }); + await renderVoiceComposer({ prompt: "/publish", executionTarget: "local" }); + expect(container.textContent).toContain("Publish skill"); + expect(container.textContent).toContain("Publish workflow"); + }); + + it("re-reads plugin commands when the slash menu reopens after a failure", async () => { + let available = false; + vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => { + if (name !== "list_plugin_commands") return { runtimeCommands: [] }; + if (!available) throw new Error("hub connection lost"); + return { + workspacePath: "/workspace/cline", + status: "ready", + commands: [{ name: "restored", description: "Recovered command" }], + }; + }); + await renderVoiceComposer({ + prompt: "/rest", + promptVersion: 1, + executionTarget: "local", + }); + expect(container.textContent).toContain("Plugin commands unavailable"); + available = true; + await renderVoiceComposer({ + prompt: "ordinary text", + promptVersion: 2, + executionTarget: "local", + }); + await renderVoiceComposer({ + prompt: "/rest", + promptVersion: 3, + executionTarget: "local", + }); + expect(container.textContent).toContain("Recovered command"); + expect(container.textContent).not.toContain("Plugin commands unavailable"); + }); + + it("does not show another session's plugin commands", async () => { + vi.spyOn(desktopClient, "invoke").mockImplementation( + async (name, payload) => { + if (name !== "list_plugin_commands") return {}; + if ((payload as { sessionId?: string }).sessionId === "session-b") + throw new Error("hub connection closed"); + return { + status: "ready", + commands: [{ name: "publish-a", description: "Session A plugin" }], + }; + }, + ); + await renderVoiceComposer({ + prompt: "/publish", + promptVersion: 1, + executionTarget: "local", + sessionId: "session-a", + }); + expect(container.textContent).toContain("Session A plugin"); + await renderVoiceComposer({ + prompt: "/publish", + promptVersion: 1, + executionTarget: "local", + sessionId: "session-b", + }); + expect(container.textContent).not.toContain("Session A plugin"); + }); + + it("refreshes commands on reopen without resetting cached suggestions", async () => { + const updated = deferred<{ + runtimeCommands: Array<{ + name: string; + kind: "workflow"; + description: string; + }>; + }>(); + let instructionRequests = 0; + const invoke = vi + .spyOn(desktopClient, "invoke") + .mockImplementation(async (name) => { + if (name === "list_plugin_commands") + return { + status: "ready", + commands: [ + { name: "publish-plugin", description: "Plugin suggestion" }, + ], + }; + if (name === "list_user_instruction_configs") { + instructionRequests++; + return instructionRequests === 1 + ? { + runtimeCommands: [ + { + name: "publish-old", + kind: "skill", + description: "Old skill", + }, + ], + } + : updated.promise; + } + return {}; + }); + await renderVoiceComposer({ + prompt: "/publish", + promptVersion: 1, + executionTarget: "local", + }); + expect(container.textContent).toContain("Old skill"); + await renderVoiceComposer({ + prompt: "ordinary text", + promptVersion: 2, + executionTarget: "local", + }); + await renderVoiceComposer({ + prompt: "/publish", + promptVersion: 3, + executionTarget: "local", + }); + expect(container.textContent).toContain("Old skill"); + expect(container.textContent).toContain("Plugin suggestion"); + expect(instructionRequests).toBe(2); + expect( + invoke.mock.calls.filter(([name]) => name === "list_plugin_commands"), + ).toHaveLength(2); + await act(async () => + updated.resolve({ + runtimeCommands: [ + { + name: "publish-new", + kind: "workflow", + description: "New workflow", + }, + ], + }), + ); + expect(container.textContent).not.toContain("Old skill"); + expect(container.textContent).toContain("New workflow"); + expect(container.textContent).toContain("Plugin suggestion"); + }); + it("prevents sending from a read-only session", async () => { const onSend = vi.fn(); await renderVoiceComposer({ prompt: "Test", readOnly: true, onSend }); @@ -634,7 +796,7 @@ describe("ChatInputBar", () => { ]); }); - it("appends plugin commands after skills and workflows", () => { + it("gives plugin commands the same precedence as execution", () => { expect( buildUserInstructionSlashCommands( { @@ -647,7 +809,7 @@ describe("ChatInputBar", () => { ], ), ).toEqual([ - { name: "goal", description: "Skill command" }, + { name: "goal", description: "Set or clear a goal" }, { name: "goal-status", description: "Plugin command" }, ]); }); diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx index c652ce83bb..4149849029 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx @@ -1,5 +1,7 @@ "use client"; +import type { PluginCommandCatalog } from "@cline/core"; + import { CLINE_DEFAULT_MODEL_ID, formatDisplayUserInput, @@ -120,11 +122,6 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ { name: "team", description: "Start the task with an agent team" }, ]; -// Last known user commands, kept across composer instances so reopening the -// slash menu paints instantly (stale-while-revalidate); the fetch that -// follows still picks up newly installed skills and workflows. -let cachedSlashCommands: SlashCommand[] | null = null; - export function buildUserInstructionSlashCommands( response: UserInstructionConfigResponse, // Plugin commands (`api.registerCommand`) as listed by the sidecar's @@ -135,21 +132,7 @@ export function buildUserInstructionSlashCommands( ? response.runtimeCommands : []; const seen = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); - const result = commands.flatMap((command) => { - const name = command.name; - if (!name || seen.has(name)) { - return []; - } - seen.add(name); - return [ - { - name, - description: - command.description?.trim() || - `${command.kind === "skill" ? "Skill" : "Workflow"} command`, - }, - ]; - }); + const result: SlashCommand[] = []; for (const command of pluginCommands) { if (!command.name || seen.has(command.name)) continue; seen.add(command.name); @@ -158,6 +141,16 @@ export function buildUserInstructionSlashCommands( description: command.description?.trim() || "Plugin command", }); } + for (const command of commands) { + if (!command.name || seen.has(command.name)) continue; + seen.add(command.name); + result.push({ + name: command.name, + description: + command.description?.trim() || + `${command.kind === "skill" ? "Skill" : "Workflow"} command`, + }); + } return result; } @@ -305,6 +298,7 @@ export function buildWorkspaceFileSearchKey( } type ChatInputBarProps = { + sessionId?: string | null; environmentId: string; variant?: "conversation" | "welcome"; readOnly?: boolean; @@ -355,6 +349,7 @@ type ChatInputBarProps = { }; function ChatInputBarImpl({ + sessionId, environmentId, variant = "conversation", readOnly = false, @@ -655,10 +650,50 @@ function ChatInputBarImpl({ executionTarget === "local" && slashKey !== null && dismissedSlashKey !== slashKey; - const [slashCommands, setSlashCommands] = useState( - () => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS, - ); - const [slashLoading, setSlashLoading] = useState(false); + const [instructionResponse, setInstructionResponse] = useState<{ + workspaceRoot: string; + environmentId: string; + response: UserInstructionConfigResponse; + }>(); + const [pluginResponse, setPluginResponse] = useState<{ + workspaceRoot: string; + environmentId: string; + sessionId?: string; + commands: SlashCommand[]; + }>(); + const slashCommands = useMemo(() => { + const current = < + T extends { workspaceRoot: string; environmentId: string }, + >( + target: T | undefined, + ) => + target?.workspaceRoot === workspaceRoot && + target.environmentId === environmentId + ? target + : undefined; + return [ + ...BUILTIN_SLASH_COMMANDS, + ...buildUserInstructionSlashCommands( + current(instructionResponse)?.response ?? {}, + // A session's plugins can differ from the workspace catalog. + pluginResponse?.sessionId === (sessionId ?? undefined) + ? (current(pluginResponse)?.commands ?? []) + : [], + ), + ]; + }, [ + instructionResponse, + pluginResponse, + workspaceRoot, + environmentId, + sessionId, + ]); + const [pluginLoading, setPluginLoading] = useState(false); + const [instructionLoading, setInstructionLoading] = useState(false); + const [pluginError, setPluginError] = useState(); + const [instructionError, setInstructionError] = useState(); + const slashLoading = pluginLoading || instructionLoading; + const slashError = pluginError ?? instructionError; const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); useEffect(() => { @@ -1012,45 +1047,64 @@ function ChatInputBarImpl({ ?.scrollIntoView({ block: "nearest" }); }, [mentionOpen, mentionSelectedIndex]); - // Reload user commands whenever the slash menu opens so newly installed or - // edited skills and workflows are reflected without remounting the chat UI. + // Refresh user and plugin commands whenever the slash menu opens, keeping + // the last results while fetching so suggestions do not flash away. Each + // request settles independently so one failure cannot hide the other. useEffect(() => { if (!slashOpen) { + setInstructionLoading(false); + setPluginLoading(false); return; } let cancelled = false; - // Only show the loading row when there is nothing cached to show. - setSlashLoading(cachedSlashCommands === null); - Promise.all([ - desktopClient.invoke( - "list_user_instruction_configs", - { workspacePath: workspaceRoot }, - ), - desktopClient - .invoke("list_plugin_commands", { - workspacePath: workspaceRoot, - }) - .catch((): SlashCommand[] => []), - ]) - .then(([response, pluginCommands]) => { + setInstructionLoading(true); + setPluginLoading(true); + desktopClient + .invoke("list_user_instruction_configs", { + workspacePath: workspaceRoot, + environmentId, + }) + .then((response) => { if (cancelled) return; - const next = [ - ...BUILTIN_SLASH_COMMANDS, - ...buildUserInstructionSlashCommands(response, pluginCommands), - ]; - cachedSlashCommands = next; - setSlashCommands(next); + setInstructionResponse({ workspaceRoot, environmentId, response }); + setInstructionError(undefined); }) .catch(() => { - // Keep built-in commands on error. + if (!cancelled) setInstructionError("Instruction commands unavailable"); }) .finally(() => { - if (!cancelled) setSlashLoading(false); + if (!cancelled) setInstructionLoading(false); + }); + desktopClient + .invoke("list_plugin_commands", { + workspacePath: workspaceRoot, + environmentId, + sessionId: sessionId ?? undefined, + }) + .then((catalog) => { + if (cancelled) return; + setPluginResponse({ + workspaceRoot, + environmentId, + sessionId: sessionId ?? undefined, + commands: catalog.commands, + }); + setPluginError( + catalog.status === "error" + ? "Plugin commands unavailable" + : undefined, + ); + }) + .catch(() => { + if (!cancelled) setPluginError("Plugin commands unavailable"); + }) + .finally(() => { + if (!cancelled) setPluginLoading(false); }); return () => { cancelled = true; }; - }, [slashOpen, workspaceRoot]); + }, [slashOpen, workspaceRoot, environmentId, sessionId]); // Filtered slash commands based on the current query. const filteredSlashCommands = useMemo(() => { @@ -1128,7 +1182,7 @@ function ChatInputBarImpl({
{slashLoading ? "Loading commands..." - : "No matching commands"} + : (slashError ?? "No matching commands")}
) : ( <> diff --git a/apps/examples/desktop-app/webview/components/views/settings/about-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/about-view.tsx index 215597a2b5..3f56fb15f2 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/about-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/about-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bug, ExternalLink, Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { Bug, ExternalLink, Loader2, Rss, Sparkles } from "lucide-react"; import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -49,7 +49,7 @@ function useAppVersion(): string | null { return appVersion; } -function UpdateRow() { +function UpdateAction() { const status = useAppUpdateStatus(); const [checking, setChecking] = useState(false); const [checkResult, setCheckResult] = useState< @@ -67,17 +67,13 @@ function UpdateRow() { : status.state === "error" && status.error ? `The last check failed: ${status.error}` : checkResult === "up-to-date" - ? "You're up to date. Cline also checks on its own shortly after launch and every two hours." + ? "You're up to date." : checkResult === "unavailable" ? "Update checks are only available in the desktop app." - : "Cline checks for updates shortly after launch and every two hours, and installs them when it restarts."; + : null; return ( -
-
-

Updates

-

{description}

-
+
{status.state === "ready" ? ( )} + {description ? ( + + {description} + + ) : null}
); } @@ -202,6 +203,7 @@ export function AboutContent({ return ( } meta={ {appVersion ? ( @@ -219,7 +221,6 @@ export function AboutContent({ title={productNameForVersion(appVersion)} />
- {whatsNew ? (
diff --git a/apps/examples/desktop-app/webview/components/views/settings/debug-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/debug-view.tsx new file mode 100644 index 0000000000..0e3bd59177 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/settings/debug-view.tsx @@ -0,0 +1,41 @@ +import { Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PageFrame, PageHeader } from "../page-layout"; + +export function DebugContent({ + onExportDiagnostics, +}: { + onExportDiagnostics: () => void; +}) { + return ( + + +
+
+
+

+ Diagnostics +

+

+ Export app info, recent logs, and the metadata of sessions you + choose as a file you can attach when reporting a problem. +

+
+ +
+
+
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/views/settings/hub-panel.test.tsx b/apps/examples/desktop-app/webview/components/views/settings/hub-panel.test.tsx new file mode 100644 index 0000000000..050b86d457 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/settings/hub-panel.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { HubPanel } from "./hub-panel"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } })); +let root: Root; +let container: HTMLDivElement; +const status = { + url: "ws://127.0.0.1:1234", + clients: [ + { + clientId: "cli-1", + clientType: "cli", + displayName: "Terminal", + connectedAt: 1, + }, + ], + events: [ + { id: 1, timestamp: 1, title: "Client connected", detail: "Terminal" }, + ], +}; +beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + invoke.mockReset().mockResolvedValue(status); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); +async function click(label: string) { + const button = [...document.querySelectorAll("button")].find( + (button) => button.textContent === label, + ); + if (!button) throw new Error(`Missing button: ${label}`); + await act(async () => button.click()); +} +it("shows clients and events, and refreshes while mounted", async () => { + vi.useFakeTimers(); + await act(async () => root.render()); + expect(container.querySelector("h3")?.textContent).toContain( + "Connected clients", + ); + expect(container.querySelector('h3 [data-slot="badge"]')?.textContent).toBe( + "1", + ); + expect(container.textContent).toContain("Client connected"); + invoke.mockResolvedValue({ ...status, clients: [] }); + await act(async () => vi.advanceTimersByTimeAsync(2000)); + expect(container.textContent).toContain("No connected clients."); +}); +it("requires confirmation before restarting and reports restart failures", async () => { + await act(async () => root.render()); + await click("Restart Hub"); + expect(invoke).not.toHaveBeenCalledWith("restart_hub"); + expect(document.body.textContent).toContain("active sessions"); + await click("Cancel"); + expect(invoke).not.toHaveBeenCalledWith("restart_hub"); + await click("Restart Hub"); + invoke.mockRejectedValueOnce(new Error("Shutdown refused")); + const dialog = document.querySelector('[role="alertdialog"]'); + if (!dialog) throw new Error("Missing restart confirmation"); + const confirm = [...dialog.querySelectorAll("button")].find( + (button) => button.textContent === "Restart Hub", + ); + if (!confirm) throw new Error("Missing restart button"); + await act(async () => confirm.click()); + expect(invoke).toHaveBeenCalledWith("restart_hub"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Shutdown refused", + ); +}); +it("shows initial connection failures", async () => { + invoke.mockRejectedValue(new Error("Hub unavailable")); + await act(async () => root.render()); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "Hub unavailable", + ); +}); diff --git a/apps/examples/desktop-app/webview/components/views/settings/hub-panel.tsx b/apps/examples/desktop-app/webview/components/views/settings/hub-panel.tsx new file mode 100644 index 0000000000..6a1de01c30 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/settings/hub-panel.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { desktopClient } from "@/lib/desktop-client"; +import type { HubStatus } from "@/lib/hub-status"; + +export function HubPanel() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [restarting, setRestarting] = useState(false); + const restartingRef = useRef(false); + const generation = useRef(0); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType; + async function refresh() { + const requestGeneration = generation.current; + try { + if (!restartingRef.current) { + const next = await desktopClient.invoke("get_hub_status"); + if (!cancelled && requestGeneration === generation.current) { + setStatus(next); + setError(null); + } + } + } catch (cause) { + if (!cancelled && requestGeneration === generation.current) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } finally { + if (!cancelled) timer = setTimeout(refresh, 2000); + } + } + void refresh(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, []); + + async function restart() { + if (restartingRef.current) return; + restartingRef.current = true; + generation.current += 1; + setRestarting(true); + setError(null); + try { + setStatus(await desktopClient.invoke("restart_hub")); + setConfirmOpen(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + setConfirmOpen(false); + } finally { + restartingRef.current = false; + setRestarting(false); + } + } + + return ( +
+
+
+

+ Cline Hub +

+ {status ? ( +

+ {status.url} +

+ ) : null} +

+ Restart the local hub to stop active sessions and reconnect clients. +

+
+ +
+ {error ? ( +

+ {error} +

+ ) : null} + {!status && !error ? ( +

Loading hub…

+ ) : null} + {status ? ( +
+
+

+ Connected clients + {status.clients.length} +

+
    + {status.clients.map((client) => ( +
  • +

    + {client.displayName || client.clientType} +

    +

    + {client.clientType} · {client.clientId} +

    +

    + Connected {new Date(client.connectedAt).toLocaleString()} +

    +
  • + ))} +
+ {status.clients.length === 0 ? ( +

+ No connected clients. +

+ ) : null} +
+
+

+ Recent events +

+
    + {status.events.map((event) => ( +
  1. +
    + {event.title} + +
    +

    + {event.detail} +

    +
  2. + ))} +
+ {status.events.length === 0 ? ( +

+ No recent events. Activity is recorded while the desktop app is + running. +

+ ) : null} +
+
+ ) : null} + { + if (!restarting) setConfirmOpen(open); + }} + > + + + Restart Cline Hub? + + This stops the local hub and its active sessions, then starts a + fresh hub. Connected desktop, CLI, and VS Code clients will + disconnect and reconnect. + + + + Cancel + + + + +
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/views/settings/sections.ts b/apps/examples/desktop-app/webview/components/views/settings/sections.ts index eceff6be6b..ef457191ef 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/sections.ts +++ b/apps/examples/desktop-app/webview/components/views/settings/sections.ts @@ -14,6 +14,7 @@ const ALL_SETTINGS_SECTIONS = [ "Import", "Remote", "Account", + "Debug", "About", ] as const; diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx index 788d51f366..5b46a5ce3b 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.test.tsx @@ -40,9 +40,11 @@ beforeEach(() => { document.documentElement.style.removeProperty("font-size"); delete document.documentElement.dataset.clineFontSize; invoke.mockReset(); - invoke.mockResolvedValue({ - telemetryOptOut: false, - autoUpdateEnabled: true, + invoke.mockImplementation(async (command: string) => { + if (command === "get_hub_status") { + return { url: "ws://127.0.0.1:1234", clients: [], events: [] }; + } + return { telemetryOptOut: false, autoUpdateEnabled: true }; }); container = document.createElement("div"); document.body.appendChild(container); diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx index 333f3d9027..d89ca86e46 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx @@ -1,5 +1,5 @@ import { Switch } from "@cline/ui"; -import { Download, Minus, Plus, RotateCcw } from "lucide-react"; +import { Minus, Plus, RotateCcw } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -66,6 +66,8 @@ import { AccountView } from "./account-view"; import { AddProviderContent, type AddProviderPayload } from "./add-provider"; import { ChannelsContent } from "./channels-view"; import { CustomizeView } from "./customize-view"; +import { DebugContent } from "./debug-view"; +import { HubPanel } from "./hub-panel"; import { ImportContent } from "./import-view"; import { NotificationSettings } from "./notification-settings"; import { @@ -635,7 +637,9 @@ export function SettingsView({ ) : activeNav === "About" ? ( onNavigateSection("Customize")} /> ) : activeNav === "General" ? ( - + + ) : activeNav === "Debug" ? ( + ) : (

@@ -665,11 +669,7 @@ const ACCENT_OPTIONS: { id: HubAccent; label: string; swatch: string }[] = [ { id: "ember", label: "Ember", swatch: "oklch(0.6 0.19 33)" }, ]; -function GeneralSettingsContent({ - onExportDiagnostics, -}: { - onExportDiagnostics: () => void; -}) { +function GeneralSettingsContent() { const [theme, setTheme] = useState(() => { if (typeof window === "undefined") return "light"; return readStoredHubTheme() ?? readSystemHubTheme(); @@ -1021,27 +1021,7 @@ function GeneralSettingsContent({ Replay

-
-
-

- Diagnostics -

-

- Export app info, recent logs, and the metadata of sessions you - choose as a file you can attach when reporting a problem. -

-
- -
+
); diff --git a/apps/examples/desktop-app/webview/hooks/use-app-update.tsx b/apps/examples/desktop-app/webview/hooks/use-app-update.tsx index 61483ecab5..9eea238d6e 100644 --- a/apps/examples/desktop-app/webview/hooks/use-app-update.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-app-update.tsx @@ -117,6 +117,7 @@ function showUpdateReadyToast(version: string) { description: "The new version has been downloaded. Restart now, or later from the update button next to the Cline logo.", duration: Number.POSITIVE_INFINITY, + className: "flex-col items-start gap-3 space-x-0", action: ( ; + events: HubActivity[]; +} diff --git a/apps/vscode/.vscode-test.mjs b/apps/vscode/.vscode-test.mjs index e509190129..b7d2618b8f 100644 --- a/apps/vscode/.vscode-test.mjs +++ b/apps/vscode/.vscode-test.mjs @@ -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, }) diff --git a/apps/vscode/esbuild.mjs b/apps/vscode/esbuild.mjs index 1eb477c527..9df61747df 100644 --- a/apps/vscode/esbuild.mjs +++ b/apps/vscode/esbuild.mjs @@ -191,25 +191,20 @@ const e2eBuildConfig = { plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin], } -/** - * Plugins run in worker threads that load this self-contained bootstrap from - * disk, next to the bundle (core looks for it beside dist/extension.js). - * Shipped as .mjs because this package is not `"type": "module"`. Without it, - * core falls back to loading plugins inside the extension host. - */ -function copyPluginBootstrap() { - const source = path.resolve(__dirname, "../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js") - if (!fs.existsSync(source)) { - console.warn(`[esbuild] ${source} not found; plugins will load in-process`) - return +/** Copy core's complete plugin runtime; its own package.json provides ESM scope. */ +function copyPluginRuntime() { + const source = path.resolve(__dirname, "../../sdk/packages/core/dist/plugin-runtime") + if (!fs.existsSync(path.join(source, "plugin-sandbox-bootstrap.js"))) { + throw new Error(`[esbuild] ${source} not found; run bun run build:sdk first`) } - fs.mkdirSync(destDir, { recursive: true }) - fs.copyFileSync(source, path.join(destDir, "plugin-sandbox-bootstrap.mjs")) + const destination = path.join(destDir, "plugin-runtime") + fs.rmSync(destination, { recursive: true, force: true }) + fs.cpSync(source, destination, { recursive: true }) } async function main() { const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig - if (!e2eBuild) copyPluginBootstrap() + if (!e2eBuild) copyPluginRuntime() const extensionCtx = await esbuild.context(config) if (watch) { await extensionCtx.watch() diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 794e36be56..f362ed93f2 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -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", @@ -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": { diff --git a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules-toggles.test.ts b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules-toggles.test.ts index f19efde278..c929211186 100644 --- a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules-toggles.test.ts +++ b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules-toggles.test.ts @@ -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: { diff --git a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules.test.ts b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules.test.ts new file mode 100644 index 0000000000..3d1861f714 --- /dev/null +++ b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/cline-rules.test.ts @@ -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 { + // 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([["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") + }) +}) diff --git a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/frontmatter.test.ts b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/frontmatter.test.ts index cba674fce8..b69e605aae 100644 --- a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/frontmatter.test.ts +++ b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/frontmatter.test.ts @@ -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 { + const block = content.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "" + const data = (parseDocument(block).toJS() ?? {}) as Record + 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) + }) +}) diff --git a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/skills.test.ts b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/skills.test.ts index ded04ff0e7..f24dd83c8d 100644 --- a/apps/vscode/src/core/context/instructions/user-instructions/__tests__/skills.test.ts +++ b/apps/vscode/src/core/context/instructions/user-instructions/__tests__/skills.test.ts @@ -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")) diff --git a/apps/vscode/src/core/context/instructions/user-instructions/cline-rules.ts b/apps/vscode/src/core/context/instructions/user-instructions/cline-rules.ts index 5403bdb0bb..4be888687d 100644 --- a/apps/vscode/src/core/context/instructions/user-instructions/cline-rules.ts +++ b/apps/vscode/src/core/context/instructions/user-instructions/cline-rules.ts @@ -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 { + 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 { + 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): Promise { + 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, +): Promise { + 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, +): Promise { + 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) diff --git a/apps/vscode/src/core/context/instructions/user-instructions/frontmatter.ts b/apps/vscode/src/core/context/instructions/user-instructions/frontmatter.ts index 9becde9909..62115fb6fd 100644 --- a/apps/vscode/src/core/context/instructions/user-instructions/frontmatter.ts +++ b/apps/vscode/src/core/context/instructions/user-instructions/frontmatter.ts @@ -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 @@ -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): boolean { + if (typeof data.disabled === "boolean") { + return data.disabled + } + return data.enabled === false +} + +export type UserInstructionKind = "rule" | "skill" + +const ENABLEMENT_KEYS: ReadonlyArray = ["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 { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} +} + +/** The top-level values other than the enablement flags, as the SDK reads them. */ +function otherFrontmatterValues(content: string, kind: UserInstructionKind): Record | 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}` +} diff --git a/apps/vscode/src/core/context/instructions/user-instructions/skills.ts b/apps/vscode/src/core/context/instructions/user-instructions/skills.ts index c10c65b0ec..48eda9aaa6 100644 --- a/apps/vscode/src/core/context/instructions/user-instructions/skills.ts +++ b/apps/vscode/src/core/context/instructions/user-instructions/skills.ts @@ -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= (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, 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) } diff --git a/apps/vscode/src/core/controller/file/__tests__/toggleClineRule.test.ts b/apps/vscode/src/core/controller/file/__tests__/toggleClineRule.test.ts new file mode 100644 index 0000000000..a82685c608 --- /dev/null +++ b/apps/vscode/src/core/controller/file/__tests__/toggleClineRule.test.ts @@ -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 = {} + const globalState = new Map([ + ["globalClineRulesToggles", {}], + ["remoteRulesToggles", {}], + ]) + const workspaceState = new Map([["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("{}") + }) +}) diff --git a/apps/vscode/src/core/controller/file/toggleClineRule.ts b/apps/vscode/src/core/controller/file/toggleClineRule.ts index d5bc14b099..d87c8af564 100644 --- a/apps/vscode/src/core/controller/file/toggleClineRule.ts +++ b/apps/vscode/src/core/controller/file/toggleClineRule.ts @@ -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) diff --git a/apps/vscode/src/core/controller/marketplace/marketplace-helpers.ts b/apps/vscode/src/core/controller/marketplace/marketplace-helpers.ts index 418ab1ad3b..3e7ff572a9 100644 --- a/apps/vscode/src/core/controller/marketplace/marketplace-helpers.ts +++ b/apps/vscode/src/core/controller/marketplace/marketplace-helpers.ts @@ -16,6 +16,7 @@ import { parseMcpInstallArgs, readGlobalSettings, resolvePluginConfigSearchPaths, + SKILLS_CLI_PACKAGE, setDisabledPlugin, syncPluginMcpServersToSettings, uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry, @@ -366,7 +367,7 @@ async function installPluginMarketplaceEntry(entry: MarketplaceEntry, args: stri async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise { const command = "npx" - const commandArgs = ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"] + const commandArgs = ["-y", SKILLS_CLI_PACKAGE, "add", ...args, "-g", "-a", "cline", "-y"] const displayCommand = formatCommand(command, commandArgs) let result: SpawnResult try { diff --git a/apps/vscode/src/hosts/vscode/hostbridge/window/getOpenTabs.test.ts b/apps/vscode/src/hosts/vscode/hostbridge/window/getOpenTabs.test.ts index a57ff306bf..65da0f8429 100644 --- a/apps/vscode/src/hosts/vscode/hostbridge/window/getOpenTabs.test.ts +++ b/apps/vscode/src/hosts/vscode/hostbridge/window/getOpenTabs.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, it } from "mocha" import { strict as assert } from "assert" import * as fs from "fs/promises" +import { afterEach, beforeEach, describe, it } from "mocha" import * as os from "os" import pWaitFor from "p-wait-for" import * as path from "path" @@ -9,23 +9,16 @@ import { getOpenTabs } from "@/hosts/vscode/hostbridge/window/getOpenTabs" import { GetOpenTabsRequest } from "@/shared/proto/host/window" describe("Hostbridge - Window - getOpenTabs", () => { - async function createAndOpenTestDocument(name: string, column: vscode.ViewColumn): Promise { - const content = `// Test file ${name}\nconsole.log('Hello from file ${name}');` - - // Create an untitled document with a custom name - const uri = vscode.Uri.parse(`untitled:test-file-${name}.js`) - - const doc = await vscode.workspace.openTextDocument(uri) - - // Set the content - const edit = new vscode.WorkspaceEdit() - edit.insert(uri, new vscode.Position(0, 0), content) - await vscode.workspace.applyEdit(edit) - - await vscode.window.showTextDocument(doc, { - viewColumn: column, - preview: false, + async function createAndOpenTestDocument(name: string, column: vscode.ViewColumn): Promise { + // Populate an unnamed untitled document before showing it. Applying a + // WorkspaceEdit to a named untitled URI can open it in the active group + // before showTextDocument opens a second copy in the requested group. + const doc = await vscode.workspace.openTextDocument({ + language: "javascript", + content: `// Test file ${name}\nconsole.log('Hello from file ${name}');`, }) + await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false }) + return doc } async function waitForAllTabsClosed(): Promise { @@ -68,8 +61,8 @@ describe("Hostbridge - Window - getOpenTabs", () => { it("should return paths of open text document tabs", async () => { // Open the documents in editors (this creates the tabs) - await createAndOpenTestDocument("open-tabs-1", vscode.ViewColumn.One) - await createAndOpenTestDocument("open-tabs-2", vscode.ViewColumn.Two) + const first = await createAndOpenTestDocument("open-tabs-1", vscode.ViewColumn.One) + const second = await createAndOpenTestDocument("open-tabs-2", vscode.ViewColumn.Two) // Wait for tabs to be fully created await pWaitFor( @@ -90,6 +83,8 @@ describe("Hostbridge - Window - getOpenTabs", () => { const request = GetOpenTabsRequest.create({}) const response = await getOpenTabs(request) + assert.deepStrictEqual([...response.paths].sort(), [first.uri.fsPath, second.uri.fsPath].sort()) + // Should have 2 tabs open assert.strictEqual( response.paths.length, @@ -98,6 +93,15 @@ describe("Hostbridge - Window - getOpenTabs", () => { ) }) + it("should return a tab for each group when the same document is open twice", async () => { + const doc = await createAndOpenTestDocument("two-groups", vscode.ViewColumn.One) + await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Two, preview: false }) + const request = GetOpenTabsRequest.create({}) + await pWaitFor(async () => (await getOpenTabs(request)).paths.length === 2, { timeout: 8000, interval: 50 }) + const response = await getOpenTabs(request) + assert.deepStrictEqual(response.paths, [doc.uri.fsPath, doc.uri.fsPath]) + }) + it("should return all open tabs even when multiple files are opened in the same ViewColumn", async () => { // Open all documents in the same column (only the last one will be visible, but all are open as tabs) await createAndOpenTestDocument("same-column-1", vscode.ViewColumn.One) diff --git a/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.test.ts b/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.test.ts index 8cbd172734..1cd60a6aab 100644 --- a/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.test.ts +++ b/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.test.ts @@ -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 { @@ -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[0], "echo hi") + + sinon.assert.calledOnceWithExactly(showStub, true) }) it("releases a reused terminal reservation when showing it fails", async () => { diff --git a/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.ts b/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.ts index 80cda975eb..eda39fdeb6 100644 --- a/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.ts +++ b/apps/vscode/src/hosts/vscode/terminal/VscodeTerminalManager.ts @@ -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 { diff --git a/apps/vscode/src/sdk/SdkController.test.ts b/apps/vscode/src/sdk/SdkController.test.ts index 08b0fe620b..f9cbda8780 100644 --- a/apps/vscode/src/sdk/SdkController.test.ts +++ b/apps/vscode/src/sdk/SdkController.test.ts @@ -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) => 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) => { + 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) => operation(), + }, stateManager, - } + }) await expect( SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 1, text: "edited" }), diff --git a/apps/vscode/src/sdk/SdkController.ts b/apps/vscode/src/sdk/SdkController.ts index c58dafa751..faf337929b 100644 --- a/apps/vscode/src/sdk/SdkController.ts +++ b/apps/vscode/src/sdk/SdkController.ts @@ -1764,6 +1764,18 @@ export class Controller { images?: string[] files?: string[] restoreWorkspace?: boolean + }): Promise { + // 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 { 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 { + // 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 { const restoreMessages = input.restoreType === "task" || input.restoreType === "taskAndWorkspace" const restoreWorkspace = input.restoreType === "workspace" || input.restoreType === "taskAndWorkspace" const checkpointRunCount = Number(input.checkpointRunCount) diff --git a/apps/vscode/src/sdk/cline-session-factory.ts b/apps/vscode/src/sdk/cline-session-factory.ts index 126d001ed3..5645f114ab 100644 --- a/apps/vscode/src/sdk/cline-session-factory.ts +++ b/apps/vscode/src/sdk/cline-session-factory.ts @@ -90,6 +90,12 @@ export interface ActiveSession { sessionId: string /** The config used to start the active session. */ startConfig?: Pick + /** + * 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 */ diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts index e65f19eee3..7cd533a1cb 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts @@ -416,6 +416,117 @@ describe("SdkInteractionCoordinator", () => { await expect(answerPromise).resolves.toBe("") }) + it("accepts an ask_question response as soon as the ask is emitted", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const messages = new SdkMessageCoordinator({ getTask: () => task }) + const setTurnPhase = vi.fn() + const coordinator = new SdkInteractionCoordinator({ + messages, + getSessionId: () => "session-123", + postStateToWebview: vi.fn(() => new Promise(() => {})), + setTurnPhase, + }) + let accepted = false + messages.onSessionEvent(() => { + if (task.messageStateHandler.getClineMessages().length === 1) { + accepted = coordinator.resolvePendingAskQuestion("yes") + } + }) + + const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) + + expect(accepted).toBe(true) + await expect(answer).resolves.toBe("yes") + expect(setTurnPhase).toHaveBeenLastCalledWith("streaming") + }) + + it.each(["Task cancelled", "Task switched"])("settles ask_question during its state post on %s", async (reason) => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + let releasePost!: () => void + const postStateToWebview = vi.fn( + () => + new Promise((resolve) => { + releasePost = resolve + }), + ) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview, + }) + let result: Awaited> | undefined + const answer = coordinator.handleAskQuestion("Continue?", [], undefined).then((value) => { + result = value + }) + await vi.waitFor(() => expect(postStateToWebview).toHaveBeenCalledOnce()) + + coordinator.clearPending(reason) + await vi.waitFor(() => expect(result).toBe("")) + expect(task.messageStateHandler.getClineMessages()).toHaveLength(1) + + releasePost() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(coordinator.resolvePendingAskQuestion("new task input")).toBe(false) + await answer + }) + + it.each(["resolves", "rejects"])("keeps a later ask when a cleared ask's state post %s", async (outcome) => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + let releaseFirstPost!: () => void + let rejectFirstPost!: (error: Error) => void + const postStateToWebview = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve, reject) => { + releaseFirstPost = resolve + rejectFirstPost = reject + }), + ) + .mockResolvedValue(undefined) + const setTurnPhase = vi.fn() + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview, + setTurnPhase, + }) + const firstAnswer = coordinator.handleAskQuestion("Old question?", [], undefined) + await vi.waitFor(() => expect(postStateToWebview).toHaveBeenCalledTimes(1)) + coordinator.clearPending("Task switched") + await expect(firstAnswer).resolves.toBe("") + + const secondAnswer = coordinator.handleAskQuestion("New question?", [], undefined) + await vi.waitFor(() => expect(postStateToWebview).toHaveBeenCalledTimes(2)) + const secondAskTs = task.messageStateHandler.getClineMessages()[1].ts + if (outcome === "resolves") { + releaseFirstPost() + } else { + rejectFirstPost(new Error("Task changed while building webview state")) + } + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(setTurnPhase).toHaveBeenLastCalledWith("awaiting_followup", secondAskTs) + expect(coordinator.resolvePendingAskQuestion("new answer")).toBe(true) + await expect(secondAnswer).resolves.toBe("new answer") + }) + + it("rejects and clears the ask whose state post fails", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const error = new Error("Failed to build webview state") + const setTurnPhase = vi.fn() + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockRejectedValue(error), + setTurnPhase, + }) + + await expect(coordinator.handleAskQuestion("Continue?", [], undefined)).rejects.toBe(error) + expect(coordinator.resolvePendingAskQuestion("late answer")).toBe(false) + expect(setTurnPhase).toHaveBeenLastCalledWith("streaming") + }) + it("shows an error row and stops immediately when the mistake limit is reached", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const setTurnPhase = vi.fn() diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts index 4d176f579e..bca66b0a3a 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts @@ -187,16 +187,36 @@ export class SdkInteractionCoordinator { partial: false, } + // Install before emitting the ask so responses and task teardown can settle it + // even while the full webview state is still being built. + let resolveAnswer!: (answer: AskQuestionAnswer) => void + let rejectAnswer!: (reason: unknown) => void + const answer = new Promise((resolve, reject) => { + resolveAnswer = resolve + rejectAnswer = reject + }) + this.pendingAskResolve = resolveAnswer + this.options.setTurnPhase?.("awaiting_followup", askMessage.ts) + this.options.messages.appendAndEmit([askMessage], { type: "status", payload: { sessionId: this.options.getSessionId(), status: "running" }, }) - this.options.setTurnPhase?.("awaiting_followup", askMessage.ts) - await this.options.postStateToWebview() - return new Promise((resolve) => { - this.pendingAskResolve = resolve - }) + void Promise.resolve() + .then(() => this.options.postStateToWebview()) + .catch((error) => { + Logger.warn(`[SdkController] Failed to display question: ${error}`) + // A cleared/answered ask may already have been replaced by a new one. + if (this.pendingAskResolve !== resolveAnswer) { + return + } + this.pendingAskResolve = undefined + this.options.setTurnPhase?.("streaming") + rejectAnswer(error) + }) + + return answer } resolvePendingToolApproval( diff --git a/apps/vscode/src/sdk/sdk-mode-coordinator.test.ts b/apps/vscode/src/sdk/sdk-mode-coordinator.test.ts index 9a22b8fdf1..b8ce40bec0 100644 --- a/apps/vscode/src/sdk/sdk-mode-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-mode-coordinator.test.ts @@ -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((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 = {}) { }), fireAndForgetSend: vi.fn(), setRunning: vi.fn(), + waitForSessionStarts: vi.fn().mockResolvedValue(undefined), }, interactions: { clearPending: vi.fn(), @@ -836,6 +934,7 @@ function makeCoordinator(input: Partial = {}) { fireAndForgetSend: ReturnType replaceActiveSession: ReturnType setRunning: ReturnType + waitForSessionStarts: ReturnType } interactions: SdkModeCoordinatorOptions["interactions"] & { clearPending: ReturnType @@ -863,6 +962,10 @@ function makeCoordinator(input: Partial = {}) { onAutoContinueFailed: ReturnType } + if (input.serializeRebuilds) { + options.rebuilds = new SdkSessionRebuildScheduler({ sessions: options.sessions }) + } + return { coordinator: new SdkModeCoordinator(options), options, @@ -880,11 +983,16 @@ interface MakeCoordinatorInput { } task: ReturnType turnPhase: string + /** Use the real rebuild lock instead of a pass-through. */ + serializeRebuilds: boolean } -function makeActiveSession(input: { isRunning?: boolean } = {}) { +type ActiveSessionFixture = ReturnType + +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, } } diff --git a/apps/vscode/src/sdk/sdk-mode-coordinator.ts b/apps/vscode/src/sdk/sdk-mode-coordinator.ts index 3a81ef3752..e849a627a1 100644 --- a/apps/vscode/src/sdk/sdk-mode-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-mode-coordinator.ts @@ -111,17 +111,29 @@ export class SdkModeCoordinator { } async togglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise { - 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 { + 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 { - 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 diff --git a/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts b/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts index 43bcca6aed..b3c51df89b 100644 --- a/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts +++ b/apps/vscode/src/sdk/sdk-session-lifecycle.test.ts @@ -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((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((_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) diff --git a/apps/vscode/src/sdk/sdk-session-lifecycle.ts b/apps/vscode/src/sdk/sdk-session-lifecycle.ts index 5ce21c68c0..33a3146b7f 100644 --- a/apps/vscode/src/sdk/sdk-session-lifecycle.ts +++ b/apps/vscode/src/sdk/sdk-session-lifecycle.ts @@ -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>() 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(operation: () => Promise): Promise { + 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 { + 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, diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts index 42aaf2e99b..d217ffb1fa 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts @@ -35,6 +35,33 @@ describe("SdkTaskControlCoordinator", () => { expect(options.postStateToWebview).toHaveBeenCalledOnce() }) + it("settles a question on cancel even while its state post is still in flight", async () => { + const pendingTask = createTaskProxy("session-123", vi.fn(), vi.fn()) + let releaseQuestionPost!: () => void + const questionPost = vi.fn( + () => + new Promise((resolve) => { + releaseQuestionPost = resolve + }), + ) + const interactions = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => pendingTask }), + getSessionId: () => pendingTask.taskId, + postStateToWebview: questionPost, + }) + const { options } = makeCoordinator({ activeSession: makeActiveSession() }) + const coordinator = new SdkTaskControlCoordinator({ ...options, interactions }) + const questionPromise = interactions.handleAskQuestion("Which option?", ["A", "B"], undefined) + await vi.waitFor(() => expect(questionPost).toHaveBeenCalledOnce()) + + await coordinator.cancelTask() + + await expect(questionPromise).resolves.toBe("") + releaseQuestionPost() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(interactions.resolvePendingAskQuestion("input after cancel")).toBe(false) + }) + it("cancels a running Cline task when the user signs out", async () => { const activeSession = makeActiveSession() const { coordinator, options } = makeCoordinator({ activeSession }) diff --git a/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts index 280861a961..b22f533054 100644 --- a/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-start-coordinator.test.ts @@ -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) => { + 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 = {}) { sdkHost, })), fireAndForgetSend: vi.fn(), + trackSessionStart: vi.fn((operation: () => Promise) => operation()), }, messages: { appendAndEmit: vi.fn(), @@ -315,6 +347,7 @@ function makeCoordinator(input: Partial = {}) { sessions: SdkTaskStartCoordinatorOptions["sessions"] & { startNewSession: ReturnType fireAndForgetSend: ReturnType + trackSessionStart: ReturnType } messages: SdkTaskStartCoordinatorOptions["messages"] & { appendAndEmit: ReturnType diff --git a/apps/vscode/src/sdk/sdk-task-start-coordinator.ts b/apps/vscode/src/sdk/sdk-task-start-coordinator.ts index e88ad1ca91..adae1e7557 100644 --- a/apps/vscode/src/sdk/sdk-task-start-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-task-start-coordinator.ts @@ -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, diff --git a/apps/vscode/src/sdk/vscode-runtime-builder.test.ts b/apps/vscode/src/sdk/vscode-runtime-builder.test.ts index 1b29c21063..be4edc99fd 100644 --- a/apps/vscode/src/sdk/vscode-runtime-builder.test.ts +++ b/apps/vscode/src/sdk/vscode-runtime-builder.test.ts @@ -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]) + }) }) diff --git a/apps/vscode/src/sdk/vscode-runtime-builder.ts b/apps/vscode/src/sdk/vscode-runtime-builder.ts index 80afb09c98..52eccaaf86 100644 --- a/apps/vscode/src/sdk/vscode-runtime-builder.ts +++ b/apps/vscode/src/sdk/vscode-runtime-builder.ts @@ -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 + _meta?: Record } 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 { + return this.mcpHub.readResource(request.serverName, request.uri, request.context?.signal) + } } export interface VscodeExtraToolsOptions { diff --git a/apps/vscode/src/services/mcp/McpHub.ts b/apps/vscode/src/services/mcp/McpHub.ts index 4d90235052..22ae2b3178 100644 --- a/apps/vscode/src/services/mcp/McpHub.ts +++ b/apps/vscode/src/services/mcp/McpHub.ts @@ -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 { + 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 diff --git a/apps/vscode/src/services/mcp/__tests__/McpHub.readResource.test.ts b/apps/vscode/src/services/mcp/__tests__/McpHub.readResource.test.ts new file mode 100644 index 0000000000..338835d081 --- /dev/null +++ b/apps/vscode/src/services/mcp/__tests__/McpHub.readResource.test.ts @@ -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: "" }, + ], + }), + }) + 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/) + }) +}) diff --git a/apps/vscode/src/shared/mcp.ts b/apps/vscode/src/shared/mcp.ts index 4b1f0e66d1..e6dd0cbdfe 100644 --- a/apps/vscode/src/shared/mcp.ts +++ b/apps/vscode/src/shared/mcp.ts @@ -35,6 +35,7 @@ export type McpTool = { description?: string inputSchema?: object autoApprove?: boolean + _meta?: Record } export type McpResource = { diff --git a/apps/vscode/src/test/bun-test-preload.ts b/apps/vscode/src/test/bun-test-preload.ts index 679d299ac9..34e9399471 100644 --- a/apps/vscode/src/test/bun-test-preload.ts +++ b/apps/vscode/src/test/bun-test-preload.ts @@ -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 = {} -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. diff --git a/apps/vscode/src/test/cline-core-vitest-stub.ts b/apps/vscode/src/test/cline-core-vitest-stub.ts index c377b45c76..32934cf663 100644 --- a/apps/vscode/src/test/cline-core-vitest-stub.ts +++ b/apps/vscode/src/test/cline-core-vitest-stub.ts @@ -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 diff --git a/apps/vscode/src/test/e2e/utils/helpers.ts b/apps/vscode/src/test/e2e/utils/helpers.ts index f2e40a672a..89e55908bf 100644 --- a/apps/vscode/src/test/e2e/utils/helpers.ts +++ b/apps/vscode/src/test/e2e/utils/helpers.ts @@ -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, maxDelay = 10000): Promise { let delay = 10 const start = Date.now() @@ -412,9 +381,7 @@ export const e2e = test }) .extend<{ openVSCode: (workspacePath: string) => Promise }>({ 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 diff --git a/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx b/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx index 59bfebd7dc..ff50019ce5 100644 --- a/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx +++ b/apps/vscode/webview-ui/src/components/chat/task-header/TaskHeader.tsx @@ -160,7 +160,7 @@ const TaskHeader: React.FC = ({ {/* Task Title */}
{ if (e.key === "Enter" || e.key === " ") { @@ -170,49 +170,52 @@ const TaskHeader: React.FC = ({ } }} tabIndex={0}> -
- {isTaskExpanded ? : } - {isTaskExpanded && ( -
- - - {/* Only visible in development mode */} - {IS_DEV && ( - - )} -
- )} -
-
+
+
+ {isTaskExpanded ? : } + {isTaskExpanded && ( +
+ + + {/* Only visible in development mode */} + {IS_DEV && ( + + )} +
+ )} +
{!isTaskExpanded && ( -
+
{highlightedText}
)} +
+ {currentCloudTask ? ( + + ) : ( + + )} + {isCostAvailable && ( +
+ ${totalCost?.toFixed(4)} +
+ )} +
-
- {currentCloudTask ? ( - - ) : ( - - )} - {isCostAvailable && ( -
- ${totalCost?.toFixed(4)} -
- )} - -
+
{/* Expand/Collapse Task Details */} diff --git a/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx b/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx index d4b18671c0..8896e448d8 100644 --- a/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx +++ b/apps/vscode/webview-ui/src/components/cloud/CloudTaskBadge.tsx @@ -20,7 +20,7 @@ export function CloudTaskBadge({ cloudTask }: { cloudTask: CurrentCloudTaskInfo