import { homedir } from 'node:os'; import { join } from 'node:path'; import type { SpawnTaskInput, WaitTaskInput, RespondTaskInput, MessageTaskInput, CancelTaskInput, } from './mcp/tool-definitions.js'; import { OPERATION_BRIDGE_TIMEOUT_MS, OPERATION_POLL_INTERVAL_MS } from './config/defaults.js'; import { CodexRuntime } from './services/codex-runtime.js'; import { TaskManager } from './task/task-manager.js'; import type { TaskState } from './task/task-state.js'; import { TaskStatus, isTerminalStatus } from './task/task-state.js'; import { TaskFileWriter } from './services/task-file-writer.js'; import { ProviderRegistry } from './execution/provider-registry.js'; import { CodexAdapter } from './execution/codex-adapter.js'; import { mapToWireState } from './task/wire-state-mapper.js'; import { computePollFrequency } from './mcp/sep1686-handlers.js'; import { renderScoreboard, renderTaskDetail, renderSummaryLog, renderVerboseLog, } from './mcp/resource-renderers.js'; import { validateResponseAgainstCapabilities } from './execution/provider-capabilities.js'; import { parseReasoning, type ParsedReasoning } from './services/reasoning-options.js'; import { buildDeveloperInstructions } from './services/fleet-mode.js'; import { buildSpawnGuidance, buildWaitGuidance, buildRespondGuidance, buildMessageGuidance, buildMessageTerminalGuidance, buildCancelGuidance, } from './mcp/next-action-guidance.js'; import { applyRecovery } from './task/task-persistence.js'; /** High-frequency delta and internal lifecycle methods excluded from events/summary resource. */ const EVENT_SKIP_METHODS = new Set([ 'item/reasoning/summaryTextDelta', 'item/reasoning/textDelta', 'item/agentMessage/delta', 'item/commandExecution/outputDelta', 'item/fileChange/outputDelta', 'item/plan/delta', 'hook/started', 'hook/completed', ]); export class CodexWorkerApp { private readonly runtime = new CodexRuntime(); private taskManager!: TaskManager; private registry!: ProviderRegistry; private fileWriter!: TaskFileWriter; private persistenceRoot!: string; private evictionInterval?: ReturnType; async initialize(): Promise { // Create TaskManager with persistence this.persistenceRoot = join(homedir(), '.mcp-codex-worker', 'tasks'); this.fileWriter = new TaskFileWriter(this.persistenceRoot); this.taskManager = new TaskManager({ persistenceRoot: this.persistenceRoot, fileWriter: this.fileWriter }); // Restore persisted tasks from disk (crash recovery) const persistedIds = await this.fileWriter.listPersistedTaskIds(); for (const taskId of persistedIds) { const meta = await this.fileWriter.readMeta(taskId); if (meta) { this.taskManager.restoreTask(applyRecovery(meta, false)); } } // Start eviction sweep for terminal tasks this.evictionInterval = setInterval(() => { this.taskManager.evictExpired(); }, 60_000); this.evictionInterval.unref(); // Create ProviderRegistry and register the Codex adapter this.registry = new ProviderRegistry(); const codexAdapter = new CodexAdapter({ fileWriter: this.fileWriter }); this.registry.register(codexAdapter); this.registry.setDefault('codex'); } getTaskManager(): TaskManager { return this.taskManager; } async shutdown(): Promise { if (this.evictionInterval) clearInterval(this.evictionInterval); await this.runtime.shutdown(); } async readResource(uri: string): Promise<{ mimeType: string; text: string }> { // ---- Task tracking resources ---- if (uri === 'task:///all') { return { mimeType: 'text/plain', text: renderScoreboard(this.taskManager.getAllTasks()), }; } // Pre-filtered timeline — one line per meaningful event const taskTimelineMatch = /^task:\/\/\/([^/]+)\/timeline$/.exec(uri); if (taskTimelineMatch) { const taskId = decodeURIComponent(taskTimelineMatch[1]!); const content = await this.fileWriter.readTimeline(taskId); if (content !== null) { return { mimeType: 'text/plain', text: content }; } return { mimeType: 'text/plain', text: '(no timeline yet)' }; } // Filtered events — excludes high-frequency delta events (reasoning, message, command output) const taskEventsSummaryMatch = /^task:\/\/\/([^/]+)\/events\/summary$/.exec(uri); if (taskEventsSummaryMatch) { const taskId = decodeURIComponent(taskEventsSummaryMatch[1]!); const content = await this.fileWriter.readEventsLog(taskId); if (content !== null) { const filtered = content.split('\n').filter(line => { if (!line.trim()) return false; try { const event = JSON.parse(line) as Record; return !EVENT_SKIP_METHODS.has(event.method as string); } catch { return true; } }).join('\n'); return { mimeType: 'application/jsonl', text: filtered }; } return { mimeType: 'text/plain', text: `No events log for task: ${taskId}` }; } // Full events — all events unfiltered const taskEventsMatch = /^task:\/\/\/([^/]+)\/events$/.exec(uri); if (taskEventsMatch) { const taskId = decodeURIComponent(taskEventsMatch[1]!); const content = await this.fileWriter.readEventsLog(taskId); if (content !== null) { return { mimeType: 'application/jsonl', text: content }; } return { mimeType: 'text/plain', text: `No events log for task: ${taskId}` }; } const taskVerboseLogMatch = /^task:\/\/\/([^/]+)\/log\.verbose$/.exec(uri); if (taskVerboseLogMatch) { const taskId = decodeURIComponent(taskVerboseLogMatch[1]!); // Verbose log reads from disk (writeOutputFileOnly goes to disk, not ring buffer) const content = await this.fileWriter.readVerboseLog(taskId); if (content !== null) { return { mimeType: 'text/plain', text: `# Verbose Log: ${taskId}\n\n${content}` }; } const task = this.taskManager.getTask(taskId); if (!task) { return { mimeType: 'text/plain', text: `Task not found: ${taskId}` }; } return { mimeType: 'text/plain', text: renderVerboseLog(task) }; } const taskSummaryLogMatch = /^task:\/\/\/([^/]+)\/log$/.exec(uri); if (taskSummaryLogMatch) { const taskId = decodeURIComponent(taskSummaryLogMatch[1]!); const task = this.taskManager.getTask(taskId); if (task) { return { mimeType: 'text/plain', text: renderSummaryLog(task) }; } // Fallback: read from disk (task may have been evicted) const content = await this.fileWriter.readSummaryLog(taskId); if (content !== null) { return { mimeType: 'text/plain', text: `# Log: ${taskId} (from disk)\n\n${content}` }; } return { mimeType: 'text/plain', text: `Task not found: ${taskId}` }; } const taskDetailMatch = /^task:\/\/\/([^/]+)$/.exec(uri); if (taskDetailMatch) { const taskId = decodeURIComponent(taskDetailMatch[1]!); // Skip the template placeholder if (taskId === '{id}') { return { mimeType: 'text/markdown', text: 'Provide a real task ID in place of {id}.' }; } let task = this.taskManager.getTask(taskId); if (!task) { // Fallback: read from disk (task may have been evicted) task = await this.fileWriter.readMeta(taskId) ?? undefined; } if (!task) { return { mimeType: 'text/markdown', text: `Task not found: ${taskId}` }; } return { mimeType: 'text/markdown', text: renderTaskDetail(task) }; } return { mimeType: 'application/json', text: JSON.stringify({ error: `Unknown resource URI: ${uri}` }, null, 2), }; } async callTool(name: string, args: unknown): Promise { // The SDK already validated args via the Zod schema passed to registerTool. // We just route to the correct handler — no need to re-validate. switch (name) { case 'spawn-task': return this.handleSpawnTask(args as SpawnTaskInput); case 'wait-task': return this.handleWaitTask(args as WaitTaskInput); case 'respond-task': return this.handleRespondTask(args as RespondTaskInput); case 'message-task': return this.handleMessageTask(args as MessageTaskInput); case 'cancel-task': return this.handleCancelTask(args as CancelTaskInput); default: throw new Error(`Unhandled tool: ${name}`); } } /** * Like callTool but threads a progress reporter through wait-task. * Claude Code sends a progressToken via _meta — we push progress * notifications during the polling loop so the client shows live status. */ async callToolWithProgress( name: string, args: unknown, reportProgress?: (progress: number, total: number, message?: string) => Promise, ): Promise { if (name === 'wait-task') { return this.handleWaitTask(args as WaitTaskInput, reportProgress); } return this.callTool(name, args); } // --------------------------------------------------------------------------- // Unified task tool handlers // --------------------------------------------------------------------------- private async handleSpawnTask(input: SpawnTaskInput): Promise { const provider = input.provider ?? 'codex'; const taskType = input.task_type ?? 'coder'; // Split `gpt-5.4(effort)` into model id + reasoning effort level. The // two travel as separate fields through the adapter chain so Codex can // receive them as `model` + `reasoningEffort`/`effort`. let parsedReasoning: ParsedReasoning | undefined; if (input.reasoning !== undefined) { parsedReasoning = parseReasoning(input.reasoning); } // 1. Create the task const createInput: Parameters[0] = { prompt: input.prompt, cwd: input.cwd ?? process.cwd(), provider, taskType, }; if (parsedReasoning !== undefined) { createInput.model = parsedReasoning.model; createInput.effort = parsedReasoning.effort; } if (input.timeout_ms !== undefined) createInput.timeoutMs = input.timeout_ms; if (input.labels !== undefined) createInput.labels = input.labels; if (input.depends_on !== undefined) createInput.dependsOn = input.depends_on; if (input.keep_alive !== undefined) createInput.keepAlive = input.keep_alive; const task = this.taskManager.createTask(createInput); // 2. Get handle const handle = this.taskManager.getHandle(task.id); if (!handle) { throw new Error(`Failed to get handle for task ${task.id}`); } // 3. Get adapter const adapter = this.registry.getAdapter(provider) ?? this.registry.selectForTaskType(taskType); if (!adapter) { throw new Error(`No provider adapter available for ${provider}/${taskType}`); } // 4. Build spawn options const spawnOptions: Parameters[0] = { taskId: task.id, prompt: input.prompt, cwd: input.cwd ?? process.cwd(), timeout: input.timeout_ms ?? 0, }; if (parsedReasoning !== undefined) { spawnOptions.model = parsedReasoning.model; spawnOptions.effort = parsedReasoning.effort; } // Compose developer instructions: user instructions + question guidance + fleet sentinel const devInstructions = buildDeveloperInstructions(input.developer_instructions, provider); if (devInstructions !== undefined) { spawnOptions.developerInstructions = devInstructions; } // 5. Dispatch asynchronously (don't block MCP response) setImmediate(() => { adapter.spawn(spawnOptions, handle).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); if (handle.isAlive()) { handle.markFailed(`Spawn error: ${message}`); } }); }); // 6. Bridge window — poll for up to OPERATION_BRIDGE_TIMEOUT_MS to see if // a question arrived immediately (fast first response) const bridgeEnd = Date.now() + OPERATION_BRIDGE_TIMEOUT_MS; let latestTask: TaskState | undefined = task; while (Date.now() < bridgeEnd) { await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_INTERVAL_MS)); latestTask = this.taskManager.getTask(task.id); if (!latestTask) break; if (isTerminalStatus(latestTask.status) || latestTask.status === TaskStatus.WAITING_ANSWER) { break; } } const current = latestTask ?? task; const wireStatus = mapToWireState(current.status); const pollFreq = computePollFrequency(current); const taskDir = join(this.persistenceRoot, task.id); const result: Record = { task_id: task.id, status: wireStatus, poll_frequency: pollFreq, resources: { scoreboard: 'task:///all', detail: `task:///${task.id}`, log: `task:///${task.id}/log`, }, disk_paths: { dir: taskDir, events_log: join(taskDir, 'events.jsonl'), timeline_log: join(taskDir, 'timeline.log'), meta: join(taskDir, 'meta.json'), }, }; if (current.labels.length > 0) { result['labels'] = current.labels; } if (current.sessionId) { result['provider_session_id'] = current.sessionId; } if (current.status === TaskStatus.WAITING_ANSWER && current.pendingQuestions.length > 0) { result['pending_question'] = current.pendingQuestions[0]; } // Append context-aware "what to do next" guidance const guidance = buildSpawnGuidance(current); const jsonBlock = JSON.stringify(result, null, 2); return jsonBlock + '\n' + guidance.join('\n'); } private async handleWaitTask( input: WaitTaskInput, reportProgress?: (progress: number, total: number, message?: string) => Promise, ): Promise { const timeoutMs = input.timeout_ms ?? 30_000; const pollIntervalMs = input.poll_interval_ms ?? 1_000; const taskId = input.task_id; const task = this.taskManager.getTask(taskId); if (!task) { throw new Error(`Task not found: ${taskId}`); } // Track last-sent progress to enforce monotonicity (MCP spec requirement) let lastSentPct = 0; const sendProgress = async (pct: number, msg: string) => { if (!reportProgress) return; // Enforce monotonic increase — never send a value lower than previous const safePct = Math.max(lastSentPct, pct); lastSentPct = safePct; await reportProgress(safePct, 100, msg); }; // Send initial progress: 0/100 = "waiting started" if (reportProgress && !isTerminalStatus(task.status)) { await sendProgress(0, `Waiting for task ${taskId}...`); } // Poll until terminal, input_required, or timeout const deadline = Date.now() + timeoutMs; const startMs = Date.now(); let current = task; let lastProgressLine = 0; let pollCount = 0; while (Date.now() < deadline) { if (isTerminalStatus(current.status) || current.status === TaskStatus.WAITING_ANSWER) { break; } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); const refreshed = this.taskManager.getTask(taskId); if (!refreshed) break; current = refreshed; pollCount++; if (reportProgress) { // Logarithmic scaling: first lines jump fast, then slows — avoids // flatline at 99% for prolific tasks. Log2(1)=0, Log2(10)≈3.3, // Log2(100)≈6.6, Log2(500)≈9 → *10 → caps at 90ish. const pct = Math.min(99, Math.max(1, Math.round(Math.log2(Math.max(1, current.output.length) + 1) * 10), )); if (current.output.length > lastProgressLine) { // New output — send the last line as progress message lastProgressLine = current.output.length; const lastLine = current.output[current.output.length - 1] ?? ''; await sendProgress(pct, lastLine.slice(0, 200)); } else if (pollCount % 5 === 0) { // Heartbeat every 5 polls even when output is static — prevents // the SDK from timing out the request during silent reasoning. const elapsedSec = Math.round((Date.now() - startMs) / 1000); await sendProgress(pct, `Agent working... (${elapsedSec}s, ${current.output.length} events)`); } } } // Final progress — must always fire so the client sees closure if (reportProgress) { if (isTerminalStatus(current.status)) { const msg = current.status === TaskStatus.COMPLETED ? `Task ${taskId} completed` : `Task ${taskId} ${current.status}: ${current.error ?? ''}`; await sendProgress(100, msg.slice(0, 200)); } else if (current.status === TaskStatus.WAITING_ANSWER) { await sendProgress(lastSentPct, `Task ${taskId} needs input — check pending_question`); } else { // Timed out — still working const elapsedSec = Math.round((Date.now() - startMs) / 1000); await sendProgress(99, `Wait timed out after ${elapsedSec}s — task still working (${current.output.length} events)`); } } const wireStatus = mapToWireState(current.status); const taskDir = join(this.persistenceRoot, taskId); const result: Record = { task_id: taskId, status: wireStatus, resources: { scoreboard: 'task:///all', detail: `task:///${taskId}`, log: `task:///${taskId}/log`, }, disk_paths: { dir: taskDir, events_log: join(taskDir, 'events.jsonl'), timeline_log: join(taskDir, 'timeline.log'), meta: join(taskDir, 'meta.json'), }, }; if (current.labels.length > 0) { result['labels'] = current.labels; } if (current.sessionId) { result['provider_session_id'] = current.sessionId; } if (current.status === TaskStatus.WAITING_ANSWER && current.pendingQuestions.length > 0) { result['pending_question'] = current.pendingQuestions[0]; } // On terminal states, include the full summary log from disk (not just // the ring buffer tail). This gives the orchestrator the complete // formatted output from the event capture module. if (isTerminalStatus(current.status)) { // Try summary log from disk first, then ring buffer, then a fallback message const summaryContent = await this.fileWriter.readSummaryLog(taskId); if (summaryContent) { result['output'] = summaryContent.trim().split('\n'); } else if (current.output.length > 0) { result['output'] = current.output.slice(-20); } else { // For fast tasks where summary.log was never created, point to events result['output'] = [`(no summary log — read task:///${taskId}/events for the full event trace)`]; } } else if (current.output.length > 0) { result['output'] = current.output.slice(-10); } if (current.error) { result['error'] = current.error; } if (current.tokenUsage) { result['token_usage'] = current.tokenUsage; if (current.tokenUsage.contextWindow && current.tokenUsage.contextWindow > 0) { result['pct_used'] = (Math.round(current.tokenUsage.totalTokens / current.tokenUsage.contextWindow * 1000) / 10).toFixed(1) + '%'; } } // For in-progress tasks, include liveness signals so the orchestrator // can distinguish "stuck" from "actively running". if (!isTerminalStatus(current.status) && current.status !== TaskStatus.WAITING_ANSWER) { result['output_lines'] = current.output.length; if (current.lastOutputAt) { result['last_activity'] = current.lastOutputAt; } if (current.startedAt) { result['elapsed_ms'] = Date.now() - new Date(current.startedAt).getTime(); } } const guidance = buildWaitGuidance(current); const jsonBlock = JSON.stringify(result, null, 2); return jsonBlock + '\n' + guidance.join('\n'); } private async handleRespondTask(input: RespondTaskInput): Promise { const taskId = input.task_id; const task = this.taskManager.getTask(taskId); if (!task) { throw new Error(`Task not found: ${taskId}`); } // If the task already reached a terminal state (e.g., the Codex // app-server process died and our exit listener marked it failed), // return the terminal status instead of trying to forward to a dead // client. The orchestrator needs to know the task is gone — not get // a confusing "No active client" error. if (isTerminalStatus(task.status)) { const wireStatus = mapToWireState(task.status); const taskDir = join(this.persistenceRoot, taskId); const result: Record = { task_id: taskId, status: wireStatus, error: task.error ?? 'Task is no longer running', resources: { scoreboard: 'task:///all', detail: `task:///${taskId}`, log: `task:///${taskId}/log`, }, disk_paths: { dir: taskDir, events_log: join(taskDir, 'events.jsonl'), timeline_log: join(taskDir, 'timeline.log'), meta: join(taskDir, 'meta.json'), }, }; const guidance = buildRespondGuidance(task); return JSON.stringify(result, null, 2) + '\n' + guidance.join('\n'); } const handle = this.taskManager.getHandle(taskId); if (!handle) { throw new Error(`No handle for task ${taskId}`); } // Validate response type against adapter capabilities const adapter = this.registry.getAdapter(task.provider); if (adapter) { const validation = validateResponseAgainstCapabilities( adapter.getCapabilities(), input.type, ); if (!validation.ok) { throw new Error(validation.reason ?? 'Unsupported response type'); } } // Peek at the head of the pending question queue — do NOT dequeue yet. // We only remove it after the response is successfully forwarded to // Codex, so a transient client crash leaves the question in place for // the orchestrator to retry. const questions = handle.getPendingQuestions(); const question = questions[0]; if (!question) { throw new Error(`No pending question for task ${taskId}`); } // Build payload based on response type and forward to provider let payload: unknown; switch (input.type) { case 'user_input': payload = { answers: input.answers }; break; case 'command_approval': payload = { decision: input.decision === 'accept' ? 'accept' : 'decline' }; break; case 'file_approval': payload = { decision: input.decision === 'accept' ? 'accept' : 'decline' }; break; case 'elicitation': payload = { action: input.action, content: input.content ?? null, _meta: null, }; break; case 'dynamic_tool': payload = { contentItems: [], success: !input.error, ...(input.result !== undefined ? { result: input.result } : {}), ...(input.error !== undefined ? { error: input.error } : {}), }; break; } // Forward the response to Codex. Only dequeue the question AFTER this // succeeds — if it throws (client crash, process exit), the question // stays in the queue so the orchestrator can retry. await this.runtime.respondToServerRequest(question.requestId, payload); handle.dequeuePendingQuestion(); // If queue is now empty and task was WAITING_ANSWER, resume tracking // The adapter's executeSession will continue automatically const currentTask = this.taskManager.getTask(taskId); const wireStatus = mapToWireState(currentTask?.status ?? task.status); const respondResult = { task_id: taskId, status: wireStatus, remaining_questions: currentTask?.pendingQuestions.length ?? 0, }; const guidance = buildRespondGuidance(currentTask ?? task); return JSON.stringify(respondResult, null, 2) + '\n' + guidance.join('\n'); } private async handleMessageTask(input: MessageTaskInput): Promise { const taskId = input.task_id; const task = this.taskManager.getTask(taskId); // Return structured JSON+guidance for all error cases instead of throwing if (!task) { return JSON.stringify({ task_id: taskId, status: 'unknown', error: 'Task not found' }, null, 2) + '\n\n---\n**Task not found.** It may have been evicted. Read `task:///` which falls back to disk, or spawn a new task.'; } if (isTerminalStatus(task.status)) { const wireStatus = mapToWireState(task.status); const taskDir = join(this.persistenceRoot, taskId); const result: Record = { task_id: taskId, status: wireStatus, error: task.error ?? `Task is in terminal status: ${task.status}`, resources: { scoreboard: 'task:///all', detail: `task:///${taskId}`, log: `task:///${taskId}/log`, }, disk_paths: { dir: taskDir, events_log: join(taskDir, 'events.jsonl'), timeline_log: join(taskDir, 'timeline.log'), meta: join(taskDir, 'meta.json'), }, }; const guidance = buildMessageTerminalGuidance(task); return JSON.stringify(result, null, 2) + '\n' + guidance.join('\n'); } if (!task.sessionId) { return JSON.stringify({ task_id: taskId, status: mapToWireState(task.status), error: 'No active session' }, null, 2) + '\n\n---\n**No active session.** The task has no Codex thread. Spawn a new task to continue.'; } // Parse `gpt-5.4(effort)` → { model, effort } const parsed = input.reasoning ? parseReasoning(input.reasoning) : undefined; const turnModel = parsed?.model ?? task.model; const turnEffort = parsed?.effort ?? task.effort; // Start a new turn on the existing thread — catch provider errors try { const built = await this.runtime.buildTurnStartParams({ threadId: task.sessionId, userInput: input.message, model: turnModel, effort: turnEffort, }); await this.runtime.ensureThreadLoaded(task.sessionId, turnModel, turnEffort); const bridged = await this.runtime.requestWithBridge('turn/start', built.params, { threadId: task.sessionId, }); // Update task status back to running this.taskManager.updateTask(taskId, { status: TaskStatus.RUNNING }); const messageResult = { task_id: taskId, provider_session_id: task.sessionId, status: 'working', operation_id: bridged.operationId, }; const guidance = buildMessageGuidance(task); return JSON.stringify(messageResult, null, 2) + '\n' + guidance.join('\n'); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); const result = { task_id: taskId, status: mapToWireState(task.status), error: `Failed to send message: ${errorMsg}`, }; return JSON.stringify(result, null, 2) + '\n\n---\n**Message failed.** The Codex session may have ended. Spawn a new task to continue this work.'; } } private async handleCancelTask(input: CancelTaskInput): Promise { const taskIds = Array.isArray(input.task_id) ? input.task_id : [input.task_id]; const cancelled: string[] = []; const alreadyTerminal: string[] = []; const notFound: string[] = []; for (const taskId of taskIds) { const task = this.taskManager.getTask(taskId); if (!task) { notFound.push(taskId); continue; } if (isTerminalStatus(task.status)) { alreadyTerminal.push(taskId); continue; } const handle = this.taskManager.getHandle(taskId); if (handle) { handle.markCancelled('Cancelled by user'); } // Try to interrupt the provider session if (task.sessionId) { try { await this.runtime.request('turn/interrupt', { threadId: task.sessionId, turnId: task.operationId ?? '', }); } catch { // Best-effort interrupt — task is already marked cancelled } } cancelled.push(taskId); } const cancelResult = { cancelled, already_terminal: alreadyTerminal, ...(notFound.length > 0 ? { not_found: notFound } : {}), }; const guidance = buildCancelGuidance({ cancelled, alreadyTerminal, notFound }); return JSON.stringify(cancelResult, null, 2) + '\n' + guidance.join('\n'); } }