refactor: extract AI checker base class + fix persistence + dedupe restoration

- Add AiCheckerBase abstract class to eliminate ~700 lines of duplication
  between AiIdleChecker and AiPlanChecker
- Fix missing persistence fields (completionConfirmMs, noOutputTimeoutMs)
  that caused custom timing values to be lost on server restart
- Extract restoreRespawnController() helper to dedupe session restoration
  logic between state.json and screens.json paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 08:25:51 +01:00
co-authored by Claude Opus 4.5
parent 3455b27f6f
commit fc97f6a41a
7 changed files with 708 additions and 916 deletions
+48 -426
View File
@@ -23,51 +23,25 @@
* @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';
import {
AiCheckerBase,
type AiCheckerConfigBase,
type AiCheckerResultBase,
type AiCheckerStateBase,
type AiCheckerStatus,
} from './ai-checker-base.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 interface AiPlanCheckConfig extends AiCheckerConfigBase {}
export type AiPlanCheckStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error';
// Re-export the status type for backwards compatibility
export type AiPlanCheckStatus = AiCheckerStatus;
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
export interface AiPlanCheckResult {
verdict: AiPlanCheckVerdict;
reasoning: string;
durationMs: number;
}
export interface AiPlanCheckResult extends AiCheckerResultBase<AiPlanCheckVerdict> {}
export interface AiPlanCheckState {
status: AiPlanCheckStatus;
lastVerdict: AiPlanCheckVerdict | null;
lastReasoning: string | null;
lastCheckDurationMs: number | null;
cooldownEndsAt: number | null;
consecutiveErrors: number;
totalChecks: number;
disabledReason: string | null;
}
export interface AiPlanCheckState extends AiCheckerStateBase<AiPlanCheckVerdict> {}
/** Events emitted by AiPlanChecker */
export interface AiPlanCheckerEvents {
@@ -92,15 +66,6 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
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;
@@ -134,397 +99,54 @@ Then optionally explain briefly why.`;
* 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 checkPromptFile: 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;
export class AiPlanChecker extends AiCheckerBase<
AiPlanCheckVerdict,
AiPlanCheckConfig,
AiPlanCheckResult,
AiPlanCheckState
> {
protected readonly screenNamePrefix = 'claudeman-plancheck-';
protected readonly doneMarker = '__PLANCHECK_DONE__';
protected readonly tempFilePrefix = 'claudeman-plancheck';
protected readonly logPrefix = '[AiPlanChecker]';
protected readonly checkDescription = 'AI plan check';
constructor(sessionId: string, config: Partial<AiPlanCheckConfig> = {}) {
super();
this.sessionId = sessionId;
// Filter out undefined values to prevent overwriting defaults
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<AiPlanCheckConfig>;
this.config = { ...DEFAULT_PLAN_CHECK_CONFIG, ...filteredConfig };
super(sessionId, DEFAULT_PLAN_CHECK_CONFIG, config);
}
/** Get the current status */
get status(): AiPlanCheckStatus {
return this._status;
protected buildPrompt(terminalBuffer: string): string {
return AI_PLAN_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
}
/** 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;
// Clear poll/timeout timers first to prevent race condition where
// the poll timer fires between setting checkCancelled and cleanup
this.cleanupCheck();
// Resolve the pending promise after cleanup
if (this.checkResolve) {
this.checkResolve({ verdict: 'ERROR', reasoning: 'Cancelled', durationMs: Date.now() - this.checkStartTime });
this.checkResolve = null;
}
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 {
// Filter out undefined values to prevent overwriting existing config
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<AiPlanCheckConfig>;
this.config = { ...this.config, ...filteredConfig };
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 files and screen name
const shortId = this.sessionId.slice(0, 8);
const timestamp = Date.now();
this.checkTempFile = join(tmpdir(), `claudeman-plancheck-${shortId}-${timestamp}.txt`);
this.checkPromptFile = join(tmpdir(), `claudeman-plancheck-prompt-${shortId}-${timestamp}.txt`);
this.checkScreenName = `claudeman-plancheck-${shortId}`;
// Ensure output temp file exists (empty) so we can poll it
writeFileSync(this.checkTempFile, '');
// Write prompt to file to avoid E2BIG error (argument list too long)
// The prompt can be 8KB+ which exceeds shell argument limits
writeFileSync(this.checkPromptFile, prompt);
// Build the command - read prompt from file via stdin to avoid argument size limits
const modelArg = `--model ${this.config.model}`;
const augmentedPath = getAugmentedPath();
const claudeCmd = `cat "${this.checkPromptFile}" | claude -p ${modelArg} --output-format text`;
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${DONE_MARKER}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
// 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)
protected parseVerdict(output: string): { verdict: AiPlanCheckVerdict; reasoning: string } | null {
const match = output.match(VERDICT_PATTERN);
if (!match) {
return { verdict: 'ERROR', reasoning: `Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs };
}
if (!match) return null;
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 };
}
protected getPositiveVerdict(): AiPlanCheckVerdict {
return 'PLAN_MODE';
}
protected getNegativeVerdict(): AiPlanCheckVerdict {
return 'NOT_PLAN_MODE';
}
protected getErrorVerdict(): AiPlanCheckVerdict {
return 'ERROR';
}
protected createErrorResult(reasoning: string, durationMs: number): AiPlanCheckResult {
return { verdict: 'ERROR', reasoning, durationMs };
}
protected createResult(verdict: AiPlanCheckVerdict, reasoning: string, durationMs: number): AiPlanCheckResult {
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 files
if (this.checkTempFile) {
try {
if (existsSync(this.checkTempFile)) {
unlinkSync(this.checkTempFile);
}
} catch {
// Best effort cleanup
}
this.checkTempFile = null;
}
if (this.checkPromptFile) {
try {
if (existsSync(this.checkPromptFile)) {
unlinkSync(this.checkPromptFile);
}
} catch {
// Best effort cleanup
}
this.checkPromptFile = 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}`);
}
}