scripts/route.mjs
#!/usr/bin/env node
// Hardened wrapper for delegating tasks to Codex as a background worker.
//
// Two backends, auto-selected:
// 1. companion — the openai/codex-plugin-cc plugin's codex-companion.mjs
// (preferred when installed): background jobs via `task --background`,
// status/result/cancel via the plugin's job store.
// 2. exec — the bare `codex` CLI (fallback when the plugin is absent):
// `codex exec` spawned detached with stdin from /dev/null (a broken
// argument pass then fails fast instead of silently waiting on stdin —
// see SKILL.md), output captured to a log file, job tracked in
// ~/.claude/route-jobs/.
//
// Both encode the same discipline so it can't be improvised wrong:
// background-only launches, stdin closed everywhere, a hard timeout on every
// synchronous call, and a bounded watch loop that reports "still running"
// instead of blocking.
//
// Usage:
// route.mjs launch [--read-only] [--model <m>] [--effort <e>] [--resume] <prompt...>
// route.mjs watch <jobId> [--ceiling-s 600] [--interval-s 20]
// route.mjs run [launch flags] [--ceiling-s 600] <prompt...> (launch + watch)
// route.mjs status [jobId] | result [jobId] | cancel <jobId>
//
// Exit codes: 0 success · 1 job failed/cancelled · 2 usage/setup error ·
// 3 ceiling reached, job still running (not an error)
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const PER_CALL_TIMEOUT_MS = 60_000;
const DEFAULT_CEILING_S = 600;
const DEFAULT_INTERVAL_S = 20;
const EXEC_JOBS_DIR = path.join(os.homedir(), ".claude", "route-jobs");
const EXEC_DONE_SENTINEL = "__ROUTE_EXEC_DONE__";
function fail(message, code = 2) {
process.stderr.write(`route: ${message}\n`);
process.exit(code);
}
// ---------------------------------------------------------------------------
// Backend resolution
// ---------------------------------------------------------------------------
function findCompanionScript() {
if (process.env.ROUTE_COMPANION) {
if (fs.existsSync(process.env.ROUTE_COMPANION)) return process.env.ROUTE_COMPANION;
fail(`ROUTE_COMPANION points to a missing file: ${process.env.ROUTE_COMPANION}`);
}
const roots = [
path.join(os.homedir(), ".claude"),
path.join(os.homedir(), ".config", "claude"),
];
const target = "codex-companion.mjs";
const queue = roots.filter((r) => fs.existsSync(r)).map((dir) => ({ dir, depth: 0 }));
while (queue.length) {
const { dir, depth } = queue.shift();
if (depth > 8) continue;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name.startsWith(".git")) continue;
const full = path.join(dir, entry.name);
if (entry.isFile() && entry.name === target) return full;
if (entry.isDirectory()) queue.push({ dir: full, depth: depth + 1 });
}
}
return null;
}
function codexBinaryAvailable() {
const probe = spawnSync(process.platform === "win32" ? "where" : "which", ["codex"], {
encoding: "utf8",
input: "",
timeout: 5_000,
});
return probe.status === 0 && (probe.stdout || "").trim().length > 0;
}
// exec-backend job ids are prefixed so every subcommand can route to the
// right backend without guessing.
function isExecJobId(jobId) {
return typeof jobId === "string" && jobId.startsWith("exec-");
}
// Claude Code cloud containers have no browser for `codex login` and an
// egress policy that blocks api.openai.com by default, so the generic
// install advice is wrong there — say what actually unblocks it.
function inCloudContainer() {
return Boolean(process.env.CCR_SESSION_PROFILE) || fs.existsSync("/root/.ccr");
}
function resolveBackend() {
const script = findCompanionScript();
if (script) return { kind: "companion", script };
if (codexBinaryAvailable()) return { kind: "exec" };
const cloudHint = inCloudContainer()
? "\n This is a Claude Code cloud container: in the environment's settings (title-bar environment menu → Edit) add an\n" +
" OPENAI_API_KEY under Network secrets, put `npm install -g @openai/codex` in the Setup script, and allow api.openai.com\n" +
" in the network policy. New sessions then have a working `codex`; this one won't."
: "";
fail(
"neither the codex plugin (codex-companion.mjs) nor the `codex` CLI was found.\n" +
" - plugin: /plugin marketplace add openai/codex-plugin-cc, then /plugin install codex@openai-codex, then /codex:setup\n" +
" - bare CLI: npm install -g @openai/codex, then `codex login`" +
cloudHint
);
}
// The plugin backend talks to a long-lived `codex app-server`. One started
// before `codex login` (or left over from an old session) keeps stale auth
// and fails every launch with quota/auth errors even though `codex login
// status` says logged in — confirmed on a real Mac, where killing it made
// /route work instantly. Returns true if a server was actually running.
function restartAppServer() {
if (process.platform === "win32") return false;
const killed = spawnSync("pkill", ["-f", "codex app-server"], { encoding: "utf8", input: "", timeout: 5_000 });
return killed.status === 0;
}
const STALE_AUTH_RE = /quota|usage limit|rate limit|unauthori[sz]ed|not logged in|log ?in|authenticat|\b(401|403|429)\b/i;
// ---------------------------------------------------------------------------
// Companion backend (plugin)
// ---------------------------------------------------------------------------
// Every companion call: stdin closed (input: ""), hard timeout. Nothing here
// can wait on input or hang past its deadline.
function companion(script, args, { timeoutMs = PER_CALL_TIMEOUT_MS } = {}) {
const out = spawnSync(process.execPath, [script, ...args], {
encoding: "utf8",
input: "",
timeout: timeoutMs,
});
const timedOut = out.error?.code === "ETIMEDOUT" || out.signal === "SIGTERM";
return {
ok: out.status === 0 && !timedOut,
timedOut,
status: out.status,
stdout: (out.stdout || "").trim(),
stderr: (out.stderr || "").trim(),
};
}
function companionJson(script, args, opts) {
const out = companion(script, [...args, "--json"], opts);
if (!out.ok) return { ...out, json: null };
try {
return { ...out, json: JSON.parse(out.stdout) };
} catch {
return { ...out, ok: false, json: null, stderr: `unparseable JSON from companion: ${out.stdout.slice(0, 400)}` };
}
}
// ---------------------------------------------------------------------------
// Exec backend (bare codex CLI)
// ---------------------------------------------------------------------------
function execJobPaths(jobId) {
return {
meta: path.join(EXEC_JOBS_DIR, `${jobId}.json`),
log: path.join(EXEC_JOBS_DIR, `${jobId}.log`),
};
}
function execReadJob(jobId) {
const { meta } = execJobPaths(jobId);
if (!fs.existsSync(meta)) fail(`no such exec job: ${jobId} (looked in ${EXEC_JOBS_DIR})`);
try {
return JSON.parse(fs.readFileSync(meta, "utf8"));
} catch {
fail(`corrupt job file for ${jobId}: ${meta}`);
}
}
function execPidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function execLaunch(flags, prompt) {
if (flags["--resume"]) {
// The bare CLI has no equivalent of the plugin's resumable task threads
// that we can target reliably from here; starting fresh is the honest
// behavior, said out loud rather than silently.
process.stderr.write("route: --resume needs the codex plugin; the bare-CLI backend starts a fresh run instead.\n");
}
fs.mkdirSync(EXEC_JOBS_DIR, { recursive: true });
const jobId = `exec-${Date.now()}-${process.pid}`;
const { meta, log } = execJobPaths(jobId);
const args = ["exec"];
if (flags["--model"]) args.push("--model", flags["--model"] === "spark" ? "gpt-5.3-codex-spark" : flags["--model"]);
if (flags["--effort"]) args.push("-c", `model_reasoning_effort=${flags["--effort"]}`);
if (flags["--read-only"]) args.push("--sandbox", "read-only");
else args.push("--sandbox", "workspace-write");
args.push(prompt);
const logFd = fs.openSync(log, "a");
// Two deliberate choices here:
// - stdin "ignore" + the `< /dev/null` inside the shell line: a broken
// argument pass makes codex exec fail fast on EOF instead of silently
// waiting forever for terminal input (see SKILL.md for the incident).
// - completion is signaled by a sentinel line the shell appends AFTER codex
// exits, not by pid-liveness: a watch loop in the same process (run =
// launch+watch) blocks the event loop, so an exited child lingers as a
// zombie and kill(pid, 0) keeps "succeeding" — pid checks read forever-
// running. The sentinel is unambiguous from any process, any time.
const child = spawn(
"bash",
["-c", `codex "$@" < /dev/null; echo "${EXEC_DONE_SENTINEL}:$?"`, "--", ...args],
{
detached: true,
stdio: ["ignore", logFd, logFd],
}
);
child.unref();
fs.closeSync(logFd);
fs.writeFileSync(
meta,
JSON.stringify({ jobId, pid: child.pid, log, promptPreview: prompt.slice(0, 200), startedAt: new Date().toISOString() }, null, 2)
);
process.stdout.write(`${JSON.stringify({ jobId, title: "codex exec (bare-CLI backend)" })}\n`);
return jobId;
}
// Reads the log for the completion sentinel. Returns:
// { done: true, exitCode, output } — sentinel present (normal end)
// { done: true, exitCode: null, output } — no sentinel but pid is gone
// (killed/cancelled/crashed shell)
// { done: false, output } — still running
function execStatus(jobId) {
const job = execReadJob(jobId);
const { log } = execJobPaths(jobId);
const raw = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
const m = raw.match(new RegExp(`${EXEC_DONE_SENTINEL}:(\\d+)\\s*$`));
const output = raw.replace(new RegExp(`${EXEC_DONE_SENTINEL}:\\d+\\s*$`), "").trim() || "(no output captured)";
if (m) return { done: true, exitCode: Number(m[1]), output, job };
if (!execPidAlive(job.pid)) return { done: true, exitCode: null, output, job };
return { done: false, output, job };
}
// ---------------------------------------------------------------------------
// Shared flow
// ---------------------------------------------------------------------------
function parseFlags(argv, { valueFlags = [], boolFlags = [] } = {}) {
const flags = {};
const rest = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (valueFlags.includes(arg)) {
flags[arg] = argv[++i];
} else if (boolFlags.includes(arg)) {
flags[arg] = true;
} else {
rest.push(arg);
}
}
return { flags, rest };
}
function doLaunch(argv) {
const { flags, rest } = parseFlags(argv, {
valueFlags: ["--model", "--effort", "--prompt-file"],
boolFlags: ["--read-only", "--resume"],
});
let prompt = rest.join(" ").trim();
if (flags["--prompt-file"]) {
if (!fs.existsSync(flags["--prompt-file"])) fail(`prompt file not found: ${flags["--prompt-file"]}`);
prompt = fs.readFileSync(flags["--prompt-file"], "utf8").trim();
}
if (!prompt) fail("no prompt given — pass the task text or --prompt-file <path>");
const backend = resolveBackend();
if (backend.kind === "exec") return execLaunch(flags, prompt);
const args = ["task", "--background"];
if (!flags["--read-only"]) args.push("--write");
if (flags["--resume"]) args.push("--resume-last");
if (flags["--model"]) args.push("--model", flags["--model"] === "spark" ? "gpt-5.3-codex-spark" : flags["--model"]);
if (flags["--effort"]) args.push("--effort", flags["--effort"]);
args.push(prompt);
let out = companionJson(backend.script, args);
if (!out.ok) {
// Exactly one retry, and only after restarting a server that was really
// running: a stale app-server is the one launch failure that heals itself.
const firstError = out.timedOut ? "launch call timed out after 60s" : out.stderr || out.stdout || "launch failed";
if (restartAppServer()) {
process.stderr.write(`route: launch failed (${firstError.split("\n")[0].slice(0, 120)}); restarted a stale codex app-server, retrying once…\n`);
sleepSync(1500);
out = companionJson(backend.script, args);
}
if (!out.ok) {
const err = out.timedOut ? "launch call timed out after 60s" : out.stderr || out.stdout || "launch failed";
fail(`${err}\n If \`codex login status\` says you are logged in, run: pkill -f "codex app-server" and try again.`, 2);
}
}
const jobId = out.json?.jobId;
if (!jobId) fail(`launch succeeded but no jobId in response: ${JSON.stringify(out.json).slice(0, 300)}`);
process.stdout.write(`${JSON.stringify({ jobId, title: out.json.title ?? null })}\n`);
return jobId;
}
function sleepSync(ms) {
// Deliberate bounded sleep between polls; Atomics.wait keeps it dependency-free.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function stillRunningNotice(jobId, ceilingMs) {
const followups = isExecJobId(jobId)
? [
` route.mjs status ${jobId} to check progress`,
` route.mjs result ${jobId} to see output so far`,
` route.mjs watch ${jobId} --ceiling-s 600 to keep waiting`,
` route.mjs cancel ${jobId} to abort`,
]
: [
` /codex:status ${jobId} (or: route.mjs status ${jobId})`,
` /codex:result ${jobId} (or: route.mjs watch ${jobId} --ceiling-s 600)`,
` /codex:cancel ${jobId} to abort`,
];
process.stdout.write(
[
`Job ${jobId} is still running after the ${Math.round(ceilingMs / 1000)}s ceiling.`,
`It continues in the background. Follow up with:`,
...followups,
].join("\n") + "\n"
);
process.exit(3);
}
// async on purpose: the sleep must yield to the event loop. With a blocking
// sleep (Atomics.wait), Node can never reap a detached child that exits while
// we watch it from the same process (run = launch+watch) — the child lingers
// as a zombie, kill(pid, 0) keeps succeeding, and the pid-liveness fallback
// reads "running" forever. An event-loop-friendly sleep lets libuv reap
// exited children, so the fallback stays truthful.
async function doWatch(argv) {
const { flags, rest } = parseFlags(argv, { valueFlags: ["--ceiling-s", "--interval-s"] });
const jobId = rest[0];
if (!jobId) fail("watch requires a jobId");
const ceilingMs = Math.max(10, Number(flags["--ceiling-s"]) || DEFAULT_CEILING_S) * 1000;
const intervalMs = Math.max(5, Number(flags["--interval-s"]) || DEFAULT_INTERVAL_S) * 1000;
const deadline = Date.now() + ceilingMs;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
if (isExecJobId(jobId)) {
while (Date.now() < deadline) {
const s = execStatus(jobId);
if (s.done) {
if (s.exitCode === null) {
process.stdout.write(`Job ${jobId} ended without a completion marker (killed or cancelled). Output so far:\n${s.output}\n`);
process.exit(1);
}
process.stdout.write(s.output + "\n");
process.exit(s.exitCode === 0 ? 0 : 1);
}
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
}
stillRunningNotice(jobId, ceilingMs);
}
const backend = resolveBackend();
if (backend.kind !== "companion") {
fail(`job ${jobId} looks like a plugin job, but the codex plugin isn't installed on this machine.`);
}
let lastPhase = "";
while (Date.now() < deadline) {
const out = companionJson(backend.script, ["status", jobId]);
if (out.ok) {
const job = out.json?.job ?? {};
if (job.phase && job.phase !== lastPhase) {
lastPhase = job.phase;
process.stderr.write(`route: ${jobId} → ${job.status ?? "?"} (${job.phase})\n`);
}
if (job.status === "completed") {
const result = companion(backend.script, ["result", jobId], { timeoutMs: PER_CALL_TIMEOUT_MS });
process.stdout.write((result.ok ? result.stdout : `completed, but result fetch failed: ${result.stderr}`) + "\n");
process.exit(result.ok ? 0 : 1);
}
if (job.status === "failed" || job.status === "cancelled") {
const message = job.errorMessage ?? job.summary ?? "no error message stored";
process.stdout.write(`Job ${jobId} ${job.status}: ${message}\n`);
if (job.status === "failed" && STALE_AUTH_RE.test(message)) {
process.stdout.write(` Looks like stale Codex auth. If \`codex login status\` says logged in, run: pkill -f "codex app-server" and re-run the task.\n`);
}
process.exit(1);
}
}
// Transient status failures are tolerated: keep polling until the ceiling.
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
}
stillRunningNotice(jobId, ceilingMs);
}
function doPassthrough(cmd, argv) {
const jobId = argv.find((a) => !a.startsWith("--"));
if (jobId && isExecJobId(jobId)) {
if (cmd === "status") {
const s = execStatus(jobId);
const state = s.done ? (s.exitCode === null ? "ended abnormally" : `finished (exit ${s.exitCode})`) : "running";
process.stdout.write(`${jobId}: ${state} (pid ${s.job.pid}, started ${s.job.startedAt})\n`);
process.exit(0);
}
if (cmd === "result") {
const s = execStatus(jobId);
process.stdout.write(s.output + "\n");
process.exit(0);
}
if (cmd === "cancel") {
const s = execStatus(jobId);
if (!s.done) {
try {
// Negative pid = the whole process group (detached made the shell
// wrapper a group leader), so codex itself dies too, not just bash.
process.kill(-s.job.pid, "SIGTERM");
} catch (e) {
fail(`could not signal process group ${s.job.pid}: ${e.message}`, 1);
}
// Teardown is asynchronous — confirm rather than report a stale state.
sleepSync(1500);
const after = execStatus(jobId);
process.stdout.write(
after.done
? `Cancelled ${jobId} (process group ${s.job.pid}).\n`
: `Sent SIGTERM to ${jobId}; it hasn't exited yet — re-check with: route.mjs status ${jobId}\n`
);
} else {
process.stdout.write(`${jobId} already finished.\n`);
}
process.exit(0);
}
}
const backend = resolveBackend();
if (backend.kind !== "companion") {
fail(`'${cmd}' without an exec- job id needs the codex plugin, which isn't installed on this machine.`);
}
const out = companion(backend.script, [cmd, ...argv]);
process.stdout.write(out.stdout + "\n");
if (out.stderr) process.stderr.write(out.stderr + "\n");
process.exit(out.ok ? 0 : 1);
}
const [cmd, ...argv] = process.argv.slice(2);
switch (cmd) {
case "launch":
doLaunch(argv);
break;
case "watch":
await doWatch(argv);
break;
case "run": {
// Split watch-only flags (WITH their values) from launch args. Filtering
// only the flag tokens left their values behind, which got glued onto the
// prompt — the worker received "600 20 <actual task>". Value-bearing
// flags must always travel as pairs.
const launchArgs = [];
const watchArgs = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--ceiling-s" || argv[i] === "--interval-s") {
watchArgs.push(argv[i], argv[++i]);
} else {
launchArgs.push(argv[i]);
}
}
const jobId = doLaunch(launchArgs);
await doWatch([jobId, ...watchArgs]);
break;
}
case "status":
case "result":
case "cancel":
doPassthrough(cmd, argv);
break;
default:
fail("usage: route.mjs <launch|watch|run|status|result|cancel> ...");
}
SKILL.md
---
name: route
description: Delegate a substantial coding task to Codex (OpenAI's coding agent, e.g. GPT-5.x-Codex / "spark") as a background worker while you keep working. Use when the user asks to route, delegate, hand off, or offload work to Codex/ChatGPT, or when a task is large/open-ended enough that a second agent working in parallel helps. Always runs the worker in the background with a bounded check-in loop — never blocks the session indefinitely.
---
# Route — delegate to Codex as a background worker
You are the administrator: you scope the task, launch the worker, review what comes back, and decide whether to iterate. Codex is the worker: it does the actual investigation/implementation, out of process, on its own turn.
## Why this skill exists — the hang bugs it guards against
The underlying plugin (`openai/codex-plugin-cc`) has a confirmed bug in its **foreground** task path: `codex-companion.mjs` → `runTrackedJob` → `codex.mjs`'s `await state.completion` has **no timeout anywhere in that call chain**. A dropped completion notification (network hiccup, stalled OpenAI call, lost subagent-turn event) blocks that await forever and hangs the entire session. The plugin's own `codex-rescue` subagent defaults to that path — never invoke it directly, and never add `--wait` to a foreground `task` call.
This skill's bundled wrapper, `scripts/route.mjs`, encodes the safe discipline so it can't be improvised wrong: background-only launches, stdin closed on every call, a 60s hard timeout on every companion invocation, and a bounded watch loop that reports "still running" instead of blocking.
## Prerequisites — two backends, auto-selected
The wrapper picks its backend automatically; you need **one** of:
1. **The codex plugin** (preferred — richer job tracking, resumable threads): `/plugin marketplace add openai/codex-plugin-cc` then `/plugin install codex@openai-codex`, then `/codex:setup`.
2. **The bare `codex` CLI** on PATH (`npm install -g @openai/codex`, then `codex login`). The wrapper runs `codex exec` detached with stdin closed, captures output to `~/.claude/route-jobs/<jobId>.log`, and tracks completion via a sentinel the shell appends after codex exits. `--resume` is plugin-only (the bare CLI starts fresh, and the wrapper says so out loud); `--read-only` maps to `--sandbox read-only`.
Either way Codex must be authenticated, and **sign-in is per machine**: credentials live in `~/.codex` and never sync, so each Mac needs `codex login` once (sign in with the ChatGPT account in the browser window — do **not** pick the API-key option; the ChatGPT plan covers usage and OpenAI API credit is untouched). Verify with `codex login status` — it should say "Logged in using ChatGPT". If the wrapper reports neither backend found, it prints the install commands for both — relay them, don't guess paths or retry blindly.
**Known failure — stale quota/auth errors after signing in:** the background `codex app-server` process can hold onto pre-login state and keep reporting quota errors even though `codex login status` says logged in. The wrapper heals this itself at launch: if the launch call fails and an app-server is running, it restarts that server and retries exactly once (you'll see `restarted a stale codex app-server, retrying once…` on stderr). If a job fails *mid-run* with auth/quota text instead, the wrapper prints the manual fix: `pkill -f "codex app-server"`, then run the task again.
**Cloud sessions (claude.ai/code):** this repo ships its skills as project skills (`.claude/skills/` symlinks), so `/route` exists in any session opened on this repo — but the container has no browser for `codex login` and blocks api.openai.com by default. The wrapper detects a cloud container and prints the exact fix (an `OPENAI_API_KEY` Network secret, `npm install -g @openai/codex` in the Setup script, api.openai.com allowed in the network policy). Relay that; don't retry.
## Procedure
All commands run from this skill's directory (`scripts/route.mjs` resolves the plugin's companion script automatically; set `ROUTE_COMPANION=/path/to/codex-companion.mjs` only if auto-discovery fails).
1. **Scope the task first.** The worker starts with no memory of this conversation. Write a self-contained prompt: the goal, the relevant files/paths, constraints, and what "done" looks like. For long prompts, write them to a file and pass `--prompt-file` — never rely on shell substitution of large strings into arguments.
2. **Launch and watch in one bounded step:**
```bash
node scripts/route.mjs run --ceiling-s 600 "<self-contained prompt>"
```
- Writes are enabled by default; add `--read-only` for pure investigation/diagnosis.
- `--model <name>` passes any Codex model name straight through (e.g. `--model astra`, `--model sol`); `spark` is the one alias, mapping to `gpt-5.3-codex-spark`. Leave it unset to take the machine's Codex default.
- `--effort <none|minimal|low|medium|high|xhigh>` only when the user explicitly asks.
- `--ceiling-s` defaults to 600 (10 min). Raise it only for jobs the user expects to run long — and say so upfront.
- Or split the phases: `launch` returns a `jobId` immediately so you can do other work, then `watch <jobId>` later.
3. **Interpret the exit code — each one has a defined next step:**
- `0` — worker finished; stdout is the result. Present it substantively (keep file paths, error messages, diffs — don't paraphrase them away).
- `1` — job failed or was cancelled; stdout carries the stored error verbatim. Surface it as-is.
- `3` — ceiling reached, job **still running in the background**. Not an error. Relay the printed follow-up commands to the user (`/codex:status`, `/codex:result`, `/codex:cancel` with the jobId) or keep working on something else and re-run `watch <jobId>` later.
- `2` — setup/usage problem (companion not found, no prompt). Fix the stated cause; if Codex is missing or unauthenticated, tell the user to run `/codex:setup`.
## Administrator review loop
Delegation isn't fire-and-forget — review the worker's output before accepting it:
1. When a run completes, **verify the claims**: if it says it changed files, look at the diff (`git diff`/`git status`); if it says tests pass, run them yourself. Codex's summary is a report, not proof.
2. If the result is incomplete or off-target, **iterate on the same thread** rather than starting over — the worker keeps its context:
```bash
node scripts/route.mjs run --resume --ceiling-s 600 "<specific follow-up: what's wrong, what to do next>"
```
3. Bound the loop: **at most 3 rounds** unless the user asks for more. If it's not converging by then, stop delegating and either do the work directly or bring the findings back to the user.
4. Only start a fresh thread (omit `--resume`) when the follow-up is genuinely a different task.
## If anything ever calls `codex exec` directly
This skill's path never does — the companion talks JSON-RPC to a persistent `codex app-server`, immune to the bug below. But delegated tasks or ad-hoc commands sometimes shell out to `codex exec`, and this bites hard: if argument passing breaks (typically shell-quoting a long prompt via `"$(cat file)"`), `codex exec` doesn't error — it silently drops into interactive mode (`Reading additional input from stdin...`) and waits forever for keyboard input that never comes. Backgrounded, that's invisible for however long you let it sit.
Rule, no exceptions: **always close stdin on `codex exec`** —
```bash
codex exec "<prompt>" < /dev/null
```
A broken argument pass then fails fast with a clear error instead of hanging. (The wrapper already does the equivalent for every companion call.)