From ae4c44560105e89845edb97bc762b38e139c939c Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 25 Jan 2026 02:55:43 +0100 Subject: [PATCH] feat: add AI-powered plan mode detection for auto-accept Adds a two-stage gate before auto-accepting plan mode prompts: 1. Strict regex pre-filter - checks for numbered options + selector 2. AI confirmation - spawns Opus to classify as PLAN_MODE or NOT_PLAN_MODE This prevents spurious Enter key presses when Claude is paused mid-thought or experiencing network lag, rather than showing a plan approval prompt. New files: - src/ai-plan-checker.ts: AI checker following ai-idle-checker.ts pattern Config fields added to RespawnConfig: - aiPlanCheckEnabled (default: true) - aiPlanCheckModel (default: claude-opus-4-5-20251101) - aiPlanCheckMaxContext (default: 8000) - aiPlanCheckTimeoutMs (default: 60000) - aiPlanCheckCooldownMs (default: 30000) New events: planCheckStarted, planCheckCompleted, planCheckFailed Co-Authored-By: Claude Opus 4.5 --- src/ai-plan-checker.ts | 503 ++++++++++++++++++++++++++++++++ src/respawn-controller.ts | 226 +++++++++++++- src/types.ts | 20 ++ src/web/server.ts | 32 ++ test/respawn-controller.test.ts | 312 +++++++++++++++++++- 5 files changed, 1073 insertions(+), 20 deletions(-) create mode 100644 src/ai-plan-checker.ts diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts new file mode 100644 index 00000000..90e4917a --- /dev/null +++ b/src/ai-plan-checker.ts @@ -0,0 +1,503 @@ +/** + * @fileoverview AI-Powered Plan Mode Checker for Auto-Accept + * + * Spawns a fresh Claude CLI session to analyze terminal output and determine + * if Claude Code is showing a plan mode approval prompt (numbered selection menu). + * Used as a confirmation gate before auto-accepting prompts. + * + * ## How It Works + * + * 1. Generate temp file path for output capture + * 2. Spawn screen: `screen -dmS claudeman-plancheck- bash -c 'claude -p ...'` + * 3. Poll the temp file every 500ms for `__PLANCHECK_DONE__` marker + * 4. Parse the file content for PLAN_MODE/NOT_PLAN_MODE on the first line + * 5. Kill screen and delete temp file + * + * ## Error Handling + * + * - Screen spawn fails: 30s cooldown, increment error counter + * - Check times out (60s): Kill screen, 30s cooldown + * - Can't parse verdict: Treat as NOT_PLAN_MODE, 30s cooldown + * - 3 consecutive errors: Disable AI plan check + * + * @module ai-plan-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'; +import { getAugmentedPath } from './session.js'; + +// ========== Types ========== + +export interface AiPlanCheckConfig { + /** Whether AI plan check is enabled */ + enabled: boolean; + /** Model to use for the check (thinking enabled by default with opus) */ + model: string; + /** Maximum characters of terminal buffer to send */ + maxContextChars: number; + /** Timeout for the check in ms */ + checkTimeoutMs: number; + /** Cooldown after NOT_PLAN_MODE verdict in ms */ + cooldownMs: number; + /** Cooldown after errors in ms */ + errorCooldownMs: number; + /** Max consecutive errors before disabling */ + maxConsecutiveErrors: number; +} + +export type AiPlanCheckStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error'; +export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR'; + +export interface AiPlanCheckResult { + verdict: AiPlanCheckVerdict; + reasoning: string; + durationMs: number; +} + +export interface AiPlanCheckState { + status: AiPlanCheckStatus; + lastVerdict: AiPlanCheckVerdict | null; + lastReasoning: string | null; + lastCheckDurationMs: number | null; + cooldownEndsAt: number | null; + consecutiveErrors: number; + totalChecks: number; + disabledReason: string | null; +} + +/** Events emitted by AiPlanChecker */ +export interface AiPlanCheckerEvents { + checkStarted: () => void; + checkCompleted: (result: AiPlanCheckResult) => void; + checkFailed: (error: string) => void; + cooldownStarted: (endsAt: number) => void; + cooldownEnded: () => void; + disabled: (reason: string) => void; + log: (message: string) => void; +} + +// ========== Constants ========== + +const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = { + enabled: true, + model: 'claude-opus-4-5-20251101', + maxContextChars: 8000, + checkTimeoutMs: 60000, + cooldownMs: 30000, + errorCooldownMs: 30000, + 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 = '__PLANCHECK_DONE__'; + +/** Pattern to match PLAN_MODE or NOT_PLAN_MODE as the first word(s) of output */ +const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i; + +/** The prompt sent to the AI plan checker */ +const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not. + +A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics: +- A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own") +- A selection indicator arrow (❯ or >) pointing to one of the options +- Text asking for approval like "Would you like to proceed?" or "Ready to implement?" +- The prompt appears at the BOTTOM of the output (most recent content) + +NOT a plan mode prompt: +- Claude actively working (spinners, "Thinking", tool execution) +- A completed response with no selection menu +- An AskUserQuestion/elicitation dialog (different format, free-text input) +- Network lag or mid-output pause +- Any state without a visible numbered selection menu + +Terminal output (most recent at bottom): +--- +{TERMINAL_BUFFER} +--- + +Answer with EXACTLY one of these on the first line: PLAN_MODE or NOT_PLAN_MODE +Then optionally explain briefly why.`; + +// ========== AiPlanChecker Class ========== + +/** + * Manages AI-powered plan mode detection by spawning a fresh Claude CLI session + * to analyze terminal output and confirm plan mode approval prompts. + */ +export class AiPlanChecker extends EventEmitter { + private config: AiPlanCheckConfig; + private sessionId: string; + + // State + private _status: AiPlanCheckStatus = 'ready'; + private lastVerdict: AiPlanCheckVerdict | 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: AiPlanCheckResult) => void) | null = null; + + constructor(sessionId: string, config: Partial = {}) { + super(); + this.sessionId = sessionId; + this.config = { ...DEFAULT_PLAN_CHECK_CONFIG, ...config }; + } + + /** Get the current status */ + get status(): AiPlanCheckStatus { + return this._status; + } + + /** Get comprehensive state for UI display */ + getState(): AiPlanCheckState { + 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 plan 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 plan 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 === 'PLAN_MODE') { + this.consecutiveErrors = 0; + this._status = 'ready'; + this.log(`AI plan check verdict: PLAN_MODE (${result.durationMs}ms) - ${result.reasoning}`); + } else if (result.verdict === 'NOT_PLAN_MODE') { + this.consecutiveErrors = 0; + this.startCooldown(this.config.cooldownMs); + this.log(`AI plan check verdict: NOT_PLAN_MODE (${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: AiPlanCheckResult = { + 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 plan 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 */ + 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(): AiPlanCheckConfig { + 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_PLAN_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-plancheck-${shortId}-${timestamp}.txt`); + this.checkScreenName = `claudeman-plancheck-${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 augmentedPath = getAugmentedPath(); + const claudeCmd = `claude -p ${modelArg} --output-format text '${escapedPrompt}'`; + const fullCmd = `export PATH="${augmentedPath}"; ${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 plan 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 plan check timed out after ${this.config.checkTimeoutMs}ms`)); + } + }, this.config.checkTimeoutMs); + }); + } + + private parseOutput(content: string, durationMs: number): AiPlanCheckResult { + // Remove the done marker and trim + const output = content.replace(DONE_MARKER, '').trim(); + + if (!output) { + return { verdict: 'ERROR', reasoning: 'Empty output from AI plan check', durationMs }; + } + + // Look for PLAN_MODE or NOT_PLAN_MODE as the first word(s) + 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 'PLAN_MODE' | 'NOT_PLAN_MODE'; + // 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 plan 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 plan check disabled: ${reason}`); + this.emit('disabled', reason); + } + + private log(message: string): void { + this.emit('log', `[AiPlanChecker] ${message}`); + } +} diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 819214fe..3ebbf91d 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -37,6 +37,7 @@ 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 ========== @@ -70,6 +71,12 @@ const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i; */ 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. @@ -327,6 +334,38 @@ export interface RespawnConfig { * @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; } /** @@ -364,6 +403,12 @@ export interface RespawnEvents { 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; /** Error occurred */ error: (error: Error) => void; /** Debug log message */ @@ -387,6 +432,11 @@ const DEFAULT_CONFIG: RespawnConfig = { 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 }; /** @@ -499,6 +549,12 @@ export class RespawnController extends EventEmitter { /** 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; @@ -564,7 +620,15 @@ export class RespawnController extends EventEmitter { 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(); } /** Wire up AI checker events to controller events */ @@ -590,6 +654,17 @@ export class RespawnController extends EventEmitter { }); } + /** 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 @@ -785,6 +860,7 @@ export class RespawnController extends EventEmitter { } this.aiChecker.reset(); + this.planChecker.reset(); this.setState('watching'); this.setupTerminalListener(); this.startDetectionUpdates(); @@ -806,6 +882,7 @@ export class RespawnController extends EventEmitter { stop(): void { this.log('Stopping respawn controller'); this.aiChecker.cancel(); + this.planChecker.cancel(); this.clearTimers(); this.stopDetectionUpdates(); this.setState('stopped'); @@ -888,6 +965,12 @@ export class RespawnController extends EventEmitter { 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) { @@ -917,6 +1000,12 @@ export class RespawnController extends EventEmitter { 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'); @@ -1405,17 +1494,14 @@ export class RespawnController extends EventEmitter { /** * Attempt to auto-accept a plan mode prompt by sending Enter. - * Only fires when: - * - In 'watching' state (not mid-cycle) - * - No completion message was detected (Claude is waiting for input, not truly idle) - * - No elicitation dialog was detected (not an AskUserQuestion prompt) - * - autoAcceptPrompts is enabled + * 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 * - * This handles Claude's plan mode (waiting for approval) by pressing Enter - * to accept the plan. It does NOT auto-accept AskUserQuestion prompts - - * those require explicit user interaction. + * 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) @@ -1436,13 +1522,116 @@ export class RespawnController extends EventEmitter { return; } - const msSinceOutput = Date.now() - this.lastOutputTime; - this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, no completion message, no elicitation)`); + // 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; + } - // Send Enter to accept the plan + // 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.log('Starting AI plan check for auto-accept confirmation'); + this.emit('planCheckStarted'); + + this.planChecker.check(buffer).then((result) => { + // Discard stale result if new output arrived during check + if (this.lastOutputTime > this.planCheckStartTime) { + this.log('Plan check result discarded (output arrived during check)'); + return; + } + + if (result.verdict === 'PLAN_MODE') { + this.emit('planCheckCompleted', result); + this.log(`Plan check confirmed PLAN_MODE, sending Enter`); + this.sendAutoAcceptEnter(); + } else if (result.verdict === 'NOT_PLAN_MODE') { + this.emit('planCheckCompleted', result); + this.log(`Plan check says NOT_PLAN_MODE, skipping auto-accept (cooldown ${this.config.aiPlanCheckCooldownMs}ms)`); + } else { + // ERROR verdict + this.emit('planCheckFailed', result.reasoning); + this.log(`Plan check error: ${result.reasoning}, skipping auto-accept`); + } + }).catch((err) => { + const errorMsg = err instanceof Error ? err.message : String(err); + this.emit('planCheckFailed', errorMsg); + this.log(`Plan check error: ${errorMsg}, skipping auto-accept`); + }); + } + + /** + * 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)`); this.session.writeViaScreen('\r'); this.emit('autoAcceptSent'); - // Reset so we don't keep spamming Enter if Claude doesn't respond this.hasReceivedOutput = false; } @@ -1711,6 +1900,19 @@ export class RespawnController extends EventEmitter { }); } + // 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)}`); } diff --git a/src/types.ts b/src/types.ts index 3836a78a..aa070b57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -221,6 +221,16 @@ export interface RespawnConfig { aiIdleCheckTimeoutMs?: number; /** Cooldown after WORKING verdict in ms */ aiIdleCheckCooldownMs?: number; + /** Whether AI plan mode check is enabled for auto-accept */ + aiPlanCheckEnabled?: boolean; + /** Model to use for AI plan mode check */ + aiPlanCheckModel?: string; + /** Maximum characters of terminal buffer for plan check */ + aiPlanCheckMaxContext?: number; + /** Timeout for AI plan check in ms */ + aiPlanCheckTimeoutMs?: number; + /** Cooldown after NOT_PLAN_MODE verdict in ms */ + aiPlanCheckCooldownMs?: number; } /** @@ -535,6 +545,16 @@ export interface PersistedRespawnConfig { aiIdleCheckTimeoutMs?: number; /** Cooldown after WORKING verdict in ms */ aiIdleCheckCooldownMs?: number; + /** Whether AI plan mode check is enabled for auto-accept */ + aiPlanCheckEnabled?: boolean; + /** Model to use for AI plan mode check */ + aiPlanCheckModel?: string; + /** Maximum characters of terminal buffer for plan check */ + aiPlanCheckMaxContext?: number; + /** Timeout for AI plan check in ms */ + aiPlanCheckTimeoutMs?: number; + /** Cooldown after NOT_PLAN_MODE verdict in ms */ + aiPlanCheckCooldownMs?: number; /** Duration in minutes if timed respawn was set */ durationMinutes?: number; } diff --git a/src/web/server.ts b/src/web/server.ts index 6f99fc54..f385ab3e 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -820,6 +820,11 @@ export class WebServer extends EventEmitter { aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext, aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs, aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs, + aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, + aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel, + aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext, + aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs, + aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs, durationMinutes: currentConfig?.durationMinutes, }; this.screenManager.updateRespawnConfig(id, merged); @@ -1576,6 +1581,11 @@ export class WebServer extends EventEmitter { aiIdleCheckMaxContext: config.aiIdleCheckMaxContext, aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs, aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs, + aiPlanCheckEnabled: config.aiPlanCheckEnabled, + aiPlanCheckModel: config.aiPlanCheckModel, + aiPlanCheckMaxContext: config.aiPlanCheckMaxContext, + aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs, + aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs, durationMinutes, }; this.screenManager.updateRespawnConfig(sessionId, persistedConfig); @@ -1844,6 +1854,18 @@ export class WebServer extends EventEmitter { this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt }); }); + controller.on('planCheckStarted', () => { + this.broadcast('respawn:planCheckStarted', { sessionId }); + }); + + controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { + this.broadcast('respawn:planCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs }); + }); + + controller.on('planCheckFailed', (error: string) => { + this.broadcast('respawn:planCheckFailed', { sessionId, error }); + }); + controller.on('log', (message: string) => { this.broadcast('respawn:log', { sessionId, message }); }); @@ -2432,6 +2454,11 @@ export class WebServer extends EventEmitter { aiIdleCheckMaxContext: savedState.respawnConfig.aiIdleCheckMaxContext ?? 16000, aiIdleCheckTimeoutMs: savedState.respawnConfig.aiIdleCheckTimeoutMs ?? 90000, aiIdleCheckCooldownMs: savedState.respawnConfig.aiIdleCheckCooldownMs ?? 180000, + aiPlanCheckEnabled: savedState.respawnConfig.aiPlanCheckEnabled ?? true, + aiPlanCheckModel: savedState.respawnConfig.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', + aiPlanCheckMaxContext: savedState.respawnConfig.aiPlanCheckMaxContext ?? 8000, + aiPlanCheckTimeoutMs: savedState.respawnConfig.aiPlanCheckTimeoutMs ?? 60000, + aiPlanCheckCooldownMs: savedState.respawnConfig.aiPlanCheckCooldownMs ?? 30000, }); this.respawnControllers.set(session.id, controller); this.setupRespawnListeners(session.id, controller); @@ -2466,6 +2493,11 @@ export class WebServer extends EventEmitter { aiIdleCheckMaxContext: screen.respawnConfig.aiIdleCheckMaxContext ?? 16000, aiIdleCheckTimeoutMs: screen.respawnConfig.aiIdleCheckTimeoutMs ?? 90000, aiIdleCheckCooldownMs: screen.respawnConfig.aiIdleCheckCooldownMs ?? 180000, + aiPlanCheckEnabled: screen.respawnConfig.aiPlanCheckEnabled ?? true, + aiPlanCheckModel: screen.respawnConfig.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', + aiPlanCheckMaxContext: screen.respawnConfig.aiPlanCheckMaxContext ?? 8000, + aiPlanCheckTimeoutMs: screen.respawnConfig.aiPlanCheckTimeoutMs ?? 60000, + aiPlanCheckCooldownMs: screen.respawnConfig.aiPlanCheckCooldownMs ?? 30000, }); this.respawnControllers.set(session.id, controller); this.setupRespawnListeners(session.id, controller); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index aa60c258..027ff179 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -877,6 +877,7 @@ describe('RespawnController Edge Cases', () => { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, // Pre-filter only for this test }); let autoAcceptFired = false; @@ -886,8 +887,8 @@ describe('RespawnController Edge Cases', () => { autoAcceptController.start(); - // Simulate output (e.g., plan content), then silence - session.simulateTerminalOutput('Plan: Here is my implementation plan...'); + // Simulate plan mode UI with numbered options and selector + session.simulateTerminalOutput('Would you like to proceed?\n❯ 1. Yes\n 2. No\n'); // Wait for autoAcceptDelayMs to expire await new Promise(resolve => setTimeout(resolve, 200)); @@ -976,6 +977,7 @@ describe('RespawnController Edge Cases', () => { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, }); let autoAcceptFired = false; @@ -984,7 +986,7 @@ describe('RespawnController Edge Cases', () => { }); autoAcceptController.start(); - session.simulateTerminalOutput('First output'); + session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); // Wait 100ms (less than 150ms delay), then send more output await new Promise(resolve => setTimeout(resolve, 100)); @@ -1007,6 +1009,7 @@ describe('RespawnController Edge Cases', () => { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, }); let autoAcceptCount = 0; @@ -1015,7 +1018,7 @@ describe('RespawnController Edge Cases', () => { }); autoAcceptController.start(); - session.simulateTerminalOutput('Plan waiting...'); + session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); // Wait for first auto-accept await new Promise(resolve => setTimeout(resolve, 200)); @@ -1025,8 +1028,8 @@ describe('RespawnController Edge Cases', () => { await new Promise(resolve => setTimeout(resolve, 200)); expect(autoAcceptCount).toBe(1); - // New output comes in, then silence again - should fire again - session.simulateTerminalOutput('Another question...'); + // New output comes in (plan mode again), then silence again - should fire again + session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); await new Promise(resolve => setTimeout(resolve, 200)); expect(autoAcceptCount).toBe(2); @@ -1101,6 +1104,7 @@ describe('RespawnController Edge Cases', () => { completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, }); let autoAcceptFired = false; @@ -1117,8 +1121,8 @@ describe('RespawnController Edge Cases', () => { // Working pattern clears the elicitation flag (new turn started) session.simulateTerminalOutput('Thinking'); - // New silence after work - plan mode approval - session.simulateTerminalOutput('Plan: Here is the plan...'); + // New silence after work - plan mode approval with plan mode UI + session.simulateTerminalOutput('❯ 1. Yes\n 2. No\n'); await new Promise(resolve => setTimeout(resolve, 200)); @@ -1356,3 +1360,295 @@ describe('RespawnController AI Idle Check', () => { controller.stop(); }); }); + +describe('RespawnController AI Plan Mode Check', () => { + let session: MockSession; + + beforeEach(() => { + session = new MockSession(); + }); + + it('should have AI plan check enabled by default', () => { + const controller = new RespawnController(session as unknown as Session); + const config = controller.getConfig(); + expect(config.aiPlanCheckEnabled).toBe(true); + expect(config.aiPlanCheckModel).toBe('claude-opus-4-5-20251101'); + expect(config.aiPlanCheckMaxContext).toBe(8000); + expect(config.aiPlanCheckTimeoutMs).toBe(60000); + expect(config.aiPlanCheckCooldownMs).toBe(30000); + controller.stop(); + }); + + it('should block auto-accept when buffer has no plan mode patterns (pre-filter)', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, // Test pre-filter only + }); + + let autoAcceptFired = false; + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + controller.start(); + + // Output without plan mode patterns (no numbered list, no selector) + session.simulateTerminalOutput('Claude is just thinking about something...\nSome regular output here.'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // Pre-filter should block - no plan mode patterns found + expect(autoAcceptFired).toBe(false); + controller.stop(); + }); + + it('should pass pre-filter when buffer contains numbered list + selector', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, // Test pre-filter only (no AI) + }); + + let autoAcceptFired = false; + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + controller.start(); + + // Output WITH plan mode patterns + session.simulateTerminalOutput( + 'Would you like to proceed with this plan?\n' + + '❯ 1. Yes\n' + + ' 2. No\n' + + ' 3. Type your own\n' + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // Pre-filter should pass and send Enter (AI disabled) + expect(autoAcceptFired).toBe(true); + controller.stop(); + }); + + it('should block pre-filter when working patterns are in the tail', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, + }); + + let autoAcceptFired = false; + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + controller.start(); + + // Plan mode patterns BUT also has working patterns (spinner) in the tail + session.simulateTerminalOutput( + '❯ 1. Yes\n' + + ' 2. No\n' + + 'Thinking ⠋\n' + ); + + // Wait for autoAcceptDelay - but working pattern resets the timer + // so we need to wait longer and check after working pattern was consumed + await new Promise(resolve => setTimeout(resolve, 200)); + + // Should NOT fire because working patterns detected resets timer + // (the working pattern in handleTerminalData clears timers) + expect(autoAcceptFired).toBe(false); + controller.stop(); + }); + + it('should emit planCheckStarted when AI plan check is triggered', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: true, + aiPlanCheckTimeoutMs: 500, // Short timeout for test + }); + + let planCheckStarted = false; + controller.on('planCheckStarted', () => { + planCheckStarted = true; + }); + + controller.start(); + + // Output with plan mode patterns to pass pre-filter + session.simulateTerminalOutput( + 'Would you like to proceed?\n' + + '❯ 1. Yes\n' + + ' 2. No\n' + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // Plan check should have been started (pre-filter passed, AI enabled) + expect(planCheckStarted).toBe(true); + controller.stop(); + }); + + it('should cancel plan check when new output arrives', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: true, + aiPlanCheckTimeoutMs: 5000, // Long timeout so we can interrupt + }); + + let planCheckStarted = false; + let autoAcceptFired = false; + controller.on('planCheckStarted', () => { + planCheckStarted = true; + }); + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + controller.start(); + + // Trigger plan check + session.simulateTerminalOutput( + '❯ 1. Yes\n' + + ' 2. No\n' + ); + await new Promise(resolve => setTimeout(resolve, 150)); + expect(planCheckStarted).toBe(true); + + // New output arrives - should cancel plan check (stale) + session.simulateTerminalOutput('New output from Claude...'); + + await new Promise(resolve => setTimeout(resolve, 100)); + + // Auto-accept should NOT have fired (check was cancelled) + expect(autoAcceptFired).toBe(false); + controller.stop(); + }); + + it('should discard stale plan check result (output during check)', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: true, + aiPlanCheckTimeoutMs: 5000, + }); + + let autoAcceptFired = false; + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + controller.start(); + + // Plan mode patterns to trigger check + session.simulateTerminalOutput( + '❯ 1. Yes\n 2. No\n' + ); + await new Promise(resolve => setTimeout(resolve, 150)); + + // Output arrives during check - result should be discarded + session.simulateTerminalOutput('Claude started working again'); + + // Wait for any pending check to complete + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(autoAcceptFired).toBe(false); + controller.stop(); + }); + + it('should fall back to pre-filter-only when AI plan check is disabled', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, // Disabled - pre-filter only + }); + + let autoAcceptFired = false; + let planCheckStarted = false; + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + controller.on('planCheckStarted', () => { + planCheckStarted = true; + }); + + controller.start(); + + // Plan mode patterns + session.simulateTerminalOutput( + '❯ 1. Yes\n 2. No\n' + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // Should send Enter directly (no AI check) + expect(planCheckStarted).toBe(false); + expect(autoAcceptFired).toBe(true); + controller.stop(); + }); + + it('should update plan checker config on updateConfig', () => { + const controller = new RespawnController(session as unknown as Session, { + aiPlanCheckEnabled: true, + }); + + controller.updateConfig({ + aiPlanCheckModel: 'claude-sonnet-4-20250514', + aiPlanCheckCooldownMs: 60000, + }); + + const config = controller.getConfig(); + expect(config.aiPlanCheckModel).toBe('claude-sonnet-4-20250514'); + expect(config.aiPlanCheckCooldownMs).toBe(60000); + controller.stop(); + }); + + it('should not auto-accept if pre-filter passes but no output received yet', async () => { + const controller = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, + }); + + let autoAcceptFired = false; + controller.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + controller.start(); + + // Don't send any output - hasReceivedOutput should guard + await new Promise(resolve => setTimeout(resolve, 200)); + + expect(autoAcceptFired).toBe(false); + controller.stop(); + }); +});