From 2753abb0845cc0498d62d45bf06d11d9e1039fbb Mon Sep 17 00:00:00 2001 From: Evan Ram Date: Tue, 4 Aug 2026 02:40:34 -0600 Subject: [PATCH] feat: add Cursor CLI agent support Wire `--agent cursor` to the Cursor `agent` binary with stream-json output, schema prompt recovery, force/trust/approve-mcps defaults, and post-success process shutdown for non-interactive runs. --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 +- AGENTS.md | 2 +- README.md | 10 +- e2e/e2e-cursor.test.ts | 272 ++++++++ e2e/fixtures/mock-cursor-agent | 4 + e2e/fixtures/mock-cursor-agent.cmd | 2 + e2e/fixtures/mock-cursor-agent.mjs | 89 +++ skills/gnhf/SKILL.md | 2 +- src/cli.test.ts | 1 + .../agents/cursor.linger.integration.test.ts | 61 ++ src/core/agents/cursor.test.ts | 589 ++++++++++++++++++ src/core/agents/cursor.ts | 404 ++++++++++++ src/core/agents/factory.test.ts | 52 ++ src/core/agents/factory.ts | 7 + src/core/bootstrap-config.golden.yml | 4 + src/core/config.test.ts | 40 ++ src/core/config.ts | 24 + 17 files changed, 1561 insertions(+), 5 deletions(-) create mode 100644 e2e/e2e-cursor.test.ts create mode 100755 e2e/fixtures/mock-cursor-agent create mode 100644 e2e/fixtures/mock-cursor-agent.cmd create mode 100755 e2e/fixtures/mock-cursor-agent.mjs create mode 100644 src/core/agents/cursor.linger.integration.test.ts create mode 100644 src/core/agents/cursor.test.ts create mode 100644 src/core/agents/cursor.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a5a87af2..65e5bb68 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -60,6 +60,7 @@ body: - opencode - copilot - pi + - cursor - other / not sure validations: required: true @@ -68,7 +69,7 @@ body: id: model attributes: label: Model / provider (if relevant) - description: For opencode, copilot, or pi this is especially useful - which provider and model were you using? + description: For opencode, copilot, pi, or cursor this is especially useful - which provider and model were you using? placeholder: "minimax / MiniMax-M2.7-highspeed" - type: input diff --git a/AGENTS.md b/AGENTS.md index b6493a42..6bfd8bf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Entry point is `src/cli.ts`. It parses flags with commander, resolves config, ha Each agent implements the `Agent` interface in `types.ts` (`name`, async `run(prompt, cwd, options)` returning `{ output, usage }`, optional `close()`). They share two responsibilities: stream stdout, extract a structured `AgentOutput` (`success`, `summary`, `key_changes_made`, `key_learnings`, commit-message fields when configured, plus `should_fully_stop` only when `--stop-when` is active) that matches the schema built by `buildAgentOutputSchema(...)`, and accumulate `TokenUsage`. `factory.ts` picks one based on config. -- `claude.ts` / `codex.ts` / `copilot.ts` / `pi.ts`: spawn the CLI per iteration in non-interactive mode. Codex uses `--output-schema` pointing at the run's schema file; Claude uses `--json-schema`, treats the last successful structured result as terminal, raises `PermanentAgentError` for low credit balance exits, and after a short grace period shuts down a lingering Claude process tree if it stays alive. Copilot uses JSONL output plus prompt-level schema instructions, then parses the final `assistant.message` content. Pi runs in JSON mode, appends the final output schema to the prompt, and parses the assistant JSON reply from Pi's streamed events. +- `claude.ts` / `codex.ts` / `copilot.ts` / `pi.ts` / `cursor.ts`: spawn the CLI per iteration in non-interactive mode. Codex uses `--output-schema` pointing at the run's schema file; Claude uses `--json-schema`, treats the last successful structured result as terminal, raises `PermanentAgentError` for low credit balance exits, and after a short grace period shuts down a lingering Claude process tree if it stays alive. Copilot uses JSONL output plus prompt-level schema instructions, then parses the final `assistant.message` content. Pi runs in JSON mode, appends the final output schema to the prompt, and parses the assistant JSON reply from Pi's streamed events. Cursor (`--agent cursor`) invokes the Cursor CLI binary `agent` with `--print --output-format stream-json`, appends the final output schema to the prompt (stdin), defaults to `--force`, `--trust`, and `--approve-mcps` unless the user overrides those flags, parses the last assistant segment (falling back to `result` text) plus usage, and after a short grace period shuts down a lingering Cursor process tree if it stays alive after a non-error result. - `rovodev.ts` / `opencode.ts`: long-running local HTTP servers managed via `managed-process.ts` (start once, reuse across iterations, close on shutdown). OpenCode creates a per-run session and applies a blanket allow rule to avoid prompt blocking. - `acp.ts`: handles `acp:` specs through the bundled `acpx` runtime and registry. It keeps a persistent per-run session keyed by run ID under `.gnhf/runs//acp-sessions`, embeds the output schema in the prompt, parses only output text deltas as final JSON, records ACP lifecycle events in `gnhf.log`, and reports per-iteration token usage from ACP `used` deltas when available with prompt-length plus tool-call estimates as a fallback. Estimated ACP usage is marked for the renderer so totals are prefixed with `~`. Path and arg overrides are native-agent-only; ACP targets are customized via `acpRegistryOverrides` in config (a target-name -> spawn-command map fed into acpx's agent registry) or by passing a raw ACP server command directly after `acp:`. Raw command specs are redacted to `acp:custom`/`custom` in debug logs, errors, and telemetry. The e2e suite exercises the full wire path against the `acp-mock` package registered through that same override mechanism. - `json-extract.ts`: shared recovery for final agent JSON that may be fenced or prose-wrapped; use it before adding ad-hoc parsing to integrations that must validate output against the agent schema. diff --git a/README.md b/README.md index 7b061e82..3268907a 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ agent: claude # codex: /path/to/custom-codex # copilot: /path/to/custom-copilot # pi: /path/to/custom-pi +# cursor: /path/to/custom-agent # Native agent CLI arg overrides (optional) # ACP targets do not support path or arg overrides. @@ -256,6 +257,9 @@ agent: claude # - gpt-5.5 # - --thinking # - high +# cursor: +# - --model +# - composer-2 # Custom ACP target commands (optional) # Maps acp: names to spawn commands. Useful for naming a @@ -286,7 +290,7 @@ Use `acpRegistryOverrides` to map `acp:` names to custom spawn commands You can also pass a raw custom ACP server command directly as a quoted `acp:` spec, for example `gnhf --agent 'acp:./bin/dev-acp --profile ci' "fix the tests"`. - Use it for agent-specific options like models, profiles, or reasoning settings without adding a dedicated `gnhf` config field for each one. -- For `codex`, `claude`, and `copilot`, `gnhf` adds its usual non-interactive permission default only when you do not provide your own permission or execution-mode flag. If you set one explicitly, `gnhf` treats that as user-managed and does not add its default on top. +- For `codex`, `claude`, `copilot`, and `cursor`, `gnhf` adds its usual non-interactive permission default only when you do not provide your own permission or execution-mode flag. If you set one explicitly, `gnhf` treats that as user-managed and does not add its default on top. - Flags that `gnhf` manages itself for a given agent, such as output-shaping or local-server startup flags, are rejected during config loading so you get a clear error instead of duplicate-argument ambiguity. For `pi` specifically, `--api-key` is also blocked; configure the Pi API key via Pi's own config or the environment variable it reads, not via `agentArgsOverride`. `commitMessage` controls the subject line that gnhf uses for each successful iteration commit. @@ -305,6 +309,7 @@ agentPathOverride: codex: /usr/local/bin/my-codex-wrapper copilot: ~/bin/copilot-wrapper pi: ~/bin/pi-wrapper + cursor: ~/bin/agent-wrapper ``` Paths may be absolute, bare executable names already on your `PATH`, `~`-prefixed, or relative to the config directory (`~/.gnhf/`). The override replaces only the binary name; all standard arguments are preserved, so the replacement must be CLI-compatible with the original agent. On Windows, `.cmd` and `.bat` wrappers are supported, including bare names resolved from `PATH`. For `rovodev`, the override must point to an `acli`-compatible binary since gnhf invokes it as ` rovodev serve ...`. @@ -325,7 +330,7 @@ Set `GNHF_TELEMETRY=0` to turn it off. ## Agents -`gnhf` supports six native agents plus ACP targets. ACP support is powered by [`acpx`](https://github.com/openclaw/acpx), which is bundled with `gnhf` and provides the runtime and agent registry for `acp:` specs. +`gnhf` supports seven native agents plus ACP targets. ACP support is powered by [`acpx`](https://github.com/openclaw/acpx), which is bundled with `gnhf` and provides the runtime and agent registry for `acp:` specs. | Agent | Flag | Requirements | Notes | | ------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -333,6 +338,7 @@ Set `GNHF_TELEMETRY=0` to turn it off. | Codex | `--agent codex` | Install OpenAI's `codex` CLI and sign in first. | `gnhf` invokes `codex exec` directly in non-interactive mode. | | GitHub Copilot CLI | `--agent copilot` | Install GitHub Copilot CLI and sign in first. | `gnhf` invokes `copilot` directly in non-interactive JSONL mode. Copilot currently exposes assistant output tokens, but not full input/cache token totals; see https://github.com/github/copilot-cli/issues/1152. | | Pi | `--agent pi` | Install the `pi` CLI and configure a usable provider/model first. | `gnhf` invokes `pi` directly in JSON mode, appends the final output schema to the prompt, and disables Pi session persistence with `--no-session`. | +| Cursor CLI | `--agent cursor` | Install Cursor's `agent` CLI and sign in first (`agent login`). | `gnhf` invokes `agent` directly in non-interactive `--print` stream-json mode, appends the final output schema to the prompt, and defaults to `--force`, `--trust`, and `--approve-mcps` unless you override those flags. After Cursor emits a non-error result, `gnhf` shuts down any lingering Cursor process tree after a short grace period. | | Rovo Dev | `--agent rovodev` | Install Atlassian's `acli` and authenticate it with Rovo Dev first. | `gnhf` starts a local `acli rovodev serve --disable-session-token ` process automatically in the repo workspace. | | OpenCode | `--agent opencode` | Install `opencode` and configure at least one usable model provider first. | `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port --print-logs` process automatically, creates a per-run session, and applies a blanket allow rule so tool calls do not block on prompts. | | ACP target | `--agent acp:` | Install and authenticate the target supported by the bundled [`acpx`](https://github.com/openclaw/acpx) registry, such as `acp:gemini`, or pass a quoted custom ACP server command. | `gnhf` runs the target through ACP with a persistent per-run session under `.gnhf/runs//acp-sessions`; token usage and `--max-tokens` use ACP `used` deltas when available, with prompt-length plus tool-call estimates as a fallback, and `agentPathOverride` and `agentArgsOverride` do not apply. | diff --git a/e2e/e2e-cursor.test.ts b/e2e/e2e-cursor.test.ts new file mode 100644 index 00000000..cbdd747c --- /dev/null +++ b/e2e/e2e-cursor.test.ts @@ -0,0 +1,272 @@ +import { execFileSync, spawn } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distCliPath = join(repoRoot, "dist", "cli.mjs"); +const fixtureBinDir = join(repoRoot, "e2e", "fixtures"); +const mockCursorAgentPath = join( + fixtureBinDir, + process.platform === "win32" ? "mock-cursor-agent.cmd" : "mock-cursor-agent", +); + +const emptyGitConfigDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-cursor-gitconfig-")); +const emptyGitConfigPath = join(emptyGitConfigDir, "gitconfig"); +writeFileSync(emptyGitConfigPath, "", "utf-8"); + +const sanitizedGitEnv: NodeJS.ProcessEnv = { + GIT_CONFIG_GLOBAL: emptyGitConfigPath, + GIT_CONFIG_SYSTEM: emptyGitConfigPath, + GIT_TERMINAL_PROMPT: "0", +}; + +interface RunResult { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +} + +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...sanitizedGitEnv }, + }).trim(); +} + +function createRepo(): string { + const cwd = mkdtempSync(join(tmpdir(), "gnhf-e2e-cursor-repo-")); + git(["init", "-b", "main"], cwd); + git(["config", "user.name", "gnhf tests"], cwd); + git(["config", "user.email", "tests@example.com"], cwd); + writeFileSync(join(cwd, "README.md"), "# fixture\n", "utf-8"); + git(["add", "README.md"], cwd); + git(["commit", "-m", "init"], cwd); + return cwd; +} + +function readJsonLines(filePath: string): Record[] { + if (!existsSync(filePath)) return []; + return readFileSync(filePath, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +function findRunLogPath(cwd: string): string { + const runsDir = join(cwd, ".gnhf", "runs"); + if (!existsSync(runsDir)) { + throw new Error(`No run directory found under ${runsDir}`); + } + const runs = readdirSync(runsDir); + if (runs.length !== 1) { + throw new Error( + `Expected exactly one run in ${runsDir}, found ${runs.length}: ${runs.join(", ")}`, + ); + } + return join(runsDir, runs[0]!, "gnhf.log"); +} + +function runCli( + cwd: string, + args: string[], + options: { env?: NodeJS.ProcessEnv } = {}, +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, [distCliPath, ...args], { + cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code, signal) => { + resolveResult({ code, signal, stdout, stderr }); + }); + child.stdin.end(); + }); +} + +function createCursorEnv( + tempDirs: string[], + options: { + mockLogPath: string; + extraConfigYaml?: string; + }, +): NodeJS.ProcessEnv { + const home = mkdtempSync(join(tmpdir(), "gnhf-e2e-cursor-home-")); + tempDirs.push(home); + mkdirSync(join(home, ".gnhf"), { recursive: true }); + writeFileSync( + join(home, ".gnhf", "config.yml"), + [ + "agent: cursor", + "preventSleep: false", + "agentPathOverride:", + ` cursor: ${mockCursorAgentPath}`, + options.extraConfigYaml ?? "", + "", + ].join("\n"), + "utf-8", + ); + + return { + ...process.env, + ...sanitizedGitEnv, + HOME: home, + USERPROFILE: home, + GNHF_TELEMETRY: "0", + GNHF_MOCK_CURSOR_LOG_PATH: options.mockLogPath, + }; +} + +describe("gnhf e2e cursor agent", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + rmSync(dir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 200, + }); + } catch { + // Best-effort cleanup on Windows file locks. + } + } + }); + + it("runs --agent cursor through stream-json with force/trust/approve-mcps defaults", async () => { + chmodSync(mockCursorAgentPath, 0o755); + const cwd = createRepo(); + tempDirs.push(cwd); + const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-cursor-logs-")); + tempDirs.push(logDir); + const mockLogPath = join(logDir, "mock-cursor.jsonl"); + + const result = await runCli( + cwd, + [ + "add a hello.txt via cursor agent", + "--agent", + "cursor", + "--max-iterations", + "1", + "--current-branch", + "--prevent-sleep", + "off", + ], + { + env: createCursorEnv(tempDirs, { mockLogPath }), + }, + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("gnhf stopped"); + expect(result.stdout).toContain("cursor ran"); + expect(result.stdout).toContain("max iterations reached (1)"); + expect(readFileSync(join(cwd, "hello.txt"), "utf-8")).toBe( + "hello from cursor mock\n", + ); + expect(git(["rev-list", "--count", "HEAD"], cwd)).toBe("2"); + expect(git(["log", "-1", "--format=%s"], cwd)).toContain("gnhf 1:"); + + const spawnEvent = readJsonLines(mockLogPath).find( + (entry) => entry.event === "spawn", + ); + expect(spawnEvent).toBeDefined(); + expect(spawnEvent?.argv).toEqual([ + "-p", + "--output-format", + "stream-json", + "--force", + "--trust", + "--approve-mcps", + ]); + expect(spawnEvent?.hasSchemaContract).toBe(true); + expect(spawnEvent?.stdinHasObjective).toBe(true); + + const debugEvents = readJsonLines(findRunLogPath(cwd)).map( + (entry) => entry.event, + ); + expect(debugEvents).toContain("agent:run:start"); + expect(debugEvents).toContain("agent:run:end"); + expect(debugEvents).toContain("run:complete"); + }, 30_000); + + it("keeps --force when agentArgsOverride.cursor sets --sandbox=enabled", async () => { + chmodSync(mockCursorAgentPath, 0o755); + const cwd = createRepo(); + tempDirs.push(cwd); + const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-cursor-logs-")); + tempDirs.push(logDir); + const mockLogPath = join(logDir, "mock-cursor-sandbox.jsonl"); + + const result = await runCli( + cwd, + [ + "add a hello.txt via cursor agent", + "--agent", + "cursor", + "--max-iterations", + "1", + "--current-branch", + "--prevent-sleep", + "off", + ], + { + env: createCursorEnv(tempDirs, { + mockLogPath, + extraConfigYaml: [ + "agentArgsOverride:", + " cursor:", + " - --sandbox=enabled", + ].join("\n"), + }), + }, + ); + + expect(result.code).toBe(0); + expect(readFileSync(join(cwd, "hello.txt"), "utf-8")).toBe( + "hello from cursor mock\n", + ); + + const spawnEvent = readJsonLines(mockLogPath).find( + (entry) => entry.event === "spawn", + ); + expect(spawnEvent?.argv).toEqual([ + "--sandbox=enabled", + "-p", + "--output-format", + "stream-json", + "--force", + "--trust", + "--approve-mcps", + ]); + }, 30_000); +}); diff --git a/e2e/fixtures/mock-cursor-agent b/e2e/fixtures/mock-cursor-agent new file mode 100755 index 00000000..2facdf5d --- /dev/null +++ b/e2e/fixtures/mock-cursor-agent @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec node "$SCRIPT_DIR/mock-cursor-agent.mjs" "$@" diff --git a/e2e/fixtures/mock-cursor-agent.cmd b/e2e/fixtures/mock-cursor-agent.cmd new file mode 100644 index 00000000..ffe711bd --- /dev/null +++ b/e2e/fixtures/mock-cursor-agent.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0\mock-cursor-agent.mjs" %* diff --git a/e2e/fixtures/mock-cursor-agent.mjs b/e2e/fixtures/mock-cursor-agent.mjs new file mode 100755 index 00000000..59f024e8 --- /dev/null +++ b/e2e/fixtures/mock-cursor-agent.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +import { appendFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +function appendLog(details) { + const logPath = process.env.GNHF_MOCK_CURSOR_LOG_PATH; + if (!logPath) return; + appendFileSync( + logPath, + `${JSON.stringify({ + timestamp: new Date().toISOString(), + pid: process.pid, + ...details, + })}\n`, + "utf-8", + ); +} + +function readStdin() { + return new Promise((resolve, reject) => { + let body = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + body += chunk; + }); + process.stdin.on("end", () => resolve(body)); + process.stdin.on("error", reject); + }); +} + +const argv = process.argv.slice(2); +const stdin = await readStdin(); + +appendLog({ + event: "spawn", + argv, + cwd: process.cwd(), + hasSchemaContract: stdin.includes("gnhf final output contract"), + stdinHasObjective: stdin.includes("add a hello.txt via cursor agent"), + stdinLen: stdin.length, +}); + +writeFileSync(join(process.cwd(), "hello.txt"), "hello from cursor mock\n", "utf-8"); + +const output = { + success: true, + summary: "cursor mock wrote hello.txt", + key_changes_made: ["hello.txt"], + key_learnings: ["cursor agent stream-json path works"], +}; +const content = JSON.stringify(output); + +process.stdout.write( + `${JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "working..." }], + }, + })}\n`, +); +process.stdout.write( + `${JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: content }], + }, + })}\n`, +); +process.stdout.write( + `${JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: content, + usage: { + inputTokens: 12, + outputTokens: 8, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + })}\n`, +); + +appendLog({ event: "done", wrote: "hello.txt" }); +process.exit(0); diff --git a/skills/gnhf/SKILL.md b/skills/gnhf/SKILL.md index f2946a3f..401bc076 100644 --- a/skills/gnhf/SKILL.md +++ b/skills/gnhf/SKILL.md @@ -149,7 +149,7 @@ Do not ask what to review first. Reconstruct state: git status --short git branch --show-current git log --oneline --decorate --max-count=20 -pgrep -fl 'gnhf|claude|codex|copilot|opencode|rovodev' || true +pgrep -fl 'gnhf|claude|codex|copilot|agent|opencode|rovodev' || true ``` Inspect likely GNHF branches, notes, logs, terminal sessions, and changed files. If a GNHF process is still running, report that first. diff --git a/src/cli.test.ts b/src/cli.test.ts index c7dd8cf5..eeed2642 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -22,6 +22,7 @@ const TEST_AGENT_NAMES = [ "opencode", "copilot", "pi", + "cursor", ]; const TEST_IS_AGENT_SPEC = (name: string) => { if (TEST_AGENT_NAMES.includes(name)) return true; diff --git a/src/core/agents/cursor.linger.integration.test.ts b/src/core/agents/cursor.linger.integration.test.ts new file mode 100644 index 00000000..d625e020 --- /dev/null +++ b/src/core/agents/cursor.linger.integration.test.ts @@ -0,0 +1,61 @@ +import { chmodSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CursorAgent } from "./cursor.js"; + +describe("CursorAgent linger shutdown (real process)", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves after shutting down a child that lingers past a success result", async () => { + const dir = mkdtempSync(join(tmpdir(), "gnhf-cursor-linger-")); + tempDirs.push(dir); + const bin = join(dir, "linger-agent.mjs"); + writeFileSync( + bin, + `#!/usr/bin/env node +const content = JSON.stringify({ + success: true, + summary: "linger integration finished", + key_changes_made: ["demo"], + key_learnings: ["shutdown after success result"], +}); +process.stdout.write(JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: content }] }, +}) + "\\n"); +process.stdout.write(JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: content, + usage: { inputTokens: 3, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }, +}) + "\\n"); +setTimeout(() => {}, 60_000); +`, + "utf8", + ); + chmodSync(bin, 0o755); + + const started = Date.now(); + const agent = new CursorAgent({ + bin, + finalResultGraceMs: 200, + platform: process.platform === "win32" ? "win32" : "darwin", + }); + + const result = await agent.run("prove linger shutdown", dir); + const elapsedMs = Date.now() - started; + + expect(result.output.success).toBe(true); + expect(result.output.summary).toBe("linger integration finished"); + // Child would otherwise block for 60s; grace shutdown must finish first. + expect(elapsedMs).toBeLessThan(5_000); + }, 15_000); +}); diff --git a/src/core/agents/cursor.test.ts b/src/core/agents/cursor.test.ts new file mode 100644 index 00000000..23e9d1ae --- /dev/null +++ b/src/core/agents/cursor.test.ts @@ -0,0 +1,589 @@ +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), + spawn: vi.fn(), +})); + +import { execFileSync, spawn } from "node:child_process"; +import { CursorAgent } from "./cursor.js"; +import { buildAgentOutputSchema } from "./types.js"; + +const mockSpawn = vi.mocked(spawn); + +function createMockProcess() { + const stdin = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }); + const proc = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin, + kill: vi.fn(), + }); + return proc as typeof proc & ReturnType; +} + +function emitJson(proc: ReturnType, event: unknown) { + proc.stdout.emit("data", Buffer.from(`${JSON.stringify(event)}\n`)); +} + +describe("CursorAgent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("has the cursor agent name and defaults the binary to agent", () => { + expect(new CursorAgent().name).toBe("cursor"); + }); + + it("spawns agent in print stream-json mode with force, trust, and approve-mcps defaults", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ platform: "linux" }); + + agent.run("test prompt", "/work/dir"); + + expect(mockSpawn).toHaveBeenCalledWith( + "agent", + [ + "-p", + "--output-format", + "stream-json", + "--force", + "--trust", + "--approve-mcps", + ], + { + cwd: "/work/dir", + detached: true, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }, + ); + expect(proc.stdin.write).toHaveBeenCalledWith( + expect.stringContaining("test prompt"), + ); + expect(proc.stdin.write).toHaveBeenCalledWith( + expect.stringContaining("gnhf final output contract"), + ); + expect(proc.stdin.end).toHaveBeenCalled(); + }); + + it("uses a shell on Windows for cmd wrapper paths", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + bin: "C:\\tools\\agent.cmd", + platform: "win32", + }); + + agent.run("test prompt", "/work/dir"); + + expect(mockSpawn).toHaveBeenCalledWith( + "C:\\tools\\agent.cmd", + expect.any(Array), + expect.objectContaining({ shell: true, detached: false }), + ); + }); + + it("uses a shell on Windows when a bare override resolves to a cmd wrapper", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + vi.mocked(execFileSync).mockReturnValue( + "C:\\tools\\cursor-switch.cmd\r\n" as never, + ); + const agent = new CursorAgent({ + bin: "cursor-switch", + platform: "win32", + }); + + agent.run("test prompt", "/work/dir"); + + expect(mockSpawn).toHaveBeenCalledWith( + "cursor-switch", + expect.any(Array), + expect.objectContaining({ shell: true }), + ); + }); + + it("passes configured extra args through and suppresses default force when user-managed", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + extraArgs: ["--model", "composer-2", "--yolo"], + }); + + agent.run("test prompt", "/work/dir"); + + const args = mockSpawn.mock.calls[0]![1] as string[]; + expect(args.slice(0, 3)).toEqual(["--model", "composer-2", "--yolo"]); + expect(args).not.toContain("--force"); + expect(args).toContain("--trust"); + expect(args).toContain("--approve-mcps"); + }); + + it("keeps default force when user only sets --sandbox", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + extraArgs: ["--sandbox=enabled"], + }); + + agent.run("test prompt", "/work/dir"); + + const args = mockSpawn.mock.calls[0]![1] as string[]; + expect(args).toEqual([ + "--sandbox=enabled", + "-p", + "--output-format", + "stream-json", + "--force", + "--trust", + "--approve-mcps", + ]); + }); + + it("suppresses default trust when the user already set it", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + extraArgs: ["--trust"], + }); + + agent.run("test prompt", "/work/dir"); + + const args = mockSpawn.mock.calls[0]![1] as string[]; + expect(args.filter((arg) => arg === "--trust")).toHaveLength(1); + expect(args).toContain("--force"); + expect(args).toContain("--approve-mcps"); + }); + + it("suppresses default approve-mcps when the user already set it", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + extraArgs: ["--approve-mcps"], + }); + + agent.run("test prompt", "/work/dir"); + + const args = mockSpawn.mock.calls[0]![1] as string[]; + expect(args.filter((arg) => arg === "--approve-mcps")).toHaveLength(1); + expect(args).toContain("--force"); + expect(args).toContain("--trust"); + }); + + it("kills the full process tree on Windows when aborted", async () => { + const proc = createMockProcess(); + Object.defineProperty(proc, "pid", { value: 6789 }); + mockSpawn.mockReturnValue(proc); + const controller = new AbortController(); + const agent = new CursorAgent({ platform: "win32" }); + + const promise = agent.run("test prompt", "/work/dir", { + signal: controller.signal, + }); + controller.abort(); + + await expect(promise).rejects.toThrow("Agent was aborted"); + expect(vi.mocked(execFileSync)).toHaveBeenCalledWith( + "taskkill", + ["/T", "/F", "/PID", "6789"], + { stdio: "ignore" }, + ); + expect(proc.kill).not.toHaveBeenCalled(); + }); + + it("parses the last assistant text and reports usage from the result event", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const onMessage = vi.fn(); + const onUsage = vi.fn(); + const agent = new CursorAgent(); + const draft = JSON.stringify({ + success: true, + summary: "stale", + key_changes_made: ["old"], + key_learnings: ["old"], + }); + const content = JSON.stringify({ + success: true, + summary: "ok", + key_changes_made: ["a"], + key_learnings: ["b"], + }); + + const promise = agent.run("test prompt", "/work/dir", { + onMessage, + onUsage, + }); + emitJson(proc, { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "working..." }], + }, + }); + emitJson(proc, { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: content }], + }, + }); + emitJson(proc, { + type: "result", + subtype: "success", + is_error: false, + result: `${draft}${content}`, + usage: { + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheWriteTokens: 5, + }, + }); + proc.emit("close", 0); + + await expect(promise).resolves.toEqual({ + output: { + success: true, + summary: "ok", + key_changes_made: ["a"], + key_learnings: ["b"], + }, + usage: { + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheCreationTokens: 5, + }, + }); + expect(onMessage).toHaveBeenCalledWith("working..."); + expect(onMessage).toHaveBeenCalledWith(content); + expect(onMessage).toHaveBeenCalledTimes(2); + expect(onUsage).toHaveBeenCalledWith({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 80, + cacheCreationTokens: 5, + }); + }); + + it("rejects stale structured output when the last assistant message is prose", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + const stale = JSON.stringify({ + success: true, + summary: "stale", + key_changes_made: ["old"], + key_learnings: ["old"], + }); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: stale }], + }, + }); + emitJson(proc, { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "could not finish" }], + }, + }); + emitJson(proc, { + type: "result", + subtype: "success", + result: `${stale}could not finish`, + }); + proc.emit("close", 0); + + await expect(promise).rejects.toThrow("Failed to parse cursor output"); + }); + + it("falls back to result text when no assistant message is present", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const onMessage = vi.fn(); + const agent = new CursorAgent(); + const content = JSON.stringify({ + success: true, + summary: "ok", + key_changes_made: [], + key_learnings: [], + }); + + const promise = agent.run("test prompt", "/work/dir", { onMessage }); + emitJson(proc, { + type: "result", + subtype: "success", + result: content, + }); + proc.emit("close", 0); + + await expect(promise).resolves.toMatchObject({ + output: { + success: true, + summary: "ok", + }, + }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("accepts a fenced JSON final answer", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "success", + result: + '```json\n{"success":true,"summary":"ok","key_changes_made":[],"key_learnings":[]}\n```', + }); + proc.emit("close", 0); + + await expect(promise).resolves.toMatchObject({ + output: { + success: true, + summary: "ok", + }, + }); + }); + + it("recovers JSON when cursor prepends prose before the final object", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "success", + result: + 'Done.\n\n{"success":true,"summary":"ok","key_changes_made":[],"key_learnings":[]}', + }); + proc.emit("close", 0); + + await expect(promise).resolves.toMatchObject({ + output: { + success: true, + summary: "ok", + }, + }); + }); + + it("includes should_fully_stop in the prompt contract when the schema requires it", () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + schema: buildAgentOutputSchema({ includeStopField: true }), + }); + + agent.run("test prompt", "/work/dir"); + + expect(proc.stdin.write).toHaveBeenCalledWith( + expect.stringContaining("should_fully_stop"), + ); + }); + + it("rejects when cursor returns no text output", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + + const promise = agent.run("test prompt", "/work/dir"); + proc.emit("close", 0); + + await expect(promise).rejects.toThrow("cursor returned no text output"); + }); + + it("rejects when the result event reports an error", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "error", + is_error: true, + result: "auth failed", + }); + proc.emit("close", 0); + + await expect(promise).rejects.toThrow("auth failed"); + }); + + it("shuts down a lingering cursor process after a non-error result", async () => { + vi.useFakeTimers(); + const processKill = vi + .spyOn(process, "kill") + .mockImplementation(() => true); + try { + const proc = createMockProcess(); + Object.defineProperty(proc, "pid", { value: 4321 }); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + finalResultGraceMs: 25, + platform: "darwin", + }); + const content = JSON.stringify({ + success: true, + summary: "done", + key_changes_made: [], + key_learnings: [], + }); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "success", + is_error: false, + result: content, + }); + + await vi.advanceTimersByTimeAsync(24); + expect(processKill).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(processKill).toHaveBeenCalledWith(-4321, "SIGTERM"); + + proc.emit("close", null); + await expect(promise).resolves.toMatchObject({ + output: { success: true, summary: "done" }, + }); + } finally { + processKill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("force kills cursor if it ignores the final-result shutdown signal", async () => { + vi.useFakeTimers(); + const processKill = vi + .spyOn(process, "kill") + .mockImplementation((pid, signal) => { + if (pid === -4321 && signal === "SIGKILL") { + queueMicrotask(() => { + proc.emit("close", null); + }); + } + return true; + }); + const proc = createMockProcess(); + Object.defineProperty(proc, "pid", { value: 4321 }); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + finalResultGraceMs: 25, + platform: "darwin", + }); + const content = JSON.stringify({ + success: true, + summary: "done", + key_changes_made: [], + key_learnings: [], + }); + + try { + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "success", + is_error: false, + result: content, + }); + + await vi.advanceTimersByTimeAsync(25); + expect(processKill).toHaveBeenCalledWith(-4321, "SIGTERM"); + + await vi.advanceTimersByTimeAsync(2_999); + expect(processKill).not.toHaveBeenCalledWith(-4321, "SIGKILL"); + + await vi.advanceTimersByTimeAsync(1); + expect(processKill).toHaveBeenCalledWith(-4321, "SIGKILL"); + + await expect(promise).resolves.toMatchObject({ + output: { success: true, summary: "done" }, + }); + } finally { + processKill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("does not schedule linger cleanup for error results", async () => { + vi.useFakeTimers(); + const processKill = vi + .spyOn(process, "kill") + .mockImplementation(() => true); + try { + const proc = createMockProcess(); + Object.defineProperty(proc, "pid", { value: 4321 }); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent({ + finalResultGraceMs: 25, + platform: "darwin", + }); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "error", + is_error: true, + result: "auth failed", + }); + + await vi.advanceTimersByTimeAsync(25); + expect(processKill).not.toHaveBeenCalled(); + + proc.emit("close", 0); + await expect(promise).rejects.toThrow("auth failed"); + } finally { + processKill.mockRestore(); + vi.useRealTimers(); + } + }); + + it("rejects when the final answer is not valid JSON", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "success", + result: "not json", + }); + proc.emit("close", 0); + + await expect(promise).rejects.toThrow("Failed to parse cursor output"); + }); + + it("rejects when the final answer misses required fields", async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + const agent = new CursorAgent(); + + const promise = agent.run("test prompt", "/work/dir"); + emitJson(proc, { + type: "result", + subtype: "success", + result: '{"success":true,"summary":"ok"}', + }); + proc.emit("close", 0); + + await expect(promise).rejects.toThrow("Failed to parse cursor output"); + }); +}); diff --git a/src/core/agents/cursor.ts b/src/core/agents/cursor.ts new file mode 100644 index 00000000..7f91ded0 --- /dev/null +++ b/src/core/agents/cursor.ts @@ -0,0 +1,404 @@ +import { execFileSync, spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { + buildAgentOutputSchema, + validateAgentOutput, + type Agent, + type AgentOutput, + type AgentOutputSchema, + type AgentResult, + type AgentRunOptions, + type TokenUsage, +} from "./types.js"; +import { parseAgentJson } from "./json-extract.js"; +import { shutdownChildProcess } from "./managed-process.js"; +import { parseJSONLStream, setupAbortHandler } from "./stream-utils.js"; + +const DEFAULT_FINAL_RESULT_EXIT_GRACE_MS = 15_000; + +interface CursorAgentDeps { + bin?: string; + extraArgs?: string[]; + finalResultGraceMs?: number; + platform?: NodeJS.Platform; + schema?: AgentOutputSchema; +} + +type JsonRecord = Record; + +interface CursorResultEvent { + type: "result"; + subtype?: string; + is_error?: boolean; + result?: string; + usage?: JsonRecord; +} + +type CursorEvent = + | { + type: "assistant"; + message?: { + content?: unknown; + }; + } + | CursorResultEvent + | { type: string }; + +function shouldUseWindowsShell( + bin: string, + platform: NodeJS.Platform, +): boolean { + if (platform !== "win32") { + return false; + } + + if (/\.(cmd|bat)$/i.test(bin)) { + return true; + } + + if (/[\\/]/.test(bin)) { + return false; + } + + try { + const resolved = execFileSync("where", [bin], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const firstMatch = resolved + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return firstMatch ? /\.(cmd|bat)$/i.test(firstMatch) : false; + } catch { + return false; + } +} + +function terminateCursorProcess( + child: ReturnType, + platform: NodeJS.Platform, +): void { + if (platform === "win32" && child.pid) { + try { + execFileSync("taskkill", ["/T", "/F", "/PID", String(child.pid)], { + stdio: "ignore", + }); + } catch { + // Best-effort: the process may have already exited. + } + return; + } + + if (child.pid) { + try { + process.kill(-child.pid, "SIGTERM"); + return; + } catch { + // Fall back to the direct child if it was not started as a process group. + } + } + + child.kill("SIGTERM"); +} + +async function shutdownCursorProcess( + child: ReturnType, + platform: NodeJS.Platform, +): Promise { + if (platform === "win32") { + terminateCursorProcess(child, platform); + return; + } + + await shutdownChildProcess(child, { + detached: true, + }); +} + +function isNonErrorResult(event: CursorResultEvent): boolean { + return !event.is_error && event.subtype !== "error"; +} + +function userSpecifiedPermissionMode(userArgs: string[]): boolean { + return userArgs.some( + (arg) => + arg === "--force" || + arg === "-f" || + arg === "--yolo" || + arg === "--auto-review", + ); +} + +function userSpecifiedTrust(userArgs: string[]): boolean { + return userArgs.some((arg) => arg === "--trust"); +} + +function userSpecifiedApproveMcps(userArgs: string[]): boolean { + return userArgs.some((arg) => arg === "--approve-mcps"); +} + +function buildCursorPrompt(prompt: string, schema: AgentOutputSchema): string { + return `${prompt} + +## gnhf final output contract + +When the iteration is complete, your final answer must be a single JSON object that matches this JSON Schema: + +\`\`\`json +${JSON.stringify(schema, null, 2)} +\`\`\` + +Return only the JSON object in the final answer. Do not wrap it in Markdown. Do not include explanatory prose outside the JSON object.`; +} + +function buildCursorArgs(extraArgs?: string[]): string[] { + const userArgs = extraArgs ?? []; + + return [ + ...userArgs, + "-p", + "--output-format", + "stream-json", + ...(userSpecifiedPermissionMode(userArgs) ? [] : ["--force"]), + ...(userSpecifiedTrust(userArgs) ? [] : ["--trust"]), + ...(userSpecifiedApproveMcps(userArgs) ? [] : ["--approve-mcps"]), + ]; +} + +function numberField(usage: JsonRecord, names: string[]): number | undefined { + for (const name of names) { + const value = usage[name]; + if (typeof value === "number") { + return value; + } + } + return undefined; +} + +function usageFromRecord(usage: JsonRecord): TokenUsage | null { + const inputTokens = numberField(usage, ["inputTokens", "input_tokens"]); + const outputTokens = numberField(usage, ["outputTokens", "output_tokens"]); + const cacheReadTokens = numberField(usage, [ + "cacheReadTokens", + "cache_read_tokens", + "cache_read_input_tokens", + ]); + const cacheCreationTokens = numberField(usage, [ + "cacheWriteTokens", + "cacheCreationTokens", + "cache_write_tokens", + "cache_creation_tokens", + "cache_creation_input_tokens", + ]); + + if ( + inputTokens === undefined && + outputTokens === undefined && + cacheReadTokens === undefined && + cacheCreationTokens === undefined + ) { + return null; + } + + return { + inputTokens: inputTokens ?? 0, + outputTokens: outputTokens ?? 0, + cacheReadTokens: cacheReadTokens ?? 0, + cacheCreationTokens: cacheCreationTokens ?? 0, + }; +} + +function textFromContentBlock(block: unknown): string | null { + if (typeof block === "string") return block; + if (!block || typeof block !== "object" || Array.isArray(block)) return null; + const record = block as JsonRecord; + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + return null; +} + +function textFromAssistantMessage(message: { content?: unknown }): string { + if (typeof message.content === "string") return message.content; + if (Array.isArray(message.content)) { + return message.content + .map(textFromContentBlock) + .filter((text): text is string => text !== null) + .join(""); + } + return ""; +} + +function parseCursorOutput( + text: string, + schema: AgentOutputSchema, +): AgentOutput { + const parsed = parseAgentJson(text, (value) => { + try { + validateAgentOutput(value, schema); + return true; + } catch { + return false; + } + }); + if (parsed !== null) { + return validateAgentOutput(parsed, schema); + } + + const fallbackParsed = parseAgentJson(text); + if (fallbackParsed !== null) { + return validateAgentOutput(fallbackParsed, schema); + } + + throw new SyntaxError( + "cursor output did not contain a parseable JSON object", + ); +} + +export class CursorAgent implements Agent { + name = "cursor"; + + private bin: string; + private extraArgs?: string[]; + private finalResultGraceMs: number; + private platform: NodeJS.Platform; + private schema: AgentOutputSchema; + + constructor(deps: CursorAgentDeps = {}) { + this.bin = deps.bin ?? "agent"; + this.extraArgs = deps.extraArgs; + this.finalResultGraceMs = + deps.finalResultGraceMs ?? DEFAULT_FINAL_RESULT_EXIT_GRACE_MS; + this.platform = deps.platform ?? process.platform; + this.schema = + deps.schema ?? buildAgentOutputSchema({ includeStopField: false }); + } + + run( + prompt: string, + cwd: string, + options?: AgentRunOptions, + ): Promise { + const { onUsage, onMessage, signal, logPath } = options ?? {}; + + return new Promise((resolve, reject) => { + const logStream = logPath ? createWriteStream(logPath) : null; + const child = spawn(this.bin, buildCursorArgs(this.extraArgs), { + cwd, + detached: this.platform !== "win32", + shell: shouldUseWindowsShell(this.bin, this.platform), + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + + child.stdin?.write(buildCursorPrompt(prompt, this.schema)); + child.stdin?.end(); + + if ( + setupAbortHandler(signal, child, reject, () => + terminateCursorProcess(child, this.platform), + ) + ) { + return; + } + + let lastAssistantText: string | null = null; + let resultText: string | null = null; + let resultError: string | null = null; + let finalResultCleanupTimer: ReturnType | null = null; + let closedAfterFinalCleanup = false; + let stderr = ""; + let usage: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + }; + + child.stderr!.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("error", (err) => { + reject(new Error(`Failed to spawn cursor: ${err.message}`)); + }); + + parseJSONLStream(child.stdout!, logStream, (event) => { + if (event.type === "assistant") { + const text = textFromAssistantMessage( + (event as { message?: { content?: unknown } }).message ?? {}, + ).trim(); + if (text) { + lastAssistantText = text; + onMessage?.(text); + } + return; + } + + if (event.type !== "result") return; + + const result = event as CursorResultEvent; + if (typeof result.result === "string") { + resultText = result.result; + } + + if (result.is_error || result.subtype === "error") { + resultError = + (typeof result.result === "string" && result.result.trim()) || + "cursor reported an error result"; + } + + if (result.usage) { + const nextUsage = usageFromRecord(result.usage); + if (nextUsage) { + usage = nextUsage; + onUsage?.({ ...usage }); + } + } + + if (isNonErrorResult(result)) { + if (finalResultCleanupTimer) { + clearTimeout(finalResultCleanupTimer); + } + finalResultCleanupTimer = setTimeout(() => { + closedAfterFinalCleanup = true; + void shutdownCursorProcess(child, this.platform); + }, this.finalResultGraceMs); + } + }); + + child.on("close", (code) => { + if (finalResultCleanupTimer) { + clearTimeout(finalResultCleanupTimer); + } + logStream?.end(); + if (code !== 0 && !closedAfterFinalCleanup) { + reject(new Error(`cursor exited with code ${code}: ${stderr}`)); + return; + } + + if (resultError) { + reject(new Error(resultError)); + return; + } + + const finalText = (lastAssistantText ?? resultText ?? "").trim(); + if (!finalText) { + reject(new Error("cursor returned no text output")); + return; + } + + try { + const output = parseCursorOutput(finalText, this.schema); + resolve({ output, usage }); + } catch (err) { + reject( + new Error( + `Failed to parse cursor output: ${err instanceof Error ? err.message : err}`, + ), + ); + } + }); + }); + } +} diff --git a/src/core/agents/factory.test.ts b/src/core/agents/factory.test.ts index c68ada7e..fa767170 100644 --- a/src/core/agents/factory.test.ts +++ b/src/core/agents/factory.test.ts @@ -45,6 +45,17 @@ vi.mock("./pi.js", () => { return { PiAgent }; }); +vi.mock("./cursor.js", () => { + const CursorAgent = vi.fn(function ( + this: Record, + deps?: Record, + ) { + this.name = "cursor"; + this.deps = deps; + }); + return { CursorAgent }; +}); + vi.mock("./rovodev.js", () => { const RovoDevAgent = vi.fn(function ( this: Record, @@ -88,6 +99,7 @@ import { CopilotAgent } from "./copilot.js"; import { CodexAgent } from "./codex.js"; import { OpenCodeAgent } from "./opencode.js"; import { PiAgent } from "./pi.js"; +import { CursorAgent } from "./cursor.js"; import { RovoDevAgent } from "./rovodev.js"; import type { RunInfo } from "../run.js"; @@ -301,6 +313,46 @@ describe("createAgent", () => { }); }); + it("creates a CursorAgent when name is 'cursor'", () => { + const agent = createAgent("cursor", stubRunInfo, undefined, undefined, { + includeStopField: false, + }); + expect(CursorAgent).toHaveBeenCalledWith({ + bin: undefined, + extraArgs: undefined, + schema: noStopSchema, + }); + expect(agent.name).toBe("cursor"); + }); + + it("passes path override and extra args through to the CursorAgent", () => { + const agent = createAgent( + "cursor", + stubRunInfo, + "/custom/agent", + ["--model", "composer-2"], + { includeStopField: false }, + ); + + expect(CursorAgent).toHaveBeenCalledWith({ + bin: "/custom/agent", + extraArgs: ["--model", "composer-2"], + schema: noStopSchema, + }); + expect(agent.name).toBe("cursor"); + }); + + it("hands CursorAgent a schema that requires should_fully_stop when includeStopField is true", () => { + createAgent("cursor", stubRunInfo, undefined, undefined, { + includeStopField: true, + }); + expect(CursorAgent).toHaveBeenCalledWith({ + bin: undefined, + extraArgs: undefined, + schema: withStopSchema, + }); + }); + it("creates a RovoDevAgent when name is 'rovodev'", () => { const agent = createAgent("rovodev", stubRunInfo, undefined, undefined, { includeStopField: false, diff --git a/src/core/agents/factory.ts b/src/core/agents/factory.ts index cddb3840..73e28388 100644 --- a/src/core/agents/factory.ts +++ b/src/core/agents/factory.ts @@ -10,6 +10,7 @@ import { AcpAgent } from "./acp.js"; import { ClaudeAgent } from "./claude.js"; import { CopilotAgent } from "./copilot.js"; import { CodexAgent } from "./codex.js"; +import { CursorAgent } from "./cursor.js"; import { OpenCodeAgent } from "./opencode.js"; import { PiAgent } from "./pi.js"; import { RovoDevAgent } from "./rovodev.js"; @@ -73,6 +74,12 @@ export function createAgent( extraArgs: agentArgsOverride, schema, }); + case "cursor": + return new CursorAgent({ + bin: pathOverride, + extraArgs: agentArgsOverride, + schema, + }); case "rovodev": return new RovoDevAgent(runInfo.schemaPath, { bin: pathOverride, diff --git a/src/core/bootstrap-config.golden.yml b/src/core/bootstrap-config.golden.yml index 4117a30a..f21886f3 100644 --- a/src/core/bootstrap-config.golden.yml +++ b/src/core/bootstrap-config.golden.yml @@ -10,6 +10,7 @@ agent: claude # codex: /path/to/custom-codex # copilot: /path/to/custom-copilot # pi: /path/to/custom-pi +# cursor: /path/to/custom-agent # Native agent CLI arg overrides (optional) # ACP targets do not support path or arg overrides. @@ -30,6 +31,9 @@ agent: claude # - gpt-5.5 # - --thinking # - high +# cursor: +# - --model +# - composer-2 # Custom ACP target commands (optional) # Maps acp: names to spawn commands. Useful for naming a diff --git a/src/core/config.test.ts b/src/core/config.test.ts index fe188bb9..4ee92052 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -285,6 +285,9 @@ describe("loadConfig", () => { " - gpt-5.5", " - --thinking", " - high", + " cursor:", + " - --model", + " - composer-2", "", ].join("\n"), ); @@ -305,6 +308,7 @@ describe("loadConfig", () => { "--thinking", "high", ], + cursor: ["--model", "composer-2"], }); }); @@ -430,6 +434,42 @@ describe("loadConfig", () => { ); }); + it("allows safe agentArgsOverride.cursor flags", () => { + mockReadFileSync.mockReturnValue( + "agentArgsOverride:\n cursor:\n - --model\n - composer-2\n - --force\n", + ); + + const config = loadConfig(); + + expect(config.agentArgsOverride).toEqual({ + cursor: ["--model", "composer-2", "--force"], + }); + }); + + it.each([ + "-p", + "--print", + "--output-format", + "--output-format=json", + "--stream-partial-output", + "--workspace", + "--workspace=/tmp", + "--resume", + "--continue", + "--worktree", + ])( + "throws when agentArgsOverride.cursor contains reserved flag %s", + (flag) => { + mockReadFileSync.mockReturnValue( + `agentArgsOverride:\n cursor:\n - ${flag}\n`, + ); + + expect(() => loadConfig()).toThrow( + /agentArgsOverride\.cursor\[0\].*managed by gnhf/, + ); + }, + ); + it("reads acpRegistryOverrides from config", () => { mockReadFileSync.mockReturnValue( [ diff --git a/src/core/config.ts b/src/core/config.ts index 5613639f..fb8b5f2f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,6 +13,7 @@ export const AGENT_NAMES = [ "opencode", "copilot", "pi", + "cursor", ] as const; export type AgentName = (typeof AGENT_NAMES)[number]; @@ -191,6 +192,25 @@ function isReservedAgentArg(agent: AgentName, arg: string): boolean { arg === "--api-key" || arg.startsWith("--api-key=") ); + case "cursor": + return ( + arg === "-p" || + arg === "--print" || + arg === "--output-format" || + arg.startsWith("--output-format=") || + arg === "--stream-partial-output" || + arg === "--workspace" || + arg.startsWith("--workspace=") || + arg === "--resume" || + arg.startsWith("--resume=") || + arg === "--continue" || + arg === "-w" || + arg === "--worktree" || + arg.startsWith("--worktree=") || + arg === "--worktree-base" || + arg.startsWith("--worktree-base=") || + arg === "--skip-worktree-setup" + ); } } @@ -524,6 +544,7 @@ function serializeConfig(config: Config): string { "# codex: /path/to/custom-codex", "# copilot: /path/to/custom-copilot", "# pi: /path/to/custom-pi", + "# cursor: /path/to/custom-agent", "", "# Native agent CLI arg overrides (optional)", "# ACP targets do not support path or arg overrides.", @@ -544,6 +565,9 @@ function serializeConfig(config: Config): string { "# - gpt-5.5", "# - --thinking", "# - high", + "# cursor:", + "# - --model", + "# - composer-2", "", "# Custom ACP target commands (optional)", "# Maps acp: names to spawn commands. Useful for naming a",