import process from 'node:process'; import { copyFile, mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { tmpdir } from 'node:os'; import { CODEX_APP_SERVER_ARGS_ENV, CODEX_APP_SERVER_COMMAND_ENV, REQUEST_TIMEOUT_MS, } from '../config/defaults.js'; import type { PendingServerRequest, RuntimeOperation } from '../types/codex.js'; import { AppServerClient, type BridgedOperationResult } from './app-server-client.js'; import { buildModelCatalog, describeAllowedModels, resolveModel, type ModelCatalog, type ModelInfo, } from './model-catalog.js'; import { ProfileManager, type CodexProfile, } from './profile-manager.js'; import type { ReasoningEffortLevel } from './reasoning-options.js'; interface RuntimeRequestOptions { timeoutMs?: number | undefined; } interface RuntimeBridgeOptions extends RuntimeRequestOptions { threadId?: string | undefined; bridgeTimeoutMs?: number | undefined; } interface RuntimeCommandOptions { command?: string | undefined; args?: string[] | undefined; env?: NodeJS.ProcessEnv | undefined; } function parseArgs(raw: string | undefined): string[] { if (!raw || !raw.trim()) { return ['app-server', '--listen', 'stdio://']; } const trimmed = raw.trim(); if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed) as unknown; if (Array.isArray(parsed) && parsed.every((value) => typeof value === 'string')) { return parsed; } } catch { // fall through to token split } } return trimmed.split(/\s+/g).filter(Boolean); } function isRateLimitSignal(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); const normalized = message.toLowerCase(); return ( normalized.includes('rate limit') || normalized.includes('usage limit') || normalized.includes('usagelimitexceeded') || normalized.includes('429') ); } function isAuthSignal(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); const normalized = message.toLowerCase(); return ( normalized.includes('unauthorized') || normalized.includes('requires auth') || normalized.includes('authentication') || normalized.includes('login') ); } function isConfigCompatSignal(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); const normalized = message.toLowerCase(); return ( normalized.includes('error deriving config') || normalized.includes('agentroletoml') || (normalized.includes('invalid type: integer') && normalized.includes('agents')) ); } function sanitizeConfigForCompatibility(rawConfig: string): string { const lines = rawConfig.split(/\r?\n/g); const sanitized: string[] = []; let skippingAgentsSection = false; for (const line of lines) { const trimmed = line.trim(); const isSection = trimmed.startsWith('[') && trimmed.endsWith(']'); if (isSection) { if (trimmed === '[agents]' || trimmed.startsWith('[agents.')) { skippingAgentsSection = true; continue; } skippingAgentsSection = false; } if (skippingAgentsSection) { continue; } sanitized.push(line); } return sanitized.join('\n'); } export interface ModelResolutionResult { resolvedModel: string; remappedFrom?: string | undefined; allowedModels: string[]; } export class CodexRuntime { private readonly profileManager: ProfileManager; private readonly command: string; private readonly args: string[]; private readonly env: NodeJS.ProcessEnv; private readonly clients = new Map(); private readonly clientStarting = new Map>(); private readonly effectiveCodexHomes = new Map(); private modelCatalog?: ModelCatalog | undefined; private readonly knownThreadIds = new Set(); private readonly loadedThreadIds = new Set(); constructor(commandOptions: RuntimeCommandOptions = {}) { this.profileManager = ProfileManager.fromEnvironment(); this.command = commandOptions.command ?? process.env[CODEX_APP_SERVER_COMMAND_ENV] ?? 'codex'; this.args = commandOptions.args ?? parseArgs(process.env[CODEX_APP_SERVER_ARGS_ENV]); this.env = commandOptions.env ?? process.env; } async shutdown(): Promise { await Promise.all( [...this.clients.values()].map((client) => client.stop()), ); this.clients.clear(); this.loadedThreadIds.clear(); } getPendingServerRequests(includeResolved = false): PendingServerRequest[] { return this.getCurrentClient().listServerRequests(includeResolved); } getPendingServerRequest(id: string | number): PendingServerRequest | undefined { return this.getCurrentClient().getServerRequest(id); } async respondToServerRequest(id: string | number, payload: unknown): Promise { await this.getCurrentClient().respondToServerRequest(id, payload); } async waitForOperation(operationId: string, timeoutMs: number, pollIntervalMs: number) { return this.getCurrentClient().waitForOperation(operationId, { timeoutMs, pollIntervalMs, }); } getOperation(operationId: string) { return this.getCurrentClient().getOperation(operationId); } async request(method: string, params: unknown, options: RuntimeRequestOptions = {}): Promise { const response = await this.withFailover(async (client, profile) => { try { return await client.request( method, params, { timeoutMs: options.timeoutMs ?? REQUEST_TIMEOUT_MS }, ); } catch (error) { if (!isConfigCompatSignal(error)) { throw error; } const compatClient = await this.activateCompatibilityClient(profile); return await compatClient.request( method, params, { timeoutMs: options.timeoutMs ?? REQUEST_TIMEOUT_MS }, ); } }); this.trackLoadedThread(method, response, params); return response; } async requestWithBridge( method: string, params: unknown, options: RuntimeBridgeOptions = {}, ): Promise { const response = await this.withFailover(async (client, profile) => { try { return await client.requestWithBridge( method, params, { timeoutMs: options.timeoutMs ?? REQUEST_TIMEOUT_MS, bridgeTimeoutMs: options.bridgeTimeoutMs, threadId: options.threadId, }, ); } catch (error) { if (!isConfigCompatSignal(error)) { throw error; } const compatClient = await this.activateCompatibilityClient(profile); return await compatClient.requestWithBridge( method, params, { timeoutMs: options.timeoutMs ?? REQUEST_TIMEOUT_MS, bridgeTimeoutMs: options.bridgeTimeoutMs, threadId: options.threadId, }, ); } }); this.trackLoadedThread(method, response.result ?? response, params); return response; } async listModels(includeHidden = false): Promise { const response = await this.request('model/list', { includeHidden }) as { data?: Array>; }; const models = (response.data ?? []).map((row) => ({ id: String(row.id), model: String(row.model), displayName: String(row.displayName), hidden: Boolean(row.hidden), upgrade: typeof row.upgrade === 'string' ? row.upgrade : null, isDefault: Boolean(row.isDefault), })); this.modelCatalog = buildModelCatalog(models); return includeHidden ? models : models.filter((model) => !model.hidden); } /** * Resolve a model only when the caller explicitly provided one. * When `requestedModel` is undefined the app-server will use the * model from its own config.toml – we must NOT pick one for it. */ private async resolveModelIfRequested( requestedModel: string | undefined, ): Promise { if (!requestedModel) { return undefined; } return this.resolveRequestedModel(requestedModel); } async resolveRequestedModel(requestedModel?: string | undefined): Promise { if (!this.modelCatalog) { await this.listModels(true); } const catalog = this.modelCatalog; if (!catalog) { throw new Error('Failed to build model catalog from model/list.'); } try { const resolution = resolveModel(catalog, requestedModel); return { resolvedModel: resolution.resolved, remappedFrom: resolution.remappedFrom, allowedModels: catalog.visibleModels.map((model) => model.id).sort(), }; } catch (error) { if (error instanceof Error && error.message.startsWith('Unsupported model')) { throw new Error( `Unsupported model "${requestedModel}". Allowed models: ${describeAllowedModels(catalog)}`, ); } throw error; } } async listVisibleModelIds(): Promise { if (!this.modelCatalog) { await this.listModels(false); } return (this.modelCatalog?.visibleModels ?? []).map((model) => model.id).sort(); } buildThreadStartParams(input: { model?: string | undefined; effort?: ReasoningEffortLevel | undefined; cwd?: string | undefined; developerInstructions?: string | undefined; }): Promise<{ params: Record; remappedFrom?: string | undefined; }> { return this.resolveModelIfRequested(input.model).then((resolved) => { // Set approvalPolicy to 'on-request' so the Codex agent can use // request_user_input (which is disabled in 'never' mode). Our // pause-flow auto-approves commands, files, elicitations, and // permissions — so 'on-request' doesn't block execution. The // original stalling issue (v1.0.2) was caused by broken // dequeue/crash handling, not the approval policy itself. const params: Record = { ...(resolved ? { model: resolved.resolvedModel } : {}), cwd: input.cwd ?? process.cwd(), // approvalPolicy deliberately omitted — user's config.toml takes effect. // 'on-request' was tested and doesn't fix V11 (complex tasks die after // turn/plan/updated regardless). 'never' from config.toml auto-approves // everything, which is correct since our pause-flow handles approvals. ...(input.effort ? { reasoningEffort: input.effort } : {}), ...(input.developerInstructions ? { developerInstructions: input.developerInstructions } : {}), persistExtendedHistory: false, }; return { params, remappedFrom: resolved?.remappedFrom, }; }); } buildThreadResumeParams(input: { threadId: string; model?: string | undefined; effort?: ReasoningEffortLevel | undefined; cwd?: string | undefined; developerInstructions?: string | undefined; }): Promise<{ params: Record; remappedFrom?: string | undefined; }> { return this.resolveModelIfRequested(input.model).then((resolved) => { const params: Record = { threadId: input.threadId, ...(resolved ? { model: resolved.resolvedModel } : {}), cwd: input.cwd ?? process.cwd(), // approvalPolicy deliberately omitted — user's config.toml takes effect. // 'on-request' was tested and doesn't fix V11 (complex tasks die after // turn/plan/updated regardless). 'never' from config.toml auto-approves // everything, which is correct since our pause-flow handles approvals. ...(input.effort ? { reasoningEffort: input.effort } : {}), ...(input.developerInstructions ? { developerInstructions: input.developerInstructions } : {}), persistExtendedHistory: false, }; return { params, remappedFrom: resolved?.remappedFrom, }; }); } buildTurnStartParams(input: { threadId: string; userInput: string; model?: string | undefined; effort?: ReasoningEffortLevel | undefined; }): Promise<{ params: Record; remappedFrom?: string | undefined; }> { return this.resolveModelIfRequested(input.model).then((resolved) => { const resolvedModel = resolved?.resolvedModel ?? input.model ?? 'gpt-5.4'; return { params: { threadId: input.threadId, ...(resolved ? { model: resolved.resolvedModel } : {}), ...(input.effort ? { effort: input.effort } : {}), // NOTE: collaborationMode: 'plan' was removed after testing showed // it doesn't fix V11 (Codex exits code=0). V11 is purely upstream — // gpt-5.4(medium/high) tasks with multi-step prompts fail ~100% of // the time regardless of plan mode, while gpt-5.4(low) succeeds. // request_user_input requires plan mode but our auto-answer handles // it without needing plan mode. input: [{ type: 'text', text: input.userInput, text_elements: [], }], }, remappedFrom: resolved?.remappedFrom, }; }); } buildTurnSteerParams(input: { threadId: string; expectedTurnId: string; userInput: string; }): Record { return { threadId: input.threadId, expectedTurnId: input.expectedTurnId, input: [{ type: 'text', text: input.userInput, text_elements: [], }], }; } getOperationsForThread(threadId: string): RuntimeOperation[] { try { return this.getCurrentClient().listOperationsForThread(threadId); } catch { return []; } } getAllOperations(): RuntimeOperation[] { try { return this.getCurrentClient().listOperations(); } catch { return []; } } async getThreadEvents(threadId: string): Promise { return this.getCurrentClient().getThreadEvents(threadId); } rememberThreadId(threadId: string): void { this.knownThreadIds.add(threadId); } listKnownThreadIds(): string[] { return [...this.knownThreadIds].sort(); } async ensureThreadLoaded( threadId: string, model?: string | undefined, effort?: ReasoningEffortLevel | undefined, ): Promise { if (!this.knownThreadIds.has(threadId) || this.loadedThreadIds.has(threadId)) { return; } const built = await this.buildThreadResumeParams({ threadId, model, effort }); await this.request('thread/resume', built.params); } private async withFailover(operation: (client: AppServerClient, profile: CodexProfile) => Promise): Promise { let attempt = 0; let lastError: unknown; while (attempt < this.profileManager.getProfiles().length) { const profile = this.profileManager.getCurrentProfile(); const client = await this.getClient(profile); try { return await operation(client, profile); } catch (error) { lastError = error; if (!isRateLimitSignal(error) && !isAuthSignal(error)) { throw error; } await client.stop().catch(() => {}); this.clients.delete(profile.codexHome); this.loadedThreadIds.clear(); const reason = isRateLimitSignal(error) ? 'rate_limit' : 'auth'; const rotated = this.profileManager.markFailure(reason); if (!rotated.rotated) { throw error; } attempt += 1; } } throw lastError instanceof Error ? lastError : new Error(String(lastError)); } private getCurrentClient(): AppServerClient { const profile = this.profileManager.getCurrentProfile(); const existing = this.clients.get(profile.codexHome); if (!existing) { throw new Error(`No active Codex app-server client for profile ${profile.codexHome}`); } return existing; } private async getClient(profile: CodexProfile): Promise { const existing = this.clients.get(profile.codexHome); if (existing) { return existing; } const inflight = this.clientStarting.get(profile.codexHome); if (inflight) { return inflight; } const codexHome = this.effectiveCodexHomes.get(profile.codexHome) ?? profile.codexHome; const starting = (async () => { const client = new AppServerClient({ command: this.command, args: this.args, env: this.env, codexHome, appVersion: '0.1.0', }); await client.start(); this.clients.set(profile.codexHome, client); this.clientStarting.delete(profile.codexHome); return client; })(); this.clientStarting.set(profile.codexHome, starting); return starting; } private async activateCompatibilityClient(profile: CodexProfile): Promise { const existing = this.clients.get(profile.codexHome); if (existing) { await existing.stop().catch(() => {}); this.clients.delete(profile.codexHome); } if (!this.effectiveCodexHomes.has(profile.codexHome)) { const compatHome = await this.createCompatibilityHome(profile.codexHome); this.effectiveCodexHomes.set(profile.codexHome, compatHome); } this.loadedThreadIds.clear(); return await this.getClient(profile); } private async createCompatibilityHome(sourceCodexHome: string): Promise { const compatHome = await mkdtemp(join(tmpdir(), 'mcp-codex-compat-')); await mkdir(compatHome, { recursive: true }); const configPath = join(sourceCodexHome, 'config.toml'); const rawConfig = await readFile(configPath, 'utf8'); await writeFile(join(compatHome, 'config.toml'), sanitizeConfigForCompatibility(rawConfig), 'utf8'); for (const fileName of ['auth.json', 'version.json']) { try { await copyFile(join(sourceCodexHome, fileName), join(compatHome, basename(fileName))); } catch { // optional compatibility files } } return compatHome; } private trackLoadedThread(method: string, response: unknown, params: unknown): void { if (method === 'thread/start' || method === 'thread/resume') { const object = response as { thread?: { id?: string } }; const threadId = object.thread?.id; if (typeof threadId === 'string') { this.rememberThreadId(threadId); this.loadedThreadIds.add(threadId); } return; } if (method === 'turn/start') { const object = params as { threadId?: string }; if (typeof object.threadId === 'string') { this.loadedThreadIds.add(object.threadId); } } } }