// server/adapters/codex/pane-monitor.ts // // Polls a tmux pane every 500ms to capture real-time streaming output from // the Codex CLI running in --no-alt-screen mode. // // Detects: // 1. Streaming response text (new text since last poll) // 2. Thinking indicators (spinner / processing patterns) // 3. Approval prompts (Codex waiting for user to approve a command) // // Modelled after server/adapters/claude/pane-monitor.ts but with // Codex-specific regex patterns. Patterns are conservative placeholders // that will be refined through empirical testing with the actual Codex TUI. import { EventEmitter } from 'events'; import { InteractivePrompt } from '../../types/messages.js'; function simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash |= 0; } return Math.abs(hash).toString(36); } /** Minimal interface for the tmux manager dependency */ interface TmuxCapture { capturePane(windowId: string, lines?: number): Promise; } /** Thinking indicator detected from pane content */ export interface ThinkingInfo { text: string; detail: string | null; } /** * CodexPaneMonitor — polls a tmux pane to detect streaming text, * thinking indicators, and approval prompts from the Codex CLI. * * Events emitted via the injected EventEmitter: * - 'streaming-text' (sessionId, newText) * - 'thinking' (sessionId, { text, detail }) * - 'approval-prompt' (sessionId, { command, explanation }) */ export class CodexPaneMonitor { private sessionId: string; private windowId: string; private tmux: TmuxCapture; private emitter: EventEmitter; private interval: ReturnType | null = null; private _lastContent: string = ''; private _lastResponseText: string = ''; private lastPromptId: string | null = null; constructor( sessionId: string, windowId: string, tmuxManager: TmuxCapture, emitter: EventEmitter, ) { this.sessionId = sessionId; this.windowId = windowId; this.tmux = tmuxManager; this.emitter = emitter; } /** Begin polling the tmux pane at 500ms intervals */ start(): void { if (this.interval) return; this.interval = setInterval(() => this._poll(), 500); } /** Stop polling and clear the interval */ stop(): void { if (this.interval) { clearInterval(this.interval); this.interval = null; } } /** Force an immediate poll (useful on hook receipt) */ async pollNow(): Promise { await this._poll(); } // --------------------------------------------------------------------------- // Internal // --------------------------------------------------------------------------- private async _poll(): Promise { try { const content = await this.tmux.capturePane(this.windowId); if (content === this._lastContent) return; this._lastContent = content; // 0. Check for interactive prompt (highest priority) const interactivePrompt = this._detectPrompt(content); if (interactivePrompt) { if (interactivePrompt.requestId !== this.lastPromptId) { this.lastPromptId = interactivePrompt.requestId; this.emitter.emit('interactive-prompt', this.sessionId, interactivePrompt); } return; // Don't process streaming while prompt is showing } else if (this.lastPromptId) { this.lastPromptId = null; } // 1. Check for approval prompt (legacy — kept for backwards compat) const approval = detectApprovalPrompt(content); if (approval) { this.emitter.emit('approval-prompt', this.sessionId, approval); return; } // 2. Check for thinking indicator const thinking = detectThinking(content); if (thinking) { this.emitter.emit('thinking', this.sessionId, thinking); return; } // 3. Extract streaming response text const text = extractResponseText(content); if (text && text !== this._lastResponseText) { this._lastResponseText = text; this.emitter.emit('streaming-text', this.sessionId, text); } } catch { // Silently ignore — tmux window may have been killed } } /** * Detect an interactive prompt in the Codex CLI pane content. * Returns an InteractivePrompt if one is detected, null otherwise. */ private _detectPrompt(content: string): InteractivePrompt | null { // Command/File/Network Approval: "(y)" with proceed/run/make patterns if ( content.includes('(y)') && (/proceed/i.test(content) || /Would you like to run/i.test(content) || /Would you like to make/i.test(content)) ) { const options = this._parseCodexOptions(content); const lines = content.split('\n'); const tail = lines.slice(-20); const promptLine = tail.find(l => /proceed|\brun\b|\bmake\b/i.test(l)) || 'Approve action'; const description = tail.join('\n').trim(); return { requestId: `codex-perm-${simpleHash(description)}`, promptType: 'permission', title: typeof promptLine === 'string' ? promptLine.trim() : 'Approve action', description, options: options.length > 0 ? options : [ { value: 'y', label: 'Yes' }, { value: 'n', label: 'No' }, ], }; } // User Input: "enter to submit" AND "esc to cancel" (but NOT approval patterns) if ( /enter to submit/i.test(content) && /esc to cancel/i.test(content) && !content.includes('(y)') ) { const lines = content.split('\n'); const tail = lines.slice(-20); const options = this._parseCodexOptions(content); const description = tail.join('\n').trim(); if (options.length > 0) { return { requestId: `codex-ask-${simpleHash(description)}`, promptType: 'question', title: 'User Input', description, options, }; } return { requestId: `codex-ask-${simpleHash(description)}`, promptType: 'question', title: 'User Input', description, textInput: { placeholder: 'Type your response...' }, }; } return null; } /** * Parse Codex-style options from content. * Matches patterns like "(y) Yes" or "(a) Always approve". */ private _parseCodexOptions(content: string): { value: string; label: string }[] { const results: { value: string; label: string }[] = []; const regex = /\(([a-z])\)\s+(.+?)(?:\n|$)/g; let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { results.push({ value: match[1]!, label: match[2]!.trim() }); } return results; } } // ============================================================================= // Detection functions (exported for unit testing) // ============================================================================= /** * Detect Codex thinking/processing indicators. * * Codex CLI shows various spinner/processing patterns while reasoning. * In --no-alt-screen mode these appear as inline text in the pane. * * Placeholder patterns — will be refined through empirical testing: * - "Thinking..." or "Reasoning..." text * - Spinner characters (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ braille spinner set) * - "Loading..." or processing indicators */ export function detectThinking(content: string): ThinkingInfo | null { const lines = content.split('\n'); // Only check the tail of the pane (last 15 lines) const tail = lines.slice(-15); for (const line of tail) { // Skip completion/summary lines if (/completed|finished|done|exited/i.test(line)) continue; // Pattern 1: Braille spinner followed by descriptive text // e.g. "⠙ Thinking..." or "⠹ Processing..." const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/); if (brailleMatch) { return { text: brailleMatch[2]!, detail: null }; } // Pattern 2: Explicit "Thinking..." or "Reasoning..." text const thinkingMatch = line.match(/^\s*(Thinking|Reasoning|Processing)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i); if (thinkingMatch) { return { text: `${thinkingMatch[1]}...`, detail: thinkingMatch[3] || null, }; } // Pattern 3: Dash/line spinner (e.g. "- thinking" or "| working") const dashSpinner = line.match(/^\s*[|/\-\\]\s+(thinking|reasoning|working)\b/i); if (dashSpinner) { return { text: `${dashSpinner[1]}...`, detail: null }; } } return null; } /** * Extract the current streaming response text from pane content. * * Codex in --no-alt-screen mode writes responses inline. We look for text * after the last user input marker and collect lines until we hit a * boundary indicator. * * Placeholder patterns — will be refined through empirical testing: * - User input prompt: ">" or "❯" followed by user text * - Response boundary: horizontal rules, new prompts, tool output markers */ export function extractResponseText(content: string): string { const lines = content.split('\n'); // Find the LAST user prompt line — responses appear after it let lastUserPrompt = -1; for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]!; // Codex user prompt patterns (conservative): // - ">" or "❯" at start of line followed by user text // - "user:" prefix if (/^\s*[>❯]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) { lastUserPrompt = i; break; } } if (lastUserPrompt === -1) return ''; // Collect response lines after the user prompt // Skip the prompt line itself and any blank lines immediately after let responseStart = lastUserPrompt + 1; while (responseStart < lines.length && lines[responseStart]!.trim() === '') { responseStart++; } if (responseStart >= lines.length) return ''; const responseLines: string[] = []; for (let i = responseStart; i < lines.length; i++) { const line = lines[i]!; // Stop at boundary markers if ( // Horizontal rules /^[─━═\-]{5,}/.test(line.trim()) || // New user prompt /^\s*[>❯]\s+\S/.test(line) || // Spinner/thinking indicators (braille set) /^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+/.test(line) || // Tool execution markers (Codex shows commands it wants to run) /^\s*\$\s+/.test(line) || // Approval prompt boundary /approve|deny|allow|reject/i.test(line) && /\?\s*$/.test(line.trim()) ) { break; } responseLines.push(line); } return responseLines.join('\n').trim(); } /** * Detect an approval prompt — Codex waiting for user to approve a command. * * When Codex wants to execute a command (shell, file write, etc.) and * requires approval, it displays the command and waits for input. * * Placeholder patterns — will be refined through empirical testing: * - "Run command?" or "Execute?" prompts * - Command display followed by [y/n] or approve/deny prompt */ export function detectApprovalPrompt( content: string, ): { command: string; explanation: string } | null { const lines = content.split('\n'); // Only check the tail of the pane (last 20 lines) for approval prompts const tail = lines.slice(-20); const tailText = tail.join('\n'); // Pattern 1: "Run ? [y/n]" style const runMatch = tailText.match( /(?:Run|Execute|Allow)\s+(?:command\s*)?[:\-]?\s*[`"]?(.+?)[`"]?\s*\?\s*(?:\[([yYnN/]+)\])?\s*$/m, ); if (runMatch) { return { command: runMatch[1]!.trim(), explanation: 'Codex is requesting approval to run a command', }; } // Pattern 2: Command displayed in a block followed by approval prompt // e.g.: // $ some-command --flag // Approve? (y/n) const blockMatch = tailText.match( /\$\s+(.+)\n[\s\S]*?(?:Approve|Allow|Confirm)\s*\?\s*(?:\(([yYnN/]+)\))?\s*$/m, ); if (blockMatch) { return { command: blockMatch[1]!.trim(), explanation: 'Codex is requesting approval to execute a command', }; } // Pattern 3: Generic approval/permission prompt at the end of pane // Catches "Do you want to proceed?" style prompts const genericMatch = tail.slice(-5).join('\n').match( /(?:proceed|continue|approve|allow)\s*\?\s*(?:\(([yYnN/]+)\))?\s*$/im, ); if (genericMatch) { // Try to extract the command from lines above the prompt const commandLine = tail.slice(-10, -3).find((l) => /^\s*\$\s+\S/.test(l)); return { command: commandLine ? commandLine.replace(/^\s*\$\s+/, '').trim() : '(unknown)', explanation: 'Codex is requesting approval to proceed', }; } return null; }