diff --git a/docs/respawn-improvement-plan.md b/docs/respawn-improvement-plan.md new file mode 100644 index 00000000..a5616612 --- /dev/null +++ b/docs/respawn-improvement-plan.md @@ -0,0 +1,385 @@ +# Respawn Controller Idle Detection Improvement Plan + +## Executive Summary + +The current respawn controller relies primarily on **parsing terminal output** to detect idle states. This approach is fragile and leads to false positives/negatives (e.g., the w3-reddit-analyse session). + +**Key insight**: Claude Code provides **direct, authoritative signals** via hooks and files that definitively indicate session state. We're receiving some of these signals but not using them for idle detection! + +--- + +## Current Detection Layers (What We Have) + +| Layer | Signal | Source | Reliability | +|-------|--------|--------|-------------| +| 1 | Completion message ("Worked for Xm Xs") | Terminal parsing | Medium - can miss edge cases | +| 2 | Output silence (configurable duration) | Terminal activity | Low - Claude can be processing silently | +| 3 | Token stability | Terminal parsing | Low - tokens don't change during I/O waits | +| 4 | Working pattern absence | Terminal parsing | Medium - patterns can be missed | +| 5 | AI idle check | Spawned Claude CLI | High but slow (90s timeout) | + +**Problem**: All layers depend on **parsing terminal output**, which is inherently unreliable. + +--- + +## Available Claude Code Signals (Not Fully Utilized) + +### 1. `Stop` Hook ⭐ CRITICAL - DEFINITIVE SIGNAL + +**What it is**: Fires when the main Claude Code agent **finishes responding**. + +**From docs**: "Runs when the main Claude Code agent has finished responding. Does not run if the stoppage occurred due to a user interrupt." + +**Current status**: We receive it via `/api/hook-event` but **don't use it for idle detection**! + +**Input received**: +```json +{ + "session_id": "abc123", + "transcript_path": "~/.claude/projects/.../00893aaf.jsonl", + "hook_event_name": "Stop", + "stop_hook_active": true // Important for preventing loops +} +``` + +**Action needed**: The `Stop` hook should be the **PRIMARY** idle detection signal. When Claude fires Stop, the agent has definitively finished its response cycle. + +### 2. `idle_prompt` Notification ⭐ HIGH VALUE + +**What it is**: Fires after **60+ seconds of idle time** when Claude is waiting for user input. + +**From docs**: "When Claude is waiting for user input (after 60+ seconds of idle time)" + +**Current status**: We receive it but only forward it to the UI for notification display. + +**Action needed**: Use `idle_prompt` as a **definitive confirmation** that Claude is idle. If we receive this, there's no need for AI idle checks or output silence timers. + +### 3. Transcript JSONL File ⭐ HIGH VALUE + +**What it is**: Complete conversation history at `~/.claude/projects/{project-hash}/{session-id}.jsonl` + +**Current status**: We already watch subagent transcripts but **not the main session transcript**. + +**Data available**: +- Every message (user, assistant, system) +- Every tool call with inputs/outputs +- Progress events +- Structured, parseable JSON + +**Action needed**: +- Monitor the main transcript file (path provided in every hook input) +- Parse the last few entries to detect: + - Tool completion + - Assistant message completion + - Error states + - Plan mode prompts + +### 4. `PostToolUse` Hook - Tool Completion Tracking + +**What it is**: Fires immediately after any tool completes successfully. + +**Use case**: Track exactly when tools finish to understand execution flow. + +**Current status**: Not implemented. + +**Action needed**: Add PostToolUse hooks to track tool completion events. + +### 5. `SubagentStop` Hook - Background Agent Completion + +**What it is**: Fires when a subagent (Task tool) finishes responding. + +**Current status**: Not implemented in hooks config (we watch JSONL files separately). + +**Action needed**: Add to hooks config for redundant detection. + +### 6. `permission_prompt` and `elicitation_dialog` - Blocking State Detection + +**What it is**: Fires when Claude needs user input (permission or question). + +**Current status**: We receive and use for auto-accept blocking. + +**Enhancement**: Use as definitive "Claude is NOT idle - it's waiting for user action". + +--- + +## Proposed Architecture: Multi-Signal Idle Detection + +### New Detection Hierarchy + +``` +Priority 1 (Definitive): + └── Stop hook received → CONFIRMED IDLE + └── idle_prompt received → CONFIRMED IDLE (60s+ idle) + +Priority 2 (Blocking): + └── permission_prompt received → NOT IDLE (waiting for permission) + └── elicitation_dialog received → NOT IDLE (waiting for answer) + └── Working patterns in terminal → NOT IDLE + +Priority 3 (Supporting): + └── Transcript analysis → Check last entries for completion + └── Output silence + token stability → Weak idle signal + +Priority 4 (Fallback): + └── AI idle check → Only if no definitive signals after timeout +``` + +### State Machine Changes + +``` + ┌─────────────────────────────────────┐ + │ │ + ▼ │ + ┌─────────────────────┐ │ + │ WATCHING │◄──────────────────────────────┤ + └─────────────────────┘ │ + │ │ │ + │ │ Stop hook or idle_prompt │ + │ └────────────────────────┐ │ + │ ▼ │ + │ Output silence ┌────────────┐ │ + │ (no definitive signals) │ HOOK_IDLE │───────┤ + │ └────────────┘ │ + ▼ (skip AI check) │ + ┌────────────────────┐ │ + │ CONFIRMING_IDLE │ │ + └────────────────────┘ │ + │ │ + │ Silence confirmed │ + ▼ │ + ┌────────────────────┐ │ + │ AI_CHECKING │──── IDLE verdict ─────────────┤ + └────────────────────┘ │ + │ │ + │ WORKING verdict │ + └─────────────────────────────────────────────┘ +``` + +### New State: `hook_idle` + +When a definitive hook signal is received: +1. Skip AI idle check entirely (saves time and API calls) +2. Short confirmation period (2-3s) to handle race conditions +3. Proceed directly to respawn sequence + +--- + +## Implementation Plan + +### Phase 1: Use Stop Hook for Idle Detection ✅ COMPLETED + +**Files modified**: +- `src/respawn-controller.ts` +- `src/web/server.ts` +- `test/respawn-controller.test.ts` + +**Changes implemented**: +1. Added `stopHookReceived`, `stopHookTime`, `idlePromptReceived`, `idlePromptTime` fields to `DetectionStatus` +2. Added `hookConfirmTimer` for short confirmation after hook signal (3s) +3. Added `signalStopHook()` method: + - Sets `stopHookReceived = true` and timestamp + - Cancels any running AI check (hook is definitive) + - Starts 3s confirmation timer + - If no new output during confirmation → triggers respawn cycle +4. Added `signalIdlePrompt()` method: + - Sets `idlePromptReceived = true` and timestamp + - Immediately confirms idle (skips confirmation timer - 60s+ already proven) +5. Added `resetHookState()` to clear hook flags on: + - Controller start + - Working patterns detected + - Cycle completion +6. Updated server.ts `/api/hook-event` endpoint to call: + - `controller.signalStopHook()` for `stop` events + - `controller.signalIdlePrompt()` for `idle_prompt` events +7. Updated `getDetectionStatus()`: + - Returns hook signal states + - Sets confidence to 100% when hook received + - Updates statusText to show hook status + +**Tests added** (9 new tests in `RespawnController Hook-Based Idle Detection` describe block): +- `should expose signalStopHook method` +- `should expose signalIdlePrompt method` +- `should set stopHookReceived in detection status when Stop hook signaled` +- `should include hook status in statusText when Stop hook received` +- `should trigger respawn cycle after Stop hook confirmation` +- `should immediately confirm idle when idle_prompt signaled (skip confirmation)` +- `should cancel Stop hook confirmation if working patterns detected` +- `should ignore Stop hook when not in watching state` +- `should have 100% confidence when hook signal is received` + +**Detection status update** (implemented): +```typescript +interface DetectionStatus { + /** Layer 0: Stop hook received (highest priority - definitive signal) */ + stopHookReceived: boolean; + stopHookTime: number | null; + /** Layer 0: idle_prompt notification received (definitive signal) */ + idlePromptReceived: boolean; + idlePromptTime: number | null; + + // Existing fields... +} +``` + +### Phase 2: Use idle_prompt for Definitive Idle ✅ COMPLETED (in Phase 1) + +**Already implemented in Phase 1**: +1. `signalIdlePrompt()` method sets `idlePromptReceived = true` +2. Immediately calls `onIdleConfirmed()` - skips all other detection +3. Server.ts calls `controller.signalIdlePrompt()` when `idle_prompt` event received +4. 60s+ of Claude waiting = definitive idle signal + +### Phase 3: Transcript File Monitoring ✅ COMPLETED + +**New file**: `src/transcript-watcher.ts` + +**Functionality implemented**: +1. Watch the session's transcript JSONL file using `fs.watch()` +2. Parse new entries as they're appended (incremental reading from last position) +3. Detect: + - `result` entry → `transcript:complete` event (isComplete = true) + - `tool_use` content block → `transcript:tool_start` event + - `tool_result` content block → `transcript:tool_end` event + - `AskUserQuestion` or `ExitPlanMode` tools → `transcript:plan_mode` event + - Error conditions in result entries +4. Emit structured events consumed by respawn controller + +**Integration implemented**: +- `transcript_path` added to allowed hook data fields in `sanitizeHookData()` +- `transcriptWatchers` Map added to WebServer for per-session watchers +- `startTranscriptWatcher()` creates watcher and wires up events: + - `transcript:complete` → `controller.signalTranscriptComplete()` + - `transcript:plan_mode` → `controller.signalTranscriptPlanMode()` +- `stopTranscriptWatcher()` cleans up on session cleanup +- Hook events with `transcript_path` automatically start watching + +**RespawnController methods added**: +- `signalTranscriptComplete()` - Supporting signal that can accelerate idle detection +- `signalTranscriptPlanMode()` - Cancels auto-accept timer (like elicitation) + +**Tests added** (13 tests in `test/transcript-watcher.test.ts`): +- Initialization tests +- File watching tests (existing file, non-existent file, stop, updatePath) +- Entry processing tests (user entry, result entry, tool execution, plan mode, errors) +- State management tests + +### Phase 4: Enhanced Hook Configuration + +**Update `src/hooks-config.ts`**: + +```typescript +export function generateHooksConfig(): { hooks: Record } { + return { + hooks: { + Notification: [ + { matcher: 'idle_prompt', hooks: [...] }, + { matcher: 'permission_prompt', hooks: [...] }, + { matcher: 'elicitation_dialog', hooks: [...] }, + ], + Stop: [{ hooks: [...] }], + // NEW: Add these + PostToolUse: [ + { matcher: '*', hooks: [...] } // Track all tool completions + ], + SubagentStop: [{ hooks: [...] }], + PreCompact: [ + { matcher: '*', hooks: [...] } // Track compaction + ], + }, + }; +} +``` + +### Phase 5: Confidence Scoring Overhaul + +Replace current confidence calculation with weighted signals: + +```typescript +function calculateConfidence(): number { + let confidence = 0; + + // Definitive signals (100% confidence) + if (this.stopHookReceived) confidence = 100; + if (this.idlePromptReceived) confidence = 100; + + // Blocking signals (0% confidence) + if (this.permissionPromptReceived) return 0; + if (this.elicitationReceived) return 0; + if (this.workingPatternRecent) return 0; + + // Supporting signals (build up to ~80%) + if (confidence < 100) { + if (this.outputSilent) confidence += 30; + if (this.tokensStable) confidence += 20; + if (this.transcriptShowsCompletion) confidence += 30; + } + + return Math.min(100, confidence); +} +``` + +--- + +## Expected Benefits + +| Metric | Current | After Implementation | +|--------|---------|---------------------| +| False positive rate | ~15-20% | <5% | +| Detection latency | 10-90s (AI check) | 3-5s (hook-based) | +| API calls for AI check | Every idle detection | Only when hooks unavailable | +| Reliability | Medium | High (definitive signals) | + +--- + +## Testing Strategy + +### Unit Tests +1. `Stop` hook triggers immediate idle confirmation +2. `idle_prompt` skips all other detection +3. `permission_prompt` blocks idle detection +4. Transcript parsing correctly identifies completion +5. Fallback to AI check when no hooks received + +### Integration Tests +1. End-to-end with real Claude session +2. Hook event delivery and handling +3. Transcript file monitoring +4. Race condition handling + +### Scenarios to Test +1. Normal completion → Stop hook → respawn +2. Long-running task → idle_prompt → respawn +3. Permission needed → wait for user action +4. AskUserQuestion → wait for user answer +5. Plan mode → auto-accept → continue +6. Hooks disabled/unavailable → fallback to AI check + +--- + +## Migration Path + +1. **Implement Phase 1** - Stop hook detection (low risk, high value) +2. **Deploy and monitor** - Verify Stop hooks are reliable +3. **Implement Phase 2** - idle_prompt (simple addition) +4. **Implement Phase 3** - Transcript monitoring (more complex) +5. **Implement Phase 4** - Enhanced hooks (optional, for completeness) +6. **Implement Phase 5** - Refactor confidence scoring + +--- + +## Open Questions + +1. **Stop hook reliability**: Does it fire 100% of the time? Edge cases? +2. **Transcript file location**: Always at the path in hook input? +3. **Hook delivery latency**: How quickly do hooks fire after state change? +4. **Race conditions**: What if Stop hook and new work happen simultaneously? + +--- + +## References + +- [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks) +- [Agent SDK Documentation](https://platform.claude.com/docs/en/agent-sdk/overview) +- Current implementation: `src/respawn-controller.ts` +- Hooks config: `src/hooks-config.ts` +- Subagent watcher: `src/subagent-watcher.ts` diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index ec2476a0..149dd2ce 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -94,6 +94,15 @@ export interface ActiveTimerInfo { } export interface DetectionStatus { + /** Layer 0: Stop hook received (highest priority - definitive signal) */ + stopHookReceived: boolean; + /** Timestamp when Stop hook was received */ + stopHookTime: number | null; + /** Layer 0: idle_prompt notification received (definitive signal) */ + idlePromptReceived: boolean; + /** Timestamp when idle_prompt was received */ + idlePromptTime: number | null; + /** Layer 1: Completion message detected ("for Xm Xs") */ completionMessageDetected: boolean; /** Timestamp when completion message was last seen */ @@ -570,6 +579,26 @@ export class RespawnController extends EventEmitter { /** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */ private elicitationDetected: boolean = false; + // ========== Hook-Based Detection State (Layer 0 - Highest Priority) ========== + + /** Whether a Stop hook was received (definitive idle signal from Claude Code) */ + private stopHookReceived: boolean = false; + + /** Timestamp when Stop hook was received */ + private stopHookTime: number | null = null; + + /** Whether an idle_prompt notification was received (60s+ idle signal) */ + private idlePromptReceived: boolean = false; + + /** Timestamp when idle_prompt was received */ + private idlePromptTime: number | null = null; + + /** Timer for short confirmation after hook signal (handles race conditions) */ + private hookConfirmTimer: NodeJS.Timeout | null = null; + + /** Confirmation delay after hook signal before confirming idle (ms) */ + private static readonly HOOK_CONFIRM_DELAY_MS = 3000; + /** Number of completed respawn cycles */ private cycleCount: number = 0; @@ -795,11 +824,16 @@ export class RespawnController extends EventEmitter { const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns // Calculate confidence level (0-100) + // Hook signals are definitive (100% confidence) let confidence = 0; - if (completionMessageDetected) confidence += 40; - if (outputSilent) confidence += 25; - if (tokensStable) confidence += 20; - if (workingPatternsAbsent) confidence += 15; + if (this.stopHookReceived || this.idlePromptReceived) { + confidence = 100; + } else { + if (completionMessageDetected) confidence += 40; + if (outputSilent) confidence += 25; + if (tokensStable) confidence += 20; + if (workingPatternsAbsent) confidence += 15; + } // Determine status text and what we're waiting for let statusText: string; @@ -808,6 +842,10 @@ export class RespawnController extends EventEmitter { if (this._state === 'stopped') { statusText = 'Controller stopped'; waitingFor = 'Start to begin monitoring'; + } else if (this.stopHookReceived || this.idlePromptReceived) { + const hookType = this.idlePromptReceived ? 'idle_prompt' : 'Stop'; + statusText = `${hookType} hook received - confirming idle`; + waitingFor = 'Short confirmation (race condition check)'; } else if (this._state === 'ai_checking') { statusText = 'AI Check: Analyzing terminal output...'; waitingFor = 'AI verdict (IDLE or WORKING)'; @@ -901,6 +939,10 @@ export class RespawnController extends EventEmitter { } return { + stopHookReceived: this.stopHookReceived, + stopHookTime: this.stopHookTime, + idlePromptReceived: this.idlePromptReceived, + idlePromptTime: this.idlePromptTime, completionMessageDetected, completionMessageTime: this.completionMessageTime, outputSilent, @@ -997,7 +1039,7 @@ export class RespawnController extends EventEmitter { this.log('Starting respawn controller (multi-layer detection)'); - // Initialize all timestamps + // Initialize all timestamps and reset hook state const now = Date.now(); this.lastActivityTime = now; this.lastOutputTime = now; @@ -1005,6 +1047,7 @@ export class RespawnController extends EventEmitter { this.lastWorkingPatternTime = now; this.completionMessageTime = null; this.hasReceivedOutput = false; + this.resetHookState(); // Seed the terminal buffer from the session's existing output. // This gives the AI checker context even if no new output arrives. @@ -1139,9 +1182,14 @@ export class RespawnController extends EventEmitter { this.workingDetected = true; this.promptDetected = false; this.elicitationDetected = false; // Clear on new work cycle + this.resetHookState(); // Clear hook signals on new work this.lastWorkingPatternTime = now; this.clearIdleTimer(); + // Cancel hook confirmation timer if running + this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected'); + this.hookConfirmTimer = null; + // Cancel any pending completion confirmation this.cancelCompletionConfirm(); @@ -1396,7 +1444,7 @@ export class RespawnController extends EventEmitter { } } - /** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, and clear fallback) */ + /** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */ private clearTimers(): void { this.clearIdleTimer(); if (this.stepTimer) { @@ -1427,6 +1475,10 @@ export class RespawnController extends EventEmitter { clearTimeout(this.noOutputTimer); this.noOutputTimer = null; } + if (this.hookConfirmTimer) { + clearTimeout(this.hookConfirmTimer); + this.hookConfirmTimer = null; + } // Clear all tracked timers this.activeTimers.clear(); } @@ -1940,6 +1992,163 @@ export class RespawnController extends EventEmitter { this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle'); } + /** + * Signal that a Stop hook was received from Claude Code. + * This is a DEFINITIVE signal that Claude has finished responding. + * Skips AI idle check and uses a short confirmation period to handle race conditions. + * + * @fires log + */ + signalStopHook(): void { + // Only process in states where we're watching for idle + if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { + this.log(`Stop hook received but ignoring (state is ${this._state})`); + return; + } + + const now = Date.now(); + this.stopHookReceived = true; + this.stopHookTime = now; + this.logAction('hook', 'Stop hook received - definitive idle signal'); + this.log('Stop hook received from Claude Code - definitive idle signal'); + + // Cancel any running AI check - we have a definitive signal + if (this._state === 'ai_checking') { + this.log('Cancelling AI check - Stop hook is definitive'); + this.aiChecker.cancel(); + } + + // Cancel completion confirm timer - hook takes precedence + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received'); + this.completionConfirmTimer = null; + + // Cancel pre-filter timer - hook takes precedence + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received'); + this.preFilterTimer = null; + + // Start short confirmation timer to handle race conditions + // (e.g., Stop hook arrives but Claude immediately starts new work) + this.startHookConfirmTimer('stop'); + } + + /** + * Signal that an idle_prompt notification was received from Claude Code. + * This fires after 60+ seconds of Claude waiting for user input. + * This is a DEFINITIVE signal that Claude is idle. + * + * @fires log + */ + signalIdlePrompt(): void { + // Only process in states where we're watching for idle + if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { + this.log(`idle_prompt received but ignoring (state is ${this._state})`); + return; + } + + const now = Date.now(); + this.idlePromptReceived = true; + this.idlePromptTime = now; + this.logAction('hook', 'idle_prompt received - 60s+ idle confirmed'); + this.log('idle_prompt notification received - Claude has been idle for 60+ seconds'); + + // Cancel any running AI check - we have a definitive signal + if (this._state === 'ai_checking') { + this.log('Cancelling AI check - idle_prompt is definitive'); + this.aiChecker.cancel(); + } + + // Cancel all other detection timers - this is definitive + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received'); + this.completionConfirmTimer = null; + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received'); + this.preFilterTimer = null; + this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received'); + this.noOutputTimer = null; + + // idle_prompt is an even stronger signal than Stop hook (60s+ idle) + // Skip confirmation and go directly to idle + this.onIdleConfirmed('idle_prompt hook (60s+ idle)'); + } + + /** + * Start a short confirmation timer after receiving a hook signal. + * This handles race conditions where a hook arrives but Claude immediately starts new work. + * + * @param hookType - Which hook triggered this ('stop' or 'idle_prompt') + */ + private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void { + this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting'); + this.hookConfirmTimer = null; + + this.hookConfirmTimer = this.startTrackedTimer( + 'hook-confirm', + RespawnController.HOOK_CONFIRM_DELAY_MS, + () => { + this.hookConfirmTimer = null; + + // Verify we haven't received new output since the hook arrived + const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime; + if (hookTime && this.lastOutputTime > hookTime) { + // Output arrived after hook - Claude started new work + this.log(`Output received after ${hookType} hook, cancelling idle confirmation`); + this.logAction('hook', `${hookType} cancelled - new output detected`); + this.resetHookState(); + this.setState('watching'); + this.startNoOutputTimer(); + this.startPreFilterTimer(); + return; + } + + // No new output - confirm idle via hook signal + this.logAction('hook', `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`); + this.onIdleConfirmed(`${hookType} hook (confirmed)`); + }, + `confirming ${hookType} hook` + ); + } + + /** + * Reset hook-based detection state. + * Called when hooks are cancelled due to new activity. + */ + private resetHookState(): void { + this.stopHookReceived = false; + this.stopHookTime = null; + this.idlePromptReceived = false; + this.idlePromptTime = null; + } + + /** + * Signal that the transcript indicates completion. + * This is a supporting signal from transcript file monitoring. + * Unlike hooks, this doesn't immediately trigger idle - it boosts confidence. + */ + signalTranscriptComplete(): void { + // Transcript completion is a supporting signal, not definitive + // It can help reduce the confirmation time needed + if (this._state === 'watching') { + this.logAction('transcript', 'Transcript shows completion - boosting confidence'); + this.log('Transcript completion detected - may accelerate idle detection'); + // If we have a completion message and transcript confirms, try AI check + if (this.completionMessageTime !== null) { + this.tryStartAiCheck('transcript + completion message'); + } + } + } + + /** + * Signal that the transcript indicates plan mode. + * This helps prevent auto-accept from triggering on AskUserQuestion. + */ + signalTranscriptPlanMode(): void { + // Plan mode from transcript = potential AskUserQuestion + // This is similar to elicitation detection + if (this._state === 'watching') { + this.logAction('transcript', 'Plan mode / AskUserQuestion detected'); + this.cancelAutoAcceptTimer(); + } + } + /** * Start completion confirmation timer. * After completion message, waits for output silence then triggers AI check. @@ -2183,6 +2392,14 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.promptDetected = false; this.workingDetected = false; + this.resetHookState(); // Clear hook signals for next cycle + + // Restart detection timers for next cycle + this.startNoOutputTimer(); + this.startPreFilterTimer(); + if (this.config.autoAcceptPrompts) { + this.startAutoAcceptTimer(); + } } /** diff --git a/src/transcript-watcher.ts b/src/transcript-watcher.ts new file mode 100644 index 00000000..52a358ad --- /dev/null +++ b/src/transcript-watcher.ts @@ -0,0 +1,422 @@ +/** + * @fileoverview Transcript Watcher - Real-time monitoring of Claude Code session transcripts + * + * Watches the main session transcript JSONL file and emits structured events for: + * - Assistant message completion + * - Tool execution state + * - Error conditions + * - Plan mode prompts + * + * The transcript path is provided by Claude Code hooks in the `transcript_path` field. + */ + +import { EventEmitter } from 'events'; +import { watch, statSync, existsSync, FSWatcher } from 'fs'; +import { createReadStream } from 'fs'; +import { createInterface } from 'readline'; + +// ========== Types ========== + +/** + * Parsed transcript entry from the JSONL file + */ +export interface TranscriptEntry { + type: 'user' | 'assistant' | 'system' | 'result'; + timestamp: string; + message?: { + role: string; + content: string | TranscriptContentBlock[]; + }; + total_cost_usd?: number; + duration_ms?: number; + error?: { + type: string; + message: string; + }; +} + +export interface TranscriptContentBlock { + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + name?: string; + input?: Record; + content?: string; + is_error?: boolean; +} + +/** + * Detected state from transcript analysis + */ +export interface TranscriptState { + /** Whether the last entry indicates completion */ + isComplete: boolean; + /** Whether a tool is currently executing */ + toolExecuting: boolean; + /** Current tool name if executing */ + currentTool: string | null; + /** Whether an error was detected */ + hasError: boolean; + /** Error message if any */ + errorMessage: string | null; + /** Whether a plan mode prompt was detected */ + planModeDetected: boolean; + /** Last assistant message (truncated) */ + lastAssistantMessage: string | null; + /** Total entries processed */ + entryCount: number; + /** Last update timestamp */ + lastUpdateAt: string | null; +} + +export interface TranscriptWatcherEvents { + 'transcript:update': (state: TranscriptState) => void; + 'transcript:complete': (state: TranscriptState) => void; + 'transcript:tool_start': (toolName: string) => void; + 'transcript:tool_end': (toolName: string, isError: boolean) => void; + 'transcript:error': (error: Error) => void; + 'transcript:plan_mode': () => void; +} + +// ========== Constants ========== + +/** How often to check for new content when file watching fails */ +const POLL_INTERVAL_MS = 1000; + +/** Max characters to keep for lastAssistantMessage */ +const MAX_MESSAGE_LENGTH = 500; + +/** Patterns that indicate plan mode / approval prompt */ +const PLAN_MODE_PATTERNS = [ + /ExitPlanMode/i, + /AskUserQuestion/i, + /Ready for user approval/i, + /approve.*plan/i, +]; + +// ========== TranscriptWatcher Class ========== + +export class TranscriptWatcher extends EventEmitter { + private transcriptPath: string | null = null; + private fileWatcher: FSWatcher | null = null; + private pollInterval: NodeJS.Timeout | null = null; + private filePosition: number = 0; + private _isRunning: boolean = false; + private state: TranscriptState = this.getInitialState(); + + constructor() { + super(); + } + + private getInitialState(): TranscriptState { + return { + isComplete: false, + toolExecuting: false, + currentTool: null, + hasError: false, + errorMessage: null, + planModeDetected: false, + lastAssistantMessage: null, + entryCount: 0, + lastUpdateAt: null, + }; + } + + // ========== Public API ========== + + /** + * Start watching a transcript file + * @param transcriptPath - Path to the JSONL transcript file + */ + start(transcriptPath: string): void { + if (this._isRunning && this.transcriptPath === transcriptPath) { + return; // Already watching this file + } + + // Stop any existing watcher + this.stop(); + + this.transcriptPath = transcriptPath; + this._isRunning = true; + this.state = this.getInitialState(); + this.filePosition = 0; + + // Check if file exists + if (!existsSync(transcriptPath)) { + // File doesn't exist yet, poll until it does + this.startPolling(); + return; + } + + // Get initial file size + try { + const stat = statSync(transcriptPath); + // Start from the end to only process new entries + this.filePosition = stat.size; + } catch { + this.filePosition = 0; + } + + // Start watching + this.setupFileWatcher(); + } + + /** + * Stop watching + */ + stop(): void { + this._isRunning = false; + + if (this.fileWatcher) { + this.fileWatcher.close(); + this.fileWatcher = null; + } + + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + this.transcriptPath = null; + this.state = this.getInitialState(); + } + + /** + * Check if watcher is running + */ + isRunning(): boolean { + return this._isRunning; + } + + /** + * Get current state + */ + getState(): TranscriptState { + return { ...this.state }; + } + + /** + * Update the transcript path (e.g., from a new hook event) + */ + updatePath(transcriptPath: string): void { + if (this.transcriptPath !== transcriptPath) { + this.start(transcriptPath); + } + } + + // ========== Private Methods ========== + + private startPolling(): void { + if (this.pollInterval) return; + + this.pollInterval = setInterval(() => { + if (!this.transcriptPath || !this._isRunning) return; + + if (existsSync(this.transcriptPath)) { + // File now exists, switch to file watching + clearInterval(this.pollInterval!); + this.pollInterval = null; + this.setupFileWatcher(); + } + }, POLL_INTERVAL_MS); + } + + private setupFileWatcher(): void { + if (!this.transcriptPath || !this._isRunning) return; + + try { + this.fileWatcher = watch(this.transcriptPath, (eventType) => { + if (eventType === 'change') { + this.processNewContent(); + } + }); + + // Initial read + this.processNewContent(); + } catch (err) { + // Fall back to polling if watch fails + this.emit('transcript:error', err as Error); + this.startPolling(); + } + } + + private async processNewContent(): Promise { + if (!this.transcriptPath || !this._isRunning) return; + + try { + const stat = statSync(this.transcriptPath); + if (stat.size <= this.filePosition) { + return; // No new content + } + + // Read new content + const newEntries = await this.readNewEntries(); + + for (const entry of newEntries) { + this.processEntry(entry); + } + + if (newEntries.length > 0) { + this.emit('transcript:update', this.getState()); + } + } catch (err) { + this.emit('transcript:error', err as Error); + } + } + + private readNewEntries(): Promise { + return new Promise((resolve, reject) => { + if (!this.transcriptPath) { + resolve([]); + return; + } + + const entries: TranscriptEntry[] = []; + const stream = createReadStream(this.transcriptPath, { + start: this.filePosition, + encoding: 'utf-8', + }); + + const rl = createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let bytesRead = this.filePosition; + + rl.on('line', (line) => { + bytesRead += Buffer.byteLength(line, 'utf-8') + 1; // +1 for newline + + if (!line.trim()) return; + + try { + const entry = JSON.parse(line) as TranscriptEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + }); + + rl.on('close', () => { + this.filePosition = bytesRead; + resolve(entries); + }); + + rl.on('error', reject); + stream.on('error', reject); + }); + } + + private processEntry(entry: TranscriptEntry): void { + this.state.entryCount++; + this.state.lastUpdateAt = entry.timestamp || new Date().toISOString(); + + // Handle based on entry type + switch (entry.type) { + case 'assistant': + this.handleAssistantEntry(entry); + break; + case 'result': + this.handleResultEntry(entry); + break; + case 'user': + // User message means new turn, reset some state + this.state.isComplete = false; + this.state.hasError = false; + this.state.errorMessage = null; + break; + case 'system': + // System messages are informational + break; + } + + // Check for plan mode patterns + this.checkPlanMode(entry); + } + + private handleAssistantEntry(entry: TranscriptEntry): void { + if (!entry.message?.content) return; + + const content = entry.message.content; + + if (typeof content === 'string') { + this.state.lastAssistantMessage = content.slice(0, MAX_MESSAGE_LENGTH); + } else if (Array.isArray(content)) { + // Process content blocks + for (const block of content) { + if (block.type === 'text' && block.text) { + this.state.lastAssistantMessage = block.text.slice(0, MAX_MESSAGE_LENGTH); + } else if (block.type === 'tool_use' && block.name) { + // Tool started + this.state.toolExecuting = true; + this.state.currentTool = block.name; + this.emit('transcript:tool_start', block.name); + } else if (block.type === 'tool_result') { + // Tool completed + const wasError = block.is_error === true; + const toolName = this.state.currentTool; + this.state.toolExecuting = false; + this.state.currentTool = null; + if (toolName) { + this.emit('transcript:tool_end', toolName, wasError); + } + if (wasError && block.content) { + this.state.hasError = true; + this.state.errorMessage = String(block.content).slice(0, 200); + } + } + } + } + } + + private handleResultEntry(entry: TranscriptEntry): void { + // Result entry indicates completion + this.state.isComplete = true; + this.state.toolExecuting = false; + this.state.currentTool = null; + + if (entry.error) { + this.state.hasError = true; + this.state.errorMessage = entry.error.message?.slice(0, 200) || 'Unknown error'; + } + + this.emit('transcript:complete', this.getState()); + } + + private checkPlanMode(entry: TranscriptEntry): void { + // Check assistant messages for plan mode patterns + if (entry.type !== 'assistant' || !entry.message?.content) return; + + const content = entry.message.content; + const textToCheck = typeof content === 'string' + ? content + : content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text) + .map(b => b.text) + .join(' '); + + // Also check for tool_use with ExitPlanMode or AskUserQuestion + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'tool_use' && block.name) { + if (block.name === 'ExitPlanMode' || block.name === 'AskUserQuestion') { + this.state.planModeDetected = true; + this.emit('transcript:plan_mode'); + return; + } + } + } + } + + for (const pattern of PLAN_MODE_PATTERNS) { + if (pattern.test(textToCheck)) { + this.state.planModeDetected = true; + this.emit('transcript:plan_mode'); + return; + } + } + } +} + +// ========== Singleton Export ========== + +export const transcriptWatcher = new TranscriptWatcher(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 2236a927..fa027e08 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2153,6 +2153,19 @@ class ClaudemanApp { const waitingEl = this.$('detectionWaiting'); const confidenceEl = this.$('detectionConfidence'); const aiCheckEl = document.getElementById('detectionAiCheck'); + const hookEl = document.getElementById('detectionHook'); + + // Hook-based detection indicator (highest priority signals) + if (hookEl) { + if (detection.stopHookReceived || detection.idlePromptReceived) { + const hookType = detection.idlePromptReceived ? 'idle_prompt' : 'Stop'; + hookEl.textContent = `🎯 ${hookType} hook received`; + hookEl.className = 'detection-hook hook-active'; + hookEl.style.display = ''; + } else { + hookEl.style.display = 'none'; + } + } // Simplified status - only show when meaningful if (detection.statusText && detection.statusText !== 'Watching...') { @@ -2171,8 +2184,14 @@ class ClaudemanApp { confidenceEl.textContent = `${confidence}%`; confidenceEl.style.display = ''; confidenceEl.className = 'detection-confidence'; - if (confidence >= 60) confidenceEl.classList.add('high'); - else if (confidence >= 30) confidenceEl.classList.add('medium'); + // Hook signals give 100% confidence + if (detection.stopHookReceived || detection.idlePromptReceived) { + confidenceEl.classList.add('hook-confirmed'); + } else if (confidence >= 60) { + confidenceEl.classList.add('high'); + } else if (confidence >= 30) { + confidenceEl.classList.add('medium'); + } } else { confidenceEl.style.display = 'none'; } diff --git a/src/web/public/index.html b/src/web/public/index.html index d5592940..f67cb717 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -73,6 +73,7 @@ + #0 diff --git a/src/web/public/styles.css b/src/web/public/styles.css index ebe125df..6ea6e8a8 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -671,6 +671,31 @@ body { color: var(--yellow); } +.detection-confidence.hook-confirmed { + background: rgba(139, 92, 246, 0.3); + color: var(--purple, #a78bfa); +} + +.detection-hook { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 3px; + background: rgba(139, 92, 246, 0.15); + color: var(--purple, #a78bfa); +} + +.detection-hook.hook-active { + background: rgba(139, 92, 246, 0.25); + color: var(--purple, #a78bfa); + font-weight: 500; + animation: pulse-hook 1.5s ease-in-out infinite; +} + +@keyframes pulse-hook { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + @keyframes spin { to { transform: rotate(360deg); } } diff --git a/src/web/server.ts b/src/web/server.ts index d74068f3..34c04eb6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -28,6 +28,7 @@ import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { writeHooksConfig } from '../hooks-config.js'; import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage } from '../subagent-watcher.js'; +import { TranscriptWatcher } from '../transcript-watcher.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; @@ -112,7 +113,7 @@ function sanitizeHookData(data: Record | undefined): Record = {}; const allowedKeys = [ 'hook_event_name', 'tool_name', 'tool_input', 'session_id', - 'cwd', 'permission_mode', 'stop_hook_active', + 'cwd', 'permission_mode', 'stop_hook_active', 'transcript_path', ]; for (const key of allowedKeys) { @@ -233,6 +234,7 @@ export class WebServer extends EventEmitter { private sessions: Map = new Map(); private respawnControllers: Map = new Map(); private respawnTimers: Map = new Map(); + private transcriptWatchers: Map = new Map(); private scheduledRuns: Map = new Map(); private sseClients: Set = new Set(); private store = getStore(); @@ -1698,11 +1700,26 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - // Signal the respawn controller to block auto-accept for question prompts - if (event === 'elicitation_dialog') { - const controller = this.respawnControllers.get(sessionId); - if (controller) { + // Signal the respawn controller based on hook event type + const controller = this.respawnControllers.get(sessionId); + if (controller) { + if (event === 'elicitation_dialog') { + // Block auto-accept for question prompts controller.signalElicitation(); + } else if (event === 'stop') { + // DEFINITIVE idle signal - Claude finished responding + controller.signalStopHook(); + } else if (event === 'idle_prompt') { + // DEFINITIVE idle signal - Claude has been idle for 60+ seconds + controller.signalIdlePrompt(); + } + } + + // Start transcript watching if transcript_path is provided + if (data && typeof data === 'object' && 'transcript_path' in data) { + const transcriptPath = String(data.transcript_path); + if (transcriptPath) { + this.startTranscriptWatcher(sessionId, transcriptPath); } } @@ -1713,6 +1730,63 @@ export class WebServer extends EventEmitter { }); } + /** + * Start a transcript watcher for a session. + * Creates a new watcher or updates an existing one with the new transcript path. + */ + private startTranscriptWatcher(sessionId: string, transcriptPath: string): void { + let watcher = this.transcriptWatchers.get(sessionId); + + if (!watcher) { + watcher = new TranscriptWatcher(); + + // Wire up transcript events to the respawn controller + watcher.on('transcript:complete', () => { + const controller = this.respawnControllers.get(sessionId); + if (controller) { + controller.signalTranscriptComplete(); + } + this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() }); + }); + + watcher.on('transcript:plan_mode', () => { + const controller = this.respawnControllers.get(sessionId); + if (controller) { + controller.signalTranscriptPlanMode(); + } + this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() }); + }); + + watcher.on('transcript:tool_start', (toolName: string) => { + this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() }); + }); + + watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => { + this.broadcast('transcript:tool_end', { sessionId, toolName, isError, timestamp: Date.now() }); + }); + + watcher.on('transcript:error', (error: Error) => { + console.error(`[Transcript] Error for session ${sessionId}:`, error.message); + }); + + this.transcriptWatchers.set(sessionId, watcher); + } + + // Start or update the watcher with the transcript path + watcher.updatePath(transcriptPath); + } + + /** + * Stop the transcript watcher for a session. + */ + private stopTranscriptWatcher(sessionId: string): void { + const watcher = this.transcriptWatchers.get(sessionId); + if (watcher) { + watcher.stop(); + this.transcriptWatchers.delete(sessionId); + } + } + /** Persists full session state including respawn config to state.json */ private persistSessionState(session: Session): void { const state = session.toState(); @@ -1852,6 +1926,9 @@ export class WebServer extends EventEmitter { this.respawnTimers.delete(sessionId); } + // Stop transcript watcher + this.stopTranscriptWatcher(sessionId); + // Clear batches and pending state updates this.terminalBatches.delete(sessionId); this.outputBatches.delete(sessionId); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index dda7f661..5dfa62d0 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -2531,4 +2531,158 @@ describe('RespawnController Timer Cleanup', () => { // Should end in stopped state without errors expect(controller.state).toBe('stopped'); }); + +}); + +// ========== Hook-Based Detection Tests (Phase 1) ========== + +describe('RespawnController Hook-Based Idle Detection', () => { + let session: MockSession; + let controller: RespawnController; + + beforeEach(() => { + session = new MockSession(); + controller = new RespawnController(session as unknown as Session, { + idleTimeoutMs: 100, + interStepDelayMs: 50, + completionConfirmMs: 50, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + }); + }); + + afterEach(() => { + controller.stop(); + }); + + it('should expose signalStopHook method', () => { + expect(typeof controller.signalStopHook).toBe('function'); + }); + + it('should expose signalIdlePrompt method', () => { + expect(typeof controller.signalIdlePrompt).toBe('function'); + }); + + it('should set stopHookReceived in detection status when Stop hook signaled', () => { + controller.start(); + expect(controller.state).toBe('watching'); + + controller.signalStopHook(); + + const status = controller.getDetectionStatus(); + expect(status.stopHookReceived).toBe(true); + expect(status.stopHookTime).not.toBeNull(); + expect(status.confidenceLevel).toBe(100); // Hook signals are definitive + }); + + it('should include hook status in statusText when Stop hook received', () => { + controller.start(); + controller.signalStopHook(); + + const status = controller.getDetectionStatus(); + expect(status.statusText).toContain('Stop hook received'); + }); + + it('should trigger respawn cycle after Stop hook confirmation', async () => { + const testController = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + }); + + const cycleStarted = vi.fn(); + testController.on('respawnCycleStarted', cycleStarted); + + testController.start(); + testController.signalStopHook(); + + // Wait for hook confirmation timer (3s default) + await new Promise(resolve => setTimeout(resolve, 3100)); + + expect(cycleStarted).toHaveBeenCalled(); + testController.stop(); + }); + + it('should immediately confirm idle when idle_prompt signaled (skip confirmation)', async () => { + const testController = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + }); + + const cycleStarted = vi.fn(); + testController.on('respawnCycleStarted', cycleStarted); + + testController.start(); + testController.signalIdlePrompt(); + + // idle_prompt skips confirmation and goes directly to idle + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(cycleStarted).toHaveBeenCalled(); + testController.stop(); + }); + + it('should cancel Stop hook confirmation if working patterns detected', async () => { + const testController = new RespawnController(session as unknown as Session, { + completionConfirmMs: 5000, // Long enough to not interfere with test timing + noOutputTimeoutMs: 10000, + aiIdleCheckEnabled: false, + }); + + const cycleStarted = vi.fn(); + testController.on('respawnCycleStarted', cycleStarted); + + testController.start(); + testController.signalStopHook(); + + // Simulate working patterns IMMEDIATELY after hook (before confirmation) + await new Promise(resolve => setTimeout(resolve, 100)); + session.simulateWorking(); + + // Wait longer than hook confirmation delay (3s) + await new Promise(resolve => setTimeout(resolve, 3500)); + + // Cycle should NOT have started because working was detected + expect(cycleStarted).not.toHaveBeenCalled(); + + const status = testController.getDetectionStatus(); + expect(status.stopHookReceived).toBe(false); // Reset by working detection + testController.stop(); + }); + + it('should ignore Stop hook when not in watching state', async () => { + const testController = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + sendClear: false, + sendInit: false, + }); + + testController.start(); + testController.signalIdlePrompt(); // Start a cycle + + await new Promise(resolve => setTimeout(resolve, 100)); + + // Now in sending_update state - Stop hook should be ignored + testController.signalStopHook(); + + const status = testController.getDetectionStatus(); + expect(status.stopHookReceived).toBe(false); + testController.stop(); + }); + + it('should have 100% confidence when hook signal is received', () => { + controller.start(); + + // Before hook - confidence should be low + let status = controller.getDetectionStatus(); + expect(status.confidenceLevel).toBeLessThan(100); + + // After Stop hook - confidence should be 100% + controller.signalStopHook(); + status = controller.getDetectionStatus(); + expect(status.confidenceLevel).toBe(100); + }); }); diff --git a/test/transcript-watcher.test.ts b/test/transcript-watcher.test.ts new file mode 100644 index 00000000..c286dd0f --- /dev/null +++ b/test/transcript-watcher.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TranscriptWatcher, TranscriptState } from '../src/transcript-watcher.js'; +import { writeFileSync, unlinkSync, mkdirSync, existsSync, appendFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('TranscriptWatcher', () => { + let watcher: TranscriptWatcher; + let testDir: string; + let testFile: string; + + beforeEach(() => { + watcher = new TranscriptWatcher(); + testDir = join(tmpdir(), `transcript-test-${Date.now()}`); + if (!existsSync(testDir)) { + mkdirSync(testDir, { recursive: true }); + } + testFile = join(testDir, 'test-transcript.jsonl'); + }); + + afterEach(() => { + watcher.stop(); + // Clean up test file + try { + if (existsSync(testFile)) { + unlinkSync(testFile); + } + } catch { + // Ignore cleanup errors + } + }); + + describe('Initialization', () => { + it('should start in stopped state', () => { + expect(watcher.isRunning()).toBe(false); + }); + + it('should have initial state with defaults', () => { + const state = watcher.getState(); + expect(state.isComplete).toBe(false); + expect(state.toolExecuting).toBe(false); + expect(state.currentTool).toBeNull(); + expect(state.hasError).toBe(false); + expect(state.planModeDetected).toBe(false); + expect(state.entryCount).toBe(0); + }); + }); + + describe('File Watching', () => { + it('should start watching an existing file', () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + expect(watcher.isRunning()).toBe(true); + }); + + it('should handle non-existent file by polling', () => { + const nonExistent = join(testDir, 'nonexistent.jsonl'); + watcher.start(nonExistent); + expect(watcher.isRunning()).toBe(true); + }); + + it('should stop watching on stop()', () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + expect(watcher.isRunning()).toBe(true); + watcher.stop(); + expect(watcher.isRunning()).toBe(false); + }); + + it('should update path with updatePath()', () => { + const file1 = join(testDir, 'file1.jsonl'); + const file2 = join(testDir, 'file2.jsonl'); + writeFileSync(file1, ''); + writeFileSync(file2, ''); + + watcher.start(file1); + expect(watcher.isRunning()).toBe(true); + + watcher.updatePath(file2); + expect(watcher.isRunning()).toBe(true); + }); + }); + + describe('Entry Processing', () => { + it('should process user entry and reset state', async () => { + // Start with some state + writeFileSync(testFile, ''); + watcher.start(testFile); + + // Add user entry + const userEntry = { type: 'user', timestamp: new Date().toISOString(), message: { role: 'user', content: 'test' } }; + appendFileSync(testFile, JSON.stringify(userEntry) + '\n'); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 100)); + + const state = watcher.getState(); + expect(state.entryCount).toBeGreaterThanOrEqual(1); + }); + + it('should emit transcript:complete on result entry', async () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + + const completeHandler = vi.fn(); + watcher.on('transcript:complete', completeHandler); + + // Add result entry + const resultEntry = { type: 'result', timestamp: new Date().toISOString() }; + appendFileSync(testFile, JSON.stringify(resultEntry) + '\n'); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(completeHandler).toHaveBeenCalled(); + const state = watcher.getState(); + expect(state.isComplete).toBe(true); + }); + + it('should track tool execution', async () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + + const toolStartHandler = vi.fn(); + watcher.on('transcript:tool_start', toolStartHandler); + + // Add assistant entry with tool_use + const assistantEntry = { + type: 'assistant', + timestamp: new Date().toISOString(), + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } } + ] + } + }; + appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n'); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(toolStartHandler).toHaveBeenCalledWith('Read'); + const state = watcher.getState(); + expect(state.toolExecuting).toBe(true); + expect(state.currentTool).toBe('Read'); + }); + + it('should detect plan mode from AskUserQuestion tool', async () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + + const planModeHandler = vi.fn(); + watcher.on('transcript:plan_mode', planModeHandler); + + // Add assistant entry with AskUserQuestion + const assistantEntry = { + type: 'assistant', + timestamp: new Date().toISOString(), + message: { + role: 'assistant', + content: [ + { type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } } + ] + } + }; + appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n'); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(planModeHandler).toHaveBeenCalled(); + const state = watcher.getState(); + expect(state.planModeDetected).toBe(true); + }); + + it('should detect errors in result entry', async () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + + // Add result entry with error + const resultEntry = { + type: 'result', + timestamp: new Date().toISOString(), + error: { type: 'api_error', message: 'Rate limited' } + }; + appendFileSync(testFile, JSON.stringify(resultEntry) + '\n'); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 200)); + + const state = watcher.getState(); + expect(state.hasError).toBe(true); + expect(state.errorMessage).toContain('Rate limited'); + }); + }); + + describe('State Management', () => { + it('should return a copy of state', () => { + const state1 = watcher.getState(); + const state2 = watcher.getState(); + expect(state1).not.toBe(state2); // Different objects + expect(state1).toEqual(state2); // Same content + }); + + it('should reset state on stop()', () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + watcher.stop(); + const state = watcher.getState(); + expect(state.entryCount).toBe(0); + }); + }); +});