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.38 kB
JavaScript
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 driver: 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 RunCliSessionOptions.parser} knows
* which CLI is on the other end — a second driver gets all of this for free
* rather than a second copy of it.
*/
export function runCliSession(opts) {
return new Promise((resolvePromise, rejectPromise) => {
for (const s of opts.signals) {
if (s.aborted) {
rejectPromise(new Error(`[framework] ${opts.driver ?? '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.driver ?? '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=cli-session.js.map