Files
paseo/packages/server/src/server/agent/providers/codex-app-server-agent.ts
T
Mohamed BoudraandGitHub db6520bf36 Show live task progress while agents work (#3227)
* feat(agents): surface live task progress

Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.

* fix(agents): admit tasks through stream sequencing

* test(agents): expect normalized task status
2026-08-11 18:23:30 +02:00

6910 lines
218 KiB
TypeScript

import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentResumeSessionOptions,
type AgentMode,
type AgentModelDefinition,
type McpServerConfig,
type AgentPersistenceHandle,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentProviderNotice,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
type AgentSession,
type AgentSessionConfig,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type ToolCallTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ProviderCatalog,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import type { Logger } from "pino";
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import {
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
splitCodexMcpToolResultImages,
} from "./codex/tool-call-mapper.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
createProviderEnvSpec,
resolveProviderLaunch,
type ProviderRuntimeSettings,
type ResolvedProviderLaunch,
} from "../provider-launch-config.js";
import {
findExecutable,
probeExecutable,
} from "../../../executable-resolution/executable-resolution.js";
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import {
CodexAppServerClient,
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
type CodexThreadForkParams,
type CodexThreadForkResponse,
type CodexThreadRollbackParams,
type CodexThreadRollbackResponse,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
resolveBinaryVersion,
} from "./diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import {
MODE_APPLIES_NEXT_TURN_NOTICE,
THINKING_APPLIES_NEXT_TURN_NOTICE,
} from "../provider-notices.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import {
applyCodexToolPolicy,
CodexProviderOptionsSchema,
type CodexProviderOptions,
} from "./codex/options.js";
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Child process did not expose stdio pipes");
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function isArchivedCodexThreadResumeError(error: unknown, threadId: string): boolean {
if (!(error instanceof Error)) return false;
const expectedMessage =
`session ${threadId} is archived. ` +
`Run \`codex unarchive ${threadId}\` to unarchive it first.`;
return error.message === expectedMessage;
}
function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(`no archived rollout found for thread id ${threadId}`);
}
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const CODEX_PROVIDER = "codex" as const;
// Codex treats most app-server client names as the model-request originator.
// This reserved Codex name is non-originating, so requests keep Codex's default
// CLI identity instead of showing up as Paseo in provider usage logs.
const CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO = {
name: "codex_app_server_daemon",
title: "Codex App Server Daemon",
version: "0.0.0",
} as const;
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
const MAX_PENDING_SUB_AGENT_THREADS = 32;
const MAX_PENDING_SUB_AGENT_NOTIFICATIONS_PER_THREAD = 128;
// COMPAT(codexLegacyCollabAgentToolCall): Codex <0.143 emits this shape. Added in
// Paseo v0.1.105; remove after 2027-01-09 once the supported Codex floor is >=0.143.
const CODEX_TOOL_THREAD_ITEM_TYPES = new Set([
"commandExecution",
"fileChange",
"mcpToolCall",
"webSearch",
"collabAgentToolCall",
"subAgentActivity",
]);
const CODEX_CONTEXT_COMPACTION_TYPE = "contextCompaction";
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
// Codex's experimental `goals` feature ships in 0.128.0+. Older binaries reject
// `--enable goals` at launch, so we gate by version and silently skip the flag
// (and the /goal slash command) when the binary is too old.
const CODEX_GOALS_MIN_VERSION: readonly [number, number, number] = [0, 128, 0];
const CODEX_AUTO_REVIEW_MIN_VERSION: readonly [number, number, number] = [0, 115, 0];
function parseCodexVersion(versionOutput: string): [number, number, number] | null {
const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function codexVersionAtLeast(
versionOutput: string,
min: readonly [number, number, number],
): boolean {
const parsed = parseCodexVersion(versionOutput);
if (!parsed) return false;
for (let i = 0; i < 3; i += 1) {
if (parsed[i] > min[i]) return true;
if (parsed[i] < min[i]) return false;
}
return true;
}
type GoalSubcommand =
| { kind: "set"; objective: string }
| { kind: "pause" }
| { kind: "resume" }
| { kind: "clear" }
| { kind: "usage" };
function parseGoalSubcommand(args: string | undefined): GoalSubcommand {
const trimmed = (args ?? "").trim();
if (!trimmed) return { kind: "usage" };
const lower = trimmed.toLowerCase();
if (lower === "pause") return { kind: "pause" };
if (lower === "resume") return { kind: "resume" };
if (lower === "clear") return { kind: "clear" };
return { kind: "set", objective: trimmed };
}
function formatOutOfBandStatusMessage(text: string): string {
return `${text.replace(/\n+$/u, "")}\n\n`;
}
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsSessionListing: true,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const CODEX_MODES: AgentMode[] = [
{
id: "auto",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
},
{
id: "auto-review",
label: "Auto-review",
description:
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent.",
},
{
id: "full-access",
label: "Full Access",
description: "Edit files, run commands, and access the network without additional prompts.",
},
];
const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown): Promise<unknown>;
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface CodexAppServerAgentDeps {
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
label: string;
extends: string;
};
customCodexConfig?: Record<string, unknown> | null;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
getTraceContext: () => CodexAppServerTraceContext,
) => CodexAppServerClientLike;
}
interface CodexModePreset {
approvalPolicy: string;
sandbox: string;
approvalsReviewer?: "auto_review";
}
const MODE_PRESETS: Record<string, CodexModePreset> = {
"read-only": {
approvalPolicy: "on-request",
sandbox: "read-only",
},
auto: {
approvalPolicy: "on-request",
sandbox: "workspace-write",
},
"auto-review": {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
},
"full-access": {
approvalPolicy: "never",
sandbox: "danger-full-access",
},
};
function isAutoReviewReviewer(value: string | undefined): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
function applyApprovalsReviewerParam(
params: Record<string, unknown>,
preset: CodexModePreset,
): void {
if (preset.approvalsReviewer) {
params.approvalsReviewer = preset.approvalsReviewer;
}
}
function shouldPromoteThreadResponseToAutoReview(params: {
approvalsReviewer: string | undefined;
approvalPolicy: string;
sandbox: string;
}): boolean {
return (
isAutoReviewReviewer(params.approvalsReviewer) &&
params.approvalPolicy === "on-request" &&
params.sandbox === "workspace-write"
);
}
function validateCodexMode(modeId: string): void {
if (!(modeId in MODE_PRESETS)) {
const validModes = Object.keys(MODE_PRESETS).join(", ");
throw new Error(`Invalid Codex mode "${modeId}". Valid modes are: ${validModes}`);
}
}
function normalizeCodexThinkingOptionId(
thinkingOptionId: string | null | undefined,
): string | undefined {
if (typeof thinkingOptionId !== "string") {
return undefined;
}
const normalized = thinkingOptionId.trim();
if (!normalized || normalized === "default") {
return undefined;
}
return normalized;
}
function normalizeCodexModelId(modelId: string | null | undefined): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = modelId.trim();
if (!normalized) {
return undefined;
}
return normalized;
}
function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, "GPT");
}
function isSchemaRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
const type = schema.type;
return (
isSchemaRecord(schema.properties) ||
type === "object" ||
(Array.isArray(type) && type.includes("object"))
);
}
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) =>
normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`),
);
}
if (!isSchemaRecord(schema)) {
return schema;
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`);
}
if (!isObjectSchemaNode(normalized)) {
return normalized;
}
if (normalized.additionalProperties === undefined) {
normalized.additionalProperties = false;
} else if (normalized.additionalProperties !== false) {
throw new Error(
`Codex structured outputs require ${schemaPath} to set additionalProperties to false for object schemas.`,
);
}
const properties = isSchemaRecord(normalized.properties) ? normalized.properties : null;
if (!properties) {
return normalized;
}
const propertyKeys = Object.keys(properties);
const existingRequired = Array.isArray(normalized.required)
? normalized.required.filter((entry): entry is string => typeof entry === "string")
: [];
normalized.required = Array.from(new Set([...existingRequired, ...propertyKeys]));
return normalized;
}
export function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
if (!isSchemaRecord(schema)) {
throw new Error("Codex structured outputs require a JSON object schema.");
}
const normalized = normalizeCodexOutputSchemaNode(schema, "$");
if (!isSchemaRecord(normalized) || !isObjectSchemaNode(normalized)) {
throw new Error("Codex structured outputs require a root object schema.");
}
return normalized;
}
interface CodexConfiguredDefaults {
model?: string;
thinkingOptionId?: string;
}
interface PersistedTimelineEntry {
item: AgentTimelineItem;
timestamp?: string;
}
interface PersistedSubAgentRoute {
childThreadId: string;
toolCall: ToolCallTimelineItem;
}
interface CodexThreadHistoryProjection {
timeline: PersistedTimelineEntry[];
subAgentRoutes: PersistedSubAgentRoute[];
}
function mergeCodexConfiguredDefaults(
primary: CodexConfiguredDefaults,
fallback: CodexConfiguredDefaults,
): CodexConfiguredDefaults {
return {
model: primary.model ?? fallback.model,
thinkingOptionId: primary.thinkingOptionId ?? fallback.thinkingOptionId,
};
}
function codexMicrosoftStorePackageRoot(): string | null {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
return null;
}
return path.join(localAppData, "Packages");
}
export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
if (process.platform !== "win32") {
return null;
}
const packageRoot = codexMicrosoftStorePackageRoot();
if (!packageRoot) {
return null;
}
let entries: Dirent[];
try {
entries = await fs.readdir(packageRoot, { withFileTypes: true });
} catch {
return null;
}
const codexPackages = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
.map((entry) => entry.name)
.sort();
for (const packageName of codexPackages) {
const candidate = path.join(
packageRoot,
packageName,
"LocalCache",
"Local",
"OpenAI",
"Codex",
"bin",
"codex.exe",
);
if (await probeExecutable(candidate)) {
return candidate;
}
}
return null;
}
export async function findDefaultCodexBinary(): Promise<string | null> {
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
}
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string;
args: string[];
}> {
const launch = await resolveCodexLaunch(runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
if (!availability.available) {
throw new Error(
"Codex binary not found. Install the Codex CLI (https://github.com/openai/codex) and ensure it is available in your shell PATH.",
);
}
return {
command:
launch.source === "override" ? launch.command : (availability.resolvedPath ?? launch.command),
args: launch.args,
};
}
async function resolveCodexLaunch(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<ResolvedProviderLaunch> {
return resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
defaultBinary: {
command: "codex",
resolvePath: findDefaultCodexBinary,
},
});
}
async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) {
return checkProviderLaunchAvailable(launch, {
command: "codex",
resolvePath: findDefaultCodexBinary,
});
}
function resolveCodexHomeDir(): string {
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
}
function decodeEscapedChar(next: string): string {
if (next === "n") return "\n";
if (next === "t") return "\t";
return next;
}
function resolvePermissionDecision(
response: AgentPermissionResponse,
): "accept" | "cancel" | "decline" {
if (response.behavior === "allow") return "accept";
if (response.interrupt) return "cancel";
return "decline";
}
function firstPositiveFiniteNumber(primary: unknown, secondary: unknown): number | undefined {
if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) {
return primary;
}
if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) {
return secondary;
}
return undefined;
}
function tokenizeCommandArgs(args: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
for (let i = 0; i < args.length; i += 1) {
const ch = args[i];
if (quote) {
if (ch === quote) {
quote = null;
continue;
}
if (ch === "\\" && i + 1 < args.length) {
const next = args[i + 1];
if (next === quote || next === "\\" || next === "n" || next === "t") {
i += 1;
current += decodeEscapedChar(next);
continue;
}
}
current += ch;
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function parseFrontMatter(markdown: string): {
frontMatter: Record<string, string>;
body: string;
} {
const lines = markdown.split("\n");
if (lines[0]?.trim() !== "---") {
return { frontMatter: {}, body: markdown };
}
let end = -1;
for (let i = 1; i < lines.length; i += 1) {
if (lines[i]?.trim() === "---") {
end = i;
break;
}
}
if (end === -1) {
return { frontMatter: {}, body: markdown };
}
const metaLines = lines.slice(1, end);
const body = lines.slice(end + 1).join("\n");
const frontMatter: Record<string, string> = {};
for (const line of metaLines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const idx = trimmed.indexOf(":");
if (idx <= 0) {
continue;
}
const key = trimmed.slice(0, idx).trim();
let value = trimmed.slice(idx + 1).trim();
value = value.replace(/^['"]/, "").replace(/['"]$/, "");
if (key && value) {
frontMatter[key] = value;
}
}
return { frontMatter, body };
}
async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
const codexHome = resolveCodexHomeDir();
const promptsDir = path.join(codexHome, "prompts");
let entries: Dirent[];
try {
entries = await fs.readdir(promptsDir, { withFileTypes: true });
} catch {
return [];
}
const mdEntries = entries.filter(
(entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name.slice(0, -".md".length),
);
const parsedCommands = await Promise.all(
mdEntries.map(async (entry): Promise<AgentSlashCommand | null> => {
const name = entry.name.slice(0, -".md".length);
const fullPath = path.join(promptsDir, entry.name);
let content: string;
try {
content = await fs.readFile(fullPath, "utf8");
} catch {
return null;
}
const parsed = parseFrontMatter(content);
const description = parsed.frontMatter["description"] ?? "Custom prompt";
const argumentHint =
parsed.frontMatter["argument-hint"] ?? parsed.frontMatter["argument_hint"] ?? "";
return {
name: `prompts:${name}`,
description,
argumentHint,
kind: "command",
};
}),
);
const commands: AgentSlashCommand[] = parsedCommands.filter(
(cmd): cmd is AgentSlashCommand => cmd !== null,
);
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listCodexSkills(
cwd: string,
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
): Promise<AgentSlashCommand[]> {
const candidates: string[] = [];
candidates.push(path.join(cwd, ".codex", "skills"));
const repoRoot = workspaceGitService
? await workspaceGitService.resolveRepoRoot(cwd).catch(() => null)
: null;
if (repoRoot) {
candidates.push(path.join(path.dirname(cwd), ".codex", "skills"));
candidates.push(path.join(repoRoot, ".codex", "skills"));
}
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
const candidateReads = await Promise.all(
candidates.map(async (dir) => {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [] as string[];
}
const dirEntries = entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink());
const skillContents = await Promise.all(
dirEntries.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillPath = path.join(skillDir, "SKILL.md");
try {
return await fs.readFile(skillPath, "utf8");
} catch {
return null;
}
}),
);
return skillContents.filter((content): content is string => content !== null);
}),
);
const commandsByName = new Map<string, AgentSlashCommand>();
for (const skillContents of candidateReads) {
for (const content of skillContents) {
const { frontMatter } = parseFrontMatter(content);
const name = frontMatter["name"];
const description = frontMatter["description"];
if (!name || !description) {
continue;
}
if (!commandsByName.has(name)) {
commandsByName.set(name, {
name,
description,
argumentHint: "",
kind: "skill",
});
}
}
}
return Array.from(commandsByName.values()).sort((a, b) => a.name.localeCompare(b.name));
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function expandCodexCustomPrompt(template: string, args: string | undefined): string {
const trimmedArgs = args ? args.trim() : "";
const tokens = trimmedArgs ? tokenizeCommandArgs(trimmedArgs) : [];
const named: Record<string, string> = {};
const positional: string[] = [];
for (const token of tokens) {
const idx = token.indexOf("=");
if (idx > 0) {
const key = token.slice(0, idx);
const value = token.slice(idx + 1);
if (key) {
named[key] = value;
continue;
}
}
positional.push(token);
}
const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__";
let out = template.split("$$").join(dollarPlaceholder);
out = out.split("$ARGUMENTS").join(trimmedArgs);
for (let i = 1; i <= 9; i += 1) {
const value = positional[i - 1] ?? "";
out = out.split(`$${i}`).join(value);
}
const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length);
for (const key of namedKeys) {
const value = named[key] ?? "";
const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g");
out = out.replace(re, value);
}
out = out.split(dollarPlaceholder).join("$");
return out;
}
interface CodexMcpServerConfig {
url?: string;
http_headers?: Record<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
tool_timeout_sec?: number;
}
function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig {
switch (config.type) {
case "stdio":
return {
command: config.command,
args: config.args,
env: config.env,
};
case "http":
return {
url: config.url,
http_headers: config.headers,
};
case "sse":
return {
url: config.url,
http_headers: config.headers,
};
default: {
const _exhaustive = config as { type: never };
throw new Error(`Unsupported MCP config type: ${String(_exhaustive.type)}`);
}
}
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
// Codex app-server API response types
interface CodexReasoningEffortEntry {
reasoningEffort?: string;
description?: string;
}
interface CodexModel {
id: string;
displayName?: string;
description?: string;
isDefault?: boolean;
model?: string;
defaultReasoningEffort?: string;
supportedReasoningEfforts?: CodexReasoningEffortEntry[];
}
const CodexModelListResponseSchema = z.object({
data: z
.array(
z.object({
id: z.string(),
displayName: z.string().optional(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
model: z.string().optional(),
defaultReasoningEffort: z.string().optional(),
supportedReasoningEfforts: z
.array(
z.object({
reasoningEffort: z.string().optional(),
description: z.string().optional(),
}),
)
.optional(),
}),
)
.optional(),
});
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
): Array<Record<string, unknown>> {
if (!cwd) {
return threads;
}
// thread/list rows carry an optional cwd. The descriptor builder later
// falls back to process.cwd() if the field is missing, so we only match
// here when the row genuinely carries a cwd string — otherwise threads
// with no cwd would falsely match the daemon's own cwd.
const matchesCwd = createPathEquivalenceMatcher(cwd);
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd));
}
export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
const usage = toObjectRecord(tokenUsage);
if (!usage) return undefined;
const last = toObjectRecord(usage.last);
const contextWindowMaxTokens = firstPositiveFiniteNumber(
usage.model_context_window,
usage.modelContextWindow,
);
const contextWindowUsedTokens = firstPositiveFiniteNumber(last?.total_tokens, last?.totalTokens);
return {
inputTokens: typeof last?.inputTokens === "number" ? last.inputTokens : undefined,
cachedInputTokens:
typeof last?.cachedInputTokens === "number" ? last.cachedInputTokens : undefined,
outputTokens: typeof last?.outputTokens === "number" ? last.outputTokens : undefined,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
};
}
function extractUserText(content: unknown): string | null {
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const item of content) {
const record = toObjectRecord(item);
if (!record) {
continue;
}
if (record.type === "text" && typeof record.text === "string") {
parts.push(record.text);
}
}
return parts.length > 0 ? parts.join("\n") : null;
}
function normalizePlanMarkdown(text: string): string {
return text
.split("\n")
.map((line) => line.replace(/\s+$/, ""))
.join("\n")
.trim();
}
export function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
const lines = steps
.map((entry) => entry.step.trim())
.filter((step) => step.length > 0)
.map((step) => {
if (/^(#{1,6}\s|[-*+]\s|\d+\.\s)/.test(step)) {
return step;
}
return `- ${step}`;
});
return normalizePlanMarkdown(lines.join("\n"));
}
export function mapCodexPlanUpdateToTodo(
steps: Array<{ step?: string | null; status?: string | null }>,
): Extract<AgentTimelineItem, { type: "todo" }> {
return {
type: "todo",
items: steps.flatMap((entry, index) => {
const text = entry.step?.trim();
if (!text) return [];
const status = normalizeCodexTaskStatus(entry.status);
return [{ id: String(index), text, status, completed: status === "completed" }];
}),
};
}
function normalizeCodexTaskStatus(status: string | null | undefined) {
if (status === "completed") return "completed" as const;
if (status === "inProgress" || status === "in_progress") return "in_progress" as const;
return "pending" as const;
}
export function mapCodexPlanToToolCall(params: {
callId: string;
text: string;
}): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
}
return {
type: "tool_call",
callId: params.callId,
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text,
},
};
}
function buildPlanPermissionActions(options?: {
includeResumeAction?: boolean;
resumeLabel?: string;
}): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "dismiss",
label: "Dismiss",
behavior: "deny",
variant: "danger",
intent: "dismiss",
},
{
id: "implement",
label: "Implement",
behavior: "allow",
variant: "primary",
intent: "implement",
},
];
if (options?.includeResumeAction && options.resumeLabel) {
actions.push({
id: "implement_resume",
label: options.resumeLabel,
behavior: "allow",
variant: "secondary",
intent: "implement_resume",
});
}
return actions;
}
function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) {
return `${CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX} Make the required code changes and verify them.`;
}
return [
CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX,
"Approved plan:",
normalizedPlan,
"Carry out the work, make the necessary code changes, and verify the result.",
].join("\n\n");
}
interface CodexQuestionOption {
label: string;
description?: string;
}
interface CodexQuestionPrompt {
id: string;
header: string;
question: string;
options: CodexQuestionOption[];
multiSelect?: boolean;
isOther?: boolean;
isSecret?: boolean;
}
export function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
if (!Array.isArray(raw)) {
return [];
}
const questions: CodexQuestionPrompt[] = [];
for (const item of raw) {
const record = toObjectRecord(item);
if (!record) {
continue;
}
const id = nonEmptyString(record.id);
const header = nonEmptyString(record.header);
const question = nonEmptyString(record.question);
if (!id || !header || !question) {
continue;
}
const options = Array.isArray(record.options)
? record.options.flatMap((option): CodexQuestionOption[] => {
const optionRecord = toObjectRecord(option);
if (!optionRecord) {
return [];
}
const label = nonEmptyString(optionRecord.label);
if (!label) {
return [];
}
return [
{
label,
...(typeof optionRecord.description === "string" &&
optionRecord.description.trim().length > 0
? { description: optionRecord.description }
: {}),
},
];
})
: [];
questions.push({
id,
header,
question,
options,
...(record.multiSelect === true ? { multiSelect: true } : {}),
...(record.isOther === true ? { isOther: true } : {}),
...(record.isSecret === true ? { isSecret: true } : {}),
});
}
return questions;
}
export function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
return questions
.map((question) => {
const lines = [`${question.header}: ${question.question}`];
if (question.options.length > 0) {
lines.push(`Options: ${question.options.map((option) => option.label).join(", ")}`);
}
return lines.join("\n");
})
.join("\n\n")
.trim();
}
export function mapCodexQuestionRequestToToolCall(params: {
callId: string;
questions: CodexQuestionPrompt[];
status: ToolCallTimelineItem["status"];
answers?: Record<string, string[]>;
error?: unknown;
}): ToolCallTimelineItem {
const formattedQuestions = formatCodexQuestionPrompts(params.questions);
const formattedAnswers =
params.answers && Object.keys(params.answers).length > 0
? Object.entries(params.answers)
.map(([id, values]) => `${id}: ${values.join(", ")}`)
.join("\n")
: null;
const detailText =
params.status === "completed" && formattedAnswers
? [formattedQuestions, "Answers:", formattedAnswers].filter(Boolean).join("\n\n")
: formattedQuestions;
const base = {
type: "tool_call" as const,
callId: params.callId,
name: "request_user_input",
detail: {
type: "plain_text" as const,
text: detailText,
icon: "brain" as const,
},
metadata: {
questions: params.questions,
...(params.answers ? { answers: params.answers } : {}),
},
};
if (params.status === "failed") {
return {
...base,
status: "failed",
error: params.error ?? { message: "Question dismissed" },
};
}
if (params.status === "canceled") {
return {
...base,
status: "canceled",
error: null,
};
}
if (params.status === "running") {
return {
...base,
status: "running",
error: null,
};
}
return {
...base,
status: "completed",
error: null,
};
}
function mapCodexQuestionResponseByHeader(params: {
questions: CodexQuestionPrompt[];
response: AgentPermissionResponse;
}): Record<string, { answers: string[] }> | null {
if (params.response.behavior !== "allow") {
return null;
}
const updatedInputRecord = toObjectRecord(params.response.updatedInput);
const answersRecord = toObjectRecord(updatedInputRecord?.answers);
if (!answersRecord) {
return null;
}
const answers: Record<string, { answers: string[] }> = {};
for (const question of params.questions) {
const rawAnswer = answersRecord[question.header];
if (typeof rawAnswer !== "string") {
continue;
}
const normalizedAnswer = rawAnswer.trim();
if (!normalizedAnswer) {
continue;
}
const values = question.multiSelect
? normalizedAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [normalizedAnswer];
if (values.length > 0) {
answers[question.id] = { answers: values };
}
}
return Object.keys(answers).length > 0 ? answers : null;
}
interface CodexPatchFileChange {
path: string;
kind?: string;
content?: string;
}
function extractPatchLikeText(value: unknown): string | undefined {
const record = toObjectRecord(value);
if (!record) {
return undefined;
}
const candidates = [
record.diff,
record.patch,
record.unified_diff,
record.unifiedDiff,
record.content,
record.newString,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.length > 0) {
return candidate;
}
}
return undefined;
}
function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined {
if (!rawType) {
return rawType;
}
switch (rawType) {
case "UserMessage":
return "userMessage";
case "AgentMessage":
return "agentMessage";
case "Reasoning":
return "reasoning";
case "Plan":
return "plan";
case "CommandExecution":
return "commandExecution";
case "FileChange":
return "fileChange";
case "McpToolCall":
return "mcpToolCall";
case "WebSearch":
return "webSearch";
case "CollabAgentToolCall":
return "collabAgentToolCall";
case "SubAgentActivity":
return "subAgentActivity";
case "ImageView":
return "imageView";
case "ImageGeneration":
return "imageGeneration";
default:
return rawType;
}
}
function normalizeCodexCommandValue(value: unknown): string | string[] | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.length) {
return null;
}
const wrapperMatch = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/);
if (!wrapperMatch) {
return trimmed;
}
const candidate = wrapperMatch[1]?.trim() ?? "";
if (!candidate.length) {
return trimmed;
}
if (
(candidate.startsWith('"') && candidate.endsWith('"')) ||
(candidate.startsWith("'") && candidate.endsWith("'"))
) {
return candidate.slice(1, -1);
}
return candidate;
}
if (!Array.isArray(value)) {
return null;
}
const parts = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (parts.length === 0) {
return null;
}
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
return parts[2] ?? parts;
}
return parts;
}
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
const resolvePathFromRecord = (record: Record<string, unknown>): string => {
const directPath =
(typeof record.path === "string" && record.path.trim().length > 0
? record.path.trim()
: "") ||
(typeof record.file_path === "string" && record.file_path.trim().length > 0
? record.file_path.trim()
: "") ||
(typeof record.filePath === "string" && record.filePath.trim().length > 0
? record.filePath.trim()
: "");
return directPath;
};
if (!changes || typeof changes !== "object") {
return [];
}
if (Array.isArray(changes)) {
return changes
.map((entry): CodexPatchFileChange | null => {
const record = toObjectRecord(entry);
if (!record) {
return null;
}
const pathValue = resolvePathFromRecord(record);
if (!pathValue) {
return null;
}
return {
path: pathValue,
kind:
(typeof record.kind === "string" && record.kind) ||
(typeof record.type === "string" && record.type) ||
undefined,
content: extractPatchLikeText(record),
};
})
.filter((entry): entry is CodexPatchFileChange => entry !== null);
}
const recordChanges = toObjectRecord(changes);
if (!recordChanges) {
return [];
}
const directPathValue = resolvePathFromRecord(recordChanges);
if (directPathValue) {
return [
{
path: directPathValue,
kind:
(typeof recordChanges.kind === "string" && recordChanges.kind) ||
(typeof recordChanges.type === "string" && recordChanges.type) ||
undefined,
content: extractPatchLikeText(recordChanges),
},
];
}
return Object.entries(recordChanges)
.map(([entryPath, value]): CodexPatchFileChange | null => {
const normalizedPath = entryPath.trim();
if (!normalizedPath) {
return null;
}
return {
path: normalizedPath,
kind:
value &&
typeof value === "object" &&
typeof (value as { type?: unknown }).type === "string"
? ((value as { type?: string }).type ?? undefined)
: undefined,
content: extractPatchLikeText(value),
};
})
.filter((entry): entry is CodexPatchFileChange => entry !== null);
}
function codexPatchTextFields(text: string | null | undefined): {
patch?: string;
content?: string;
} {
if (typeof text !== "string") {
return {};
}
const normalized = text.trimStart();
const looksLikeUnifiedDiff =
normalized.startsWith("diff --git") ||
normalized.startsWith("@@") ||
normalized.startsWith("--- ") ||
normalized.startsWith("+++ ");
return looksLikeUnifiedDiff ? { patch: text } : { content: text };
}
function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem {
return {
...item,
status: "running",
error: null,
};
}
function isEditToolCallWithoutContent(item: ToolCallTimelineItem): boolean {
if (item.type !== "tool_call") {
return false;
}
if (item.detail.type !== "edit") {
return false;
}
const hasDiff =
typeof item.detail.unifiedDiff === "string" && item.detail.unifiedDiff.trim().length > 0;
const hasNewString =
typeof item.detail.newString === "string" && item.detail.newString.trim().length > 0;
return !hasDiff && !hasNewString;
}
function decodeCodexOutputDeltaChunk(chunk: string): string {
const trimmed = chunk.trim();
if (trimmed.length === 0) {
return chunk;
}
if (!/^[A-Za-z0-9+/=]+$/.test(trimmed) || trimmed.length % 4 !== 0) {
return chunk;
}
try {
const decoded = Buffer.from(trimmed, "base64").toString("utf8");
if (decoded.length === 0) {
return chunk;
}
const normalizedInput = trimmed.replace(/=+$/, "");
const normalizedRoundTrip = Buffer.from(decoded, "utf8").toString("base64").replace(/=+$/, "");
return normalizedRoundTrip === normalizedInput ? decoded : chunk;
} catch {
return chunk;
}
}
function mapCodexExecNotificationToToolCall(params: {
callId?: string | null;
command: unknown;
cwd?: string | null;
output?: string | null;
exitCode?: number | null;
success?: boolean | null;
stderr?: string | null;
running: boolean;
}): ToolCallTimelineItem | null {
const command = normalizeCodexCommandValue(params.command);
if (!command) {
return null;
}
const isFailure = params.running
? false
: params.success === false || (typeof params.exitCode === "number" && params.exitCode !== 0);
const output = params.running
? null
: {
command,
...(params.output !== null && params.output !== undefined ? { output: params.output } : {}),
...(params.exitCode !== null && params.exitCode !== undefined
? { exitCode: params.exitCode }
: {}),
};
const mapped = mapCodexToolCallEnvelope({
callId: params.callId ?? null,
name: "shell",
input: {
command,
...(params.cwd ? { cwd: params.cwd } : {}),
},
output,
error: isFailure ? { message: params.stderr?.trim() || "Command failed" } : null,
cwd: params.cwd ?? null,
});
if (!mapped) {
return null;
}
return params.running ? toRunningToolCall(mapped) : mapped;
}
export function mapCodexPatchNotificationToToolCall(params: {
callId?: string | null;
changes: unknown;
cwd?: string | null;
stdout?: string | null;
stderr?: string | null;
success?: boolean | null;
running: boolean;
}): ToolCallTimelineItem | null {
const files = parseCodexPatchChanges(params.changes);
const firstPath = files[0]?.path;
const firstPatchText = files
.map((file) => file.content?.trim())
.find((value): value is string => typeof value === "string" && value.length > 0);
const patchText = firstPatchText;
const patchFields = codexPatchTextFields(patchText);
const mapped = mapCodexToolCallEnvelope({
callId: params.callId ?? null,
name: "apply_patch",
input: firstPath
? {
path: firstPath,
...patchFields,
files: files.map((file) => ({ path: file.path, kind: file.kind })),
}
: {
changes: params.changes ?? null,
...patchFields,
},
output: params.running
? null
: {
...(files.length > 0
? {
files: files.map((file) =>
Object.assign(
{ path: file.path },
file.kind ? { kind: file.kind } : {},
codexPatchTextFields(file.content ?? patchText),
),
),
}
: {}),
...(params.stdout ? { stdout: params.stdout } : {}),
...(params.stderr ? { stderr: params.stderr } : {}),
...(params.success !== null && params.success !== undefined
? { success: params.success }
: {}),
},
error:
params.running || params.success !== false
? null
: { message: params.stderr?.trim() || "Patch apply failed" },
cwd: params.cwd ?? null,
});
if (!mapped) {
return null;
}
return params.running ? toRunningToolCall(mapped) : mapped;
}
function mapCodexTerminalInteractionToToolCall(params: {
callId: string;
processId?: string | null;
command?: string | null;
stdin?: string | null;
}): ToolCallTimelineItem {
const processId = nonEmptyString(params.processId ?? undefined);
const label = nonEmptyString(params.command ?? undefined);
return {
type: "tool_call",
callId: params.callId,
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
...(label ? { label } : {}),
...(params.stdin !== null && params.stdin !== undefined ? { text: params.stdin } : {}),
icon: "square_terminal",
},
...(processId ? { metadata: { processId } } : {}),
};
}
function mapCodexThreadPlanItem(normalizedItem: Record<string, unknown>): AgentTimelineItem | null {
const callId =
nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ??
`plan:${normalizePlanMarkdown(typeof normalizedItem.text === "string" ? normalizedItem.text : "")}`;
return mapCodexPlanToToolCall({
callId,
text: typeof normalizedItem.text === "string" ? normalizedItem.text : "",
});
}
function mapCodexThreadReasoningItem(
normalizedItem: Record<string, unknown>,
): AgentTimelineItem | null {
const summary = Array.isArray(normalizedItem.summary) ? normalizedItem.summary.join("\n") : "";
const content = Array.isArray(normalizedItem.content) ? normalizedItem.content.join("\n") : "";
const text = summary || content;
return text ? { type: "reasoning", text } : null;
}
function mapCodexThreadUserMessageItem(
normalizedItem: Record<string, unknown>,
includeUserMessage: boolean,
): AgentTimelineItem | null {
if (!includeUserMessage) {
return null;
}
const text = extractUserText(normalizedItem.content) ?? "";
const messageId = nonEmptyString(normalizedItem.id);
return {
type: "user_message",
text,
...(messageId ? { messageId } : {}),
};
}
function firstStringField(
record: Record<string, unknown>,
fields: readonly string[],
): string | null {
for (const field of fields) {
const value = record[field];
if (typeof value === "string") {
return value;
}
}
return null;
}
function readCodexHistoryTimestamp(item: unknown): string | null {
const record = toObjectRecord(item);
if (!record) {
return null;
}
return (
normalizeProviderReplayTimestamp(record.timestamp) ??
normalizeProviderReplayTimestamp(record.createdAt) ??
normalizeProviderReplayTimestamp(record.created_at)
);
}
function readCodexTurnHistoryTimestamp(
turn: unknown,
timelineItem: AgentTimelineItem,
): string | null {
const record = toObjectRecord(turn);
if (!record) {
return null;
}
const startedAt =
normalizeProviderReplayTimestamp(record.startedAt) ??
normalizeProviderReplayTimestamp(record.started_at);
const completedAt =
normalizeProviderReplayTimestamp(record.completedAt) ??
normalizeProviderReplayTimestamp(record.completed_at);
if (timelineItem.type === "user_message") {
return startedAt ?? completedAt;
}
return completedAt ?? startedAt;
}
interface CodexSubAgentActivity {
id: string | null;
agentThreadId: string;
kind: "started" | "interacted" | "interrupted";
}
function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null {
const record = toObjectRecord(item);
if (!record) {
return null;
}
const normalizedType = normalizeCodexThreadItemType(
typeof record.type === "string" ? record.type : undefined,
);
if (
normalizedType !== "subAgentActivity" ||
typeof record.agentThreadId !== "string" ||
(record.kind !== "started" && record.kind !== "interacted" && record.kind !== "interrupted")
) {
return null;
}
return {
id: nonEmptyString(record.id) ?? null,
agentThreadId: record.agentThreadId,
kind: record.kind,
};
}
function shouldIgnoreMirroredLifecycleItem(source: "item" | "codex_event", item: unknown): boolean {
return source === "codex_event" && !readCodexSubAgentActivity(item);
}
function settleHistoricalSubAgentActivity(
item: ToolCallTimelineItem,
kind: CodexSubAgentActivity["kind"],
): ToolCallTimelineItem {
// thread/read returns completed parent items, not a live child snapshot.
// Only an explicit interruption remains non-completed when replayed.
return {
...item,
status: kind === "interrupted" ? "canceled" : "completed",
error: null,
};
}
function updateHistoricalSubAgentActivity(
timeline: PersistedTimelineEntry[],
index: number,
kind: CodexSubAgentActivity["kind"],
subAgentType?: string,
): void {
const existing = timeline[index];
if (existing?.item.type !== "tool_call") {
return;
}
const settledItem = settleHistoricalSubAgentActivity(existing.item, kind);
timeline[index] = {
...existing,
item:
subAgentType && settledItem.detail.type === "sub_agent"
? {
...settledItem,
detail: { ...settledItem.detail, subAgentType },
}
: settledItem,
};
}
function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] {
const activity = readCodexSubAgentActivity(item);
if (activity) {
return [activity.agentThreadId];
}
const record = toObjectRecord(item);
const normalizedType = normalizeCodexThreadItemType(
typeof record?.type === "string" ? record.type : undefined,
);
if (normalizedType !== "collabAgentToolCall" || !Array.isArray(record?.receiverThreadIds)) {
return [];
}
return record.receiverThreadIds.filter(
(threadId): threadId is string => typeof threadId === "string" && threadId.length > 0,
);
}
function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null {
if (typeof result === "string") {
const trimmed = result.trim();
if (
trimmed.toLowerCase().startsWith("data:image/") ||
(/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed) && trimmed.length > 64)
) {
return { data: trimmed };
}
return { url: trimmed };
}
const resultRecord = toObjectRecord(result);
if (!resultRecord) {
return null;
}
return {
path: firstStringField(resultRecord, ["path", "savedPath", "saved_path"]),
url: firstStringField(resultRecord, ["url"]),
data: firstStringField(resultRecord, ["data"]),
mimeType: firstStringField(resultRecord, ["mimeType", "mime_type"]),
};
}
function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): AgentTimelineItem | null {
if (normalizedType === "imageView") {
return renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
data: result?.data ?? null,
mimeType: result?.mimeType ?? null,
},
{ materialize: materializeProviderImage },
);
}
export function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem | null {
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
const cwd = options?.cwd ?? null;
const normalizedType = normalizeCodexThreadItemType(
typeof itemRecord.type === "string" ? itemRecord.type : undefined,
);
const normalizedItem: Record<string, unknown> =
normalizedType && normalizedType !== itemRecord.type
? { ...itemRecord, type: normalizedType }
: itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return mapCodexThreadImageItem(normalizedType, normalizedItem);
}
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
}
switch (normalizedType) {
case "userMessage":
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
case "agentMessage": {
const messageId = nonEmptyString(normalizedItem.id);
return {
type: "assistant_message",
text: typeof normalizedItem.text === "string" ? normalizedItem.text : "",
...(messageId ? { messageId } : {}),
};
}
case "plan":
return mapCodexThreadPlanItem(normalizedItem);
case "reasoning":
return mapCodexThreadReasoningItem(normalizedItem);
case CODEX_CONTEXT_COMPACTION_TYPE:
return {
type: "compaction",
status: "completed",
};
default:
return null;
}
}
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
const itemRecord = toObjectRecord(item);
if (!itemRecord) {
return [];
}
const normalizedType = normalizeCodexThreadItemType(
typeof itemRecord.type === "string" ? itemRecord.type : undefined,
);
if (normalizedType !== "mcpToolCall") {
return [];
}
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
return images
.map((image) =>
renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
}),
)
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
}
function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem[] {
const timelineItem = threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
}
const CodexThreadReadResponseSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
async function requestCodexThreadHistory(
requestThread: CodexThreadReadRequest,
threadId: string,
): Promise<CodexThreadReadResponse> {
const response = await requestThread(threadId);
return CodexThreadReadResponseSchema.parse(response);
}
async function loadCodexThreadHistoryTimeline(params: {
threadId: string;
cwd: string | null;
requestThread: CodexThreadReadRequest;
}): Promise<CodexThreadHistoryProjection> {
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
for (const turn of response.thread.turns) {
for (const item of turn.items) {
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
item: settledTimelineItem,
timestamp: timestamp ?? undefined,
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
const item = timeline[timelineIndex]?.item;
return item?.type === "tool_call" && item.detail.type === "sub_agent"
? [{ childThreadId, toolCall: item }]
: [];
},
);
return { timeline, subAgentRoutes };
}
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
});
}
export async function forkCodexThread(
client: CodexAppServerClientLike,
params: CodexThreadForkParams,
): Promise<CodexThreadForkResponse> {
if (client.forkThread) {
return client.forkThread(params);
}
return parseCodexThreadForkResponse(await client.request("thread/fork", params));
}
export async function rollbackCodexThread(
client: CodexAppServerClientLike,
params: CodexThreadRollbackParams,
): Promise<CodexThreadRollbackResponse> {
if (client.rollbackThread) {
return client.rollbackThread(params);
}
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
}
function toSandboxPolicy(
type: string,
workspaceWrite?: CodexProviderOptions["sandbox_workspace_write"],
): Record<string, unknown> {
switch (type) {
case "read-only":
return { type: "readOnly" };
case "workspace-write":
return {
type: "workspaceWrite",
networkAccess: workspaceWrite?.network_access ?? false,
writableRoots: workspaceWrite?.writable_roots ?? [],
excludeSlashTmp: workspaceWrite?.exclude_slash_tmp ?? false,
excludeTmpdirEnvVar: workspaceWrite?.exclude_tmpdir_env_var ?? false,
};
case "danger-full-access":
return { type: "dangerFullAccess" };
default:
return { type: "workspaceWrite", networkAccess: false, writableRoots: [] };
}
}
function readSandboxWorkspaceWrite(
value: unknown,
): NonNullable<CodexProviderOptions["sandbox_workspace_write"]> | null {
const record = toObjectRecord(value);
if (!record) return null;
const workspaceWrite: NonNullable<CodexProviderOptions["sandbox_workspace_write"]> = {};
const writableRoots = record.writable_roots ?? record.writableRoots;
if (Array.isArray(writableRoots)) {
workspaceWrite.writable_roots = writableRoots.filter(
(root): root is string => typeof root === "string",
);
}
const networkAccess = record.network_access ?? record.networkAccess;
if (typeof networkAccess === "boolean") workspaceWrite.network_access = networkAccess;
const excludeSlashTmp = record.exclude_slash_tmp ?? record.excludeSlashTmp;
if (typeof excludeSlashTmp === "boolean") workspaceWrite.exclude_slash_tmp = excludeSlashTmp;
const excludeTmpdirEnvVar = record.exclude_tmpdir_env_var ?? record.excludeTmpdirEnvVar;
if (typeof excludeTmpdirEnvVar === "boolean") {
workspaceWrite.exclude_tmpdir_env_var = excludeTmpdirEnvVar;
}
return workspaceWrite;
}
function toCodexSandboxPolicyType(type: string): string {
switch (type) {
case "workspace-write":
return "workspaceWrite";
case "read-only":
return "readOnly";
default:
return "dangerFullAccess";
}
}
const ThreadStartedNotificationSchema = z
.object({
thread: z.object({ id: z.string() }).passthrough(),
})
.passthrough();
const TurnStartedNotificationSchema = z
.object({
threadId: z.string().optional(),
turn: z.object({ id: z.string() }).passthrough(),
})
.passthrough();
const TurnCompletedNotificationSchema = z
.object({
threadId: z.string().optional(),
turn: z
.object({
id: z.string().optional(),
status: z.string(),
error: z
.object({
message: z.string().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough(),
})
.passthrough();
const TurnPlanUpdatedNotificationSchema = z
.object({
threadId: z.string().optional(),
plan: z.array(
z
.object({
step: z.string().optional(),
status: z.string().optional(),
})
.passthrough(),
),
})
.passthrough();
const TurnDiffUpdatedNotificationSchema = z
.object({
threadId: z.string().optional(),
diff: z.string(),
})
.passthrough();
const ThreadTokenUsageUpdatedNotificationSchema = z
.object({
threadId: z.string().optional(),
tokenUsage: z.unknown(),
})
.passthrough();
const ItemTextDeltaNotificationSchema = z
.object({
threadId: z.string().optional(),
itemId: z.string(),
delta: z.string(),
})
.passthrough();
const ItemLifecycleNotificationSchema = z
.object({
threadId: z.string().optional(),
item: z
.object({
id: z.string().optional(),
type: z.string().optional(),
})
.passthrough(),
})
.passthrough();
const ContextCompactedNotificationSchema = z
.object({
threadId: z.string(),
turnId: z.string().optional(),
})
.passthrough();
const CodexEventThreadIdFields = {
threadId: z.string().optional(),
thread_id: z.string().optional(),
};
function getCodexEventThreadId(params: {
threadId?: string;
thread_id?: string;
msg: { threadId?: string; thread_id?: string };
}): string | null {
return params.threadId ?? params.thread_id ?? params.msg.threadId ?? params.msg.thread_id ?? null;
}
const CodexEventTurnAbortedNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("turn_aborted"),
reason: z.string().optional(),
})
.passthrough(),
})
.passthrough();
const CodexEventTaskCompleteNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("task_complete"),
})
.passthrough(),
})
.passthrough();
const CodexEventItemLifecycleNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.enum(["item_started", "item_completed"]),
item: z
.object({
id: z.string().optional(),
type: z.string().optional(),
})
.passthrough(),
})
.passthrough(),
})
.passthrough();
const CodexEventExecCommandBeginNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("exec_command_begin"),
call_id: z.string().optional(),
command: z.unknown().optional(),
cwd: z.string().optional(),
})
.passthrough(),
})
.passthrough();
const CodexEventExecCommandEndNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("exec_command_end"),
call_id: z.string().optional(),
command: z.unknown().optional(),
cwd: z.string().optional(),
stdout: z.string().optional(),
stderr: z.string().optional(),
aggregated_output: z.string().nullable().optional(),
aggregatedOutput: z.string().nullable().optional(),
formatted_output: z.string().optional(),
exit_code: z.number().nullable().optional(),
exitCode: z.number().nullable().optional(),
success: z.boolean().optional(),
})
.passthrough(),
})
.passthrough();
const CodexEventExecCommandOutputDeltaNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("exec_command_output_delta"),
call_id: z.string().optional(),
stream: z.string().optional(),
chunk: z.string().optional(),
delta: z.string().optional(),
})
.passthrough(),
})
.passthrough();
const CodexEventTerminalInteractionNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("terminal_interaction"),
call_id: z.string().optional(),
process_id: z.union([z.string(), z.number()]).optional(),
stdin: z.string().optional(),
})
.passthrough(),
})
.passthrough();
const ItemCommandExecutionTerminalInteractionNotificationSchema = z
.object({
threadId: z.string().optional(),
itemId: z.string().optional(),
processId: z.union([z.string(), z.number()]).optional(),
stdin: z.string().optional(),
})
.passthrough();
const CodexEventPatchApplyBeginNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("patch_apply_begin"),
call_id: z.string().optional(),
changes: z.unknown().optional(),
})
.passthrough(),
})
.passthrough();
const CodexEventPatchApplyEndNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("patch_apply_end"),
call_id: z.string().optional(),
changes: z.unknown().optional(),
stdout: z.string().optional(),
stderr: z.string().optional(),
success: z.boolean().optional(),
})
.passthrough(),
})
.passthrough();
const ItemFileChangeOutputDeltaNotificationSchema = z
.object({
threadId: z.string().optional(),
itemId: z.string(),
delta: z.string().optional(),
chunk: z.string().optional(),
})
.passthrough();
const CodexEventTurnDiffNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("turn_diff"),
unified_diff: z.string().optional(),
diff: z.string().optional(),
})
.passthrough(),
})
.passthrough();
const CodexEventThreadRolledBackNotificationSchema = z
.object({
...CodexEventThreadIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
type: z.literal("thread_rolled_back"),
num_turns: z.number().int().nonnegative().optional(),
numTurns: z.number().int().nonnegative().optional(),
})
.passthrough(),
})
.passthrough();
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string; threadId: string | null }
| {
kind: "turn_completed";
status: string;
errorMessage: string | null;
threadId: string | null;
}
| {
kind: "plan_updated";
plan: Array<{ step: string | null; status: string | null }>;
threadId: string | null;
}
| { kind: "diff_updated"; diff: string; threadId: string | null }
| { kind: "token_usage_updated"; tokenUsage: unknown; threadId: string | null }
| { kind: "agent_message_delta"; itemId: string; delta: string; threadId: string | null }
| { kind: "reasoning_delta"; itemId: string; delta: string; threadId: string | null }
| {
kind: "item_completed";
source: "item" | "codex_event";
threadId: string | null;
item: { id?: string; type?: string; [key: string]: unknown };
}
| {
kind: "item_started";
source: "item" | "codex_event";
threadId: string | null;
item: { id?: string; type?: string; [key: string]: unknown };
}
| {
kind: "exec_command_started";
callId: string | null;
command: unknown;
cwd: string | null;
threadId: string | null;
}
| {
kind: "exec_command_completed";
callId: string | null;
command: unknown;
cwd: string | null;
output: string | null;
exitCode: number | null;
success: boolean | null;
stderr: string | null;
threadId: string | null;
}
| {
kind: "exec_command_output_delta";
callId: string | null;
stream: string | null;
chunk: string | null;
threadId: string | null;
}
| {
kind: "terminal_interaction";
source: "item" | "codex_event";
callId: string | null;
processId: string | null;
stdin: string | null;
threadId: string | null;
}
| {
kind: "patch_apply_started";
callId: string | null;
changes: unknown;
threadId: string | null;
}
| {
kind: "patch_apply_completed";
callId: string | null;
changes: unknown;
stdout: string | null;
stderr: string | null;
success: boolean | null;
threadId: string | null;
}
| {
kind: "file_change_output_delta";
itemId: string;
delta: string | null;
threadId: string | null;
}
| { kind: "thread_rolled_back"; numTurns: number; threadId: string | null }
| { kind: "context_compacted"; threadId: string; turnId: string | null }
| { kind: "invalid_payload"; method: string; params: unknown }
| { kind: "unknown_method"; method: string; params: unknown };
type CodexDeltaNotification = Extract<
ParsedCodexNotification,
{
kind:
| "agent_message_delta"
| "reasoning_delta"
| "exec_command_output_delta"
| "file_change_output_delta";
}
>;
type CodexThreadRoute =
| { kind: "root" }
| { kind: "sub_agent"; callId: string }
| { kind: "pending_sub_agent"; threadId: string };
function getCodexNotificationThreadId(parsed: ParsedCodexNotification): string | null {
return "threadId" in parsed ? parsed.threadId : null;
}
function isCodexDeltaNotification(
parsed: ParsedCodexNotification,
): parsed is CodexDeltaNotification {
return (
parsed.kind === "agent_message_delta" ||
parsed.kind === "reasoning_delta" ||
parsed.kind === "exec_command_output_delta" ||
parsed.kind === "file_change_output_delta"
);
}
const CodexNotificationSchema = z.union([
z
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "thread_started",
threadId: params.thread.id,
}),
),
z.object({ method: z.literal("thread/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({
kind: "turn_started",
turnId: params.turn.id,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("turn/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("turn/completed"), params: TurnCompletedNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "turn_completed",
status: params.turn.status,
errorMessage: params.turn.error?.message ?? null,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("turn/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("turn/plan/updated"), params: TurnPlanUpdatedNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "plan_updated",
plan: params.plan.map((entry) => ({
step: entry.step ?? null,
status: entry.status ?? null,
})),
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("turn/plan/updated"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("turn/diff/updated"), params: TurnDiffUpdatedNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "diff_updated",
diff: params.diff,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("turn/diff/updated"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("thread/tokenUsage/updated"),
params: ThreadTokenUsageUpdatedNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "token_usage_updated",
tokenUsage: params.tokenUsage,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("thread/tokenUsage/updated"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("thread/compacted"), params: ContextCompactedNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "context_compacted",
threadId: params.threadId,
turnId: params.turnId ?? null,
}),
),
z.object({ method: z.literal("thread/compacted"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("item/agentMessage/delta"),
params: ItemTextDeltaNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "agent_message_delta",
itemId: params.itemId,
delta: params.delta,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("item/agentMessage/delta"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("item/reasoning/summaryTextDelta"),
params: ItemTextDeltaNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "reasoning_delta",
itemId: params.itemId,
delta: params.delta,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("item/reasoning/summaryTextDelta"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "item_completed",
source: "item",
threadId: params.threadId ?? null,
item: params.item,
}),
),
z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "item_started",
source: "item",
threadId: params.threadId ?? null,
item: params.item,
}),
),
z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/item_started"),
params: CodexEventItemLifecycleNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "item_started",
source: "codex_event",
threadId: getCodexEventThreadId(params),
item: params.msg.item,
}),
),
z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/item_completed"),
params: CodexEventItemLifecycleNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "item_completed",
source: "codex_event",
threadId: getCodexEventThreadId(params),
item: params.msg.item,
}),
),
z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/exec_command_begin"),
params: CodexEventExecCommandBeginNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_started",
callId: params.msg.call_id ?? null,
command: params.msg.command ?? null,
cwd: params.msg.cwd ?? null,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/exec_command_begin"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/exec_command_end"),
params: CodexEventExecCommandEndNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_completed",
callId: params.msg.call_id ?? null,
command: params.msg.command ?? null,
cwd: params.msg.cwd ?? null,
output:
params.msg.aggregated_output ??
params.msg.aggregatedOutput ??
params.msg.formatted_output ??
params.msg.stdout ??
null,
exitCode: params.msg.exit_code ?? params.msg.exitCode ?? null,
success: params.msg.success ?? null,
stderr: params.msg.stderr ?? null,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/exec_command_output_delta"),
params: CodexEventExecCommandOutputDeltaNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_output_delta",
callId: params.msg.call_id ?? null,
stream: params.msg.stream ?? null,
chunk: params.msg.chunk ?? params.msg.delta ?? null,
threadId: getCodexEventThreadId(params),
}),
),
z
.object({
method: z.literal("codex/event/exec_command_output_delta"),
params: z.unknown(),
})
.transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/terminal_interaction"),
params: CodexEventTerminalInteractionNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "terminal_interaction",
source: "codex_event",
callId: params.msg.call_id ?? null,
processId:
typeof params.msg.process_id === "number"
? String(params.msg.process_id)
: (params.msg.process_id ?? null),
stdin: params.msg.stdin ?? null,
threadId: getCodexEventThreadId(params),
}),
),
z
.object({ method: z.literal("codex/event/terminal_interaction"), params: z.unknown() })
.transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("item/commandExecution/terminalInteraction"),
params: ItemCommandExecutionTerminalInteractionNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "terminal_interaction",
source: "item",
callId: params.itemId ?? null,
processId:
typeof params.processId === "number"
? String(params.processId)
: (params.processId ?? null),
stdin: params.stdin ?? null,
threadId: params.threadId ?? null,
}),
),
z
.object({
method: z.literal("item/commandExecution/terminalInteraction"),
params: z.unknown(),
})
.transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/patch_apply_begin"),
params: CodexEventPatchApplyBeginNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "patch_apply_started",
callId: params.msg.call_id ?? null,
changes: params.msg.changes ?? null,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/patch_apply_begin"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/patch_apply_end"),
params: CodexEventPatchApplyEndNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "patch_apply_completed",
callId: params.msg.call_id ?? null,
changes: params.msg.changes ?? null,
stdout: params.msg.stdout ?? null,
stderr: params.msg.stderr ?? null,
success: params.msg.success ?? null,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("item/fileChange/outputDelta"),
params: ItemFileChangeOutputDeltaNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "file_change_output_delta",
itemId: params.itemId,
delta: params.delta ?? params.chunk ?? null,
threadId: params.threadId ?? null,
}),
),
z.object({ method: z.literal("item/fileChange/outputDelta"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/turn_diff"),
params: CodexEventTurnDiffNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "diff_updated",
diff: params.msg.unified_diff ?? params.msg.diff ?? "",
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/turn_diff"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/turn_aborted"),
params: CodexEventTurnAbortedNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "turn_completed",
status: "interrupted",
errorMessage: null,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/turn_aborted"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/task_complete"),
params: CodexEventTaskCompleteNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "turn_completed",
status: "completed",
errorMessage: null,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/task_complete"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/thread_rolled_back"),
params: CodexEventThreadRolledBackNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "thread_rolled_back",
numTurns: params.msg.num_turns ?? params.msg.numTurns ?? 0,
threadId: getCodexEventThreadId(params),
}),
),
z.object({ method: z.literal("codex/event/thread_rolled_back"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.string(), params: z.unknown() })
.transform(
({ method, params }): ParsedCodexNotification => ({ kind: "unknown_method", method, params }),
),
]);
async function readCodexConfiguredDefaults(
client: CodexAppServerClient,
logger: Logger,
): Promise<CodexConfiguredDefaults> {
let savedConfigDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("getUserSavedConfig", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.modelReasoningEffort === "string" ? config.modelReasoningEffort : null;
savedConfigDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex saved config defaults");
}
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
return savedConfigDefaults;
}
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
}
interface CodexSkillPromptBlock {
type: "skill";
name: string;
path: string;
}
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
type CodexPromptInput = string | CodexPromptContentBlock[];
interface CodexTextElement {
byteRange: {
start: number;
end: number;
};
placeholder: string | null;
}
type CodexAppServerUserInput =
| {
type: "text";
text: string;
text_elements: CodexTextElement[];
}
| {
type: "localImage";
path: string;
}
| CodexSkillPromptBlock;
export async function codexAppServerTurnInputFromPrompt(
prompt: CodexPromptInput,
logger: Logger,
): Promise<CodexAppServerUserInput[]> {
if (typeof prompt === "string") {
return [toCodexTextInput(prompt)];
}
const output: CodexAppServerUserInput[] = [];
let previousTextBlock = false;
for (const block of prompt) {
if (block.type === "text") {
output.push(toCodexTextInput(block.text));
previousTextBlock = block.text.length > 0;
continue;
}
if (block.type === "skill") {
output.push(block);
previousTextBlock = false;
continue;
}
if (block.type === "image") {
try {
const filePath = materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
}).path;
output.push({ type: "localImage", path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn({ message }, "Failed to write Codex image attachment");
output.push({
...toCodexTextInput(`User attached image (failed to write temp file): ${message}`),
});
}
previousTextBlock = false;
continue;
}
const attachmentText = renderPromptAttachmentAsText(block);
output.push(toCodexTextInput(previousTextBlock ? `\n\n${attachmentText}` : attachmentText));
previousTextBlock = true;
}
return output;
}
function toCodexTextInput(text: string): Extract<CodexAppServerUserInput, { type: "text" }> {
return {
type: "text",
text,
text_elements: [],
};
}
export function buildCodexAppServerEnv(
runtimeSettings?: ProviderRuntimeSettings,
launchEnv?: Record<string, string>,
): NodeJS.ProcessEnv {
return createProviderEnv({
runtimeSettings,
overlays: [launchEnv],
});
}
function buildCodexAppServerInitializeParams(): {
clientInfo: { name: string; title: string; version: string };
capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true };
} {
return {
clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO,
capabilities: {
experimentalApi: true,
mcpServerOpenaiFormElicitation: true,
},
};
}
function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutTrailingSlashes = trimmed.replace(/\/+$/u, "");
if (withoutTrailingSlashes.endsWith("/v1")) {
return withoutTrailingSlashes;
}
return `${withoutTrailingSlashes}/v1`;
}
function buildCodexCustomProviderConfig(
runtimeSettings: ProviderRuntimeSettings | undefined,
customProvider: CodexAppServerAgentDeps["customProvider"],
): Record<string, unknown> | null {
if (customProvider?.extends !== CODEX_PROVIDER) {
return null;
}
const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL;
if (typeof baseUrl !== "string") {
return null;
}
const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return null;
}
const providerConfig: Record<string, unknown> = {
name: customProvider.label,
base_url: normalizedBaseUrl,
wire_api: "responses",
};
if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) {
providerConfig.env_key = "OPENAI_API_KEY";
providerConfig.requires_openai_auth = false;
}
return {
model_provider: customProvider.id,
model_providers: {
[customProvider.id]: providerConfig,
},
};
}
interface CodexSubAgentCallState {
callId: string;
toolCall: ToolCallTimelineItem;
parentCallId: string | null;
activityItemIds: Set<string>;
pendingCommandOutputDeltas: Map<string, string[]>;
pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
}
interface CodexPendingPermissionHandler {
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
}
export class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
private readonly logger: Logger;
private readonly config: AgentSessionConfig;
private currentMode: string;
private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions;
private resolvedWorkspaceWrite: NonNullable<
CodexProviderOptions["sandbox_workspace_write"]
> | null = null;
private resolvedSandboxPolicy: Record<string, unknown> | null = null;
private currentThreadId: string | null = null;
private currentTurnId: string | null = null;
private pendingForegroundTurnIdentification: {
foregroundTurnId: string;
promise: Promise<string | null>;
resolve: (turnId: string | null) => void;
} | null = null;
private client: CodexAppServerClient | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = [];
private loadingPersistedHistory = false;
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
private resolvedPermissionRequests = new Set<string>();
private pendingAgentMessages = new Map<string, string>();
private pendingReasoning = new Map<string, string[]>();
private pendingCommandOutputDeltas = new Map<string, string[]>();
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
>();
private nextTerminalInteractionOrdinal = 0;
private emittedTerminalInteractionKeys = new Set<string>();
private emittedExecCommandStartedCallIds = new Set<string>();
private emittedExecCommandCompletedCallIds = new Set<string>();
private emittedItemStartedIds = new Set<string>();
private emittedItemCompletedIds = new Set<string>();
private emittedProviderSubagentUserMessageKeys = new Set<string>();
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
private subAgentCallIdByChildThreadId = new Map<string, string>();
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
private warnedUnknownNotificationMethods = new Set<string>();
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private readonly userMessageTurnIndexes = new Map<string, number>();
private readonly userMessageTurnIds: string[] = [];
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
private unpairedCompactionItemCompletions = 0;
private connected = false;
private connectionPromise: Promise<void> | null = null;
private closed = false;
private collaborationModes: Array<{
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
}> = [];
private resolvedCollaborationMode: {
mode: string;
settings: Record<string, unknown>;
name: string;
} | null = null;
private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null;
constructor(
config: AgentSessionConfig,
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
logger: Logger,
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
private readonly deps: CodexAppServerAgentDeps = {},
private readonly ephemeral: boolean = false,
private readonly goalsEnabled: boolean = false,
private readonly autoReviewEnabled: boolean = false,
private readonly agentId?: string,
private readonly initialResumePurpose: "interactive" | "history" = "interactive",
) {
this.logger = logger.child({
module: "agent",
provider: CODEX_PROVIDER,
agentId: this.agentId,
});
if (config.modeId !== undefined) {
validateCodexMode(config.modeId);
}
this.hasWorkflowModeOverride = config.modeId !== undefined;
this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID;
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
this.config = config;
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = "fast";
}
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
this.historyPending = true;
}
}
get id(): string | null {
return this.currentThreadId;
}
get features(): AgentFeature[] {
return buildCodexFeatures({
modelId: this.config.model,
fastModeEnabled: this.serviceTier === "fast",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
async connect(): Promise<void> {
if (this.closed) {
throw this.createClosedError();
}
if (this.connected) return;
if (this.connectionPromise) {
await this.connectionPromise;
if (this.closed) {
throw this.createClosedError();
}
return;
}
const connectionPromise = this.establishConnection();
this.connectionPromise = connectionPromise;
try {
await connectionPromise;
} finally {
if (this.connectionPromise === connectionPromise) {
this.connectionPromise = null;
}
}
if (this.closed) {
throw this.createClosedError();
}
}
private async establishConnection(): Promise<void> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
if (this.closed) {
await client.dispose();
throw this.createClosedError();
}
this.client = client;
client.setUnexpectedTerminationHandler((error) => {
this.handleUnexpectedTermination(error);
});
client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
if (this.currentThreadId) {
await this.ensureThreadLoaded({
allowArchivedHistory: this.initialResumePurpose === "history",
});
await this.loadPersistedHistory();
}
if (this.closed) {
throw this.createClosedError();
}
this.connected = true;
} catch (error) {
try {
if (this.client === client) {
await this.disposeClient();
} else {
await client.dispose();
}
} catch (disposeError) {
this.logger.warn(
{ err: disposeError, connectError: error },
"Failed to dispose Codex app-server client after connection failure",
);
}
throw error;
}
}
private async loadResolvedWorkspaceWrite(): Promise<void> {
if (!this.client) return;
try {
const response = toObjectRecord(
await this.client.request("config/read", { cwd: this.config.cwd ?? null }),
);
const config = toObjectRecord(response?.config);
this.resolvedWorkspaceWrite = readSandboxWorkspaceWrite(config?.sandbox_workspace_write);
} catch (error) {
this.logger.debug({ error }, "Failed to read resolved Codex workspace-write config");
}
}
private rememberResolvedSandboxPolicy(response: unknown): void {
const sandbox = toObjectRecord(toObjectRecord(response)?.sandbox);
this.resolvedSandboxPolicy = sandbox ?? null;
if (sandbox?.type !== "workspaceWrite") return;
this.resolvedWorkspaceWrite = readSandboxWorkspaceWrite(sandbox);
}
private createClosedError(): Error {
return new Error("Codex app-server session is closed");
}
private traceContext(): CodexAppServerTraceContext {
return {
agentId: this.agentId,
sessionId: this.currentThreadId ?? undefined,
turnId: this.activeForegroundTurnId ?? undefined,
};
}
private handleUnexpectedTermination(error: Error): void {
this.connected = false;
const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null;
this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn });
if (hasActiveRootTurn) {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: error.message,
});
}
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.currentTurnId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
this.pendingForegroundTurnIdentification = null;
}
private async loadCollaborationModes(): Promise<void> {
if (!this.client) return;
try {
const response = toObjectRecord(await this.client.request("collaborationMode/list", {}));
const data = Array.isArray(response?.data) ? response.data : [];
this.collaborationModes = data.map((entry) => {
const record = toObjectRecord(entry);
return {
name: typeof record?.name === "string" ? record.name : "",
mode: typeof record?.mode === "string" ? record.mode : null,
model: typeof record?.model === "string" ? record.model : null,
reasoning_effort:
typeof record?.reasoning_effort === "string" ? record.reasoning_effort : null,
developer_instructions:
typeof record?.developer_instructions === "string"
? record.developer_instructions
: null,
};
});
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.collaboration_modes_failed",
);
this.collaborationModes = [];
}
this.refreshResolvedCollaborationMode();
}
private async loadSkills(): Promise<void> {
if (!this.client) return;
try {
const response = toObjectRecord(
await this.client.request("skills/list", {
cwds: [this.config.cwd],
}),
);
const entries = Array.isArray(response?.data) ? response.data : [];
const skillsByName = new Map<string, { name: string; description: string; path: string }>();
for (const entry of entries) {
const entryRecord = toObjectRecord(entry);
const list = Array.isArray(entryRecord?.skills) ? entryRecord.skills : [];
for (const skill of list) {
const skillRecord = toObjectRecord(skill);
if (typeof skillRecord?.name !== "string" || typeof skillRecord?.path !== "string")
continue;
// Codex skills/list returns disabled skills with enabled:false; omit them from
// slash-command surfaces so Paseo matches Codex CLI/TUI behavior.
// Missing enabled (older binaries) is treated as enabled.
if (skillRecord.enabled === false) continue;
if (!skillsByName.has(skillRecord.name)) {
skillsByName.set(skillRecord.name, {
name: skillRecord.name,
description: resolveSkillDescription(skillRecord),
path: skillRecord.path,
});
}
}
}
this.cachedSkills = Array.from(skillsByName.values());
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
error,
},
"provider.codex.metadata.skills_failed",
);
this.cachedSkills = null;
}
}
private findCollaborationMode(target: "code" | "plan"): {
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
} | null {
if (this.collaborationModes.length === 0) return null;
const findByName = (predicate: (name: string) => boolean) =>
this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase()));
if (target === "plan") {
return findByName((name) => name.includes("plan") || name.includes("read")) ?? null;
}
return (
findByName((name) => name.includes("auto") || name.includes("code")) ??
this.collaborationModes.find((entry) => {
const name = entry.name.toLowerCase();
return !name.includes("plan") && !name.includes("read");
}) ??
this.collaborationModes[0] ??
null
);
}
private hasPlanCollaborationMode(): boolean {
return this.findCollaborationMode("plan") !== null;
}
private resolveCollaborationMode(): {
mode: string;
settings: Record<string, unknown>;
name: string;
} | null {
const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code");
if (!match) return null;
const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
const developerInstructions = composeSystemPromptParts(
match.developer_instructions,
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) settings.developer_instructions = developerInstructions;
if (this.config.model) settings.model = this.config.model;
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId;
return { mode: match.mode ?? "code", settings, name: match.name };
}
private refreshResolvedCollaborationMode(): void {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void {
this.config.featureValues = {
...this.config.featureValues,
[featureId]: value,
};
if (featureId === "fast_mode") {
this.serviceTier = value ? "fast" : null;
this.cachedRuntimeInfo = null;
return;
}
this.planModeEnabled = value;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
private rememberPlanResult(item: ToolCallTimelineItem): void {
if (item.detail.type !== "plan") {
return;
}
this.latestPlanResult = {
callId: item.callId,
text: item.detail.text,
turnId: this.currentTurnId,
};
}
private emitSyntheticPlanApprovalRequest(planText: string): void {
this.dismissPendingPlanApprovals("Superseded by a newer plan");
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexPlanApproval",
kind: "plan",
title: "Plan",
description: "Review the proposed plan before implementation starts.",
input: { plan: planText },
actions: buildPlanPermissionActions(),
metadata: {
planText,
source: "codex_plan_approval",
},
};
this.pendingPermissions.set(requestId, request);
this.pendingPermissionHandlers.set(requestId, {
resolve: () => undefined,
kind: "plan",
planText,
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
/**
* Prepare the session for plan implementation by disabling plan mode
* and returning the implementation prompt. The caller is responsible for
* starting the turn through the normal streamAgent path.
*/
private preparePlanImplementation(params: { planText?: unknown }): string {
const planText =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
this.applyFeatureValue("plan_mode", false);
return buildCodexPlanImplementationPrompt(planText);
}
private registerRequestHandlers(): void {
if (!this.client) return;
this.client.setRequestHandler("item/commandExecution/requestApproval", (params) =>
this.handleCommandApprovalRequest(params),
);
this.client.setRequestHandler("item/fileChange/requestApproval", (params) =>
this.handleFileChangeApprovalRequest(params),
);
this.client.setRequestHandler("item/tool/requestUserInput", (params) =>
this.handleToolApprovalRequest(params),
);
this.client.setRequestHandler("mcpServer/elicitation/request", (params, requestId) =>
this.handleMcpElicitationRequest(params, requestId),
);
// Keep the legacy method name for older Codex builds.
this.client.setRequestHandler("tool/requestUserInput", (params) =>
this.handleToolApprovalRequest(params),
);
}
private async loadPersistedHistory(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const client = this.client;
const threadId = this.currentThreadId;
const history = await loadCodexThreadHistoryTimeline({
threadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => {
return readCodexThread(client, threadIdToRead);
},
});
const { timeline, subAgentRoutes } = history;
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
this.persistedProviderSubagentEvents = [];
this.loadingPersistedHistory = true;
try {
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
} finally {
this.loadingPersistedHistory = false;
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
if (entry.item.type === "user_message") {
this.rememberCodexUserMessageTurn(entry.item.messageId);
}
}
this.persistedHistory = timeline;
this.historyPending = timeline.length > 0;
}
private async loadPersistedSubAgentHistories(
client: CodexAppServerClientLike,
rootRoutes: readonly PersistedSubAgentRoute[],
): Promise<void> {
const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null }));
const visitedThreadIds = new Set(this.currentThreadId ? [this.currentThreadId] : []);
while (queue.length > 0 && visitedThreadIds.size < 100) {
const next = queue.shift();
if (!next || visitedThreadIds.has(next.route.childThreadId)) {
continue;
}
visitedThreadIds.add(next.route.childThreadId);
this.registerSubAgentToolCall({
timelineItem: next.route.toolCall,
rawItem: { agentThreadId: next.route.childThreadId },
parentCallId: next.parentCallId,
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
threadId: next.route.childThreadId,
cwd: this.config.cwd ?? null,
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
});
for (const entry of childHistory.timeline) {
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
}
for (const route of childHistory.subAgentRoutes) {
queue.push({ route, parentCallId: next.route.toolCall.callId });
}
} catch (error) {
this.logger.trace(
{ err: error, childThreadId: next.route.childThreadId },
"Failed to load persisted Codex child history",
);
}
}
}
private async ensureThreadLoaded(
options: { allowArchivedHistory?: boolean } = {},
): Promise<void> {
if (!this.client || !this.currentThreadId) return;
try {
const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {}));
const ids = Array.isArray(loaded?.data) ? loaded.data : [];
if (ids.includes(this.currentThreadId)) {
return;
}
const params: Record<string, unknown> = { threadId: this.currentThreadId };
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
params.config = codexConfig;
}
const response = await this.client.request("thread/resume", params);
this.rememberResolvedSandboxPolicy(response);
} catch (error) {
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
if (
options.allowArchivedHistory === true &&
isArchivedCodexThreadResumeError(error, threadId)
) {
this.logger.info(
{ threadId },
"Loading archived Codex thread history without resuming the native session",
);
return;
}
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
}
}
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
return null;
}
const withoutPrefix = trimmed.slice(1);
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
const commandName =
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
if (!commandName || commandName.includes("/")) {
return null;
}
const rawArgs =
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName };
}
private async resolveSlashCommandInvocation(
prompt: AgentPromptInput,
): Promise<{ commandName: string; args?: string } | null> {
if (typeof prompt !== "string") {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
if (!parsed) {
return null;
}
try {
const commands = await this.listCommands();
return commands.some((command) => command.name === parsed.commandName) ? parsed : null;
} catch (error) {
this.logger.warn(
{ err: error, commandName: parsed.commandName },
"Failed to resolve slash command; falling back to plain prompt input",
);
return null;
}
}
private async buildCommandPromptInput(
commandName: string,
args?: string,
): Promise<CodexPromptInput> {
if (commandName.startsWith("prompts:")) {
const promptName = commandName.slice("prompts:".length);
const codexHome = resolveCodexHomeDir();
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
const raw = await fs.readFile(promptPath, "utf8");
const parsed = parseFrontMatter(raw);
return expandCodexCustomPrompt(parsed.body, args);
}
if (!this.connected) {
await this.connect();
} else {
await this.loadSkills();
}
const skill = this.cachedSkills?.find((entry) => entry.name === commandName);
if (skill) {
const trimmedArgs = args?.trim() ?? "";
const text = trimmedArgs ? `$${skill.name} ${trimmedArgs}` : `$${skill.name}`;
const input: CodexPromptContentBlock[] = [
{ type: "skill", name: skill.name, path: skill.path },
{ type: "text", text },
];
return input;
}
return args ? `$${commandName} ${args}` : `$${commandName}`;
}
private async buildTurnStartParams(
prompt: CodexPromptInput,
options?: AgentRunOptions,
): Promise<{
params: Record<string, unknown>;
thinkingOptionId?: string;
approvalPolicy?: string;
sandboxPolicyType?: string;
hasOutputSchema: boolean;
hasDeveloperInstructions: boolean;
hasCodexConfig: boolean;
}> {
const input = await this.buildUserInput(prompt);
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const params: Record<string, unknown> = {
threadId: this.currentThreadId,
input,
};
const { approvalPolicy, sandboxPolicyType } = this.applyTurnWorkflowPolicy(params, preset);
if (this.config.model) {
params.model = this.config.model;
}
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (thinkingOptionId) {
params.effort = thinkingOptionId;
}
if (this.serviceTier) {
params.serviceTier = this.serviceTier;
}
if (this.resolvedCollaborationMode) {
params.collaborationMode = {
mode: this.resolvedCollaborationMode.mode,
settings: this.resolvedCollaborationMode.settings,
};
}
if (this.config.cwd) {
params.cwd = this.config.cwd;
}
if (options?.outputSchema) {
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
}
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
params.config = codexConfig;
}
return {
params,
thinkingOptionId,
approvalPolicy,
sandboxPolicyType,
hasOutputSchema: Boolean(options?.outputSchema),
hasDeveloperInstructions: Boolean(developerInstructions),
hasCodexConfig: Boolean(codexConfig),
};
}
private applyTurnWorkflowPolicy(
params: Record<string, unknown>,
preset: CodexModePreset,
): { approvalPolicy?: string; sandboxPolicyType?: string } {
const approvalPolicy = this.hasWorkflowModeOverride ? preset.approvalPolicy : undefined;
const sandboxPolicyType =
this.providerOptions.sandbox_mode ??
(this.hasWorkflowModeOverride ? preset.sandbox : undefined);
if (approvalPolicy && this.providerOptions.approval_policy === undefined) {
params.approvalPolicy = approvalPolicy;
}
if (sandboxPolicyType) {
const nativeType = toCodexSandboxPolicyType(sandboxPolicyType);
const workspaceWrite = {
...this.resolvedWorkspaceWrite,
...this.providerOptions.sandbox_workspace_write,
};
params.sandboxPolicy =
this.resolvedSandboxPolicy?.type === nativeType
? this.resolvedSandboxPolicy
: toSandboxPolicy(sandboxPolicyType, workspaceWrite);
}
if (this.hasWorkflowModeOverride) {
applyApprovalsReviewerParam(params, preset);
}
return { approvalPolicy, sandboxPolicyType };
}
private logTurnStartSummary({
turnId,
thinkingOptionId,
approvalPolicy,
sandboxPolicyType,
hasOutputSchema,
hasDeveloperInstructions,
hasCodexConfig,
}: {
turnId: string;
thinkingOptionId?: string;
approvalPolicy?: string;
sandboxPolicyType?: string;
hasOutputSchema: boolean;
hasDeveloperInstructions: boolean;
hasCodexConfig: boolean;
}): void {
this.logger.info(
{
turnId,
threadId: this.currentThreadId,
model: this.config.model ?? null,
modeId: this.currentMode ?? null,
effort: thinkingOptionId ?? null,
serviceTier: this.serviceTier,
cwd: this.config.cwd ?? null,
approvalPolicy: approvalPolicy ?? null,
sandboxPolicyType: sandboxPolicyType ?? null,
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
hasOutputSchema,
hasDeveloperInstructions,
hasCodexConfig,
},
"Starting Codex app-server turn",
);
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
let currentAssistantMessageId: string | null = null;
let currentAssistantMessageHasBoundary = false;
let hasAssistantMessage = false;
return runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: async () => (await this.getRuntimeInfo()).sessionId ?? "",
reduceFinalText: ({ current, item }) => {
if (item.type === "assistant_message") {
const hasPreviousAssistantMessage = hasAssistantMessage;
hasAssistantMessage = true;
const isNewMessage =
item.messageId === undefined || item.messageId !== currentAssistantMessageId;
if (isNewMessage) {
currentAssistantMessageId = item.messageId ?? null;
currentAssistantMessageHasBoundary =
hasPreviousAssistantMessage &&
item.text.startsWith(ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN);
}
const finalTextItem = currentAssistantMessageHasBoundary
? {
...item,
text: item.text.startsWith(ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN)
? item.text.slice(ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN.length)
: item.text,
}
: item;
return isNewMessage
? finalTextItem.text
: appendOrReplaceGrowingAssistantMessage({ current, item: finalTextItem });
}
if (item.type === "tool_call" && item.detail.type === "plan") {
currentAssistantMessageId = null;
currentAssistantMessageHasBoundary = false;
return item.detail.text;
}
return current;
},
});
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
}
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: prompt;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.currentTurnId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
let resolveTurnIdentification!: (identifiedTurnId: string | null) => void;
const turnIdentification = new Promise<string | null>((resolvePromise) => {
resolveTurnIdentification = resolvePromise;
});
this.pendingForegroundTurnIdentification = {
foregroundTurnId: turnId,
promise: turnIdentification,
resolve: resolveTurnIdentification,
};
this.logTurnStartSummary({
turnId,
thinkingOptionId: turnStart.thinkingOptionId,
approvalPolicy: turnStart.approvalPolicy,
sandboxPolicyType: turnStart.sandboxPolicyType,
hasOutputSchema: turnStart.hasOutputSchema,
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
return { turnId };
} catch (error) {
this.pendingForegroundTurnIdentification?.resolve(null);
this.pendingForegroundTurnIdentification = null;
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
throw error;
}
}
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
if (typeof messageId !== "string" || messageId.length === 0) {
return false;
}
if (this.userMessageTurnIndexes.has(messageId)) {
return false;
}
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
this.userMessageTurnIds.push(messageId);
return true;
}
private resetCodexUserMessageTurns(): void {
this.userMessageTurnIndexes.clear();
this.userMessageTurnIds.length = 0;
}
private truncateCodexUserMessageTurns(numTurns: number): void {
if (numTurns <= 0) {
return;
}
this.userMessageTurnIds.length = Math.max(0, this.userMessageTurnIds.length - numTurns);
this.userMessageTurnIndexes.clear();
this.userMessageTurnIds.forEach((messageId, index) => {
this.userMessageTurnIndexes.set(messageId, index);
});
}
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
return {
resolve: (messageId) => this.userMessageTurnIndexes.get(messageId) ?? null,
count: () => this.userMessageTurnIds.length,
};
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (
(!this.historyPending || this.persistedHistory.length === 0) &&
this.persistedProviderSubagentEvents.length === 0
) {
return;
}
const history = this.persistedHistory;
const providerSubagents = this.persistedProviderSubagentEvents;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
for (const event of providerSubagents) {
yield event;
}
for (const entry of history) {
yield {
type: "timeline",
provider: CODEX_PROVIDER,
item: entry.item,
timestamp: entry.timestamp,
};
}
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
if (!this.connected) {
await this.connect();
}
if (!this.currentThreadId) {
await this.ensureThread();
}
const info: AgentRuntimeInfo = {
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
model: this.config.model ?? null,
thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null,
modeId: this.currentMode ?? null,
extra: this.resolvedCollaborationMode
? { collaborationMode: this.resolvedCollaborationMode.name }
: undefined,
};
this.cachedRuntimeInfo = info;
return { ...info };
}
async getAvailableModes(): Promise<AgentMode[]> {
if (this.autoReviewEnabled) {
return CODEX_MODES;
}
return CODEX_MODES.filter((mode) => mode.id !== "auto-review");
}
async getCurrentMode(): Promise<string | null> {
return this.currentMode ?? null;
}
async setMode(modeId: string): Promise<void | AgentProviderNotice> {
validateCodexMode(modeId);
this.currentMode = modeId;
this.hasWorkflowModeOverride = true;
this.config.modeId = modeId;
this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return MODE_APPLIES_NEXT_TURN_NOTICE;
}
}
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
if (!codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = null;
}
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return THINKING_APPLIES_NEXT_TURN_NOTICE;
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId === "fast_mode") {
if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) {
throw new Error(
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
);
}
this.applyFeatureValue("fast_mode", Boolean(value));
return;
}
if (featureId === "plan_mode") {
this.applyFeatureValue("plan_mode", Boolean(value));
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const pending = this.pendingPermissionHandlers.get(requestId);
if (!pending) {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
}
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
if (pending.kind === "plan") {
return this.handlePlanPermissionResponse({ requestId, response, pending, pendingRequest });
}
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
if (response.behavior === "deny" && pendingRequest?.kind === "tool") {
this.emitDeniedToolCallTimelineEvent({ requestId, response, pendingRequest });
}
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
});
if (pending.kind === "command") {
pending.resolve({ decision: resolvePermissionDecision(response) });
return;
}
if (pending.kind === "file") {
pending.resolve({ decision: resolvePermissionDecision(response) });
return;
}
if (pending.kind === "mcp_elicitation") {
pending.resolve({
action: resolvePermissionDecision(response),
content: response.behavior === "allow" ? {} : null,
_meta: null,
});
return;
}
const questions = pending.questions ?? [];
const itemId =
typeof pendingRequest?.metadata?.itemId === "string"
? pendingRequest.metadata.itemId
: requestId;
if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponseByHeader({
questions,
response,
});
const answers =
mappedAnswers ??
Object.fromEntries(
questions
.map((question) => {
const fallback = question.options[0]?.label?.trim();
return fallback ? [question.id, { answers: [fallback] }] : null;
})
.filter((entry): entry is [string, { answers: string[] }] => entry !== null),
);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexQuestionRequestToToolCall({
callId: itemId,
questions,
status: "completed",
answers: Object.fromEntries(
Object.entries(answers).map(([id, value]) => [id, value.answers]),
),
}),
});
pending.resolve({ answers });
return;
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexQuestionRequestToToolCall({
callId: itemId,
questions,
status: response.interrupt ? "canceled" : "failed",
error: { message: response.message ?? "Question dismissed" },
}),
});
pending.resolve({ answers: {} });
}
private handlePlanPermissionResponse(params: {
requestId: string;
response: AgentPermissionResponse;
pending: CodexPendingPermissionHandler;
pendingRequest: AgentPermissionRequest | null;
}): AgentPermissionResult | void {
const { requestId, response, pending, pendingRequest } = params;
let followUpPrompt: string | undefined;
if (response.behavior === "allow") {
followUpPrompt = this.preparePlanImplementation({
planText: pending.planText ?? pendingRequest?.metadata?.planText,
});
}
this.resolvePlanPermission(requestId, response);
if (followUpPrompt) {
return { followUpPrompt };
}
}
private dismissPendingPlanApprovals(message: string): void {
const requestIds = Array.from(this.pendingPermissionHandlers)
.filter(([, pending]) => pending.kind === "plan")
.map(([requestId]) => requestId);
for (const requestId of requestIds) {
this.resolvePlanPermission(requestId, { behavior: "deny", message });
}
}
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution,
});
}
private emitDeniedToolCallTimelineEvent(params: {
requestId: string;
response: Extract<AgentPermissionResponse, { behavior: "deny" }>;
pendingRequest: AgentPermissionRequest;
}): void {
const { requestId, response, pendingRequest } = params;
let fallbackName: string;
if (pendingRequest.name === "CodexBash") {
fallbackName = "shell";
} else if (pendingRequest.name === "CodexFileChange") {
fallbackName = "apply_patch";
} else {
fallbackName = pendingRequest.name;
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "tool_call",
callId: requestId,
name: fallbackName,
status: "failed",
error: { message: response.message ?? "Permission denied" },
detail: pendingRequest.detail ?? {
type: "unknown",
input: pendingRequest.input ?? null,
output: null,
},
metadata: {
permissionRequestId: requestId,
denied: true,
},
},
});
}
describePersistence(): {
provider: typeof CODEX_PROVIDER;
sessionId: string;
nativeHandle: string;
metadata: Record<string, unknown>;
} | null {
if (!this.currentThreadId) return null;
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null;
return {
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
nativeHandle: this.currentThreadId,
metadata: {
provider: CODEX_PROVIDER,
cwd: this.config.cwd,
title: this.config.title ?? null,
threadId: this.currentThreadId,
modeId: this.config.modeId,
model: this.config.model ?? null,
thinkingOptionId,
providerOptions: this.config.providerOptions,
toolPolicy: this.config.toolPolicy,
systemPrompt: this.config.systemPrompt,
mcpServers: this.config.mcpServers,
},
};
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.connect();
if (!this.client) {
throw new Error("Codex client is not initialized");
}
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
await revertCodexConversation({
client: this.client,
threadId: this.currentThreadId,
messageId: input.messageId,
cwd: this.config.cwd ?? null,
model: this.config.model ?? null,
serviceTier: this.serviceTier,
userMessageTurns: this.codexUserMessageTurns(),
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
this.cachedRuntimeInfo = null;
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory();
},
});
}
async interrupt(): Promise<void> {
if (!this.client || !this.currentThreadId) {
throw new Error("Cannot interrupt Codex before the active thread is initialized");
}
let turnId = this.currentTurnId;
const foregroundTurnId = this.activeForegroundTurnId;
const pendingIdentification = this.pendingForegroundTurnIdentification;
// turn/start is accepted before Codex publishes the native turn id. Keep the
// interrupt attached to this foreground turn until that ordered notification arrives.
if (
!turnId &&
foregroundTurnId &&
pendingIdentification?.foregroundTurnId === foregroundTurnId
) {
turnId = await pendingIdentification.promise;
}
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
this.pendingSubAgentNotificationsByThreadId.clear();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
this.pendingForegroundTurnIdentification = null;
await this.disposeClient();
this.currentThreadId = null;
}
private clearPendingPermissions(options?: { preservePlanApprovals?: boolean }): void {
for (const [requestId, pending] of this.pendingPermissionHandlers) {
if (options?.preservePlanApprovals && pending.kind === "plan") {
continue;
}
pending.resolve({ decision: "cancel" });
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
}
this.mcpElicitationPermissionIds.clear();
this.resolvedPermissionRequests.clear();
}
private async disposeClient(): Promise<void> {
const client = this.client;
this.client = null;
this.connected = false;
this.currentTurnId = null;
if (client) {
await client.dispose();
}
}
async listCommands(): Promise<AgentSlashCommand[]> {
const prompts = await listCodexCustomPrompts();
if (!this.connected) {
await this.connect();
} else {
await this.loadSkills();
}
const appServerSkills = (this.cachedSkills ?? []).map((skill) => ({
name: skill.name,
description: skill.description,
argumentHint: "",
kind: "skill" as const,
}));
const fallbackSkills =
this.cachedSkills === null
? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService)
: [];
const builtin: AgentSlashCommand[] = [
{
name: "compact",
description: "Summarize conversation to prevent hitting the context limit",
argumentHint: "",
kind: "command",
},
];
if (this.goalsEnabled) {
builtin.push({
name: "goal",
description: "Set, pause, resume, or clear the agent's goal",
argumentHint: "[<objective>|pause|resume|clear]",
kind: "command",
});
}
return [...builtin, ...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
a.name.localeCompare(b.name),
);
}
tryHandleOutOfBand(
prompt: AgentPromptInput,
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
if (typeof prompt !== "string") return null;
const parsed = this.parseSlashCommandInput(prompt);
if (!parsed) return null;
if (parsed.commandName === "compact") {
return {
run: async ({ emit }) => {
const error = await this.executeCompactCommand();
if (error) {
emit({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "assistant_message", text: formatOutOfBandStatusMessage(error) },
});
}
},
};
}
if (!this.goalsEnabled || parsed.commandName !== "goal") return null;
const subcommand = parseGoalSubcommand(parsed.args);
return {
run: async ({ emit }) => {
const text = formatOutOfBandStatusMessage(await this.executeGoalSubcommand(subcommand));
emit({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "assistant_message", text },
});
},
};
}
private async executeCompactCommand(): Promise<string | null> {
try {
await this.connect();
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
if (!this.client || !this.currentThreadId) {
throw new Error("Codex thread is not available");
}
this.pendingManualCompactionStarts += 1;
try {
await this.client.request("thread/compact/start", {
threadId: this.currentThreadId,
});
} catch (error) {
this.pendingManualCompactionStarts = Math.max(0, this.pendingManualCompactionStarts - 1);
throw error;
}
return null;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
return `Failed to compact context: ${message}`;
}
}
private async executeGoalSubcommand(subcommand: GoalSubcommand): Promise<string> {
if (subcommand.kind === "usage") {
return "Usage: /goal <objective>|pause|resume|clear";
}
try {
await this.connect();
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
if (!this.client || !this.currentThreadId) {
throw new Error("Codex thread is not available");
}
switch (subcommand.kind) {
case "set": {
await this.client.request("thread/goal/set", {
threadId: this.currentThreadId,
objective: subcommand.objective,
status: "active",
});
return `Goal set: ${subcommand.objective}`;
}
case "pause": {
await this.client.request("thread/goal/set", {
threadId: this.currentThreadId,
status: "paused",
});
return "Goal paused.";
}
case "resume": {
await this.client.request("thread/goal/set", {
threadId: this.currentThreadId,
status: "active",
});
return "Goal resumed.";
}
case "clear": {
await this.client.request("thread/goal/clear", {
threadId: this.currentThreadId,
});
return "Goal cleared.";
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
return `Failed to update goal: ${message}`;
}
}
private async resolveModelAndThinking(): Promise<{
model: string;
thinkingOptionId: string | undefined;
}> {
if (!this.client) {
throw new Error("Codex client is not initialized");
}
let configuredDefaults: CodexConfiguredDefaults = {};
let model = this.config.model;
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (!model || !thinkingOptionId) {
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
}
if (!model) {
model = configuredDefaults.model;
}
if (!thinkingOptionId) {
thinkingOptionId = configuredDefaults.thinkingOptionId;
}
if (!model || !thinkingOptionId) {
const modelResponse = toObjectRecord(await this.client.request("model/list", {}));
const modelData = Array.isArray(modelResponse?.data) ? modelResponse.data : [];
const models = modelData
.map((m) => {
const record = toObjectRecord(m);
return {
id: typeof record?.id === "string" ? record.id : "",
isDefault: !!record?.isDefault,
defaultReasoningEffort:
typeof record?.defaultReasoningEffort === "string"
? record.defaultReasoningEffort
: undefined,
};
})
.filter((m) => m.id);
const defaultModel = models.find((m) => m.isDefault) ?? models[0];
if (!defaultModel) {
throw new Error("No models available from Codex app-server");
}
const selectedModel =
(model ? models.find((candidate) => candidate.id === model) : undefined) ?? defaultModel;
if (!model) {
model = selectedModel.id;
}
if (!thinkingOptionId) {
thinkingOptionId = normalizeCodexThinkingOptionId(selectedModel.defaultReasoningEffort);
}
}
if (!model) {
throw new Error("Unable to resolve Codex model");
}
return { model, thinkingOptionId };
}
private async ensureThread(): Promise<void> {
if (!this.client) return;
if (this.currentThreadId) return;
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.config.thinkingOptionId = thinkingOptionId;
const { params, approvalPolicy, sandbox } = this.buildThreadStartRequest(model);
const rawResponse = await this.client.request("thread/start", params);
this.rememberResolvedSandboxPolicy(rawResponse);
const response = toObjectRecord(rawResponse);
const threadRecord = toObjectRecord(response?.thread);
const threadId = typeof threadRecord?.id === "string" ? threadRecord.id : undefined;
if (!threadId) {
throw new Error("Codex app-server did not return thread id");
}
const responseApprovalsReviewer =
typeof response?.approvalsReviewer === "string" ? response.approvalsReviewer : undefined;
if (
shouldPromoteThreadResponseToAutoReview({
approvalsReviewer: responseApprovalsReviewer,
approvalPolicy: approvalPolicy ?? String(this.providerOptions.approval_policy ?? ""),
sandbox: sandbox ?? this.providerOptions.sandbox_mode ?? "",
})
) {
this.currentMode = "auto-review";
this.cachedRuntimeInfo = null;
}
this.currentThreadId = threadId;
}
private buildThreadStartRequest(model: string): {
params: Record<string, unknown>;
approvalPolicy?: string;
sandbox?: string;
} {
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = this.hasWorkflowModeOverride ? preset.approvalPolicy : undefined;
const sandbox = this.hasWorkflowModeOverride ? preset.sandbox : undefined;
const innerConfig = this.buildCodexInnerConfig();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const params: Record<string, unknown> = {
model,
cwd: this.config.cwd ?? null,
...(approvalPolicy && this.providerOptions.approval_policy === undefined
? { approvalPolicy }
: {}),
...(sandbox && this.providerOptions.sandbox_mode === undefined ? { sandbox } : {}),
...(developerInstructions ? { developerInstructions } : {}),
...(innerConfig ? { config: innerConfig } : {}),
...(this.ephemeral ? { ephemeral: true } : {}),
};
if (this.hasWorkflowModeOverride) {
applyApprovalsReviewerParam(params, preset);
}
return { params, approvalPolicy, sandbox };
}
private buildCodexInnerConfig(): Record<string, unknown> | null {
const innerConfig: Record<string, unknown> = {};
Object.assign(innerConfig, this.providerOptions);
if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig);
}
if (this.config.mcpServers) {
const mcpServers: Record<string, CodexMcpServerConfig> = {};
for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) {
mcpServers[name] = toCodexMcpConfig(serverConfig);
}
innerConfig.mcp_servers = mcpServers;
}
const configured = applyCodexToolPolicy(innerConfig, this.config.toolPolicy);
return Object.keys(configured).length > 0 ? configured : null;
}
private async buildUserInput(prompt: CodexPromptInput): Promise<CodexAppServerUserInput[]> {
if (typeof prompt === "string") {
return [toCodexTextInput(prompt)];
}
return await codexAppServerTurnInputFromPrompt(prompt, this.logger);
}
private emitEvent(event: AgentStreamEvent): void {
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
this.persistedProviderSubagentEvents.push(event);
return;
}
this.notifySubscribers(event);
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: getAgentStreamEventTurnId(tagged),
event: tagged,
},
"provider.codex.event_emit",
);
for (const callback of this.subscribers) {
try {
callback(tagged);
} catch (error) {
this.logger.warn({ err: error }, "Subscriber callback threw");
}
}
}
private createTurnId(): string {
return `codex-turn-${this.nextTurnOrdinal++}`;
}
private handleNotification(method: string, params: unknown): void {
const notificationParams = toObjectRecord(params);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
if (requestId) {
const pending = this.pendingPermissionHandlers.get(requestId);
this.mcpElicitationPermissionIds.delete(notificationParams.requestId);
if (!pending) {
return;
}
this.pendingPermissions.delete(requestId);
this.pendingPermissionHandlers.delete(requestId);
pending.resolve({ action: "cancel", content: null, _meta: null });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: { behavior: "deny", interrupt: true },
});
}
return;
}
const parsed = CodexNotificationSchema.parse({ method, params });
this.traceParsedNotification(method, params, parsed);
const route = this.resolveCodexThreadRoute(getCodexNotificationThreadId(parsed));
if (route.kind === "pending_sub_agent") {
this.bufferPendingSubAgentNotification(route.threadId, parsed);
return;
}
if (route.kind === "sub_agent") {
this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
this.dispatchParsedNotification(parsed);
}
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running");
return;
case "turn_started":
case "turn_completed":
case "agent_message_delta":
case "reasoning_delta":
case "item_started":
case "item_completed":
this.dispatchParsedNotification(parsed);
return;
case "exec_command_output_delta":
case "file_change_output_delta":
this.handleCodexDeltaNotification(parsed, callId);
return;
case "exec_command_started":
this.handleExecCommandStartedNotification(parsed, callId);
return;
case "exec_command_completed":
this.handleExecCommandCompletedNotification(parsed, callId);
return;
case "patch_apply_started":
this.handlePatchApplyStartedNotification(parsed, callId);
return;
case "patch_apply_completed":
this.handlePatchApplyCompletedNotification(parsed, callId);
return;
default:
// Aggregate child telemetry is redundant and must not leak into the
// root timeline. Concrete legacy tools are projected above for Codex
// versions that do not also emit canonical item lifecycle events.
return;
}
}
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
}
if (this.handleThreadStateNotification(parsed)) {
return;
}
switch (parsed.kind) {
case "thread_started":
this.handleThreadStartedNotification(parsed);
return;
case "turn_started":
this.handleTurnStartedNotification(parsed);
return;
case "turn_completed":
this.handleTurnCompletedNotification(parsed);
return;
case "plan_updated":
this.handlePlanUpdatedNotification(parsed);
return;
case "diff_updated":
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// containing a full accumulated unified diff for the *entire turn*.
// This is not a concrete file-change tool call; it is progress telemetry.
return;
case "token_usage_updated":
this.handleTokenUsageUpdatedNotification(parsed);
return;
case "exec_command_started":
this.handleExecCommandStartedNotification(parsed);
return;
case "exec_command_completed":
this.handleExecCommandCompletedNotification(parsed);
return;
case "terminal_interaction":
this.handleTerminalInteractionNotification(parsed);
return;
case "patch_apply_started":
this.handlePatchApplyStartedNotification(parsed);
return;
case "patch_apply_completed":
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
return;
case "unknown_method":
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
return;
default:
return;
}
}
private resolveCodexThreadRoute(threadId: string | null): CodexThreadRoute {
if (!threadId || !this.currentThreadId || threadId === this.currentThreadId) {
return { kind: "root" };
}
const callId = this.subAgentCallIdByChildThreadId.get(threadId);
return callId ? { kind: "sub_agent", callId } : { kind: "pending_sub_agent", threadId };
}
private bufferPendingSubAgentNotification(
threadId: string,
parsed: ParsedCodexNotification,
): void {
let pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
if (this.pendingSubAgentNotificationsByThreadId.size >= MAX_PENDING_SUB_AGENT_THREADS) {
const oldestThreadId = this.pendingSubAgentNotificationsByThreadId.keys().next().value;
if (typeof oldestThreadId === "string") {
this.pendingSubAgentNotificationsByThreadId.delete(oldestThreadId);
}
}
pending = [];
this.pendingSubAgentNotificationsByThreadId.set(threadId, pending);
}
if (pending.length >= MAX_PENDING_SUB_AGENT_NOTIFICATIONS_PER_THREAD) {
pending.shift();
}
pending.push(parsed);
}
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
continue;
}
this.pendingSubAgentNotificationsByThreadId.delete(threadId);
const callId = this.subAgentCallIdByChildThreadId.get(threadId);
if (!callId) {
continue;
}
for (const parsed of pending) {
this.dispatchSubAgentNotification(parsed, callId);
}
}
}
private handleThreadStateNotification(parsed: ParsedCodexNotification): boolean {
switch (parsed.kind) {
case "context_compacted":
this.handleContextCompactedNotification(parsed);
return true;
case "thread_rolled_back":
this.handleThreadRolledBackNotification(parsed);
return true;
default:
return false;
}
}
private traceParsedNotification(
method: string,
params: unknown,
parsed: z.infer<typeof CodexNotificationSchema>,
): void {
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
parsed,
},
"provider.codex.parsed_event",
);
}
private getSubAgentCallIdForThread(threadId: string | null | undefined): string | null {
if (!threadId || threadId === this.currentThreadId) {
return null;
}
return this.subAgentCallIdByChildThreadId.get(threadId) ?? null;
}
private registerSubAgentToolCall(params: {
timelineItem: ToolCallTimelineItem;
rawItem: { [key: string]: unknown };
parentCallId: string | null;
}): string[] {
const { timelineItem, rawItem, parentCallId } = params;
if (timelineItem.detail.type !== "sub_agent") {
return [];
}
const existing = this.subAgentCallsByCallId.get(timelineItem.callId);
const state: CodexSubAgentCallState =
existing ??
({
callId: timelineItem.callId,
toolCall: timelineItem,
parentCallId,
activityItemIds: new Set<string>(),
pendingCommandOutputDeltas: new Map<string, string[]>(),
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} satisfies CodexSubAgentCallState);
state.toolCall = {
...timelineItem,
detail: {
...timelineItem.detail,
log:
timelineItem.detail.log ||
(state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
},
};
state.parentCallId ??= parentCallId;
const activity = readCodexSubAgentActivity(rawItem);
if (activity?.id) {
state.activityItemIds.add(activity.id);
}
this.subAgentCallsByCallId.set(timelineItem.callId, state);
const receiverThreadIds = Array.isArray(rawItem.receiverThreadIds)
? rawItem.receiverThreadIds.filter((value): value is string => typeof value === "string")
: [];
const agentThreadId =
typeof rawItem.agentThreadId === "string" && rawItem.agentThreadId.length > 0
? rawItem.agentThreadId
: null;
const childThreadIds = Array.from(
new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds),
).filter((threadId) => threadId !== this.currentThreadId);
for (const receiverThreadId of childThreadIds) {
this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId);
state.childThreadIds.add(receiverThreadId);
this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status);
}
return childThreadIds;
}
private handleRegisteredSubAgentActivity(rawItem: { [key: string]: unknown }): boolean {
const activity = readCodexSubAgentActivity(rawItem);
if (!activity) {
return false;
}
const callId = this.subAgentCallIdByChildThreadId.get(activity.agentThreadId);
if (!callId) {
return false;
}
const state = this.subAgentCallsByCallId.get(callId);
if (!state) {
return false;
}
if (activity.id && state.activityItemIds.has(activity.id)) {
return true;
}
if (activity.id) {
state.activityItemIds.add(activity.id);
}
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
cwd: this.config.cwd ?? null,
});
if (
activityToolCall?.detail.type === "sub_agent" &&
state.toolCall.detail.type === "sub_agent"
) {
state.toolCall = {
...state.toolCall,
detail: {
...state.toolCall.detail,
subAgentType: activityToolCall.detail.subAgentType,
},
};
}
this.emitSubAgentActivityUpdate(
callId,
activity.kind === "interrupted" ? "canceled" : "running",
);
return true;
}
private handleCompletedContextCompactionItem(item: {
id?: string;
type?: string;
[key: string]: unknown;
}): boolean {
if (!this.isContextCompactionItem(item)) {
return false;
}
if (this.unpairedCompactionNotificationCompletions > 0) {
this.unpairedCompactionNotificationCompletions -= 1;
return true;
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed", item.id),
});
this.unpairedCompactionItemCompletions += 1;
return true;
}
private handleCompletedSpecialItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
childSubAgentCallId: string | null,
): boolean {
if (
childSubAgentCallId &&
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
) {
return true;
}
return (
this.handleCompletedContextCompactionItem(parsed.item) ||
this.handleRegisteredSubAgentActivity(parsed.item)
);
}
private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state) {
return;
}
if (!state.childItems.has(itemId)) {
state.childItemOrder.push(itemId);
}
state.childItems.set(itemId, item);
}
private emitCodexToolTimelineItem(
timelineItem: ToolCallTimelineItem,
subAgentCallId: string | null,
childThreadId?: string | null,
): void {
if (!subAgentCallId) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
const state = this.subAgentCallsByCallId.get(subAgentCallId);
if (state) {
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
for (const targetThreadId of targetThreadIds) {
this.emitProviderSubagentTimeline(targetThreadId, timelineItem);
}
}
this.emitSubAgentActivityUpdate(
subAgentCallId,
timelineItem.status === "running" ? "running" : undefined,
);
}
private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] {
return state.childItemOrder
.map((itemId) => state.childItems.get(itemId))
.filter((item): item is AgentTimelineItem => Boolean(item));
}
private emitSubAgentActivityUpdate(
callId: string,
status?: ToolCallTimelineItem["status"],
): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") {
return;
}
const childTimeline = this.getSubAgentChildTimeline(state);
const log =
childTimeline.length > 0
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: "";
const resolvedStatus = status ?? state.toolCall.status;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
const baseToolCall = {
...state.toolCall,
detail: {
...state.toolCall.detail,
log,
},
};
const nextToolCall: ToolCallTimelineItem =
resolvedStatus === "failed"
? {
...baseToolCall,
status: "failed",
error: state.toolCall.error ?? { message: "Sub-agent failed" },
}
: {
...baseToolCall,
status: resolvedStatus,
error: null,
};
state.toolCall = nextToolCall;
if (state.parentCallId && state.parentCallId !== callId) {
this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
this.emitSubAgentActivityUpdate(state.parentCallId);
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
}
private emitProviderSubagentUpsert(
childThreadId: string,
state: CodexSubAgentCallState,
status: ToolCallTimelineItem["status"],
): void {
const detail = state.toolCall.detail;
if (detail.type !== "sub_agent") {
return;
}
let providerStatus: "running" | "completed" | "failed" | "canceled" = "running";
if (status === "completed") {
providerStatus = "completed";
} else if (status === "failed") {
providerStatus = "failed";
} else if (status === "canceled") {
providerStatus = "canceled";
}
this.emitEvent({
type: "provider_subagent",
provider: CODEX_PROVIDER,
event: {
type: "upsert",
id: childThreadId,
title: detail.subAgentType ?? "Codex subagent",
description: detail.description ?? null,
status: providerStatus,
toolCallId: state.callId,
},
});
}
private emitProviderSubagentTimeline(
childThreadId: string,
item: AgentTimelineItem,
timestamp?: string,
): void {
this.emitEvent({
type: "provider_subagent",
provider: CODEX_PROVIDER,
event: {
type: "timeline",
id: childThreadId,
item,
...(timestamp ? { timestamp } : {}),
},
});
}
private emitCompletedProviderSubagentItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
timelineItem: AgentTimelineItem,
): void {
const itemId = parsed.item.id;
if (!parsed.threadId) return;
if (timelineItem.type === "assistant_message" && itemId) {
const streamedText = this.pendingAgentMessages.get(itemId);
if (streamedText !== undefined) {
const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix);
return;
}
}
if (timelineItem.type === "reasoning" && itemId) {
const streamedText = this.pendingReasoning.get(itemId)?.join("");
if (streamedText !== undefined) {
const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix);
return;
}
}
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
}
private emitStartedProviderSubagentItem(
threadId: string | null,
timelineItem: AgentTimelineItem,
): void {
if (threadId) {
this.emitProviderSubagentTimeline(threadId, timelineItem);
}
}
private emitProviderSubagentTimelineItems(
threadId: string | null,
timelineItems: readonly AgentTimelineItem[],
): void {
if (!threadId) {
return;
}
for (const timelineItem of timelineItems) {
this.emitProviderSubagentTimeline(threadId, timelineItem);
}
}
private handleSubAgentChildItemCompleted(
callId: string,
itemId: string | undefined,
timelineItem: AgentTimelineItem,
): void {
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
if (itemId) {
this.upsertSubAgentChildItem(callId, itemId, timelineItem);
this.pendingAgentMessages.delete(itemId);
this.pendingReasoning.delete(itemId);
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(callId, "running");
}
private handleSubAgentContextCompactionItem(
callId: string,
item: { id?: string; type?: string; [key: string]: unknown },
status: "loading" | "completed",
): boolean {
if (!this.isContextCompactionItem(item)) {
return false;
}
if (item.id) {
this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
}
this.emitSubAgentActivityUpdate(callId);
return true;
}
private shouldSkipCompletedThreadItem(
timelineItem: AgentTimelineItem,
normalizedItemType: string | undefined,
itemId: string | undefined,
): boolean {
// For commandExecution items, codex/event/exec_command_* is authoritative.
if (timelineItem.type === "tool_call" && normalizedItemType === "commandExecution") {
const callId = timelineItem.callId || itemId;
return Boolean(callId && this.emittedExecCommandCompletedCallIds.has(callId));
}
return Boolean(itemId && this.emittedItemCompletedIds.has(itemId));
}
private handleCodexDeltaNotification(
parsed: CodexDeltaNotification,
routedSubAgentCallId: string | null = null,
): void {
if (parsed.kind === "agent_message_delta") {
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
const text = prev + parsed.delta;
this.pendingAgentMessages.set(parsed.itemId, text);
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, {
type: "assistant_message",
messageId: parsed.itemId,
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "assistant_message",
messageId: parsed.itemId,
text,
});
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
const isFirstDeltaForItem = prev.length === 0;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "assistant_message",
messageId: parsed.itemId,
text:
isFirstDeltaForItem && this.pendingAssistantMessageBoundary
? `${ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN}${parsed.delta}`
: parsed.delta,
},
});
if (isFirstDeltaForItem) {
this.pendingAssistantMessageBoundary = false;
}
return;
}
if (parsed.kind === "reasoning_delta") {
const prev = this.pendingReasoning.get(parsed.itemId) ?? [];
prev.push(parsed.delta);
this.pendingReasoning.set(parsed.itemId, prev);
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, {
type: "reasoning",
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "reasoning",
text: prev.join(""),
});
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "reasoning", text: parsed.delta },
});
return;
}
if (parsed.kind === "exec_command_output_delta") {
const outputDeltas = routedSubAgentCallId
? this.subAgentCallsByCallId.get(routedSubAgentCallId)?.pendingCommandOutputDeltas
: this.pendingCommandOutputDeltas;
if (!outputDeltas) {
return;
}
this.appendOutputDeltaChunk(outputDeltas, parsed.callId, parsed.chunk, {
decodeBase64: true,
});
return;
}
const outputDeltas = routedSubAgentCallId
? this.subAgentCallsByCallId.get(routedSubAgentCallId)?.pendingFileChangeOutputDeltas
: this.pendingFileChangeOutputDeltas;
if (outputDeltas) {
this.appendOutputDeltaChunk(outputDeltas, parsed.itemId, parsed.delta);
}
}
private handleThreadStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "thread_started" }>,
): void {
this.currentThreadId = parsed.threadId;
this.emitEvent({
type: "thread_started",
provider: CODEX_PROVIDER,
sessionId: parsed.threadId,
});
}
private handleTurnStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_started" }>,
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
this.currentTurnId = parsed.turnId;
const pendingIdentification = this.pendingForegroundTurnIdentification;
if (
pendingIdentification &&
pendingIdentification.foregroundTurnId === this.activeForegroundTurnId
) {
pendingIdentification.resolve(parsed.turnId);
this.pendingForegroundTurnIdentification = null;
}
this.resetTurnTrackingState();
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
}
private handleTurnCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
let status: ToolCallTimelineItem["status"] = "completed";
if (parsed.status === "failed") {
status = "failed";
} else if (parsed.status === "interrupted") {
status = "canceled";
}
this.emitSubAgentActivityUpdate(subAgentCallId, status);
return;
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: parsed.errorMessage ?? "Codex turn failed",
});
} else if (parsed.status === "interrupted") {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
}
this.emitEvent({
type: "turn_completed",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.currentTurnId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
this.pendingForegroundTurnIdentification = null;
this.pendingSubAgentNotificationsByThreadId.clear();
this.resetTurnTrackingState();
}
private resetTurnTrackingState(): void {
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedProviderSubagentUserMessageKeys.clear();
this.emittedExecCommandStartedCallIds.clear();
this.emittedExecCommandCompletedCallIds.clear();
this.pendingAgentMessages.clear();
this.pendingReasoning.clear();
this.pendingCommandOutputDeltas.clear();
this.pendingFileChangeOutputDeltas.clear();
this.pendingAssistantMessageBoundary = false;
this.warnedIncompleteEditToolCallIds.clear();
this.unpairedCompactionNotificationCompletions = 0;
this.unpairedCompactionItemCompletions = 0;
}
private handlePlanUpdatedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "plan_updated" }>,
): void {
if (!this.planModeEnabled) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexPlanUpdateToTodo(parsed.plan),
});
return;
}
const timelineItem = mapCodexPlanToToolCall({
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
text: planStepsToMarkdown(
parsed.plan.map((entry) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
})),
),
});
if (timelineItem) {
this.rememberPlanResult(timelineItem);
// Older Codex app-server builds reported Plan-mode proposals through
// turn/plan/updated. Retain that compatibility path only while Plan mode is active.
return;
}
}
private handleTokenUsageUpdatedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "token_usage_updated" }>,
): void {
this.latestUsage = toAgentUsage(parsed.tokenUsage);
if (this.latestUsage) {
this.notifySubscribers({
type: "usage_updated",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
}
private resolveContextCompactionTrigger(itemId?: string): "auto" | "manual" | undefined {
if (itemId) {
const known = this.compactionTriggerByItemId.get(itemId);
if (known) {
return known;
}
}
if (this.pendingManualCompactionStarts > 0) {
this.pendingManualCompactionStarts -= 1;
return "manual";
}
return undefined;
}
private createContextCompactionTimelineItem(
status: "loading" | "completed",
itemId?: string,
): Extract<AgentTimelineItem, { type: "compaction" }> {
const trigger = this.resolveContextCompactionTrigger(itemId);
if (itemId && trigger) {
if (status === "loading") {
this.compactionTriggerByItemId.set(itemId, trigger);
} else {
this.compactionTriggerByItemId.delete(itemId);
}
}
return {
type: "compaction",
status,
...(trigger ? { trigger } : {}),
};
}
private isContextCompactionItem(item: { type?: string; [key: string]: unknown }): boolean {
return (
normalizeCodexThreadItemType(typeof item.type === "string" ? item.type : undefined) ===
CODEX_CONTEXT_COMPACTION_TYPE
);
}
private isUserMessageItem(item: { type?: string; [key: string]: unknown }): boolean {
return (
normalizeCodexThreadItemType(typeof item.type === "string" ? item.type : undefined) ===
"userMessage"
);
}
private handleThreadRolledBackNotification(
parsed: Extract<ParsedCodexNotification, { kind: "thread_rolled_back" }>,
): void {
this.truncateCodexUserMessageTurns(parsed.numTurns);
}
private handleContextCompactedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "context_compacted" }>,
): void {
if (parsed.threadId !== this.currentThreadId) {
return;
}
if (this.unpairedCompactionItemCompletions > 0) {
this.unpairedCompactionItemCompletions -= 1;
return;
}
this.unpairedCompactionNotificationCompletions += 1;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed"),
...(parsed.turnId ? { turnId: parsed.turnId } : {}),
});
}
private handleExecCommandStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "exec_command_started" }>,
subAgentCallId: string | null = null,
): void {
const outputDeltas = subAgentCallId
? this.subAgentCallsByCallId.get(subAgentCallId)?.pendingCommandOutputDeltas
: this.pendingCommandOutputDeltas;
if (!outputDeltas) {
return;
}
if (parsed.callId && !subAgentCallId) {
this.emittedExecCommandStartedCallIds.add(parsed.callId);
}
if (parsed.callId) {
outputDeltas.delete(parsed.callId);
}
const timelineItem = mapCodexExecNotificationToToolCall({
callId: parsed.callId,
command: parsed.command,
cwd: parsed.cwd ?? this.config.cwd ?? null,
running: true,
});
if (timelineItem) {
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
private handleExecCommandCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "exec_command_completed" }>,
subAgentCallId: string | null = null,
): void {
const outputDeltas = subAgentCallId
? this.subAgentCallsByCallId.get(subAgentCallId)?.pendingCommandOutputDeltas
: this.pendingCommandOutputDeltas;
if (!outputDeltas) {
return;
}
const bufferedOutput = this.consumeOutputDelta(outputDeltas, parsed.callId);
const resolvedOutput = parsed.output ?? bufferedOutput;
if (!subAgentCallId) {
this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput);
}
const timelineItem = mapCodexExecNotificationToToolCall({
callId: parsed.callId,
command: parsed.command,
cwd: parsed.cwd ?? this.config.cwd ?? null,
output: resolvedOutput,
exitCode: parsed.exitCode,
success: parsed.success,
stderr: parsed.stderr,
running: false,
});
if (timelineItem) {
if (!subAgentCallId) {
this.emittedExecCommandCompletedCallIds.add(timelineItem.callId);
}
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
private handleTerminalInteractionNotification(
parsed: Extract<ParsedCodexNotification, { kind: "terminal_interaction" }>,
): void {
const interactionKey = [parsed.processId ?? "", parsed.stdin ?? ""].join("\u0000");
if (!this.shouldEmitTerminalInteractionKey(interactionKey)) {
return;
}
const command =
(parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
null;
const callId = this.createTerminalInteractionCallId(parsed.processId, parsed.callId);
if (!command && parsed.processId) {
const pendingInteractions =
this.pendingUnlabeledTerminalInteractions.get(parsed.processId) ?? [];
pendingInteractions.push({ callId, stdin: parsed.stdin });
this.pendingUnlabeledTerminalInteractions.set(parsed.processId, pendingInteractions);
}
const timelineItem = mapCodexTerminalInteractionToToolCall({
callId,
processId: parsed.processId,
command,
stdin: parsed.stdin,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
private handlePatchApplyStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "patch_apply_started" }>,
subAgentCallId: string | null = null,
): void {
const outputDeltas = subAgentCallId
? this.subAgentCallsByCallId.get(subAgentCallId)?.pendingFileChangeOutputDeltas
: this.pendingFileChangeOutputDeltas;
if (!outputDeltas) {
return;
}
if (parsed.callId) {
outputDeltas.delete(parsed.callId);
}
const timelineItem = mapCodexPatchNotificationToToolCall({
callId: parsed.callId,
changes: parsed.changes,
cwd: this.config.cwd ?? null,
running: true,
});
if (timelineItem) {
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
callId: parsed.callId,
changes: parsed.changes,
});
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
private handlePatchApplyCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "patch_apply_completed" }>,
subAgentCallId: string | null = null,
): void {
const outputDeltas = subAgentCallId
? this.subAgentCallsByCallId.get(subAgentCallId)?.pendingFileChangeOutputDeltas
: this.pendingFileChangeOutputDeltas;
if (!outputDeltas) {
return;
}
const bufferedOutput = this.consumeOutputDelta(outputDeltas, parsed.callId);
const timelineItem = mapCodexPatchNotificationToToolCall({
callId: parsed.callId,
changes: parsed.changes,
cwd: this.config.cwd ?? null,
stdout: parsed.stdout ?? bufferedOutput,
stderr: parsed.stderr,
success: parsed.success,
running: false,
});
if (timelineItem) {
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
callId: parsed.callId,
changes: parsed.changes,
stdout: parsed.stdout,
});
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
private handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): void {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
// the provider-subagent route.
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem) {
return;
}
const registeredChildThreadIds =
timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
parentCallId: childSubAgentCallId,
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
);
const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true;
}
if (itemId) {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
if (timelineItem.type === "tool_call") {
if (timelineItem.detail.type === "plan") {
this.rememberPlanResult(timelineItem);
// Codex can surface plans both as turn/plan updates and as completed
// thread items. In plan mode, approval owns the visible plan card.
if (this.planModeEnabled) {
return;
}
}
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true;
}
for (const imageItem of imageItems) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: imageItem });
this.pendingAssistantMessageBoundary = true;
}
if (itemId) {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
timelineItem: AgentTimelineItem,
itemId: string | null | undefined,
): boolean {
if (!itemId) {
return false;
}
if (timelineItem.type === "assistant_message" && this.pendingAgentMessages.has(itemId)) {
const streamedText = this.pendingAgentMessages.get(itemId) ?? "";
this.pendingAgentMessages.delete(itemId);
this.emitMissingFinalTextSuffix(timelineItem, streamedText);
return true;
}
if (timelineItem.type === "reasoning" && this.pendingReasoning.has(itemId)) {
const streamedText = this.pendingReasoning.get(itemId)?.join("") ?? "";
this.pendingReasoning.delete(itemId);
this.emitMissingFinalTextSuffix(timelineItem, streamedText);
return true;
}
return false;
}
private emitMissingFinalTextSuffix(
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
streamedText: string,
): void {
const item = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
if (item) this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
}
private buildMissingFinalTextSuffix(
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
streamedText: string,
): AgentTimelineItem | null {
if (!timelineItem.text.startsWith(streamedText)) return timelineItem;
const suffix = timelineItem.text.slice(streamedText.length);
if (!suffix) return null;
return timelineItem.type === "assistant_message"
? {
type: timelineItem.type,
text: suffix,
...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
}
: { type: timelineItem.type, text: suffix };
}
private applyBufferedDeltaTextToTimelineItem(
timelineItem: AgentTimelineItem,
itemId: string | null | undefined,
): void {
if (!itemId) {
return;
}
if (timelineItem.type === "assistant_message") {
const buffered = this.pendingAgentMessages.get(itemId);
if (buffered && buffered.length > 0) {
if (!timelineItem.text.startsWith(buffered)) timelineItem.text = buffered;
}
return;
}
if (timelineItem.type === "reasoning") {
const buffered = this.pendingReasoning.get(itemId);
if (buffered && buffered.length > 0) {
const streamedText = buffered.join("");
if (!timelineItem.text.startsWith(streamedText)) timelineItem.text = streamedText;
}
}
}
private handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): void {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (
childSubAgentCallId &&
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
) {
return;
}
if (this.isContextCompactionItem(parsed.item)) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("loading", parsed.item.id),
});
return;
}
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem || timelineItem.type !== "tool_call") {
return;
}
const registeredChildThreadIds = this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
parentCallId: childSubAgentCallId,
});
if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
if (parsed.item.id) {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
);
const itemId = parsed.item.id;
if (normalizedItemType === "commandExecution") {
const callId = timelineItem.callId || itemId;
if (callId && this.emittedExecCommandStartedCallIds.has(callId)) {
return;
}
}
if (itemId && this.emittedItemStartedIds.has(itemId)) {
return;
}
this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (itemId) {
this.emittedItemStartedIds.add(itemId);
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const itemId = parsed.item.id;
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
if (!timelineItem || timelineItem.type !== "user_message") {
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (childSubAgentCallId) {
const childMessageId = itemId ?? timelineItem.messageId;
if (!childMessageId) {
return;
}
const childMessageKey = `${parsed.threadId ?? childSubAgentCallId}:${childMessageId}`;
if (this.emittedProviderSubagentUserMessageKeys.has(childMessageKey)) {
return;
}
this.emittedProviderSubagentUserMessageKeys.add(childMessageKey);
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
}
if (itemId) {
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
return;
}
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
return;
}
const item = this.activeClientMessageId
? { ...timelineItem, clientMessageId: this.activeClientMessageId }
: timelineItem;
this.activeClientMessageId = null;
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
}
private warnUnknownNotificationMethod(method: string, params: unknown): void {
if (this.warnedUnknownNotificationMethods.has(method)) {
return;
}
this.warnedUnknownNotificationMethods.add(method);
this.logger.trace(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
turnId: this.activeForegroundTurnId ?? undefined,
method,
params,
},
"provider.codex.event_unhandled",
);
}
private warnInvalidNotificationPayload(method: string, params: unknown): void {
const key = method;
if (this.warnedInvalidNotificationPayloads.has(key)) {
return;
}
this.warnedInvalidNotificationPayloads.add(key);
this.logger.warn({ method, params }, "Invalid Codex app-server notification payload");
}
private appendOutputDeltaChunk(
store: Map<string, string[]>,
id: string | null | undefined,
chunk: string | null | undefined,
options?: { decodeBase64?: boolean },
): void {
if (!id || !chunk) {
return;
}
const normalized = options?.decodeBase64 ? decodeCodexOutputDeltaChunk(chunk) : chunk;
if (!normalized.length) {
return;
}
const prev = store.get(id) ?? [];
prev.push(normalized);
store.set(id, prev);
}
private consumeOutputDelta(
store: Map<string, string[]>,
id: string | null | undefined,
): string | null {
if (!id) {
return null;
}
const buffered = store.get(id);
if (!buffered || buffered.length === 0) {
return null;
}
store.delete(id);
return buffered.join("");
}
private rememberTerminalProcessForCommand(command: unknown, output: string | null): void {
const normalizedCommand = normalizeCodexCommandValue(command);
if (!normalizedCommand) {
return;
}
const displayCommand =
typeof normalizedCommand === "string"
? normalizedCommand
: normalizedCommand.join(" ").trim();
if (!displayCommand) {
return;
}
const processId = extractCodexTerminalSessionId(output ?? undefined);
if (!processId) {
return;
}
this.terminalCommandByProcessId.set(processId, displayCommand);
if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
return;
}
const pendingInteractions = this.pendingUnlabeledTerminalInteractions.get(processId) ?? [];
this.pendingUnlabeledTerminalInteractions.delete(processId);
for (const pendingInteraction of pendingInteractions) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexTerminalInteractionToToolCall({
callId: pendingInteraction.callId,
processId,
command: displayCommand,
stdin: pendingInteraction.stdin,
}),
});
}
}
private createTerminalInteractionCallId(
processId: string | null,
fallbackCallId: string | null,
): string {
const baseCallId = processId
? `terminal-session-${processId}`
: (nonEmptyString(fallbackCallId ?? undefined) ?? "terminal-interaction");
this.nextTerminalInteractionOrdinal += 1;
return `${baseCallId}-${this.nextTerminalInteractionOrdinal}`;
}
private shouldEmitTerminalInteractionKey(key: string): boolean {
if (this.emittedTerminalInteractionKeys.has(key)) {
return false;
}
this.emittedTerminalInteractionKeys.add(key);
return true;
}
private warnOnIncompleteEditToolCall(
item: ToolCallTimelineItem,
source: string,
payload: unknown,
): void {
if (!isEditToolCallWithoutContent(item)) {
return;
}
const warnKey = `${source}:${item.callId}`;
if (this.warnedIncompleteEditToolCallIds.has(warnKey)) {
return;
}
this.warnedIncompleteEditToolCallIds.add(warnKey);
this.logger.warn(
{
source,
callId: item.callId,
status: item.status,
name: item.name,
detail: item.detail,
payload,
},
"Codex edit tool call is missing diff/content fields",
);
}
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
const parsed = z
.object({
itemId: z.string(),
threadId: z.string(),
turnId: z.string(),
command: z.string().nullable().optional(),
cwd: z.string().nullable().optional(),
reason: z.string().nullable().optional(),
})
.parse(params);
const commandPreview = mapCodexExecNotificationToToolCall({
callId: parsed.itemId,
command: parsed.command,
cwd: parsed.cwd ?? this.config.cwd ?? null,
running: true,
});
const requestId = `permission-${parsed.itemId}`;
const title = parsed.command ? `Run command: ${parsed.command}` : "Run command";
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexBash",
kind: "tool",
title,
description: parsed.reason ?? undefined,
input: {
command: parsed.command ?? undefined,
cwd: parsed.cwd ?? undefined,
},
detail: commandPreview?.detail ?? {
type: "unknown",
input: {
command: parsed.command ?? null,
cwd: parsed.cwd ?? null,
},
output: null,
},
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
turnId: parsed.turnId,
},
};
this.pendingPermissions.set(requestId, request);
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
return new Promise((resolve) => {
this.pendingPermissionHandlers.set(requestId, { resolve, kind: "command" });
});
}
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
const parsed = z
.object({
itemId: z.string(),
threadId: z.string(),
turnId: z.string(),
reason: z.string().nullable().optional(),
})
.parse(params);
const requestId = `permission-${parsed.itemId}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexFileChange",
kind: "tool",
title: "Apply file changes",
description: parsed.reason ?? undefined,
detail: {
type: "unknown",
input: {
reason: parsed.reason ?? null,
},
output: null,
},
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
turnId: parsed.turnId,
},
};
this.pendingPermissions.set(requestId, request);
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
return new Promise((resolve) => {
this.pendingPermissionHandlers.set(requestId, { resolve, kind: "file" });
});
}
private handleToolApprovalRequest(params: unknown): Promise<unknown> {
const parsed = z
.object({
itemId: z.string(),
threadId: z.string(),
turnId: z.string(),
questions: z.array(z.unknown()),
})
.parse(params);
const requestId = `permission-${parsed.itemId}`;
const questions = normalizeCodexQuestionPrompts(parsed.questions);
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "request_user_input",
kind: "question",
title: "Question",
description: undefined,
detail: {
type: "plain_text",
text: formatCodexQuestionPrompts(questions),
icon: "brain",
},
input: { questions },
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
turnId: parsed.turnId,
questions,
},
};
this.pendingPermissions.set(requestId, request);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexQuestionRequestToToolCall({
callId: parsed.itemId,
questions,
status: "running",
}),
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
return new Promise((resolve) => {
this.pendingPermissionHandlers.set(requestId, {
resolve,
kind: "question",
questions,
});
});
}
private handleMcpElicitationRequest(params: unknown, serverRequestId: number): Promise<unknown> {
const parsed = z
.object({
threadId: z.string(),
turnId: z.string().nullable().optional(),
serverName: z.string(),
mode: z.enum(["form", "openai/form", "url"]),
message: z.string(),
requestedSchema: z.unknown().optional(),
url: z.string().optional(),
elicitationId: z.string().optional(),
})
.parse(params);
if (parsed.mode === "url") {
return Promise.resolve({ action: "decline", content: null, _meta: null });
}
const requiredFields = toObjectRecord(parsed.requestedSchema)?.required;
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
return Promise.resolve({ action: "decline", content: null, _meta: null });
}
const requestId = `permission-${randomUUID()}`;
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexMcpElicitation",
kind: "tool",
title: `MCP approval: ${parsed.serverName}`,
description: parsed.message,
input: {
mode: parsed.mode,
requestedSchema: parsed.requestedSchema ?? null,
url: parsed.url ?? null,
},
metadata: {
threadId: parsed.threadId,
turnId: parsed.turnId ?? null,
serverName: parsed.serverName,
elicitationId: parsed.elicitationId ?? null,
},
};
this.pendingPermissions.set(requestId, request);
this.mcpElicitationPermissionIds.set(serverRequestId, requestId);
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
return new Promise((resolve) => {
this.pendingPermissionHandlers.set(requestId, { resolve, kind: "mcp_elicitation" });
});
}
}
export class CodexAppServerAgentClient implements AgentClient {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
private goalsEnabledPromise: Promise<boolean> | null = null;
private autoReviewEnabledPromise: Promise<boolean> | null = null;
constructor(
private readonly logger: Logger,
private readonly runtimeSettings?: ProviderRuntimeSettings,
private readonly deps: CodexAppServerAgentDeps = {},
) {}
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
};
}
private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => {
try {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_GOALS_MIN_VERSION);
this.logger.trace(
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.goals_resolved",
);
return enabled;
} catch (error) {
this.logger.warn({ err: error }, "Failed to probe codex version for goals gate");
return false;
}
})();
}
return this.goalsEnabledPromise;
}
private resolveAutoReviewEnabled(): Promise<boolean> {
if (!this.autoReviewEnabledPromise) {
this.autoReviewEnabledPromise = (async () => {
try {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION);
this.logger.trace(
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.auto_review_resolved",
);
return enabled;
} catch (error) {
this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate");
return false;
}
})();
}
return this.autoReviewEnabledPromise;
}
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const args = [...launchPrefix.args, "app-server"];
if (options?.goalsEnabled) {
args.push("--enable", "goals");
}
this.logger.trace(
{
agentId: options?.agentId,
provider: CODEX_PROVIDER,
launchPrefix,
goalsEnabled: options?.goalsEnabled === true,
},
"provider.codex.spawn",
);
const child = spawnProcess(launchPrefix.command, args, {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
}),
});
assertChildWithPipes(child);
return child;
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
if (options?.persistSession === false) {
this.logger.debug(
"Codex app-server does not expose an ephemeral-session option; persistSession=false is currently a no-op",
);
// TODO: Honor persistSession=false if app-server adds support, or route
// utility generations through `codex exec --ephemeral` in a larger change.
}
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const session = new CodexAppServerAgentSession(
sessionConfig,
null,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
options?.persistSession === false,
goalsEnabled,
autoReviewEnabled,
launchContext?.agentId,
);
await session.connect();
return session;
}
async resumeSession(
handle: { sessionId: string; metadata?: Record<string, unknown> },
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged: AgentSessionConfig = {
...storedConfig,
...overrides,
provider: CODEX_PROVIDER,
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
};
const goalsEnabled = await this.resolveGoalsEnabled();
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
const session = new CodexAppServerAgentSession(
merged,
handle,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
this.sessionDeps(),
false,
goalsEnabled,
autoReviewEnabled,
launchContext?.agentId,
options?.purpose ?? "interactive",
);
await session.connect();
return session;
}
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
// thread/list returns the cheap `cwd` field. Fetch a wider window when
// filtering since most threads will be from other cwds, then keep the
// local realpath-aware filter for symlink-equivalent workspace paths.
const listLimit = options?.cwd ? Math.max(limit, 50) : limit;
const response = toObjectRecord(
await client.request("thread/list", {
limit: listLimit,
...(options?.cwd ? { cwd: options.cwd } : {}),
}),
);
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
const cwd = typeof thread.cwd === "string" ? thread.cwd : process.cwd();
const preview = typeof thread.preview === "string" ? thread.preview : null;
const title = typeof thread.name === "string" && thread.name.trim() ? thread.name : preview;
return {
providerHandleId: threadId,
cwd,
title,
firstPromptPreview: preview,
lastPromptPreview: preview,
lastActivityAt: new Date(
((typeof thread.updatedAt === "number" ? thread.updatedAt : undefined) ??
(typeof thread.createdAt === "number" ? thread.createdAt : undefined) ??
0) * 1000,
),
};
});
} finally {
await client.dispose();
}
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
return importSessionFromPersistence({
provider: CODEX_PROVIDER,
request: input,
context,
resumeSession: this.resumeSession.bind(this),
});
}
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
const [models, autoReviewEnabled] = await Promise.all([
this.fetchModelsFromAppServer(),
this.resolveAutoReviewEnabled(),
]);
return {
models,
defaultModeId: autoReviewEnabled ? "auto-review" : DEFAULT_CODEX_MODE_ID,
modes: autoReviewEnabled
? CODEX_MODES
: CODEX_MODES.filter((mode) => mode.id !== "auto-review"),
};
}
async resolveDefaultModeId(): Promise<string> {
return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID;
}
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const rawResponse = await client.request("model/list", {});
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
const hasConfiguredDefaultModel =
typeof configuredDefaultModelId === "string"
? models.some((model) => model?.id === configuredDefaultModelId)
: false;
return models.map((model) =>
buildCodexModelDefinition(model, {
configuredDefaultModelId,
configuredDefaultThinkingOptionId,
hasConfiguredDefaultModel,
}),
);
} finally {
await client.dispose();
}
}
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
const threadId = handle.nativeHandle ?? handle.sessionId;
if (!threadId) return;
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
await client.request("thread/archive", { threadId });
} finally {
await client.dispose();
}
}
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
const threadId = handle.nativeHandle ?? handle.sessionId;
if (!threadId) return;
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
try {
await client.request("thread/unarchive", { threadId });
} catch (error) {
if (!isCodexAlreadyUnarchivedError(error, threadId)) {
throw error;
}
try {
await client.request("thread/read", { threadId });
} catch {
throw error;
}
}
} finally {
await client.dispose();
}
}
async isAvailable(): Promise<boolean> {
const launch = await resolveCodexLaunch(this.runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
return availability.available;
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const launch = await resolveCodexLaunch(this.runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
const entries: Array<{ label: string; value: string }> = [
...(await buildCommandResolutionDiagnosticRows(launch, {
knownBinaryNames: ["codex"],
})),
...(await buildBinaryDiagnosticRows(launch, availability)),
];
return {
diagnostic: formatProviderDiagnostic("Codex", entries),
};
} catch (error) {
return {
diagnostic: formatProviderDiagnosticError("Codex", error),
};
}
}
}
interface CodexModelBuildContext {
configuredDefaultModelId: string | undefined;
configuredDefaultThinkingOptionId: string | undefined;
hasConfiguredDefaultModel: boolean;
}
function buildCodexModelDefinition(
model: CodexModel,
ctx: CodexModelBuildContext,
): AgentModelDefinition {
const defaultReasoningEffort = normalizeCodexThinkingOptionId(
typeof model.defaultReasoningEffort === "string" ? model.defaultReasoningEffort : null,
);
const resolvedDefaultReasoningEffort =
ctx.configuredDefaultThinkingOptionId ?? defaultReasoningEffort;
const thinkingById = buildCodexThinkingOptionMap(
model.supportedReasoningEfforts,
resolvedDefaultReasoningEffort,
ctx.configuredDefaultThinkingOptionId,
);
const thinkingOptions = Array.from(thinkingById.values()).map((option) =>
Object.assign({}, option, {
isDefault: option.id === resolvedDefaultReasoningEffort,
}),
);
const defaultThinkingOptionId =
resolvedDefaultReasoningEffort ??
thinkingOptions.find((option) => option.isDefault)?.id ??
thinkingOptions[0]?.id;
const isDefaultModel = ctx.hasConfiguredDefaultModel
? model.id === ctx.configuredDefaultModelId
: model.isDefault;
return {
provider: CODEX_PROVIDER,
id: model.id,
label: normalizeCodexModelLabel(model.displayName ?? ""),
description: model.description,
isDefault: isDefaultModel,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId,
metadata: {
model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort,
supportedReasoningEfforts: model.supportedReasoningEfforts,
},
};
}
function buildCodexThinkingOptionMap(
supportedReasoningEfforts: CodexReasoningEffortEntry[] | undefined,
resolvedDefaultReasoningEffort: string | undefined,
configuredDefaultThinkingOptionId: string | undefined,
): Map<string, { id: string; label: string; description?: string }> {
const thinkingById = new Map<string, { id: string; label: string; description?: string }>();
if (Array.isArray(supportedReasoningEfforts)) {
for (const entry of supportedReasoningEfforts) {
const id = normalizeCodexThinkingOptionId(
typeof entry?.reasoningEffort === "string" ? entry.reasoningEffort : null,
);
if (!id) continue;
const description =
typeof entry?.description === "string" && entry.description.trim().length > 0
? entry.description
: undefined;
thinkingById.set(id, { id, label: id, description });
}
}
if (resolvedDefaultReasoningEffort && !thinkingById.has(resolvedDefaultReasoningEffort)) {
thinkingById.set(resolvedDefaultReasoningEffort, {
id: resolvedDefaultReasoningEffort,
label: resolvedDefaultReasoningEffort,
description:
configuredDefaultThinkingOptionId === resolvedDefaultReasoningEffort
? "Configured default reasoning effort"
: "Model default reasoning effort",
});
}
return thinkingById;
}
function resolveSkillDescription(skill: Record<string, unknown>): string {
if (typeof skill.description === "string") {
return skill.description;
}
if (typeof skill.shortDescription === "string") {
return skill.shortDescription;
}
return "Skill";
}