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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 08:51:35 -07:00
parent 1d3f6c198c
commit b6efd89ae5
19 changed files with 458 additions and 83 deletions

View File

@@ -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.

View File

@@ -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.
<!--
FNXC:CursorCli 2026-08-15-15:16:
FN-9097 verified the non-interactive Cursor transport against cursor-agent 2026.08.11-e8db854. Prompt content travels on stdin and cwd alone binds the workspace, so the streaming transport omits --workspace and avoids an avoidable command-boundary token.
-->
**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 <session_id>` and `--model <id>` 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.

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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<ResolvedSessionResult> {
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<typeof resolveCredentialInstanceRef> | 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;
/*

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -26,6 +26,7 @@
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/plugin-sdk": "workspace:*"
},
"peerDependencies": {

View File

@@ -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(/&/);
});
});

View File

@@ -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: "" });
});
});

View File

@@ -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();
});
});

View File

@@ -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" }); });
});

View File

@@ -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<string, string>();
/** 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<typeof spawnSync>;
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";
}

View File

@@ -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<string, unknown> | 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<string, unknown> | undefined),
},
},
],

View File

@@ -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<string, unknown>) => 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<CursorPromptResult> {
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<typeof superviseSpawn>[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<CursorPromptResult>((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))); }
});
}

View File

@@ -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<string, unknown>) {}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
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<void> {
// 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<void> {
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<string>();
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\//, "")}`; }
}

View File

@@ -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<string, unknown>; 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<string, unknown>;
try {
const parsed: unknown = JSON.parse(line);
if (!parsed || typeof parsed !== "object") return { kind: "unknown" };
value = parsed as Record<string, unknown>;
} 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<string, unknown> | undefined;
const key = call && Object.keys(call).find((name) => name.endsWith("ToolCall"));
if (!key) return { kind: "unknown" };
const details = call[key] as Record<string, unknown> | 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<string, unknown> : 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" };
}

View File

@@ -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<string, unknown>) => 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<AgentRuntimeOptions, "onText" | "onThinking" | "onToolStart" | "onToolEnd">; 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<AgentSessionResult>; promptWithFallback(session: CursorStreamSession, prompt: string, options?: unknown): Promise<void>; describeModel(session: CursorStreamSession): string; }

3
pnpm-lock.yaml generated
View File

@@ -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