// --------------------------------------------------------------------------- // CodexAdapter — wraps CodexRuntime behind the BaseProviderAdapter contract // Spec reference: §3.4 // --------------------------------------------------------------------------- import type { Provider } from '../task/task-state.js'; import type { TaskHandle } from '../task/task-handle.js'; import type { ProviderCapabilities } from './provider-capabilities.js'; import { CODEX_CAPABILITIES } from './provider-capabilities.js'; import { BaseProviderAdapter, type ProviderSpawnOptions, type AvailabilityResult, } from './base-adapter.js'; import { CodexRuntime } from '../services/codex-runtime.js'; import type { AppServerClient } from '../services/app-server-client.js'; import { attachPauseFlow } from './codex-pause-flow.js'; import { attachEventCapture } from './codex-event-capture.js'; import type { TaskFileWriter } from '../services/task-file-writer.js'; import { createRequire } from 'node:module'; import { REQUEST_TIMEOUT_MS } from '../config/defaults.js'; import { formatTimelineLine } from '../timeline-writer.js'; const require = createRequire(import.meta.url); const SERVER_VERSION: string = (require('../../package.json') as { version: string }).version; // --------------------------------------------------------------------------- // Options // --------------------------------------------------------------------------- export interface CodexAdapterOptions { command?: string; args?: string[]; env?: NodeJS.ProcessEnv; fileWriter?: TaskFileWriter; } // --------------------------------------------------------------------------- // Adapter // --------------------------------------------------------------------------- export class CodexAdapter extends BaseProviderAdapter { readonly id: Provider = 'codex'; readonly displayName = 'Codex'; private readonly options: CodexAdapterOptions; private runtime?: CodexRuntime; constructor(options: CodexAdapterOptions) { super(); this.options = options; } checkAvailability(): AvailabilityResult { return { available: true }; } getCapabilities(): ProviderCapabilities { return CODEX_CAPABILITIES; } getStats(): Record { return {}; } // ------------------------------------------------------------------------- // Core session lifecycle // ------------------------------------------------------------------------- protected async executeSession( handle: TaskHandle, prompt: string, _signal: AbortSignal, options: ProviderSpawnOptions, ): Promise { const runtime = this.getRuntime(); let detachPauseFlow: (() => void) | undefined; let detachEventCapture: (() => void) | undefined; let removeExitListener: (() => void) | undefined; let heartbeatTimer: ReturnType | undefined; // Clean up listeners only once, regardless of which path triggers it. const cleanup = () => { detachPauseFlow?.(); detachPauseFlow = undefined; detachEventCapture?.(); detachEventCapture = undefined; removeExitListener?.(); removeExitListener = undefined; if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = undefined; } }; try { // 1. Create a new thread const { params: threadParams } = await runtime.buildThreadStartParams({ model: options.model, effort: options.effort, cwd: options.cwd, developerInstructions: options.developerInstructions, }); const threadResult = await runtime.request('thread/start', threadParams) as { thread?: { id?: string }; }; const threadId = threadResult?.thread?.id; if (!threadId) { throw new Error('thread/start did not return a thread ID'); } // 2. Mark running with the thread ID as session identifier handle.markRunning(threadId); // Stamp version in events + timeline so tester can confirm which code is running if (this.options.fileWriter) { const ts = new Date().toTimeString().slice(0, 8); this.options.fileWriter.appendEvent(handle.taskId, { method: '_server_version', version: SERVER_VERSION, threadId, }).catch(() => {}); this.options.fileWriter.appendTimeline(handle.taskId, `${ts} VERSION mcp-codex-worker v${SERVER_VERSION}`).catch(() => {}); } // 3. Attach pause-flow: translate server-requests → PendingQuestions // getCurrentClient() is private on CodexRuntime; access via cast. // Safe after a successful request() call guarantees the client exists. const client = (runtime as unknown as { getCurrentClient(): AppServerClient }) .getCurrentClient(); detachPauseFlow = attachPauseFlow(client, handle, threadId, this.options.fileWriter); detachEventCapture = attachEventCapture(client, handle, threadId, this.options.fileWriter); // 3b. Capture stderr for diagnostics — logs to verbose + events.jsonl. // Also accumulate recent stderr to detect auth token expiry on exit. let recentStderr = ''; let stderrSeenForTimeline = false; const onStderr = (chunk: string) => { handle.writeOutputFileOnly(`[stderr] ${chunk.trimEnd()}`); recentStderr += chunk; // Cap at 10KB to avoid unbounded growth on verbose processes if (recentStderr.length > 10_000) { recentStderr = recentStderr.slice(-10_000); } if (this.options.fileWriter) { this.options.fileWriter.appendEvent(handle.taskId, { method: '_stderr', data: chunk.trimEnd(), }).catch(() => {}); if (!stderrSeenForTimeline) { const tlResult = formatTimelineLine({ t: new Date().toISOString(), method: '_stderr', data: chunk.trimEnd() }); if (tlResult.line) { this.options.fileWriter.appendTimeline(handle.taskId, tlResult.line).catch(() => {}); } stderrSeenForTimeline = true; } } }; client.on('stderr', onStderr); // 3c. Listen for app-server exits. Detect the root cause from stderr // and produce an actionable error message. const onExit = (info: { code: number | null; signal: string | null }) => { if (handle.isAlive()) { const stderrLower = recentStderr.toLowerCase(); let errorMessage: string; if (stderrLower.includes('refresh token') && stderrLower.includes('already used')) { errorMessage = 'AUTH_TOKEN_EXPIRED: Codex refresh token was already consumed. Run `codex auth login` to re-authenticate, then retry.'; } else if (stderrLower.includes('unauthorized') || stderrLower.includes('requires auth') || stderrLower.includes('log out and sign in')) { errorMessage = 'AUTH_ERROR: Codex authentication failed. Run `codex auth login` to re-authenticate, then retry.'; } else if (stderrLower.includes('rate limit') || stderrLower.includes('usage limit')) { errorMessage = `RATE_LIMITED: Codex hit a rate or usage limit. Details in events.jsonl. Exit code=${String(info.code)}`; } else { errorMessage = `Codex app-server exited (code=${String(info.code)}, signal=${String(info.signal)})`; } handle.markFailed(errorMessage); } if (this.options.fileWriter) { this.options.fileWriter.appendEvent(handle.taskId, { method: '_process_exit', code: info.code, signal: info.signal, stderr_tail: recentStderr.slice(-2000), }).catch(() => {}); // Synthesize a turn/completed (status=failed) event so event analysis // has a clean terminal marker matching the documented protocol shape. this.options.fileWriter.appendEvent(handle.taskId, { method: 'turn/completed', synthetic: true, params: { turn: { status: 'failed', error: { message: recentStderr.slice(-500) || `process exited (code=${String(info.code)})` } } }, }).catch(() => {}); // Timeline: EXIT line const exitTl = formatTimelineLine({ t: new Date().toISOString(), method: '_process_exit', code: info.code, signal: info.signal }); if (exitTl.line) { this.options.fileWriter.appendTimeline(handle.taskId, exitTl.line).catch(() => {}); } } cleanup(); }; client.on('exit', onExit); removeExitListener = () => { client.off('exit', onExit); client.off('stderr', onStderr); }; // 3d. Heartbeat — prevent Codex idle timeout during approval/question waits. // Sends a lightweight request every 10s to keep the connection alive. // Was 30s but V14 showed the process dying at ~24s during user_input wait. heartbeatTimer = setInterval(() => { runtime.request('account/rateLimits/read', {}).catch(() => {}); }, 10_000); heartbeatTimer.unref(); // 4. Build turn params and start the turn via bridged request const { params: turnParams } = await runtime.buildTurnStartParams({ threadId, userInput: prompt, model: options.model, effort: options.effort, }); const bridgeResult = await runtime.requestWithBridge( 'turn/start', turnParams, { threadId }, ); // 5. Handle bridge result if (bridgeResult.status === 'pending_request') { // Pause flow already queued the question on the handle and // markInputRequired was called. Keep listeners attached — // they will be cleaned up when the task reaches a terminal // state via message-task, or when the client exits. // // Send an immediate heartbeat to keep the connection alive // while the orchestrator processes the question. Without this, // the Codex process may exit before the heartbeat interval fires. runtime.request('account/rateLimits/read', {}).catch(() => {}); return; } if (bridgeResult.status === 'completed') { handle.markCompleted(); cleanup(); return; } // status === 'running' — the bridge timed out before completion; // poll until the operation settles. const timeoutMs = options.timeout > 0 ? options.timeout : REQUEST_TIMEOUT_MS; const op = await runtime.waitForOperation( bridgeResult.operationId, timeoutMs, 500, ); if (op.status === 'completed') { handle.markCompleted(); } else { handle.markFailed(op.error ?? 'Turn failed'); } cleanup(); } catch (err) { cleanup(); throw err; } } async shutdown(): Promise { await this.runtime?.shutdown(); } // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- private getRuntime(): CodexRuntime { if (!this.runtime) { this.runtime = new CodexRuntime({ command: this.options.command, args: this.options.args, env: this.options.env, }); } return this.runtime; } }