/** * cursor spawn adapter: runs one subagent as a headless `cursor-agent -p` * subprocess with `--output-format json`. * * Contract notes (LIVE-VERIFIED 2026-07-17 against cursor-agent * 2026.07.16-899851b: schema-gated triage + text stages round-trip via * `--adapter cursor`, session_id parses from the envelope): * - `cursor-agent -p "" --output-format json` prints a single result * envelope modeled on Claude Code's (`{type: "result", is_error, result, * session_id, …}`). * - `--trust` is required: headless runs refuse untrusted workspaces (exit 1, * "Workspace Trust Required") — see the argv comment below. * - `--force` is required: print mode has no channel for an operator to approve * shell commands, so Smart Auto can leave a child able to patch files but * unable to test or commit them. * - Envelope drift guard: when stdout doesn't parse as JSON but the process * exited 0, the raw stdout is returned as the reply text. * - No per-run cost surface → undefined. No max-turns equivalent → `maxTurns` * accepted and ignored (documented in the CLI docs page). */ import { exec } from "../../lib/exec.ts"; import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profiles.ts"; import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts"; import { notFoundError } from "./adapters.ts"; import { buildChildEnv } from "./child-env.ts"; import { resolveSandboxProjection } from "./sandbox-projection.ts"; import { isUpstreamFailureText, vendorFailureText } from "./spawn-failure.ts"; import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts"; interface CursorEnvelope { type?: string; is_error?: boolean; result?: string; session_id?: string; } /** Exported for unit tests (no live binary to test against). */ export function parseCursorOutput(stdout: string): { text: string; sessionId?: string; isError: boolean; } { try { const envelope = JSON.parse(stdout) as CursorEnvelope; return { text: String(envelope.result ?? ""), sessionId: envelope.session_id, isError: Boolean(envelope.is_error), }; } catch { return { text: stdout, isError: false }; } } export function buildCursorInvocation(req: SpawnRequest): AdapterInvocation { validateAdapterEffort("cursor", req.effort); if (req.filesystemPolicy) { // Declared unrepresentable: refuse rather than drop it silently (ADR 0039). resolveSandboxProjection( "cursor", builtinAdapterProfile("cursor")?.sandboxProjection, req.filesystemPolicy, ); } // --trust: headless cursor-agent refuses untrusted workspaces (exit 1, // "Workspace Trust Required"). --force: a print-mode child has no interactive // approval channel, so host-authorized workflow dispatch must let it run // commands. Neither flag maps host policy into child tools; that capability // remains explicitly unsupported. const argv = ["cursor-agent", "-p", req.prompt, "--output-format", "json", "--trust", "--force"]; if (req.model) argv.push("--model", req.model); return { argv }; } export function normalizeCursorResult(raw: AdapterRawResult): SpawnResult { if (raw.timedOut) { return { ok: false, text: "", durationMs: raw.durationMs, error: `cursor timed out after ${raw.durationMs}ms and was killed`, }; } // Structural environment signal: the binary was never there (spawned directly, // so a missing binary surfaces as ENOENT). Uncharged and not retried. if (raw.spawnErrno === "ENOENT") { return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("cursor"), class: "environment", }; } if (raw.exitCode === 127) { // A bare 127 with no errno is a shell/vendor 127, indistinguishable from a // legitimate one — charged as work rather than classed environment. return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("cursor") }; } if (raw.exitCode !== 0) { const failureText = vendorFailureText(raw); return { ok: false, text: "", durationMs: raw.durationMs, error: `cursor-agent exited ${raw.exitCode}: ${failureText}`, ...(isUpstreamFailureText(failureText) ? { class: "upstream" as const } : {}), }; } const parsed = parseCursorOutput(raw.stdout); if (parsed.isError) { return { ok: false, text: parsed.text, sessionId: parsed.sessionId, durationMs: raw.durationMs, error: `cursor-agent reported is_error: ${parsed.text.slice(0, 300)}`, ...(isUpstreamFailureText(parsed.text) ? { class: "upstream" as const } : {}), }; } return { ok: true, text: parsed.text, sessionId: parsed.sessionId, durationMs: raw.durationMs, }; } export const cursorSpawner: Spawner = async (req: SpawnRequest): Promise => { const t0 = Date.now(); let invocation: AdapterInvocation; try { invocation = buildCursorInvocation(req); } catch (error) { return { ok: false, text: "", durationMs: 0, error: (error as Error).message }; } const r = await exec(invocation.argv, { cwd: req.cwd, env: buildChildEnv(req.runId, { subscriptionOnly: req.subscriptionOnly, agentId: req.agentId, }), timeout: req.timeoutMs, }); return normalizeCursorResult({ ...r, durationMs: Date.now() - t0 }); };