import type { AppServerClient } from '../services/app-server-client.js'; import type { TaskFileWriter } from '../services/task-file-writer.js'; import type { TaskHandle } from '../task/task-handle.js'; import type { PendingQuestion } from '../task/task-state.js'; import type { JsonLineRequest, PendingServerRequest } from '../types/codex.js'; import { formatTimelineLine } from '../timeline-writer.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function asObject(value: unknown): Record | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } return value as Record; } function asArray(value: unknown): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } // --------------------------------------------------------------------------- // Translation: Codex server-request → PendingQuestion // --------------------------------------------------------------------------- /** * Translate a raw Codex JSON-RPC server request into a provider-blind * PendingQuestion variant. * * Returns `null` for: * - Guardian Subagent auto-approval events (method contains `autoApprovalReview`) * - Unrecognised / unmapped methods * * Spec reference: §6.1, §6.2 */ export function translateCodexRequestToPendingQuestion( req: JsonLineRequest, ): PendingQuestion | null { // Guardian Subagent bypass (PR #13860) — never surface as a pause if (req.method.includes('autoApprovalReview')) { return null; } const params = asObject(req.params); const requestId = String(req.id); switch (req.method) { // -- user_input -------------------------------------------------------- case 'tool/requestUserInput': case 'item/tool/requestUserInput': { const rawQuestions = asArray(params?.questions) ?? []; const questions = rawQuestions.map((raw) => { const q = asObject(raw) ?? {}; return { id: asString(q.id) ?? '', text: asString(q.text) ?? asString(q.question) ?? '', ...(asArray(q.options) ? { options: (q.options as unknown[]).map((opt) => { if (typeof opt === 'string') return opt; const o = opt as Record | null; return String(o?.label ?? o?.value ?? o?.description ?? JSON.stringify(opt)); }) } : {}), ...(q.isOther !== undefined || q.allowEmpty !== undefined ? { allowFreeform: Boolean(q.isOther ?? q.allowEmpty) } : {}), }; }); return { type: 'user_input', requestId, questions }; } // -- command_approval -------------------------------------------------- case 'item/commandExecution/requestApproval': case 'execCommandApproval': { const command = asString(params?.command) ?? ''; const sandboxPolicy = asString(params?.sandbox_policy) ?? asString(params?.sandboxPolicy); return { type: 'command_approval', requestId, command, ...(sandboxPolicy !== undefined ? { sandboxPolicy } : {}), }; } // -- file_approval ----------------------------------------------------- case 'item/fileChange/requestApproval': case 'applyPatchApproval': { const rawChanges = asArray(params?.file_changes) ?? asArray(params?.fileChanges) ?? []; const fileChanges = rawChanges.map((raw) => { const fc = asObject(raw) ?? {}; return { path: asString(fc.path) ?? '', patch: asString(fc.patch) ?? '', }; }); return { type: 'file_approval', requestId, fileChanges }; } // -- elicitation ------------------------------------------------------- case 'mcpServer/elicitation/request': { const serverName = asString(params?.server_name) ?? asString(params?.serverName); const message = asString(params?.message) ?? ''; const schema = params?.schema; return { type: 'elicitation', requestId, ...(serverName !== undefined ? { serverName } : {}), message, ...(schema !== undefined ? { schema } : {}), }; } // -- dynamic_tool ------------------------------------------------------ case 'item/tool/call': { const toolName = asString(params?.tool_name) ?? asString(params?.toolName) ?? ''; const rawArgs = params?.arguments; const args = typeof rawArgs === 'string' ? rawArgs : rawArgs !== undefined ? JSON.stringify(rawArgs) : '{}'; return { type: 'dynamic_tool', requestId, toolName, arguments: args }; } default: return null; } } // --------------------------------------------------------------------------- // Attachment: wire pause-flow onto an AppServerClient + TaskHandle // --------------------------------------------------------------------------- function extractThreadIdFromParams(params: unknown): string | undefined { const obj = asObject(params); const threadId = obj?.threadId; if (typeof threadId === 'string') return threadId; const thread = asObject(obj?.thread); if (typeof thread?.id === 'string') return thread.id; return undefined; } /** * Listen for `server-request` events on the AppServerClient, translate each * to a PendingQuestion, queue it on the handle, and transition to * WAITING_ANSWER. Returns an unsubscribe function. * * Spec reference: §6.2, §6.3 */ export function attachPauseFlow( client: AppServerClient, handle: TaskHandle, threadId: string, fileWriter?: TaskFileWriter | undefined, ): () => void { const onServerRequest = (pending: PendingServerRequest) => { const reqThreadId = extractThreadIdFromParams(pending.params); if (reqThreadId !== threadId) return; // Log EVERY server-request to events.jsonl — server-requests are // invisible in notifications, so without this the question flow // and approval flow have no trace in the event log. if (fileWriter) { fileWriter.appendEvent(handle.taskId, { method: `_server_request:${pending.method}`, requestId: pending.id, params: pending.params, }).catch(() => {}); // Timeline: show the server-request as an event const tlResult = formatTimelineLine({ t: new Date().toISOString(), method: pending.method, params: pending.params, }); if (tlResult.line) { fileWriter.appendTimeline(handle.taskId, tlResult.line).catch(() => {}); } } // Guardian Subagent bypass — log but don't surface if (pending.method.includes('autoApprovalReview')) { handle.writeOutputFileOnly(`[guardian] ${pending.method}`); return; } const params = asObject(pending.params); // ----------------------------------------------------------------- // Auto-approve: command approvals, file approvals, elicitations, // and permission requests. Respond instantly so the agent never // stalls waiting for orchestrator input on these. // ----------------------------------------------------------------- // Command approval → accept immediately if (pending.method === 'item/commandExecution/requestApproval' || pending.method === 'execCommandApproval') { const cmd = asString(params?.command) ?? 'unknown'; handle.writeOutput(`[auto-approve] cmd: ${cmd}`); client.respondToServerRequest(pending.id, { decision: 'accept' }).catch(() => {}); return; } // File change approval → accept immediately if (pending.method === 'item/fileChange/requestApproval' || pending.method === 'applyPatchApproval') { handle.writeOutput('[auto-approve] file change'); client.respondToServerRequest(pending.id, { decision: 'accept' }).catch(() => {}); return; } // MCP elicitation → accept immediately if (pending.method === 'mcpServer/elicitation/request') { handle.writeOutput('[auto-approve] elicitation'); client.respondToServerRequest(pending.id, { action: 'accept', content: {}, }).catch(() => {}); return; } // Permission request → grant all requested permissions for the session if (pending.method === 'item/permissions/requestApproval') { const perms = asObject(params?.permissions); handle.writeOutput('[auto-approve] permissions'); client.respondToServerRequest(pending.id, { scope: 'session', permissions: perms ?? {}, }).catch(() => {}); return; } // ----------------------------------------------------------------- // Auto-answer: requestUserInput — the Codex process has a ~4s // timeout for responses. The orchestrator can't respond that fast // (wait-task polling + LLM thinking + respond-task call = 5-30s). // So we auto-select the first (recommended) option for each // question and log the choices. If the orchestrator disagrees, // they can steer via message-task. // ----------------------------------------------------------------- if (pending.method === 'tool/requestUserInput' || pending.method === 'item/tool/requestUserInput') { const rawQuestions = asArray(params?.questions) ?? []; // ToolRequestUserInputResponse = { answers: { [qId]: ToolRequestUserInputAnswer } } // ToolRequestUserInputAnswer = { answers: string[] } const autoAnswers: Record = {}; const logParts: string[] = []; for (const raw of rawQuestions) { const q = asObject(raw) ?? {}; const id = asString(q.id) ?? ''; const text = asString(q.text) ?? asString(q.question) ?? ''; const options = asArray(q.options); let chosenLabel: string; if (options && options.length > 0) { // Pick the first option (recommended per QUESTION POLICY) const firstOpt = options[0]; if (typeof firstOpt === 'string') { chosenLabel = firstOpt; } else { const o = asObject(firstOpt); chosenLabel = asString(o?.label) ?? asString(o?.value) ?? String(firstOpt); } } else { chosenLabel = 'yes'; } // Each answer must be { answers: string[] } per ToolRequestUserInputAnswer autoAnswers[id] = { answers: [chosenLabel] }; logParts.push(`${text} → "${chosenLabel}"`); } client.respondToServerRequest(pending.id, { answers: autoAnswers }).catch(() => {}); handle.writeOutput(`[auto-answer] ${logParts.join(' | ')}`); // Log to events.jsonl and timeline if (fileWriter) { const answerSummary = logParts.join(' | '); fileWriter.appendEvent(handle.taskId, { method: '_auto_answer', answers: autoAnswers, summary: answerSummary, }).catch(() => {}); const ts = new Date().toTimeString().slice(0, 8); const line = `${ts} AUTO ${answerSummary.length > 450 ? answerSummary.slice(0, 447) + '...' : answerSummary}`; fileWriter.appendTimeline(handle.taskId, line).catch(() => {}); } return; } // ----------------------------------------------------------------- // Queue: dynamic_tool still needs real answers from the // orchestrator — this is the only type that pauses the task. // ----------------------------------------------------------------- const req: JsonLineRequest = { method: pending.method, id: pending.id, params: pending.params, }; const question = translateCodexRequestToPendingQuestion(req); if (!question) return; handle.queuePendingQuestion(question); if (!handle.isTerminal()) { handle.markInputRequired(); } }; client.on('server-request', onServerRequest); return () => { client.off('server-request', onServerRequest); }; }