import type { TranscriptMessage, UsageStats } from "./types.ts"; import type { ResolvedLaunchSpec } from "../launch/launch-spec.ts"; import { realpathSync } from "node:fs"; import { shellEscape } from "../mux/shell.ts"; const CODEX_REASONING_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]); export function codexReasoningEffort(thinking: string | undefined): string | undefined { if (!thinking) return undefined; const v = thinking.toLowerCase().trim(); return CODEX_REASONING_EFFORTS.has(v) ? v : undefined; // "off"/unknown → omitted } // Escapes a string for embedding inside a TOML basic (double-quoted) string. // POSIX paths can legally contain newlines and other control characters, so // escape the full TOML-forbidden control range instead of only quotes/slashes. function tomlBasicStringEscape(s: string): string { return s.replace(/["\\\u0000-\u001F\u007F-\u009F]/g, (ch) => { switch (ch) { case "\\": return "\\\\"; case '"': return '\\"'; case "\b": return "\\b"; case "\t": return "\\t"; case "\n": return "\\n"; case "\f": return "\\f"; case "\r": return "\\r"; default: return `\\u${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`; } }); } // Per-launch override that marks the active workdir as a trusted Codex project. // Codex otherwise shows an interactive "Is this a project you trust?" prompt for // any directory not already recorded in ~/.codex/config.toml, which blocks an // unattended subagent. Emitting this as a `-c` override applies the trust for // THIS launch only and does NOT cause pi-mux-subagents to write the user's // persistent config (Codex itself may still record its own project trust // metadata as a side effect). Codex canonicalizes project paths before checking // trust (for example macOS `/var` resolves to `/private/var`), so canonicalize // the launch cwd when possible; otherwise fall back to the caller-provided path // for not-yet-existing test fixtures. The cwd is embedded as a TOML quoted key // inside an inline `projects={...}` table so paths containing quotes, // backslashes, or control characters survive intact. // // Use the inline-table override rather than a dotted path such as // `projects."/path".trust_level="trusted"`: Codex CLI 0.138 accepts the // dotted token syntactically but the interactive pane trust screen does not // observe it, while the inline `projects={"/path"={trust_level="trusted"}}` // form bypasses the trust screen for the current launch. // // Returns RAW `-c key=value` tokens (no shell escaping); pane callers // shell-escape each token. export function buildCodexProjectTrustArgs(cwd: string): string[] { let trustPath = cwd; try { trustPath = realpathSync(cwd); } catch {} return ["-c", `projects={"${tomlBasicStringEscape(trustPath)}"={trust_level="trusted"}}`]; } export function codexSandboxArgs(policy: "guarded" | "unrestricted", transport: "headless" | "pane"): string[] { if (policy === "unrestricted") return ["--dangerously-bypass-approvals-and-sandbox"]; // guarded: return transport === "headless" ? ["--sandbox", "workspace-write", "-c", 'approval_policy="never"'] // non-interactive: cannot answer prompts : ["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]; // interactive: a human can answer } export function buildCodexExecArgs( spec: ResolvedLaunchSpec, opts: { outputLastMessageFile: string; cwd: string }, ): string[] { const args: string[] = ["exec"]; // `resume` is an `exec` subcommand. Exec-level options (`--cd`, `--sandbox`, // `--json`, `--output-last-message`, `--model`, `-c`, ...) must be emitted // BEFORE the `resume` token — the real Codex CLI rejects them when they // follow `resume ` (e.g. "unexpected argument '--cd' found"). args.push( "--json", "--output-last-message", opts.outputLastMessageFile, "--cd", opts.cwd, "--skip-git-repo-check", ); // Mark the workdir trusted for this launch so headless exec never stalls on the // interactive trust prompt (and without mutating ~/.codex/config.toml). args.push(...buildCodexProjectTrustArgs(opts.cwd)); if (spec.codexModelArg) args.push("--model", spec.codexModelArg); const effort = codexReasoningEffort(spec.effectiveThinking); if (effort) args.push("-c", `model_reasoning_effort="${effort}"`); args.push(...codexSandboxArgs(spec.effectiveExecutionPolicy, "headless")); // Resume the prior session after the exec-level options. The trailing `-` // is the PROMPT positional for `codex exec resume`, telling Codex to read the // follow-up prompt from stdin (the runner already writes spec.fullTask there). if (spec.resumeSessionId) args.push("resume", spec.resumeSessionId, "-"); return args; } export function buildCodexPaneCmdParts(input: { model?: string; effectiveThinking?: string; executionPolicy: "guarded" | "unrestricted"; mcpOverrideArgs: string[]; task: string; cwd?: string; }): string[] { const parts: string[] = ["codex"]; parts.push(...codexSandboxArgs(input.executionPolicy, "pane").map(shellEscape)); // Mark the workdir trusted for this launch so the pane Codex never stalls on // the interactive trust prompt (and without mutating ~/.codex/config.toml). if (input.cwd) parts.push(...buildCodexProjectTrustArgs(input.cwd).map(shellEscape)); if (input.model) parts.push("--model", shellEscape(input.model)); const effort = codexReasoningEffort(input.effectiveThinking); if (effort) parts.push("-c", shellEscape(`model_reasoning_effort="${effort}"`)); parts.push(...input.mcpOverrideArgs.map(shellEscape)); // already raw `-c key=value` tokens if (input.task !== "") { parts.push("--"); parts.push(shellEscape(input.task)); } return parts; } export function parseCodexEvent(event: Record): TranscriptMessage[] | undefined { if (event.type !== "item.completed") return undefined; const item = event.item as Record | undefined; if (!item) return undefined; if (item.type === "agent_message" && typeof item.text === "string") return [{ role: "assistant", content: [{ type: "text", text: item.text }] }]; if (item.type === "reasoning" && typeof item.text === "string") return [{ role: "assistant", content: [{ type: "thinking", thinking: item.text }] }]; if (item.type === "command_execution" || item.type === "tool_call" || item.type === "mcp_tool_call") return [{ role: "assistant", content: [{ type: "toolCall", id: String(item.id ?? ""), name: String((item.name ?? item.command ?? item.type) as string).toLowerCase(), arguments: (item as any).arguments ?? (item as any).command ?? {} }] }]; return undefined; // unknown item types are skipped, not fabricated } export function extractCodexSessionId(event: Record): string | undefined { // Defensive: the id field name is version-sensitive. Check the known carriers. if (event.type === "thread.started") { const e: any = event; const id = e.thread_id ?? e.threadId ?? e.session_id ?? e.sessionId ?? e.thread?.id; return typeof id === "string" && id.length > 0 ? id : undefined; } return undefined; } // Codex reports failures as JSONL `error` / `turn.failed` events on stdout while // often writing only benign informational text (e.g. "Reading prompt from // stdin...") to stderr. Extract the structured failure message so callers can // prefer it over boilerplate stderr. The carrier field names are version- // sensitive, so check the known shapes defensively and return undefined for // anything we cannot read as a non-empty string. export function parseCodexError(event: Record): string | undefined { if (event.type !== "error" && event.type !== "turn.failed") return undefined; const e: any = event; const candidates = [e.error?.message, e.message, e.error, e.reason]; for (const c of candidates) { if (typeof c === "string" && c.trim().length > 0) return c.trim(); } return undefined; } export interface CodexUsageDelta { usage: UsageStats; } export function parseCodexUsage(event: Record): UsageStats | undefined { if (event.type !== "turn.completed") return undefined; const u = ((event as any).usage ?? {}) as Record; const input = u.input_tokens ?? u.input ?? 0; const output = u.output_tokens ?? u.output ?? 0; const cacheRead = u.cached_input_tokens ?? u.cache_read_input_tokens ?? 0; return { input, output, cacheRead, cacheWrite: 0, cost: 0, contextTokens: input + output + cacheRead, turns: 0 }; }