From 0e56e590f3ec7d9ba9eb047ed20acae09b50e933 Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 24 Jan 2026 18:48:37 +0100 Subject: [PATCH] feat: add AI-powered idle check for respawn controller Replace the "Worked for Xm Xs" pattern as the sole primary idle detection signal with a final AI-powered check. When pre-filter conditions are met (output silence, no working patterns, tokens stable), a fresh Claude CLI session is spawned in a screen to analyze terminal output and provide a definitive IDLE/WORKING verdict before proceeding with the respawn cycle. New state in state machine: `ai_checking` (between pre-filter confirmation and `sending_update`). WORKING verdict triggers a 3-minute cooldown. Errors auto-disable after 3 consecutive failures, falling back to the existing noOutputTimeoutMs safety net. Co-Authored-By: Claude Opus 4.5 --- src/ai-idle-checker.ts | 492 +++++++++++++++++++++++++++++++ src/respawn-controller.ts | 285 ++++++++++++++++-- src/types.ts | 20 ++ src/web/public/app.js | 50 ++++ src/web/public/index.html | 1 + src/web/server.ts | 36 +++ test/ai-idle-checker.test.ts | 495 ++++++++++++++++++++++++++++++++ test/respawn-controller.test.ts | 240 ++++++++++++++++ 8 files changed, 1602 insertions(+), 17 deletions(-) create mode 100644 src/ai-idle-checker.ts create mode 100644 test/ai-idle-checker.test.ts diff --git a/src/ai-idle-checker.ts b/src/ai-idle-checker.ts new file mode 100644 index 00000000..87c1440a --- /dev/null +++ b/src/ai-idle-checker.ts @@ -0,0 +1,492 @@ +/** + * @fileoverview AI-Powered Idle Checker for Respawn Controller + * + * Spawns a fresh Claude CLI session in a screen to analyze terminal output + * and provide a definitive IDLE/WORKING verdict. This replaces the "Worked for Xm Xs" + * pattern as the primary idle detection signal. + * + * ## How It Works + * + * 1. Generate temp file path for output capture + * 2. Spawn screen: `screen -dmS claudeman-aicheck- bash -c 'claude -p ...'` + * 3. Poll the temp file every 500ms for `__AICHECK_DONE__` marker + * 4. Parse the file content for IDLE/WORKING on the first word + * 5. Kill screen and delete temp file + * + * ## Error Handling + * + * - Screen spawn fails: 1-min cooldown, increment error counter + * - Check times out (90s): Kill screen, 1-min cooldown + * - Can't parse IDLE/WORKING: Treat as WORKING, 1-min cooldown + * - 3 consecutive errors: Disable AI check, fall back to noOutputTimeoutMs + * - Claude CLI not found: Disable permanently + * + * @module ai-idle-checker + */ + +import { execSync, spawn as childSpawn } from 'node:child_process'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; + +// ========== Types ========== + +export interface AiIdleCheckConfig { + /** Whether AI idle check is enabled */ + enabled: boolean; + /** Model to use for the check */ + model: string; + /** Maximum characters of terminal buffer to send */ + maxContextChars: number; + /** Timeout for the check in ms */ + checkTimeoutMs: number; + /** Cooldown after WORKING verdict in ms */ + cooldownMs: number; + /** Cooldown after errors in ms */ + errorCooldownMs: number; + /** Max consecutive errors before disabling */ + maxConsecutiveErrors: number; +} + +export type AiCheckStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error'; +export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR'; + +export interface AiCheckResult { + verdict: AiCheckVerdict; + reasoning: string; + durationMs: number; +} + +export interface AiCheckState { + status: AiCheckStatus; + lastVerdict: AiCheckVerdict | null; + lastReasoning: string | null; + lastCheckDurationMs: number | null; + cooldownEndsAt: number | null; + consecutiveErrors: number; + totalChecks: number; + disabledReason: string | null; +} + +/** Events emitted by AiIdleChecker */ +export interface AiIdleCheckerEvents { + checkStarted: () => void; + checkCompleted: (result: AiCheckResult) => void; + checkFailed: (error: string) => void; + cooldownStarted: (endsAt: number) => void; + cooldownEnded: () => void; + disabled: (reason: string) => void; + log: (message: string) => void; +} + +// ========== Constants ========== + +const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = { + enabled: true, + model: 'claude-opus-4-5-20251101', + maxContextChars: 16000, + checkTimeoutMs: 90000, + cooldownMs: 180000, + errorCooldownMs: 60000, + maxConsecutiveErrors: 3, +}; + +/** ANSI escape code pattern for stripping terminal formatting */ +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; + +/** Poll interval for checking temp file completion */ +const POLL_INTERVAL_MS = 500; + +/** Marker written to temp file when check is complete */ +const DONE_MARKER = '__AICHECK_DONE__'; + +/** Pattern to match IDLE or WORKING as the first word of output */ +const VERDICT_PATTERN = /^\s*(IDLE|WORKING)\b/i; + +/** The prompt sent to the AI checker */ +const AI_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the session is IDLE (done working, waiting for new input) or WORKING (still actively processing). + +IDLE indicators: prompt character at the end, completion summary shown ("Worked for Xm Xs"), clear stopping point, no spinners, cost summary displayed +WORKING indicators: spinner chars, "Thinking"/"Writing"/"Reading"/"Running" text, active tool execution, truncated mid-output, partial lines + +Terminal output (most recent at bottom): +--- +{TERMINAL_BUFFER} +--- + +Answer with EXACTLY one word on the first line: IDLE or WORKING +Then optionally explain briefly why.`; + +// ========== AiIdleChecker Class ========== + +/** + * Manages AI-powered idle detection by spawning a fresh Claude CLI session + * to analyze terminal output and provide a definitive IDLE/WORKING verdict. + */ +export class AiIdleChecker extends EventEmitter { + private config: AiIdleCheckConfig; + private sessionId: string; + + // State + private _status: AiCheckStatus = 'ready'; + private lastVerdict: AiCheckVerdict | null = null; + private lastReasoning: string | null = null; + private lastCheckDurationMs: number | null = null; + private cooldownEndsAt: number | null = null; + private cooldownTimer: NodeJS.Timeout | null = null; + private consecutiveErrors: number = 0; + private totalChecks: number = 0; + private disabledReason: string | null = null; + + // Active check state + private checkScreenName: string | null = null; + private checkTempFile: string | null = null; + private checkPollTimer: NodeJS.Timeout | null = null; + private checkTimeoutTimer: NodeJS.Timeout | null = null; + private checkStartTime: number = 0; + private checkCancelled: boolean = false; + private checkResolve: ((result: AiCheckResult) => void) | null = null; + + constructor(sessionId: string, config: Partial = {}) { + super(); + this.sessionId = sessionId; + this.config = { ...DEFAULT_AI_CHECK_CONFIG, ...config }; + } + + /** Get the current status */ + get status(): AiCheckStatus { + return this._status; + } + + /** Get comprehensive state for UI display */ + getState(): AiCheckState { + return { + status: this._status, + lastVerdict: this.lastVerdict, + lastReasoning: this.lastReasoning, + lastCheckDurationMs: this.lastCheckDurationMs, + cooldownEndsAt: this.cooldownEndsAt, + consecutiveErrors: this.consecutiveErrors, + totalChecks: this.totalChecks, + disabledReason: this.disabledReason, + }; + } + + /** Check if the checker is on cooldown */ + isOnCooldown(): boolean { + if (this.cooldownEndsAt === null) return false; + return Date.now() < this.cooldownEndsAt; + } + + /** Get remaining cooldown time in ms */ + getCooldownRemainingMs(): number { + if (this.cooldownEndsAt === null) return 0; + return Math.max(0, this.cooldownEndsAt - Date.now()); + } + + /** + * Run an AI idle check against the provided terminal buffer. + * Spawns a fresh Claude CLI in a screen, captures output to temp file. + * + * @param terminalBuffer - Raw terminal output to analyze + * @returns The verdict result + */ + async check(terminalBuffer: string): Promise { + if (this._status === 'disabled') { + return { verdict: 'ERROR', reasoning: `Disabled: ${this.disabledReason}`, durationMs: 0 }; + } + + if (this.isOnCooldown()) { + return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 }; + } + + if (this._status === 'checking') { + return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 }; + } + + this._status = 'checking'; + this.checkCancelled = false; + this.checkStartTime = Date.now(); + this.totalChecks++; + this.emit('checkStarted'); + this.log('Starting AI idle check'); + + try { + const result = await this.runCheck(terminalBuffer); + + if (this.checkCancelled) { + return { verdict: 'ERROR', reasoning: 'Cancelled', durationMs: Date.now() - this.checkStartTime }; + } + + this.lastVerdict = result.verdict; + this.lastReasoning = result.reasoning; + this.lastCheckDurationMs = result.durationMs; + + if (result.verdict === 'IDLE') { + this.consecutiveErrors = 0; + this._status = 'ready'; + this.log(`AI check verdict: IDLE (${result.durationMs}ms) - ${result.reasoning}`); + } else if (result.verdict === 'WORKING') { + this.consecutiveErrors = 0; + this.startCooldown(this.config.cooldownMs); + this.log(`AI check verdict: WORKING (${result.durationMs}ms) - ${result.reasoning}`); + } else { + this.handleError('Unexpected verdict'); + } + + this.emit('checkCompleted', result); + return result; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + this.handleError(errorMsg); + const result: AiCheckResult = { + verdict: 'ERROR', + reasoning: errorMsg, + durationMs: Date.now() - this.checkStartTime, + }; + this.emit('checkFailed', errorMsg); + return result; + } finally { + this.cleanupCheck(); + } + } + + /** + * Cancel an in-progress check. + * Kills the screen session and cleans up. + */ + cancel(): void { + if (this._status !== 'checking') return; + + this.log('Cancelling AI check'); + this.checkCancelled = true; + + // Resolve the pending promise before cleanup + if (this.checkResolve) { + this.checkResolve({ verdict: 'ERROR', reasoning: 'Cancelled', durationMs: Date.now() - this.checkStartTime }); + this.checkResolve = null; + } + + this.cleanupCheck(); + this._status = 'ready'; + } + + /** Reset all state for a new cycle */ + reset(): void { + this.cancel(); + this.clearCooldown(); + this.lastVerdict = null; + this.lastReasoning = null; + this.lastCheckDurationMs = null; + this.consecutiveErrors = 0; + this._status = this.disabledReason ? 'disabled' : 'ready'; + } + + /** Update configuration at runtime */ + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + if (config.enabled === false) { + this.disable('Disabled by config'); + } else if (config.enabled === true && this._status === 'disabled') { + this.disabledReason = null; + this._status = 'ready'; + } + } + + /** Get current config */ + getConfig(): AiIdleCheckConfig { + return { ...this.config }; + } + + // ========== Private Methods ========== + + private async runCheck(terminalBuffer: string): Promise { + // Prepare the terminal buffer (strip ANSI, trim to maxContextChars) + const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN, ''); + const trimmed = stripped.length > this.config.maxContextChars + ? stripped.slice(-this.config.maxContextChars) + : stripped; + + // Build the prompt + const prompt = AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', trimmed); + + // Generate temp file and screen name + const shortId = this.sessionId.slice(0, 8); + const timestamp = Date.now(); + this.checkTempFile = join(tmpdir(), `claudeman-aicheck-${shortId}-${timestamp}.txt`); + this.checkScreenName = `claudeman-aicheck-${shortId}`; + + // Ensure temp file exists (empty) so we can poll it + writeFileSync(this.checkTempFile, ''); + + // Build the command - escape the prompt for shell + const escapedPrompt = prompt.replace(/'/g, "'\\''"); + const modelArg = `--model ${this.config.model}`; + const claudeCmd = `claude -p ${modelArg} --output-format text '${escapedPrompt}'`; + const fullCmd = `${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${DONE_MARKER}" >> "${this.checkTempFile}"`; + + // Spawn screen + try { + // Kill any leftover screen with this name first + try { + execSync(`screen -X -S ${this.checkScreenName} quit 2>/dev/null`, { timeout: 3000 }); + } catch { + // No existing screen, that's fine + } + + const screenProcess = childSpawn('screen', [ + '-dmS', this.checkScreenName, + '-c', '/dev/null', + 'bash', '-c', fullCmd + ], { + detached: true, + stdio: 'ignore', + }); + screenProcess.unref(); + } catch (err) { + throw new Error(`Failed to spawn AI check screen: ${err instanceof Error ? err.message : String(err)}`); + } + + // Poll the temp file for completion + return new Promise((resolve, reject) => { + const startTime = this.checkStartTime; + this.checkResolve = resolve; + + this.checkPollTimer = setInterval(() => { + if (this.checkCancelled) { + // Cancel was already handled by cancel() calling resolve + return; + } + + try { + if (!this.checkTempFile || !existsSync(this.checkTempFile)) return; + const content = readFileSync(this.checkTempFile, 'utf-8'); + if (content.includes(DONE_MARKER)) { + const durationMs = Date.now() - startTime; + const result = this.parseOutput(content, durationMs); + this.checkResolve = null; + resolve(result); + } + } catch { + // File might not be ready yet, keep polling + } + }, POLL_INTERVAL_MS); + + // Set timeout + this.checkTimeoutTimer = setTimeout(() => { + if (this._status === 'checking' && !this.checkCancelled) { + this.checkResolve = null; + reject(new Error(`AI check timed out after ${this.config.checkTimeoutMs}ms`)); + } + }, this.config.checkTimeoutMs); + }); + } + + private parseOutput(content: string, durationMs: number): AiCheckResult { + // Remove the done marker and trim + const output = content.replace(DONE_MARKER, '').trim(); + + if (!output) { + return { verdict: 'ERROR', reasoning: 'Empty output from AI check', durationMs }; + } + + // Look for IDLE or WORKING as the first word + const match = output.match(VERDICT_PATTERN); + if (!match) { + return { verdict: 'ERROR', reasoning: `Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs }; + } + + const verdict = match[1].toUpperCase() as 'IDLE' | 'WORKING'; + // Everything after the first line is the reasoning + const lines = output.split('\n'); + const reasoning = lines.slice(1).join('\n').trim() || `AI determined: ${verdict}`; + + return { verdict, reasoning, durationMs }; + } + + private cleanupCheck(): void { + // Clear poll timer + if (this.checkPollTimer) { + clearInterval(this.checkPollTimer); + this.checkPollTimer = null; + } + + // Clear timeout timer + if (this.checkTimeoutTimer) { + clearTimeout(this.checkTimeoutTimer); + this.checkTimeoutTimer = null; + } + + // Kill the screen + if (this.checkScreenName) { + try { + execSync(`screen -X -S ${this.checkScreenName} quit 2>/dev/null`, { timeout: 3000 }); + } catch { + // Screen may already be dead + } + this.checkScreenName = null; + } + + // Delete temp file + if (this.checkTempFile) { + try { + if (existsSync(this.checkTempFile)) { + unlinkSync(this.checkTempFile); + } + } catch { + // Best effort cleanup + } + this.checkTempFile = null; + } + } + + private handleError(errorMsg: string): void { + this.consecutiveErrors++; + this.log(`AI check error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`); + + if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) { + this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`); + } else { + this.startCooldown(this.config.errorCooldownMs); + } + } + + private startCooldown(durationMs: number): void { + this.clearCooldown(); + this.cooldownEndsAt = Date.now() + durationMs; + this._status = 'cooldown'; + this.emit('cooldownStarted', this.cooldownEndsAt); + this.log(`Cooldown started: ${Math.round(durationMs / 1000)}s`); + + this.cooldownTimer = setTimeout(() => { + this.cooldownEndsAt = null; + this._status = 'ready'; + this.emit('cooldownEnded'); + this.log('Cooldown ended'); + }, durationMs); + } + + private clearCooldown(): void { + if (this.cooldownTimer) { + clearTimeout(this.cooldownTimer); + this.cooldownTimer = null; + } + this.cooldownEndsAt = null; + if (this._status === 'cooldown') { + this._status = 'ready'; + } + } + + private disable(reason: string): void { + this.disabledReason = reason; + this._status = 'disabled'; + this.clearCooldown(); + this.log(`AI check disabled: ${reason}`); + this.emit('disabled', reason); + } + + private log(message: string): void { + this.emit('log', `[AiIdleChecker] ${message}`); + } +} diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index f1de4415..174e5182 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -36,6 +36,7 @@ import { EventEmitter } from 'node:events'; import { Session } from './session.js'; +import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js'; // ========== Configuration Constants ========== @@ -101,6 +102,9 @@ export interface DetectionStatus { /** Milliseconds since last working pattern */ msSinceLastWorking: number; + /** Layer 5: AI idle check status */ + aiCheck: AiCheckState | null; + /** Overall confidence level (0-100) */ confidenceLevel: number; @@ -183,6 +187,8 @@ export type RespawnState = | '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 */ @@ -289,6 +295,38 @@ export interface RespawnConfig { * @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; } /** @@ -318,6 +356,14 @@ export interface RespawnEvents { 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; /** Error occurred */ error: (error: Error) => void; /** Debug log message */ @@ -336,6 +382,11 @@ const DEFAULT_CONFIG: RespawnConfig = { 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 }; /** @@ -418,6 +469,9 @@ export class RespawnController extends EventEmitter { /** 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; @@ -442,6 +496,9 @@ export class RespawnController extends EventEmitter { /** Reference to terminal event handler (for cleanup) */ private terminalHandler: ((data: string) => void) | null = null; + /** AI idle checker instance */ + private aiChecker: AiIdleChecker; + /** Timer for /clear step fallback (sends /init if no prompt detected) */ private clearFallbackTimer: NodeJS.Timeout | null = null; @@ -500,6 +557,33 @@ export class RespawnController extends EventEmitter { super(); this.session = session; this.config = { ...DEFAULT_CONFIG, ...config }; + 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.setupAiCheckerListeners(); + } + + /** 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); + }); + + this.aiChecker.on('disabled', (reason: string) => { + this.log(`AI checker disabled: ${reason}. Falling back to noOutputTimeoutMs.`); + }); } /** @@ -557,19 +641,27 @@ export class RespawnController extends EventEmitter { if (this._state === 'stopped') { statusText = 'Controller stopped'; waitingFor = 'Start to begin monitoring'; + } 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') { - if (completionMessageDetected) { + 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 = 'Completion message or timeout'; + waitingFor = 'Pre-filter conditions for AI check'; } else { - statusText = 'Watching for completion'; - waitingFor = 'Completion message (for Xm Xs)'; + 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}`; @@ -589,6 +681,7 @@ export class RespawnController extends EventEmitter { msSinceTokenChange, workingPatternsAbsent, msSinceLastWorking, + aiCheck: this.config.aiIdleCheckEnabled ? this.aiChecker.getState() : null, confidenceLevel: confidence, statusText, waitingFor, @@ -679,10 +772,12 @@ export class RespawnController extends EventEmitter { this.completionMessageTime = null; this.hasReceivedOutput = false; + this.aiChecker.reset(); this.setState('watching'); this.setupTerminalListener(); this.startDetectionUpdates(); this.startNoOutputTimer(); + this.startPreFilterTimer(); if (this.config.autoAcceptPrompts) { this.startAutoAcceptTimer(); } @@ -698,6 +793,7 @@ export class RespawnController extends EventEmitter { */ stop(): void { this.log('Stopping respawn controller'); + this.aiChecker.cancel(); this.clearTimers(); this.stopDetectionUpdates(); this.setState('stopped'); @@ -777,6 +873,7 @@ export class RespawnController extends EventEmitter { this.lastActivityTime = now; this.hasReceivedOutput = true; this.resetNoOutputTimer(); + this.resetPreFilterTimer(); this.resetAutoAcceptTimer(); // Track token count (Layer 3) @@ -801,6 +898,13 @@ export class RespawnController extends EventEmitter { // 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'); + } + // 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'); @@ -842,15 +946,21 @@ export class RespawnController extends EventEmitter { return; } - // In confirming_idle state, substantial output cancels the confirmation. + // 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') { + 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) { - // 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(); + 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; } } @@ -1020,7 +1130,7 @@ export class RespawnController extends EventEmitter { } } - /** Clear all timers (idle, step, completion confirm, no-output, step confirm, auto-accept, and clear fallback) */ + /** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, and clear fallback) */ private clearTimers(): void { this.clearIdleTimer(); if (this.stepTimer) { @@ -1043,6 +1153,10 @@ export class RespawnController extends EventEmitter { clearTimeout(this.autoAcceptTimer); this.autoAcceptTimer = null; } + if (this.preFilterTimer) { + clearTimeout(this.preFilterTimer); + this.preFilterTimer = null; + } if (this.noOutputTimer) { clearTimeout(this.noOutputTimer); this.noOutputTimer = null; @@ -1084,7 +1198,8 @@ export class RespawnController extends EventEmitter { /** * Start the no-output fallback timer. - * If no output for noOutputTimeoutMs, triggers idle detection. + * 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 { if (this.noOutputTimer) { @@ -1094,7 +1209,12 @@ export class RespawnController extends EventEmitter { 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'); + // 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'); + } } }, this.config.noOutputTimeoutMs); } @@ -1107,6 +1227,123 @@ export class RespawnController extends EventEmitter { 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 { + if (this.preFilterTimer) { + clearTimeout(this.preFilterTimer); + } + // Only set up pre-filter when AI check is enabled + if (!this.config.aiIdleCheckEnabled) return; + + this.preFilterTimer = setTimeout(() => { + 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 >= 3000; + const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs; + + if (silenceMet && noWorkingMet && tokensStableMet) { + this.log(`Pre-filter conditions met: silence=${msSinceOutput}ms, noWorking=${msSinceWorking}ms, tokensStable=${msSinceTokenChange}ms`); + this.tryStartAiCheck('pre-filter'); + } + } + }, this.config.completionConfirmMs); + } + + /** + * 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.log(`Starting AI idle check (triggered by: ${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') { + this.emit('aiCheckCompleted', result); + this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); + } else if (result.verdict === 'WORKING') { + this.emit('aiCheckCompleted', result); + this.setState('watching'); + this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`); + } else { + // ERROR verdict + this.emit('aiCheckFailed', result.reasoning); + this.setState('watching'); + } + }).catch((err) => { + if (this._state === 'ai_checking') { + const errorMsg = err instanceof Error ? err.message : String(err); + this.emit('aiCheckFailed', errorMsg); + this.setState('watching'); + this.log(`AI check error: ${errorMsg}`); + } + }); + } + // ========== Auto-Accept Prompt Methods ========== /** @@ -1202,7 +1439,7 @@ export class RespawnController extends EventEmitter { /** * Start completion confirmation timer. - * After completion message, waits for output silence. + * After completion message, waits for output silence then triggers AI check. */ private startCompletionConfirmTimer(): void { if (this.completionConfirmTimer) { @@ -1215,8 +1452,8 @@ export class RespawnController extends EventEmitter { 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'); + this.log(`Silence confirmed after completion message (${msSinceOutput}ms)`); + this.tryStartAiCheck('completion + silence'); } else { // Output received during wait, stay in confirming state and re-check this.log(`Output received during confirmation, resetting timer`); @@ -1318,8 +1555,8 @@ export class RespawnController extends EventEmitter { * @fires respawnCycleStarted */ private onIdleDetected(): void { - // Accept both watching and confirming_idle states - if (this._state !== 'watching' && this._state !== 'confirming_idle') { + // Accept watching, confirming_idle, and ai_checking states + if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { return; } @@ -1439,6 +1676,20 @@ export class RespawnController extends EventEmitter { */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; + + // 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, + }); + } + this.log(`Config updated: ${JSON.stringify(config)}`); } diff --git a/src/types.ts b/src/types.ts index 37c14f2f..3836a78a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -211,6 +211,16 @@ export interface RespawnConfig { autoAcceptPrompts?: boolean; /** Delay before auto-accepting plan mode prompts when no output and no completion message (ms) */ autoAcceptDelayMs?: number; + /** Whether AI idle check is enabled */ + aiIdleCheckEnabled?: boolean; + /** Model to use for AI idle check */ + aiIdleCheckModel?: string; + /** Maximum characters of terminal buffer for AI check */ + aiIdleCheckMaxContext?: number; + /** Timeout for AI check in ms */ + aiIdleCheckTimeoutMs?: number; + /** Cooldown after WORKING verdict in ms */ + aiIdleCheckCooldownMs?: number; } /** @@ -515,6 +525,16 @@ export interface PersistedRespawnConfig { autoAcceptPrompts?: boolean; /** Delay before auto-accepting prompts (ms) */ autoAcceptDelayMs?: number; + /** Whether AI idle check is enabled */ + aiIdleCheckEnabled?: boolean; + /** Model to use for AI idle check */ + aiIdleCheckModel?: string; + /** Maximum characters of terminal buffer for AI check */ + aiIdleCheckMaxContext?: number; + /** Timeout for AI check in ms */ + aiIdleCheckTimeoutMs?: number; + /** Cooldown after WORKING verdict in ms */ + aiIdleCheckCooldownMs?: number; /** Duration in minutes if timed respawn was set */ durationMinutes?: number; } diff --git a/src/web/public/app.js b/src/web/public/app.js index daa59f74..813da8d3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -939,6 +939,36 @@ class ClaudemanApp { } }); + this.eventSource.addEventListener('respawn:aiCheckStarted', (e) => { + const data = JSON.parse(e.data); + if (data.sessionId === this.activeSessionId) { + document.getElementById('respawnStep').textContent = 'AI Check: Analyzing...'; + } + }); + + this.eventSource.addEventListener('respawn:aiCheckCompleted', (e) => { + const data = JSON.parse(e.data); + if (data.sessionId === this.activeSessionId) { + const icon = data.verdict === 'IDLE' ? '✓' : '⏳'; + document.getElementById('respawnStep').textContent = `AI Check: ${data.verdict} ${icon}`; + } + }); + + this.eventSource.addEventListener('respawn:aiCheckFailed', (e) => { + const data = JSON.parse(e.data); + if (data.sessionId === this.activeSessionId) { + document.getElementById('respawnStep').textContent = `AI Check: Error - ${data.error?.substring(0, 50)}`; + } + }); + + this.eventSource.addEventListener('respawn:aiCheckCooldown', (e) => { + const data = JSON.parse(e.data); + if (data.sessionId === this.activeSessionId && data.active) { + const remaining = Math.ceil((data.endsAt - Date.now()) / 1000); + document.getElementById('respawnStep').textContent = `AI Check: WORKING (cooldown ${remaining}s)`; + } + }); + // Respawn timer events this.eventSource.addEventListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); @@ -1904,6 +1934,26 @@ class ClaudemanApp { } else { confidenceEl.style.display = 'none'; } + + // Show AI check info if available + const aiCheckEl = document.getElementById('detectionAiCheck'); + if (aiCheckEl && detection.aiCheck) { + const ai = detection.aiCheck; + let aiText = ''; + if (ai.status === 'checking') { + aiText = 'AI: Analyzing...'; + } else if (ai.status === 'cooldown' && ai.cooldownEndsAt) { + const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000); + aiText = `AI: WORKING (${remaining}s cooldown)`; + } else if (ai.status === 'disabled') { + aiText = `AI: Disabled (${ai.disabledReason || 'errors'})`; + } else if (ai.lastVerdict) { + aiText = `AI: Last=${ai.lastVerdict}`; + if (ai.lastCheckDurationMs) aiText += ` (${Math.round(ai.lastCheckDurationMs / 1000)}s)`; + } + aiCheckEl.textContent = aiText; + aiCheckEl.style.display = aiText ? '' : 'none'; + } } showRespawnTimer() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 24989dfa..f0fa2830 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -73,6 +73,7 @@ + Cycle 0 diff --git a/src/web/server.ts b/src/web/server.ts index ec98436c..6f99fc54 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -815,6 +815,11 @@ export class WebServer extends EventEmitter { kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt, autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true, autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000, + aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true, + aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel, + aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext, + aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs, + aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs, durationMinutes: currentConfig?.durationMinutes, }; this.screenManager.updateRespawnConfig(id, merged); @@ -1566,6 +1571,11 @@ export class WebServer extends EventEmitter { kickstartPrompt: config.kickstartPrompt, autoAcceptPrompts: config.autoAcceptPrompts, autoAcceptDelayMs: config.autoAcceptDelayMs, + aiIdleCheckEnabled: config.aiIdleCheckEnabled, + aiIdleCheckModel: config.aiIdleCheckModel, + aiIdleCheckMaxContext: config.aiIdleCheckMaxContext, + aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs, + aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs, durationMinutes, }; this.screenManager.updateRespawnConfig(sessionId, persistedConfig); @@ -1818,6 +1828,22 @@ export class WebServer extends EventEmitter { this.broadcast('respawn:autoAcceptSent', { sessionId }); }); + controller.on('aiCheckStarted', () => { + this.broadcast('respawn:aiCheckStarted', { sessionId }); + }); + + controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { + this.broadcast('respawn:aiCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs }); + }); + + controller.on('aiCheckFailed', (error: string) => { + this.broadcast('respawn:aiCheckFailed', { sessionId, error }); + }); + + controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => { + this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt }); + }); + controller.on('log', (message: string) => { this.broadcast('respawn:log', { sessionId, message }); }); @@ -2401,6 +2427,11 @@ export class WebServer extends EventEmitter { noOutputTimeoutMs: savedState.respawnConfig.noOutputTimeoutMs, autoAcceptPrompts: savedState.respawnConfig.autoAcceptPrompts ?? true, autoAcceptDelayMs: savedState.respawnConfig.autoAcceptDelayMs ?? 8000, + aiIdleCheckEnabled: savedState.respawnConfig.aiIdleCheckEnabled ?? true, + aiIdleCheckModel: savedState.respawnConfig.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', + aiIdleCheckMaxContext: savedState.respawnConfig.aiIdleCheckMaxContext ?? 16000, + aiIdleCheckTimeoutMs: savedState.respawnConfig.aiIdleCheckTimeoutMs ?? 90000, + aiIdleCheckCooldownMs: savedState.respawnConfig.aiIdleCheckCooldownMs ?? 180000, }); this.respawnControllers.set(session.id, controller); this.setupRespawnListeners(session.id, controller); @@ -2430,6 +2461,11 @@ export class WebServer extends EventEmitter { kickstartPrompt: screen.respawnConfig.kickstartPrompt, autoAcceptPrompts: screen.respawnConfig.autoAcceptPrompts ?? true, autoAcceptDelayMs: screen.respawnConfig.autoAcceptDelayMs ?? 8000, + aiIdleCheckEnabled: screen.respawnConfig.aiIdleCheckEnabled ?? true, + aiIdleCheckModel: screen.respawnConfig.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', + aiIdleCheckMaxContext: screen.respawnConfig.aiIdleCheckMaxContext ?? 16000, + aiIdleCheckTimeoutMs: screen.respawnConfig.aiIdleCheckTimeoutMs ?? 90000, + aiIdleCheckCooldownMs: screen.respawnConfig.aiIdleCheckCooldownMs ?? 180000, }); this.respawnControllers.set(session.id, controller); this.setupRespawnListeners(session.id, controller); diff --git a/test/ai-idle-checker.test.ts b/test/ai-idle-checker.test.ts new file mode 100644 index 00000000..53f20389 --- /dev/null +++ b/test/ai-idle-checker.test.ts @@ -0,0 +1,495 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AiIdleChecker, AiCheckVerdict } from '../src/ai-idle-checker.js'; + +// Mock child_process +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), + spawn: vi.fn(() => ({ + unref: vi.fn(), + pid: 12345, + on: vi.fn(), + })), +})); + +// Mock fs +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => ''), + unlinkSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +import { execSync, spawn } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; + +const mockedExecSync = vi.mocked(execSync); +const mockedSpawn = vi.mocked(spawn); +const mockedExistsSync = vi.mocked(existsSync); +const mockedReadFileSync = vi.mocked(readFileSync); +const mockedWriteFileSync = vi.mocked(writeFileSync); + +describe('AiIdleChecker', () => { + let checker: AiIdleChecker; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + checker = new AiIdleChecker('test-session-1234', { + checkTimeoutMs: 5000, + cooldownMs: 3000, + errorCooldownMs: 1000, + maxConsecutiveErrors: 3, + maxContextChars: 1000, + }); + }); + + afterEach(() => { + checker.cancel(); + vi.useRealTimers(); + }); + + describe('Initialization', () => { + it('should start in ready status', () => { + expect(checker.status).toBe('ready'); + }); + + it('should not be on cooldown initially', () => { + expect(checker.isOnCooldown()).toBe(false); + }); + + it('should have clean initial state', () => { + const state = checker.getState(); + expect(state.status).toBe('ready'); + expect(state.lastVerdict).toBeNull(); + expect(state.lastReasoning).toBeNull(); + expect(state.consecutiveErrors).toBe(0); + expect(state.totalChecks).toBe(0); + expect(state.disabledReason).toBeNull(); + }); + }); + + describe('Output Parsing', () => { + it('should parse IDLE verdict', async () => { + // Set up mock to return IDLE result after polling + mockedReadFileSync.mockReturnValueOnce('') // writeFileSync creates empty file + .mockReturnValueOnce('IDLE\nSession shows completion message and prompt.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('some terminal output'); + + // First poll - empty + await vi.advanceTimersByTimeAsync(500); + // Second poll - has result + await vi.advanceTimersByTimeAsync(500); + + const result = await checkPromise; + expect(result.verdict).toBe('IDLE'); + expect(result.reasoning).toContain('completion message'); + }); + + it('should parse WORKING verdict', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('WORKING\nSpinner characters detected, still processing.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('some terminal output'); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + const result = await checkPromise; + expect(result.verdict).toBe('WORKING'); + expect(result.reasoning).toContain('Spinner'); + }); + + it('should handle lowercase verdict', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + const result = await checkPromise; + expect(result.verdict).toBe('IDLE'); + }); + + it('should return ERROR for unparseable output', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('Something unexpected happened.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + const result = await checkPromise; + expect(result.verdict).toBe('ERROR'); + expect(result.reasoning).toContain('Could not parse'); + }); + + it('should return ERROR for empty output', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + const result = await checkPromise; + expect(result.verdict).toBe('ERROR'); + expect(result.reasoning).toContain('Empty output'); + }); + }); + + describe('Screen Spawn', () => { + it('should spawn a screen session for the check', async () => { + mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__'); + + const checkPromise = checker.check('terminal output'); + await vi.advanceTimersByTimeAsync(500); + await checkPromise; + + // Verify screen was spawned with correct args + expect(mockedSpawn).toHaveBeenCalledWith( + 'screen', + expect.arrayContaining(['-dmS', expect.stringContaining('claudeman-aicheck-')]), + expect.objectContaining({ detached: true, stdio: 'ignore' }) + ); + }); + + it('should kill existing screen with same name before spawning', async () => { + mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await checkPromise; + + // First call should try to kill existing screen + expect(mockedExecSync).toHaveBeenCalledWith( + expect.stringContaining('screen -X -S claudeman-aicheck-'), + expect.any(Object) + ); + }); + + it('should create temp file for output capture', async () => { + mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await checkPromise; + + expect(mockedWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining('claudeman-aicheck-'), + '' + ); + }); + }); + + describe('Timeout', () => { + it('should timeout after checkTimeoutMs', async () => { + // Never return a result + mockedReadFileSync.mockReturnValue(''); + + const checkPromise = checker.check('output'); + + // Advance past timeout + await vi.advanceTimersByTimeAsync(5100); + + const result = await checkPromise; + expect(result.verdict).toBe('ERROR'); + expect(result.reasoning).toContain('timed out'); + }); + }); + + describe('Cancellation', () => { + it('should cancel an in-progress check', async () => { + mockedReadFileSync.mockReturnValue(''); // Never complete + + const checkPromise = checker.check('output'); + + // Cancel immediately - the resolve callback will be called synchronously + checker.cancel(); + expect(checker.status).toBe('ready'); + + const result = await checkPromise; + expect(result.verdict).toBe('ERROR'); + expect(result.reasoning).toBe('Cancelled'); + }); + + it('should clean up screen on cancel', async () => { + mockedReadFileSync.mockReturnValue(''); + + const checkPromise = checker.check('output'); + + checker.cancel(); + await checkPromise; + + // Should have tried to kill the screen (initial kill + cleanup kill) + const killCalls = mockedExecSync.mock.calls.filter( + call => typeof call[0] === 'string' && call[0].includes('quit') + ); + expect(killCalls.length).toBeGreaterThan(0); + }); + + it('should be a no-op if not checking', () => { + checker.cancel(); // Should not throw + expect(checker.status).toBe('ready'); + }); + }); + + describe('Cooldown', () => { + it('should start cooldown after WORKING verdict', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await checkPromise; + + expect(checker.status).toBe('cooldown'); + expect(checker.isOnCooldown()).toBe(true); + expect(checker.getCooldownRemainingMs()).toBeGreaterThan(0); + }); + + it('should return to ready after cooldown expires', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + expect(checker.status).toBe('cooldown'); + + // Advance past cooldown + await vi.advanceTimersByTimeAsync(3100); + + expect(checker.status).toBe('ready'); + expect(checker.isOnCooldown()).toBe(false); + }); + + it('should not start new check during cooldown', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__'); + + const firstCheck = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await firstCheck; + + // Try to check during cooldown + const result = await checker.check('output'); + expect(result.verdict).toBe('ERROR'); + expect(result.reasoning).toBe('On cooldown'); + }); + }); + + describe('Error Handling', () => { + it('should start error cooldown after parse error', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('garbage output\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + expect(checker.status).toBe('cooldown'); + expect(checker.getState().consecutiveErrors).toBe(1); + }); + + it('should disable after maxConsecutiveErrors', async () => { + for (let i = 0; i < 3; i++) { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('garbage\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + // Clear cooldown for next check (except after the last one which disables) + if (i < 2) { + await vi.advanceTimersByTimeAsync(1100); + } + } + + expect(checker.status).toBe('disabled'); + expect(checker.getState().disabledReason).toContain('3 consecutive errors'); + }); + + it('should reset error counter on successful check', async () => { + // First check: error + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('garbage\n__AICHECK_DONE__'); + const firstCheck = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await firstCheck; + expect(checker.getState().consecutiveErrors).toBe(1); + + // Wait for cooldown + await vi.advanceTimersByTimeAsync(1100); + + // Second check: success + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__'); + const secondCheck = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await secondCheck; + + expect(checker.getState().consecutiveErrors).toBe(0); + }); + + it('should return ERROR if disabled', async () => { + checker.updateConfig({ enabled: false }); + + const result = await checker.check('output'); + expect(result.verdict).toBe('ERROR'); + expect(result.reasoning).toContain('Disabled'); + }); + }); + + describe('Buffer Handling', () => { + it('should strip ANSI codes from terminal buffer', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('IDLE\n__AICHECK_DONE__'); + + const ansiBuffer = '\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m text'; + const checkPromise = checker.check(ansiBuffer); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + // Verify the spawn command was called (which means the prompt was built) + expect(mockedSpawn).toHaveBeenCalled(); + }); + + it('should trim buffer to maxContextChars', async () => { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('IDLE\n__AICHECK_DONE__'); + + // Create buffer longer than maxContextChars (1000) + const longBuffer = 'x'.repeat(2000); + const checkPromise = checker.check(longBuffer); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + // The check should complete successfully (trimming happened internally) + expect(mockedSpawn).toHaveBeenCalled(); + }); + }); + + describe('Config Updates', () => { + it('should disable when config sets enabled=false', () => { + checker.updateConfig({ enabled: false }); + expect(checker.status).toBe('disabled'); + }); + + it('should re-enable when config sets enabled=true', () => { + checker.updateConfig({ enabled: false }); + expect(checker.status).toBe('disabled'); + + checker.updateConfig({ enabled: true }); + expect(checker.status).toBe('ready'); + }); + + it('should update model in config', () => { + checker.updateConfig({ model: 'claude-sonnet-4-20250514' }); + expect(checker.getConfig().model).toBe('claude-sonnet-4-20250514'); + }); + }); + + describe('Reset', () => { + it('should clear all state on reset', async () => { + // Trigger a WORKING verdict to set state + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + expect(checker.status).toBe('cooldown'); + + // Reset + checker.reset(); + + expect(checker.status).toBe('ready'); + expect(checker.isOnCooldown()).toBe(false); + const state = checker.getState(); + expect(state.lastVerdict).toBeNull(); + expect(state.consecutiveErrors).toBe(0); + }); + }); + + describe('Events', () => { + it('should emit checkStarted event', async () => { + const handler = vi.fn(); + checker.on('checkStarted', handler); + + mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(500); + await checkPromise; + + expect(handler).toHaveBeenCalled(); + }); + + it('should emit checkCompleted event with result', async () => { + const handler = vi.fn(); + checker.on('checkCompleted', handler); + + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ + verdict: 'IDLE', + })); + }); + + it('should emit cooldownStarted event after WORKING', async () => { + const handler = vi.fn(); + checker.on('cooldownStarted', handler); + + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__'); + + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + + expect(handler).toHaveBeenCalledWith(expect.any(Number)); + }); + + it('should emit disabled event after max errors', async () => { + const handler = vi.fn(); + checker.on('disabled', handler); + + for (let i = 0; i < 3; i++) { + mockedReadFileSync.mockReturnValueOnce('') + .mockReturnValueOnce('garbage\n__AICHECK_DONE__'); + const checkPromise = checker.check('output'); + await vi.advanceTimersByTimeAsync(1000); + await checkPromise; + if (i < 2) await vi.advanceTimersByTimeAsync(1100); + } + + expect(handler).toHaveBeenCalledWith(expect.stringContaining('consecutive errors')); + }); + }); + + describe('Concurrent Check Prevention', () => { + it('should reject if already checking', async () => { + mockedReadFileSync.mockReturnValue(''); // Never complete + + const firstCheck = checker.check('output'); + + const secondResult = await checker.check('output'); + expect(secondResult.verdict).toBe('ERROR'); + expect(secondResult.reasoning).toBe('Already checking'); + + // Clean up first check + checker.cancel(); + await firstCheck; + }); + }); +}); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index 404fd894..aa60c258 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -76,6 +76,7 @@ describe('RespawnController', () => { interStepDelayMs: 50, completionConfirmMs: 50, // Short confirmation delay for testing noOutputTimeoutMs: 500, // Short fallback timeout for testing + aiIdleCheckEnabled: false, // Disable AI check for legacy tests }); }); @@ -614,6 +615,7 @@ describe('RespawnController State Transitions', () => { interStepDelayMs: 20, completionConfirmMs: 50, // Short confirmation for testing noOutputTimeoutMs: 300, // Short fallback for testing + aiIdleCheckEnabled: false, // Disable AI check for legacy tests }); }); @@ -814,6 +816,7 @@ describe('RespawnController Edge Cases', () => { interStepDelayMs: 10, completionConfirmMs: 30, // Short confirmation for testing noOutputTimeoutMs: 200, // Short fallback for testing + aiIdleCheckEnabled: false, }); expect(controller.currentCycle).toBe(0); @@ -873,6 +876,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, // Short delay for testing completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -899,6 +903,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, completionConfirmMs: 200, // Longer than autoAcceptDelay noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -924,6 +929,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -946,6 +952,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -968,6 +975,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 150, completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -998,6 +1006,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptCount = 0; @@ -1031,6 +1040,7 @@ describe('RespawnController Edge Cases', () => { completionConfirmMs: 50, interStepDelayMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -1062,6 +1072,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -1089,6 +1100,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, }); let autoAcceptFired = false; @@ -1116,3 +1128,231 @@ describe('RespawnController Edge Cases', () => { }); }); }); + +describe('RespawnController AI Idle Check', () => { + let session: MockSession; + + beforeEach(() => { + session = new MockSession(); + }); + + it('should have AI idle check enabled by default', () => { + const controller = new RespawnController(session as unknown as Session); + const config = controller.getConfig(); + expect(config.aiIdleCheckEnabled).toBe(true); + expect(config.aiIdleCheckModel).toBe('claude-opus-4-5-20251101'); + expect(config.aiIdleCheckMaxContext).toBe(16000); + expect(config.aiIdleCheckTimeoutMs).toBe(90000); + expect(config.aiIdleCheckCooldownMs).toBe(180000); + controller.stop(); + }); + + it('should include AI check state in detection status when enabled', () => { + const controller = new RespawnController(session as unknown as Session, { + aiIdleCheckEnabled: true, + }); + controller.start(); + + const detection = controller.getDetectionStatus(); + expect(detection.aiCheck).not.toBeNull(); + expect(detection.aiCheck?.status).toBe('ready'); + + controller.stop(); + }); + + it('should not include AI check state when disabled', () => { + const controller = new RespawnController(session as unknown as Session, { + aiIdleCheckEnabled: false, + }); + controller.start(); + + const detection = controller.getDetectionStatus(); + expect(detection.aiCheck).toBeNull(); + + controller.stop(); + }); + + it('should transition to ai_checking state when pre-filter is met', async () => { + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: true, + aiIdleCheckTimeoutMs: 500, // Short timeout for test + }); + + const states: string[] = []; + controller.on('stateChanged', (state: string) => states.push(state)); + + controller.start(); + + // Simulate completion message + session.simulateCompletionMessage(); + + // Wait for completion confirm timer to fire and AI check to start + await new Promise(resolve => setTimeout(resolve, 200)); + + // Should have transitioned to ai_checking + expect(states).toContain('ai_checking'); + + controller.stop(); + }); + + it('should cancel AI check when working patterns detected', async () => { + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: true, + aiIdleCheckTimeoutMs: 5000, // Long timeout so we can interrupt + }); + + controller.start(); + session.simulateCompletionMessage(); + + // Wait for AI check to start + await new Promise(resolve => setTimeout(resolve, 100)); + + // Simulate working patterns during AI check + session.simulateWorking(); + + // Should be back to watching + await new Promise(resolve => setTimeout(resolve, 50)); + expect(controller.state).toBe('watching'); + + controller.stop(); + }); + + it('should cancel AI check when substantial output arrives', async () => { + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: true, + aiIdleCheckTimeoutMs: 5000, + }); + + controller.start(); + session.simulateCompletionMessage(); + + // Wait for AI check to start + await new Promise(resolve => setTimeout(resolve, 100)); + + // Simulate substantial output during AI check + session.simulateTerminalOutput('Some meaningful output that is more than 2 chars'); + + // Should be back to watching + await new Promise(resolve => setTimeout(resolve, 50)); + expect(controller.state).toBe('watching'); + + controller.stop(); + }); + + it('should fall back to direct idle when AI check is disabled', async () => { + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + }); + + let cycleStarted = false; + controller.on('respawnCycleStarted', () => { + cycleStarted = true; + }); + + controller.start(); + session.simulateCompletionMessage(); + + // Wait for completion confirm and direct idle + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(cycleStarted).toBe(true); + controller.stop(); + }); + + it('should emit aiCheckStarted event', async () => { + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: true, + aiIdleCheckTimeoutMs: 500, + }); + + let aiCheckStarted = false; + controller.on('aiCheckStarted', () => { + aiCheckStarted = true; + }); + + controller.start(); + session.simulateCompletionMessage(); + + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(aiCheckStarted).toBe(true); + controller.stop(); + }); + + it('should update AI checker config on updateConfig', () => { + const controller = new RespawnController(session as unknown as Session, { + aiIdleCheckEnabled: true, + }); + + controller.updateConfig({ + aiIdleCheckModel: 'claude-sonnet-4-20250514', + aiIdleCheckCooldownMs: 60000, + }); + + const config = controller.getConfig(); + expect(config.aiIdleCheckModel).toBe('claude-sonnet-4-20250514'); + expect(config.aiIdleCheckCooldownMs).toBe(60000); + controller.stop(); + }); + + it('should trigger AI check via completion message path (not requiring 3s working-absent)', async () => { + // The pre-filter timer requires 3s without working patterns, + // but the completion message path (startCompletionConfirmTimer) bypasses + // the working-absent check and goes directly through tryStartAiCheck. + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: true, + aiIdleCheckTimeoutMs: 500, + }); + + const states: string[] = []; + controller.on('stateChanged', (state: string) => states.push(state)); + + controller.start(); + + // Completion message triggers the completion confirm timer + // which routes through tryStartAiCheck after silence + session.simulateCompletionMessage(); + + // Wait for completion confirm timer + AI check start + await new Promise(resolve => setTimeout(resolve, 150)); + + // Should have triggered ai_checking via completion path + expect(states).toContain('ai_checking'); + + controller.stop(); + }); + + it('should handle AI check timeout gracefully', async () => { + const controller = new RespawnController(session as unknown as Session, { + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: true, + aiIdleCheckTimeoutMs: 100, // Very short timeout + }); + + const states: string[] = []; + controller.on('stateChanged', (state: string) => states.push(state)); + + controller.start(); + session.simulateCompletionMessage(); + + // Wait for AI check to start and timeout + await new Promise(resolve => setTimeout(resolve, 300)); + + // Should return to watching after timeout (with cooldown) + expect(controller.state).toBe('watching'); + controller.stop(); + }); +});