denberek_obsidian-agent-fleet/src/adapters/codexAdapter.ts
Denis Berekchiyan 0b5ea13bd0 release: 0.16.0 — Windows stdin fix, robustness hardening, UX polish, internal restructuring
See CHANGELOG.md for details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:58:22 -07:00

415 lines
16 KiB
TypeScript

import type { AgentConfig, ExecutionToolUse, FleetSettings } from "../types";
import { splitLines } from "../utils/platform";
import { setupCodexPermissions } from "./codexPermissions";
import { tryParseJson, warnJsonParseFailure } from "./parseHelpers";
import type {
CliAdapter,
ExecBuildOptions,
ExecInvocation,
ExecParseResult,
PermissionSetupOptions,
PermissionSetupState,
} from "./types";
/**
* Codex CLI adapter.
*
* One-shot runs and chat turns both go through `codex exec --json`, which
* emits one JSONL event per line:
*
* {"type":"thread.started","thread_id":"<uuid>"}
* {"type":"turn.started"}
* {"type":"item.started"|"item.updated"|"item.completed","item":{...}}
* {"type":"turn.completed","usage":{input_tokens,cached_input_tokens,output_tokens,reasoning_output_tokens}}
* {"type":"turn.failed","error":{"message":"..."}}
* {"type":"error","message":"..."}
*
* Item types: agent_message {text}, reasoning {text}, command_execution
* {command, aggregated_output, exit_code, status}, file_change {changes:[{path,kind}]},
* mcp_tool_call {server, tool, status}, web_search {query}, todo_list, error {message}.
*
* Unlike Claude Code there is NO long-lived stdin/stdout chat process —
* conversation continuity comes from `codex exec resume <thread_id>`, which
* replays the session rollout stored under ~/.codex/sessions. ChatSession
* spawns one process per turn and stores the thread id in the same slot it
* uses for Claude session ids.
*
* Codex reports token usage but no dollar cost (there is no total_cost_usd
* equivalent), so costUsd is always undefined for Codex runs.
*/
/** True for model strings that clearly belong to the Claude family. Used to
* stop a plugin-wide Claude default from leaking into a Codex invocation. */
export function isClaudeShapedModel(value: string): boolean {
const v = value.trim();
return /^(opus|sonnet|haiku|opusplan)$/i.test(v) || /claude|anthropic/i.test(v);
}
/** Map the plugin's Claude-scale effort values onto Codex's
* model_reasoning_effort vocabulary (low|medium|high|xhigh). */
export function mapCodexEffort(effort: string): string {
const e = effort.trim().toLowerCase();
if (!e) return "";
if (e === "max") return "xhigh";
if (["low", "medium", "high", "xhigh"].includes(e)) return e;
return "";
}
/**
* Map the agent's permissionMode onto Codex sandbox flags. `codex exec`
* hardcodes approval policy to "never" (headless runs can't prompt), so the
* sandbox is the only enforcement axis:
*
* - Claude's bypassPermissions/dontAsk → --dangerously-bypass-approvals-and-sandbox
* - acceptEdits / default → --sandbox workspace-write ("default" can't ask
* headlessly; workspace-write is the closest safe behavior)
* - plan → --sandbox read-only
*
* Codex-native values (read-only / workspace-write / danger-full-access) are
* accepted as-is so agents created with the Codex form round-trip cleanly.
*/
export function codexSandboxArgs(permissionMode: string | undefined): string[] {
const mode = (permissionMode ?? "").trim();
switch (mode) {
case "plan":
case "read-only":
return ["--sandbox", "read-only"];
case "acceptEdits":
case "default":
case "workspace-write":
return ["--sandbox", "workspace-write"];
case "danger-full-access":
return ["--sandbox", "danger-full-access"];
default:
// bypassPermissions, dontAsk, "" and anything unknown — match the
// Claude adapter's "bypass when unset" default.
return ["--dangerously-bypass-approvals-and-sandbox"];
}
}
/**
* Build the `codex exec` argv. Pure. The prompt travels via stdin (positional
* `-`) to dodge argv length limits on big agent prompts.
*
* MCP servers are NOT handled here — they're projected uniformly across both
* adapters by `services/mcpProjection.ts` (which appends `-c mcp_servers.*`
* overrides to this argv at run time). This keeps a single source of truth for
* what servers a run sees, instead of a separate `codex mcp list` enumeration.
*/
export function buildCodexExecArgs(opts: ExecBuildOptions): { args: string[]; stdinPayload: string } {
const args = ["exec", "--json", "--skip-git-repo-check"];
const skipModel = opts.modelSource === "settings" && isClaudeShapedModel(opts.model);
if (opts.model && !skipModel) {
args.push("-m", opts.model);
}
const effort = mapCodexEffort(opts.effort);
if (effort) {
args.push("-c", `model_reasoning_effort="${effort}"`);
}
args.push(...codexSandboxArgs(opts.agent.permissionMode));
// `resume` is a subcommand of exec; parent-level options must precede it.
if (opts.resumeSessionId) {
args.push("resume", opts.resumeSessionId);
}
args.push("-"); // read the prompt from stdin
return { args, stdinPayload: opts.prompt };
}
// ═══════════════════════════════════════════════════════
// JSONL event helpers (shared by exec parsing and chat)
// ═══════════════════════════════════════════════════════
type JsonRecord = Record<string, unknown>;
/** Parse one JSONL line into an object event, or null. Non-JSON lines are
* expected noise (CLI banners, progress text) and are skipped silently —
* parseExecOutput warns separately when NO line parsed at all. */
function parseJsonLine(line: string): JsonRecord | null {
const trimmed = line.trim();
if (!trimmed) return null;
const parsed = tryParseJson(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as JsonRecord)
: null;
}
function itemOf(event: JsonRecord): JsonRecord | null {
const item = event.item;
return item && typeof item === "object" ? (item as JsonRecord) : null;
}
/** Describe a tool-ish thread item for display / run-log purposes.
* Returns null for non-tool items (agent_message, reasoning, todo_list). */
export function describeCodexToolItem(item: JsonRecord): ExecutionToolUse | null {
const type = typeof item.type === "string" ? item.type : "";
if (type === "command_execution") {
return {
tool: "shell",
command: typeof item.command === "string" ? item.command : undefined,
};
}
if (type === "mcp_tool_call") {
const server = typeof item.server === "string" ? item.server : "mcp";
const tool = typeof item.tool === "string" ? item.tool : "tool";
return { tool: `mcp__${server}__${tool}` };
}
if (type === "web_search") {
return {
tool: "web_search",
command: typeof item.query === "string" ? item.query : undefined,
};
}
if (type === "file_change") {
const changes = Array.isArray(item.changes) ? (item.changes as JsonRecord[]) : [];
const summary = changes
.map((c) => `${typeof c.kind === "string" ? c.kind : "update"} ${typeof c.path === "string" ? c.path : ""}`.trim())
.filter(Boolean)
.join(", ");
return { tool: "file_change", command: summary || undefined };
}
return null;
}
/** Token total from a turn.completed event: input + output. Cached input is
* a subset of input_tokens and reasoning a subset of output_tokens in the
* Codex usage shape, so summing all four would double-count. */
function tokensFromTurnCompleted(event: JsonRecord): number {
const usage = event.usage as JsonRecord | undefined;
if (!usage || typeof usage !== "object") return 0;
const input = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
const output = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
return input + output;
}
/** Context-usage proxy from a turn.completed event — the latest turn's input
* tokens approximate "how full is the thread right now". */
export function contextTokensFromTurnCompleted(event: JsonRecord): number {
const usage = event.usage as JsonRecord | undefined;
if (!usage || typeof usage !== "object") return 0;
return typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
}
// ═══════════════════════════════════════════════════════
// Chat-mode event translation
// ═══════════════════════════════════════════════════════
/** Per-turn parser state. agent_message items can arrive incrementally via
* item.updated — we track how much text per item id has already been
* emitted and only forward the unseen suffix. */
export interface CodexTurnParseState {
emittedTextLengths: Map<string, number>;
}
export function newCodexTurnParseState(): CodexTurnParseState {
return { emittedTextLengths: new Map() };
}
export type CodexChatSignal =
| { kind: "session"; sessionId: string }
| { kind: "text"; text: string }
| { kind: "tool"; toolName: string; command?: string }
| { kind: "usage"; contextTokens: number; totalTokens: number }
| { kind: "turn-failed"; message: string }
| { kind: "error"; message: string };
/**
* Translate one Codex JSONL event into zero or more chat signals.
* Stateless except for agent_message delta tracking via `state`.
*/
export function parseCodexChatEvent(event: JsonRecord, state: CodexTurnParseState): CodexChatSignal[] {
const type = typeof event.type === "string" ? event.type : "";
const signals: CodexChatSignal[] = [];
if (type === "thread.started" && typeof event.thread_id === "string" && event.thread_id) {
signals.push({ kind: "session", sessionId: event.thread_id });
return signals;
}
if (type === "turn.completed") {
signals.push({
kind: "usage",
contextTokens: contextTokensFromTurnCompleted(event),
totalTokens: tokensFromTurnCompleted(event),
});
return signals;
}
if (type === "turn.failed") {
const err = event.error as JsonRecord | undefined;
const message = err && typeof err.message === "string" ? err.message : "turn failed";
signals.push({ kind: "turn-failed", message });
return signals;
}
if (type === "error") {
const message = typeof event.message === "string" ? event.message : "stream error";
signals.push({ kind: "error", message });
return signals;
}
if (type === "item.started" || type === "item.updated" || type === "item.completed") {
const item = itemOf(event);
if (!item) return signals;
const itemType = typeof item.type === "string" ? item.type : "";
if (itemType === "agent_message") {
const text = typeof item.text === "string" ? item.text : "";
const id = typeof item.id === "string" ? item.id : "__single__";
const already = state.emittedTextLengths.get(id) ?? 0;
if (text.length > already) {
signals.push({ kind: "text", text: text.slice(already) });
state.emittedTextLengths.set(id, text.length);
}
return signals;
}
if (itemType === "error") {
const message = typeof item.message === "string" ? item.message : "item error";
signals.push({ kind: "error", message });
return signals;
}
// Tool-ish items: surface once, when they start.
if (type === "item.started") {
const tool = describeCodexToolItem(item);
if (tool) {
signals.push({ kind: "tool", toolName: tool.tool, command: tool.command });
}
}
return signals;
}
return signals;
}
// ═══════════════════════════════════════════════════════
// Adapter implementation (one-shot exec)
// ═══════════════════════════════════════════════════════
export const codexAdapter: CliAdapter = {
id: "codex",
label: "Codex",
cliPath(settings: FleetSettings): string {
return settings.codexCliPath;
},
buildExec(opts: ExecBuildOptions): Promise<ExecInvocation> {
const { args, stdinPayload } = buildCodexExecArgs(opts);
return Promise.resolve({ cliPath: opts.settings.codexCliPath, args, stdinPayload });
},
parseExecOutput(stdout: string, stderr: string, _streaming: boolean): ExecParseResult {
const agentMessages: string[] = [];
const toolsUsed: ExecutionToolUse[] = [];
const errors: string[] = [];
let totalTokens = 0;
let sessionId: string | undefined;
let lastTurnCompleted: JsonRecord | undefined;
let parsedAnyEvent = false;
for (const line of splitLines(stdout)) {
const event = parseJsonLine(line);
if (!event) continue;
parsedAnyEvent = true;
const type = typeof event.type === "string" ? event.type : "";
if (type === "thread.started" && typeof event.thread_id === "string") {
sessionId = event.thread_id;
} else if (type === "turn.completed") {
totalTokens += tokensFromTurnCompleted(event);
lastTurnCompleted = event;
} else if (type === "turn.failed") {
const err = event.error as JsonRecord | undefined;
errors.push(err && typeof err.message === "string" ? err.message : "turn failed");
} else if (type === "error") {
errors.push(typeof event.message === "string" ? event.message : "stream error");
} else if (type === "item.completed") {
const item = itemOf(event);
if (!item) continue;
const itemType = typeof item.type === "string" ? item.type : "";
if (itemType === "agent_message" && typeof item.text === "string" && item.text.trim()) {
agentMessages.push(item.text);
} else if (itemType === "error" && typeof item.message === "string") {
errors.push(item.message);
} else {
const tool = describeCodexToolItem(item);
if (tool) toolsUsed.push(tool);
}
}
}
// Non-JSON lines between events are expected; a non-empty stdout with NO
// parseable JSONL event at all means the CLI's output format drifted (or
// --json was ignored) — surface that instead of a silent "(no output)".
if (!parsedAnyEvent && stdout.trim()) {
warnJsonParseFailure("Codex exec output contained no parseable JSONL event", stdout.trim());
}
let outputText = agentMessages.join("\n\n").trim();
if (!outputText) outputText = errors.join("\n").trim();
if (!outputText) outputText = stderr.trim() || "(no output)";
const lastMessage = agentMessages[agentMessages.length - 1];
return {
outputText,
finalResult: lastMessage?.trim() ? lastMessage : undefined,
tokensUsed: totalTokens > 0 ? totalTokens : undefined,
// Codex emits no dollar-cost field — leave costUsd unset.
costUsd: undefined,
toolsUsed,
concreteModel: undefined,
rawJson: lastTurnCompleted,
sessionId,
};
},
extractStreamChunk(line: string): string | null {
const event = parseJsonLine(line);
if (!event) return null;
const type = typeof event.type === "string" ? event.type : "";
if (type === "item.completed") {
const item = itemOf(event);
if (item && item.type === "agent_message" && typeof item.text === "string" && item.text.trim()) {
return item.text;
}
return null;
}
if (type === "item.started") {
const item = itemOf(event);
if (!item) return null;
const tool = describeCodexToolItem(item);
if (tool) {
const cmd = tool.command ? `: ${tool.command.slice(0, 200)}` : "";
return `\n▸ ${tool.tool}${cmd}\n`;
}
return null;
}
if (type === "turn.failed") {
const err = event.error as JsonRecord | undefined;
const message = err && typeof err.message === "string" ? err.message : "turn failed";
return `\n✖ ${message}\n`;
}
return null;
},
setupPermissions(
_cwd: string,
agent: AgentConfig,
settings: FleetSettings,
_opts?: PermissionSetupOptions,
): Promise<PermissionSetupState | null> {
// Sandbox mode + MCP scoping travel via CLI flags (see buildExec). On top
// of that, an agent's permissionRules.allow/deny are translated into
// Codex execpolicy rules and injected via a per-agent CODEX_HOME overlay.
// Every failure path is soft — the run falls back to sandbox-only. The
// remember MCP tool (allowRememberTool) is Claude-only, so _opts is ignored.
return setupCodexPermissions(agent, settings);
},
};