import { spawn, type ChildProcessByStdio } from "node:child_process"; import { mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import type { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; // The runner is spawned with stdio ["ignore", "pipe", "pipe"], so stdout/stderr // are readable streams and stdin is null. Model that precisely. type RunnerChild = ChildProcessByStdio; const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url)); const RUNNER = join(EXTENSION_DIR, "scripts/claude_cli_runner.sh"); const BUNDLED_SKILLS_DIR = join(EXTENSION_DIR, "skills"); const MAX_MESSAGE_CHARS = 6_000; // Cap the in-memory copy of the runner's stdout/stderr. The runner is // agent-safe (it prints only a small status envelope by default), but a // caller opting into --print-full could otherwise stream unbounded output // into this process. Keep the tail, which is where the status/errors land. const MAX_CAPTURE_CHARS = 256_000; const RUNS_ROOT = join(tmpdir(), "pi-claude-code"); const RUN_LEDGER_FILE = "run.json"; // Robustness defaults. Every spawned worker is guaranteed to reach exactly one // terminal notification within a bounded time; these govern how quickly a // wedged or silent worker is detected and reported. const DEFAULT_TIMEOUT_MS = 30 * 60_000; // hard wall-clock ceiling (kills) // Absolute, non-disableable wall-clock ceiling. No caller-supplied timeoutMs — // including 0 — can push the worker's kill deadline past this; it exists to // bound runaway-cost exposure even if every other guard is misconfigured. const MAX_TIMEOUT_MS = 6 * 60 * 60 * 1000; // 6h const MIN_TIMEOUT_MS = 30_000; // Hard cap on simultaneously active (unfinalized) runs. Not configurable via // env/config: a bad loop spawning workers is exactly the failure this guards. const DEFAULT_MAX_ACTIVE_RUNS = 3; // Non-blocking inactivity alert: if the worker produces no new output for this // long it does NOT get killed — the orchestrator is pinged so it can decide. const DEFAULT_INACTIVITY_ALERT_MS = 120_000; // Hard stall KILL is opt-in (0 = off). Inactivity is surfaced as an alert, not a // kill; the wall-clock timeout remains the guaranteed hard backstop. const DEFAULT_STALL_KILL_MS = 0; const KILL_GRACE_MS = 5_000; // SIGTERM -> SIGKILL escalation window const FORCE_FINALIZE_MS = 10_000; // if `close` never fires after SIGKILL, finalize anyway // How often to sample the artifact's size to measure "output produced". Kept // well under the alert threshold so detection lands within one poll of crossing. const MIN_POLL_MS = 2_000; const MAX_POLL_MS = 15_000; // Advisory-only floor: below this, a maxTurns cap has burned real workers on // error_max_turns for multi-file implementation tasks (edit+test+lint+format // across several files) even though each turn was doing real work, not // looping. Callers passing a low cap for a narrowly-bounded task (a single // read-only check, a one-file lookup) are exactly what the skill's runaway // guard is for, so this never blocks the call — it only annotates the // immediate return so the caller notices before the completion hook (and the // spent turns) arrive. const LOW_MAX_TURNS_WARNING = 30; type TerminationReason = | "completed" | "claude_error" | "timeout" | "stall" | "stopped" | "spawn_error" | "shutdown"; // The durable ledger can be on disk in a non-terminal state — seeded before a // worker's fate is known at all — so its terminationReason is a superset of // the in-memory RunState's. "pending" means exactly that: no terminal event // (completion, kill, or genuine session_shutdown) has been recorded yet, so // the run is NOT recoverable as finished. Only "shutdown" is ever recovered. type LedgerTerminationReason = TerminationReason | "pending"; type RunState = { id: string; prompt: string; cwd: string; outputFile: string; runDir: string; startedAt: string; startedAtMs: number; timeoutMs: number; inactivityAlertMs: number; stallKillMs: number; finishedAt?: string; exitCode?: number | null; signal?: NodeJS.Signals | null; status?: Record; error?: string; terminationReason?: TerminationReason; // Lifecycle bookkeeping — never surfaced to the LLM directly. pid?: number; child?: RunnerChild; finalized: boolean; terminating?: TerminationReason; // Activity tracking, driven by the artifact file growing as the streamed // event log is written. This is the only live signal (the runner is silent on // stdout until it exits), so it powers both the inactivity alert and any // opt-in stall kill. lastActivityAtMs: number; lastSize: number; inactivityAlertCount: number; lastAlertAtMs: number; polling: boolean; // guards against overlapping async polls wallTimer?: NodeJS.Timeout; pollTimer?: NodeJS.Timeout; killTimer?: NodeJS.Timeout; forceTimer?: NodeJS.Timeout; // Serialized writes make a terminal shutdown record durable before teardown. ledgerWrite?: Promise; }; type RecoveredRun = { runId: string; startedAt: string; finishedAt: string | null; terminationReason: LedgerTerminationReason; cwd: string; outputFile: string; pid: number | null; exitCode: number | null; signal: string | null; timeoutMs: number; status: Record | null; error: string | null; }; function makeId(): string { return `claude-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function asObject(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } function parseStatus(stdout: string): Record | undefined { const trimmed = stdout.trim(); if (!trimmed) return undefined; // The runner emits its status envelope with jq -n, which pretty-prints a // multi-line JSON object. Parse the whole stdout first, then fall back to // progressively shorter suffixes so diagnostics before the envelope do not // hide the terminal status. try { return asObject(JSON.parse(trimmed)); } catch { /* status envelope may be preceded by diagnostics */ } const lines = trimmed.split(/\r?\n/); for (let start = lines.length - 1; start >= 0; start -= 1) { const candidate = lines.slice(start).join("\n").trim(); if (!candidate.startsWith("{")) continue; try { return asObject(JSON.parse(candidate)); } catch { /* keep scanning earlier suffixes */ } } return undefined; } function bounded(value: string): string { if (value.length <= MAX_MESSAGE_CHARS) return value; return `${value.slice(0, MAX_MESSAGE_CHARS)}\n… truncated; inspect the artifact path for the full result.`; } // Keep only the tail of a growing capture buffer so a rogue --print-full run // cannot exhaust memory in the Pi process. function appendCapped(buffer: string, chunk: string): string { const next = buffer + chunk; return next.length <= MAX_CAPTURE_CHARS ? next : next.slice(next.length - MAX_CAPTURE_CHARS); } async function readBoundedFile(path: string, maxChars = MAX_MESSAGE_CHARS): Promise { const handle = await open(path, "r"); try { const buffer = Buffer.alloc(maxChars + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); const text = buffer.subarray(0, Math.min(bytesRead, maxChars)).toString("utf8"); return bytesRead > maxChars ? `${text}\n… truncated; inspect the artifact path for the full result.` : text; } finally { await handle.close(); } } export default function claudeCodeExtension(pi: ExtensionAPI): void { const runs = new Map(); const recoveredRuns = new Map(); let shuttingDown = false; // A session runtime is intentionally ephemeral, but a shutdown result must // survive it. Only terminal shutdown records are reloaded: completed runs // are already represented by their completion hook and should not clutter a // later session's run list indefinitely. const recoveryReady = (async () => { const entries = await readdir(RUNS_ROOT, { withFileTypes: true }).catch(() => []); await Promise.all(entries.filter(entry => entry.isDirectory()).map(async (entry) => { try { const ledger = JSON.parse(await readFile(join(RUNS_ROOT, entry.name, RUN_LEDGER_FILE), "utf8")) as RecoveredRun; if (ledger.terminationReason === "shutdown" && typeof ledger.runId === "string") recoveredRuns.set(ledger.runId, ledger); } catch { /* no ledger or a partial/corrupt write: it is not recoverable */ } })); })(); pi.on("resources_discover", () => ({ skillPaths: [BUNDLED_SKILLS_DIR], })); function scheduleLedger(state: RunState): Promise { const ledger: RecoveredRun = { runId: state.id, startedAt: state.startedAt, finishedAt: state.finishedAt ?? null, // Only a state that has actually reached a terminal reason (set by // finalize() or the session_shutdown handler) may be written as such. // Before that, the ledger is "pending" — durable on disk, but never // matched by the "shutdown"-only recovery filter below, so an abrupt // crash (no session_shutdown) can never be misreported as a clean // shutdown while the detached worker may still be running. terminationReason: state.terminationReason ?? "pending", cwd: state.cwd, outputFile: state.outputFile, pid: state.pid ?? null, exitCode: state.exitCode ?? null, signal: state.signal ?? null, timeoutMs: state.timeoutMs, status: state.status ?? null, error: state.error ?? null, }; const write = async () => { await writeFile(join(state.runDir, RUN_LEDGER_FILE), `${JSON.stringify(ledger)}\n`, "utf8"); }; state.ledgerWrite = (state.ledgerWrite ?? Promise.resolve()).catch(() => undefined).then(write); return state.ledgerWrite; } function clearTimers(state: RunState): void { if (state.pollTimer) clearInterval(state.pollTimer); state.pollTimer = undefined; for (const key of ["wallTimer", "killTimer", "forceTimer"] as const) { const timer = state[key]; if (timer) clearTimeout(timer); state[key] = undefined; } } // Kill the whole process group (the runner shell AND its `claude` grandchild), // falling back to the single pid if the group signal is rejected. Spawning the // runner detached makes its pid the group leader, so `-pid` targets the group. function killGroup(state: RunState, sig: NodeJS.Signals): boolean { const pid = state.pid; if (!pid) return false; try { process.kill(-pid, sig); return true; } catch { try { process.kill(pid, sig); return true; } catch { return false; } } } // Single funnel for every terminal path. Idempotent: fires the completion hook // exactly once and clears all timers. This is the invariant that guarantees the // orchestrator always learns a worker's fate — success, failure, timeout, // stall, spawn failure, explicit stop, or shutdown. function finalize(state: RunState, reason: TerminationReason, opts?: { forced?: boolean }): void { if (state.finalized) return; state.finalized = true; clearTimers(state); state.finishedAt = new Date().toISOString(); state.terminationReason = reason; state.child = undefined; // The ledger is intentionally best-effort for ordinary runs, but shutdown // awaits it below before signalling. Persist every terminal state so a // late close cannot overwrite shutdown with a misleading completion. void scheduleLedger(state); if (shuttingDown) return; // never inject a hook while the session is tearing down const elapsedMs = Date.now() - state.startedAtMs; const failed = reason !== "completed"; const headline = { completed: `Claude Code run ${state.id} completed.`, claude_error: `Claude Code run ${state.id} FAILED — Claude reported an error.`, timeout: `Claude Code run ${state.id} TIMED OUT after ${Math.round(elapsedMs / 1000)}s and was terminated.`, stall: `Claude Code run ${state.id} STALLED (no output for ${Math.round(state.stallKillMs / 1000)}s) and was terminated.`, stopped: `Claude Code run ${state.id} was STOPPED by the orchestrator.`, spawn_error: `Claude Code run ${state.id} FAILED to start (spawn error).`, shutdown: `Claude Code run ${state.id} was terminated by session shutdown.`, }[reason]; const lines = [ headline, `Workdir: ${state.cwd}`, `Elapsed: ${Math.round(elapsedMs / 1000)}s (exit=${state.exitCode ?? "null"}${state.signal ? `, signal=${state.signal}` : ""}).`, `Output artifact: ${String(state.status?.output_file ?? state.outputFile)}`, state.status ? `Runner status: ${JSON.stringify(state.status)}` : "Runner emitted no parseable status envelope.", state.error ? `Error: ${state.error}` : "", opts?.forced ? "NOTE: forced finalize — the worker never reported clean exit; treat any partial work as unverified." : "", failed ? "This run did NOT succeed. Do not report the delegated task as done. Inspect the artifact/stderr, then retry or escalate." : "Inspect the output artifact and independently verify the requested work before reporting completion.", "Fold relevant evidence into the ongoing task. If this run is stale, superseded, or irrelevant, do not produce a separate user-facing recap.", ].filter(Boolean); pi.sendMessage( { customType: "claude-code-complete", content: bounded(lines.join("\n")), display: true, details: { runId: state.id, terminationReason: reason, elapsedMs, ...state.status } }, { triggerTurn: true, deliverAs: "steer" }, ); } // Escalating termination: SIGTERM the group, SIGKILL after a grace window, and // force-finalize if `close` never arrives (a truly wedged, unkillable child). function terminate(state: RunState, reason: TerminationReason): void { if (state.finalized || state.terminating) return; state.terminating = reason; clearTimers(state); // stop watchdogs; kill/force timers are set below killGroup(state, "SIGTERM"); state.killTimer = setTimeout(() => { if (!state.finalized) killGroup(state, "SIGKILL"); }, KILL_GRACE_MS); state.forceTimer = setTimeout(() => { if (!state.finalized) finalize(state, reason, { forced: true }); }, KILL_GRACE_MS + FORCE_FINALIZE_MS); } // Non-blocking, repeating inactivity alert. The worker is NOT touched — the // orchestrator is pinged so it can decide (inspect, keep waiting, or stop). function sendInactivityAlert(state: RunState): void { if (shuttingDown || state.finalized) return; state.inactivityAlertCount += 1; state.lastAlertAtMs = Date.now(); const idleSec = Math.round((Date.now() - state.lastActivityAtMs) / 1000); const elapsedSec = Math.round((Date.now() - state.startedAtMs) / 1000); const content = [ `⚠️ Claude Code run ${state.id} has produced no new output for ${idleSec}s (inactivity alert #${state.inactivityAlertCount}; running ${elapsedSec}s total).`, `Workdir: ${state.cwd}`, "The worker is STILL RUNNING and was NOT stopped. This can be a legitimately long step (a big test run, a slow build, a large file edit) rather than a hang.", `Decide: call get_claude_code_run("${state.id}") to inspect liveness; call stop_claude_code_run("${state.id}") only if it is genuinely stuck; otherwise keep waiting for the completion hook.`, ].join("\n"); pi.sendMessage( { customType: "claude-code-inactivity", content, display: true, details: { runId: state.id, inactivityAlertCount: state.inactivityAlertCount, idleMs: Date.now() - state.lastActivityAtMs, elapsedMs: Date.now() - state.startedAtMs, running: true }, }, { triggerTurn: true, deliverAs: "followUp" }, ); } // Sample the artifact size; growth == the worker produced output. Fire the // inactivity alert (and, only if explicitly enabled, the hard stall kill) when // the quiet window is exceeded. function pollActivity(state: RunState): void { if (state.polling || state.finalized || state.terminating || !state.pid) return; state.polling = true; stat(state.outputFile) .then((info) => { if (state.finalized || state.terminating) return; const now = Date.now(); if (info.size > state.lastSize) { state.lastSize = info.size; state.lastActivityAtMs = now; } const idleMs = now - state.lastActivityAtMs; if (state.stallKillMs > 0 && idleMs >= state.stallKillMs) { terminate(state, "stall"); return; } if (state.inactivityAlertMs > 0 && idleMs >= state.inactivityAlertMs && now - state.lastAlertAtMs >= state.inactivityAlertMs) { sendInactivityAlert(state); } }) .catch(() => { /* artifact not created yet; idle clock keeps running */ }) .finally(() => { state.polling = false; }); } function startPolling(state: RunState): void { if (state.inactivityAlertMs <= 0 && state.stallKillMs <= 0) return; const thresholds = [state.inactivityAlertMs, state.stallKillMs].filter(v => v > 0); const every = Math.max(MIN_POLL_MS, Math.min(MAX_POLL_MS, ...thresholds)); state.pollTimer = setInterval(() => pollActivity(state), every); state.pollTimer.unref?.(); } pi.registerTool({ name: "spawn_claude_code", label: "Spawn Claude Code", description: "Start Claude Code headlessly through the local agent-safe runner. Returns immediately; a single completion hook is guaranteed to be injected back into Pi — on success, failure, timeout, stall, or crash — so a worker can never hang silently. While it runs, a non-blocking inactivity alert fires if the worker produces no output for inactivityAlertMs (default 120s), without killing it. This tool and its run IDs belong ONLY to this extension: do not pass a run ID returned here to subagent_wait or any other pi-subagents tool — that extension is unrelated and cannot observe these runs. Do not poll get_claude_code_run/list_claude_code_runs in a loop while a run is active; continue other work or end the turn and wait for the completion hook.", promptSnippet: "Start a headless Claude Code worker; a completion hook always fires, and an inactivity alert pings you if it goes quiet. Never hand its run ID to subagent_wait — that's a different extension.", promptGuidelines: [ "Use spawn_claude_code only for a well-scoped autonomous coding or review task with explicit success criteria.", "spawn_claude_code guarantees a completion hook; when its terminationReason is not 'completed', treat the delegated task as unfinished and do not report success.", "Never pass a spawn_claude_code run ID to subagent_wait or any other pi-subagents tool. pi-subagents is a separate extension that cannot observe or wait on Claude Code runs; doing so is an invalid cross-plugin tool call and will not work.", "After spawning, do not busy-poll get_claude_code_run/list_claude_code_runs. Continue other useful, independent work or yield/end the turn — the claude-code-complete hook will steer Pi awake on its own; no waiting loop is needed.", "Only call get_claude_code_run/list_claude_code_runs mid-flight after a claude-code-inactivity alert, an explicit user request for status, or concrete evidence the worker is stuck. Calling them repeatedly on a fixed short interval 'just to check' is not a valid reason.", "A claude-code-inactivity alert from spawn_claude_code means the worker went quiet, not that it failed — inspect with get_claude_code_run before deciding to stop it.", "After a spawn_claude_code completion hook fires, call get_claude_code_run once to inspect the finalized artifact/status, then independently verify important changes before claiming success.", ], parameters: Type.Object({ prompt: Type.String({ minLength: 1, description: "Complete task prompt with success criteria and validation commands." }), cwd: Type.Optional(Type.String({ description: "Absolute or Pi-cwd-relative working directory. Defaults to Pi cwd." })), allowedTools: Type.Optional(Type.String({ description: "Claude allowed-tools expression for pre-approving matching tool calls, for example Read,Edit,Write,Bash(git *),Bash(npm test). Not a sandbox boundary; use tools/disallowedTools to constrain availability." })), tools: Type.Optional(Type.String({ description: "Restrict Claude's available built-in tools (--tools), for example Read,Edit or an empty string to disable tools. Use this when a delegation requires a narrow tool set." })), disallowedTools: Type.Optional(Type.String({ description: "Additional Claude deny rules, combined with the extension's mandatory Agent,Task denial (e.g. Bash(rm *),Write)." })), model: Type.Optional(Type.String({ description: "Claude model override. The runner default is used when omitted." })), fallbackModel: Type.Optional(Type.String({ description: "Fallback model (or comma-separated chain) tried when the primary is overloaded/unavailable. Passed to --fallback-model." })), appendSystemPrompt: Type.Optional(Type.String({ description: "Extra standing instructions appended to Claude's system prompt (--append-system-prompt)." })), addDir: Type.Optional(Type.String({ description: "Additional working directory Claude may read/edit (--add-dir)." })), permissionMode: Type.Optional(Type.String({ description: "Claude permission mode, e.g. plan | acceptEdits | bypassPermissions (--permission-mode)." })), bare: Type.Optional(Type.Boolean({ description: "Disabled: Claude --bare mode is rejected at runtime because it bypasses hooks/plugins/MCP/CLAUDE.md and breaks the orchestration safety model." })), maxTurns: Type.Optional(Type.Integer({ minimum: 1, description: "Hard cap on Claude agentic turns. Guards against runaway loops. Omit for the runner default (unlimited)." })), timeoutMs: Type.Optional(Type.Integer({ minimum: MIN_TIMEOUT_MS, maximum: MAX_TIMEOUT_MS, description: `Wall-clock ceiling in ms; the worker is killed and reported as timed out past this. Default ${DEFAULT_TIMEOUT_MS}. Must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} (6h); this ceiling cannot be disabled.` })), inactivityAlertMs: Type.Optional(Type.Integer({ minimum: 0, description: `Send a non-blocking, repeating alert (without killing the worker) when it produces no output for this many ms. Default ${DEFAULT_INACTIVITY_ALERT_MS}. 0 disables the alert.` })), stallKillMs: Type.Optional(Type.Integer({ minimum: 0, description: `Optionally KILL a worker that produces no output for this many ms (reported as terminationReason 'stall'). Default 0 = disabled; inactivity is surfaced as an alert, not a kill.` })), }), async execute(_id, params, _signal, _onUpdate, ctx) { if (params.bare === true) { throw new Error("bare: true is disabled for this extension because it bypasses hooks/plugins/MCP/CLAUDE.md and breaks the orchestration safety model."); } await stat(RUNNER).catch(() => { throw new Error(`Claude runner not found: ${RUNNER}`); }); const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : resolve(ctx.cwd, params.cwd)) : ctx.cwd; const cwdInfo = await stat(cwd).catch(() => undefined); if (!cwdInfo?.isDirectory()) throw new Error(`Working directory does not exist: ${cwd}`); // Capacity check + slot reservation must be one synchronous critical // section: the count is read and the reservation (runs.set) happens with // no `await` in between, so two concurrent spawn_claude_code calls can // never both observe capacity and both reserve a slot (the classic // check-then-act TOCTOU race that an `await mkdir` between them would // otherwise open up). const activeRuns = [...runs.values()].filter(run => !run.finalized).length; if (activeRuns >= DEFAULT_MAX_ACTIVE_RUNS) { throw new Error(`Refusing to spawn Claude Code: ${activeRuns} active runs already exist (max ${DEFAULT_MAX_ACTIVE_RUNS}). Stop or wait for a run before spawning another.`); } const id = makeId(); const runDir = join(tmpdir(), "pi-claude-code", id); const outputFile = join(runDir, "result.json"); // Absolute, non-disableable ceiling: no requested value — including a // schema-bypassing 0 — can push the kill deadline past MAX_TIMEOUT_MS or // below MIN_TIMEOUT_MS. const requestedTimeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, requestedTimeoutMs)); const inactivityAlertMs = params.inactivityAlertMs ?? DEFAULT_INACTIVITY_ALERT_MS; const stallKillMs = params.stallKillMs ?? DEFAULT_STALL_KILL_MS; const startedAtMs = Date.now(); const state: RunState = { id, prompt: params.prompt, cwd, outputFile, runDir, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, timeoutMs, inactivityAlertMs, stallKillMs, finalized: false, lastActivityAtMs: startedAtMs, lastSize: 0, inactivityAlertCount: 0, lastAlertAtMs: startedAtMs, polling: false, }; // Reserve the slot NOW, synchronously, before any await — this is what // makes the capacity check above atomic with the reservation. runs.set(id, state); try { await mkdir(runDir, { recursive: true }); } catch (err) { // Roll back the reservation: no run dir, no child, nothing to clean up // beyond freeing the slot we reserved above. runs.delete(id); throw new Error(`Failed to create run directory for ${id}: ${err instanceof Error ? err.message : String(err)}`, { cause: err, }); } // Seed the durable ledger before spawning, in "pending" state. This gives // a genuine shutdown a known location to write to even if process // creation or teardown races the first output, but a "pending" record is // never recoverable — only a real terminal write (finalize, or the // session_shutdown handler) can promote it, so a Pi crash that skips // session_shutdown entirely leaves the run correctly unrecovered instead // of falsely reported as shutdown/running:false. await scheduleLedger(state); // --stream makes the runner write a live-growing JSONL event log to the // artifact, which is the activity signal the inactivity poller measures. const args = ["-P", params.prompt, "-d", cwd, "--stream", "-o", outputFile]; if (params.allowedTools) args.push("-a", params.allowedTools); if (params.tools !== undefined) args.push("--tools", params.tools); if (params.disallowedTools) args.push("--disallowed-tools", params.disallowedTools); if (params.model) args.push("-m", params.model); if (params.fallbackModel) args.push("--fallback-model", params.fallbackModel); if (params.appendSystemPrompt) args.push("--append-system-prompt", params.appendSystemPrompt); if (params.addDir) args.push("--add-dir", params.addDir); if (params.permissionMode) args.push("--permission-mode", params.permissionMode); if (params.maxTurns) args.push("-t", String(params.maxTurns)); // Give the runner its own hard backstop so a wedged claude still dies even // if this Pi process itself is killed and orphans the worker. timeoutMs is // always > 0 (clamped above), so this backstop is always armed. args.push("--timeout", String(Math.ceil(timeoutMs / 1000) + 30)); let child: RunnerChild; try { // detached:true => the runner is a group leader, so we can signal the // whole tree (runner shell + claude) with a negative pid. child = spawn(RUNNER, args, { cwd, env: process.env, detached: true, stdio: ["ignore", "pipe", "pipe"] }) as RunnerChild; } catch (err) { // Synchronous spawn failure: the tool result below reports it inline, so // the orchestrator learns immediately — no completion hook needed (that // would double-signal and trigger a redundant turn). Mark terminal so // get/list report it correctly and no watchdog can ever fire. state.error = err instanceof Error ? err.message : String(err); state.finalized = true; state.terminationReason = "spawn_error"; state.finishedAt = new Date().toISOString(); return { content: [{ type: "text", text: `Failed to spawn Claude Code run ${id}: ${state.error}` }], details: { runId: id, cwd, terminationReason: "spawn_error", error: state.error } }; } state.child = child; state.pid = child.pid; let stdout = ""; let stderr = ""; // The runner is silent on stdout until it exits (the streamed event log // goes to the artifact file, not here), so these buffers just collect the // final status envelope / runner diagnostics. Live activity is measured by // the artifact-growth poller, not by these chunks. const onData = (target: "out" | "err") => (chunk: unknown) => { const text = String(chunk); if (target === "out") stdout = appendCapped(stdout, text); else stderr = appendCapped(stderr, text); }; child.stdout.on("data", onData("out")); child.stderr.on("data", onData("err")); // NOTE: the worker is intentionally NOT tied to the tool-call abort signal. // It is a background job that must outlive the (immediately-returning) tool // call; killing it belongs to stop_claude_code_run, the watchdogs, or // session shutdown. Tying it to the tool-call signal risked premature death // — the exact "worker vanished without the orchestrator knowing" failure. child.once("error", (error) => { state.error = error.message; finalize(state, "spawn_error"); }); child.once("close", (exitCode, childSignal) => { state.exitCode = exitCode; state.signal = childSignal; state.status = parseStatus(stdout); if (stderr.trim() && !state.error) state.error = bounded(stderr.trim()); const runnerStatus = typeof state.status?.status === "string" ? state.status.status : undefined; const claudeFailed = runnerStatus === "error" || runnerStatus === "claude_error" || state.status?.is_error === true; const reason: TerminationReason = state.terminating ?? ((exitCode ?? 1) === 0 && !claudeFailed ? "completed" : "claude_error"); finalize(state, reason); }); // Arm watchdogs only after the child is live. timeoutMs is always > 0 // (clamped above), so the wall timer is always armed — it is the // non-disableable hard-kill ceiling. state.wallTimer = setTimeout(() => terminate(state, "timeout"), timeoutMs); startPolling(state); const alertNote = inactivityAlertMs > 0 ? `non-blocking inactivity alert at ${Math.round(inactivityAlertMs / 1000)}s of silence` : "inactivity alert disabled"; const lowMaxTurnsWarning = params.maxTurns !== undefined && params.maxTurns < LOW_MAX_TURNS_WARNING ? ` WARNING: maxTurns=${params.maxTurns} is low — if this task touches more than one file or runs tests/lint, it can hit error_max_turns before finishing (see skill step 5: leave maxTurns unset unless this is a narrowly-bounded task).` : ""; return { content: [{ type: "text", text: `Started headless Claude Code run ${id} (pid ${child.pid}). Completion hook guaranteed (wall-clock ${Math.round(timeoutMs / 1000)}s); ${alertNote}. Artifact: ${outputFile}.${lowMaxTurnsWarning}` }], details: { runId: id, cwd, outputFile, pid: child.pid, timeoutMs, inactivityAlertMs, stallKillMs, maxTurns: params.maxTurns }, }; }, }); function recoveredView(run: RecoveredRun) { return { runId: run.runId, running: false, finalized: true, recovered: true, terminationReason: run.terminationReason, startedAt: run.startedAt, finishedAt: run.finishedAt, elapsedMs: Date.parse(run.finishedAt ?? run.startedAt) - Date.parse(run.startedAt), pid: run.pid, cwd: run.cwd, outputFile: run.outputFile, exitCode: run.exitCode, signal: run.signal, timeoutMs: run.timeoutMs, timeoutBudgetRemainingMs: null, inactivityAlertMs: 0, stallKillMs: 0, msSinceLastOutput: null, inactivityAlertCount: 0, status: run.status, error: run.error, }; } function publicView(state: RunState) { const now = Date.now(); const running = Boolean(state.child) && !state.finalized; const elapsedMs = (state.finishedAt ? Date.parse(state.finishedAt) : now) - state.startedAtMs; return { runId: state.id, running, finalized: state.finalized, recovered: false, terminationReason: state.terminationReason ?? (state.terminating ? `terminating:${state.terminating}` : null), startedAt: state.startedAt, finishedAt: state.finishedAt ?? null, elapsedMs, pid: state.pid ?? null, cwd: state.cwd, outputFile: state.outputFile, exitCode: state.exitCode ?? null, signal: state.signal ?? null, // Liveness the orchestrator can reason about without blocking. timeoutMs: state.timeoutMs, timeoutBudgetRemainingMs: running ? Math.max(0, state.timeoutMs - elapsedMs) : null, inactivityAlertMs: state.inactivityAlertMs, stallKillMs: state.stallKillMs, msSinceLastOutput: running ? now - state.lastActivityAtMs : null, inactivityAlertCount: state.inactivityAlertCount, status: state.status ?? null, error: state.error ?? null, }; } pi.registerTool({ name: "get_claude_code_run", label: "Get Claude Code Run", description: "One-shot diagnostic inspection of a headless Claude Code run: liveness (elapsed, timeout budget remaining, ms since last output), termination reason, and — when finished — a bounded result excerpt plus artifact metadata. This is NOT a wait or polling mechanism and never blocks until completion — it only reports run state at the instant it is called. Do not call it repeatedly on a timer while a run is active; the run's own claude-code-complete hook will notify Pi when it finishes. Only useful mid-flight after a claude-code-inactivity alert, an explicit user status request, or concrete suspicion the worker is stuck. This tool only knows about runs started by spawn_claude_code in this extension — it is unrelated to pi-subagents and cannot be used to check on subagent_wait/spawn-style runs from that extension.", promptSnippet: "One-shot status check for a previously spawned Claude Code run — not a wait/poll; do not call it in a loop.", promptGuidelines: [ "get_claude_code_run is a one-shot status check, not a wait. Do not call it repeatedly (e.g. every few seconds) while a run is active — that is busy-polling and wastes turns; wait for the claude-code-complete hook instead.", "Only call get_claude_code_run mid-flight after a claude-code-inactivity alert, an explicit user request for status, or concrete suspicion the worker is stuck.", "After the claude-code-complete hook fires, call get_claude_code_run once to inspect the finalized artifact/status before verification — you do not need to call it again after that.", "Never pass a runId from subagent_wait or any pi-subagents tool here, and never pass a spawn_claude_code runId to subagent_wait — the two extensions are unrelated and cannot observe each other's runs.", ], parameters: Type.Object({ runId: Type.String({ minLength: 1, description: "Run ID returned by spawn_claude_code. Do not pass a run ID that came from subagent_wait or another extension." }) }), async execute(_id, params) { const state = runs.get(params.runId); if (!state) { await recoveryReady; const recovered = recoveredRuns.get(params.runId); if (!recovered) throw new Error(`Unknown Claude Code run: ${params.runId}`); const view = recoveredView(recovered); return { content: [{ type: "text", text: bounded(`${JSON.stringify(view, null, 2)}\n\nRecovered after session shutdown; inspect the output artifact if it exists.`) }], details: view as any }; } const view = publicView(state); let excerpt = ""; if (state.finalized) { excerpt = await readBoundedFile(String(state.status?.output_file ?? state.outputFile)).catch(() => "Output artifact is not readable."); } return { content: [{ type: "text", text: bounded(`${JSON.stringify(view, null, 2)}${excerpt ? `\n\nArtifact excerpt:\n${excerpt}` : ""}`) }], details: view as any }; }, }); pi.registerTool({ name: "list_claude_code_runs", label: "List Claude Code Runs", description: "One-shot diagnostic listing of every Claude Code run owned by this Pi session, with liveness and termination reason, so a fleet of workers can be monitored and a stuck one spotted at a glance. This is NOT a wait or polling mechanism — it only reports state at the instant it is called. Do not call it on a repeating timer while runs are active; each run's own claude-code-complete hook will notify Pi when it finishes. Only relevant to runs started by spawn_claude_code in this extension, not to pi-subagents runs.", promptSnippet: "One-shot listing of spawned Claude Code workers and their liveness — not a wait/poll; do not call it in a loop.", promptGuidelines: [ "list_claude_code_runs is a one-shot status check, not a wait. Do not call it repeatedly while runs are active — wait for each run's claude-code-complete hook instead.", "Only call list_claude_code_runs mid-flight after a claude-code-inactivity alert, an explicit user request for status, or concrete suspicion a worker is stuck.", ], parameters: Type.Object({ activeOnly: Type.Optional(Type.Boolean({ description: "Only include runs that are still executing." })), }), async execute(_id, params) { await recoveryReady; let views: any[] = [...runs.values()].map(publicView); if (!params.activeOnly) views = [...views, ...[...recoveredRuns.values()].map(recoveredView)]; if (params.activeOnly) views = views.filter(v => v.running); views.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)); const summary = views.length ? views.map(v => `${v.runId} — ${v.running ? "RUNNING" : (v.terminationReason ?? "done")} — ${Math.round(v.elapsedMs / 1000)}s${v.running && v.msSinceLastOutput != null ? `, ${Math.round(v.msSinceLastOutput / 1000)}s since output` : ""}`).join("\n") : "No Claude Code runs in this session."; return { content: [{ type: "text", text: bounded(summary) }], details: { count: views.length, runs: views } }; }, }); pi.registerTool({ name: "stop_claude_code_run", label: "Stop Claude Code Run", description: "Terminate a running headless Claude Code worker (SIGTERM, escalating to SIGKILL) owned by this Pi session. A completion hook with terminationReason 'stopped' will follow.", promptSnippet: "Stop a running headless Claude Code worker.", parameters: Type.Object({ runId: Type.String({ minLength: 1, description: "Run ID returned by spawn_claude_code." }) }), async execute(_id, params) { const state = runs.get(params.runId); if (!state) throw new Error(`Unknown Claude Code run: ${params.runId}`); if (state.finalized || !state.child) { return { content: [{ type: "text", text: `${params.runId} is not running (${state.terminationReason ?? "already finished"}).` }], details: { runId: params.runId, stopped: false } }; } terminate(state, "stopped"); return { content: [{ type: "text", text: `Stopping ${params.runId} (SIGTERM, SIGKILL after ${KILL_GRACE_MS}ms). A 'stopped' completion hook will follow.` }], details: { runId: params.runId, stopped: true } }; }, }); pi.on("session_shutdown", async () => { shuttingDown = true; const active = [...runs.values()].filter(state => !state.finalized); // Mark and persist before signalling. Pi awaits session_shutdown handlers, // so a replacement session can discover every run it just terminated. for (const state of active) { state.finalized = true; state.finishedAt = new Date().toISOString(); state.terminationReason = "shutdown"; state.child = undefined; clearTimers(state); void scheduleLedger(state); } await Promise.all(active.map(state => state.ledgerWrite)); for (const state of active) { if (killGroup(state, "SIGTERM")) { setTimeout(() => { try { killGroup(state, "SIGKILL"); } catch { /* gone */ } }, KILL_GRACE_MS).unref?.(); } } }); }