From ec92e5b1cf95f14c7e1c8ff578e76ed6cbe731b2 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 22 Jan 2026 19:24:36 +0100 Subject: [PATCH] fix: respawn controller multi-layer detection and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update idle detection from legacy '↵ send' to completion message pattern ("for Xm Xs" time patterns like "Worked for 2m 46s") - Add confirming_idle state for false positive prevention - Add completionConfirmMs (5s) and noOutputTimeoutMs (30s) config options - Add multi-layer detection with confidence scoring (0-100%) - Fix null pointer error in extractTokenCount with guard clause - Fix respawn controller not stopping on session cleanup (broadcast respawn:stopped) - Update tests to use new completion message patterns - Add detection status UI display (confidence level, waiting state) - Update CLAUDE.md with new detection documentation Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 40 ++- src/respawn-controller.ts | 542 ++++++++++++++++++++++++++------ src/web/public/app.js | 43 +++ src/web/public/index.html | 5 + src/web/public/styles.css | 38 +++ src/web/server.ts | 6 + test/respawn-controller.test.ts | 92 +++--- test/setup.ts | 173 ++++------ 8 files changed, 678 insertions(+), 261 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c2ac3b7..cd6227da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,11 @@ npx vitest run -t "should create session" # By pattern # Global test utilities (describe/it/expect) available without imports (globals: true) # Tests run sequentially (fileParallelism: false) to respect screen session limits # Global setup (test/setup.ts) enforces max 10 concurrent screens + orphan cleanup +# +# ✅ TEST SAFETY: test/setup.ts protects its own process tree during cleanup. +# You can safely run tests from within a Claudeman-managed session - the cleanup +# will not kill your own Claude instance. The respawn-controller tests use +# MockSession (not real screens). # TypeScript checking npm run typecheck # Type check without building (or: npx tsc --noEmit) @@ -153,21 +158,18 @@ claudeman reset # Reset all state ### Respawn State Machine ``` -┌─────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ -▼ │ -WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR │ - │ │ - ▼ │ - SENDING_INIT → WAITING_INIT → MONITORING_INIT ──┬──────────────────┘ - │ - ▼ (if no work triggered) - SENDING_KICKSTART → WAITING_KICKSTART +WATCHING → CONFIRMING_IDLE → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR + ↑ │ (new output) │ + │ ↓ ▼ + │ (reset) SENDING_INIT → WAITING_INIT → MONITORING_INIT + │ │ + │ (if no work triggered) ▼ + └────────────────────────────────────── SENDING_KICKSTART ← WAITING_KICKSTART ◄┘ ``` -**States**: `watching`, `sending_update`, `waiting_update`, `sending_clear`, `waiting_clear`, `sending_init`, `waiting_init`, `monitoring_init`, `sending_kickstart`, `waiting_kickstart`, `stopped` +**States**: `watching`, `confirming_idle`, `sending_update`, `waiting_update`, `sending_clear`, `waiting_clear`, `sending_init`, `waiting_init`, `monitoring_init`, `sending_kickstart`, `waiting_kickstart`, `stopped` -Steps can be skipped via config (`sendClear: false`, `sendInit: false`). Optional `kickstartPrompt` triggers if `/init` doesn't start work. Idle detection triggers state transitions. +Steps can be skipped via config (`sendClear: false`, `sendInit: false`). Optional `kickstartPrompt` triggers if `/init` doesn't start work. Multi-layer idle detection triggers state transitions. ### Session Modes @@ -275,7 +277,14 @@ curl -X POST localhost:3000/api/sessions/:id/input \ ### Idle Detection -**RespawnController**: Primary `↵ send` indicator, fallback prompt chars (`❯`, `⏵`) + 10s timeout. Working patterns: `Thinking`, `Writing`, `Running`. +**RespawnController (Claude Code 2024+)**: Multi-layer detection with confidence scoring: +1. **Completion message**: Primary signal - detects "for Xm Xs" time patterns (e.g., "Worked for 2m 46s") +2. **Output silence**: Confirms idle after `completionConfirmMs` (5s) of no new output +3. **Token stability**: Tokens haven't changed +4. **Working patterns absent**: No `Thinking`, `Writing`, spinner chars + +Uses `confirming_idle` state to prevent false positives. Fallback: `noOutputTimeoutMs` (30s) if no output at all. + **Session**: emits `idle`/`working` events on prompt detection + 2s activity timeout. ### Token Tracking @@ -360,6 +369,7 @@ Key events for frontend handling (see `app.js:handleSSEEvent()`): - `session:terminal`, `session:clearTerminal` - Terminal content - `session:completion`, `session:autoClear`, `session:autoCompact` - Lifecycle events - `session:ralphLoopUpdate`, `session:ralphTodoUpdate`, `session:ralphCompletionDetected` - Ralph tracking +- `respawn:detectionUpdate` - Multi-layer idle detection status (confidence level, waiting state) ### Frontend (app.js) @@ -393,7 +403,9 @@ Writes debounced to `~/.claudeman/state.json`. Batches rapid changes. | Task panel render debounce | 100ms | `app.js` | | Input batch interval | 16ms | `app.js` (60fps) | | Idle activity timeout | 2s | `session.ts` | -| Respawn idle timeout | 5s default | `RespawnConfig.idleTimeoutMs` | +| Respawn idle timeout | 10s default | `RespawnConfig.idleTimeoutMs` | +| Respawn completion confirm | 5s | `RespawnConfig.completionConfirmMs` | +| Respawn no-output fallback | 30s | `RespawnConfig.noOutputTimeoutMs` | ### TypeScript Config diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index a354a46e..bb9614b8 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -2,26 +2,34 @@ * @fileoverview Respawn Controller for autonomous Claude Code session cycling * * The RespawnController manages automatic respawning of Claude Code sessions. - * When Claude finishes working (detected by idle prompt), it automatically - * cycles through update → clear → init steps to keep the session productive. + * When Claude finishes working (detected by completion message + output silence), + * it automatically cycles through update → clear → init steps to keep the session productive. * * ## State Machine * * ``` - * WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR - * ↑ │ - * │ ▼ - * │ SENDING_INIT → WAITING_INIT → MONITORING_INIT ───────┘ - * │ │ - * │ ▼ (if no work triggered) - * └── SENDING_KICKSTART → WAITING_KICKSTART ──┘ + * WATCHING → CONFIRMING_IDLE → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR + * ↑ │ │ + * │ │ (new output) ▼ + * │ └─────────────► SENDING_INIT → WAITING_INIT → MONITORING_INIT ────────┘ + * │ │ + * │ ▼ (if no work triggered) + * └──────────────────────── SENDING_KICKSTART → WAITING_KICKSTART ──┘ * ``` * + * ## Idle Detection (Updated for Claude Code 2024+) + * + * Primary detection: Completion message pattern "for Xm Xs" (e.g., "✻ Worked for 2m 46s") + * Confirmation: No new output for configurable duration (default 5s) + * Fallback: No output at all for extended period (default 30s) + * * ## Configuration * * - `sendClear`: Whether to send /clear after update (default: true) * - `sendInit`: Whether to send /init after clear (default: true) * - `kickstartPrompt`: Optional prompt if /init doesn't trigger work + * - `completionConfirmMs`: Time to wait after completion message (default: 5000) + * - `noOutputTimeoutMs`: Fallback timeout with no output at all (default: 30000) * * @module respawn-controller */ @@ -46,10 +54,59 @@ const RESPAWN_BUFFER_TRIM_SIZE = 512 * 1024; // 512KB // ========== Constants ========== /** - * The definitive "ready for input" indicator. - * When Claude shows a suggestion, this appears and indicates idle state. + * Pattern to detect completion messages from Claude. + * Matches "for Xh Xm Xs" time duration patterns that appear at end of work. + * Examples: "for 2m 46s", "for 46s", "for 1h 2m 3s", "for 5m" */ -const READY_INDICATOR = '↵ send'; +const COMPLETION_TIME_PATTERN = /\bfor\s+\d+[hms](\s*\d+[hms])*/i; + +/** + * Pattern to extract token count from Claude's status line. + * Matches: "123.4k tokens", "5234 tokens", "1.2M tokens" + */ +const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; + +// Note: The old '↵ send' indicator is no longer reliable in Claude Code 2024+ +// Detection now uses completion message patterns ("for Xm Xs") instead. + +// ========== Detection Layer Types ========== + +/** + * Detection layers for multi-signal idle detection. + * Each layer provides a confidence signal that Claude has finished working. + */ +export interface DetectionStatus { + /** Layer 1: Completion message detected ("for Xm Xs") */ + completionMessageDetected: boolean; + /** Timestamp when completion message was last seen */ + completionMessageTime: number | null; + + /** Layer 2: Output silence - no new output for threshold duration */ + outputSilent: boolean; + /** Milliseconds since last output */ + msSinceLastOutput: number; + + /** Layer 3: Token count stability - tokens haven't changed */ + tokensStable: boolean; + /** Last observed token count */ + lastTokenCount: number; + /** Milliseconds since token count changed */ + msSinceTokenChange: number; + + /** Layer 4: Working patterns absent - no spinners/activity words */ + workingPatternsAbsent: boolean; + /** Milliseconds since last working pattern */ + msSinceLastWorking: number; + + /** Overall confidence level (0-100) */ + confidenceLevel: number; + + /** Human-readable status for UI */ + statusText: string; + + /** What the controller is currently waiting for */ + waitingFor: string; +} // ========== Buffer Accumulator ========== @@ -121,6 +178,8 @@ class BufferAccumulator { export type RespawnState = /** Watching for idle, ready to start respawn sequence */ | 'watching' + /** Completion message detected, waiting for output silence to confirm */ + | 'confirming_idle' /** About to send the update docs prompt */ | 'sending_update' /** Waiting for update to complete */ @@ -194,6 +253,20 @@ export interface RespawnConfig { * @default undefined */ kickstartPrompt?: string; + + /** + * Time to wait after completion message before confirming idle (ms). + * After seeing "for Xm Xs" pattern, waits this long with no new output. + * @default 5000 (5 seconds) + */ + completionConfirmMs: number; + + /** + * Fallback timeout when no output received at all (ms). + * If no terminal output for this duration, assumes idle even without completion message. + * @default 30000 (30 seconds) + */ + noOutputTimeoutMs: number; } /** @@ -204,6 +277,7 @@ export interface RespawnConfig { * @event respawnCycleCompleted - Fired when a cycle finishes * @event stepSent - Fired when a command is sent to the session * @event stepCompleted - Fired when a step finishes (ready indicator detected) + * @event detectionUpdate - Fired when detection status changes (for UI) * @event error - Fired on errors * @event log - Fired for debug logging */ @@ -218,6 +292,8 @@ export interface RespawnEvents { stepSent: (step: string, input: string) => void; /** Step completed (ready indicator detected) */ stepCompleted: (step: string) => void; + /** Detection status update for UI display */ + detectionUpdate: (status: DetectionStatus) => void; /** Error occurred */ error: (error: Error) => void; /** Debug log message */ @@ -226,12 +302,14 @@ export interface RespawnEvents { /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { - idleTimeoutMs: 10000, // 10 seconds of no activity after prompt + idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) updatePrompt: 'update all the docs and CLAUDE.md', interStepDelayMs: 1000, // 1 second between steps enabled: true, sendClear: true, // send /clear after update prompt sendInit: true, // send /init after /clear + completionConfirmMs: 5000, // 5 seconds of silence after completion message + noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all }; /** @@ -242,19 +320,25 @@ const DEFAULT_CONFIG: RespawnConfig = { * * ## How It Works * - * 1. **Idle Detection**: Watches terminal output for `↵ send` indicator - * 2. **Update**: Sends configured prompt (e.g., "update all docs") - * 3. **Clear**: Sends `/clear` to reset context (optional) - * 4. **Init**: Sends `/init` to re-initialize with CLAUDE.md (optional) - * 5. **Kickstart**: If /init doesn't trigger work, sends fallback prompt (optional) - * 6. **Repeat**: Returns to watching state for next cycle + * 1. **Idle Detection**: Watches for completion message ("for Xm Xs" pattern) + * 2. **Confirmation**: Waits for output silence (no new tokens for 5s) + * 3. **Update**: Sends configured prompt (e.g., "update all docs") + * 4. **Clear**: Sends `/clear` to reset context (optional) + * 5. **Init**: Sends `/init` to re-initialize with CLAUDE.md (optional) + * 6. **Kickstart**: If /init doesn't trigger work, sends fallback prompt (optional) + * 7. **Repeat**: Returns to watching state for next cycle * - * ## Idle Detection + * ## Idle Detection (Updated for Claude Code 2024+) * - * Primary indicator: `↵ send` - Claude's suggestion prompt - * Fallback indicators: Various prompt characters (❯, ⏵, etc.) + * Primary: Completion message with time duration (e.g., "✻ Worked for 2m 46s") + * The pattern "for Xm Xs" indicates Claude finished work and reports duration. * - * Working indicators: Thinking, Writing, spinner characters, etc. + * Confirmation: After seeing completion message, waits for output silence. + * If no new output for `completionConfirmMs` (default 5s), confirms idle. + * + * Fallback: If no output at all for `noOutputTimeoutMs` (default 30s), assumes idle. + * + * Working indicators: Thinking, Writing, spinner characters, etc. reset detection. * * ## Events * @@ -270,7 +354,7 @@ const DEFAULT_CONFIG: RespawnConfig = { * ```typescript * const respawn = new RespawnController(session, { * updatePrompt: 'continue working on the task', - * idleTimeoutMs: 5000, + * completionConfirmMs: 5000, // Wait 5s after completion message * }); * * respawn.on('respawnCycleCompleted', (cycle) => { @@ -296,6 +380,15 @@ export class RespawnController extends EventEmitter { /** Timer for step delays */ private stepTimer: NodeJS.Timeout | null = null; + /** Timer for completion confirmation (Layer 2) */ + private completionConfirmTimer: NodeJS.Timeout | null = null; + + /** Timer for no-output fallback (Layer 5) */ + private noOutputTimer: NodeJS.Timeout | null = null; + + /** Timer for periodic detection status updates */ + private detectionUpdateTimer: NodeJS.Timeout | null = null; + /** Number of completed respawn cycles */ private cycleCount: number = 0; @@ -320,30 +413,43 @@ export class RespawnController extends EventEmitter { /** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */ private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000; + // ========== Multi-Layer Detection State ========== + + /** Layer 1: Timestamp when completion message was detected */ + private completionMessageTime: number | null = null; + + /** Layer 2: Timestamp of last terminal output received */ + private lastOutputTime: number = 0; + + /** Layer 3: Last observed token count */ + private lastTokenCount: number = 0; + + /** Layer 3: Timestamp when token count last changed */ + private lastTokenChangeTime: number = 0; + + /** Layer 4: Timestamp when last working pattern was seen */ + private lastWorkingPatternTime: number = 0; + /** - * Patterns indicating Claude is ready for input. - * Primary: `↵ send` (suggestion prompt) - * Fallback: Various prompt characters + * Patterns indicating Claude is ready for input (legacy fallback). + * Used as secondary signals, not primary detection. */ private readonly PROMPT_PATTERNS = [ - '↵ send', // Suggestion ready to send (strongest indicator of idle) '❯', // Standard prompt '\u276f', // Unicode variant '⏵', // Claude Code prompt variant - '> ', // Fallback - 'tokens', // The status line shows "X tokens" when at prompt ]; /** * Patterns indicating Claude is actively working. - * When detected, resets idle detection. + * When detected, resets all idle detection timers. + * Note: ✻ and ✽ removed - they appear in completion messages too. */ private readonly WORKING_PATTERNS = [ 'Thinking', 'Writing', 'Reading', 'Running', 'Searching', 'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing', 'Synthesizing', 'Brewing', // Claude's processing indicators '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars - '✻', '✽', // Activity indicators (spinning star) ]; /** @@ -383,6 +489,97 @@ export class RespawnController extends EventEmitter { return this._state !== 'stopped'; } + /** + * Get current detection status for UI display. + * Shows all detection layers and their current state. + * @returns DetectionStatus object + */ + getDetectionStatus(): DetectionStatus { + const now = Date.now(); + const msSinceLastOutput = now - this.lastOutputTime; + const msSinceTokenChange = now - this.lastTokenChangeTime; + const msSinceLastWorking = now - this.lastWorkingPatternTime; + + const completionMessageDetected = this.completionMessageTime !== null; + const outputSilent = msSinceLastOutput >= this.config.completionConfirmMs; + const tokensStable = msSinceTokenChange >= this.config.completionConfirmMs; + const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns + + // Calculate confidence level (0-100) + let confidence = 0; + 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; + let waitingFor: string; + + if (this._state === 'stopped') { + statusText = 'Controller stopped'; + waitingFor = 'Start to begin monitoring'; + } else if (this._state === 'confirming_idle') { + statusText = `Confirming idle (${confidence}% confidence)`; + waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`; + } else if (this._state === 'watching') { + if (completionMessageDetected) { + statusText = 'Completion detected, confirming...'; + waitingFor = 'Output silence to confirm'; + } else if (workingPatternsAbsent && msSinceLastOutput > 5000) { + statusText = 'No activity detected'; + waitingFor = 'Completion message or timeout'; + } else { + statusText = 'Watching for completion'; + waitingFor = 'Completion message (for Xm Xs)'; + } + } else if (this._state.startsWith('waiting_') || this._state.startsWith('sending_')) { + statusText = `Respawn step: ${this._state}`; + waitingFor = 'Step completion'; + } else { + statusText = `State: ${this._state}`; + waitingFor = 'Next event'; + } + + return { + completionMessageDetected, + completionMessageTime: this.completionMessageTime, + outputSilent, + msSinceLastOutput, + tokensStable, + lastTokenCount: this.lastTokenCount, + msSinceTokenChange, + workingPatternsAbsent, + msSinceLastWorking, + confidenceLevel: confidence, + statusText, + waitingFor, + }; + } + + /** + * Start periodic detection status updates for UI. + * Emits 'detectionUpdate' event every 500ms while running. + */ + private startDetectionUpdates(): void { + this.stopDetectionUpdates(); + this.detectionUpdateTimer = setInterval(() => { + if (this._state !== 'stopped') { + this.emit('detectionUpdate', this.getDetectionStatus()); + } + }, 500); + } + + /** + * Stop periodic detection status updates. + */ + private stopDetectionUpdates(): void { + if (this.detectionUpdateTimer) { + clearInterval(this.detectionUpdateTimer); + this.detectionUpdateTimer = null; + } + } + /** * Transition to a new state. * Emits 'stateChanged' event with old and new states. @@ -433,10 +630,20 @@ export class RespawnController extends EventEmitter { return; } - this.log('Starting respawn controller'); + this.log('Starting respawn controller (multi-layer detection)'); + + // Initialize all timestamps + const now = Date.now(); + this.lastActivityTime = now; + this.lastOutputTime = now; + this.lastTokenChangeTime = now; + this.lastWorkingPatternTime = now; + this.completionMessageTime = null; + this.setState('watching'); this.setupTerminalListener(); - this.lastActivityTime = Date.now(); + this.startDetectionUpdates(); + this.startNoOutputTimer(); } /** @@ -450,6 +657,7 @@ export class RespawnController extends EventEmitter { stop(): void { this.log('Stopping respawn controller'); this.clearTimers(); + this.stopDetectionUpdates(); this.setState('stopped'); if (this.terminalHandler) { this.session.off('terminal', this.terminalHandler); @@ -500,30 +708,51 @@ export class RespawnController extends EventEmitter { } /** - * Process terminal data for idle/working detection. + * Process terminal data for idle/working detection using multi-layer approach. * - * 1. Buffers data (with size limit) - * 2. Detects working indicators → resets idle - * 3. Detects ready indicator → triggers state-specific action - * 4. Detects prompt indicators → starts idle timer + * Detection Layers: + * 1. Completion message ("for Xm Xs") - PRIMARY signal + * 2. Output silence - confirms completion + * 3. Token stability - additional confirmation + * 4. Working pattern absence - supports idle detection + * 5. No-output fallback - catches edge cases * * @param data - Raw terminal output data */ private handleTerminalData(data: string): void { + // Guard against null/undefined/empty data + if (!data || typeof data !== 'string') { + return; + } + + const now = Date.now(); + // BufferAccumulator handles auto-trimming when max size exceeded this.terminalBuffer.append(data); - // Check for the definitive "ready for input" indicator - const isReady = data.includes(READY_INDICATOR); + // Track output time (Layer 2) + this.lastOutputTime = now; + this.lastActivityTime = now; + this.resetNoOutputTimer(); - // Detect working state - const isWorking = this.WORKING_PATTERNS.some(pattern => data.includes(pattern)); + // Track token count (Layer 3) + const tokenCount = this.extractTokenCount(data); + if (tokenCount !== null && tokenCount !== this.lastTokenCount) { + this.lastTokenCount = tokenCount; + this.lastTokenChangeTime = now; + } + + // Detect working patterns (Layer 4) + const isWorking = this.hasWorkingPattern(data); if (isWorking) { this.workingDetected = true; this.promptDetected = false; - this.lastActivityTime = Date.now(); + this.lastWorkingPatternTime = now; this.clearIdleTimer(); + // Cancel any pending completion confirmation + this.cancelCompletionConfirm(); + // If we're monitoring init and work started, go to watching (no kickstart needed) if (this._state === 'monitoring_init') { this.log('/init triggered work, skipping kickstart'); @@ -533,19 +762,55 @@ export class RespawnController extends EventEmitter { return; } - // Detect ready state (↵ send indicator) - if (isReady) { + // Detect completion message (Layer 1) - PRIMARY DETECTION + if (this.isCompletionMessage(data)) { + this.completionMessageTime = now; + this.workingDetected = false; + this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`); + + // In watching state, start completion confirmation timer + if (this._state === 'watching') { + this.startCompletionConfirmTimer(); + return; + } + + // In waiting states, treat completion message as step completion + switch (this._state) { + case 'waiting_update': + this.checkUpdateComplete(); + break; + case 'waiting_clear': + this.checkClearComplete(); + break; + case 'waiting_init': + this.checkInitComplete(); + break; + case 'waiting_kickstart': + this.checkKickstartComplete(); + break; + } + return; + } + + // In confirming_idle state, any output (except completion) resets confirmation + if (this._state === 'confirming_idle') { + // Check if enough time has passed since completion message + const msSinceCompletion = this.completionMessageTime ? now - this.completionMessageTime : 0; + if (msSinceCompletion > 1000) { + // New output more than 1s after completion message - might be new work + this.log('New output during confirmation, checking if work resumed...'); + // Don't immediately cancel - the confirmation timer will handle it + } + } + + // Legacy fallback: detect prompt characters (still useful for waiting_* states) + const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern)); + if (hasPrompt) { this.promptDetected = true; this.workingDetected = false; - this.lastActivityTime = Date.now(); - this.log('Ready indicator detected (↵ send)'); - // Handle based on current state + // Handle legacy detection in waiting states switch (this._state) { - case 'watching': - // Start idle timer instead of immediate action - gives user time to type - this.startIdleTimer(); - break; case 'waiting_update': this.checkUpdateComplete(); break; @@ -562,24 +827,6 @@ export class RespawnController extends EventEmitter { this.checkKickstartComplete(); break; } - return; - } - - // Fallback: detect prompt characters - start timeout-based check - const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern)); - if (hasPrompt) { - const wasPromptDetected = this.promptDetected; - this.promptDetected = true; - this.workingDetected = false; - this.lastActivityTime = Date.now(); - - if (!wasPromptDetected) { - this.log('Prompt detected'); - // Start fallback timeout for watching state - if (this._state === 'watching') { - this.startIdleTimer(); - } - } } } @@ -710,24 +957,10 @@ export class RespawnController extends EventEmitter { this.completeCycle(); } - /** - * Start the idle detection timer. - * After idleTimeoutMs, triggers onIdleDetected if still idle. - */ - private startIdleTimer(): void { - this.clearIdleTimer(); + // Note: Legacy startIdleTimer removed - now using completion-based detection + // with startCompletionConfirmTimer() and startNoOutputTimer() instead. - this.idleTimer = setTimeout(() => { - // Double-check we're still idle and in watching state - if (this._state === 'watching' && this.promptDetected && !this.workingDetected) { - const timeSinceActivity = Date.now() - this.lastActivityTime; - this.log(`Idle timeout fired (${timeSinceActivity}ms since last activity)`); - this.onIdleDetected(); - } - }, this.config.idleTimeoutMs); - } - - /** Clear the idle detection timer if running */ + /** Clear the idle detection timer if running (legacy cleanup) */ private clearIdleTimer(): void { if (this.idleTimer) { clearTimeout(this.idleTimer); @@ -735,7 +968,7 @@ export class RespawnController extends EventEmitter { } } - /** Clear all timers (idle, step, and clear fallback) */ + /** Clear all timers (idle, step, completion confirm, no-output, and clear fallback) */ private clearTimers(): void { this.clearIdleTimer(); if (this.stepTimer) { @@ -746,6 +979,132 @@ export class RespawnController extends EventEmitter { clearTimeout(this.clearFallbackTimer); this.clearFallbackTimer = null; } + if (this.completionConfirmTimer) { + clearTimeout(this.completionConfirmTimer); + this.completionConfirmTimer = null; + } + if (this.noOutputTimer) { + clearTimeout(this.noOutputTimer); + this.noOutputTimer = null; + } + } + + // ========== Multi-Layer Detection Methods ========== + + /** + * Check if data contains a completion message pattern. + * Matches "for Xh Xm Xs" time duration patterns. + */ + private isCompletionMessage(data: string): boolean { + return COMPLETION_TIME_PATTERN.test(data); + } + + /** + * Check if data contains working patterns. + */ + private hasWorkingPattern(data: string): boolean { + return this.WORKING_PATTERNS.some(pattern => data.includes(pattern)); + } + + /** + * Extract token count from data if present. + * Returns null if no token pattern found. + */ + private extractTokenCount(data: string): number | null { + const match = data.match(TOKEN_PATTERN); + if (!match) return null; + + let count = parseFloat(match[1]); + const suffix = match[2]?.toLowerCase(); + if (suffix === 'k') count *= 1000; + else if (suffix === 'm') count *= 1000000; + + return Math.round(count); + } + + /** + * Start the no-output fallback timer. + * If no output for noOutputTimeoutMs, triggers idle detection. + */ + private startNoOutputTimer(): void { + if (this.noOutputTimer) { + clearTimeout(this.noOutputTimer); + } + this.noOutputTimer = setTimeout(() => { + if (this._state === 'watching' || this._state === 'confirming_idle') { + const msSinceOutput = Date.now() - this.lastOutputTime; + this.log(`No-output fallback triggered (${msSinceOutput}ms since last output)`); + this.onIdleConfirmed('no-output fallback'); + } + }, this.config.noOutputTimeoutMs); + } + + /** + * Reset the no-output fallback timer. + * Called whenever output is received. + */ + private resetNoOutputTimer(): void { + this.startNoOutputTimer(); + } + + /** + * Start completion confirmation timer. + * After completion message, waits for output silence. + */ + private startCompletionConfirmTimer(): void { + if (this.completionConfirmTimer) { + clearTimeout(this.completionConfirmTimer); + } + + this.setState('confirming_idle'); + this.log(`Completion message detected, waiting ${this.config.completionConfirmMs}ms for silence...`); + + this.completionConfirmTimer = setTimeout(() => { + const msSinceOutput = Date.now() - this.lastOutputTime; + if (msSinceOutput >= this.config.completionConfirmMs) { + this.log(`Idle confirmed: ${msSinceOutput}ms silence after completion message`); + this.onIdleConfirmed('completion + silence'); + } else { + // Output received during wait, stay in confirming state and re-check + this.log(`Output received during confirmation, resetting timer`); + this.startCompletionConfirmTimer(); + } + }, this.config.completionConfirmMs); + } + + /** + * Cancel completion confirmation if new activity detected. + */ + private cancelCompletionConfirm(): void { + if (this.completionConfirmTimer) { + clearTimeout(this.completionConfirmTimer); + this.completionConfirmTimer = null; + } + if (this._state === 'confirming_idle') { + this.setState('watching'); + this.completionMessageTime = null; + } + } + + /** + * Called when idle is confirmed through any detection layer. + * @param reason - What triggered the confirmation + */ + private onIdleConfirmed(reason: string): void { + this.log(`Idle confirmed via: ${reason}`); + const status = this.getDetectionStatus(); + this.log(`Detection status: confidence=${status.confidenceLevel}%, ` + + `completion=${status.completionMessageDetected}, ` + + `silent=${status.outputSilent}, ` + + `tokensStable=${status.tokensStable}, ` + + `noWorking=${status.workingPatternsAbsent}`); + + // Reset detection state + this.completionMessageTime = null; + this.cancelCompletionConfirm(); + + // Trigger the respawn cycle + this.onIdleDetected(); } /** @@ -754,7 +1113,8 @@ export class RespawnController extends EventEmitter { * @fires respawnCycleStarted */ private onIdleDetected(): void { - if (this._state !== 'watching') { + // Accept both watching and confirming_idle states + if (this._state !== 'watching' && this._state !== 'confirming_idle') { return; } @@ -897,6 +1257,7 @@ export class RespawnController extends EventEmitter { * - timeSinceActivity: Milliseconds since last activity * - promptDetected: Whether prompt indicator seen * - workingDetected: Whether working indicator seen + * - detection: Multi-layer detection status * - config: Current configuration */ getStatus() { @@ -907,6 +1268,7 @@ export class RespawnController extends EventEmitter { timeSinceActivity: Date.now() - this.lastActivityTime, promptDetected: this.promptDetected, workingDetected: this.workingDetected, + detection: this.getDetectionStatus(), config: this.config, }; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 8165a9dd..8d313ab4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -503,6 +503,16 @@ class ClaudemanApp { } }); + this.eventSource.addEventListener('respawn:detectionUpdate', (e) => { + const data = JSON.parse(e.data); + if (this.respawnStatus[data.sessionId]) { + this.respawnStatus[data.sessionId].detection = data.detection; + } + if (data.sessionId === this.activeSessionId) { + this.updateDetectionDisplay(data.detection); + } + }); + // Respawn timer events this.eventSource.addEventListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); @@ -1287,6 +1297,39 @@ class ClaudemanApp { this.$('respawnState').textContent = state.replace(/_/g, ' '); } + updateDetectionDisplay(detection) { + if (!detection) return; + + const statusEl = this.$('detectionStatus'); + const waitingEl = this.$('detectionWaiting'); + const confidenceEl = this.$('detectionConfidence'); + + // Update status text + statusEl.textContent = detection.statusText || ''; + + // Update waiting for text + if (detection.waitingFor) { + waitingEl.textContent = `→ ${detection.waitingFor}`; + } else { + waitingEl.textContent = ''; + } + + // Update confidence level + const confidence = detection.confidenceLevel || 0; + if (confidence > 0) { + 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'); + } + } else { + confidenceEl.style.display = 'none'; + } + } + showRespawnTimer() { const timerEl = this.$('respawnTimer'); timerEl.style.display = ''; diff --git a/src/web/public/index.html b/src/web/public/index.html index 5ca337fc..ffadf90c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -65,6 +65,11 @@