diff --git a/src/claude/index.ts b/src/claude/index.ts index 9662f6b..83be70e 100644 --- a/src/claude/index.ts +++ b/src/claude/index.ts @@ -35,6 +35,7 @@ export type { InitEventData, ToolUseEventData, ToolResultEventData, + ToolOutputEventData, TextEventData, ThinkingEventData, ResultEventData, diff --git a/src/claude/process.ts b/src/claude/process.ts index 866b1a1..a62bbbe 100644 --- a/src/claude/process.ts +++ b/src/claude/process.ts @@ -31,6 +31,7 @@ export type { InitEventData, ToolUseEventData, ToolResultEventData, + ToolOutputEventData, TextEventData, ThinkingEventData, ResultEventData, diff --git a/src/claude/types.ts b/src/claude/types.ts index d042c8e..aba4094 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -27,6 +27,11 @@ export interface ToolResultEventData { isError: boolean; } +export interface ToolOutputEventData { + tool: string; + output: string; +} + export interface TextEventData { text: string; } @@ -51,6 +56,7 @@ export type StreamEvent = | { type: 'init'; data: InitEventData } | { type: 'tool_use'; data: ToolUseEventData } | { type: 'tool_result'; data: ToolResultEventData } + | { type: 'tool_output'; data: ToolOutputEventData } | { type: 'text'; data: TextEventData } | { type: 'thinking'; data: ThinkingEventData } | { type: 'result'; data: ResultEventData } diff --git a/src/codex/client.ts b/src/codex/client.ts index c9af246..d54d225 100644 --- a/src/codex/client.ts +++ b/src/codex/client.ts @@ -31,43 +31,6 @@ function extractThreadId(value: unknown): string | undefined { return typeof id === 'string' ? id : undefined; } -function normalizeCodexResult(result: unknown): string { - if (result == null) return ''; - if (typeof result === 'string') return result; - if (typeof result !== 'object') return String(result); - - const record = result as Record; - const directFields = ['output_text', 'output', 'content', 'text', 'message']; - for (const field of directFields) { - const value = record[field]; - if (typeof value === 'string') { - return value; - } - } - - if (Array.isArray(record.output)) { - const first = record.output[0]; - if (typeof first === 'string') return first; - if (first && typeof first === 'object') { - const text = (first as Record).text; - if (typeof text === 'string') return text; - } - } - - if (Array.isArray(record.choices)) { - const firstChoice = record.choices[0] as Record | undefined; - const message = firstChoice?.message as Record | undefined; - const content = message?.content; - if (typeof content === 'string') return content; - } - - try { - return JSON.stringify(result, null, 2); - } catch { - return String(result); - } -} - function emitInit( onStream: StreamCallback | undefined, model: string | undefined, @@ -88,6 +51,39 @@ function emitText(onStream: StreamCallback | undefined, text: string): void { onStream({ type: 'text', data: { text } }); } +function emitThinking(onStream: StreamCallback | undefined, thinking: string): void { + if (!onStream || !thinking) return; + onStream({ type: 'thinking', data: { thinking } }); +} + +function emitToolUse( + onStream: StreamCallback | undefined, + tool: string, + input: Record, + id: string +): void { + if (!onStream) return; + onStream({ type: 'tool_use', data: { tool, input, id } }); +} + +function emitToolResult( + onStream: StreamCallback | undefined, + content: string, + isError: boolean +): void { + if (!onStream) return; + onStream({ type: 'tool_result', data: { content, isError } }); +} + +function emitToolOutput( + onStream: StreamCallback | undefined, + tool: string, + output: string +): void { + if (!onStream || !output) return; + onStream({ type: 'tool_output', data: { tool, output } }); +} + function emitResult( onStream: StreamCallback | undefined, success: boolean, @@ -109,6 +105,237 @@ function determineStatus(content: string, patterns: Record): Sta return detectStatus(content, patterns); } +type CodexEvent = { + type: string; + [key: string]: unknown; +}; + +type CodexItem = { + id?: string; + type: string; + [key: string]: unknown; +}; + +function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string { + if (!changes.length) return ''; + return changes + .map((change) => { + const kind = change.kind ? `${change.kind}: ` : ''; + return `${kind}${change.path ?? ''}`.trim(); + }) + .filter(Boolean) + .join('\n'); +} + +function emitCodexItemStart( + item: CodexItem, + onStream: StreamCallback | undefined, + startedItems: Set +): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + if (startedItems.has(id)) return; + + switch (item.type) { + case 'command_execution': { + const command = typeof item.command === 'string' ? item.command : ''; + emitToolUse(onStream, 'Bash', { command }, id); + startedItems.add(id); + break; + } + case 'mcp_tool_call': { + const tool = typeof item.tool === 'string' ? item.tool : 'Tool'; + const args = (item.arguments ?? {}) as Record; + emitToolUse(onStream, tool, args, id); + startedItems.add(id); + break; + } + case 'web_search': { + const query = typeof item.query === 'string' ? item.query : ''; + emitToolUse(onStream, 'WebSearch', { query }, id); + startedItems.add(id); + break; + } + case 'file_change': { + const changes = Array.isArray(item.changes) ? item.changes : []; + const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); + emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id); + startedItems.add(id); + break; + } + default: + break; + } +} + +function emitCodexItemCompleted( + item: CodexItem, + onStream: StreamCallback | undefined, + startedItems: Set, + outputOffsets: Map, + textOffsets: Map, + thinkingOffsets: Map +): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + + switch (item.type) { + case 'reasoning': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = thinkingOffsets.get(id) ?? 0; + if (text.length > prev) { + emitThinking(onStream, text.slice(prev) + '\n'); + thinkingOffsets.set(id, text.length); + } + } + break; + } + case 'agent_message': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = textOffsets.get(id) ?? 0; + if (text.length > prev) { + emitText(onStream, text.slice(prev)); + textOffsets.set(id, text.length); + } + } + break; + } + case 'command_execution': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; + if (output) { + const prev = outputOffsets.get(id) ?? 0; + if (output.length > prev) { + emitToolOutput(onStream, 'Bash', output.slice(prev)); + outputOffsets.set(id, output.length); + } + } + const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined; + const status = typeof item.status === 'string' ? item.status : ''; + const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0); + const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : ''); + emitToolResult(onStream, content, isError); + break; + } + case 'mcp_tool_call': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + const status = typeof item.status === 'string' ? item.status : ''; + const isError = status === 'failed' || !!item.error; + const errorMessage = + item.error && typeof item.error === 'object' && 'message' in item.error + ? String((item.error as { message?: unknown }).message ?? '') + : ''; + let content = errorMessage; + if (!content && item.result && typeof item.result === 'object') { + try { + content = JSON.stringify(item.result); + } catch { + content = ''; + } + } + emitToolResult(onStream, content, isError); + break; + } + case 'web_search': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + emitToolResult(onStream, 'Search completed', false); + break; + } + case 'file_change': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + const status = typeof item.status === 'string' ? item.status : ''; + const isError = status === 'failed'; + const changes = Array.isArray(item.changes) ? item.changes : []; + const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); + emitToolResult(onStream, summary || 'Applied patch', isError); + break; + } + default: + break; + } +} + +function emitCodexItemUpdate( + item: CodexItem, + onStream: StreamCallback | undefined, + startedItems: Set, + outputOffsets: Map, + textOffsets: Map, + thinkingOffsets: Map +): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + + switch (item.type) { + case 'command_execution': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; + if (output) { + const prev = outputOffsets.get(id) ?? 0; + if (output.length > prev) { + emitToolOutput(onStream, 'Bash', output.slice(prev)); + outputOffsets.set(id, output.length); + } + } + break; + } + case 'agent_message': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = textOffsets.get(id) ?? 0; + if (text.length > prev) { + emitText(onStream, text.slice(prev)); + textOffsets.set(id, text.length); + } + } + break; + } + case 'reasoning': { + const text = typeof item.text === 'string' ? item.text : ''; + if (text) { + const prev = thinkingOffsets.get(id) ?? 0; + if (text.length > prev) { + emitThinking(onStream, text.slice(prev)); + thinkingOffsets.set(id, text.length); + } + } + break; + } + case 'file_change': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + break; + } + case 'mcp_tool_call': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + break; + } + case 'web_search': { + if (!startedItems.has(id)) { + emitCodexItemStart(item, onStream, startedItems); + } + break; + } + default: + break; + } +} + /** * Call Codex with an agent prompt. */ @@ -118,17 +345,19 @@ export async function callCodex( options: CodexCallOptions ): Promise { const codex = new Codex(); + const threadOptions = { + model: options.model, + workingDirectory: options.cwd, + }; const thread = options.sessionId - ? await codex.resumeThread(options.sessionId) - : await codex.startThread(); - const threadId = extractThreadId(thread) || options.sessionId; + ? await codex.resumeThread(options.sessionId, threadOptions) + : await codex.startThread(threadOptions); + let threadId = extractThreadId(thread) || options.sessionId; const fullPrompt = options.systemPrompt ? `${options.systemPrompt}\n\n${prompt}` : prompt; - emitInit(options.onStream, options.model, threadId); - try { log.debug('Executing Codex thread', { agentType, @@ -136,21 +365,93 @@ export async function callCodex( hasSystemPrompt: !!options.systemPrompt, }); - const runOptions = options.model ? { model: options.model } : undefined; - const result = await (thread as { run: (p: string, o?: unknown) => Promise }) - .run(fullPrompt, runOptions); + const { events } = await thread.runStreamed(fullPrompt); + let content = ''; + let success = true; + let failureMessage = ''; + const startedItems = new Set(); + const outputOffsets = new Map(); + const textOffsets = new Map(); + const thinkingOffsets = new Map(); - const content = normalizeCodexResult(result).trim(); - emitText(options.onStream, content); - emitResult(options.onStream, true, content, threadId); + for await (const event of events as AsyncGenerator) { + if (event.type === 'thread.started') { + threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; + emitInit(options.onStream, options.model, threadId); + continue; + } + + if (event.type === 'turn.failed') { + success = false; + if (event.error && typeof event.error === 'object' && 'message' in event.error) { + failureMessage = String((event.error as { message?: unknown }).message ?? ''); + } + break; + } + + if (event.type === 'error') { + success = false; + failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; + break; + } + + if (event.type === 'item.started') { + const item = event.item as CodexItem | undefined; + if (item) { + emitCodexItemStart(item, options.onStream, startedItems); + } + continue; + } + + if (event.type === 'item.updated') { + const item = event.item as CodexItem | undefined; + if (item) { + emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets); + } + continue; + } + + if (event.type === 'item.completed') { + const item = event.item as CodexItem | undefined; + if (item) { + if (item.type === 'agent_message' && typeof item.text === 'string') { + content = item.text; + } + emitCodexItemCompleted( + item, + options.onStream, + startedItems, + outputOffsets, + textOffsets, + thinkingOffsets + ); + } + continue; + } + } + + if (!success) { + const message = failureMessage || 'Codex execution failed'; + emitResult(options.onStream, false, message, threadId); + return { + agent: agentType, + status: 'blocked', + content: message, + timestamp: new Date(), + sessionId: threadId, + }; + } + + const trimmed = content.trim(); + emitResult(options.onStream, true, trimmed, threadId); const patterns = options.statusPatterns || GENERIC_STATUS_PATTERNS; - const status = determineStatus(content, patterns); + const status = determineStatus(trimmed, patterns); return { agent: agentType, status, - content, + content: trimmed, timestamp: new Date(), sessionId: threadId, }; diff --git a/src/utils/ui.ts b/src/utils/ui.ts index a84212b..9b7c0c1 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -151,6 +151,9 @@ export function truncate(text: string, maxLength: number): string { /** Stream display manager for real-time Claude output */ export class StreamDisplay { private lastToolUse: string | null = null; + private currentToolInputPreview: string | null = null; + private toolOutputBuffer = ''; + private toolOutputPrinted = false; private textBuffer = ''; private thinkingBuffer = ''; private isFirstText = true; @@ -206,6 +209,31 @@ export class StreamDisplay { // Start spinner to show tool is executing this.startToolSpinner(tool, inputPreview); this.lastToolUse = tool; + this.currentToolInputPreview = inputPreview; + this.toolOutputBuffer = ''; + this.toolOutputPrinted = false; + } + + /** Display tool output streaming */ + showToolOutput(output: string, tool?: string): void { + if (!output) return; + this.stopToolSpinner(); + this.flushThinking(); + this.flushText(); + + if (tool && !this.lastToolUse) { + this.lastToolUse = tool; + } + + this.toolOutputBuffer += output; + const lines = this.toolOutputBuffer.split(/\r?\n/); + this.toolOutputBuffer = lines.pop() ?? ''; + + this.printToolOutputLines(lines, tool); + + if (this.lastToolUse && this.currentToolInputPreview) { + this.startToolSpinner(this.lastToolUse, this.currentToolInputPreview); + } } /** Display tool result event */ @@ -213,6 +241,11 @@ export class StreamDisplay { // Stop the spinner first this.stopToolSpinner(); + if (this.toolOutputBuffer) { + this.printToolOutputLines([this.toolOutputBuffer], this.lastToolUse ?? undefined); + this.toolOutputBuffer = ''; + } + const toolName = this.lastToolUse || 'Tool'; if (isError) { const errorContent = content || 'Unknown error'; @@ -225,6 +258,8 @@ export class StreamDisplay { console.log(chalk.green(` ✓ ${toolName}`)); } this.lastToolUse = null; + this.currentToolInputPreview = null; + this.toolOutputPrinted = false; } /** Display streaming thinking (Claude's internal reasoning) */ @@ -308,6 +343,9 @@ export class StreamDisplay { reset(): void { this.stopToolSpinner(); this.lastToolUse = null; + this.currentToolInputPreview = null; + this.toolOutputBuffer = ''; + this.toolOutputPrinted = false; this.textBuffer = ''; this.thinkingBuffer = ''; this.isFirstText = true; @@ -330,6 +368,9 @@ export class StreamDisplay { case 'tool_result': this.showToolResult(event.data.content, event.data.isError); break; + case 'tool_output': + this.showToolOutput(event.data.output, event.data.tool); + break; case 'text': this.showText(event.data.text); break; @@ -372,4 +413,19 @@ export class StreamDisplay { } } } + + private ensureToolOutputHeader(tool?: string): void { + if (this.toolOutputPrinted) return; + const label = tool || this.lastToolUse || 'Tool'; + console.log(chalk.gray(` ${chalk.yellow(label)} output:`)); + this.toolOutputPrinted = true; + } + + private printToolOutputLines(lines: string[], tool?: string): void { + if (lines.length === 0) return; + this.ensureToolOutputHeader(tool); + for (const line of lines) { + console.log(chalk.gray(` │ ${line}`)); + } + } }