From b6efd89ae569164216504396a56fa1dee8ae2e6e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 08:51:35 -0700 Subject: [PATCH] FN-9097: add Cursor CLI execution runtime Add a supervised Cursor CLI runtime for routed Cursor model sessions. - Stream Cursor prompt output into Fusion session callbacks with resume support. - Support Windows Cursor shims, inactivity-based supervision, and cursor-agent to cursor fallback execution. - Route Cursor CLI models through the bundled plugin and document the runtime contract. Files changed: .changeset/fn-9097-cursor-cli-runtime.md | 7 ++ docs/cursor-cli-contract.md | 21 ++++ docs/settings-reference.md | 4 +- .../cli-runtime-routing-conformance.test.ts | 11 +- .../engine/src/agents/agent-session-helpers.ts | 6 +- packages/engine/src/agents/cli-provider-routing.ts | 27 ++--- plugins/fusion-plugin-cursor-runtime/README.md | 21 ++++ plugins/fusion-plugin-cursor-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 25 ++++- .../src/__tests__/prompt-transport.test.ts | 58 ++++++++++ .../src/__tests__/runtime-adapter.test.ts | 44 +++++--- .../src/__tests__/stream-parser.test.ts | 12 +++ .../fusion-plugin-cursor-runtime/src/cli-spawn.ts | 57 +++++++++- plugins/fusion-plugin-cursor-runtime/src/index.ts | 4 +- .../src/prompt-transport.ts | 120 +++++++++++++++++++++ .../src/runtime-adapter.ts | 59 ++++++---- .../src/stream-parser.ts | 43 ++++++++ plugins/fusion-plugin-cursor-runtime/src/types.ts | 18 ++-- pnpm-lock.yaml | 3 + 19 files changed, 458 insertions(+), 83 deletions(-) Fusion-Task-Id: FN-9097 Fusion-Task-Lineage: a4eed861-6059-4528-9f80-3426f5ccad58 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9097-cursor-cli-runtime.md | 7 + docs/cursor-cli-contract.md | 21 +++ docs/settings-reference.md | 4 +- .../cli-runtime-routing-conformance.test.ts | 11 +- .../src/agents/agent-session-helpers.ts | 6 +- .../engine/src/agents/cli-provider-routing.ts | 27 ++-- .../fusion-plugin-cursor-runtime/README.md | 21 +++ .../fusion-plugin-cursor-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 25 +++- .../src/__tests__/prompt-transport.test.ts | 58 +++++++++ .../src/__tests__/runtime-adapter.test.ts | 44 +++++-- .../src/__tests__/stream-parser.test.ts | 12 ++ .../src/cli-spawn.ts | 57 ++++++++- .../fusion-plugin-cursor-runtime/src/index.ts | 4 +- .../src/prompt-transport.ts | 120 ++++++++++++++++++ .../src/runtime-adapter.ts | 59 ++++++--- .../src/stream-parser.ts | 43 +++++++ .../fusion-plugin-cursor-runtime/src/types.ts | 18 +-- pnpm-lock.yaml | 3 + 19 files changed, 458 insertions(+), 83 deletions(-) create mode 100644 .changeset/fn-9097-cursor-cli-runtime.md create mode 100644 plugins/fusion-plugin-cursor-runtime/src/__tests__/prompt-transport.test.ts create mode 100644 plugins/fusion-plugin-cursor-runtime/src/__tests__/stream-parser.test.ts create mode 100644 plugins/fusion-plugin-cursor-runtime/src/prompt-transport.ts create mode 100644 plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts diff --git a/.changeset/fn-9097-cursor-cli-runtime.md b/.changeset/fn-9097-cursor-cli-runtime.md new file mode 100644 index 0000000000..96d6235306 --- /dev/null +++ b/.changeset/fn-9097-cursor-cli-runtime.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Run selected Cursor CLI models through Fusion's supervised runtime. +category: feature +dev: Routes cursor-cli to the Cursor runtime, retires cursorCliExecutionSupported, and uses supervised stream-json turns. diff --git a/docs/cursor-cli-contract.md b/docs/cursor-cli-contract.md index 57d549ce49..7f5d10333d 100644 --- a/docs/cursor-cli-contract.md +++ b/docs/cursor-cli-contract.md @@ -116,3 +116,24 @@ The original FN-3396 preflight treated the following as canonical pending strong - Model discovery must be dynamic-first with resilient fallback and no hardcoded static catalog by default. Binary candidates and expected failure states above remain accurate. The dynamic-first/no-static-catalog principle also still holds, but the specific commands are now confirmed rather than assumed — see "Structured output and model discovery" and "Windows PATH shim invocation" above for the verified `cursor-agent models` / `cursor-agent status --format json` contract that replaces the earlier `--json`-flag guesswork. + + + +**Update history:** 2026-08-15 — FN-9097 verified the execution transport and added the supervised Windows launch contract. + +## Execution transport contract + +External integration evidence: Cursor CLI is closed-source with no canonical upstream source repository; public tracker: https://github.com/cursor/cursor. Docs: https://cursor.com/docs/cli/overview. Installation: `curl https://cursor.com/install -fsS | bash` or `irm 'https://cursor.com/install?win32=true' | iex`. Binary: `cursor-agent`. Checksum: `upstream-pending-verification` because Cursor publishes no per-release manifest; local observed version: `2026.08.11-e8db854`. + +On 2026-08-15, in an external scratch directory, `printf 'Reply with exactly: ok\\n' | cursor-agent --print --output-format stream-json --force --trust --model auto` exited 0 and emitted NDJSON `system/init`, `user`, `thinking`, `assistant`, and `result` events. The init event reported the process cwd, proving no `--workspace` argument is needed. A `--mode plan --trust` run read a scratch file, emitted a `readToolCall` started/completed pair, and terminated without `--force`. `-p` help states it has write/shell tool access; `--force` controls approvals, while `--trust` clears workspace trust. `--sandbox` was left config-driven. `--resume ` and `--model ` are supported; no system-prompt flag exists, so the first prompt contains the fused system context. `mcp --help` confirms MCP configuration is `.cursor/mcp.json`-based and has no `--mcp-config` flag. + +Contract supports non-interactive execution: **yes**. The transport maps coding to `--force --trust`; readonly and unset tools to `--mode plan --trust`, never `--force`. `--stream-partial-output` is omitted: assistant messages are deduplicated by emitted content because Cursor can emit the same response with and without `model_call_id`. + +### Streaming Windows launch decision + +Prompt turns always use `superviseSpawn` with `shell:false`, a first-line deadline, and an inactivity deadline reset by every stream line; active output has no total-duration cap. When no binary override is configured, an absent `cursor-agent` launch retries the documented `cursor` PATH fallback. They prefer direct executable targets, send the prompt on stdin, and omit `--workspace`. A cmd shim is the sole cmd boundary: each token is validated then rejected for `" % ! ^ & | < > ( )` and controls before quote-only escaping, ComSpec must be an absolute `cmd.exe`, and command lines over 8000 characters fail. `windowsVerbatimArguments` is used only there (it had no prior repository call site). A `.ps1` target uses PowerShell `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File`; unknown extensions fail. + +This avoids cmd's re-parse (Node argv escaping is not cmd-safe), Node's refusal to direct-spawn batch files, and launcher-only kills that orphan agents. Resolution honors the first `where.exe` directory and PATHEXT only within it. POSIX teardown uses the supervisor process group; Windows additionally invokes `taskkill /T /F`. Windows PATH shim shape remains unverified on this macOS host, so launch is shape-agnostic. Probe/discovery retains its existing shell-backed behavior described above; only streaming turns use this hardened branch. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index bca92e7087..5b90697ae1 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1979,10 +1979,10 @@ Fusion routes picker providers through `packages/engine/src/agents/cli-provider- | `grok-cli` | Plugin runtime `grok` | `grok`; missing plugin fails fast only for no-visible-key primary routing | fail-fast / pinned pi fallback / defer to `resolveRuntime` | defer to runtime | | `hermes` | Plugin runtime `hermes` | `hermes`; missing plugin names Hermes installation and login remediation | fail-fast / pinned pi fallback / assert available | drop fallback with warning | | `claude-cli` | Plugin runtime `claude` | Claude Code; missing plugin names Claude install/auth remediation | fail-fast / pinned pi fallback / assert available | drop fallback with warning | -| `cursor-cli` | Withheld unsupported | `cursor-agent`; this build's stub transport fails fast with a Cursor-named plugin remediation | fail-fast / pinned pi fallback / assert available | none | +| `cursor-cli` | Plugin runtime `cursor` | `cursor-agent`; missing plugin names Cursor install/auth remediation | fail-fast / pinned pi fallback / assert available | drop fallback with warning | A `grok-cli` primary selection without a Fusion-visible `GROK_API_KEY` uses the Grok runtime and fails fast if it is absent. With a visible key, as a fallback-only selection, or under an explicit `runtimeHint: "grok"`, Grok deliberately retains the shipped pi/direct-xAI fallback behavior. This is intentional and differs from OMP's explicit-hint reassertion. -Cursor support detection does not make the current `TODO(FN-3396)` adapter executable: this engine seam owns the Cursor-named fail-fast result for both support-predicate values. See the [Cursor runtime plugin README](../plugins/fusion-plugin-cursor-runtime/README.md), [Hermes README](../plugins/fusion-plugin-hermes-runtime/README.md), and [Claude README](../plugins/fusion-plugin-claude-runtime/README.md) for operator installation details. +Cursor selections route to the verified `cursor` plugin runtime, which runs supervised `cursor-agent --print --output-format stream-json` turns. A missing runtime still fails fast with Cursor installation and authentication remediation; a fallback-only Cursor selection is dropped with a warning rather than delegated to pi. See the [Cursor runtime plugin README](../plugins/fusion-plugin-cursor-runtime/README.md), [Hermes README](../plugins/fusion-plugin-hermes-runtime/README.md), and [Claude README](../plugins/fusion-plugin-claude-runtime/README.md) for operator installation details. When adding a picker provider, add a census entry with `autoDerive`, `guardNotApplicable`, and `onExplicitHint` policies. The blocking `check-cli-runtime-routing` static gate parses picker admission and fails on an unclassified, stale, or policy-incomplete entry. diff --git a/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts b/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts index d816c2b814..33314642a6 100644 --- a/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts +++ b/packages/engine/src/__tests__/cli-runtime-routing-conformance.test.ts @@ -128,7 +128,6 @@ describe("CLI provider routing conformance", () => { await expect(createResolvedAgentSession(options(entry, { runtimeHint, pluginRunner: runner(runtimeHint, availability), - cursorCliExecutionSupported: true, }))).rejects.toThrow(/Cursor CLI/); } return; @@ -195,16 +194,14 @@ describe("CLI provider routing conformance", () => { const result = await createResolvedAgentSession(options(entry, { pluginRunner, settings: { testMode: true }, - cursorCliExecutionSupported: true, })); expect(result.runtimeId).toBe("mock"); expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled(); }); - it.each([false, true])("injects Cursor support=%s into the withheld Cursor session seam", async (cursorCliExecutionSupported) => { - await expect(createResolvedAgentSession(options( - CLI_PROVIDER_ROUTING_CENSUS.find((entry) => entry.providerId === "cursor-cli"), - { cursorCliExecutionSupported, runtimeHint: "cursor", pluginRunner: runner("cursor") }, - ))).rejects.toThrow(/Cursor CLI/); + it("routes Cursor primary selection through its installed runtime", async () => { + const entry = CLI_PROVIDER_ROUTING_CENSUS.find((candidate) => candidate.providerId === "cursor-cli"); + const result = await createResolvedAgentSession(options(entry, { runtimeHint: "cursor", pluginRunner: runner("cursor") })); + expect(result.runtimeId).toBe("cursor"); }); }); diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index be4e3c2365..69a5621ab2 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -140,7 +140,6 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions { * Injected Cursor support status for routing conformance. Production leaves it * unset while the bundled Cursor adapter remains a non-executable stub. */ - cursorCliExecutionSupported?: boolean; /** * Optional run-audit emitter; when provided, a `session:runtime-resolved` * database event is recorded at resolution time. No-ops when omitted to @@ -783,7 +782,7 @@ export function resolveMergerSessionModel( export async function createResolvedAgentSession( options: ResolvedSessionOptions, ): Promise { - const { sessionPurpose, pluginRunner, runtimeHint, cursorCliExecutionSupported, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options; + const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options; let credentialResolution: ReturnType | undefined; if (requestedCredentialInstanceId) { try { @@ -890,13 +889,12 @@ export async function createResolvedAgentSession( runtimeOptions, pluginRunner, grokApiKeyVisible: isGrokApiKeyFusionVisible(), - cursorCliExecutionSupported, }) : undefined; const effectiveRuntimeHint = autoCliRuntimeHint ?? runtimeHint; if (!useMockRuntime) { // Explicit CLI hints with assert-available policy must pre-empt resolveRuntime's pi fallback. - assertExplicitCliRuntimeHint({ runtimeHint, runtimeOptions, pluginRunner, cursorCliExecutionSupported }); + assertExplicitCliRuntimeHint({ runtimeHint, runtimeOptions, pluginRunner }); } const usesAutoGrokRuntime = autoCliRuntimeHint === "grok" && runtimeOptions.defaultProvider === GROK_CLI_PROVIDER_ID; /* diff --git a/packages/engine/src/agents/cli-provider-routing.ts b/packages/engine/src/agents/cli-provider-routing.ts index 2740118515..394557a660 100644 --- a/packages/engine/src/agents/cli-provider-routing.ts +++ b/packages/engine/src/agents/cli-provider-routing.ts @@ -44,11 +44,8 @@ export function buildMissingClaudeRuntimeError(): Error { return unavailable("Claude Code CLI", "Install and enable the Claude runtime plugin (fusion-plugin-claude-runtime), install Claude Code, and authenticate with `claude`."); } -export function buildMissingCursorRuntimeError(cursorCliExecutionSupported = false): Error { - const transportDetail = cursorCliExecutionSupported - ? "The host reports Cursor CLI support, but this build has no executable Cursor CLI transport." - : "This build has no executable Cursor CLI transport."; - return unavailable("Cursor CLI", `Install and enable the Cursor runtime plugin (fusion-plugin-cursor-runtime). ${transportDetail}`); +export function buildMissingCursorRuntimeError(): Error { + return unavailable("Cursor CLI", "Install and enable the Cursor runtime plugin (fusion-plugin-cursor-runtime), install `cursor-agent`, and authenticate with `cursor-agent login`."); } /* @@ -61,10 +58,9 @@ while visible-key, fallback-only, and explicit-hint paths retain their shipped pi fallback. Factory failures remain observations because read-only resolveRuntime decides them after this seam confirms a registration exists. -Cursor Branch C applies on this revision: the support predicate is absent and -the adapter remains TODO(FN-3396), so both injectable support states fail fast -at this seam rather than claiming the stub can execute prompts. Completeness is -checked by a repository static guard instead of importing dashboard from engine. +FN-9097 verified the supervised cursor-agent stream-json transport. Cursor now +uses the same runtime-routed contract as Hermes/Claude: a missing runtime fails +fast and fallback-only selection is dropped rather than delegated to pi. */ export const CLI_PROVIDER_ROUTING_CENSUS: readonly CliProviderRouting[] = [ { providerId: "pi-claude-cli", classification: "registry-native", autoDerive: "n/a", guardNotApplicable: "n/a", onExplicitHint: "n/a", fallbackPolicy: "none", rationale: "Vendored pi extension resolves through pi's registry." }, @@ -74,7 +70,7 @@ export const CLI_PROVIDER_ROUTING_CENSUS: readonly CliProviderRouting[] = [ { providerId: "grok-cli", classification: "runtime-routed", runtimeId: "grok", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "defer-to-resolve-runtime", fallbackPolicy: "defer-to-runtime", missingRuntimeError: buildMissingGrokRuntimeError, rationale: "Visible-key, fallback-only, and explicit-hint paths intentionally preserve the shipped direct xAI/pi fallback." }, { providerId: "hermes", classification: "runtime-routed", runtimeId: "hermes", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "drop-with-warning", missingRuntimeError: buildMissingHermesRuntimeError, rationale: "Fallback-only Hermes cannot be resolved by a healthy primary pi runtime." }, { providerId: "claude-cli", classification: "runtime-routed", runtimeId: "claude", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "drop-with-warning", missingRuntimeError: buildMissingClaudeRuntimeError, rationale: "Fallback-only Claude CLI cannot be resolved by a healthy primary pi runtime." }, - { providerId: "cursor-cli", classification: "withheld-unsupported", runtimeId: "cursor", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "none", missingRuntimeError: buildMissingCursorRuntimeError, rationale: "The registered adapter is a TODO(FN-3396) transport stub; support detection cannot make it executable." }, + { providerId: "cursor-cli", classification: "runtime-routed", runtimeId: "cursor", autoDerive: "fail-fast", guardNotApplicable: "pinned-pi-fallback", onExplicitHint: "assert-available", fallbackPolicy: "drop-with-warning", missingRuntimeError: buildMissingCursorRuntimeError, rationale: "FN-9097 verified Cursor's supervised stream-json transport and session resume contract." }, ] as const; export function getCliProviderRouting(providerId: string | undefined): CliProviderRouting | undefined { @@ -106,9 +102,8 @@ export function deriveCliRuntimeHint(args: { runtimeOptions: AgentRuntimeOptions; pluginRunner: PluginRunner | undefined; grokApiKeyVisible: boolean; - cursorCliExecutionSupported?: boolean; }): string | undefined { - const { runtimeOptions, pluginRunner, grokApiKeyVisible, cursorCliExecutionSupported } = args; + const { runtimeOptions, pluginRunner, grokApiKeyVisible } = args; const primary = getCliProviderRouting(runtimeOptions.defaultProvider); const omp = getCliProviderRouting("omp-cli")!; if (runtimeOptions.defaultProvider === "omp-cli" || runtimeOptions.fallbackProvider === "omp-cli") { @@ -117,8 +112,6 @@ export function deriveCliRuntimeHint(args: { } if (!primary || primary.classification === "registry-native" || primary.classification === "non-cli") return undefined; if (primary.classification === "withheld-unsupported") { - // FNXC:CliRuntimeRouting 2026-08-15-14:06: The Cursor adapter is a transport stub, so a registered plugin must not make either injected support state executable. - if (primary.providerId === "cursor-cli") throw buildMissingCursorRuntimeError(cursorCliExecutionSupported); throw primary.missingRuntimeError?.() ?? new Error(`${primary.providerId} is unavailable.`); } if (primary.providerId === "grok-cli" && grokApiKeyVisible) return undefined; @@ -130,17 +123,13 @@ export function assertExplicitCliRuntimeHint(args: { runtimeHint: string | undefined; runtimeOptions: AgentRuntimeOptions; pluginRunner: PluginRunner | undefined; - cursorCliExecutionSupported?: boolean; }): void { if (!args.runtimeHint) return; const selected = getCliProviderRouting(args.runtimeOptions.defaultProvider) ?? (args.runtimeOptions.fallbackProvider === "omp-cli" ? getCliProviderRouting("omp-cli") : undefined); if (!selected || selected.runtimeId !== args.runtimeHint || selected.onExplicitHint !== "assert-available") return; - // FNXC:CliRuntimeRouting 2026-08-15-14:06: Explicit hints cannot bypass a withheld Cursor adapter merely because its non-executable stub registration exists. if (selected.classification === "withheld-unsupported") { - throw selected.providerId === "cursor-cli" - ? buildMissingCursorRuntimeError(args.cursorCliExecutionSupported) - : selected.missingRuntimeError?.() ?? new Error(`${selected.providerId} is unavailable.`); + throw selected.missingRuntimeError?.() ?? new Error(`${selected.providerId} is unavailable.`); } assertAvailable(selected, args.pluginRunner); } diff --git a/plugins/fusion-plugin-cursor-runtime/README.md b/plugins/fusion-plugin-cursor-runtime/README.md index 5d05e7c08f..6938c1856c 100644 --- a/plugins/fusion-plugin-cursor-runtime/README.md +++ b/plugins/fusion-plugin-cursor-runtime/README.md @@ -13,3 +13,24 @@ Cursor CLI-backed provider/runtime plugin for Fusion. ## Notes Status/auth and model discovery behavior follows `docs/cursor-cli-contract.md`. + +## External Integration Evidence + +- Canonical upstream repository: Cursor CLI is closed-source and has no canonical upstream source repository. Its public issue tracker is https://github.com/cursor/cursor. +- Docs / homepage: https://cursor.com/docs/cli/overview +- Release / download: `curl https://cursor.com/install -fsS | bash` (macOS/Linux/WSL), or `irm 'https://cursor.com/install?win32=true' | iex` (Windows PowerShell). +- Binary: `cursor-agent` +- Checksum: `upstream-pending-verification`; Cursor publishes no versioned checksum manifest. Verified local provenance: `2026.08.11-e8db854` on 2026-08-15. + +## Execution transport contract + +Fusion runs one supervised `cursor-agent --print --output-format stream-json` turn per prompt. The prompt is supplied on stdin, the process `cwd` is the Fusion task worktree, and the init event confirmed that cwd is the Cursor workspace without a `--workspace` argument. Stream JSON emits init, thinking, assistant, tool_call, and terminal result events; session IDs are retained with `--resume` on the next turn. + +| Fusion tool mode | Cursor flags | +| --- | --- | +| `coding` | `--force --trust` | +| `readonly` or unset | `--mode plan --trust` | + +`--print` already grants built-in write and shell tools. `--force` controls approval, so it is limited to coding sessions whose cwd is Fusion's isolated task worktree. Fusion does not use `--auto-review`, worktree, add-dir, MCP approval, plugin-dir, or sandbox override flags. Fusion `fn_*` tools are not bridged into Cursor; Cursor uses only its own built-in tools. + +All turns use `superviseSpawn` with a finite lifetime. The Windows prompt transport prefers a direct executable; `.cmd`/`.bat` shims validate and reject cmd metacharacters before a quoted cmd launch, `.ps1` uses PowerShell `-File`, and unknown extensions fail loudly. `PI_CURSOR_CLI_FIRST_LINE_TIMEOUT_MS` and `PI_CURSOR_CLI_TIMEOUT_MS` optionally tune cold-start and inactivity guards. diff --git a/plugins/fusion-plugin-cursor-runtime/package.json b/plugins/fusion-plugin-cursor-runtime/package.json index e9913128cd..df16a16577 100644 --- a/plugins/fusion-plugin-cursor-runtime/package.json +++ b/plugins/fusion-plugin-cursor-runtime/package.json @@ -26,6 +26,7 @@ "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { + "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*" }, "peerDependencies": { diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts index d668205c85..9be0b5008e 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/cli-spawn.test.ts @@ -2,10 +2,10 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("node:child_process", () => ({ spawn: vi.fn() })); +vi.mock("node:child_process", () => ({ spawn: vi.fn(), spawnSync: vi.fn() })); -import { spawn } from "node:child_process"; -import { runCursorCommand } from "../cli-spawn.js"; +import { spawn, spawnSync } from "node:child_process"; +import { assertCmdBoundarySafe, quoteCmdArgument, resolvePowerShellExecutable, runCursorCommand } from "../cli-spawn.js"; function mockPlatform(platform: NodeJS.Platform) { return vi.spyOn(process, "platform", "get").mockReturnValue(platform); @@ -101,3 +101,22 @@ describe("runCursorCommand", () => { await expect(resultPromise).resolves.toMatchObject({ code: 124 }); }); }); + +describe("cmd boundary validation", () => { + it("falls back to Windows PowerShell when pwsh is not resolvable", () => { + vi.mocked(spawnSync).mockReturnValue({ status: 1, stdout: "" } as never); + expect(resolvePowerShellExecutable()).toBe("powershell.exe"); + vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: "C:\\Program Files\\PowerShell\\7\\pwsh.exe\n" } as never); + expect(resolvePowerShellExecutable()).toBe("pwsh.exe"); + }); + + it("allows Cursor bracketed parameterized model identifiers", () => { + const model = "claude-opus-4-8[context=1m,effort=high]"; + expect(() => assertCmdBoundarySafe(model)).not.toThrow(); + expect(quoteCmdArgument(model)).toBe(`"${model}"`); + }); + + it("rejects cmd control characters rather than escaping them", () => { + expect(() => assertCmdBoundarySafe("model&payload")).toThrow(/&/); + }); +}); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/prompt-transport.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/prompt-transport.test.ts new file mode 100644 index 0000000000..4c7db6f0f4 --- /dev/null +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/prompt-transport.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { launchCursorPrompt } from "../prompt-transport.js"; + +function fakeSupervisor() { + const child = new EventEmitter() as EventEmitter & { stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; exitCode: number | null; signalCode: NodeJS.Signals | null }; + child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = null; child.signalCode = null; + const supervise = vi.fn(() => ({ child, pid: 44, pgid: 44, kill: vi.fn(), waitExit: async () => ({ code: 0, signal: null }) })); + return { child, supervise }; +} +describe("launchCursorPrompt", () => { + it("uses a supervised shell:false cwd-bound direct launch and streams callbacks", async () => { + const { child, supervise } = fakeSupervisor(); const text = vi.fn(); const thinking = vi.fn(); + const promise = launchCursorPrompt({ cwd: "/tmp", prompt: "hello", model: "cursor-cli/auto", tools: "readonly", onText: text, onThinking: thinking }, { supervise: supervise as never, platform: "darwin" }); + expect(supervise).toHaveBeenCalledWith("cursor-agent", expect.arrayContaining(["--mode", "plan"]), expect.objectContaining({ shell: false, cwd: "/tmp", maxLifetimeMs: Number.POSITIVE_INFINITY })); + child.stdout.write('{"type":"system","subtype":"init","session_id":"chat"}\n'); child.stdout.write('{"type":"thinking","subtype":"delta","text":"think"}\n'); child.stdout.write('{"type":"assistant","message":{"content":[{"text":"ok"}]}}\n'); child.stdout.write('{"type":"result","is_error":false,"result":"ok","session_id":"chat"}\n'); child.emit("close", 0); + await expect(promise).resolves.toMatchObject({ sessionId: "chat", text: "ok" }); expect(text).toHaveBeenCalledWith("ok"); expect(thinking).toHaveBeenCalledWith("think"); + }); + it("retries the documented cursor PATH fallback when cursor-agent is absent", async () => { + const first = fakeSupervisor(); const second = fakeSupervisor(); + const supervise = vi.fn() + .mockReturnValueOnce({ child: first.child, pid: 44, pgid: 44, kill: vi.fn(), waitExit: async () => ({ code: 0, signal: null }) }) + .mockReturnValueOnce({ child: second.child, pid: 45, pgid: 45, kill: vi.fn(), waitExit: async () => ({ code: 0, signal: null }) }); + const promise = launchCursorPrompt({ cwd: "/tmp", prompt: "hello" }, { supervise: supervise as never, platform: "linux" }); + first.child.emit("error", Object.assign(new Error("missing cursor-agent"), { code: "ENOENT" })); + expect(supervise).toHaveBeenLastCalledWith("cursor", expect.any(Array), expect.any(Object)); + second.child.stdout.write('{"type":"result","is_error":false}\n'); second.child.emit("close", 0); + await expect(promise).resolves.toMatchObject({ text: "" }); + }); + + it("uses force only for coding", async () => { + const { child, supervise } = fakeSupervisor(); const promise = launchCursorPrompt({ cwd: "/tmp", prompt: "hello", tools: "coding" }, { supervise: supervise as never, platform: "linux" }); + expect(supervise).toHaveBeenCalledWith("cursor-agent", expect.arrayContaining(["--force"]), expect.any(Object)); + expect(supervise).not.toHaveBeenCalledWith("cursor-agent", expect.arrayContaining(["--mode"]), expect.any(Object)); + child.stdout.write('{"type":"result","is_error":false}\n'); child.emit("close", 0); await promise; + }); + + it("preserves bracketed model parameters through a validated Windows cmd shim", async () => { + const { child, supervise } = fakeSupervisor(); + const promise = launchCursorPrompt({ cwd: "/tmp", prompt: "hello", binary: "C:\\Cursor Agent\\cursor-agent.CMD", model: "cursor-cli/claude-opus-4-8[context=1m,effort=high]" }, { supervise: supervise as never, platform: "win32" }); + expect(supervise).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", expect.stringContaining("claude-opus-4-8[context=1m,effort=high]")], expect.objectContaining({ shell: false, windowsVerbatimArguments: true })); + child.stdout.write('{"type":"result","is_error":false}\n'); child.emit("close", 0); + await expect(promise).resolves.toMatchObject({ text: "" }); + }); + + it("uses Windows PowerShell when pwsh is not resolvable", async () => { + const { child, supervise } = fakeSupervisor(); + const promise = launchCursorPrompt({ cwd: "/tmp", prompt: "hello", binary: "C:\\Cursor Agent\\cursor-agent.ps1" }, { + supervise: supervise as never, + platform: "win32", + resolvePowerShell: () => "powershell.exe", + }); + expect(supervise).toHaveBeenCalledWith("powershell.exe", expect.arrayContaining(["-NoProfile", "-File", "C:\\Cursor Agent\\cursor-agent.ps1"]), expect.not.objectContaining({ windowsVerbatimArguments: expect.anything() })); + child.stdout.write('{"type":"result","is_error":false}\n'); child.emit("close", 0); + await expect(promise).resolves.toMatchObject({ text: "" }); + }); +}); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/runtime-adapter.test.ts index dd6dc5ee33..f14973b720 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/runtime-adapter.test.ts @@ -1,21 +1,39 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CursorRuntimeAdapter } from "../runtime-adapter.js"; +import * as transport from "../prompt-transport.js"; describe("CursorRuntimeAdapter", () => { - it("creates a session with default model fallback", async () => { - const adapter = new CursorRuntimeAdapter(); - const result = await adapter.createSession({ systemPrompt: "sys" }); - expect(result.session.model).toBe("cursor/default"); - expect(result.session.systemPrompt).toBe("sys"); + it("normalizes model and creates a cwd-bound session", async () => { + const result = await new CursorRuntimeAdapter().createSession({ cwd: "/tmp", systemPrompt: "sys", defaultModelId: "cursor-cli/pro", tools: "readonly" }); + expect(result.session.model).toBe("pro"); + expect(result.session.cwd).toBe("/tmp"); + expect(result.session.tools).toBe("readonly"); + expect(new CursorRuntimeAdapter().describeModel(result.session)).toBe("cursor-cli/pro"); }); - - it("promptWithFallback resolves without throwing", async () => { - const adapter = new CursorRuntimeAdapter(); - await expect(adapter.promptWithFallback()).resolves.toBeUndefined(); + it("fuses the first prompt and resumes retained Cursor chat", async () => { + const spy = vi.spyOn(transport, "launchCursorPrompt").mockResolvedValueOnce({ sessionId: "chat-1", text: "first" }).mockResolvedValueOnce({ sessionId: "chat-1", text: "second" }); + const text = vi.fn(); const adapter = new CursorRuntimeAdapter(); + const { session } = await adapter.createSession({ cwd: "/tmp", systemPrompt: "system", onText: text, tools: "readonly" }); + await adapter.promptWithFallback(session, "one"); await adapter.promptWithFallback(session, "two"); + expect(spy.mock.calls[0][0]).toMatchObject({ cwd: "/tmp", tools: "readonly", resumeId: undefined }); + expect(spy.mock.calls[0][0].prompt).toContain("system"); + expect(spy.mock.calls[1][0]).toMatchObject({ prompt: "two", resumeId: "chat-1", tools: "readonly" }); }); - - it("describeModel formats cursor prefix", () => { + it("restores the session id on transport failure and disposal aborts an active turn exactly once", async () => { + vi.spyOn(transport, "launchCursorPrompt").mockRejectedValueOnce(new Error("failed")); const adapter = new CursorRuntimeAdapter(); - expect(adapter.describeModel({ model: "cursor/pro" })).toBe("cursor/cursor/pro"); + const { session } = await adapter.createSession({ cwd: "/tmp", systemPrompt: "system" }); session.sessionId = "prior"; + await expect(adapter.promptWithFallback(session, "x")).rejects.toThrow("failed"); expect(session.sessionId).toBe("prior"); + + let signal: AbortSignal | undefined; + vi.spyOn(transport, "launchCursorPrompt").mockImplementationOnce((input) => new Promise((_resolve, reject) => { + signal = input.signal; + input.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + })); + const active = adapter.promptWithFallback(session, "active"); + session.dispose(); session.dispose(); + expect(signal?.aborted).toBe(true); + await expect(active).rejects.toThrow("aborted"); + expect(session.activeAbortController).toBeUndefined(); }); }); diff --git a/plugins/fusion-plugin-cursor-runtime/src/__tests__/stream-parser.test.ts b/plugins/fusion-plugin-cursor-runtime/src/__tests__/stream-parser.test.ts new file mode 100644 index 0000000000..f393ef1119 --- /dev/null +++ b/plugins/fusion-plugin-cursor-runtime/src/__tests__/stream-parser.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { parseCursorStreamLine } from "../stream-parser.js"; +describe("parseCursorStreamLine", () => { + it("parses verified Cursor events", () => { + expect(parseCursorStreamLine('{"type":"system","subtype":"init","session_id":"s","cwd":"/tmp"}')).toEqual({ kind: "system-init", sessionId: "s", cwd: "/tmp", model: undefined }); + expect(parseCursorStreamLine('{"type":"thinking","subtype":"delta","text":"thought"}')).toEqual({ kind: "thinking-delta", text: "thought" }); + expect(parseCursorStreamLine('{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}')).toEqual({ kind: "assistant-text", text: "ok" }); + expect(parseCursorStreamLine('{"type":"tool_call","subtype":"started","call_id":"id\\nnext","tool_call":{"readToolCall":{"args":{"path":"x"}}}}')).toMatchObject({ kind: "tool-call-started", name: "read", callId: "id\nnext" }); + expect(parseCursorStreamLine('{"type":"result","is_error":false,"result":"ok","session_id":"s"}')).toMatchObject({ kind: "result", text: "ok", sessionId: "s" }); + }); + it("does not throw for incomplete or unknown lines", () => { expect(parseCursorStreamLine("{")).toEqual({ kind: "unknown" }); expect(parseCursorStreamLine("")).toEqual({ kind: "unknown" }); expect(parseCursorStreamLine('{"type":"new"}')).toEqual({ kind: "unknown" }); }); +}); diff --git a/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts index 1079209738..5ae39aade7 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/cli-spawn.ts @@ -1,4 +1,5 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import path, { sep as PATH_SEP } from "node:path"; function formatSpawnError(error: Error & { code?: unknown }): string { const code = typeof error.code === "string" ? `${error.code}: ` : ""; @@ -23,6 +24,7 @@ export async function runCursorCommand(binary: string, args: string[], timeoutMs FNXC:CursorCli 2026-07-02-00:00: Windows Cursor installers and npm-style shims can expose `cursor-agent.cmd` or `cursor.cmd` on PATH, and Node cannot direct-spawn those batch wrappers without the command shell. Keep Unix/macOS on direct spawn so only the known Cursor CLI probe/discovery seam uses shell resolution where Windows requires it. + Streaming prompt execution deliberately diverges; see prompt-transport.ts for its supervised shell:false launch contract. */ const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"], @@ -48,3 +50,56 @@ export async function runCursorCommand(binary: string, args: string[], timeoutMs }); }); } + +const resolvedSpawnBinaries = new Map(); + +/** Resolve only the first where.exe directory; later PATH entries are different programs. */ +export function resolveCursorBinaryForSpawn(binary: string): string { + if (process.platform !== "win32") return binary; + if (binary.includes(PATH_SEP) || binary.includes("/") || /\.[^.\\/]+$/i.test(binary)) return binary; + const cached = resolvedSpawnBinaries.get(binary); + if (cached) return cached; + let result: ReturnType; + try { result = spawnSync("where.exe", [binary], { encoding: "utf-8", windowsHide: true, shell: false, timeout: 2_000 }); } catch { return binary; } + const lines = String(result.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (!lines.length) return binary; + const first = lines[0]; + const firstDirectory = path.dirname(first).toLowerCase(); + const extensions = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((extension) => extension.toLowerCase()); + const candidate = extensions.map((extension) => lines.find((line) => path.dirname(line).toLowerCase() === firstDirectory && path.extname(line).toLowerCase() === extension)).find(Boolean) ?? first; + resolvedSpawnBinaries.set(binary, candidate); + return candidate; +} + +export type WindowsLaunchTarget = "direct" | "cmd-shim" | "powershell-shim" | "unsupported"; +export function classifyWindowsLaunchTarget(target: string): WindowsLaunchTarget { + const extension = path.extname(target).toLowerCase(); + if (!extension || extension === ".exe" || extension === ".com") return "direct"; + if (extension === ".cmd" || extension === ".bat") return "cmd-shim"; + if (extension === ".ps1") return "powershell-shim"; + return "unsupported"; +} + +// FNXC:CursorCli 2026-08-15-15:32: Bracketed Cursor model parameters are data, not cmd control syntax; reject only characters cmd.exe can interpret at this boundary. +const CMD_UNSAFE = /["%!^&|<>()\r\n\0]/; +export function assertCmdBoundarySafe(token: string): void { + const offending = token.match(CMD_UNSAFE)?.[0]; + if (offending) throw new Error(`Cursor cmd boundary rejected token ${JSON.stringify(token)} containing ${JSON.stringify(offending)}; relocate the worktree or use a direct cursor-agent executable.`); +} +export function quoteCmdArgument(value: string): string { assertCmdBoundarySafe(value); return `"${value}"`; } + +/** Prefer PowerShell 7 only when it is actually resolvable; Windows PowerShell remains the installer-compatible fallback. */ +export function resolvePowerShellExecutable(): string { + try { + const result = spawnSync("where.exe", ["pwsh.exe"], { + encoding: "utf-8", + windowsHide: true, + shell: false, + timeout: 2_000, + }); + if (result.status === 0 && String(result.stdout ?? "").split(/\r?\n/).some((line) => line.trim())) return "pwsh.exe"; + } catch { + // Fall through to the Windows PowerShell executable supplied on standard Windows installs. + } + return "powershell.exe"; +} diff --git a/plugins/fusion-plugin-cursor-runtime/src/index.ts b/plugins/fusion-plugin-cursor-runtime/src/index.ts index 10b0b82cb1..9e34bd4abb 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/index.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/index.ts @@ -24,7 +24,7 @@ const plugin: FusionPlugin = definePlugin({ name: "Cursor Runtime", version: "0.1.0", }, - factory: async () => new CursorRuntimeAdapter(), + factory: async (ctx) => new CursorRuntimeAdapter(ctx.settings as Record | undefined), }, cliProviders: [ { @@ -53,7 +53,7 @@ const plugin: FusionPlugin = definePlugin({ discoverModels: discoverCursorProviderModels, runtime: { runtimeId: "cursor", - createAdapter: async () => new CursorRuntimeAdapter(), + createAdapter: async (ctx) => new CursorRuntimeAdapter(ctx.settings as Record | undefined), }, }, ], diff --git a/plugins/fusion-plugin-cursor-runtime/src/prompt-transport.ts b/plugins/fusion-plugin-cursor-runtime/src/prompt-transport.ts new file mode 100644 index 0000000000..9ecbc88cd5 --- /dev/null +++ b/plugins/fusion-plugin-cursor-runtime/src/prompt-transport.ts @@ -0,0 +1,120 @@ +import { existsSync } from "node:fs"; +import { spawn } from "node:child_process"; +import readline from "node:readline"; +import path from "node:path"; +import { superviseSpawn, type SupervisedChild } from "@fusion/core"; +import { assertCmdBoundarySafe, classifyWindowsLaunchTarget, quoteCmdArgument, resolveCursorBinaryForSpawn, resolvePowerShellExecutable } from "./cli-spawn.js"; +import { parseCursorStreamLine } from "./stream-parser.js"; + +const FIRST_LINE_DEFAULT_MS = 30_000; +const INACTIVITY_DEFAULT_MS = 120_000; +const STDERR_MAX = 16_384; +function timeout(name: string, fallback: number) { const value = Number(process.env[name]); return Number.isFinite(value) && value > 0 ? value : fallback; } + +export interface CursorPromptCallbacks { onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; } +export interface CursorPromptInput extends CursorPromptCallbacks { binary?: string; model?: string; cwd: string; tools?: "coding" | "readonly"; prompt: string; resumeId?: string; signal?: AbortSignal; workspaceFlagRequired?: boolean; } +export interface CursorPromptResult { sessionId?: string; text: string; usage?: unknown; } +export interface CursorPromptDependencies { supervise?: typeof superviseSpawn; taskkill?: typeof spawn; platform?: NodeJS.Platform; resolvePowerShell?: () => string; } + +/* +FNXC:CursorCli 2026-08-15-15:16: +Streaming Cursor turns are supervised rather than using the probe's shell runner. The prompt stays +on stdin; direct targets eliminate cmd, while cmd shims reject unsafe tokens before verbatim quoting. +This preserves task-worktree autonomy and prevents a long-running agent from outliving Fusion. +*/ +export async function launchCursorPrompt(input: CursorPromptInput, deps: CursorPromptDependencies = {}): Promise { + if (!input.cwd || !existsSync(input.cwd)) throw new Error(`Cursor CLI requires an existing session cwd: ${input.cwd || "(missing)"}`); + const platform = deps.platform ?? process.platform; + const configuredBinary = input.binary?.trim(); + const target = resolveCursorBinaryForSpawn(configuredBinary || "cursor-agent"); + const fallbackBinary = configuredBinary ? undefined : target === "cursor-agent" ? "cursor" : undefined; + const model = (input.model || "auto").replace(/^cursor-cli\//, ""); + const args = ["--print", "--output-format", "stream-json", "--model", model, "--trust"]; + if (input.workspaceFlagRequired) args.push("--workspace", input.cwd); + if (input.tools === "coding") args.push("--force"); else args.push("--mode", "plan"); + if (input.resumeId) args.push("--resume", input.resumeId); + /* + FNXC:CursorCli 2026-08-15-15:47: + A Cursor turn is bounded by first output and reset-on-output inactivity, not a total duration. + Active coding turns may legitimately stream beyond two minutes, so disable the supervisor lifetime cap while retaining parent-shutdown supervision and explicit teardown. + */ + const options = { shell: false as const, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], cwd: input.cwd, maxLifetimeMs: Number.POSITIVE_INFINITY }; + const supervise = deps.supervise ?? superviseSpawn; + let command = target; let launchArgs = args; let launchOptions: Parameters[2] = options; + if (platform === "win32") { + const classification = classifyWindowsLaunchTarget(target); + if (classification === "unsupported") throw new Error(`Cursor CLI resolved target ${target} has unsupported ${path.extname(target)} extension; point cursorCliBinaryPath at the cursor-agent executable.`); + if (classification === "cmd-shim") { + [target, ...args].forEach(assertCmdBoundarySafe); + const commandLine = [quoteCmdArgument(target), ...args.map(quoteCmdArgument)].join(" "); + const configured = process.env.ComSpec; + const comspec = configured && path.isAbsolute(configured) && path.basename(configured).toLowerCase() === "cmd.exe" && !configured.includes('"') ? configured : "cmd.exe"; + if (comspec.length + commandLine.length > 8000) throw new Error(`Cursor cmd command line length ${comspec.length + commandLine.length} exceeds 8000 characters for ${target}.`); + command = comspec; launchArgs = ["/d", "/s", "/c", `"${commandLine}"`]; launchOptions = { ...options, windowsVerbatimArguments: true }; + } else if (classification === "powershell-shim") { + // FNXC:CursorCli 2026-08-15-15:32: Cursor's Windows installer can leave only Windows PowerShell available, so prefer pwsh only after resolution and retain powershell.exe fallback. + command = (deps.resolvePowerShell ?? resolvePowerShellExecutable)(); + launchArgs = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", target, ...args]; + } + } + let supervised: SupervisedChild; + try { + supervised = supervise(command, launchArgs, launchOptions); + } catch (error) { + if (fallbackBinary && (error as NodeJS.ErrnoException).code === "ENOENT") { + return launchCursorPrompt({ ...input, binary: fallbackBinary }, deps); + } + throw new Error(`Cursor CLI spawn failed for ${target} (${platform === "win32" ? classifyWindowsLaunchTarget(target) : "direct"}): ${error instanceof Error ? error.message : String(error)}`); + } + const child = supervised.child; + let settled = false, sawResult = false, output = "", sessionId: string | undefined, usage: unknown, stderr = "", teardownReason: string | undefined; + let firstTimer: NodeJS.Timeout | undefined; let inactivityTimer: NodeJS.Timeout | undefined; + const teardown = (reason: string) => { + if (teardownReason || child.exitCode !== null || child.signalCode !== null) return; + teardownReason = reason; + if (platform === "win32" && typeof supervised.pid === "number") { + try { + (deps.taskkill ?? spawn)("taskkill", ["/pid", String(supervised.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => undefined); + } catch { + // FNXC:CursorCli 2026-08-15-15:16: taskkill can be absent; supervisor teardown still reaps the launcher. + } + supervised.kill(); + } else supervised.kill("SIGKILL"); + }; + return new Promise((resolve, reject) => { + const finish = (error?: Error) => { + if (settled) return; + settled = true; + if (firstTimer) clearTimeout(firstTimer); + if (inactivityTimer) clearTimeout(inactivityTimer); + if (input.signal) input.signal.removeEventListener("abort", abort); + if (error) reject(error); else resolve({ sessionId, text: output, usage }); + }; + const retryWithFallback = (error: NodeJS.ErrnoException): boolean => { + if (!fallbackBinary || error.code !== "ENOENT" || settled) return false; + settled = true; + if (firstTimer) clearTimeout(firstTimer); + if (inactivityTimer) clearTimeout(inactivityTimer); + if (input.signal) input.signal.removeEventListener("abort", abort); + /* + FNXC:CursorCli 2026-08-15-15:47: + Probe and execution share the cursor-agent → cursor PATH contract. Retry only an absent default launcher; an explicit operator binary remains authoritative and non-ENOENT launch failures must surface unchanged. + */ + void launchCursorPrompt({ ...input, binary: fallbackBinary }, deps).then(resolve, reject); + return true; + }; + const resetInactivity = () => { if (inactivityTimer) clearTimeout(inactivityTimer); inactivityTimer = setTimeout(() => { teardown("Cursor CLI inactivity timeout"); finish(new Error("Cursor CLI inactivity timeout")); }, timeout("PI_CURSOR_CLI_TIMEOUT_MS", INACTIVITY_DEFAULT_MS)); }; + const abort = () => { teardown("Cursor CLI aborted"); finish(new Error("Cursor CLI aborted")); }; + firstTimer = setTimeout(() => { teardown("Cursor CLI first-line timeout"); finish(new Error("Cursor CLI first-line timeout")); }, timeout("PI_CURSOR_CLI_FIRST_LINE_TIMEOUT_MS", FIRST_LINE_DEFAULT_MS)); + input.signal?.addEventListener("abort", abort, { once: true }); + child.stderr?.on("data", (chunk: Buffer | string) => { stderr = (stderr + String(chunk)).slice(-STDERR_MAX); }); + child.stdin?.on("error", (error: NodeJS.ErrnoException) => { if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") finish(error); }); + const lines = readline.createInterface({ input: child.stdout! }); + lines.on("line", (line) => { if (firstTimer) { clearTimeout(firstTimer); firstTimer = undefined; } resetInactivity(); const event = parseCursorStreamLine(line); if (event.kind === "system-init") sessionId = event.sessionId ?? sessionId; if (event.kind === "thinking-delta") input.onThinking?.(event.text); if (event.kind === "assistant-text") { output += event.text; input.onText?.(event.text); } if (event.kind === "tool-call-started") input.onToolStart?.(event.name, event.args); if (event.kind === "tool-call-completed") input.onToolEnd?.(event.name, false, event.result); if (event.kind === "result") { sawResult = true; sessionId = event.sessionId ?? sessionId; usage = event.usage; if (event.isError) finish(new Error(`Cursor CLI reported an error: ${event.text ?? "unknown error"}`)); } }); + child.once("error", (error: NodeJS.ErrnoException) => { + if (!retryWithFallback(error)) finish(new Error(`Cursor CLI spawn failed for ${target}: ${error.message}`)); + }); + child.once("close", (code: number | null) => { if (teardownReason) return finish(new Error(teardownReason)); if (code !== 0) return finish(new Error(`Cursor CLI exited ${code}: ${stderr}`)); if (!sawResult) return finish(new Error("Cursor CLI stream ended without a result event.")); finish(); }); + try { child.stdin?.end(input.prompt); } catch (error) { finish(error instanceof Error ? error : new Error(String(error))); } + }); +} diff --git a/plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.ts index 3ee98272e0..9acf52e633 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.ts @@ -1,25 +1,44 @@ -export class CursorRuntimeAdapter { +import { launchCursorPrompt } from "./prompt-transport.js"; +import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult, CursorStreamSession } from "./types.js"; + +function context(options: AgentRuntimeOptions): string { + const skills = Array.isArray(options.skills) ? options.skills.filter((value) => typeof value === "string" && value.trim()) : []; + return ["Fusion runtime context:", `- Tool mode: ${options.tools ?? "readonly"}`, skills.length ? `- Requested skills: ${skills.join(", ")}` : ""].filter(Boolean).join("\n"); +} + +/* +FNXC:CursorCli 2026-08-15-15:16: +Cursor has no system-prompt flag, so Fusion fuses system context only into the first stdin prompt. +The stored cwd and tools are immutable session authority: later turns cannot turn a review session into --force. +*/ +export class CursorRuntimeAdapter implements AgentRuntime { readonly id = "cursor"; readonly name = "Cursor Runtime"; - - async createSession(options: { defaultModelId?: string; systemPrompt?: string }) { - return { - session: { - model: options.defaultModelId ?? "cursor/default", - systemPrompt: options.systemPrompt, - messages: [], - }, - sessionFile: undefined, - }; + constructor(private readonly settings?: Record) {} + async createSession(options: AgentRuntimeOptions): Promise { + const messages: unknown[] = []; + const session: CursorStreamSession = { model: options.defaultModelId?.replace(/^cursor-cli\//, "") ?? "auto", systemPrompt: options.systemPrompt, messages, state: { messages }, sessionId: "", cwd: options.cwd, tools: options.tools, callbacks: { onText: options.onText, onThinking: options.onThinking, onToolStart: options.onToolStart, onToolEnd: options.onToolEnd }, fusedSystemPrompt: [options.systemPrompt?.trim(), context(options)].filter(Boolean).join("\n\n"), disposed: false, dispose: () => { + /* FNXC:CursorCli 2026-08-15-15:32: Disposing a Fusion session must abort its live autonomous Cursor turn so prompt-transport performs supervised process-tree teardown. */ + session.disposed = true; + session.activeAbortController?.abort(); + } }; + return { session, sessionFile: undefined }; } - - async promptWithFallback(): Promise { - // TODO(FN-3396): Implement Cursor agent prompt streaming once a stable - // invocation contract beyond probe/discovery commands is confirmed. - return; - } - - describeModel(session: { model?: string }) { - return `cursor/${session.model ?? "default"}`; + async promptWithFallback(session: CursorStreamSession, prompt: string, _options?: unknown): Promise { + if (session.disposed) throw new Error("Cursor session is disposed."); + const priorId = session.sessionId; + const first = !priorId; + const sent = first ? `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}` : prompt; + const emitted = new Set(); + const controller = new AbortController(); + session.activeAbortController = controller; + try { + const outcome = await launchCursorPrompt({ binary: typeof this.settings?.cursorCliBinaryPath === "string" ? this.settings.cursorCliBinaryPath : undefined, model: session.model, cwd: session.cwd, tools: session.tools, prompt: sent, resumeId: priorId || undefined, signal: controller.signal, onThinking: session.callbacks.onThinking, onToolStart: session.callbacks.onToolStart, onToolEnd: session.callbacks.onToolEnd, onText: (text) => { if (!emitted.has(text)) { emitted.add(text); session.callbacks.onText?.(text); } } }); + session.sessionId = outcome.sessionId ?? session.sessionId; + session.messages.push({ role: "user", content: prompt }, { role: "assistant", content: outcome.text }); + } catch (error) { session.sessionId = priorId; throw error; } finally { + if (session.activeAbortController === controller) session.activeAbortController = undefined; + } } + describeModel(session: CursorStreamSession): string { return `cursor-cli/${(session.model || "auto").replace(/^cursor-cli\//, "")}`; } } diff --git a/plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts b/plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts new file mode 100644 index 0000000000..a89f15bc6d --- /dev/null +++ b/plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts @@ -0,0 +1,43 @@ +export type CursorStreamEvent = + | { kind: "system-init"; sessionId?: string; cwd?: string; model?: string } + | { kind: "thinking-delta"; text: string } + | { kind: "assistant-text"; text: string } + | { kind: "tool-call-started" | "tool-call-completed"; callId?: string; name: string; args?: Record; result?: unknown } + | { kind: "result"; sessionId?: string; text?: string; isError: boolean; usage?: unknown } + | { kind: "unknown" }; + +/* +FNXC:CursorCli 2026-08-15-15:16: +Cursor's verified stream-json fixture has one JSON object per line, but tool call ids +can contain newlines after parsing. Keep ids opaque and make malformed stream data a +non-fatal unknown event so a partial CLI line cannot crash the engine. +*/ +export function parseCursorStreamLine(line: string): CursorStreamEvent { + if (!line.trim()) return { kind: "unknown" }; + let value: Record; + try { + const parsed: unknown = JSON.parse(line); + if (!parsed || typeof parsed !== "object") return { kind: "unknown" }; + value = parsed as Record; + } catch { return { kind: "unknown" }; } + const type = value.type; + const sessionId = typeof value.session_id === "string" ? value.session_id : undefined; + if (type === "system" && value.subtype === "init") return { kind: "system-init", sessionId, cwd: typeof value.cwd === "string" ? value.cwd : undefined, model: typeof value.model === "string" ? value.model : undefined }; + if (type === "thinking" && value.subtype === "delta" && typeof value.text === "string") return { kind: "thinking-delta", text: value.text }; + if (type === "assistant") { + const message = value.message as { content?: unknown } | undefined; + const content = Array.isArray(message?.content) ? message.content : []; + const text = content.filter((item): item is { text: string } => Boolean(item) && typeof item === "object" && typeof (item as { text?: unknown }).text === "string").map((item) => item.text).join(""); + return text ? { kind: "assistant-text", text } : { kind: "unknown" }; + } + if (type === "tool_call" && (value.subtype === "started" || value.subtype === "completed")) { + const call = value.tool_call as Record | undefined; + const key = call && Object.keys(call).find((name) => name.endsWith("ToolCall")); + if (!key) return { kind: "unknown" }; + const details = call[key] as Record | undefined; + const name = key.slice(0, -"ToolCall".length) || "unknown"; + return { kind: value.subtype === "started" ? "tool-call-started" : "tool-call-completed", callId: typeof value.call_id === "string" ? value.call_id : undefined, name, args: details?.args && typeof details.args === "object" ? details.args as Record : undefined, result: details?.result }; + } + if (type === "result") return { kind: "result", sessionId, text: typeof value.result === "string" ? value.result : undefined, isError: value.is_error === true, usage: value.usage }; + return { kind: "unknown" }; +} diff --git a/plugins/fusion-plugin-cursor-runtime/src/types.ts b/plugins/fusion-plugin-cursor-runtime/src/types.ts index 89ee2034e2..619adf1dbf 100644 --- a/plugins/fusion-plugin-cursor-runtime/src/types.ts +++ b/plugins/fusion-plugin-cursor-runtime/src/types.ts @@ -1,12 +1,6 @@ -export interface CursorBinaryStatus { - available: boolean; - authenticated?: boolean; - binaryPath?: string; - binaryName?: string; - configuredBinaryPath?: string; - usingConfiguredBinaryPath?: boolean; - diagnostics?: string[]; - version?: string; - reason?: string; - probeDurationMs: number; -} +export interface CursorBinaryStatus { available: boolean; authenticated?: boolean; binaryPath?: string; binaryName?: string; configuredBinaryPath?: string; usingConfiguredBinaryPath?: boolean; diagnostics?: string[]; version?: string; reason?: string; probeDurationMs: number; } + +export interface AgentRuntimeOptions { cwd: string; systemPrompt: string; tools?: "coding" | "readonly"; defaultModelId?: string; skills?: string[]; skillSelection?: unknown; onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; } +export interface CursorStreamSession { model?: string; systemPrompt?: string; messages: unknown[]; state: { messages: unknown[] }; sessionId: string; cwd: string; tools?: "coding" | "readonly"; callbacks: Pick; fusedSystemPrompt: string; disposed: boolean; activeAbortController?: AbortController; dispose: () => void; } +export interface AgentSessionResult { session: CursorStreamSession; sessionFile?: string; } +export interface AgentRuntime { readonly id: string; readonly name: string; createSession(options: AgentRuntimeOptions): Promise; promptWithFallback(session: CursorStreamSession, prompt: string, options?: unknown): Promise; describeModel(session: CursorStreamSession): string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad018509f4..dae37b7a08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -978,6 +978,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.1 version: 0.84.1(@modelcontextprotocol/sdk@1.28.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk