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.

310 lines 13.8 kB
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