import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { getCommanderStateDir } from '../../data-path'; import { descendantPids } from '../tasks/process-tree'; /* What a commander spawned, and why the platform cannot work it out for itself. A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself started. The platform never sees that spawn: it happens inside a background job, two processes below a conversation, and the agent talks to Anthropic rather than to us. Three ways to recover the link were tried and none works: - the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing identifying in its environment. Verified 2026-08-22. - the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look like markers the platform injects and are the harness's own, appearing in every session equally. A discriminator built on them rejected a real child and reported zero when one was running. - CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches as well as the agent following it. So the loop records it, and the platform reads it. One newline-delimited file per commander: //commander-state//spawned The loop can only do that if it knows which commander it belongs to, which it does not by default — the id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and every link is required. */ const FILE = 'spawned'; const PID_FILE = 'loop-pid'; /** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */ export function spawnedRecordPath(email: string, commanderSessionId: string): string { return join(getCommanderStateDir(email, commanderSessionId), FILE); } /** * Every agent this commander recorded, oldest first, de-duplicated. * * Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a * truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest * is used. A record that fails closed here would make a commander undeletable because of a bad line. */ export function listSpawnedSessions(email: string, commanderSessionId: string): string[] { const path = spawnedRecordPath(email, commanderSessionId); if (!existsSync(path)) return []; try { const seen = new Set(); for (const raw of readFileSync(path, 'utf-8').split('\n')) { const id = raw.trim(); if (SESSION_ID.test(id)) seen.add(id.toLowerCase()); } return [...seen]; } catch { return []; } } /** * Record one, idempotently. For the platform's own use — the loop appends directly, by design. * * Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when * that arrives it must write the same file rather than a second record. */ export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void { if (!SESSION_ID.test(spawnedId)) return; if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return; const dir = getCommanderStateDir(email, commanderSessionId); mkdirSync(dir, { recursive: true }); appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 }); } /** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */ export function clearSpawnedRecord(email: string, commanderSessionId: string): void { try { rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true }); } catch { // A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this // without a commander to read it for. } } /* ── Stopping what a commander is running ── The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and a `claude` process cannot be attributed to a particular commander from outside — every commander's session runs with the same cwd, so the process table cannot tell them apart. So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the only party that knows. From that one number the whole tree follows — the loop's children include any agent it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct child, so a running grandchild is left orphaned and keeps going"). A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed before this existed, and pretending otherwise would report a clean delete over a loop still polling. */ /** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */ export function loopPidPath(email: string, commanderSessionId: string): string { return join(getCommanderStateDir(email, commanderSessionId), PID_FILE); } /** The loop's pid, if it recorded one and the process is still alive. */ export function liveLoopPid(email: string, commanderSessionId: string): number | null { const path = loopPidPath(email, commanderSessionId); if (!existsSync(path)) return null; const pid = Number(readFileSync(path, 'utf-8').trim()); if (!Number.isInteger(pid) || pid <= 1) return null; try { // Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an // error — loops end — and reusing a dead pid's number would kill something unrelated. process.kill(pid, 0); return pid; } catch { return null; } } /** * Stop the loop and everything under it. Returns how many processes were signalled. * * Children first, then the loop, so the loop cannot spawn a replacement between the two. */ export function stopCommanderProcesses(email: string, commanderSessionId: string): number { const pid = liveLoopPid(email, commanderSessionId); if (!pid) return 0; const tree = [...descendantPids(pid), pid]; let signalled = 0; for (const target of tree) { try { process.kill(target, 'SIGTERM'); signalled += 1; } catch { // Gone between the walk and the signal. Not a failure — that is the outcome we wanted. } } return signalled; }