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.
310 lines • 13.8 kB
JavaScript
import { spawn as nodeSpawn } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { readClaudeQuota } from './claude-code-quota.js';
import { combineFraming, combineSignals, makeEmit, readWorkspaceFile } from './session-support.js';
import { runCliSession } from './cli-session.js';
/**
* The first real {@link Driver}: wraps the **Claude Code CLI** in print mode
* (`claude -p --output-format stream-json`). Each {@link DriverSession.prompt}
* spawns a fresh non-interactive invocation, so every loop pass gets fresh
* context (option A). We stream its JSON events to {@link DriverStartOptions.onEvent}
* for the dashboard and return the final `result` text as the turn.
*
* True black box: we prompt and read the result; Claude Code owns its own loop,
* tools, and (subscription-based) auth. A second agent slots in behind the same
* `Driver` interface without touching the orchestration above it.
*/
export class ClaudeCodeDriver {
opts;
id = 'claude-code';
constructor(opts = {}) {
this.opts = opts;
}
start(opts) {
return Promise.resolve(new ClaudeCodeSession(this.opts, opts));
}
/** Where the account's subscription quota stands (#521). Account-wide, so no session. */
readQuota(opts = {}) {
return readClaudeQuota({
...(this.opts.bin !== undefined ? { bin: this.opts.bin } : {}),
...(this.opts.env !== undefined ? { env: this.opts.env } : {}),
...(this.opts.spawn !== undefined ? { spawn: this.opts.spawn } : {}),
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
});
}
}
let sessionCounter = 0;
/** One workspace-bound Claude Code session. `prompt` is a fresh CLI invocation. */
export class ClaudeCodeSession {
config;
startOpts;
id;
cwd;
/** Path to the written `--mcp-config` file, lazily created on first use. */
mcpConfigPath;
/**
* The agent's own session id from the last turn (#714). Retained so a
* {@link DriverPromptOptions.resume} prompt can `--resume` the same
* conversation; resume keeps the id stable, so consecutive chat messages chain.
*/
lastSessionId;
constructor(config, startOpts) {
this.config = config;
this.startOpts = startOpts;
this.cwd = startOpts.cwd;
this.id = `claude-code-${++sessionCounter}`;
// Resume a finished agent (#720): seeding lastSessionId makes the very first `resume` prompt
// `--resume` this conversation, exactly as a mid-run chat turn continues its own session.
this.lastSessionId = startOpts.resumeSessionId;
}
async prompt(text, opts = {}) {
const system = combineFraming(this.startOpts.system, opts.system);
const resumeId = opts.resume ? this.lastSessionId : undefined;
const emit = makeEmit(this.startOpts.onEvent, 'claude-code');
const signals = combineSignals(this.startOpts.signal, opts.signal);
const agent = (id, emitFn) => runClaude({
bin: this.config.bin ?? 'claude',
args: this.buildArgs(system, id),
cwd: this.cwd,
env: this.config.env ?? process.env,
prompt: text,
spawn: this.config.spawn ?? nodeSpawn,
emit: emitFn,
signals,
});
let turn;
// On a resume attempt, hold the failure's `error` event back until the conversation-gone
// case (#778) is ruled out: a turn that recovers on the retry must not show a failed row.
// Safe to hold — runCliSession emits `error` exactly once, right before it rejects.
let heldError;
try {
turn = await agent(resumeId, resumeId === undefined ? emit : event => {
if (event.type === 'error')
heldError = event;
else
emit(event);
});
}
catch (err) {
// The id we captured outlives what the CLI will resume (#778) — its retention, a
// cleared history, another machine. There is no way to ask first, so let it fail
// once and continue as a fresh conversation (which gets the system framing back)
// rather than losing the message the user already typed.
if (resumeId === undefined || !isConversationGone(err) || signals.some(s => s.aborted)) {
if (heldError)
emit(heldError);
throw err;
}
this.lastSessionId = undefined;
emit({ type: 'notice', message: 'That conversation is no longer available; continuing without its history.' });
// The retry re-sends the same prompt; swallow its duplicate `start` so the user's
// message appears once in the transcript, not twice.
let startSeen = false;
turn = await agent(undefined, event => {
if (event.type === 'start' && !startSeen)
startSeen = true;
else
emit(event);
});
}
// Track the agent's session so a later resume continues this exact conversation.
if (turn.sessionId)
this.lastSessionId = turn.sessionId;
return turn;
}
readCode(path) {
return readWorkspaceFile(this.cwd, path);
}
dispose() {
// Each prompt spawns and reaps its own process, so the only durable thing is
// the temp MCP config file; drop it. The session id reaches the UI via the
// emitted result event.
if (this.mcpConfigPath) {
try {
rmSync(dirname(this.mcpConfigPath), { recursive: true, force: true });
}
catch {
// Best effort: the temp dir lands under the OS tmp and is reaped anyway.
}
this.mcpConfigPath = undefined;
}
return Promise.resolve();
}
buildArgs(system, resumeId) {
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
if (this.config.dangerouslySkipPermissions)
args.push('--dangerously-skip-permissions');
else
args.push('--permission-mode', this.config.permissionMode ?? 'acceptEdits');
// Resume the same conversation for a chat turn (#714). Skip the system append then:
// the resumed transcript already carries its framing, so re-appending only duplicates it.
if (resumeId)
args.push('--resume', resumeId);
else if (system)
args.push('--append-system-prompt', system);
if (this.startOpts.model)
args.push('--model', this.startOpts.model);
const mcpConfig = this.mcpConfigFile();
if (mcpConfig)
args.push('--mcp-config', mcpConfig);
if (this.config.extraArgs)
args.push(...this.config.extraArgs);
return args;
}
/**
* Lazily materialize the `--mcp-config` file for {@link ClaudeCodeDriverOptions.mcpServers}.
* Written once and reused across the session's prompts; `undefined` when no
* servers are configured. Not `--strict-mcp-config`, so these merge with the
* user's own MCP servers rather than replacing them.
*/
mcpConfigFile() {
const servers = this.config.mcpServers;
if (!servers || Object.keys(servers).length === 0)
return undefined;
if (!this.mcpConfigPath) {
const dir = mkdtempSync(join(tmpdir(), 'framework-mcp-'));
this.mcpConfigPath = join(dir, 'mcp.json');
writeFileSync(this.mcpConfigPath, JSON.stringify({ mcpServers: servers }));
}
return this.mcpConfigPath;
}
}
/** How the CLI reports that the session id we asked it to resume is gone from its history (#778). */
const CONVERSATION_GONE = /No conversation found with session ID/i;
/** Whether a failed turn failed because the conversation we tried to resume no longer exists. */
function isConversationGone(err) {
return CONVERSATION_GONE.test(err instanceof Error ? err.message : String(err));
}
/** Spawn one Claude Code invocation and resolve with its final turn. */
export function runClaude(opts) {
return runCliSession({ ...opts, parser: new StreamJsonParser() });
}
/**
* Incremental parser for Claude Code's `stream-json` output: newline-delimited
* JSON, one object per line. We surface assistant text + tool names as
* {@link DriverEvent}s and keep the final `result` line as the turn text.
* Kept separate from the process plumbing so it is unit-testable in isolation.
*/
export class StreamJsonParser {
finalText = '';
assistantText = '';
sessionId;
usage;
/** Feed one line; returns the events it produced (may be empty). */
push(line) {
const trimmed = line.trim();
if (!trimmed)
return [];
let obj;
try {
obj = JSON.parse(trimmed);
}
catch {
return []; // Non-JSON noise (banners etc.); ignore.
}
// Announced on the very first stream line, so it must not wait for `result`: a turn that is
// stopped or dies mid-flight would take the id — the agent's `claude --resume` handle — with
// it (#1322). Emitted only when it changes; every subsequent line repeats the same id.
const announced = [];
if (typeof obj['session_id'] === 'string' && obj['session_id'] !== this.sessionId) {
this.sessionId = obj['session_id'];
announced.push({ type: 'session', sessionId: this.sessionId });
}
const type = obj['type'];
if (type === 'assistant')
return [...announced, ...this.handleAssistant(obj)];
if (type === 'rate_limit_event') {
const limit = parseRateLimit(obj);
return limit ? [...announced, { type: 'rate-limit', limit }] : announced;
}
if (type === 'result') {
const result = obj['result'];
if (typeof result === 'string')
this.finalText = result;
const usage = parseUsage(obj);
if (usage)
this.usage = usage;
return announced; // The `result` event is emitted by the runner after `close`.
}
return announced;
}
handleAssistant(obj) {
const message = obj['message'];
if (typeof message !== 'object' || message === null)
return [];
const content = message['content'];
if (!Array.isArray(content))
return [];
const events = [];
for (const item of content) {
if (typeof item !== 'object' || item === null)
continue;
const block = item;
if (block['type'] === 'text' && typeof block['text'] === 'string') {
this.assistantText += block['text'];
events.push({ type: 'text', text: block['text'] });
}
else if (block['type'] === 'tool_use' && typeof block['name'] === 'string') {
events.push({ type: 'action', label: block['name'] });
}
}
return events;
}
/** The final turn: the `result` text, falling back to accumulated assistant text. */
result() {
const text = this.finalText || this.assistantText;
return {
text,
...(this.sessionId ? { sessionId: this.sessionId } : {}),
...(this.usage ? { usage: this.usage } : {}),
};
}
}
/**
* Pull the account's quota standing off a `rate_limit_event` line (#517):
* `{status, resetsAt, rateLimitType}`. The agent emits one per turn, so this is
* free telemetry — no extra call, no polling. Returns undefined when the payload
* is missing the parts we'd gate on, so a malformed line stays silent rather
* than reporting a bogus reset.
*/
function parseRateLimit(obj) {
const raw = obj['rate_limit_info'];
if (typeof raw !== 'object' || raw === null)
return undefined;
const info = raw;
const status = info['status'];
const window = info['rateLimitType'];
const resetsAt = info['resetsAt'];
if (typeof status !== 'string' || typeof window !== 'string')
return undefined;
if (typeof resetsAt !== 'number' || !Number.isFinite(resetsAt))
return undefined;
// The agent reports epoch seconds; the rest of the framework speaks millis.
return { status, window, resetsAt: resetsAt * 1000 };
}
/**
* Pull token + cost accounting off Claude Code's `result` line (#322):
* `total_cost_usd` plus a `usage` object of token counts. Returns undefined when
* the line carries neither, so a driver/agent that omits usage stays usage-free.
*/
function parseUsage(obj) {
const cost = obj['total_cost_usd'];
const raw = obj['usage'];
const hasUsage = typeof raw === 'object' && raw !== null;
if (typeof cost !== 'number' && !hasUsage)
return undefined;
const usage = (hasUsage ? raw : {});
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
// Omit costUsd when there is no price, never 0: the budget gate reads 0 as "free"
// and undefined as "unknown" (#540), and Codex reports tokens without a price.
return {
...(typeof cost === 'number' && Number.isFinite(cost) ? { costUsd: cost } : {}),
inputTokens: num(usage['input_tokens']),
outputTokens: num(usage['output_tokens']),
cacheReadTokens: num(usage['cache_read_input_tokens']),
cacheCreationTokens: num(usage['cache_creation_input_tokens']),
};
}
//# sourceMappingURL=claude-code.js.map