Files
clawtap/server/adapters/codex/pane-monitor.ts
T
kuannnn 0fcf66fc22 feat: ClawTap v0.2.0
Interactive Prompts:
- Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini)
- InteractivePromptOverlay component with options, text input, countdown
- Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval
- respondInteractivePrompt routing: permission → respondPermission, options → _selectOption
- Claude AskUserQuestion nested questions[0] structure parsing

Cross-AI Review:
- Client-generated reviewId, removed pendingReview state
- FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive)
- Child review sessions default to YOLO/bypass permission mode
- Send back to parent, send to existing/new review, tab switching, end review
- Collapsed review cards with read-only panel for ended reviews
- Full reconnect support: active + ended reviews restore correctly

AskUserQuestion Tool Card UI:
- Dedicated renderer replaces raw JSON display
- Options shown with selected (green) / unselected (gray) indicators
- Free text answers shown in quoted format with green border
- Collapsed summary: question → answer
- Shared parseAskQuestionInput utility (client + server)
- Historical tool results attached via _result on tool_use blocks

Adapter Fixes:
- Session→adapter mapping persisted in SQLite (survives server restart)
- SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini)
- session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd
- Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt
- Claude: auto-accept bypass permissions confirmation (v2.1.85+)
- Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper

Other:
- Desktop Enter sends / Shift+Enter newline; Mobile Enter newline
- Strip CLAWTAP_REF marker from session list
- Active sessions tab shows adapter badge
- Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:46:00 +08:00

380 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string>;
}
/** 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<typeof setInterval> | 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<void> {
await this._poll();
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private async _poll(): Promise<void> {
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 <command>? [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;
}