import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentHistoryEntry } from "./agent-history.js"; import { compactAgentHistory } from "./agent-history.js"; import { type CliProcessResult, type CliSpawnOptions, isCliProcessAbortedError, runCliProcess } from "./cli-harness.js"; import { classifyProviderLimit, WorkflowError, WorkflowErrorCode } from "./errors.js"; import { type AgentExecutor, EXECUTOR_CAPABILITY_DESCRIPTORS, type ExecutorRunRequest, type ExecutorRunResult, type ExecutorUsage, } from "./executor.js"; import { assertTopLevelObjectSchema, resolveStructuredOutput } from "./structured-output.js"; export interface CodexHarnessOptions extends Omit { command?: string; /** Override temp-file parent directory; useful for deterministic tests. */ tempDirectory?: string; } interface FinalMessage { text?: string; value?: unknown; } /** Adapter for Codex CLI 0.145.0's `codex exec --json` protocol. */ export class CodexHarness implements AgentExecutor { readonly executor = "codex" as const; readonly descriptor = EXECUTOR_CAPABILITY_DESCRIPTORS.codex; private readonly options: CodexHarnessOptions; constructor(options: CodexHarnessOptions = {}) { this.options = options; } async run(request: ExecutorRunRequest): Promise> { const cwd = request.cwd ?? process.cwd(); const command = this.options.command ?? "codex"; let tempDirectory: string | undefined; let schemaPath: string | undefined; try { if (request.schema) { assertTopLevelObjectSchema(request.schema, "Codex structured output"); tempDirectory = await mkdtemp(join(this.options.tempDirectory ?? tmpdir(), "pi-dynamic-codex-")); schemaPath = join(tempDirectory, "output-schema.json"); await writeFile(schemaPath, JSON.stringify(request.schema), "utf8"); } const args = ["exec", "--json", "--ephemeral", "--cd", cwd, "--sandbox", "workspace-write"]; if (request.model) args.push("--model", request.model); if (schemaPath) args.push("--output-schema", schemaPath); args.push("-"); let result: CliProcessResult; try { result = await runCliProcess(command, args, { cwd, prompt: request.prompt, signal: request.signal, env: { ...process.env, ...(request.env ?? {}), ...(this.options.env ?? {}) }, stderrLimit: this.options.stderrLimit, terminateGraceMs: this.options.terminateGraceMs, killGraceMs: this.options.killGraceMs, spawn: this.options.spawn, }); } catch (error) { if (isCliProcessAbortedError(error)) { reportUsage(request, usageFromEvents(error.partialResult.events)); } throw error; } const events = result.events; const usage = usageFromEvents(events); reportUsage(request, usage); if (result.jsonlErrors.length > 0) { throw protocolError("Codex returned malformed JSONL", { errors: result.jsonlErrors, stderr: result.stderr }); } if (result.exitCode !== 0) { throw classifyCliFailure(command, result.exitCode, result.stderr, events); } const final = findFinalMessage(events); if (!final) { const limit = classifyProviderLimit( `${result.stderr}\n${events.map((event) => JSON.stringify(event)).join("\n")}`, ); if (limit.matched) { throw new WorkflowError( result.stderr || "Codex provider usage limit reached", WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, resetHint: limit.resetHint, }, ); } throw protocolError("Codex completed without a final agent message", { events, stderr: result.stderr, }); } const text = final.text; const value = request.schema ? resolveStructuredOutput(final.value ?? text, request.schema, "Codex structured output") : ((final.value ?? text) as T); return { result: value, text, usage, history: historyFromEvents(events), events, }; } finally { if (tempDirectory) await rm(tempDirectory, { recursive: true, force: true }).catch(() => {}); } } } export function createCodexHarness(options: CodexHarnessOptions = {}): CodexHarness { return new CodexHarness(options); } function findFinalMessage(events: readonly unknown[]): FinalMessage | undefined { let found: FinalMessage | undefined; for (const raw of events) { const event = asRecord(raw); if (!event) continue; const item = asRecord(event.item); const type = stringValue(event.type); if (type === "item.completed" && item && stringValue(item.type) === "agent_message") { found = messageFromRecord(item) ?? found; continue; } if (type === "agent_message") { found = messageFromRecord(event) ?? found; continue; } if (type === "message" && (event.role === "assistant" || event.role === undefined)) { found = messageFromRecord(event) ?? found; continue; } // Some compatible Codex builds emit a terminal result event rather than an // item.completed agent_message. Ignore turn.completed, whose `usage` is not text. if (type === "result" || type === "completed") { const result = event.result ?? event.output ?? event.value; if (result !== undefined) found = valueToMessage(result); } } return found; } function messageFromRecord(record: Record): FinalMessage | undefined { const direct = record.text ?? record.output_text ?? record.result; if (typeof direct === "string") return { text: direct }; if (direct !== undefined && typeof direct === "object") return { value: direct, text: JSON.stringify(direct) }; const content = textFromContent(record.content); if (content) return { text: content }; return undefined; } function valueToMessage(value: unknown): FinalMessage { return typeof value === "string" ? { text: value } : { value, text: JSON.stringify(value) }; } function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => { const item = asRecord(part); if (!item) return ""; return typeof item.text === "string" ? item.text : typeof item.content === "string" ? item.content : ""; }) .join(""); } function usageFromEvents(events: readonly unknown[]): ExecutorUsage | undefined { let usage: Record | undefined; for (const raw of events) { const event = asRecord(raw); const candidate = asRecord(event?.usage) ?? asRecord(asRecord(event?.turn)?.usage); if (candidate) usage = candidate; } if (!usage) return undefined; const input = numberValue(usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens); const output = numberValue(usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens); const cacheRead = numberValue(usage.cached_input_tokens ?? usage.cache_read_input_tokens ?? usage.cacheRead); const cacheWrite = numberValue(usage.cache_write_input_tokens ?? usage.cacheWrite); const total = numberValue(usage.total_tokens) || input + output; return { input, output, total, cost: numberValue(usage.cost ?? usage.total_cost_usd), cacheRead, cacheWrite }; } function reportUsage(request: ExecutorRunRequest, usage: ExecutorUsage | undefined): void { if (!usage || !request.onUsage) return; try { request.onUsage(usage); } catch { // Usage is telemetry only; never let it mask the execution result/error. } } function historyFromEvents(events: readonly unknown[]): AgentHistoryEntry[] { const messages: unknown[] = []; for (const raw of events) { const event = asRecord(raw); if (!event) continue; const item = asRecord(event.item); const itemType = stringValue(item?.type); if (itemType === "agent_message") { messages.push({ role: "assistant", content: [{ type: "text", text: textFromContent(item?.text ?? item?.content) }], }); } else if (itemType === "command_execution" && stringValue(event.type) === "item.completed") { const command = stringValue(item?.command) ?? ""; const output = stringValue(item?.aggregated_output ?? item?.output) ?? ""; const exitCode = numberValue(item?.exit_code ?? item?.exitCode); messages.push({ role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command } }], }); messages.push({ role: "toolResult", toolName: "bash", content: [{ type: "text", text: output || "(no command output)" }], isError: exitCode !== 0, }); } else if (stringValue(event.type) === "message") { messages.push({ role: "assistant", content: event.content }); } } return compactAgentHistory(messages); } function classifyCliFailure( command: string, exitCode: number | null, stderr: string, events: readonly unknown[], ): WorkflowError { const text = `${stderr}\n${events.map((event) => JSON.stringify(event)).join("\n")}`; const limit = classifyProviderLimit(text); if (limit.matched) { return new WorkflowError(stderr || "Codex provider usage limit reached", WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, resetHint: limit.resetHint, }); } return new WorkflowError( `${command} exited with code ${exitCode ?? "unknown"}${stderr ? `: ${stderr}` : ""}`, WorkflowErrorCode.EXECUTOR_PROTOCOL_ERROR, { recoverable: false, details: { exitCode, stderr }, }, ); } function protocolError(message: string, details: unknown): WorkflowError { return new WorkflowError(message, WorkflowErrorCode.EXECUTOR_PROTOCOL_ERROR, { recoverable: false, details }); } function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" ? (value as Record) : undefined; } function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function numberValue(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; }