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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 02:55:43 +01:00
co-authored by Claude Opus 4.5
parent 5ce95ee1ba
commit ae4c445601
5 changed files with 1073 additions and 20 deletions
+503
View File
@@ -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-<short> 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<AiPlanCheckConfig> = {}) {
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<AiPlanCheckResult> {
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<AiPlanCheckConfig>): 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<AiPlanCheckResult> {
// 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<AiPlanCheckResult>((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}`);
}
}
+214 -12
View File
@@ -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)}`);
}
+20
View File
@@ -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;
}
+32
View File
@@ -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);
+304 -8
View File
@@ -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();
});
});