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.

186 lines 7.44 kB
import { spawn as nodeSpawn } from 'node:child_process'; import { runCliSession } from './cli-session.js'; import { combineFraming, combineSignals, makeEmit, readWorkspaceFile } from './session-support.js'; /** * The second real {@link Driver} (#539): wraps the **Codex CLI** in its * non-interactive mode (`codex exec --json`), on the user's own ChatGPT * subscription — no API key (#495's "bring your own subscription"). * * The seam always said "Claude Code today, Codex later", and this is that. Same * black box: prompt it, let its own loop run, read the code it wrote. * * Three ways it differs from Claude Code, all of them the agent's business * rather than ours: * * - **No system-prompt flag.** Codex has no `--append-system-prompt`, so the * framing is prepended to the prompt instead. Same words reach the agent. * - **Tokens, no price.** Codex reports token counts but never a price, so usage * carries the counts and omits `costUsd` rather than claim a turn cost `$0`, * which would read as free (#540). The budget cap (#322) gates on a price, so * it cannot fire here; the CLI says so at startup instead of implying it. * - **No quota read.** No `readQuota`, for the same reason: the seam is optional * precisely so an agent that can't report one simply doesn't. */ export class CodexDriver { opts; id = 'codex'; constructor(opts = {}) { this.opts = opts; } start(opts) { return Promise.resolve(new CodexSession(this.opts, opts)); } } let sessionCounter = 0; /** One workspace-bound Codex session. `prompt` is a fresh CLI invocation. */ export class CodexSession { config; startOpts; id; cwd; constructor(config, startOpts) { this.config = config; this.startOpts = startOpts; this.cwd = startOpts.cwd; this.id = `codex-${++sessionCounter}`; } prompt(text, opts = {}) { // Codex takes no system-prompt flag, so the framing rides in front of the // prompt. Blank-line separated, so it reads as its own block. const framing = combineFraming(this.startOpts.system, opts.system); const prompt = framing ? `${framing}\n\n${text}` : text; return runCliSession({ bin: this.config.bin ?? 'codex', args: this.buildArgs(), cwd: this.cwd, env: this.config.env ?? process.env, prompt, spawn: this.config.spawn ?? nodeSpawn, emit: makeEmit(this.startOpts.onEvent, 'codex'), signals: combineSignals(this.startOpts.signal, opts.signal), parser: new CodexJsonParser(), driver: 'codex', }); } readCode(path) { return readWorkspaceFile(this.cwd, path); } dispose() { // Each prompt spawns and reaps its own process; nothing durable to free. return Promise.resolve(); } buildArgs() { // No prompt argument: it goes over stdin, so a long one never hits the // arg-length limit. `--skip-git-repo-check` because Codex otherwise refuses // to run outside a git repo, and a workspace may legitimately not be one yet. const args = ['exec', '--json', '--skip-git-repo-check', '--sandbox', this.config.sandbox ?? 'workspace-write', '-C', this.cwd]; if (this.startOpts.model) args.push('-m', this.startOpts.model); if (this.config.extraArgs) args.push(...this.config.extraArgs); return args; } } /** * Parses Codex's `exec --json` output: one JSON event per line. * * The dialect, as observed on codex-cli 0.144.4: * ``` * {"type":"thread.started","thread_id":"019f..."} * {"type":"turn.started"} * {"type":"item.completed","item":{"type":"agent_message","text":"..."}} * {"type":"item.started","item":{"type":"file_change","status":"in_progress"}} * {"type":"turn.completed","usage":{"input_tokens":12210,"output_tokens":5}} * ``` */ export class CodexJsonParser { text = ''; sessionId; usage; push(line) { let obj; try { obj = JSON.parse(line); } catch { return []; // Banners and other noise: not every line is an event. } const type = obj['type']; if (type === 'thread.started') { const id = obj['thread_id']; if (typeof id === 'string') this.sessionId = id; return []; } if (type === 'turn.completed') { const usage = parseCodexUsage(obj['usage']); if (usage) this.usage = usage; return []; } const item = obj['item']; if (typeof item !== 'object' || item === null) return []; const itemObj = item; const itemType = itemObj['type']; if (itemType === 'agent_message' && type === 'item.completed') { const text = itemObj['text']; if (typeof text !== 'string') return []; // Codex narrates in several messages; the last is its answer, and the // rest are progress. Keep the last as the turn, stream them all. this.text = text; return [{ type: 'text', text }]; } // Any other item is the agent using a tool. We surface the kind only, never // the arguments: the seam is the code and the outcome, not the tool calls. if (type === 'item.started' && typeof itemType === 'string') { return [{ type: 'action', label: itemType }]; } return []; } result() { // Tokens but no `costUsd`: Codex prices nothing, and a `$0` would read as free // rather than as "we don't know" (#540). return { text: this.text, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(this.usage ? { usage: this.usage } : {}), }; } } /** * Map Codex's `turn.completed` usage onto {@link DriverUsage}. The dialect is * OpenAI's Responses API shape, flattened: * `{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}`. * * Two things that shape the mapping, both verified against codex-cli 0.144.4: * * - `input_tokens` is the **total** input, cached included. Repeating one prompt * held it at 12218 while `cached_input_tokens` rose 9984 -> 12032; a non-cached * count would have fallen. So the uncached part is the difference, which is what * `inputTokens` means here. * - `reasoning_output_tokens` is a **subset** of `output_tokens` (as * `cached_input_tokens` is of `input_tokens`), so adding it would double-count. * * No price, and no cache-*write* count: OpenAI caches implicitly and bills no * separate write, so `cacheCreationTokens` is honestly 0 rather than a guess. */ export function parseCodexUsage(raw) { if (typeof raw !== 'object' || raw === null) return undefined; const usage = raw; const num = (key) => { const value = usage[key]; return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0; }; const input = num('input_tokens'); const cached = Math.min(num('cached_input_tokens'), input); return { inputTokens: input - cached, outputTokens: num('output_tokens'), cacheReadTokens: cached, cacheCreationTokens: 0, }; } //# sourceMappingURL=codex.js.map