UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

134 lines 6.37 kB
import { createInterface } from 'node:readline'; import { killTree, registerChild, unregisterChild } from './child-registry.js'; // The agent-agnostic core for running one wrapped coding-agent CLI: spawn it in its own // process group, stream its output through a parser, and gate the turn on its exit. Each // concrete driver (claude-code, codex) supplies the argv and an AgentCliParser for its own // output dialect; everything about the *process* lives here, so a second driver reuses it // rather than reaching into the first driver's file for it. /** Grace between SIGTERM and the SIGKILL that forces a hung agent tree down. */ const TERMINATE_GRACE_MS = 5000; /** * Spawn one agent-CLI invocation and resolve with its final turn. * * Everything here is about the *process*, not the agent: its own process group * so an interrupt kills the whole tree rather than orphaning it, a SIGTERM/ * SIGKILL grace window, abort wiring, and a non-zero exit failing the turn even * when text was streamed first. Only {@link RunAgentCliOptions.parser} knows * which agent is on the other end — a second agent gets all of this for free * rather than a second copy of it. */ export function runAgentCli(opts) { return new Promise((resolvePromise, rejectPromise) => { for (const s of opts.signals) { if (s.aborted) { rejectPromise(new Error(`[framework] ${opts.agent ?? 'claude-code'} prompt aborted`)); return; } } opts.emit({ type: 'start', prompt: opts.prompt }); // `detached` makes the child its own process-group leader so we can kill the // whole agent subtree (claude + node workers + tool calls) at once, not just // the top process — otherwise an interrupt orphans the tree (the leak). const child = opts.spawn(opts.bin, opts.args, { cwd: opts.cwd, env: opts.env, detached: true }); const pid = child.pid; if (pid != null) registerChild(pid); const parser = opts.parser; const agent = opts.agent ?? 'claude-code'; let settled = false; let hardKillTimer; // Raw bytes, decoded once at close: a per-chunk `String(chunk)` corrupts a multibyte // UTF-8 codepoint split across two chunks, and this text becomes the turn's error detail. const stderrChunks = []; // Kill the agent's whole process group: SIGTERM to let it flush, then a // SIGKILL after a grace window in case it ignores the term (mid tool-call). const terminate = () => { if (pid != null) killTree(pid, 'SIGTERM'); else child.kill('SIGTERM'); hardKillTimer = setTimeout(() => { if (pid != null) killTree(pid, 'SIGKILL'); else child.kill('SIGKILL'); }, TERMINATE_GRACE_MS); hardKillTimer.unref?.(); }; // Runs exactly once the process is done with (closed, errored, or killed): // stop tracking it and cancel any pending hard-kill. const cleanup = () => { if (pid != null) unregisterChild(pid); if (hardKillTimer) clearTimeout(hardKillTimer); }; const finish = (fn) => { if (settled) return; settled = true; for (const { signal, handler } of aborts) signal.removeEventListener('abort', handler); fn(); }; const aborts = opts.signals.map(signal => { const handler = () => { if (settled) return; terminate(); finish(() => rejectPromise(new Error(`[framework] ${agent} prompt aborted`))); }; signal.addEventListener('abort', handler); return { signal, handler }; }); child.on('error', err => { cleanup(); finish(() => rejectPromise(err)); }); if (child.stdout) { const rl = createInterface({ input: child.stdout }); rl.on('line', line => { for (const event of parser.push(line)) opts.emit(event); }); } if (child.stderr) { child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); } child.on('close', code => { cleanup(); // An abort (or a spawn error) already settled and reported the turn; the process // still closes afterward, but its late exit must not emit a second telemetry event. if (settled) return; const turn = parser.result(); // A non-zero exit is a failed turn even when the agent streamed some text // first: the loop gates on the outcome, so a crash mid-build must not pass // as a result. Surface stderr, else the partial text, as context. if (code !== 0) { const detail = Buffer.concat(stderrChunks).toString('utf8').trim() || turn.text.trim() || `exit code ${code ?? 'null'}`; opts.emit({ type: 'error', message: detail }); finish(() => rejectPromise(new Error(`[framework] ${agent} exited (${code ?? 'null'}): ${detail}`))); return; } opts.emit({ type: 'result', text: turn.text, ...(turn.sessionId ? { sessionId: turn.sessionId } : {}), ...(turn.usage ? { usage: turn.usage } : {}), }); finish(() => resolvePromise(turn)); }); // Feed the prompt over stdin so long prompts never hit arg-length limits. if (child.stdin) { // A CLI that exits before reading stdin (bad flag, instant crash) surfaces an async // EPIPE on the stream; with no listener that is an uncaught exception in the daemon // (#943). The close handler already reports the failed turn, so the error carries // nothing the caller needs. child.stdin.on('error', () => { }); child.stdin.write(opts.prompt); child.stdin.end(); } }); } //# sourceMappingURL=agent-cli.js.map