mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* 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>
7950 lines
260 KiB
TypeScript
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";
|
|
}
|