/** * @fileoverview Respawn Controller for autonomous Claude Code session cycling * * The RespawnController manages automatic respawning of Claude Code sessions. * 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 → 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: 10000) * - `noOutputTimeoutMs`: Fallback timeout with no output at all (default: 30000) * * @module respawn-controller */ import { EventEmitter } from 'node:events'; import { Session } from './session.js'; import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js'; import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; // ========== Configuration Constants ========== /** * Maximum terminal buffer size for respawn controller. * Buffer is trimmed when this limit is exceeded to prevent memory issues. */ const MAX_RESPAWN_BUFFER_SIZE = 1024 * 1024; // 1MB /** * Size to trim buffer to when MAX_RESPAWN_BUFFER_SIZE is exceeded. * Keeps the most recent output for pattern detection. */ const RESPAWN_BUFFER_TRIM_SIZE = 512 * 1024; // 512KB // ========== Constants ========== /** * Pattern to detect completion messages from Claude Code. * Requires "Worked for" prefix to avoid false positives from bare time durations * in regular text (e.g., "wait for 5s", "run for 2m"). * * Matches: "✻ Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s" * Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..." */ const COMPLETION_TIME_PATTERN = /\bWorked\s+for\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/; /** Pre-filter: numbered option pattern for plan mode detection */ const PLAN_MODE_OPTION_PATTERN = /\d+\.\s+(Yes|No|Type|Cancel|Skip|Proceed|Approve|Reject)/i; /** Pre-filter: selection indicator arrow for plan mode detection */ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./; // 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. */ /** Active timer info for UI display */ export interface ActiveTimerInfo { name: string; remainingMs: number; totalMs: number; } 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 */ 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; /** Layer 5: AI idle check status */ aiCheck: AiCheckState | null; /** Overall confidence level (0-100) */ confidenceLevel: number; /** Human-readable status for UI */ statusText: string; /** What the controller is currently waiting for */ waitingFor: string; /** Active countdown timers */ activeTimers: ActiveTimerInfo[]; /** Recent action log entries (last 10) */ recentActions: ActionLogEntry[]; /** Current phase description */ currentPhase: string; /** Next expected action */ nextAction: string; } // ========== Buffer Accumulator ========== /** * High-performance buffer accumulator using array-based collection. * Reduces GC pressure by avoiding repeated string concatenation. */ class BufferAccumulator { private chunks: string[] = []; private totalLength: number = 0; private readonly maxSize: number; private readonly trimSize: number; constructor(maxSize: number, trimSize: number) { this.maxSize = maxSize; this.trimSize = trimSize; } append(data: string): void { if (!data) return; this.chunks.push(data); this.totalLength += data.length; if (this.totalLength > this.maxSize) { this.trim(); } } get value(): string { if (this.chunks.length === 0) return ''; if (this.chunks.length === 1) return this.chunks[0]; const result = this.chunks.join(''); this.chunks = [result]; return result; } get length(): number { return this.totalLength; } clear(): void { this.chunks = []; this.totalLength = 0; } private trim(): void { const full = this.chunks.join(''); const trimmed = full.slice(-this.trimSize); this.chunks = [trimmed]; this.totalLength = trimmed.length; } } // ========== Type Definitions ========== /** * Respawn sequence states. * * The controller cycles through these states: * ``` * WATCHING → SENDING_UPDATE → WAITING_UPDATE → * SENDING_CLEAR → WAITING_CLEAR → * SENDING_INIT → WAITING_INIT → * MONITORING_INIT → (maybe SENDING_KICKSTART → WAITING_KICKSTART) → * WATCHING (repeat) * ``` * * Steps can be skipped via config (`sendClear: false`, `sendInit: false`). */ export type RespawnState = /** Watching for idle, ready to start respawn sequence */ | 'watching' /** Completion message detected, waiting for output silence to confirm */ | 'confirming_idle' /** AI checker is analyzing terminal output for IDLE/WORKING verdict */ | 'ai_checking' /** About to send the update docs prompt */ | 'sending_update' /** Waiting for update to complete */ | 'waiting_update' /** About to send /clear command */ | 'sending_clear' /** Waiting for clear to complete */ | 'waiting_clear' /** About to send /init command */ | 'sending_init' /** Waiting for init to complete */ | 'waiting_init' /** Monitoring if /init triggered work */ | 'monitoring_init' /** About to send kickstart prompt */ | 'sending_kickstart' /** Waiting for kickstart to complete */ | 'waiting_kickstart' /** Controller stopped (not running) */ | 'stopped'; /** * Configuration options for the RespawnController. */ export interface RespawnConfig { /** * How long to wait after seeing prompt before considering truly idle. * Prevents premature cycling when user is about to type. * @default 10000 (10 seconds) */ idleTimeoutMs: number; /** * The prompt to send when updating docs. * Sent at the start of each respawn cycle. * @default 'update all the docs and CLAUDE.md' */ updatePrompt: string; /** * Delay between sending steps (ms). * Gives Claude time to process each command. * @default 1000 (1 second) */ interStepDelayMs: number; /** * Whether the respawn loop is enabled. * When false, start() will be a no-op. * @default true */ enabled: boolean; /** * Whether to send /clear after update prompt completes. * Resets Claude's context for fresh start. * @default true */ sendClear: boolean; /** * Whether to send /init after /clear completes. * Re-initializes Claude with CLAUDE.md context. * @default true */ sendInit: boolean; /** * Optional prompt to send if /init doesn't trigger work. * Used as a fallback when /init completes but Claude doesn't start working. * @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 10000 (10 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; /** * Whether to auto-accept plan mode prompts by pressing Enter. * When Claude enters plan mode and presents a plan for approval, output stops * without a completion message. This feature detects that state and sends Enter * to accept the plan. Does NOT auto-accept AskUserQuestion prompts (those are * blocked via the elicitation_dialog hook signal). * @default true */ autoAcceptPrompts: boolean; /** * Delay before auto-accepting plan mode prompts (ms). * After no output for this duration AND no completion message detected * AND no elicitation dialog signaled, sends Enter to accept the plan. * Must be shorter than noOutputTimeoutMs. * @default 8000 (8 seconds) */ autoAcceptDelayMs: number; /** * Whether AI idle check is enabled. * When enabled, spawns a fresh Claude CLI to analyze terminal output * and provide a definitive IDLE/WORKING verdict before starting respawn. * @default true */ aiIdleCheckEnabled: boolean; /** * Model to use for AI idle check. * @default 'claude-opus-4-5-20251101' */ aiIdleCheckModel: string; /** * Maximum characters of terminal buffer to send to AI checker. * @default 16000 */ aiIdleCheckMaxContext: number; /** * Timeout for the AI check in ms. * @default 90000 (90 seconds) */ aiIdleCheckTimeoutMs: number; /** * Cooldown after WORKING verdict in ms. * @default 180000 (3 minutes) */ aiIdleCheckCooldownMs: number; /** * Whether AI plan mode check is enabled for auto-accept. * When enabled, spawns a fresh Claude CLI to confirm the terminal is * showing a plan mode approval prompt before auto-accepting. * @default true */ aiPlanCheckEnabled: boolean; /** * Model to use for AI plan mode check. * @default 'claude-opus-4-5-20251101' (thinking enabled by default) */ aiPlanCheckModel: string; /** * Maximum characters of terminal buffer to send to plan checker. * @default 8000 */ aiPlanCheckMaxContext: number; /** * Timeout for the AI plan check in ms. * @default 60000 (60 seconds, allows time for thinking) */ aiPlanCheckTimeoutMs: number; /** * Cooldown after NOT_PLAN_MODE verdict in ms. * @default 30000 (30 seconds) */ aiPlanCheckCooldownMs: number; } /** * Events emitted by RespawnController. * * @event stateChanged - Fired when state machine transitions * @event respawnCycleStarted - Fired when a new cycle begins * @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 */ /** Timer info for countdown display */ export interface TimerInfo { name: string; durationMs: number; endsAt: number; reason?: string; } /** Action log entry for detailed UI feedback */ export interface ActionLogEntry { type: string; detail: string; timestamp: number; } export interface RespawnEvents { /** State machine transition */ stateChanged: (state: RespawnState, prevState: RespawnState) => void; /** New respawn cycle started */ respawnCycleStarted: (cycleNumber: number) => void; /** Respawn cycle finished */ respawnCycleCompleted: (cycleNumber: number) => void; /** Command sent to session */ 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; /** Auto-accept sent for plan mode approval */ autoAcceptSent: () => void; /** AI idle check started */ aiCheckStarted: () => void; /** AI idle check completed with verdict */ aiCheckCompleted: (result: AiCheckResult) => void; /** AI idle check failed */ aiCheckFailed: (error: string) => void; /** AI idle check cooldown state changed */ aiCheckCooldown: (active: boolean, endsAt: number | null) => void; /** AI plan check started */ planCheckStarted: () => void; /** AI plan check completed with verdict */ planCheckCompleted: (result: AiPlanCheckResult) => void; /** AI plan check failed */ planCheckFailed: (error: string) => void; /** Timer started for countdown display */ timerStarted: (timer: TimerInfo) => void; /** Timer cancelled */ timerCancelled: (timerName: string, reason?: string) => void; /** Timer completed */ timerCompleted: (timerName: string) => void; /** Verbose action log for detailed UI feedback */ actionLog: (action: ActionLogEntry) => void; /** Error occurred */ error: (error: Error) => void; /** Debug log message */ log: (message: string) => void; } /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { 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: 10000, // 10 seconds of silence after completion message noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckModel: 'claude-opus-4-5-20251101', aiIdleCheckMaxContext: 16000, // ~4k tokens aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow) aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept aiPlanCheckModel: 'claude-opus-4-5-20251101', aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact) aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow) aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE }; /** * RespawnController - Automatic session cycling for continuous Claude work. * * Monitors a Claude Code session for idle state and automatically cycles * through update → clear → init steps to keep the session productive. * * ## How It Works * * 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 (Updated for Claude Code 2024+) * * Primary: Completion message with time duration (e.g., "✻ Worked for 2m 46s") * The pattern "for Xm Xs" indicates Claude finished work and reports duration. * * Confirmation: After seeing completion message, waits for output silence. * If no new output for `completionConfirmMs` (default 10s), confirms idle. * * Fallback: If no output at all for `noOutputTimeoutMs` (default 30s), assumes idle. * * Working indicators: Thinking, Writing, spinner characters, etc. reset detection. * * ## Events * * - `stateChanged`: State machine transition * - `respawnCycleStarted`: New cycle began * - `respawnCycleCompleted`: Cycle finished * - `stepSent`: Command sent to session * - `stepCompleted`: Step finished * - `log`: Debug messages * * @extends EventEmitter * @example * ```typescript * const respawn = new RespawnController(session, { * updatePrompt: 'continue working on the task', * completionConfirmMs: 10000, // Wait 10s after completion message * }); * * respawn.on('respawnCycleCompleted', (cycle) => { * console.log(`Completed cycle ${cycle}`); * }); * * respawn.start(); * ``` */ export class RespawnController extends EventEmitter { /** The session being controlled */ private session: Session; /** Current configuration */ private config: RespawnConfig; /** Current state machine state */ private _state: RespawnState = 'stopped'; /** Timer for idle detection timeout */ private idleTimer: NodeJS.Timeout | null = null; /** 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; /** Timer for auto-accepting plan mode prompts */ private autoAcceptTimer: NodeJS.Timeout | null = null; /** Timer for pre-filter silence detection (triggers AI check) */ private preFilterTimer: NodeJS.Timeout | null = null; /** Whether any terminal output has been received since start/last-auto-accept */ private hasReceivedOutput: boolean = false; /** 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; /** Timestamp of last terminal activity */ private lastActivityTime: number = 0; /** Buffer for recent terminal output (uses BufferAccumulator to reduce GC pressure) */ private terminalBuffer = new BufferAccumulator(MAX_RESPAWN_BUFFER_SIZE, RESPAWN_BUFFER_TRIM_SIZE); /** Whether a prompt indicator was detected */ private promptDetected: boolean = false; /** Whether a working indicator was detected */ private workingDetected: boolean = false; /** Reference to terminal event handler (for cleanup) */ private terminalHandler: ((data: string) => void) | null = null; /** AI idle checker instance */ private aiChecker: AiIdleChecker; /** AI plan mode checker instance */ private planChecker: AiPlanChecker; /** Timestamp when plan check was started (to detect stale results) */ private planCheckStartTime: number = 0; /** Timer for /clear step fallback (sends /init if no prompt detected) */ private clearFallbackTimer: NodeJS.Timeout | null = null; /** Timer for step completion confirmation (waits for silence after completion) */ private stepConfirmTimer: NodeJS.Timeout | null = null; /** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */ private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000; // ========== Timer Tracking for UI Countdown Display ========== /** Active timers being tracked for UI display */ private activeTimers: Map = new Map(); /** Recent action log entries (for UI display, max 20) */ private recentActions: ActionLogEntry[] = []; // ========== 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 (legacy fallback). * Used as secondary signals, not primary detection. */ private readonly PROMPT_PATTERNS = [ '❯', // Standard prompt '\u276f', // Unicode variant '⏵', // Claude Code prompt variant ]; /** * Patterns indicating Claude is actively working. * 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 'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading', 'Processing', 'Generating', 'Loading', 'Starting', 'Updating', 'Checking', 'Validating', 'Testing', 'Formatting', 'Linting', '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars '◐', '◓', '◑', '◒', // Alternative spinners '⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners ]; /** * Rolling window buffer for working pattern detection. * Prevents split-chunk issues where "Thinking" arrives as "Thin" + "king". * Size: 300 chars should be enough to catch any split pattern. */ private workingPatternWindow: string = ''; private static readonly WORKING_PATTERN_WINDOW_SIZE = 300; /** * Minimum time without working patterns before considering idle (ms). * Increased from 3s to 8s to avoid false positives during tool execution gaps. */ private static readonly MIN_WORKING_PATTERN_ABSENCE_MS = 8000; /** * Creates a new RespawnController. * * @param session - The Session instance to control * @param config - Partial configuration (merged with defaults) */ constructor(session: Session, config: Partial = {}) { super(); this.session = session; // Filter out undefined values from config to prevent overwriting defaults const filteredConfig = Object.fromEntries( Object.entries(config).filter(([, v]) => v !== undefined) ) as Partial; this.config = { ...DEFAULT_CONFIG, ...filteredConfig }; // Validate configuration values this.validateConfig(); this.aiChecker = new AiIdleChecker(session.id, { enabled: this.config.aiIdleCheckEnabled, model: this.config.aiIdleCheckModel, maxContextChars: this.config.aiIdleCheckMaxContext, checkTimeoutMs: this.config.aiIdleCheckTimeoutMs, cooldownMs: this.config.aiIdleCheckCooldownMs, }); this.planChecker = new AiPlanChecker(session.id, { enabled: this.config.aiPlanCheckEnabled, model: this.config.aiPlanCheckModel, maxContextChars: this.config.aiPlanCheckMaxContext, checkTimeoutMs: this.config.aiPlanCheckTimeoutMs, cooldownMs: this.config.aiPlanCheckCooldownMs, }); this.setupAiCheckerListeners(); this.setupPlanCheckerListeners(); } /** * Validate configuration values and reset invalid ones to defaults. * Ensures timeouts are positive and logically consistent. */ private validateConfig(): void { const c = this.config; // Ensure timeouts are positive if (c.idleTimeoutMs <= 0) c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs; if (c.completionConfirmMs <= 0) c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs; if (c.noOutputTimeoutMs <= 0) c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs; if (c.autoAcceptDelayMs < 0) c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs; if (c.interStepDelayMs <= 0) c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs; // Ensure completion confirm doesn't exceed no-output timeout if (c.completionConfirmMs > c.noOutputTimeoutMs) { c.completionConfirmMs = c.noOutputTimeoutMs; } // Ensure AI check timeouts are positive if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs; if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs; if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext; // Ensure plan check timeouts are positive if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs; if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs; if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext; } /** Wire up AI checker events to controller events */ private setupAiCheckerListeners(): void { this.aiChecker.on('log', (message: string) => { this.log(message); }); this.aiChecker.on('cooldownStarted', (endsAt: number) => { this.emit('aiCheckCooldown', true, endsAt); }); this.aiChecker.on('cooldownEnded', () => { this.emit('aiCheckCooldown', false, null); // Restart pre-filter timer when cooldown expires so a new check can be triggered if (this._state === 'watching') { this.startPreFilterTimer(); } }); this.aiChecker.on('disabled', (reason: string) => { this.log(`AI checker disabled: ${reason}. Falling back to noOutputTimeoutMs.`); }); } /** Wire up plan checker events to controller events */ private setupPlanCheckerListeners(): void { this.planChecker.on('log', (message: string) => { this.log(message); }); this.planChecker.on('disabled', (reason: string) => { this.log(`Plan checker disabled: ${reason}. Falling back to pre-filter only.`); }); } /** * Get the current state machine state. * @returns Current RespawnState */ get state(): RespawnState { return this._state; } /** * Get the current respawn cycle count. * Increments each time a new cycle starts. * @returns Number of cycles started */ get currentCycle(): number { return this.cycleCount; } /** * Check if the controller is currently running. * @returns True if state is not 'stopped' */ get isRunning(): boolean { 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 >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS; // Calculate confidence level (0-100) // Hook signals are definitive (100% confidence) let confidence = 0; 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; let waitingFor: string; 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)'; } 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') { const aiState = this.aiChecker.getState(); if (aiState.status === 'cooldown') { const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000); statusText = `AI Check: WORKING (cooldown ${remaining}s)`; waitingFor = 'Cooldown to expire'; } else if (completionMessageDetected) { statusText = 'Completion detected, confirming...'; waitingFor = 'Output silence to confirm'; } else if (workingPatternsAbsent && msSinceLastOutput > 5000) { statusText = 'No activity detected'; waitingFor = 'Pre-filter conditions for AI check'; } else { statusText = 'Watching for idle signals'; waitingFor = 'Silence + no working patterns + tokens stable'; } } 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'; } // Determine current phase and next action let currentPhase: string; let nextAction: string; switch (this._state) { case 'stopped': currentPhase = 'Stopped'; nextAction = 'Start to begin'; break; case 'watching': currentPhase = 'Monitoring for idle'; nextAction = 'Waiting for silence + no working patterns'; break; case 'confirming_idle': currentPhase = 'Confirming idle state'; nextAction = 'Waiting for output silence'; break; case 'ai_checking': currentPhase = 'AI analyzing terminal'; nextAction = 'Waiting for IDLE/WORKING verdict'; break; case 'sending_update': currentPhase = 'Sending update prompt'; nextAction = 'Will send prompt after delay'; break; case 'waiting_update': currentPhase = 'Waiting for update to complete'; nextAction = 'Will send /clear when done'; break; case 'sending_clear': currentPhase = 'Sending /clear'; nextAction = 'Will clear context'; break; case 'waiting_clear': currentPhase = 'Waiting for /clear to complete'; nextAction = 'Will send /init when done'; break; case 'sending_init': currentPhase = 'Sending /init'; nextAction = 'Will re-initialize'; break; case 'waiting_init': currentPhase = 'Waiting for /init to complete'; nextAction = 'Monitoring for work'; break; case 'monitoring_init': currentPhase = 'Monitoring if /init triggered work'; nextAction = 'Kickstart if no work started'; break; case 'sending_kickstart': currentPhase = 'Sending kickstart prompt'; nextAction = 'Will send prompt after delay'; break; case 'waiting_kickstart': currentPhase = 'Waiting for kickstart to complete'; nextAction = 'Completing cycle'; break; default: currentPhase = this._state; nextAction = 'Processing...'; } return { stopHookReceived: this.stopHookReceived, stopHookTime: this.stopHookTime, idlePromptReceived: this.idlePromptReceived, idlePromptTime: this.idlePromptTime, completionMessageDetected, completionMessageTime: this.completionMessageTime, outputSilent, msSinceLastOutput, tokensStable, lastTokenCount: this.lastTokenCount, msSinceTokenChange, workingPatternsAbsent, msSinceLastWorking, aiCheck: this.config.aiIdleCheckEnabled ? this.aiChecker.getState() : null, confidenceLevel: confidence, statusText, waitingFor, activeTimers: this.getActiveTimers(), recentActions: this.recentActions.slice(0, 10), currentPhase, nextAction, }; } /** * 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. * No-op if already in the target state. * * @param newState - State to transition to * @fires stateChanged */ private setState(newState: RespawnState): void { if (newState === this._state) return; const prevState = this._state; this._state = newState; this.log(`State: ${prevState} → ${newState}`); this.logAction('state', `${prevState} → ${newState}`); this.emit('stateChanged', newState, prevState); } /** * Emit a timestamped log message. * @param message - Log message content * @fires log */ private log(message: string): void { const timestamp = new Date().toISOString(); this.emit('log', `[${timestamp}] [Respawn] ${message}`); } /** * Start watching the session for idle state. * * Begins monitoring terminal output for idle indicators. * When idle is detected, starts the respawn cycle. * * No-op if: * - `config.enabled` is false * - Already running (state !== 'stopped') * * @fires stateChanged - Transitions to 'watching' */ start(): void { if (!this.config.enabled) { this.log('Respawn is disabled'); return; } if (this._state !== 'stopped') { this.log('Already running'); return; } this.log('Starting respawn controller (multi-layer detection)'); // Initialize all timestamps and reset hook state const now = Date.now(); this.lastActivityTime = now; this.lastOutputTime = now; this.lastTokenChangeTime = now; 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. const existingBuffer = this.session.terminalBuffer; if (existingBuffer) { this.terminalBuffer.clear(); this.terminalBuffer.append(existingBuffer); } this.aiChecker.reset(); this.planChecker.reset(); this.setState('watching'); this.setupTerminalListener(); this.startDetectionUpdates(); this.startNoOutputTimer(); this.startPreFilterTimer(); if (this.config.autoAcceptPrompts) { this.startAutoAcceptTimer(); } } /** * Stop the respawn controller. * * Clears all timers, removes terminal listener, and sets state to 'stopped'. * Safe to call multiple times. * * @fires stateChanged - Transitions to 'stopped' */ stop(): void { this.log('Stopping respawn controller'); this.aiChecker.cancel(); this.planChecker.cancel(); this.clearTimers(); this.stopDetectionUpdates(); this.setState('stopped'); if (this.terminalHandler) { this.session.off('terminal', this.terminalHandler); this.terminalHandler = null; } } /** * Pause respawn without stopping. * * Clears timers but keeps listening to terminal. * State is preserved; won't trigger idle detection while paused. * Use resume() to continue. */ pause(): void { this.log('Pausing respawn'); this.clearTimers(); // Stay in current state but clear timers } /** * Resume respawn after pause. * * If in 'watching' state, immediately checks for idle condition. * Otherwise, continues from current state. */ resume(): void { this.log('Resuming respawn'); if (this._state === 'watching') { this.checkIdleAndMaybeStart(); } } /** * Set up terminal output listener on the session. * Removes any previous listener first to avoid duplicates. */ private setupTerminalListener(): void { // Remove our previous listener if any (don't remove other listeners!) if (this.terminalHandler) { this.session.off('terminal', this.terminalHandler); } this.terminalHandler = (data: string) => { this.handleTerminalData(data); }; this.session.on('terminal', this.terminalHandler); } /** * Process terminal data for idle/working detection using multi-layer approach. * * 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); // Track output time (Layer 2) this.lastOutputTime = now; this.lastActivityTime = now; this.hasReceivedOutput = true; this.resetNoOutputTimer(); this.resetPreFilterTimer(); this.resetAutoAcceptTimer(); // Cancel plan check if running (new output makes result stale) if (this.planChecker.status === 'checking') { this.log('New output during plan check, cancelling (stale)'); this.planChecker.cancel(); } // Track token count (Layer 3) const tokenCount = this.extractTokenCount(data); if (tokenCount !== null && tokenCount !== this.lastTokenCount) { this.lastTokenCount = tokenCount; this.lastTokenChangeTime = now; } // Detect completion message FIRST (Layer 1) - PRIMARY DETECTION // Check this before working patterns because completion message indicates // the work is done, even if working patterns are still in the rolling window if (this.isCompletionMessage(data)) { // Clear the rolling window - completion marks a transition point this.clearWorkingPatternWindow(); this.workingDetected = false; this.completionMessageTime = now; this.cancelAutoAcceptTimer(); // Normal idle flow handles this 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, also use confirmation timer (same detection logic) // This ensures we wait for Claude to finish before proceeding switch (this._state) { case 'waiting_update': this.startStepConfirmTimer('update'); break; case 'waiting_clear': this.checkClearComplete(); // /clear is quick, no need to wait break; case 'waiting_init': this.startStepConfirmTimer('init'); break; case 'waiting_kickstart': this.startStepConfirmTimer('kickstart'); break; } return; } // Detect working patterns (Layer 4) const isWorking = this.hasWorkingPattern(data); if (isWorking) { 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(); // Cancel any pending step confirmation (Claude is still working) this.cancelStepConfirm(); // If AI check is running, cancel it (Claude is working) if (this._state === 'ai_checking') { this.log('Working patterns detected during AI check, cancelling'); this.aiChecker.cancel(); this.setState('watching'); } // Cancel plan check if running (Claude started working) if (this.planChecker.status === 'checking') { this.log('Working patterns detected during plan check, cancelling'); this.planChecker.cancel(); } // 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'); this.emit('stepCompleted', 'init'); this.completeCycle(); } return; } // In confirming_idle or ai_checking state, substantial output cancels the flow. // This prevents false triggers when Claude pauses briefly mid-work. if (this._state === 'confirming_idle' || this._state === 'ai_checking') { // Strip ANSI escape codes to check if there's real content const stripped = data.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); if (stripped.length > 2) { if (this._state === 'ai_checking') { this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`); this.aiChecker.cancel(); this.setState('watching'); } else { // Real content (not just escape codes or single chars) - cancel confirmation this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`); this.cancelCompletionConfirm(); } return; } } // 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; // Handle legacy detection in waiting states - also use confirmation timers switch (this._state) { case 'waiting_update': this.startStepConfirmTimer('update'); break; case 'waiting_clear': this.checkClearComplete(); // /clear is quick, no need to wait break; case 'waiting_init': this.startStepConfirmTimer('init'); break; case 'monitoring_init': this.checkMonitoringInitIdle(); break; case 'waiting_kickstart': this.startStepConfirmTimer('kickstart'); break; } } } /** * Handle update step completion. * Called when ready indicator detected in waiting_update state. * Proceeds to clear, init, or completes cycle based on config. * @fires stepCompleted - With step 'update' */ private checkUpdateComplete(): void { this.clearIdleTimer(); this.log('Update completed (ready indicator)'); this.emit('stepCompleted', 'update'); if (this.config.sendClear) { this.sendClear(); } else if (this.config.sendInit) { this.sendInit(); } else { this.completeCycle(); } } /** * Handle /clear step completion. * Proceeds to init or completes cycle based on config. * @fires stepCompleted - With step 'clear' */ private checkClearComplete(): void { this.clearIdleTimer(); // Clear the fallback timer since we got prompt detection this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected'); this.clearFallbackTimer = null; this.logAction('step', '/clear completed'); this.emit('stepCompleted', 'clear'); if (this.config.sendInit) { this.sendInit(); } else { this.completeCycle(); } } /** * Handle /init step completion. * If kickstart is configured, monitors for work. * Otherwise completes cycle. * @fires stepCompleted - With step 'init' (if no kickstart) */ private checkInitComplete(): void { this.clearIdleTimer(); this.log('/init completed (ready indicator)'); // If kickstart prompt is configured, monitor to see if /init triggered work if (this.config.kickstartPrompt) { this.startMonitoringInit(); } else { this.emit('stepCompleted', 'init'); this.completeCycle(); } } /** * Start monitoring to see if /init triggered work. * Enters 'monitoring_init' state and waits 3s grace period. * If no work detected, sends kickstart prompt. */ private startMonitoringInit(): void { this.setState('monitoring_init'); this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); this.workingDetected = false; this.logAction('step', 'Monitoring if /init triggered work...'); // Give Claude a moment to start working before checking for idle this.stepTimer = this.startTrackedTimer( 'init-monitor', 3000, () => { this.stepTimer = null; // If still in monitoring state and no work detected, consider it idle if (this._state === 'monitoring_init' && !this.workingDetected) { this.checkMonitoringInitIdle(); } }, 'grace period for /init' ); } /** * Handle monitoring timeout when /init didn't trigger work. * Sends kickstart prompt as fallback. * @fires stepCompleted - With step 'init' */ private checkMonitoringInitIdle(): void { this.clearIdleTimer(); if (this.stepTimer) { clearTimeout(this.stepTimer); this.stepTimer = null; } this.log('/init did not trigger work, sending kickstart prompt'); this.emit('stepCompleted', 'init'); this.sendKickstart(); } /** * Send the kickstart prompt to get Claude working. * @fires stepSent - With step 'kickstart' */ private sendKickstart(): void { this.setState('sending_kickstart'); this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, () => { this.stepTimer = null; const prompt = this.config.kickstartPrompt!; this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI this.emit('stepSent', 'kickstart', prompt); this.setState('waiting_kickstart'); this.promptDetected = false; this.workingDetected = false; }, 'delay before kickstart' ); } /** * Handle kickstart step completion. * @fires stepCompleted - With step 'kickstart' */ private checkKickstartComplete(): void { this.clearIdleTimer(); this.log('Kickstart completed (ready indicator)'); this.emit('stepCompleted', 'kickstart'); this.completeCycle(); } // Note: Legacy startIdleTimer removed - now using completion-based detection // with startCompletionConfirmTimer() and startNoOutputTimer() instead. /** Clear the idle detection timer if running (legacy cleanup) */ private clearIdleTimer(): void { if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } } /** 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) { clearTimeout(this.stepTimer); this.stepTimer = null; } if (this.clearFallbackTimer) { clearTimeout(this.clearFallbackTimer); this.clearFallbackTimer = null; } if (this.completionConfirmTimer) { clearTimeout(this.completionConfirmTimer); this.completionConfirmTimer = null; } if (this.stepConfirmTimer) { clearTimeout(this.stepConfirmTimer); this.stepConfirmTimer = null; } if (this.autoAcceptTimer) { clearTimeout(this.autoAcceptTimer); this.autoAcceptTimer = null; } if (this.preFilterTimer) { clearTimeout(this.preFilterTimer); this.preFilterTimer = null; } if (this.noOutputTimer) { clearTimeout(this.noOutputTimer); this.noOutputTimer = null; } if (this.hookConfirmTimer) { clearTimeout(this.hookConfirmTimer); this.hookConfirmTimer = null; } // Clear all tracked timers this.activeTimers.clear(); } // ========== Timer Tracking Methods ========== /** * Start a tracked timer with UI countdown support. * Emits timerStarted event and tracks the timer for UI display. */ private startTrackedTimer( name: string, durationMs: number, callback: () => void, reason?: string ): NodeJS.Timeout { const now = Date.now(); const endsAt = now + durationMs; this.activeTimers.set(name, { name, startedAt: now, durationMs, endsAt }); this.emit('timerStarted', { name, durationMs, endsAt, reason }); this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`); return setTimeout(() => { this.activeTimers.delete(name); this.emit('timerCompleted', name); callback(); }, durationMs); } /** * Cancel a tracked timer and emit cancellation event. */ private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void { if (timerRef) { clearTimeout(timerRef); if (this.activeTimers.has(name)) { this.activeTimers.delete(name); this.emit('timerCancelled', name, reason); this.logAction('timer-cancel', `${name}${reason ? `: ${reason}` : ''}`); } } } /** * Get all active timers with remaining time for UI display. */ getActiveTimers(): ActiveTimerInfo[] { const now = Date.now(); return Array.from(this.activeTimers.values()).map(t => ({ name: t.name, remainingMs: Math.max(0, t.endsAt - now), totalMs: t.durationMs, })); } /** * Log an action for detailed UI feedback. * Keeps the last 20 entries. */ private logAction(type: string, detail: string): void { const action: ActionLogEntry = { type, detail, timestamp: Date.now() }; this.recentActions.unshift(action); if (this.recentActions.length > 20) { this.recentActions.pop(); } this.emit('actionLog', action); } /** * Get recent action log entries. */ getRecentActions(): ActionLogEntry[] { return [...this.recentActions]; } // ========== 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. * Uses rolling window to catch patterns split across chunks (e.g., "Thin" + "king"). */ private hasWorkingPattern(data: string): boolean { // Always update the rolling window first to maintain continuity this.workingPatternWindow += data; if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) { this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE); } // Check the rolling window (includes current data, catches both complete and split patterns) return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern)); } /** * Clear the working pattern rolling window. * Called when starting a new detection cycle. */ private clearWorkingPatternWindow(): void { this.workingPatternWindow = ''; } /** * 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 as safety net * (used when AI check is disabled or has too many errors). */ private startNoOutputTimer(): void { this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting'); this.noOutputTimer = null; this.noOutputTimer = this.startTrackedTimer( 'no-output-fallback', this.config.noOutputTimeoutMs, () => { this.noOutputTimer = null; if (this._state === 'watching' || this._state === 'confirming_idle') { const msSinceOutput = Date.now() - this.lastOutputTime; this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`); // If AI check is disabled or errored out, go directly to idle if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') { this.onIdleConfirmed('no-output fallback (AI check disabled)'); } else { this.tryStartAiCheck('no-output fallback'); } } }, 'fallback if no output at all' ); } /** * Reset the no-output fallback timer. * Called whenever output is received. */ private resetNoOutputTimer(): void { this.startNoOutputTimer(); } // ========== Pre-Filter & AI Check Methods ========== /** * Start the pre-filter timer. * Fires after completionConfirmMs of silence. When it fires, checks if * all pre-filter conditions are met and starts the AI check if so. * This provides an additional path to AI check even without a completion message. */ private startPreFilterTimer(): void { this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting'); this.preFilterTimer = null; // Only set up pre-filter when AI check is enabled if (!this.config.aiIdleCheckEnabled) return; this.preFilterTimer = this.startTrackedTimer( 'pre-filter', this.config.completionConfirmMs, () => { this.preFilterTimer = null; if (this._state === 'watching') { const now = Date.now(); const msSinceOutput = now - this.lastOutputTime; const msSinceWorking = now - this.lastWorkingPatternTime; const msSinceTokenChange = now - this.lastTokenChangeTime; // Check pre-filter conditions const silenceMet = msSinceOutput >= this.config.completionConfirmMs; const noWorkingMet = msSinceWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS; const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs; if (silenceMet && noWorkingMet && tokensStableMet) { this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`); this.tryStartAiCheck('pre-filter'); } } }, 'checking idle conditions' ); } /** * Reset the pre-filter timer. * Called whenever output is received. */ private resetPreFilterTimer(): void { this.startPreFilterTimer(); } /** * Attempt to start an AI idle check. * Checks if AI check is enabled, not on cooldown, and not already checking. * Falls back to direct idle confirmation if AI check is unavailable. * * @param reason - What triggered this attempt (for logging) */ private tryStartAiCheck(reason: string): void { // If AI check is disabled or errored out, fall back to direct idle confirmation if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') { this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`); this.onIdleConfirmed(reason); return; } // If on cooldown, don't start check - wait for cooldown to expire if (this.aiChecker.isOnCooldown()) { this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`); return; } // If already checking, don't start another if (this.aiChecker.status === 'checking') { this.log('AI check already in progress'); return; } // Start the AI check this.startAiCheck(reason); } /** * Start the AI idle check. * Transitions to 'ai_checking' state and runs the check asynchronously. * * @param reason - What triggered this check (for logging) */ private startAiCheck(reason: string): void { this.setState('ai_checking'); this.logAction('ai-check', `Spawning AI idle checker (${reason})`); this.emit('aiCheckStarted'); // Get the terminal buffer for analysis const buffer = this.terminalBuffer.value; this.aiChecker.check(buffer).then((result) => { // If state changed while checking (e.g., cancelled), ignore result if (this._state !== 'ai_checking') { this.log(`AI check result ignored (state is now ${this._state})`); return; } if (result.verdict === 'IDLE') { // Cancel any pending confirmation timers - AI has spoken this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); this.completionConfirmTimer = null; this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); this.preFilterTimer = null; this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); this.emit('aiCheckCompleted', result); this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); } else if (result.verdict === 'WORKING') { // Cancel timers and go to cooldown this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING'); this.completionConfirmTimer = null; this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); this.emit('aiCheckCompleted', result); this.setState('watching'); this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`); // Restart timers so the controller retries after cooldown expires this.startNoOutputTimer(); this.startPreFilterTimer(); } else { // ERROR verdict this.logAction('ai-check', `Error: ${result.reasoning}`); this.emit('aiCheckFailed', result.reasoning); this.setState('watching'); // Restart timers to allow retry this.startNoOutputTimer(); this.startPreFilterTimer(); } }).catch((err) => { if (this._state === 'ai_checking') { const errorMsg = err instanceof Error ? err.message : String(err); this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); this.emit('aiCheckFailed', errorMsg); this.setState('watching'); this.log(`AI check error: ${errorMsg}`); // Restart timers to allow retry this.startNoOutputTimer(); this.startPreFilterTimer(); } }); } // ========== Auto-Accept Prompt Methods ========== /** * Reset the auto-accept timer. * Called whenever output is received. After autoAcceptDelayMs of silence * (without a completion message), sends Enter to accept prompts. */ private resetAutoAcceptTimer(): void { if (!this.config.autoAcceptPrompts) return; this.startAutoAcceptTimer(); } /** * Start the auto-accept timer. * Fires after autoAcceptDelayMs of no output when no completion message * and no elicitation dialog was detected. Only handles plan mode approvals. */ private startAutoAcceptTimer(): void { this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting'); this.autoAcceptTimer = null; this.autoAcceptTimer = this.startTrackedTimer( 'auto-accept', this.config.autoAcceptDelayMs, () => { this.autoAcceptTimer = null; this.tryAutoAccept(); }, 'plan mode detection' ); } /** * Cancel the auto-accept timer. * Called when a completion message is detected (normal idle flow handles it). */ private cancelAutoAcceptTimer(): void { this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled'); this.autoAcceptTimer = null; } /** * Attempt to auto-accept a plan mode prompt by sending Enter. * Two-stage gate: * 1. Strict regex pre-filter — check if terminal buffer contains plan mode UI elements * 2. AI confirmation — spawn Opus to classify buffer as PLAN_MODE or NOT_PLAN_MODE * * Only sends Enter if both stages confirm (or pre-filter only if AI disabled). * * @fires autoAcceptSent * @fires planCheckStarted */ private tryAutoAccept(): void { // Only auto-accept in watching state (not during a respawn cycle) if (this._state !== 'watching') return; // Don't auto-accept if a completion message was detected (normal idle handles it) if (this.completionMessageTime !== null) return; // Don't auto-accept if disabled if (!this.config.autoAcceptPrompts) return; // Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start) if (!this.hasReceivedOutput) return; // Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected if (this.elicitationDetected) { this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)'); return; } // Stage 1: Pre-filter — check if buffer looks like plan mode const buffer = this.terminalBuffer.value; if (!this.isPlanModePreFilterMatch(buffer)) { this.log('Skipping auto-accept: pre-filter did not match plan mode patterns'); return; } // Stage 2: AI confirmation (if enabled and available) if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') { if (this.planChecker.isOnCooldown()) { this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`); return; } if (this.planChecker.status === 'checking') { this.log('Skipping auto-accept: plan check already in progress'); return; } // Start async AI plan check this.startPlanCheck(buffer); return; } // AI plan check disabled — pre-filter passed, send Enter directly this.sendAutoAcceptEnter(); } /** * Check if the terminal buffer matches plan mode pre-filter patterns. * Only checks the last 2000 chars (plan mode UI appears at the bottom). * * Must find: * - Numbered option pattern (e.g., "1. Yes", "2. No") * - Selection indicator (❯ or > followed by number) * Must NOT find: * - Recent working patterns (spinners, "Thinking", etc.) in the tail */ private isPlanModePreFilterMatch(buffer: string): boolean { // Only check the last 2000 chars (plan mode UI is at the bottom) const tail = buffer.slice(-2000); // Strip ANSI codes for pattern matching const stripped = tail.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); // Must find numbered option pattern if (!PLAN_MODE_OPTION_PATTERN.test(stripped)) return false; // Must find selection indicator const selectorMatch = stripped.match(PLAN_MODE_SELECTOR_PATTERN); if (!selectorMatch) return false; // Must NOT have working patterns AFTER the selector position. // Working patterns before the selector are from earlier work and don't matter. const selectorIndex = stripped.lastIndexOf(selectorMatch[0]); const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length); const hasWorking = this.WORKING_PATTERNS.some(pattern => afterSelector.includes(pattern)); if (hasWorking) return false; return true; } /** * Start an AI plan check to confirm plan mode before auto-accepting. * Async — result handled by then/catch. * * @param buffer - Terminal buffer to analyze * @fires planCheckStarted * @fires planCheckCompleted * @fires planCheckFailed */ private startPlanCheck(buffer: string): void { this.planCheckStartTime = Date.now(); this.logAction('plan-check', 'Spawning AI plan checker'); this.emit('planCheckStarted'); this.planChecker.check(buffer).then((result) => { // Discard stale result if new output arrived during check if (this.lastOutputTime > this.planCheckStartTime) { this.logAction('plan-check', 'Result discarded (output arrived during check)'); return; } if (result.verdict === 'PLAN_MODE') { // Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began) if (this._state !== 'watching') { this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`); return; } this.emit('planCheckCompleted', result); this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately'); this.sendAutoAcceptEnter(); // No cooldown needed - we're taking action } else if (result.verdict === 'NOT_PLAN_MODE') { this.emit('planCheckCompleted', result); this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`); } else { // ERROR verdict this.emit('planCheckFailed', result.reasoning); this.logAction('plan-check', `Error: ${result.reasoning}`); } }).catch((err) => { const errorMsg = err instanceof Error ? err.message : String(err); this.emit('planCheckFailed', errorMsg); this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`); }); } /** * Send the actual Enter keystroke for auto-accept. * Factored out so both pre-filter-only and AI-confirmed paths can call it. * @fires autoAcceptSent */ private sendAutoAcceptEnter(): void { const msSinceOutput = Date.now() - this.lastOutputTime; this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)`); // Cancel any pending AI idle checks - we're about to make Claude work if (this.aiChecker.status === 'checking') { this.log('Cancelling AI idle check before auto-accept'); this.aiChecker.cancel(); } // Cancel completion confirmation - auto-accept takes precedence this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept'); this.completionConfirmTimer = null; this.completionMessageTime = null; // Ensure we're in watching state (not confirming_idle or ai_checking) if (this._state !== 'watching') { this.setState('watching'); } this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)'); this.emit('stepSent', 'auto-accept', '↵'); this.session.writeViaScreen('\r'); this.emit('autoAcceptSent'); // Reset so we don't keep spamming Enter if Claude doesn't respond this.hasReceivedOutput = false; } /** * Signal that an elicitation dialog (AskUserQuestion) was detected via hook. * This prevents auto-accept from firing, since the user needs to make a selection. * The flag is cleared when working patterns are detected (new turn starts). */ signalElicitation(): void { this.elicitationDetected = true; this.cancelAutoAcceptTimer(); 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. */ private startCompletionConfirmTimer(): void { this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting'); this.completionConfirmTimer = null; this.setState('confirming_idle'); this.logAction('detection', 'Completion message found in output'); this.completionConfirmTimer = this.startTrackedTimer( 'completion-confirm', this.config.completionConfirmMs, () => { this.completionConfirmTimer = null; const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`); this.tryStartAiCheck('completion + silence'); } else { // Output received during wait, stay in confirming state and re-check this.logAction('detection', 'Output during confirmation, resetting'); this.startCompletionConfirmTimer(); } }, 'waiting for silence after completion' ); } /** * 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; } } /** * Start step confirmation timer for waiting states. * Waits for output silence before proceeding to next step. * This ensures Claude has finished processing before we send the next command. */ private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void { this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting'); this.stepConfirmTimer = null; this.stepConfirmTimer = this.startTrackedTimer( 'step-confirm', this.config.completionConfirmMs, () => { this.stepConfirmTimer = null; const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`); // Proceed with the step completion switch (step) { case 'update': this.checkUpdateComplete(); break; case 'init': this.checkInitComplete(); break; case 'kickstart': this.checkKickstartComplete(); break; } } else { // Output received during wait, restart timer this.logAction('step', `Output during ${step} confirmation, resetting`); this.startStepConfirmTimer(step); } }, `confirming ${step} completion` ); } /** * Cancel step confirmation if working patterns detected. */ private cancelStepConfirm(): void { if (this.stepConfirmTimer) { clearTimeout(this.stepConfirmTimer); this.stepConfirmTimer = null; this.log(`Step confirmation cancelled (working detected)`); } } /** * Called when idle is confirmed through any detection layer. * @param reason - What triggered the confirmation */ private onIdleConfirmed(reason: string): void { // Safety check: if Session thinks it's still working, don't trigger idle // This catches cases where our detection missed working patterns if (this.session.isWorking) { this.log(`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`); this.logAction('detection', 'Rejected: Session still working'); this.setState('watching'); this.startNoOutputTimer(); this.startPreFilterTimer(); return; } 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(); } /** * Handle confirmed idle detection. * Starts a new respawn cycle. * @fires respawnCycleStarted */ private onIdleDetected(): void { // Accept watching, confirming_idle, and ai_checking states if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { return; } // Start the respawn cycle this.cycleCount++; this.log(`Starting respawn cycle #${this.cycleCount}`); this.emit('respawnCycleStarted', this.cycleCount); this.sendUpdateDocs(); } /** * Send the update docs prompt (first step of cycle). * @fires stepSent - With step 'update' */ private sendUpdateDocs(): void { this.setState('sending_update'); this.terminalBuffer.clear(); // Clear buffer for fresh detection this.clearWorkingPatternWindow(); // Clear rolling window this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, () => { this.stepTimer = null; const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI this.logAction('command', `Sending: "${this.config.updatePrompt.substring(0, 50)}..."`); this.session.writeViaScreen(input); this.emit('stepSent', 'update', this.config.updatePrompt); this.setState('waiting_update'); this.promptDetected = false; this.workingDetected = false; }, 'delay before update prompt' ); } /** * Send /clear command. * Starts a 10-second fallback timer - if no prompt is detected after /clear, * proceeds to /init anyway (workaround for when Claude doesn't show prompt after /clear). * @fires stepSent - With step 'clear' */ private sendClear(): void { this.setState('sending_clear'); this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, () => { this.stepTimer = null; this.logAction('command', 'Sending: /clear'); this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI this.emit('stepSent', 'clear', '/clear'); this.setState('waiting_clear'); this.promptDetected = false; // Start fallback timer - if no prompt detected after 10s, proceed to /init anyway this.clearFallbackTimer = this.startTrackedTimer( 'clear-fallback', RespawnController.CLEAR_FALLBACK_TIMEOUT_MS, () => { this.clearFallbackTimer = null; if (this._state === 'waiting_clear') { this.logAction('step', '/clear fallback: proceeding to /init'); this.emit('stepCompleted', 'clear'); if (this.config.sendInit) { this.sendInit(); } else { this.completeCycle(); } } }, 'fallback if no prompt after /clear' ); }, 'delay before /clear' ); } /** * Send /init command. * @fires stepSent - With step 'init' */ private sendInit(): void { this.setState('sending_init'); this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); this.stepTimer = this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, () => { this.stepTimer = null; this.logAction('command', 'Sending: /init'); this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI this.emit('stepSent', 'init', '/init'); this.setState('waiting_init'); this.promptDetected = false; this.workingDetected = false; }, 'delay before /init' ); } /** * Complete the current respawn cycle. * Returns to watching state for next cycle. * @fires respawnCycleCompleted */ private completeCycle(): void { this.log(`Respawn cycle #${this.cycleCount} completed`); this.emit('respawnCycleCompleted', this.cycleCount); // Go back to watching state for next cycle this.setState('watching'); this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); // Clear rolling window for fresh detection 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(); } } /** * Check if already idle and start cycle if so. * Used when resuming from pause. */ private checkIdleAndMaybeStart(): void { // Check if already idle const timeSinceActivity = Date.now() - this.lastActivityTime; if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) { this.onIdleDetected(); } } /** * Update configuration at runtime. * * Merges provided config with existing config. * Takes effect immediately for new operations. * * @param config - Partial configuration to merge * @fires log - With updated config details */ updateConfig(config: Partial): void { // Filter out undefined values to prevent overwriting existing config with undefined const filteredConfig = Object.fromEntries( Object.entries(config).filter(([, v]) => v !== undefined) ) as Partial; this.config = { ...this.config, ...filteredConfig }; // Sync AI checker config if relevant fields changed if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined || config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined || config.aiIdleCheckCooldownMs !== undefined) { this.aiChecker.updateConfig({ enabled: this.config.aiIdleCheckEnabled, model: this.config.aiIdleCheckModel, maxContextChars: this.config.aiIdleCheckMaxContext, checkTimeoutMs: this.config.aiIdleCheckTimeoutMs, cooldownMs: this.config.aiIdleCheckCooldownMs, }); } // Sync plan checker config if relevant fields changed if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined || config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined || config.aiPlanCheckCooldownMs !== undefined) { this.planChecker.updateConfig({ enabled: this.config.aiPlanCheckEnabled, model: this.config.aiPlanCheckModel, maxContextChars: this.config.aiPlanCheckMaxContext, checkTimeoutMs: this.config.aiPlanCheckTimeoutMs, cooldownMs: this.config.aiPlanCheckCooldownMs, }); } this.log(`Config updated: ${JSON.stringify(config)}`); } /** * Get current configuration. * @returns Copy of current config (safe to modify) */ getConfig(): RespawnConfig { return { ...this.config }; } /** * Get comprehensive status information. * * Useful for debugging and monitoring. * * @returns Status object with: * - state: Current state machine state * - cycleCount: Number of cycles started * - lastActivityTime: Timestamp of last activity * - timeSinceActivity: Milliseconds since last activity * - promptDetected: Whether prompt indicator seen * - workingDetected: Whether working indicator seen * - detection: Multi-layer detection status * - config: Current configuration */ getStatus() { return { state: this._state, cycleCount: this.cycleCount, lastActivityTime: this.lastActivityTime, timeSinceActivity: Date.now() - this.lastActivityTime, promptDetected: this.promptDetected, workingDetected: this.workingDetected, detection: this.getDetectionStatus(), config: this.config, }; } }