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 { JsonLineNotification } from '../types/codex.js'; import { formatTimelineLine, DEFAULT_TIMELINE_CONFIG } 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 asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } function ts(): string { const d = new Date(); return [d.getHours(), d.getMinutes(), d.getSeconds()] .map(n => String(n).padStart(2, '0')) .join(':'); } function truncate(text: string, max = 200): string { const oneLine = text.replace(/\n/g, ' ').trim(); return oneLine.length <= max ? oneLine : oneLine.slice(0, max) + '...'; } function countDiffLines(diff: string): { added: number; removed: number } { let added = 0, removed = 0; for (const line of diff.split('\n')) { if (line.startsWith('+') && !line.startsWith('+++')) added++; if (line.startsWith('-') && !line.startsWith('---')) removed++; } return { added, removed }; } // --------------------------------------------------------------------------- // Attachment: wire event capture onto an AppServerClient + TaskHandle // --------------------------------------------------------------------------- /** * Listen for `notification` events on the AppServerClient, capture agent * messages, command output, file diffs, and errors — writing them to the * TaskHandle's output methods. Returns an unsubscribe function. */ export function attachEventCapture( client: AppServerClient, handle: TaskHandle, threadId: string, fileWriter?: TaskFileWriter | undefined, ): () => void { const agentMessageBuffers = new Map(); const commandOutputBuffers = new Map(); const taskId = handle.taskId; let timelineLastTokenCount = 0; let timelineStartedEmitted = false; const onNotification = (notification: JsonLineNotification) => { const params = asObject(notification.params); if (asString(params?.threadId) !== threadId) return; // Raw event trace — every notification for this task goes to events.jsonl if (fileWriter) { fileWriter.appendEvent(taskId, { method: notification.method, params: notification.params, }).catch(() => {}); // Pre-filtered timeline — one line per meaningful event const tlResult = formatTimelineLine( { t: new Date().toISOString(), method: notification.method, params: notification.params }, DEFAULT_TIMELINE_CONFIG, timelineLastTokenCount, ); if (tlResult.line) { for (const singleLine of tlResult.line.split('\n')) { // Suppress duplicate STARTED — only emit the first one per task if (singleLine.includes('STARTED')) { if (timelineStartedEmitted) continue; timelineStartedEmitted = true; } const capped = singleLine.length > 500 ? singleLine.slice(0, 497) + '...' : singleLine; fileWriter.appendTimeline(taskId, capped).catch(() => {}); } } if (tlResult.newTokenCount !== undefined) { timelineLastTokenCount = tlResult.newTokenCount; } } switch (notification.method) { // -- item started (visibility for long-running items) -------------------- case 'item/started': { const item = asObject(params?.item); const itemType = asString(item?.type); if (itemType === 'commandExecution') { const command = asString(item?.command) ?? ''; if (command) { handle.writeOutputFileOnly(`[${ts()}] started: ${command}`); } } break; } // -- agent message deltas ----------------------------------------------- case 'item/agentMessage/delta': { const itemId = asString(params?.itemId); const delta = asString(params?.delta); if (!itemId || !delta) break; const existing = agentMessageBuffers.get(itemId) ?? ''; agentMessageBuffers.set(itemId, existing + delta); break; } // -- command output deltas ---------------------------------------------- case 'item/commandExecution/outputDelta': { const itemId = asString(params?.itemId); const delta = asString(params?.delta); if (!itemId || !delta) break; const existing = commandOutputBuffers.get(itemId) ?? ''; commandOutputBuffers.set(itemId, existing + delta); handle.writeOutputFileOnly(delta); break; } // -- item completed ----------------------------------------------------- case 'item/completed': { const item = asObject(params?.item); const itemType = asString(item?.type); const itemId = asString(item?.id); if (itemType === 'agentMessage') { const text = (itemId ? agentMessageBuffers.get(itemId) : undefined) ?? asString(item?.text) ?? ''; handle.writeOutput(`[${ts()}] agent: ${truncate(text)}`); handle.writeOutputFileOnly(`[${ts()}] === agent message ===\n${text}`); if (itemId) agentMessageBuffers.delete(itemId); } else if (itemType === 'commandExecution') { const command = asString(item?.command) ?? 'unknown'; const exitCode = typeof item?.exitCode === 'number' ? item.exitCode : undefined; const durationMs = typeof item?.durationMs === 'number' ? item.durationMs : undefined; const duration = durationMs !== undefined ? (durationMs / 1000).toFixed(1) + 's' : ''; handle.writeOutput( `[${ts()}] cmd: ${command} (exit ${exitCode ?? '?'}${duration ? ', ' + duration : ''})`, ); if (itemId) { // Deltas were already streamed to verbose in real-time. // Write only a completion marker to avoid duplicating output. handle.writeOutputFileOnly( `[${ts()}] === command completed: ${command} (exit ${exitCode ?? '?'}${duration ? ', ' + duration : ''}) ===`, ); commandOutputBuffers.delete(itemId); } } else if (itemType === 'fileChange') { const changes = Array.isArray(item?.changes) ? (item.changes as unknown[]) : []; for (const raw of changes) { const change = asObject(raw); if (!change) continue; const path = asString(change.path) ?? 'unknown'; const diff = asString(change.diff) ?? ''; const { added, removed } = countDiffLines(diff); handle.writeOutput(`[${ts()}] edit: ${path} (+${added} -${removed})`); handle.writeOutputFileOnly(`[${ts()}] === file diff: ${path} ===\n${diff}`); } } break; } // -- turn diff ---------------------------------------------------------- case 'turn/diff/updated': { const diff = asString(params?.diff); if (diff) { handle.writeOutputFileOnly(`[${ts()}] === turn diff ===\n${diff}`); } break; } // -- turn plan updated ----------------------------------------------------- case 'turn/plan/updated': { const explanation = asString(params?.explanation); const plan = Array.isArray(params?.plan) ? (params.plan as Array<{ step?: string; status?: string }>) : []; const steps = plan.map(s => `[${s.status ?? '?'}] ${s.step ?? ''}`).join(', '); handle.writeOutput(`[${ts()}] plan: ${truncate(explanation ?? steps)}`); break; } // -- turn completed ------------------------------------------------------- case 'turn/completed': { const turn = asObject(params?.turn); const turnStatus = asString(turn?.status) ?? 'unknown'; const turnError = asObject(turn?.error); if (turnStatus === 'completed') { handle.writeOutput(`[${ts()}] turn completed`); } else if (turnStatus === 'failed') { const errorMsg = asString(turnError?.message) ?? 'unknown'; const errorInfo = asString(turnError?.codexErrorInfo) ?? ''; handle.writeOutput(`[${ts()}] turn failed: ${errorInfo ? errorInfo + ' — ' : ''}${truncate(errorMsg)}`); } else if (turnStatus === 'interrupted') { handle.writeOutput(`[${ts()}] turn interrupted`); } else { handle.writeOutput(`[${ts()}] turn ${turnStatus}`); } break; } // -- error -------------------------------------------------------------- case 'error': { const errorObj = asObject(params?.error); const message = asString(errorObj?.message) ?? asString(params?.message) ?? 'unknown'; const codexErrorInfo = asString(errorObj?.codexErrorInfo) ?? asString(params?.codexErrorInfo) ?? 'unknown'; handle.writeOutput(`[${ts()}] ERROR: ${codexErrorInfo} — ${truncate(message)}`); handle.writeOutputFileOnly( `[${ts()}] === error ===\n${message}\ncodexErrorInfo: ${codexErrorInfo}`, ); break; } // -- token usage updates -------------------------------------------------- case 'thread/tokenUsage/updated': { const tokenUsage = asObject(params?.tokenUsage); if (tokenUsage) { const total = asObject(tokenUsage.total); handle.setTokenUsage({ totalTokens: typeof total?.totalTokens === 'number' ? total.totalTokens : 0, inputTokens: typeof total?.inputTokens === 'number' ? total.inputTokens : 0, outputTokens: typeof total?.outputTokens === 'number' ? total.outputTokens : 0, contextWindow: typeof tokenUsage.modelContextWindow === 'number' ? tokenUsage.modelContextWindow : null, }); } break; } default: break; } }; client.on('notification', onNotification); return () => { client.off('notification', onNotification); }; }