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:
7
.changeset/fn-9097-cursor-cli-runtime.md
Normal file
7
.changeset/fn-9097-cursor-cli-runtime.md
Normal 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.
|
||||||
@@ -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.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ describe("CLI provider routing conformance", () => {
|
|||||||
await expect(createResolvedAgentSession(options(entry, {
|
await expect(createResolvedAgentSession(options(entry, {
|
||||||
runtimeHint,
|
runtimeHint,
|
||||||
pluginRunner: runner(runtimeHint, availability),
|
pluginRunner: runner(runtimeHint, availability),
|
||||||
cursorCliExecutionSupported: true,
|
|
||||||
}))).rejects.toThrow(/Cursor CLI/);
|
}))).rejects.toThrow(/Cursor CLI/);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -195,16 +194,14 @@ describe("CLI provider routing conformance", () => {
|
|||||||
const result = await createResolvedAgentSession(options(entry, {
|
const result = await createResolvedAgentSession(options(entry, {
|
||||||
pluginRunner,
|
pluginRunner,
|
||||||
settings: { testMode: true },
|
settings: { testMode: true },
|
||||||
cursorCliExecutionSupported: true,
|
|
||||||
}));
|
}));
|
||||||
expect(result.runtimeId).toBe("mock");
|
expect(result.runtimeId).toBe("mock");
|
||||||
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled();
|
expect(pluginRunner.getRuntimeById).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([false, true])("injects Cursor support=%s into the withheld Cursor session seam", async (cursorCliExecutionSupported) => {
|
it("routes Cursor primary selection through its installed runtime", async () => {
|
||||||
await expect(createResolvedAgentSession(options(
|
const entry = CLI_PROVIDER_ROUTING_CENSUS.find((candidate) => candidate.providerId === "cursor-cli");
|
||||||
CLI_PROVIDER_ROUTING_CENSUS.find((entry) => entry.providerId === "cursor-cli"),
|
const result = await createResolvedAgentSession(options(entry, { runtimeHint: "cursor", pluginRunner: runner("cursor") }));
|
||||||
{ cursorCliExecutionSupported, runtimeHint: "cursor", pluginRunner: runner("cursor") },
|
expect(result.runtimeId).toBe("cursor");
|
||||||
))).rejects.toThrow(/Cursor CLI/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions {
|
|||||||
* Injected Cursor support status for routing conformance. Production leaves it
|
* Injected Cursor support status for routing conformance. Production leaves it
|
||||||
* unset while the bundled Cursor adapter remains a non-executable stub.
|
* unset while the bundled Cursor adapter remains a non-executable stub.
|
||||||
*/
|
*/
|
||||||
cursorCliExecutionSupported?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Optional run-audit emitter; when provided, a `session:runtime-resolved`
|
* Optional run-audit emitter; when provided, a `session:runtime-resolved`
|
||||||
* database event is recorded at resolution time. No-ops when omitted to
|
* database event is recorded at resolution time. No-ops when omitted to
|
||||||
@@ -783,7 +782,7 @@ export function resolveMergerSessionModel(
|
|||||||
export async function createResolvedAgentSession(
|
export async function createResolvedAgentSession(
|
||||||
options: ResolvedSessionOptions,
|
options: ResolvedSessionOptions,
|
||||||
): Promise<ResolvedSessionResult> {
|
): 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;
|
let credentialResolution: ReturnType<typeof resolveCredentialInstanceRef> | undefined;
|
||||||
if (requestedCredentialInstanceId) {
|
if (requestedCredentialInstanceId) {
|
||||||
try {
|
try {
|
||||||
@@ -890,13 +889,12 @@ export async function createResolvedAgentSession(
|
|||||||
runtimeOptions,
|
runtimeOptions,
|
||||||
pluginRunner,
|
pluginRunner,
|
||||||
grokApiKeyVisible: isGrokApiKeyFusionVisible(),
|
grokApiKeyVisible: isGrokApiKeyFusionVisible(),
|
||||||
cursorCliExecutionSupported,
|
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const effectiveRuntimeHint = autoCliRuntimeHint ?? runtimeHint;
|
const effectiveRuntimeHint = autoCliRuntimeHint ?? runtimeHint;
|
||||||
if (!useMockRuntime) {
|
if (!useMockRuntime) {
|
||||||
// Explicit CLI hints with assert-available policy must pre-empt resolveRuntime's pi fallback.
|
// 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;
|
const usesAutoGrokRuntime = autoCliRuntimeHint === "grok" && runtimeOptions.defaultProvider === GROK_CLI_PROVIDER_ID;
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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`.");
|
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 {
|
export function buildMissingCursorRuntimeError(): Error {
|
||||||
const transportDetail = cursorCliExecutionSupported
|
return unavailable("Cursor CLI", "Install and enable the Cursor runtime plugin (fusion-plugin-cursor-runtime), install `cursor-agent`, and authenticate with `cursor-agent login`.");
|
||||||
? "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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -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
|
pi fallback. Factory failures remain observations because read-only
|
||||||
resolveRuntime decides them after this seam confirms a registration exists.
|
resolveRuntime decides them after this seam confirms a registration exists.
|
||||||
|
|
||||||
Cursor Branch C applies on this revision: the support predicate is absent and
|
FN-9097 verified the supervised cursor-agent stream-json transport. Cursor now
|
||||||
the adapter remains TODO(FN-3396), so both injectable support states fail fast
|
uses the same runtime-routed contract as Hermes/Claude: a missing runtime fails
|
||||||
at this seam rather than claiming the stub can execute prompts. Completeness is
|
fast and fallback-only selection is dropped rather than delegated to pi.
|
||||||
checked by a repository static guard instead of importing dashboard from engine.
|
|
||||||
*/
|
*/
|
||||||
export const CLI_PROVIDER_ROUTING_CENSUS: readonly CliProviderRouting[] = [
|
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." },
|
{ 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: "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: "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: "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;
|
] as const;
|
||||||
|
|
||||||
export function getCliProviderRouting(providerId: string | undefined): CliProviderRouting | undefined {
|
export function getCliProviderRouting(providerId: string | undefined): CliProviderRouting | undefined {
|
||||||
@@ -106,9 +102,8 @@ export function deriveCliRuntimeHint(args: {
|
|||||||
runtimeOptions: AgentRuntimeOptions;
|
runtimeOptions: AgentRuntimeOptions;
|
||||||
pluginRunner: PluginRunner | undefined;
|
pluginRunner: PluginRunner | undefined;
|
||||||
grokApiKeyVisible: boolean;
|
grokApiKeyVisible: boolean;
|
||||||
cursorCliExecutionSupported?: boolean;
|
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const { runtimeOptions, pluginRunner, grokApiKeyVisible, cursorCliExecutionSupported } = args;
|
const { runtimeOptions, pluginRunner, grokApiKeyVisible } = args;
|
||||||
const primary = getCliProviderRouting(runtimeOptions.defaultProvider);
|
const primary = getCliProviderRouting(runtimeOptions.defaultProvider);
|
||||||
const omp = getCliProviderRouting("omp-cli")!;
|
const omp = getCliProviderRouting("omp-cli")!;
|
||||||
if (runtimeOptions.defaultProvider === "omp-cli" || runtimeOptions.fallbackProvider === "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 || primary.classification === "registry-native" || primary.classification === "non-cli") return undefined;
|
||||||
if (primary.classification === "withheld-unsupported") {
|
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.`);
|
throw primary.missingRuntimeError?.() ?? new Error(`${primary.providerId} is unavailable.`);
|
||||||
}
|
}
|
||||||
if (primary.providerId === "grok-cli" && grokApiKeyVisible) return undefined;
|
if (primary.providerId === "grok-cli" && grokApiKeyVisible) return undefined;
|
||||||
@@ -130,17 +123,13 @@ export function assertExplicitCliRuntimeHint(args: {
|
|||||||
runtimeHint: string | undefined;
|
runtimeHint: string | undefined;
|
||||||
runtimeOptions: AgentRuntimeOptions;
|
runtimeOptions: AgentRuntimeOptions;
|
||||||
pluginRunner: PluginRunner | undefined;
|
pluginRunner: PluginRunner | undefined;
|
||||||
cursorCliExecutionSupported?: boolean;
|
|
||||||
}): void {
|
}): void {
|
||||||
if (!args.runtimeHint) return;
|
if (!args.runtimeHint) return;
|
||||||
const selected = getCliProviderRouting(args.runtimeOptions.defaultProvider)
|
const selected = getCliProviderRouting(args.runtimeOptions.defaultProvider)
|
||||||
?? (args.runtimeOptions.fallbackProvider === "omp-cli" ? getCliProviderRouting("omp-cli") : undefined);
|
?? (args.runtimeOptions.fallbackProvider === "omp-cli" ? getCliProviderRouting("omp-cli") : undefined);
|
||||||
if (!selected || selected.runtimeId !== args.runtimeHint || selected.onExplicitHint !== "assert-available") return;
|
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") {
|
if (selected.classification === "withheld-unsupported") {
|
||||||
throw selected.providerId === "cursor-cli"
|
throw selected.missingRuntimeError?.() ?? new Error(`${selected.providerId} is unavailable.`);
|
||||||
? buildMissingCursorRuntimeError(args.cursorCliExecutionSupported)
|
|
||||||
: selected.missingRuntimeError?.() ?? new Error(`${selected.providerId} is unavailable.`);
|
|
||||||
}
|
}
|
||||||
assertAvailable(selected, args.pluginRunner);
|
assertAvailable(selected, args.pluginRunner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,24 @@ Cursor CLI-backed provider/runtime plugin for Fusion.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Status/auth and model discovery behavior follows `docs/cursor-cli-contract.md`.
|
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.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fusion/core": "workspace:*",
|
||||||
"@fusion/plugin-sdk": "workspace:*"
|
"@fusion/plugin-sdk": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { EventEmitter } from "node:events";
|
|||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { spawn, spawnSync } from "node:child_process";
|
||||||
import { runCursorCommand } from "../cli-spawn.js";
|
import { assertCmdBoundarySafe, quoteCmdArgument, resolvePowerShellExecutable, runCursorCommand } from "../cli-spawn.js";
|
||||||
|
|
||||||
function mockPlatform(platform: NodeJS.Platform) {
|
function mockPlatform(platform: NodeJS.Platform) {
|
||||||
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
||||||
@@ -101,3 +101,22 @@ describe("runCursorCommand", () => {
|
|||||||
await expect(resultPromise).resolves.toMatchObject({ code: 124 });
|
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(/&/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,39 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { CursorRuntimeAdapter } from "../runtime-adapter.js";
|
import { CursorRuntimeAdapter } from "../runtime-adapter.js";
|
||||||
|
import * as transport from "../prompt-transport.js";
|
||||||
|
|
||||||
describe("CursorRuntimeAdapter", () => {
|
describe("CursorRuntimeAdapter", () => {
|
||||||
it("creates a session with default model fallback", async () => {
|
it("normalizes model and creates a cwd-bound session", async () => {
|
||||||
const adapter = new CursorRuntimeAdapter();
|
const result = await new CursorRuntimeAdapter().createSession({ cwd: "/tmp", systemPrompt: "sys", defaultModelId: "cursor-cli/pro", tools: "readonly" });
|
||||||
const result = await adapter.createSession({ systemPrompt: "sys" });
|
expect(result.session.model).toBe("pro");
|
||||||
expect(result.session.model).toBe("cursor/default");
|
expect(result.session.cwd).toBe("/tmp");
|
||||||
expect(result.session.systemPrompt).toBe("sys");
|
expect(result.session.tools).toBe("readonly");
|
||||||
|
expect(new CursorRuntimeAdapter().describeModel(result.session)).toBe("cursor-cli/pro");
|
||||||
});
|
});
|
||||||
|
it("fuses the first prompt and resumes retained Cursor chat", async () => {
|
||||||
it("promptWithFallback resolves without throwing", async () => {
|
const spy = vi.spyOn(transport, "launchCursorPrompt").mockResolvedValueOnce({ sessionId: "chat-1", text: "first" }).mockResolvedValueOnce({ sessionId: "chat-1", text: "second" });
|
||||||
const adapter = new CursorRuntimeAdapter();
|
const text = vi.fn(); const adapter = new CursorRuntimeAdapter();
|
||||||
await expect(adapter.promptWithFallback()).resolves.toBeUndefined();
|
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("restores the session id on transport failure and disposal aborts an active turn exactly once", async () => {
|
||||||
it("describeModel formats cursor prefix", () => {
|
vi.spyOn(transport, "launchCursorPrompt").mockRejectedValueOnce(new Error("failed"));
|
||||||
const adapter = new CursorRuntimeAdapter();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }); });
|
||||||
|
});
|
||||||
@@ -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 {
|
function formatSpawnError(error: Error & { code?: unknown }): string {
|
||||||
const code = typeof error.code === "string" ? `${error.code}: ` : "";
|
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:
|
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.
|
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.
|
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, {
|
const child = spawn(binary, args, {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const plugin: FusionPlugin = definePlugin({
|
|||||||
name: "Cursor Runtime",
|
name: "Cursor Runtime",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
},
|
},
|
||||||
factory: async () => new CursorRuntimeAdapter(),
|
factory: async (ctx) => new CursorRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined),
|
||||||
},
|
},
|
||||||
cliProviders: [
|
cliProviders: [
|
||||||
{
|
{
|
||||||
@@ -53,7 +53,7 @@ const plugin: FusionPlugin = definePlugin({
|
|||||||
discoverModels: discoverCursorProviderModels,
|
discoverModels: discoverCursorProviderModels,
|
||||||
runtime: {
|
runtime: {
|
||||||
runtimeId: "cursor",
|
runtimeId: "cursor",
|
||||||
createAdapter: async () => new CursorRuntimeAdapter(),
|
createAdapter: async (ctx) => new CursorRuntimeAdapter(ctx.settings as Record<string, unknown> | undefined),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
120
plugins/fusion-plugin-cursor-runtime/src/prompt-transport.ts
Normal file
120
plugins/fusion-plugin-cursor-runtime/src/prompt-transport.ts
Normal 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))); }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 id = "cursor";
|
||||||
readonly name = "Cursor Runtime";
|
readonly name = "Cursor Runtime";
|
||||||
|
constructor(private readonly settings?: Record<string, unknown>) {}
|
||||||
async createSession(options: { defaultModelId?: string; systemPrompt?: string }) {
|
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||||
return {
|
const messages: unknown[] = [];
|
||||||
session: {
|
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: () => {
|
||||||
model: options.defaultModelId ?? "cursor/default",
|
/* 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. */
|
||||||
systemPrompt: options.systemPrompt,
|
session.disposed = true;
|
||||||
messages: [],
|
session.activeAbortController?.abort();
|
||||||
},
|
} };
|
||||||
sessionFile: undefined,
|
return { session, sessionFile: undefined };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
async promptWithFallback(session: CursorStreamSession, prompt: string, _options?: unknown): Promise<void> {
|
||||||
async promptWithFallback(): Promise<void> {
|
if (session.disposed) throw new Error("Cursor session is disposed.");
|
||||||
// TODO(FN-3396): Implement Cursor agent prompt streaming once a stable
|
const priorId = session.sessionId;
|
||||||
// invocation contract beyond probe/discovery commands is confirmed.
|
const first = !priorId;
|
||||||
return;
|
const sent = first ? `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}` : prompt;
|
||||||
}
|
const emitted = new Set<string>();
|
||||||
|
const controller = new AbortController();
|
||||||
describeModel(session: { model?: string }) {
|
session.activeAbortController = controller;
|
||||||
return `cursor/${session.model ?? "default"}`;
|
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\//, "")}`; }
|
||||||
}
|
}
|
||||||
|
|||||||
43
plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts
Normal file
43
plugins/fusion-plugin-cursor-runtime/src/stream-parser.ts
Normal 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" };
|
||||||
|
}
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
export interface CursorBinaryStatus {
|
export interface CursorBinaryStatus { available: boolean; authenticated?: boolean; binaryPath?: string; binaryName?: string; configuredBinaryPath?: string; usingConfiguredBinaryPath?: boolean; diagnostics?: string[]; version?: string; reason?: string; probeDurationMs: number; }
|
||||||
available: boolean;
|
|
||||||
authenticated?: boolean;
|
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; }
|
||||||
binaryPath?: string;
|
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; }
|
||||||
binaryName?: string;
|
export interface AgentSessionResult { session: CursorStreamSession; sessionFile?: string; }
|
||||||
configuredBinaryPath?: 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; }
|
||||||
usingConfiguredBinaryPath?: boolean;
|
|
||||||
diagnostics?: string[];
|
|
||||||
version?: string;
|
|
||||||
reason?: string;
|
|
||||||
probeDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -978,6 +978,9 @@ importers:
|
|||||||
'@earendil-works/pi-coding-agent':
|
'@earendil-works/pi-coding-agent':
|
||||||
specifier: 0.84.1
|
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)
|
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':
|
'@fusion/plugin-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/plugin-sdk
|
version: link:../../packages/plugin-sdk
|
||||||
|
|||||||
Reference in New Issue
Block a user