Files
paseo/packages/server/src/server/agent/providers/codex-app-server-agent.ts
T
paseo-bot[bot] ec3ae09926 Keep the native model when importing sessions (#8948)
* fix(server): preserve the selected model when importing sessions

Import preparation resolved a new-session catalog default and sent it as an explicit resume override. Keep import selections with the resumed provider and persist its reported model for subsequent reloads.

* fix(codex): allow the first reply after importing a session

Resolve collaboration settings again when default model and effort are applied so imported sessions send a complete first-turn request.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 00:08:25 +00:00

7950 lines
260 KiB
TypeScript

import { CodexBackgroundInventory } from "./codex/background-inventory.js";
import { validateProviderOptions } from "../provider-options.js";
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 SteerActiveTurnOptions,
type SteerResult,
type AgentSlashCommand,
type AgentStreamEvent,
type AgentTimelineItem,
type ToolCallTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { runProviderRefreshActivity } from "../provider-refresh-deadline.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 { isDeepStrictEqual } from "node:util";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
fileChangesToPreview,
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
normalizeCommandExecutionCommand,
splitCodexMcpToolResultImages,
} from "./codex/tool-call-mapper.js";
import { normalizeCodexFilePath } from "./codex/tool-call-detail-parser.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
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,
CodexServiceTierSchema,
readCodexServiceTier,
type CodexServiceTier,
} from "./codex-feature-definitions.js";
import {
CodexAppServerClient,
CodexAppServerRpcError,
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 {
type CodexHistoryRow,
CodexThreadHistoryTooLargeError,
readCodexThreadHistory,
} from "./codex/thread-history.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { limitAgentTimelineItemContent } from "../agent-timeline-content.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;
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
// Each terminate is bounded by the first constant. A turn start waits for the
// interrupted turn's commands to end, at most the interrupt's own bound plus one
// terminate's and a margin, so it gives up only when a request outlives its timeout.
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
// Turns whose commands are remembered; older turns are forgotten first.
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan.
const CODEX_BUILTIN_MODEL_PROVIDER_IDS = new Set([
"openai",
"ollama",
"lmstudio",
"amazon-bedrock",
"amazon-bedrock-runtime",
"gcp",
]);
// 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];
// Codex removed `thread/rollback` in 0.156.0, so rewinding a legacy thread there
// has to fork before the target turn instead.
const CODEX_THREAD_ROLLBACK_REMOVED_VERSION: readonly [number, number, number] = [0, 156, 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;
}
// The initialize userAgent starts with `<client>/<codex version>`. An agent we
// cannot parse is treated as an older server that still has rollback.
function codexServerHasThreadRollback(userAgent: unknown): boolean {
return (
typeof userAgent !== "string" ||
!codexVersionAtLeast(userAgent, CODEX_THREAD_ROLLBACK_REMOVED_VERSION)
);
}
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 {
environment?: Record<string, string>;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
customProvider?: {
id: string;
label: string;
extends: string;
};
customCodexConfig?: CodexCustomProviderConfig | null;
// The CODEX_HOME the session's app-server runs with; prompts and skills are read from it.
codexHome?: string;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
getTraceContext: () => CodexAppServerTraceContext,
) => CodexAppServerClientLike;
resolveSlashCommandInvocation?: (
prompt: AgentPromptInput,
) => Promise<{ commandName: string; args?: string } | null>;
}
interface CodexModePreset {
approvalPolicy: string;
sandbox: string;
approvalsReviewer: "user" | "auto_review";
}
const MODE_PRESETS: Record<string, CodexModePreset> = {
"read-only": {
approvalPolicy: "on-request",
sandbox: "read-only",
approvalsReviewer: "user",
},
auto: {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "user",
},
"auto-review": {
approvalPolicy: "on-request",
sandbox: "workspace-write",
approvalsReviewer: "auto_review",
},
"full-access": {
approvalPolicy: "never",
sandbox: "danger-full-access",
approvalsReviewer: "user",
},
};
function isAutoReviewReviewer(value: string | undefined): boolean {
return value === "auto_review" || value === "guardian_subagent";
}
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"))
);
}
// Keywords whose value maps names to schemas.
const SCHEMA_MAP_KEYWORDS = [
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
] as const;
// Keywords whose value is a schema or an array of schemas.
const SUBSCHEMA_KEYWORDS = [
"items",
"prefixItems",
"additionalItems",
"contains",
"additionalProperties",
"unevaluatedProperties",
"unevaluatedItems",
"propertyNames",
"allOf",
"anyOf",
"oneOf",
"not",
"if",
"then",
"else",
] as const;
function normalizeCodexOutputSubschema(schema: unknown, schemaPath: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) =>
normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`),
);
}
return normalizeCodexOutputSchemaNode(schema, schemaPath);
}
function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown {
if (!isSchemaRecord(schema)) {
return schema;
}
const normalized: Record<string, unknown> = { ...schema };
for (const keyword of SCHEMA_MAP_KEYWORDS) {
const schemas = schema[keyword];
if (isSchemaRecord(schemas)) {
normalized[keyword] = Object.fromEntries(
Object.entries(schemas).map(([name, child]) => [
name,
normalizeCodexOutputSchemaNode(child, `${schemaPath}.${keyword}.${name}`),
]),
);
}
}
for (const keyword of SUBSCHEMA_KEYWORDS) {
if (keyword in schema) {
normalized[keyword] = normalizeCodexOutputSubschema(
schema[keyword],
`${schemaPath}.${keyword}`,
);
}
}
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;
providerTurnId?: string;
}
interface CodexThreadHistory {
timeline: PersistedTimelineEntry[];
providerSubagentEvents: AgentStreamEvent[];
}
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 function codexMicrosoftStoreBinaryCandidates(
packageRoot: string,
entries: Dirent[],
): string[] {
return entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
.map((entry) =>
path.join(
packageRoot,
entry.name,
"LocalCache",
"Local",
"OpenAI",
"Codex",
"bin",
"codex.exe",
),
)
.sort();
}
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;
}
for (const candidate of codexMicrosoftStoreBinaryCandidates(packageRoot, entries)) {
if (await probeExecutable(candidate)) {
return candidate;
}
}
return null;
}
export async function findDefaultCodexBinary(): Promise<string | null> {
const pathBinary = await findExecutable("codex");
if (pathBinary) return pathBinary;
return 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(env: NodeJS.ProcessEnv): string {
return 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(codexHome: string): Promise<AgentSlashCommand[]> {
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,
codexHome: 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(codexHome, "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);
}
// Match every placeholder in one pass, so inserted argument text is never expanded again.
const namedPlaceholders = Object.keys(named)
.sort((a, b) => b.length - a.length)
.map((key) => `${escapeRegExp(key)}\\b`);
const placeholder = new RegExp(
`\\$(${["\\$", "ARGUMENTS", "[1-9]", ...namedPlaceholders].join("|")})`,
"g",
);
return template.replace(placeholder, (match, name: string) => {
if (name === "$") return "$";
if (name === "ARGUMENTS") return trimmedArgs;
if (/^[1-9]$/.test(name)) return positional[Number(name) - 1] ?? "";
return named[name] ?? match;
});
}
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;
}
function isDefinitiveCodexSteerRejection(error: unknown): boolean {
if (!(error instanceof CodexAppServerRpcError)) return false;
if (error.code === -32601) return true;
if (error.code !== -32600) return false;
const data = toObjectRecord(error.data);
if (data && isRecord(toObjectRecord(data.codexErrorInfo)?.activeTurnNotSteerable)) return true;
// These app-server invalid-request messages describe requests that reached
// Codex but could not have submitted input. Keep this exact: a generic
// invalid-request, timeout, disconnect, or unknown error is ambiguous.
return (
error.message === "no active turn to steer" ||
/^expected active turn id `[^`]+` but found `[^`]+`$/.test(error.message) ||
error.message === "active turn uses a different output schema"
);
}
function isCodexAlreadyIdleInterrupt(error: unknown): boolean {
return (
error instanceof CodexAppServerRpcError &&
error.code === -32600 &&
error.message === "no active turn to interrupt"
);
}
// 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[];
serviceTiers?: CodexServiceTier[];
}
const CodexModelListResponseSchema = z.object({
data: z
.array(
z.object({
id: z.string(),
serviceTiers: z.array(CodexServiceTierSchema).optional(),
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(),
});
/**
* Read the newest `window.limit` threads Codex knows about.
*
* Codex caps every `thread/list` response at 100 rows whatever limit it is
* given (codex-cli 0.153 and 0.155), so one request only ever sees the newest
* page and hands back a cursor for the rest. Follow that cursor until the
* caller's window is full or Codex runs out of threads.
*/
async function readCodexThreadWindow(
client: Pick<CodexAppServerClientLike, "request">,
logger: Logger,
window: { limit: number; cwd?: string; modelProviders?: string[] },
): Promise<Array<Record<string, unknown>>> {
// A thread updated while the scan is paging moves under `updated_at` order
// and can come back on a later page, so identify each one and keep it once.
// A row Codex sends without an id stands for itself.
const threads = new Map<unknown, Record<string, unknown>>();
let cursor: string | undefined;
while (threads.size < window.limit) {
const response = toObjectRecord(
await client.request("thread/list", {
limit: window.limit - threads.size,
// Rank the window by last use. Codex pages by creation time by default,
// which drops an old conversation that is still being worked in.
// Older Codex builds ignore the unknown key and keep that order.
sortKey: "updated_at",
...(window.cwd ? { cwd: window.cwd } : {}),
...(window.modelProviders ? { modelProviders: window.modelProviders } : {}),
...(cursor ? { cursor } : {}),
}),
);
const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const sizeBeforePage = threads.size;
for (const thread of page) {
const identity = typeof thread.id === "string" ? thread.id : thread;
if (!threads.has(identity)) threads.set(identity, thread);
}
const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined;
if (!nextCursor) break;
if (threads.size === sizeBeforePage) {
// Every page that continues the scan brings a thread the scan has not
// seen, so the loop is bounded by the window. A page that brings none
// would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for
// ever behind a caller that has already timed out.
logger.warn(
{ cursor: nextCursor },
"codex thread/list returned no new threads mid-scan, stopping the session scan",
);
break;
}
cursor = nextCursor;
}
return [...threads.values()];
}
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 belongsToWorkspace = createPathEquivalenceMatcher(cwd);
return threads.filter(
(thread) => typeof thread.cwd === "string" && belongsToWorkspace(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;
movePath?: string;
}
function codexPatchMovePath(value: unknown): string | undefined {
const record = toObjectRecord(value);
if (!record) return undefined;
const kind = toObjectRecord(record.kind);
const destination = record.move_path ?? kind?.move_path;
return typeof destination === "string" ? destination : undefined;
}
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),
movePath: codexPatchMovePath(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),
movePath: codexPatchMovePath(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),
movePath: codexPatchMovePath(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;
}
function codexPatchPreview(
files: CodexPatchFileChange[],
cwd?: string | null,
): {
filePath: string | undefined;
patchText: string | undefined;
} {
const preview =
files.length > 1
? fileChangesToPreview(
files.map((file) => ({
path: normalizeCodexFilePath(file.path, cwd) ?? file.path,
kind: file.kind,
diff: file.content,
movePath: file.movePath ? normalizeCodexFilePath(file.movePath, cwd) : undefined,
})),
)
: undefined;
const firstPath = preview?.filePath ?? files[0]?.path;
const firstPatchText = files
.map((file) => file.content?.trim())
.find((value): value is string => typeof value === "string" && value.length > 0);
return { filePath: firstPath, patchText: preview?.unifiedDiff ?? firstPatchText };
}
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 { filePath: firstPath, patchText } = codexPatchPreview(files, params.cwd);
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);
const clientMessageId = nonEmptyString(
normalizedItem.clientId ?? normalizedItem.client_id ?? normalizedItem.clientUserMessageId,
);
return {
type: "user_message",
text,
...(messageId ? { messageId } : {}),
...(clientMessageId ? { clientMessageId } : {}),
};
}
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)
);
}
/** Falls back to the row's start time for a user message and its end time otherwise. */
function readCodexHistoryRowTimestamp(
row: CodexHistoryRow,
timelineItem: AgentTimelineItem,
): string | null {
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
if (timelineItem.type === "user_message") {
return startedAt ?? completedAt;
}
return completedAt ?? startedAt;
}
interface CodexSubAgentActivity {
id: string | null;
agentThreadId: string;
kind: "started" | "interacted" | "interrupted";
}
function isTerminalSubAgentStatus(
status: ToolCallTimelineItem["status"],
): status is "completed" | "failed" | "canceled" {
return status === "completed" || status === "failed" || status === "canceled";
}
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.kind === "started" ? [activity.agentThreadId] : [];
}
const record = toObjectRecord(item);
const normalizedType = normalizeCodexThreadItemType(
typeof record?.type === "string" ? record.type : undefined,
);
if (
normalizedType !== "collabAgentToolCall" ||
record?.tool !== "spawnAgent" ||
!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"]),
};
}
async function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): Promise<AgentTimelineItem | null> {
if (normalizedType === "imageView") {
return await renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
if (normalizedItem.status === "failed") {
return mapCodexToolCallEnvelope({
callId: firstStringField(normalizedItem, ["id"]),
name: "image_generation",
input: { prompt: firstStringField(normalizedItem, ["revisedPrompt", "revised_prompt"]) },
error: normalizedItem.failure ?? { message: "Image generation failed" },
});
}
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return await renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
data: result?.data ?? null,
mimeType: result?.mimeType ?? null,
},
{ materialize: materializeProviderImage },
);
}
function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem {
const question = codexAsyncQuestionToTimeline(item);
if (question) return question;
const messageId = nonEmptyString(item.id);
return {
type: "assistant_message",
text: typeof item.text === "string" ? item.text : "",
...(messageId ? { messageId } : {}),
};
}
export async function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<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 await 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":
return mapCodexAgentMessage(normalizedItem);
case "plan":
return mapCodexThreadPlanItem(normalizedItem);
case "reasoning":
return mapCodexThreadReasoningItem(normalizedItem);
case CODEX_CONTEXT_COMPACTION_TYPE:
return {
type: "compaction",
status: "completed",
};
default:
return null;
}
}
async function mcpToolResultImagesToTimeline(item: unknown): Promise<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);
const items: AgentTimelineItem[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) items.push(imageItem);
}
return items;
}
async function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem[]> {
const timelineItem = await threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
async function loadCodexThreadHistoryTimeline(params: {
client: CodexAppServerClientLike;
threadId: string;
cwd: string | null;
}): Promise<CodexThreadHistoryProjection> {
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
try {
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
} catch (error) {
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
// The session stays usable; the row says why the history stops here.
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
}
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 };
}
async function projectCodexThreadHistory(
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
projection: {
timeline: PersistedTimelineEntry[];
subAgentTimelineIndexByThreadId: Map<string, number>;
},
): Promise<void> {
const { timeline, subAgentTimelineIndexByThreadId } = projection;
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
const { item } = row;
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await 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 await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
// Cap each item as it is projected so a large history is never held uncapped.
item: limitAgentTimelineItemContent(settledTimelineItem),
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && row.turnId !== undefined
? { providerTurnId: row.turnId }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
}
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(),
turnId: 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(),
};
const CodexEventTurnIdFields = {
turnId: z.string().optional(),
turn_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;
}
function getCodexEventTurnId(params: {
turnId?: string;
turn_id?: string;
msg: { turnId?: string; turn_id?: string };
}): string | null {
return params.turnId ?? params.turn_id ?? params.msg.turnId ?? params.msg.turn_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,
...CodexEventTurnIdFields,
msg: z
.object({
...CodexEventThreadIdFields,
...CodexEventTurnIdFields,
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;
turnId: string | null;
item: { id?: string; type?: string; [key: string]: unknown };
}
| {
kind: "item_started";
source: "item" | "codex_event";
threadId: string | null;
turnId: 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,
turnId: params.turnId ?? 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,
turnId: params.turnId ?? 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),
turnId: getCodexEventTurnId(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),
turnId: getCodexEventTurnId(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;
}
function enabledCodexSkills(
entries: unknown[],
): Array<{ name: string; description: string; path: string }> {
const skillsByName = new Map<string, { name: string; description: string; path: string }>();
for (const entry of entries) {
const skillRecord = toObjectRecord(entry);
if (
!skillRecord ||
skillRecord.enabled === false ||
typeof skillRecord.name !== "string" ||
typeof skillRecord.path !== "string" ||
skillsByName.has(skillRecord.name)
) {
continue;
}
skillsByName.set(skillRecord.name, {
name: skillRecord.name,
description: resolveSkillDescription(skillRecord),
path: skillRecord.path,
});
}
return Array.from(skillsByName.values());
}
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 = (
await 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>,
) {
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`;
}
interface CodexCustomProviderConfig {
model_provider: string;
model_providers: Record<string, Record<string, unknown>>;
}
function buildCodexCustomProviderConfig(
runtimeSettings: ProviderRuntimeSettings | undefined,
customProvider: CodexAppServerAgentDeps["customProvider"],
): CodexCustomProviderConfig | 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;
}
// Namespace Paseo ids so they cannot override Codex's built-ins. A colon is
// forbidden in Paseo ids, keeping these keys distinct from every legacy id
// when both generations are listed together.
const providerKey = `paseo:${customProvider.id}`;
return {
model_provider: providerKey,
model_providers: {
[providerKey]: providerConfig,
},
};
}
interface CodexSubAgentCallState {
callId: string;
toolCall: ToolCallTimelineItem;
parentCallId: string | null;
parentSubagentId: string | null;
activityItemIds: Set<string>;
pendingCommandOutputDeltas: Map<string, string[]>;
pendingFileChangeOutputDeltas: Map<string, string[]>;
childThreadIds: Set<string>;
}
function resolveCodexParentSubagentId(
parentCallId: string | null,
emittingThreadId: string | null,
): string | null {
return parentCallId ? emittingThreadId : null;
}
interface CodexPendingPermissionHandler {
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
questions?: CodexQuestionPrompt[];
plan?: { text: string; turnId: string | undefined };
}
interface ConsumedRootCompaction {
itemId?: 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 readonly asyncQuestions: CodexAsyncQuestions;
private readonly codexHome: string;
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;
// Codex writes a new thread's rollout at its first turn. Until then the thread exists only
// in this app-server, so no other app-server can resume it and it is not reported.
private savedThreadId: string | null = null;
private currentTurnId: string | null = null;
private pendingForegroundTurnIdentification: {
foregroundTurnId: string;
promise: Promise<string | null>;
resolve: (turnId: string | null) => void;
} | null = null;
private pendingForegroundStart: {
promise: Promise<void>;
resolve: () => void;
cancelRequested: boolean;
} | null = null;
private client: CodexAppServerClient | null = null;
private readonly background = new CodexBackgroundInventory((event) => this.emitEvent(event));
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: string | null = null;
private speedModels: CodexModel[] = [];
private planModeEnabled = false;
// History read by rewind or an archived resume, held until streamHistory consumes it.
private stagedHistory: CodexThreadHistory | null = null;
// Collects provider subagent events emitted while a thread history read rebuilds routes.
private historySubagentEvents: AgentStreamEvent[] | null = null;
// A resumed thread's earlier turns and sub-agents are known only after reading its history.
private historyIndexed = true;
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 pendingToolCallDetails = new Map<string, ToolCallTimelineItem["detail"]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
// `item/started` is the only record of which turn owns a process.
private terminalProcessIdsByTurnId = new Map<
string,
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
>();
private interruptedTurnCleanup: Promise<void> | null = null;
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 readonly userMessageProviderTurnIds = new Map<string, string>();
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
private pendingRootCompactionItemIds = new Set<string>();
private pendingAnonymousRootCompactions = 0;
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
// `thread/compacted` or the end of the child's turn.
private pendingChildCompactionThreadIds = new Set<string>();
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
private unpairedCompactionItemCompletions = 0;
private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected";
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;
private readonly usageSessionKey = randomUUID();
private readonly harnessEnvironment: Record<string, string>;
usageSession() {
if (this.closed) return null;
return {
provider: "codex",
model: this.config.model,
env: this.harnessEnvironment,
sessionKey: this.usageSessionKey,
};
}
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 =
validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {};
this.config = config;
this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv();
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
this.serviceTier = readCodexServiceTier(this.config.featureValues);
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
this.savedThreadId = this.resumeHandle.sessionId;
this.historyIndexed = false;
// Resuming can start a goal before the caller receives the session.
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
}
}
get id(): string | null {
return this.currentThreadId;
}
get features(): AgentFeature[] {
return buildCodexFeatures({
serviceTiers: this.currentServiceTiers(),
serviceTier: this.serviceTier ?? "default",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
async connect(): Promise<void> {
if (this.closed) {
throw this.createClosedError();
}
if (this.connectionState !== "disconnected") 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> {
if (this.initialResumePurpose === "history") {
this.stagedHistory = await this.readArchivedHistory();
this.connectionState = "history-ready";
return;
}
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 {
const initialized = toObjectRecord(
await client.request("initialize", buildCodexAppServerInitializeParams()),
);
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
client.notify("initialized", {});
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.reconcileServiceTier();
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.applyDefaultModelAndThinking();
}
if (this.closed) {
throw this.createClosedError();
}
this.connectionState = "connected";
this.background.start({ client, thread: this.currentThreadId });
} 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.connectionState = "disconnected";
this.background.stop();
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 allSkills: unknown[] = [];
for (const entry of entries) {
const entryRecord = toObjectRecord(entry);
const list = Array.isArray(entryRecord?.skills) ? entryRecord.skills : [];
allSkills.push(...list);
}
this.cachedSkills = enabledCodexSkills(allSkills);
} 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 currentServiceTiers(): CodexServiceTier[] {
const selectedModel = this.config.model
? this.speedModels.find(
(model) => model.id === this.config.model || model.model === this.config.model,
)
: (this.speedModels.find((model) => model.isDefault) ?? this.speedModels[0]);
return selectedModel?.serviceTiers ?? [];
}
private reconcileServiceTier(): void {
const tiers = this.currentServiceTiers();
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
}
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
this.serviceTier = null;
}
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
}
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
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",
plan: { text: planText, turnId: this.activeForegroundTurnId ?? undefined },
});
// Reserve the proposal's place before a later prompt can resolve it.
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "tool_call",
callId: requestId,
name: "plan_approval",
status: "running",
error: null,
detail: { type: "plan", text: 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 readArchivedHistory(): Promise<CodexThreadHistory> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
return await this.readThreadHistory(client);
} finally {
await client.dispose();
}
}
// Reads the thread and its sub-agent threads, rebuilding the turn index and
// sub-agent routes. The caller owns the returned history; nothing retains it.
private async readThreadHistory(
client: CodexAppServerClientLike | null,
): Promise<CodexThreadHistory> {
if (!client || !this.currentThreadId) {
return { timeline: [], providerSubagentEvents: [] };
}
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
client,
threadId: this.currentThreadId,
cwd: this.config.cwd ?? null,
});
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
const providerSubagentEvents: AgentStreamEvent[] = [];
this.historySubagentEvents = providerSubagentEvents;
try {
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
} finally {
this.historySubagentEvents = null;
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") {
entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item;
}
if (entry.item.type === "user_message") {
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
}
}
this.historyIndexed = true;
return { timeline, providerSubagentEvents };
}
// Turns and rewind route by the thread's earlier turns and sub-agents. A resumed
// session whose projection Paseo already holds reads them here and drops the rest.
private async ensureHistoryIndexed(): Promise<void> {
if (this.historyIndexed) return;
await this.readThreadHistory(this.client);
}
private async loadPersistedSubAgentHistories(
client: CodexAppServerClientLike,
rootRoutes: readonly PersistedSubAgentRoute[],
): Promise<void> {
const queue = rootRoutes.map((route) => ({
route,
parentCallId: null as string | null,
parentSubagentId: 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,
parentSubagentId: next.parentSubagentId,
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
client,
threadId: next.route.childThreadId,
cwd: this.config.cwd ?? null,
});
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,
parentSubagentId: next.route.childThreadId,
});
}
} catch (error) {
this.logger.trace(
{ err: error, childThreadId: next.route.childThreadId },
"Failed to load persisted Codex child history",
);
}
}
}
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
// Paseo reads history only when it replays it; the resume response needs no turns.
const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true };
if (this.deps.customCodexConfig) {
// Resume otherwise prefers the provider stored on the thread, including
// the unnamespaced key emitted by earlier Paseo versions.
params.modelProvider = this.deps.customCodexConfig.model_provider;
}
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
params.config = codexConfig;
}
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 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 (isArchivedCodexThreadResumeError(error, threadId)) {
try {
await this.client.request("thread/unarchive", { threadId });
} catch (unarchiveError) {
if (!isCodexAlreadyUnarchivedError(unarchiveError, threadId)) {
throw unarchiveError;
}
}
const response = await this.client.request("thread/resume", params);
this.rememberResolvedSandboxPolicy(response);
this.logger.info({ threadId }, "Unarchived Codex thread to restore active Paseo agent");
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 (this.deps.resolveSlashCommandInvocation) {
return this.deps.resolveSlashCommandInvocation(prompt);
}
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 promptPath = path.join(this.codexHome, "prompts", `${promptName}.md`);
const raw = await fs.readFile(promptPath, "utf8");
const parsed = parseFrontMatter(raw);
return expandCodexCustomPrompt(parsed.body, args);
}
if (this.connectionState === "disconnected") {
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;
}
params.serviceTier = this.serviceTier ?? "default";
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) {
params.approvalsReviewer = preset.approvalsReviewer;
}
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 || this.pendingForegroundStart) {
throw new Error("A foreground turn is already active");
}
let resolveStart!: () => void;
const pendingStart = {
promise: new Promise<void>((resolve) => {
resolveStart = resolve;
}),
resolve: () => resolveStart(),
cancelRequested: false,
};
this.pendingForegroundStart = pendingStart;
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.pendingNotifications;
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();
await this.ensureHistoryIndexed();
} 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,
});
// The interrupted turn's commands end before this turn begins, so none of them
// outlives it into the new turn.
await this.waitForInterruptedTurnCleanup();
if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex");
}
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;
} finally {
if (this.pendingForegroundStart === pendingStart) {
this.pendingForegroundStart = null;
}
pendingStart.resolve();
}
}
async steerActiveTurn(
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
const client = this.client;
const threadId = this.currentThreadId;
let nativeTurnId = this.currentTurnId;
const foregroundTurnId = this.activeForegroundTurnId;
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const pendingIdentification = this.pendingForegroundTurnIdentification;
// The start acknowledgement precedes the notification that identifies the native
// turn. Keep this steer attached to the accepted foreground turn while it arrives.
if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) {
nativeTurnId = await pendingIdentification.promise;
}
if (!nativeTurnId) {
return { status: "unavailable" };
}
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
const input = await this.buildUserInput(prompt);
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
try {
const response = await client.request(
"turn/steer",
{
threadId,
expectedTurnId: nativeTurnId,
input,
...(options.clientMessageId ? { clientUserMessageId: options.clientMessageId } : {}),
},
TURN_START_TIMEOUT_MS,
);
const record = toObjectRecord(response);
const turn = record ? toObjectRecord(record.turn) : null;
const acknowledgedTurnId = nonEmptyString(record?.turnId) ?? nonEmptyString(turn?.id);
if (acknowledgedTurnId !== nativeTurnId) {
throw new Error("Codex returned an invalid steer acknowledgement");
}
if (options.clearPendingPermissions) {
await this.clearPendingPermissionsForSteer();
}
return { status: "accepted" };
} catch (error) {
if (isDefinitiveCodexSteerRejection(error)) return { status: "unavailable" };
throw error;
}
}
private matchesSteerAdmission(admission: {
client: CodexAppServerClientLike;
threadId: string;
nativeTurnId: string;
foregroundTurnId: string;
}): boolean {
return (
this.client === admission.client &&
this.currentThreadId === admission.threadId &&
this.currentTurnId === admission.nativeTurnId &&
this.activeForegroundTurnId === admission.foregroundTurnId
);
}
private rememberCodexUserMessageTurn(
messageId: string | null | undefined,
providerTurnId?: string | null,
): boolean {
if (typeof messageId !== "string" || messageId.length === 0) {
return false;
}
if (this.userMessageTurnIndexes.has(messageId)) {
if (providerTurnId) {
this.userMessageProviderTurnIds.set(messageId, providerTurnId);
}
return false;
}
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
this.userMessageTurnIds.push(messageId);
if (providerTurnId) {
this.userMessageProviderTurnIds.set(messageId, providerTurnId);
}
return true;
}
private resetCodexUserMessageTurns(): void {
this.userMessageTurnIndexes.clear();
this.userMessageTurnIds.length = 0;
this.userMessageProviderTurnIds.clear();
}
private truncateCodexUserMessageTurns(numTurns: number): void {
if (numTurns <= 0) {
return;
}
const retainedCount = Math.max(0, this.userMessageTurnIds.length - numTurns);
const removedMessageIds = this.userMessageTurnIds.splice(retainedCount);
for (const messageId of removedMessageIds) {
this.userMessageProviderTurnIds.delete(messageId);
}
this.userMessageTurnIndexes.clear();
this.userMessageTurnIds.forEach((messageId, index) => {
this.userMessageTurnIndexes.set(messageId, index);
});
}
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
return {
resolve: (messageId) => {
const index = this.userMessageTurnIndexes.get(messageId);
return index === undefined
? null
: { index, turnId: this.userMessageProviderTurnIds.get(messageId) ?? null };
},
count: () => this.userMessageTurnIds.length,
};
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
const startupEvents = this.startupEvents;
this.startupEvents = null;
for (const event of startupEvents ?? []) this.notifySubscribers(event);
const background = this.background.snapshot();
if (background) callback(background);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const history = this.stagedHistory ?? (await this.readThreadHistory(this.client));
this.stagedHistory = null;
for (const event of history.providerSubagentEvents) {
yield event;
}
for (const entry of history.timeline) {
yield {
type: "timeline",
provider: CODEX_PROVIDER,
item: entry.item,
timestamp: entry.timestamp,
};
}
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
if (this.connectionState === "disconnected") {
await this.connect();
}
if (!this.currentThreadId) {
await this.ensureThread();
}
const info: AgentRuntimeInfo = {
provider: CODEX_PROVIDER,
sessionId: this.savedCurrentThreadId(),
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;
this.reconcileServiceTier();
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> {
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
if (featureId === "fast_mode") {
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
if (value && !fast)
throw new Error(
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
);
return this.setFeature("service_tier", value ? fast!.id : "default");
}
if (featureId === "service_tier") {
if (
typeof value !== "string" ||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
) {
throw new Error(
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
);
}
this.serviceTier = value;
this.reconcileServiceTier();
this.cachedRuntimeInfo = null;
return;
}
if (featureId === "plan_mode") {
this.applyFeatureValue("plan_mode", Boolean(value));
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
}
getPendingPermissions(): AgentPermissionRequest[] {
return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()];
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
if (this.asyncQuestions.hasPending(requestId)) {
return this.respondToAsyncQuestion(requestId, response);
}
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 async respondToAsyncQuestion(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const prepared = this.asyncQuestions.prepareResponse(requestId, response);
let followUpPrompt = prepared.prompt;
const expectedTurnId = this.activeForegroundTurnId;
if (prepared.prompt && expectedTurnId) {
const result = await this.steerActiveTurn(prepared.prompt, {
expectedTurnId,
clientMessageId: randomUUID(),
});
if (result.status !== "accepted") {
throw new Error("The active Codex turn changed. Retry sending your answer.");
}
followUpPrompt = undefined;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
});
return followUpPrompt ? { followUpPrompt } : undefined;
}
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.plan?.text ?? 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 async clearPendingPermissionsForSteer(): Promise<void> {
const requestIds = Array.from(this.pendingPermissionHandlers.keys());
for (const requestId of requestIds) {
if (!this.pendingPermissionHandlers.has(requestId)) continue;
await this.respondToPermission(requestId, {
behavior: "deny",
message: "The user answered with a message instead of approving. Their message follows.",
});
}
}
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
this.recordPlanOutcome(requestId, resolution.behavior === "allow" ? "approved" : "rejected");
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution,
});
}
private recordPlanOutcome(
requestId: string,
outcome: "approved" | "rejected" | "canceled",
): void {
const plan = this.pendingPermissionHandlers.get(requestId)?.plan;
if (plan) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
turnId: plan.turnId,
item: {
type: "tool_call",
callId: requestId,
name: "plan_approval",
status: outcome === "canceled" ? "canceled" : "completed",
error: null,
detail: { type: "plan", text: plan.text },
metadata: outcome === "canceled" ? {} : { approved: outcome === "approved" },
},
});
}
}
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 {
const threadId = this.savedCurrentThreadId();
if (!threadId) return null;
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null;
return {
provider: CODEX_PROVIDER,
sessionId: threadId,
nativeHandle: threadId,
metadata: {
provider: CODEX_PROVIDER,
cwd: this.config.cwd,
title: this.config.title ?? null,
threadId,
modeId: this.config.modeId,
model: this.config.model ?? null,
thinkingOptionId,
toolPolicy: this.config.toolPolicy,
systemPrompt: this.config.systemPrompt,
mcpServers: this.config.mcpServers,
asyncQuestions: this.asyncQuestions.serialize(),
},
};
}
private savedCurrentThreadId(): string | null {
return this.currentThreadId !== null && this.currentThreadId === this.savedThreadId
? this.currentThreadId
: null;
}
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 this.ensureHistoryIndexed();
await revertCodexConversation({
client: this.client,
threadId: this.currentThreadId,
messageId: input.messageId,
cwd: this.config.cwd ?? null,
model: this.config.model ?? null,
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
userMessageTurns: this.codexUserMessageTurns(),
threadRollbackAvailable: this.threadRollbackAvailable,
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
// Codex writes a forked thread's rollout when it forks it, even before any turn.
this.savedThreadId = threadId;
this.cachedRuntimeInfo = null;
this.stagedHistory = await this.readThreadHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
},
});
}
async interrupt(): Promise<void> {
const pendingStart = this.pendingForegroundStart;
if (pendingStart) {
pendingStart.cancelRequested = true;
await pendingStart.promise;
}
if (!this.client || !this.currentThreadId) {
if (
!this.activeForegroundTurnId &&
!this.currentTurnId &&
!this.pendingForegroundTurnIdentification
) {
return;
}
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 && !this.activeForegroundTurnId && !this.currentTurnId) {
return;
}
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
const client = this.client;
const threadId = this.currentThreadId;
const interruptRequest = client.request(
"turn/interrupt",
{
threadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
// Once Codex accepts the interrupt, end the commands this turn started. Registered
// before the await, so a turn start racing this interrupt waits for it. An already-idle
// turn was not interrupted, and what it left running stays.
this.trackInterruptedTurnCleanup(
interruptRequest.then(
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
() => undefined,
),
);
try {
await interruptRequest;
} catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) {
throw error;
}
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.currentTurnId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
this.pendingForegroundTurnIdentification = null;
}
}
stopBackgroundWork(id: string): Promise<void> {
return this.background.stopItem(id);
}
async canStopRuntime(): Promise<boolean> {
if (
!this.client ||
this.client.hasPendingRequests() ||
!this.currentThreadId ||
this.activeForegroundTurnId ||
this.currentTurnId ||
this.pendingForegroundStart ||
this.pendingPermissionHandlers.size ||
this.getPendingPermissions().length ||
this.pendingSubAgentNotificationsByThreadId.size
)
return false;
// Older app servers reject this request. A failed or malformed probe must retain
// the process: command completion alone does not mean its terminal has exited.
const loaded = z
.object({ data: z.array(z.string()) })
.parse(await this.client.request("thread/loaded/list", {}, 10_000));
const threadIds = new Set([this.currentThreadId, ...loaded.data]);
for (const threadId of threadIds) {
const response = z
.object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() })
.parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000));
if (response.data.length > 0 || response.nextCursor !== null) return false;
}
return !this.client.hasPendingRequests();
}
// Remember which turn started a command, so an interrupt ends that turn's commands and
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
// A command that starts after its turn's interrupt was accepted is ended as soon as it
// is seen.
private recordTurnTerminalProcess(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const processId = parsed.item.processId;
if (
parsed.source !== "item" ||
!parsed.turnId ||
!parsed.threadId ||
parsed.threadId !== this.currentThreadId ||
typeof processId !== "string" ||
!processId ||
normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
) !== "commandExecution"
) {
return;
}
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
if (parsed.kind === "item_completed") {
// Only a completed command has exited. A failed one can still be running:
// Codex reports an interrupted turn's live command as failed.
if (parsed.item.status === "completed") record?.processIds.delete(processId);
return;
}
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
current.processIds.add(processId);
if (current.interruptAccepted && this.client) {
this.trackInterruptedTurnCleanup(
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
);
}
}
// An accepted interrupt needs a record even before any of its turn's commands is seen:
// an `item/started` can still be queued behind an earlier notification's conversion.
private getTurnTerminalRecord(turnId: string) {
const existing = this.terminalProcessIdsByTurnId.get(turnId);
if (existing) return existing;
const record = {
processIds: new Set<string>(),
terminatedProcessIds: new Set<string>(),
interruptAccepted: false,
};
this.terminalProcessIdsByTurnId.set(turnId, record);
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
}
return record;
}
// A later sweep joins the pending one, so a turn start waits for both.
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
.then(() => undefined)
.finally(() => {
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
});
this.interruptedTurnCleanup = tracked;
}
// Terminate each process the interrupted turn started, by its own id, once each.
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
// an earlier turn's included. A process already gone answers `terminated: false`. Never
// rejects; failures are logged.
private async endInterruptedTurnProcesses(
client: CodexAppServerClient,
threadId: string | null,
turnId: string,
): Promise<void> {
if (!threadId) return;
const record = this.getTurnTerminalRecord(turnId);
record.interruptAccepted = true;
const processIds = [...record.processIds].filter(
(processId) => !record.terminatedProcessIds.has(processId),
);
for (const processId of processIds) record.terminatedProcessIds.add(processId);
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
await Promise.all(
processIds.map(async (processId) => {
try {
const response = await client.request(
"thread/backgroundTerminals/terminate",
{ threadId, processId },
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
);
this.logger.info(
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
"provider.codex.interrupt.terminal_terminated",
);
} catch (error) {
this.logger.warn(
{ ...logContext, processId, err: error },
"provider.codex.interrupt.terminal_terminate_failed",
);
}
}),
);
}
// A new turn starts only after every pending sweep of interrupted turns' commands has
// ended, or after the bound, whichever is first.
private async waitForInterruptedTurnCleanup(): Promise<void> {
if (!this.interruptedTurnCleanup) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
(async () => {
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
return false;
})(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
this.logger.warn(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
},
"provider.codex.interrupt.terminal_cleanup_timed_out",
);
}
}
async close(): Promise<void> {
this.closed = true;
this.background.stop();
this.clearPendingPermissions();
this.pendingToolCallDetails.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
this.subscribers.clear();
this.startupEvents = null;
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;
}
if (pending.kind === "plan") {
this.recordPlanOutcome(requestId, "canceled");
}
pending.resolve({ decision: "cancel" });
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
}
this.mcpElicitationPermissionIds.clear();
this.resolvedPermissionRequests.clear();
}
private async disposeClient(): Promise<void> {
this.background.stop();
const client = this.client;
this.connectionState = "disconnected";
this.currentTurnId = null;
if (client) {
await client.dispose();
}
this.client = null;
}
async listCommands(): Promise<AgentSlashCommand[]> {
const prompts = await listCodexCustomPrompts(this.codexHome);
if (this.connectionState === "disconnected") {
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.codexHome, 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();
// A goal runs turns of its own.
await this.ensureHistoryIndexed();
} 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 };
}
// A session without a model or effort runs on Codex's configured defaults, whether its
// thread is new or resumed after a restart.
private async applyDefaultModelAndThinking(): Promise<string> {
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId;
this.refreshResolvedCollaborationMode();
return model;
}
private async ensureThread(): Promise<void> {
if (!this.client) return;
if (this.currentThreadId) return;
const model = await this.applyDefaultModelAndThinking();
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) {
params.approvalsReviewer = preset.approvalsReviewer;
}
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.historySubagentEvents && event.type === "provider_subagent") {
this.historySubagentEvents.push(event);
return;
}
this.notifySubscribers(event);
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
if (this.startupEvents) {
this.startupEvents.push(tagged);
return;
}
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 pendingNotifications: Promise<void> = Promise.resolve();
private handleNotification(method: string, params: unknown): Promise<void> {
// An image must exist before its event, and completion must follow that event.
// Transport callbacks cannot await: the session owns their ordered drain.
const next = this.pendingNotifications.then(() => {
return this.closed ? undefined : this.processNotification(method, params);
});
this.pendingNotifications = next.catch((error: unknown) => {
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
});
return this.pendingNotifications;
}
private async processNotification(method: string, params: unknown): Promise<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") {
await this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
if (
parsed.kind === "turn_completed" ||
parsed.kind === "exec_command_started" ||
parsed.kind === "exec_command_completed" ||
((parsed.kind === "item_started" || parsed.kind === "item_completed") &&
normalizeCodexThreadItemType(parsed.item.type) === "commandExecution")
)
this.background.changed(this.currentThreadId);
await this.dispatchParsedNotification(parsed);
}
private async dispatchSubAgentNotification(
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
return;
case "turn_started":
case "turn_completed":
case "agent_message_delta":
case "reasoning_delta":
case "item_started":
case "item_completed":
await 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;
case "context_compacted":
this.settleChildCompaction(parsed.threadId);
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 async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<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":
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
await 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 async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<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) {
await 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;
parentSubagentId: string | null;
}): string[] {
const { timelineItem, rawItem, parentCallId, parentSubagentId } = 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,
parentSubagentId,
activityItemIds: new Set<string>(),
pendingCommandOutputDeltas: new Map<string, string[]>(),
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
childThreadIds: new Set<string>(),
} satisfies CodexSubAgentCallState);
state.toolCall = timelineItem;
state.parentCallId ??= parentCallId;
state.parentSubagentId ??= parentSubagentId;
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) {
const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId);
if (owningCallId && owningCallId !== timelineItem.callId) {
continue;
}
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,
});
const subAgentType =
activityToolCall?.detail.type === "sub_agent"
? activityToolCall.detail.subAgentType
: undefined;
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
return true;
}
private handleCompletedContextCompactionItem(item: {
id?: string;
type?: string;
[key: string]: unknown;
}): boolean {
if (!this.isContextCompactionItem(item)) {
return false;
}
const consumedPendingCompaction = this.consumePendingRootCompaction(item.id);
const hasDifferentPendingCompaction =
this.pendingRootCompactionItemIds.size > 0 || this.pendingAnonymousRootCompactions > 0;
const isLateCompletionForOlderItem =
item.id !== undefined &&
consumedPendingCompaction === undefined &&
hasDifferentPendingCompaction;
if (isLateCompletionForOlderItem) {
return true;
}
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 &&
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"completed",
)
) {
return true;
}
return (
this.handleCompletedContextCompactionItem(parsed.item) ||
this.handleRegisteredSubAgentActivity(parsed.item)
);
}
private emitCodexToolTimelineItem(
timelineItem: ToolCallTimelineItem,
subAgentCallId: string | null,
childThreadId?: string | null,
): void {
if (!subAgentCallId) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
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 emitSubAgentActivityUpdate(
callId: string,
status?: ToolCallTimelineItem["status"],
options?: { reopen?: boolean; subAgentType?: string },
): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") {
return;
}
let resolvedStatus = status ?? state.toolCall.status;
if (
status === "running" &&
!options?.reopen &&
isTerminalSubAgentStatus(state.toolCall.status)
) {
resolvedStatus = state.toolCall.status;
}
const baseToolCall = options?.subAgentType
? {
...state.toolCall,
detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
}
: state.toolCall;
const nextToolCall: ToolCallTimelineItem =
resolvedStatus === "failed"
? {
...baseToolCall,
status: "failed",
error: state.toolCall.error ?? { message: "Sub-agent failed" },
}
: {
...baseToolCall,
status: resolvedStatus,
error: null,
};
// The card is a launch marker: the child's activity streams to its own timeline, so the card
// is re-sent only when its status or label changes.
const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall);
state.toolCall = nextToolCall;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
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,
parentSubagentId: state.parentSubagentId,
},
});
}
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.pendingAgentMessages.delete(itemId);
this.pendingReasoning.delete(itemId);
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(callId);
}
private handleSubAgentContextCompactionItem(
callId: string,
childThreadId: string,
item: { id?: string; type?: string; [key: string]: unknown },
status: "loading" | "completed",
): boolean {
if (!this.isContextCompactionItem(item)) {
return false;
}
if (status === "loading") {
this.pendingChildCompactionThreadIds.add(childThreadId);
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
} else {
this.settleChildCompaction(childThreadId);
}
this.emitSubAgentActivityUpdate(callId);
return true;
}
private settleChildCompaction(childThreadId: string): void {
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
}
}
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.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.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.background.changed(this.currentThreadId);
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", { reopen: true });
return;
}
this.markCurrentThreadSaved(parsed.threadId);
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 });
}
// Codex has written the rollout by the time it announces a thread's turn, including a
// goal's or a compaction's. From here on the thread can be reported and resumed.
private markCurrentThreadSaved(threadId: string | null): void {
const current = this.currentThreadId;
if (!current || (threadId !== null && threadId !== current)) return;
if (this.savedThreadId === current) return;
this.savedThreadId = current;
this.cachedRuntimeInfo = null;
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current });
}
private handleTurnCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
let status: ToolCallTimelineItem["status"] = "completed";
if (parsed.status === "failed") {
status = "failed";
} else if (parsed.status === "interrupted") {
status = "canceled";
}
this.emitSubAgentActivityUpdate(subAgentCallId, status);
return;
}
this.completePendingRootCompactions();
// What a turn that was not interrupted leaves running, it left on purpose; no later
// interrupt may end it.
if (parsed.status !== "interrupted" && this.currentTurnId) {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: parsed.errorMessage ?? "Codex turn failed",
});
} else if (parsed.status === "interrupted") {
this.dismissInterruptedAsyncQuestions();
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.pendingToolCallDetails.clear();
this.pendingAssistantMessageBoundary = false;
this.warnedIncompleteEditToolCallIds.clear();
this.pendingRootCompactionItemIds.clear();
this.pendingAnonymousRootCompactions = 0;
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 trackPendingRootCompaction(itemId?: string): void {
if (itemId) {
this.pendingRootCompactionItemIds.add(itemId);
return;
}
this.pendingAnonymousRootCompactions += 1;
}
private consumePendingRootCompaction(itemId?: string): ConsumedRootCompaction | undefined {
if (itemId) {
if (this.pendingRootCompactionItemIds.delete(itemId)) {
return { itemId };
}
if (
this.pendingRootCompactionItemIds.size === 0 &&
this.pendingAnonymousRootCompactions > 0
) {
this.pendingAnonymousRootCompactions -= 1;
return {};
}
return undefined;
}
const pendingItemId = this.pendingRootCompactionItemIds.values().next().value;
if (typeof pendingItemId === "string") {
this.pendingRootCompactionItemIds.delete(pendingItemId);
return { itemId: pendingItemId };
}
if (this.pendingAnonymousRootCompactions > 0) {
this.pendingAnonymousRootCompactions -= 1;
return {};
}
return undefined;
}
private completePendingRootCompactions(): void {
// Some Codex builds end a turn without completing the contextCompaction
// item. Close every loading timeline row before emitting the terminal turn.
for (const itemId of this.pendingRootCompactionItemIds) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed", itemId),
});
}
for (let index = 0; index < this.pendingAnonymousRootCompactions; index += 1) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed"),
});
}
this.pendingRootCompactionItemIds.clear();
this.pendingAnonymousRootCompactions = 0;
}
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;
}
const pendingItemId = this.consumePendingRootCompaction()?.itemId;
this.unpairedCompactionNotificationCompletions += 1;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed", pendingItemId),
...(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 receiveAsyncQuestion(threadId: string | null, item: unknown): void {
if (threadId !== this.currentThreadId) return;
const request = this.asyncQuestions.receive(item);
if (request)
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
private dismissInterruptedAsyncQuestions(): void {
const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" };
for (const request of this.asyncQuestions.pending()) {
const prepared = this.asyncQuestions.prepareResponse(request.id, resolution);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId: request.id,
resolution,
});
}
}
private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void {
const retainedIds = new Set(
timeline.flatMap(({ item }) =>
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
),
);
for (const requestId of this.asyncQuestions.retain(retainedIds)) {
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: { behavior: "deny", message: "Removed by rewind" },
});
}
}
private ownsNotificationTurn(turnId: string | null): boolean {
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
// 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 (parsed.item.id) {
this.pendingToolCallDetails.delete(parsed.item.id);
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const foregroundTurnId = this.activeForegroundTurnId;
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem) {
return;
}
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
const registeredChildThreadIds =
timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
: [];
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
await 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)) {
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true;
}
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
await 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);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
timelineItem: AgentTimelineItem,
itemId: string | null | undefined,
): itemId is string {
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 async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (
childSubAgentCallId &&
parsed.threadId &&
this.handleSubAgentContextCompactionItem(
childSubAgentCallId,
parsed.threadId,
parsed.item,
"loading",
)
) {
return;
}
if (this.isContextCompactionItem(parsed.item)) {
this.trackPendingRootCompaction(parsed.item.id);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("loading", parsed.item.id),
});
return;
}
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem || timelineItem.type !== "tool_call") {
return;
}
this.pendingToolCallDetails.set(timelineItem.callId, timelineItem.detail);
const registeredChildThreadIds = this.registerSubAgentToolCall({
timelineItem,
rawItem: parsed.item,
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
});
if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
);
const itemId = parsed.item.id;
if (normalizedItemType === "commandExecution") {
this.rememberTerminalProcessForItem(parsed.item);
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);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private async handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): Promise<void> {
const itemId = parsed.item.id;
const timelineItem = await 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);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
return;
}
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId, parsed.turnId)) {
return;
}
const clientMessageId = timelineItem.clientMessageId ?? this.activeClientMessageId;
const item = clientMessageId ? { ...timelineItem, clientMessageId } : 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 processId = extractCodexTerminalSessionId(output ?? undefined);
if (processId) {
this.rememberTerminalProcess(processId, command);
}
}
private rememberTerminalProcessForItem(item: { [key: string]: unknown }): void {
if (typeof item.processId === "string" && item.processId) {
this.rememberTerminalProcess(item.processId, item.command);
}
}
private rememberTerminalProcess(processId: string, command: unknown): void {
const displayCommand = normalizeCommandExecutionCommand(command);
if (!displayCommand) {
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(),
// Set when several approval callbacks belong to one item; it names the callback.
approvalId: z.string().nullable().optional(),
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.approvalId ?? 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 async handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
await this.pendingNotifications;
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 detail: ToolCallTimelineItem["detail"] = this.pendingToolCallDetails.get(
parsed.itemId,
) ?? {
type: "unknown",
input: { reason: parsed.reason ?? null },
output: null,
};
const title =
detail.type === "edit" && detail.filePath
? `Apply file changes: ${detail.filePath}`
: "Apply file changes";
const request: AgentPermissionRequest = {
id: requestId,
provider: CODEX_PROVIDER,
name: "CodexFileChange",
kind: "tool",
title,
description: parsed.reason ?? undefined,
detail,
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(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
return {
...this.deps,
codexHome: resolveCodexHomeDir(buildCodexAppServerEnv(this.runtimeSettings, launchEnv)),
customCodexConfig: this.customProviderConfig(),
};
}
private customProviderConfig(): CodexCustomProviderConfig | null {
return 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(signal?: AbortSignal): Promise<boolean> {
if (signal) return this.probeAutoReviewEnabled(signal);
if (!this.autoReviewEnabledPromise) {
this.autoReviewEnabledPromise = this.probeAutoReviewEnabled();
}
return this.autoReviewEnabledPromise;
}
private async probeAutoReviewEnabled(signal?: AbortSignal): Promise<boolean> {
try {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
signal?.throwIfAborted();
const versionOutput = await resolveBinaryVersion(launchPrefix.command, signal);
signal?.throwIfAborted();
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) {
if (signal?.aborted) throw signal.reason;
this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate");
return false;
}
}
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, 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"],
baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, 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 environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
const session = new CodexAppServerAgentSession(
sessionConfig,
null,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
{ ...this.sessionDeps(launchContext?.env), environment },
options?.persistSession === false,
goalsEnabled,
autoReviewEnabled,
launchContext?.agentId,
"interactive",
);
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 environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
const session = new CodexAppServerAgentSession(
merged,
handle,
this.logger,
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
{ ...this.sessionDeps(launchContext?.env), environment },
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;
const scanLimit = Math.min(options?.scanLimit ?? limit, 500);
// 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(scanLimit, 50) : scanLimit;
// Codex records each thread under the model provider that ran it and,
// unless told otherwise, lists only the provider its own config selects.
// Include legacy custom ids, but keep Codex built-in sessions separate.
const customProviderConfig = this.customProviderConfig();
let modelProviders: string[] | undefined;
if (customProviderConfig && this.deps.customProvider) {
modelProviders = [customProviderConfig.model_provider];
if (!CODEX_BUILTIN_MODEL_PROVIDER_IDS.has(this.deps.customProvider.id)) {
modelProviders.push(this.deps.customProvider.id);
}
}
const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit,
cwd: options?.cwd,
...(modelProviders ? { modelProviders } : {}),
});
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 getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
// This client discovers through host configuration, independent of project cwd.
return "host";
}
async fetchCatalog(
_options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const [models, autoReviewEnabled] = await Promise.all([
this.fetchModelsFromAppServer(context),
runProviderRefreshActivity(context, "version", () =>
this.resolveAutoReviewEnabled(context?.signal),
),
]);
return {
models,
defaultModeId: autoReviewEnabled ? "auto-review" : DEFAULT_CODEX_MODE_ID,
modes: autoReviewEnabled
? CODEX_MODES
: CODEX_MODES.filter((mode) => mode.id !== "auto-review"),
};
}
async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> {
return (await this.resolveAutoReviewEnabled(input.signal))
? "auto-review"
: DEFAULT_CODEX_MODE_ID;
}
private async fetchModelsFromAppServer(
context?: ProviderRefreshContext,
): Promise<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
let client: CodexAppServerClient | undefined;
let disposePromise: Promise<void> | undefined;
const dispose = () => {
if (!client) return Promise.resolve();
disposePromise ??= client.dispose();
return disposePromise;
};
const handleAbort = () => void dispose().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try {
await runProviderRefreshActivity(context, "app-server.start", async () => {
const child = await this.spawnAppServer();
client = new CodexAppServerClient(child, this.logger);
if (context?.signal.aborted) await dispose();
});
if (!client) throw new Error("Codex app-server did not start");
await runProviderRefreshActivity(context, "initialize", () =>
client!.request("initialize", buildCodexAppServerInitializeParams()),
);
client.notify("initialized", {});
const rawResponse = await runProviderRefreshActivity(context, "model/list", () =>
client!.request("model/list", {}),
);
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
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 {
context?.signal.removeEventListener("abort", handleAbort);
await dispose();
}
}
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
await this.updateNativeThreadArchiveState(handle, "archive");
}
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
await this.updateNativeThreadArchiveState(handle, "restore");
}
private async updateNativeThreadArchiveState(
handle: AgentPersistenceHandle,
state: "archive" | "restore",
): 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", {});
if (state === "archive") {
await client.request("thread/archive", { threadId });
return;
}
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";
}