Files
fusion/plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts
gsxdsm b6efd89ae5 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>
2026-08-15 08:51:35 -07:00

44 lines
2.9 KiB
TypeScript

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