references/dispatch-and-poll.md
# Dispatch and poll
`scripts/relay.mjs` wraps Cursor's headless print mode (`cursor-agent -p`), captures its structured
stream, and writes a `result.json`. Run one command, then read one file.
## Before the first run
```bash
command -v cursor-agent
cursor-agent --version
cursor-agent status
```
Follow the installer for your platform at [cursor.com/cli](https://cursor.com/cli), inspect what it
will run, then authenticate with `cursor-agent login`. On Windows the CLI installs as a `.cmd` shim;
the relay handles that launch itself, no setup needed.
## Dispatching
```bash
node "<skill-dir>/scripts/relay.mjs" --brief brief.txt --cd /path/to/repo
```
`<skill-dir>` is the installed folder containing this skill's `SKILL.md`.
| Flag | Effect |
| --- | --- |
| `--brief <file>` | Brief path. Omit it to read the brief from stdin. |
| `--cd <dir>` | Working root and child process cwd (default: current directory). |
| `--lane <name>` | Fleet lane from `delegate-setup` config. Applies that lane's dials; fails if the lane's `implementer` is not this relay. Explicit dial flags win. |
| `--model <name>` | Cursor model for this run (default: your Cursor default, usually `auto`). Names come from `cursor-agent models`. |
| `--read-only` | Run in Cursor's plan mode: read-only analysis, no edits, no `--force`. |
| `--sandbox <mode>` | Override Cursor's sandbox for this dispatch: `enabled` or `disabled`. |
| `--no-force` | Keep the run write-capable but withhold `--force`; commands requiring approval are refused. |
| `--session <id>` | Resume a specific Cursor chat (`--resume <id>`); send only the delta brief. |
| `--resume-last` | Resume the most recent Cursor chat (`--continue`); send only the delta brief. |
| `--add-dir <dir>` | Add an extra workspace root on Cursor `2026.07.23` or newer. Repeatable. Edits there are not reported in `touchedFiles`. |
| `--timeout <dur>` | Relay watchdog (default: `30m`; h/m/s strings). cursor-agent has no timeout flag. |
| `--out-dir <dir>` | Artifact directory (default: a fresh directory under the system temp dir). |
| `-h`, `--help` | Print the relay's header help. |
`--session` and `--resume-last` are mutually exclusive. The child cwd pins the primary workspace;
`--add-dir` adds extra workspace roots only.
A fresh run defaults to write-capable with `--force` (commands run without approval unless your
Cursor config denies them). `--no-force` withholds automatic command approval while retaining file
edits; `--read-only` switches to plan mode instead. The relay always passes `--trust` so a headless
run never stalls on the workspace-trust prompt — point `--cd` only at repositories you trust.
## Artifacts and result fields
Artifacts live outside the repo by default, so they do not appear in `touchedFiles`; an `--out-dir`
inside the worktree can make the artifacts appear there:
- `brief.txt` — the exact brief.
- `events.jsonl` — raw cursor-agent stdout events.
- `final.txt` — the final report; absent if none was emitted.
- `stderr.txt` — complete stderr.
- `result.json` — the stable `delegate-relay.result.v1` contract.
`result.json` fields:
- `schema`, `tool` (`"cursor-agent"`), `status` (`completed` | `failed` | `timeout` | `aborted` |
`cursor_agent_unavailable`), `exitCode`, and `signal` (`null` unless the child died on a signal).
- `workdir`, `model` (the requested name or `null`), `resolvedModel` (the model Cursor actually
served, from its init event), `permissionMode` (the mode Cursor reported applying), `readOnly`,
`force`, `sandbox` (the requested value or `null`, not a claim about what Cursor applied),
`resumed`, `cursorAgentVersion`, `sessionId`, `startedAt`, and `finishedAt`.
- `briefPath`, `finalPath`, `eventsPath`, and `stderrPath`.
- `finalMessage` — the `result` field of Cursor's closing event; when the run died before emitting
one, the assistant text chunks joined with `"\n\n"` instead. Tool calls and tool results are
excluded.
- `touchedFiles` — `git status --porcelain` lines for the **final working tree under `--cd` only**,
not an attribution of Cursor's edits: anything already dirty before dispatch shows up too, and
edits Cursor makes inside `--add-dir` roots do not show up at all — inspect those trees yourself.
Dispatch from a clean tree when you want the list to read as "what Cursor changed". `null` means
git could not report; `[]` means git ran and the tree is clean.
- `usage` — Cursor's token-usage object from the closing result event, or `null` if no result event
supplied one.
- `stderrTail` — the last 20 non-empty stderr lines on any run that did not complete (`failed`,
`timeout`, `aborted`), except a launch failure, which reports `failed` with no `stderrTail`.
- `error` — present for launch failures, when the relay watchdog fires (`timeout`), on an `aborted`
run, and when Cursor's own result event carries `is_error: true`.
## Waiting for completion
The helper blocks. Use the orchestrator's background-command facility, or background it in a shell
and poll for `result.json`. The run is done only when the process exits and the file contains a
`status`.
A pre-run usage error exits 2 and writes no result. A missing `cursor-agent` exits 127 and writes
`status: "cursor_agent_unavailable"`.
## When a run misbehaves
- **`status: "cursor_agent_unavailable"` (exit 127):** install the Cursor CLI, authenticate with
`cursor-agent login`, and re-dispatch.
- **`status: "failed"`:** read `stderrTail`, `stderrPath`, and the tail of `events.jsonl`. If the
result event carried `is_error: true` the relay reports `failed` even on a zero exit; Cursor's own
message is in `finalMessage`. An unknown `--model` name fails fast — re-check against
`cursor-agent models`.
- **A version-preflight failure:** the relay writes `failed` with the probe's exit code, or `timeout`
with exit 124 when the probe exceeds the smaller of the run watchdog and 10 seconds. Cursor is not
dispatched.
- **`status: "aborted"`:** the relay itself was killed (its parent's timeout, a stopped task, a
closed terminal) and forwarded the kill to cursor-agent. The result is written before the relay
exits; inspect the working tree before re-dispatching. On native Windows a hard kill of the relay
is uncatchable (Node supports no `SIGTERM` handler there), so this status may never get written —
a relay process that is gone without a `result.json` is an aborted run; inspect the working tree
and `events.jsonl` directly.
- **`status: "failed"` with `signal: "SIGKILL"`:** the host killed the process, commonly through the
OOM killer or a supervisor timeout. This is not a Cursor error; check host memory and re-dispatch,
or split the task into smaller briefs.
- **`status: "timeout"`:** the `--timeout` watchdog killed the run; `error` reads
`cursor-agent did not finish within --timeout <dur>; killed by the relay watchdog`. Increase
`--timeout` or split the task. The relay sends SIGTERM, waits 10 seconds, then sends SIGKILL if
needed (on Windows a single process-tree kill).
- **Empty `finalMessage`:** inspect `touchedFiles` and the diff. Add a
`<structured_output_contract>` to the next brief to require a closing report.
- **Every command Cursor runs is rejected with "Hook blocked with message: … eval: … syntax error
near unexpected token `&`" (or Cursor reports "the terminal hook failed"):** a cursor-agent bug,
not a hook bug. When cursor-agent is launched from a Git Bash (MSYS) console on Windows — which
is what an orchestrator's bash tool uses — it selects `bash.exe` as its persistent shell while
still generating its hook wrappers in PowerShell syntax, so every configured hook (its own
`~/.cursor/hooks.json` and any imported Claude Code `PreToolUse` hooks) errors and Cursor blocks
the command, fail-closed. File edits still work; command execution does not — which also means
Cursor cannot run the gates, only claim it could not. Workaround: dispatch the relay from a
PowerShell or cmd console instead (observed fixed there); or temporarily remove the hook entries
for the run. Verified on cursor-agent 2026.07.23.
## Recovering lost work
`events.jsonl` in the run directory records every event the implementer streamed. If finished
work is lost — the run killed late, or the working tree damaged afterward — read the event log
before re-dispatching: it identifies which files and tool commands were involved, which scopes
what needs redoing. Whether it also carries the edit contents depends on what the CLI streams,
so treat any reconstruction as unverified until it matches a working-tree diff — when the tree
still holds the work, preserve the tree rather than replaying the log.
## What the relay runs
The argv is equivalent to:
```bash
cursor-agent --print --output-format stream-json --trust \
[--force | --mode plan] [--sandbox enabled|disabled] [--model <name>] \
[--resume <id> | --continue] \
[--add-dir <dir> ...] # brief on stdin
```
`--no-force` omits both `--force` and `--mode plan`; the run can edit files, but approval-gated
commands are refused.
The brief rides stdin, so it is not visible in the host process list and has no OS argument-size
cap. On Windows the launch goes through the shell so the `cursor-agent.cmd` shim resolves; the brief
still travels on stdin, sandbox, model, session, and directory values are validated, and spaceable
values are quoted.
## The commit boundary
The relay never commits. Cursor edits the working tree; the orchestrator reviews, re-runs the gates,
and commits. See [review-and-land.md](review-and-land.md).
scripts/relay.mjs
#!/usr/bin/env node
/**
* delegate-skills · cursor-delegate · relay.mjs
*
* Dispatch a self-contained brief to the Cursor Agent CLI (`cursor-agent -p`),
* capture the run, and write a structured result the orchestrating agent can
* review. The orchestrator runs this one command and reads the result JSON —
* every Cursor-specific mechanic lives in here, which keeps the skill
* orchestrator-agnostic. Verified against cursor-agent 2026.07.23 on Windows.
*
* Trust posture: relay.mjs itself makes no network calls, reads or writes no
* credentials, and sends no telemetry; it has no dependencies (Node built-ins
* only). It shells out only to `cursor-agent` and `git` (plus taskkill on
* Windows for process-tree termination). The `cursor-agent` process it
* launches does authenticate — exactly as you do at the terminal. Read this
* file before you run it.
*
* The brief is fed on the child's stdin, never argv, so it is not visible in
* the host process list and has no OS argument-size cap.
*
* It deliberately does NOT commit. Committing is always the orchestrator's job
* — after it reviews the diff and re-runs the project gates.
*
* Autonomy: a fresh run defaults to write-capable with `--force` (Cursor runs
* commands without approval unless your Cursor config denies them). Pass
* `--read-only` to run in Cursor's plan mode (read-only/planning, no edits)
* instead. The relay always passes `--trust` so a headless run never stalls
* on the workspace-trust prompt — point --cd only at repositories you trust.
*
* Usage:
* node relay.mjs --brief <file> [options]
* cat brief.txt | node relay.mjs [options]
*
* Options:
* --brief <file> Path to the brief. If omitted, read it from stdin.
* --cd <dir> Working root for Cursor (default: current directory).
* --lane <name> Fleet lane from delegate-setup config (dials apply; explicit flags win).
* --model <name> Cursor model (default: your Cursor default, usually
* auto). List names with `cursor-agent models`.
* --read-only Run in Cursor's plan mode: read-only analysis, no
* edits, no --force.
* --sandbox <mode> Explicitly enable or disable Cursor's sandbox for
* this dispatch (enabled | disabled).
* --no-force Withhold --force on a write-capable run; commands
* that require approval are refused instead of run.
* --session <id> Resume a specific Cursor chat (`--resume <id>`);
* send only the delta brief.
* --resume-last Resume the most recent Cursor chat (`--continue`);
* send only the delta brief.
* --add-dir <dir> Add an extra workspace root. Repeatable; requires
* cursor-agent 2026.07.23 or newer.
* --timeout <dur> Relay-side watchdog (default: 30m; h/m/s strings
* like 90s, 45m, 2h). cursor-agent has no timeout flag.
* --out-dir <dir> Where to write run artifacts (default: a fresh dir
* under the system temp dir).
* -h, --help Show this help.
*
* Result: written to <out-dir>/result.json and summarized on stdout —
* status, exitCode, signal, cursorAgentVersion, sessionId, resolvedModel,
* permissionMode, force, sandbox (requested value or null), usage,
* finalMessage (Cursor's own report),
* touchedFiles (git porcelain, null if git cannot report), and paths to
* brief.txt, final.txt, events.jsonl, and stderr.txt.
*
* Exit codes: a pre-run usage error (bad/missing args, empty brief) exits 2
* before any run and writes no result file; a missing `cursor-agent` binary
* exits 127 and writes status `cursor_agent_unavailable`; otherwise the exit
* code mirrors cursor-agent's own (0 success, non-zero failure). If the child
* dies on a signal, the exit code is 128 plus the signal number and
* `result.json` records the signal. Once the brief validates, `result.json` is
* written on every outcome — completed, failed, timeout (the --timeout
* watchdog fired), aborted (the relay itself was killed and forwarded the kill
* to cursor-agent), or cursor_agent_unavailable.
*/
import {spawn, execSync, execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync, renameSync, rmSync, readFileSync, existsSync, appendFileSync } from "node:fs";
import {join, resolve, basename, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { constants, tmpdir } from "node:os";
import { StringDecoder } from "node:string_decoder";
const MAX_BUFFERED_CHARS = 1_048_576;
const DEFAULT_TIMEOUT = "30m";
const MAX_TIMER_MS = 2_147_483_647;
const VERSION_PROBE_TIMEOUT_MS = 10_000;
const SAFE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._:@/[\],=-]*$/;
const SAFE_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
const SANDBOX_MODES = new Set(["enabled", "disabled"]);
const IMPLEMENTER_KEY = "cursor";
function makeEventScanner(onObject) {
let buf = "";
let index = 0;
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
return (chunk) => {
if (!chunk) return;
buf += chunk;
for (;;) {
while (index < buf.length) {
const ch = buf[index];
// Only track strings inside an object (depth > 0). At depth 0 we are
// skipping a junk prefix, and an unmatched `"` there must not swallow the
// real `{...}` that follows in the same chunk.
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
} else if (ch === '"') {
if (depth > 0) inString = true;
} else if (ch === "{") {
if (depth === 0) start = index;
depth += 1;
} else if (ch === "}") {
if (depth > 0) {
depth -= 1;
if (depth === 0 && start !== -1) {
const slice = buf.slice(start, index + 1);
try { onObject(JSON.parse(slice)); } catch { /* skip malformed */ }
start = -1;
}
}
}
index += 1;
}
if (depth === 0 || start === -1 || buf.length - start <= MAX_BUFFERED_CHARS) break;
// A complete object may exceed the retained-input cap within this chunk.
// Drop only an oversized partial, then rescan its suffix so a later
// concatenated event is not lost.
buf = buf.slice(start + MAX_BUFFERED_CHARS);
index = 0;
start = -1;
depth = 0;
inString = false;
escaped = false;
}
if (depth > 0 && start !== -1) {
if (start > 0) {
buf = buf.slice(start);
index -= start;
start = 0;
}
} else {
buf = "";
index = 0;
start = -1;
}
};
}
function applyFleetLane(opts, flagged) {
if (!opts.lane) return;
const script = join(dirname(fileURLToPath(import.meta.url)), "../../delegate-setup/scripts/lane.mjs");
if (!existsSync(script)) {
fail("--lane requires the delegate-setup skill installed beside this relay");
}
const r = spawnSync(
process.execPath,
[script, "resolve", "--cwd", opts.cd, "--lane", opts.lane, "--implementer", IMPLEMENTER_KEY],
{ encoding: "utf8", env: process.env },
);
if (r.error) fail(`lane resolve failed: ${r.error.message}`);
if (r.status !== 0) {
fail((r.stderr || "lane resolve failed").trim().replace(/^lane\.mjs:\s*/, ""));
}
let resolved;
try {
const lines = (r.stdout || "").trim().split("\n").filter(Boolean);
resolved = JSON.parse(lines[lines.length - 1]);
} catch {
fail("lane resolve returned invalid JSON");
}
opts.laneSource = resolved.source;
for (const [field, value] of Object.entries(resolved.dials || {})) {
if (flagged.has(field)) continue;
if (field === "autonomy" && (flagged.has("autonomy") || flagged.has("sandbox") || flagged.has("readOnly"))) continue;
if (field === "agent" && (flagged.has("agent") || flagged.has("readOnly"))) continue;
if (field === "sandbox" && (flagged.has("sandbox") || flagged.has("readOnly"))) continue;
if (field === "permissionMode" && (flagged.has("permissionMode") || flagged.has("readOnly"))) continue;
if (field === "planOnly" && (flagged.has("planOnly") || flagged.has("readOnly"))) continue;
if (field === "readOnly" && flagged.has("readOnly")) continue;
if (field === "force" && flagged.has("force")) continue;
opts[field] = value;
}
}
function fail(message, code = 2) {
process.stderr.write(`relay: ${message}\n`);
process.exit(code);
}
function parseArgs(argv) {
const flagged = new Set();
const opts = {
lane: null,
laneSource: null,
brief: null,
cd: process.cwd(),
model: null,
readOnly: false,
force: true,
sandbox: null,
session: null,
resumeLast: false,
addDirs: [],
timeout: DEFAULT_TIMEOUT,
outDir: null,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
const value = argv[i + 1];
if (value === undefined) fail(`${arg} requires a value`);
i += 1;
return value;
};
switch (arg) {
case "-h":
case "--help":
process.stdout.write(headerComment());
process.exit(0);
break;
case "--brief": opts.brief = next(); break;
case "--cd": opts.cd = resolve(next()); break;
case "--lane": opts.lane = next(); break;
case "--model": opts.model = next(); flagged.add("model"); break;
case "--read-only": opts.readOnly = true; flagged.add("readOnly"); break;
case "--sandbox": opts.sandbox = next(); flagged.add("sandbox"); break;
case "--no-force": opts.force = false; flagged.add("force"); break;
case "--session": opts.session = next(); break;
case "--resume-last": opts.resumeLast = true; break;
case "--add-dir": opts.addDirs.push(next()); break;
case "--timeout": opts.timeout = next(); flagged.add("timeout"); break;
case "--out-dir": opts.outDir = resolve(next()); break;
default:
fail(`unknown option: ${arg}`);
}
}
applyFleetLane(opts, flagged);
if (opts.sandbox !== null && !SANDBOX_MODES.has(opts.sandbox)) {
fail(`--sandbox "${opts.sandbox}" is invalid; expected enabled or disabled`);
}
if (opts.resumeLast && opts.session) {
fail("--resume-last and --session are mutually exclusive; pass only one");
}
if (opts.model !== null && !SAFE_MODEL.test(opts.model)) {
fail("--model contains unsupported characters (allowed: letters, digits, . _ : @ / [ ] , = -)");
}
if (opts.session !== null && !SAFE_SESSION.test(opts.session)) {
fail("--session contains unsupported characters (allowed: letters, digits, . _ : -)");
}
// cursor-agent resolves a relative --add-dir against ITS cwd, so resolve
// against --cd (not the relay's own cwd) — and only after the loop, since
// --add-dir may appear before --cd on the command line. resolve() passes
// absolutes through.
opts.addDirs = opts.addDirs.map((dir) => resolve(opts.cd, dir));
if (process.platform === "win32" && opts.addDirs.some((dir) => /[\0\r\n"%!]/.test(dir))) {
fail("--add-dir cannot contain %, !, a quote, or a newline when cursor-agent launches through cmd.exe");
}
// The watchdog is relay-only (cursor-agent has no timeout flag), so a
// malformed --timeout must fail loudly here — a silent 30m fallback would be wrong.
if (parseDuration(opts.timeout) === null) {
fail(`--timeout "${opts.timeout}" is invalid or too long; use a positive h/m/s duration no longer than about 24 days`);
}
return opts;
}
function headerComment() {
// The leading block comment doubles as --help text.
const src = readFileSync(new URL(import.meta.url), "utf8");
const match = src.match(/\/\*\*([\s\S]*?)\*\//);
if (!match) return "relay.mjs — dispatch a brief to cursor-agent -p\n";
return `${match[1].replace(/^\s*\* ?/gm, "").trim()}\n`;
}
function readBrief(opts) {
if (opts.brief) {
if (!existsSync(opts.brief)) fail(`brief file not found: ${opts.brief}`);
return readFileSync(opts.brief, "utf8");
}
if (process.stdin.isTTY) {
fail("no --brief given and stdin is a TTY; pass --brief <file> or pipe the brief on stdin");
}
let stdin = "";
try {
stdin = readFileSync(0, "utf8");
} catch {
stdin = "";
}
return stdin;
}
function killChild(child, signal = "SIGTERM") {
if (!child || !child.pid) return;
if (process.platform === "win32") {
if (signal !== "SIGTERM") return;
try {
execFileSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
stdio: ["ignore", "ignore", "inherit"],
});
} catch {
// The process tree already exited.
}
return;
}
try {
process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
// The process group already exited.
}
}
}
function cursorAgentVersion(timeoutMs) {
try {
// On Windows, cursor-agent installs as a .cmd shim; Node's CreateProcess only
// auto-appends .exe, never .cmd, so launching it needs a shell there or it
// ENOENTs on a working install. A pre-joined string (not shell:true + args)
// avoids Node's DEP0190 warning. POSIX is unaffected. (git installs a real
// git.exe and must NOT go through a shell — see gitTouchedFiles.)
const options = {
encoding: "utf8",
timeout: Math.min(timeoutMs, VERSION_PROBE_TIMEOUT_MS),
killSignal: "SIGKILL",
};
const out = process.platform === "win32"
? execSync("cursor-agent --version", options).trim()
: execFileSync("cursor-agent", ["--version"], options).trim();
return { version: out || "unknown", error: null };
} catch (error) {
if (error?.code === "ENOENT") return { version: null, error: null };
if (process.platform === "win32" &&
/not recognized as an internal or external command/i.test(String(error?.stderr || ""))) {
return { version: null, error: null };
}
return { version: null, error };
}
}
function parseDuration(duration) {
const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(duration);
if (!match || (!match[1] && !match[2] && !match[3])) return null;
try {
const seconds =
BigInt(match[1] || 0) * 3600n +
BigInt(match[2] || 0) * 60n +
BigInt(match[3] || 0);
const milliseconds = seconds * 1000n;
if (milliseconds <= 0n || milliseconds > BigInt(MAX_TIMER_MS)) return null;
return Number(milliseconds);
} catch {
return null;
}
}
function gitTouchedFiles(cwd) {
try {
const output = execFileSync("git", ["status", "--porcelain"], {
cwd,
encoding: "utf8",
timeout: 10_000,
killSignal: "SIGKILL",
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 64 * 1024 * 1024,
});
return output.split("\n").map((line) => line.trimEnd()).filter(Boolean);
} catch {
return null;
}
}
function timestamp() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function winq(value) {
// shell:true on win32 (needed for the cursor-agent.cmd shim) doesn't quote
// args, so a path with spaces (C:\Users\First Last\...) would split, and a
// parameterized model ("opus[context=1m,effort=high]") would be re-tokenized
// by the shim's PowerShell hop. Quote the spaceable/parameterized values on
// Windows only — on POSIX the quotes would become literal characters.
return process.platform === "win32" ? `"${value}"` : value;
}
function buildArgv(opts) {
const argv = ["--print", "--output-format", "stream-json", "--trust"];
if (opts.readOnly) argv.push("--mode", "plan");
else if (opts.force) argv.push("--force");
if (opts.sandbox) argv.push("--sandbox", opts.sandbox);
if (opts.model) argv.push("--model", winq(opts.model));
if (opts.session) argv.push("--resume", winq(opts.session));
else if (opts.resumeLast) argv.push("--continue");
for (const dir of opts.addDirs) argv.push("--add-dir", winq(dir));
return argv;
}
function prepareRunDir(opts, brief) {
const startedAt = new Date().toISOString();
const outDir = opts.outDir || join(tmpdir(), "delegate-relay", `${basename(opts.cd) || "repo"}-${timestamp()}`);
mkdirSync(outDir, { recursive: true });
const run = {
startedAt,
briefPath: join(outDir, "brief.txt"),
finalPath: join(outDir, "final.txt"),
eventsPath: join(outDir, "events.jsonl"),
stderrPath: join(outDir, "stderr.txt"),
resultPath: join(outDir, "result.json"),
};
rmSync(run.finalPath, { force: true });
rmSync(run.resultPath, { force: true });
writeFileSync(run.briefPath, brief, "utf8");
writeFileSync(run.eventsPath, "", "utf8");
writeFileSync(run.stderrPath, "", "utf8");
return run;
}
function makeResultWriter(opts, version, run) {
return (extra) => {
const result = {
schema: "delegate-relay.result.v1",
lane: opts.lane,
laneSource: opts.laneSource,
tool: "cursor-agent",
workdir: opts.cd,
model: opts.model,
readOnly: opts.readOnly,
force: opts.force && !opts.readOnly,
sandbox: opts.sandbox,
resumed: Boolean(opts.resumeLast || opts.session),
cursorAgentVersion: version,
startedAt: run.startedAt,
finishedAt: new Date().toISOString(),
briefPath: run.briefPath,
finalPath: existsSync(run.finalPath) ? run.finalPath : null,
eventsPath: run.eventsPath,
stderrPath: run.stderrPath,
...extra,
};
const temporary = `${run.resultPath}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, "utf8");
renameSync(temporary, run.resultPath);
return result;
};
}
function reportUnavailable(writeResult, resultPath) {
const result = writeResult({
status: "cursor_agent_unavailable",
exitCode: 127,
signal: null,
sessionId: null,
resolvedModel: null,
permissionMode: null,
usage: null,
finalMessage: "",
touchedFiles: null,
});
printSummary(result, resultPath);
process.stderr.write("relay: `cursor-agent` not found on PATH. Install the Cursor CLI (https://cursor.com/cli) and run `cursor-agent login`.\n");
process.exit(127);
}
function reportVersionFailure(opts, writeResult, run, error, timeoutMs) {
const timedOut = error?.code === "ETIMEDOUT";
const stderr = String(error?.stderr || "").trim();
if (stderr) writeFileSync(run.stderrPath, `${stderr}\n`, "utf8");
const message = timedOut
? `cursor-agent --version preflight timed out after ${Math.min(timeoutMs, VERSION_PROBE_TIMEOUT_MS)}ms; Cursor was not dispatched`
: `cursor-agent --version preflight failed${Number.isInteger(error?.status) ? ` with exit ${error.status}` : ""}; Cursor was not dispatched`;
const result = writeResult({
status: timedOut ? "timeout" : "failed",
exitCode: timedOut ? 124 : Number.isInteger(error?.status) ? error.status : 1,
signal: null,
sessionId: null,
resolvedModel: null,
permissionMode: null,
usage: null,
finalMessage: "",
touchedFiles: gitTouchedFiles(opts.cd),
...(stderr ? { stderrTail: stderr.split("\n").slice(-20) } : {}),
error: message,
});
printSummary(result, run.resultPath);
process.stderr.write(`relay: ${message}\n`);
process.exit(result.exitCode);
}
function installPreflightSignalHandlers(opts, run, writeResult) {
let active = true;
const handlers = new Map();
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
const handler = () => {
if (!active) return;
active = false;
const result = writeResult({
status: "aborted",
exitCode: 128 + (constants.signals[sig] || 15),
signal: sig,
sessionId: null,
resolvedModel: null,
permissionMode: null,
usage: null,
finalMessage: "",
touchedFiles: gitTouchedFiles(opts.cd),
error: `the relay was killed by ${sig} during the cursor-agent version preflight; Cursor was not dispatched`,
});
printSummary(result, run.resultPath);
process.exit(result.exitCode);
};
handlers.set(sig, handler);
process.on(sig, handler);
}
return () => {
active = false;
for (const [sig, handler] of handlers) process.removeListener(sig, handler);
};
}
function dispatchToCursor(opts, brief, run, writeResult) {
// A shell launch on Windows so the cursor-agent.cmd shim resolves (see
// cursorAgentVersion) — as a pre-joined string, which sidesteps Node's
// DEP0190 warning about shell:true with an args array. Safe: the brief is
// fed via child.stdin below — never argv — and argv holds only fixed flags
// plus the win32-quoted model and directory values. detached on POSIX: the
// child leads a new process group so killChild can fell the whole tree.
const argv = buildArgv(opts);
const child = process.platform === "win32"
? spawn(["cursor-agent", ...argv].join(" "), { cwd: opts.cd, stdio: ["pipe", "pipe", "pipe"], shell: true })
: spawn("cursor-agent", argv, { cwd: opts.cd, stdio: ["pipe", "pipe", "pipe"], detached: true });
let sessionId = null;
let resolvedModel = null;
let permissionMode = null;
let usage = null;
let resultMessage = null;
let resultIsError = false;
const textChunks = [];
const stderrTail = [];
const scan = makeEventScanner((event) => {
if (typeof event.session_id === "string") sessionId = event.session_id;
if (event.type === "system" && event.subtype === "init") {
if (typeof event.model === "string") resolvedModel = event.model;
if (typeof event.permissionMode === "string") permissionMode = event.permissionMode;
}
if (event.type === "assistant" && event.message && Array.isArray(event.message.content)) {
for (const part of event.message.content) {
if (part && part.type === "text" && typeof part.text === "string") textChunks.push(part.text);
}
}
if (event.type === "result") {
if (typeof event.result === "string") resultMessage = event.result;
if (event.is_error === true) resultIsError = true;
if (event.usage && typeof event.usage === "object") usage = event.usage;
}
});
// The brief rides stdin: no process-list exposure, no OS argv-size cap.
child.stdin.on("error", () => { /* child exited before reading the brief; its exit code tells the story */ });
child.stdin.write(brief);
child.stdin.end();
// Decode across chunk boundaries: a multibyte UTF-8 character split between
// two data events would otherwise decode as U+FFFD and corrupt the report.
// Files get the raw bytes; only in-memory parsing goes through the decoders.
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
child.stdout.on("data", (chunk) => {
appendFileSync(run.eventsPath, chunk);
scan(stdoutDecoder.write(chunk));
});
child.stderr.on("data", (chunk) => {
process.stderr.write(chunk);
appendFileSync(run.stderrPath, chunk);
const text = stderrDecoder.write(chunk);
for (const line of text.split("\n")) {
if (line.trim()) stderrTail.push(line.trimEnd());
}
while (stderrTail.length > 20) stderrTail.shift();
});
const assembleFinal = () => {
// Prefer the result event's own report; fall back to the assistant text
// stream when the run died before emitting one.
const message = resultMessage && resultMessage.trim() ? resultMessage : textChunks.join("\n\n");
if (message) writeFileSync(run.finalPath, message, "utf8");
return message;
};
let settled = false;
let watchdogFired = false;
let sigkillTimer = null;
const timeoutMs = parseDuration(opts.timeout) ?? parseDuration(DEFAULT_TIMEOUT);
const watchdogTimer = setTimeout(() => {
watchdogFired = true;
child.once("exit", () => {
child.stdout.destroy();
child.stderr.destroy();
});
killChild(child);
sigkillTimer = setTimeout(() => {
if (!settled) killChild(child, "SIGKILL");
}, 10_000);
}, timeoutMs);
// The relay's own death must still produce a result: without this, a kill from the
// orchestrator's side (its command timeout, a stopped task, a closed terminal) writes
// no result.json and leaves the cursor-agent child running or dying mid-edit with
// nothing recording why. SIGTERM/SIGHUP registration is a no-op on Windows; SIGINT works there.
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
process.on(sig, () => {
if (settled) return;
settled = true;
clearTimeout(watchdogTimer);
if (sigkillTimer) clearTimeout(sigkillTimer);
const touched = gitTouchedFiles(opts.cd);
const abortedFields = {
status: "aborted",
exitCode: 128 + (constants.signals[sig] || 15),
signal: sig,
sessionId,
resolvedModel,
permissionMode,
usage,
finalMessage: assembleFinal(),
touchedFiles: touched,
stderrTail: stderrTail.slice(-20),
error: `the relay was killed by ${sig}; cursor-agent was terminated with it — inspect the working tree before re-dispatching`,
};
const result = writeResult(abortedFields);
printSummary(result, run.resultPath);
killChild(child);
setTimeout(() => {
killChild(child, "SIGKILL");
// the child may flush files during the grace window; refresh the snapshot so the
// artifact matches the tree the orchestrator will actually find
const late = gitTouchedFiles(opts.cd);
writeResult({ ...abortedFields, touchedFiles: late });
process.exit(result.exitCode);
}, 2000);
});
}
child.on("error", (err) => {
if (settled) return;
settled = true;
clearTimeout(watchdogTimer);
if (sigkillTimer) clearTimeout(sigkillTimer);
const touched = gitTouchedFiles(opts.cd);
const result = writeResult({
status: "failed",
exitCode: 1,
signal: null,
sessionId,
resolvedModel,
permissionMode,
usage,
finalMessage: assembleFinal(),
touchedFiles: touched,
stderrTail: stderrTail.slice(-20),
error: String(err && err.message ? err.message : err),
});
printSummary(result, run.resultPath);
process.exit(1);
});
child.on("close", (code, signal) => {
if (settled) return;
settled = true;
clearTimeout(watchdogTimer);
if (sigkillTimer) clearTimeout(sigkillTimer);
// a descendant that ignored SIGTERM must not outlive the timeout report: once the
// parent is down, sweep the group (no-op where taskkill already felled the tree)
if (watchdogFired) killChild(child, "SIGKILL");
// A timed-out run is failed even if cursor-agent handles SIGTERM by exiting 0 —
// orchestrators key off status and the relay exit code. A result event with
// is_error true is failed even on exit 0.
const succeeded = code === 0 && !watchdogFired && !resultIsError;
const mapped = code ?? (constants.signals[signal] ? 128 + constants.signals[signal] : 1);
const exitCode = succeeded ? 0 : mapped === 0 ? 1 : mapped;
const touched = gitTouchedFiles(opts.cd);
const result = writeResult({
status: succeeded ? "completed" : watchdogFired ? "timeout" : "failed",
exitCode,
signal: signal ?? null,
sessionId,
resolvedModel,
permissionMode,
usage,
finalMessage: assembleFinal(),
touchedFiles: touched,
...(succeeded ? {} : { stderrTail: stderrTail.slice(-20) }),
...(watchdogFired ? { error: `cursor-agent did not finish within --timeout ${opts.timeout}; killed by the relay watchdog` } : {}),
...(resultIsError && !watchdogFired ? { error: "cursor-agent reported an error result (is_error: true in its result event)" } : {}),
});
printSummary(result, run.resultPath);
process.exit(result.exitCode);
});
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const brief = readBrief(opts);
if (!brief.trim()) fail("empty brief (pass --brief <file> or pipe the brief on stdin)");
const timeoutMs = parseDuration(opts.timeout);
const run = prepareRunDir(opts, brief);
let writeResult = makeResultWriter(opts, null, run);
const clearPreflightSignals = installPreflightSignalHandlers(opts, run, writeResult);
const probe = cursorAgentVersion(timeoutMs);
// Synchronous child-process calls defer JavaScript signal handlers. Yield once
// so a signal received during the bounded probe becomes "aborted" before dispatch.
await new Promise((resolve) => setImmediate(resolve));
writeResult = makeResultWriter(opts, probe.version, run);
if (!probe.version && !probe.error) {
clearPreflightSignals();
reportUnavailable(writeResult, run.resultPath);
return;
}
if (probe.error) {
clearPreflightSignals();
reportVersionFailure(opts, writeResult, run, probe.error, timeoutMs);
return;
}
clearPreflightSignals();
dispatchToCursor(opts, brief, run, writeResult);
}
function printSummary(result, resultPath) {
const lines = [];
lines.push("");
lines.push(`relay: ${result.status} (exit ${result.exitCode}${result.signal ? `, killed by ${result.signal}` : ""}) · cursor-agent ${result.cursorAgentVersion ?? "?"}`);
if (result.signal === "SIGKILL" && result.status === "failed") lines.push("hint: the host killed the process (commonly the OOM killer or a supervisor timeout) — this is not a cursor-agent error; check host memory and re-dispatch, or split the task into smaller briefs.");
if (result.signal === "SIGTERM" && result.status === "failed") lines.push("hint: something outside the relay terminated cursor-agent (a supervisor, the session ending, or a manual kill) — when the relay itself does the killing it reports status \"timeout\" or \"aborted\" instead; inspect the working tree before re-dispatching.");
if (result.resumed) lines.push("mode: resumed an existing session");
if (result.readOnly) lines.push("mode: read-only (plan)");
if (result.resolvedModel) lines.push(`model: ${result.resolvedModel}${result.permissionMode ? ` · permission mode: ${result.permissionMode}` : ""}`);
if (result.sessionId) lines.push(`session id (resume with: --session ${result.sessionId}): ${result.sessionId}`);
const touched = result.touchedFiles;
if (touched === null) {
lines.push("touched files: git unavailable — inspect the working tree directly");
} else {
lines.push(`touched files: ${touched.length}`);
for (const file of touched.slice(0, 40)) lines.push(` ${file}`);
if (touched.length > 40) lines.push(` ... and ${touched.length - 40} more`);
}
if (result.stderrTail && result.stderrTail.length) {
lines.push("last stderr:");
for (const line of result.stderrTail.slice(-8)) lines.push(` ${line}`);
}
lines.push("");
lines.push("--- cursor-agent final report ---");
lines.push(result.finalMessage || "(no final message captured)");
lines.push("--- end report ---");
lines.push("");
lines.push(`result: ${resultPath}`);
lines.push("relay does not commit. Review the diff, re-run the project gates yourself, then commit from the orchestrator.");
process.stdout.write(`${lines.join("\n")}\n`);
}
main();
SKILL.md
---
name: cursor-delegate
description: >-
Delegate a coding task to the Cursor Agent CLI (`cursor-agent`) as a background implementer, then
review its diff and land it yourself. Use this whenever the user wants to hand implementation work
to Cursor — phrasings like "have Cursor implement X", "delegate this to Cursor", "run it through
Cursor Agent", or "use Cursor to implement/fix/refactor" — or wants to run a queue of coding tasks
through Cursor while staying the reviewer. DO NOT USE for tasks small enough to do inline, or when
the user wants the code written directly without delegating.
license: MIT
compatibility: Requires the `cursor-agent` CLI installed and authenticated, Node 18+, and git. The optional `--add-dir` flag requires cursor-agent 2026.07.23 or newer. The orchestrating agent must be able to run shell commands and read files. Shell examples assume bash/zsh (macOS/Linux, or Git Bash/WSL on Windows).
metadata:
version: 0.5.0
---
# Cursor Delegate
You are the **orchestrator**. Hand a bounded coding task to a separate **implementer** — the Cursor
Agent CLI — then review what it produced and land it yourself. You write the brief and own the
judgment; Cursor does the typing in its own session; you verify and commit.
The loop needs only a shell command and file access, so any comparable orchestrator can drive it.
## When NOT to use this
- The task is small enough to do inline; delegation overhead is not worth it.
- The `cursor-agent` CLI is not installed or authenticated (run `cursor-agent login`).
- You want to write the code yourself, or you only need Cursor's opinion on code you wrote (a
`--read-only` dispatch covers that — see below — but a plain review may not need delegation at all).
## Prerequisites (check once)
1. `cursor-agent --version` succeeds. If not, follow the installer for your platform at
[cursor.com/cli](https://cursor.com/cli), inspect what it will run, and authenticate with
`cursor-agent login`.
2. `cursor-agent status` shows you logged in.
3. You are in (or will point `--cd` at) the target git repository. The relay passes `--trust`, so
point it only at repositories you trust.
## Choose the model
Omitting `--model` uses your Cursor default (usually `auto` — Cursor picks). To pin one, pass
`--model <name>` with a name from the account's live `cursor-agent models` output — select from that
list rather than inventing a name. Parameterized forms like `<name>[context=1m,effort=high]` are
forwarded as-is. The model that actually served the run is recorded as `resolvedModel` in
`result.json`.
## The loop
Run these five steps per task. Steps 1, 4, and 5 require judgment; 2 and 3 are mechanical.
### 1. Write the brief
Cursor sees only the text you send plus what it can inspect in the workspace — no chat history or
shared context. Include the goal, current state, what to change, what to leave untouched, the
project's **actual** gates, and a report contract. Tell Cursor not to commit. Keep one task per
brief. See [references/writing-the-brief.md](references/writing-the-brief.md).
### 2. Dispatch
Use the bundled helper. It wraps `cursor-agent -p`, feeds the brief on stdin, captures the
structured event stream, and writes `result.json`. (`<skill-dir>` is the installed folder containing
this `SKILL.md`.)
```bash
node "<skill-dir>/scripts/relay.mjs" --brief brief.txt --cd /path/to/repo
# read-only (plan mode — review/diagnosis, no edits): add --read-only
# write-capable without automatic command approval: add --no-force
# explicitly override Cursor's sandbox for this run: add --sandbox enabled|disabled
# pin a model from `cursor-agent models`: add --model <name>
# resume the most recent session: add --resume-last (delta brief only)
# resume a specific session: add --session <id> (delta brief only)
# hard time limit (watchdog): add --timeout 2h (the 30m default suits short runs; implementation briefs routinely need 1-2h)
# see all options: node .../relay.mjs --help
```
The child process's cwd pins the workspace. On Cursor `2026.07.23` or newer, use repeatable
`--add-dir` flags only for extra workspace directories. The relay writes artifacts under the system
temp dir by default and never commits. See
[references/dispatch-and-poll.md](references/dispatch-and-poll.md).
### 3. Wait for completion
The helper blocks until Cursor finishes. Run it with the orchestrator's background-command facility,
or background it in the shell and poll for `result.json`. A pre-run usage error exits 2 and writes no
result; a missing `cursor-agent` exits 127 and writes `status: "cursor_agent_unavailable"`.
Trust process state and the working tree over a progress display. Completion means the process exited
and `result.json` exists. Cursor's full report is the `finalMessage` field in `result.json` (also
printed in full on stdout between the report markers).
**Windows + hooks caveat:** if the user has Cursor hooks configured (`~/.cursor/hooks.json`, or
Claude Code `PreToolUse` hooks, which cursor-agent imports), dispatching from a Git Bash (MSYS)
console makes cursor-agent feed PowerShell-syntax hook wrappers to bash, so every command Cursor
tries to run is blocked — edits still land, gates do not run. Dispatch from a PowerShell or cmd
console instead. Details: [references/dispatch-and-poll.md](references/dispatch-and-poll.md).
### 4. Review — do not trust the self-report
Treat Cursor's final message and gate claims as claims:
- Re-run the project's gates yourself.
- Read the diff against the brief, starting with `touchedFiles`.
- Run relevant guard skills if installed.
- Round-trip migrations and grep for dangling references after removals or renames.
See [references/review-and-land.md](references/review-and-land.md).
### 5. Land it
The implementer edits the working tree; **the orchestrator commits.** Commit only after the gates
pass and the diff holds. If rework is needed, send a delta brief with `--resume-last` or
`--session <id>`, then review again.
## Autonomy and permissions
A fresh run defaults to **write-capable with `--force`**: Cursor runs commands without approval
unless your Cursor config explicitly denies them, so ordinary gates (tests, linters, builds) run
headlessly. `--no-force` keeps the run write-capable but withholds automatic command approval;
commands that require approval are refused because a headless run cannot prompt. `--read-only`
switches to Cursor's **plan mode** (read-only analysis, no edits, no `--force`). The relay always
passes `--trust` to keep headless runs from stalling on the workspace-trust prompt, which is why
`--cd` must only ever point at repositories you trust. Pass `--sandbox enabled` or `--sandbox
disabled` only when you need to override Cursor's sandbox for that dispatch. The requested value is
recorded as `sandbox` in `result.json`; it does not claim what Cursor actually applied. The permission
mode Cursor reports is recorded as `permissionMode`; inspect `touchedFiles` and the diff after every
run.
## Read-only second opinions
`--read-only` doubles as a clean way to get an adversarial second opinion with no write risk:
dispatch a brief that lists the agreed points, then each contested point with both positions, and ask
Cursor to defend or concede each — deliverable in its final message, touching no files.
## Authorization model
Delegation is something the human opts into. Once they have ("run this queue", "proceed"), committing
verified, gate-passing work is the agreed contract. Two limits remain: **surface, don't absorb**
(report Cursor's design decisions, defensible-but-unasked turns, and non-blocking nitpicks) and
**stop for scope changes** (if correct completion needs going beyond the brief, ask instead of
expanding the mandate). See [references/review-and-land.md](references/review-and-land.md).
## References
- [references/writing-the-brief.md](references/writing-the-brief.md) — structure, report contract,
real gates, and delta briefs.
- [references/dispatch-and-poll.md](references/dispatch-and-poll.md) — flags, artifacts,
`result.json`, polling, and failure recovery.
- [references/review-and-land.md](references/review-and-land.md) — review checklist, commit boundary,
and rework through Cursor sessions.
- [references/multi-task-queues.md](references/multi-task-queues.md) — sequential queues, constraint
carry-forward, progress tracking, and the final coherence pass.