From fc97f6a41aafe918e374ec4fc21ee5bace475209 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 25 Jan 2026 08:25:51 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- package.json | 2 +- src/ai-checker-base.ts | 554 +++++++++++++++++++++++++++++++++++++++++ src/ai-idle-checker.ts | 474 ++++------------------------------- src/ai-plan-checker.ts | 474 ++++------------------------------- src/types.ts | 4 + src/web/server.ts | 114 ++++----- 7 files changed, 708 insertions(+), 916 deletions(-) create mode 100644 src/ai-checker-base.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1bacfbdc..78aff0c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1348 +**Version**: 0.1349 **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty diff --git a/package.json b/package.json index 3780262c..033ab6b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1348", + "version": "0.1349", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts new file mode 100644 index 00000000..c89862a2 --- /dev/null +++ b/src/ai-checker-base.ts @@ -0,0 +1,554 @@ +/** + * @fileoverview Base Class for AI Checker Components + * + * Provides shared functionality for AI-powered checkers that spawn fresh Claude CLI + * sessions to analyze terminal output. This base class handles: + * - Screen session spawning and cleanup + * - Temp file management for output capture + * - Polling for completion markers + * - Cooldown management + * - Error handling and consecutive error tracking + * - Event emission for state changes + * + * Subclasses implement: + * - `screenNamePrefix`: Prefix for screen session names (e.g., 'claudeman-aicheck-') + * - `doneMarker`: Completion marker in output file (e.g., '__AICHECK_DONE__') + * - `tempFilePrefix`: Prefix for temp files (e.g., 'claudeman-aicheck') + * - `logPrefix`: Prefix for log messages (e.g., '[AiIdleChecker]') + * - `buildPrompt()`: Build the prompt from terminal buffer + * - `parseVerdict()`: Parse the verdict from AI output + * - `getPositiveVerdict()`: Return the "positive" verdict that doesn't trigger cooldown + * - `defaultConfig`: Default configuration values + * + * @module ai-checker-base + */ + +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 ========== + +/** Base configuration shared by all AI checkers */ +export interface AiCheckerConfigBase { + /** Whether the checker is enabled */ + enabled: boolean; + /** Model to use for the check */ + model: string; + /** Maximum characters of terminal buffer to send */ + maxContextChars: number; + /** Timeout for the check in ms */ + checkTimeoutMs: number; + /** Cooldown after negative verdict in ms */ + cooldownMs: number; + /** Cooldown after errors in ms */ + errorCooldownMs: number; + /** Max consecutive errors before disabling */ + maxConsecutiveErrors: number; +} + +/** Status values shared by all AI checkers */ +export type AiCheckerStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error'; + +/** Base result structure for all AI checkers */ +export interface AiCheckerResultBase { + verdict: V; + reasoning: string; + durationMs: number; +} + +/** Base state structure for all AI checkers */ +export interface AiCheckerStateBase { + status: AiCheckerStatus; + lastVerdict: V | null; + lastReasoning: string | null; + lastCheckDurationMs: number | null; + cooldownEndsAt: number | null; + consecutiveErrors: number; + totalChecks: number; + disabledReason: string | null; +} + +/** Events emitted by AI checkers */ +export interface AiCheckerEvents { + checkStarted: () => void; + checkCompleted: (result: R) => void; + checkFailed: (error: string) => void; + cooldownStarted: (endsAt: number) => void; + cooldownEnded: () => void; + disabled: (reason: string) => void; + log: (message: string) => void; +} + +// ========== Constants ========== + +/** 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; + +// ========== AiCheckerBase Class ========== + +/** + * Abstract base class for AI-powered checkers. + * Handles spawning Claude CLI in a screen session to analyze terminal output. + * + * @template V - The verdict type (e.g., 'IDLE' | 'WORKING' | 'ERROR') + * @template C - The configuration type + * @template R - The result type + * @template S - The state type + */ +export abstract class AiCheckerBase< + V extends string, + C extends AiCheckerConfigBase, + R extends AiCheckerResultBase, + S extends AiCheckerStateBase +> extends EventEmitter { + protected config: C; + protected sessionId: string; + + // State + protected _status: AiCheckerStatus = 'ready'; + protected lastVerdict: V | null = null; + protected lastReasoning: string | null = null; + protected lastCheckDurationMs: number | null = null; + protected cooldownEndsAt: number | null = null; + protected cooldownTimer: NodeJS.Timeout | null = null; + protected consecutiveErrors: number = 0; + protected totalChecks: number = 0; + protected disabledReason: string | null = null; + + // Active check state + protected checkScreenName: string | null = null; + protected checkTempFile: string | null = null; + protected checkPromptFile: string | null = null; + protected checkPollTimer: NodeJS.Timeout | null = null; + protected checkTimeoutTimer: NodeJS.Timeout | null = null; + protected checkStartTime: number = 0; + protected checkCancelled: boolean = false; + protected checkResolve: ((result: R) => void) | null = null; + + // ========== Abstract Properties ========== + + /** Prefix for screen session names (e.g., 'claudeman-aicheck-') */ + protected abstract readonly screenNamePrefix: string; + + /** Marker written to temp file when check is complete */ + protected abstract readonly doneMarker: string; + + /** Prefix for temp files (e.g., 'claudeman-aicheck') */ + protected abstract readonly tempFilePrefix: string; + + /** Prefix for log messages (e.g., '[AiIdleChecker]') */ + protected abstract readonly logPrefix: string; + + /** Description for log messages (e.g., 'AI idle check', 'AI plan check') */ + protected abstract readonly checkDescription: string; + + // ========== Abstract Methods ========== + + /** + * Build the prompt to send to Claude. + * @param terminalBuffer - The trimmed and stripped terminal buffer + * @returns The complete prompt string + */ + protected abstract buildPrompt(terminalBuffer: string): string; + + /** + * Parse the verdict from Claude's output. + * @param output - The raw output from Claude (without done marker) + * @returns The parsed verdict and reasoning, or null if unparseable + */ + protected abstract parseVerdict(output: string): { verdict: V; reasoning: string } | null; + + /** + * Get the "positive" verdict that indicates success and doesn't trigger cooldown. + * For idle checker this is 'IDLE', for plan checker this is 'PLAN_MODE'. + */ + protected abstract getPositiveVerdict(): V; + + /** + * Get the "negative" verdict that triggers cooldown. + * For idle checker this is 'WORKING', for plan checker this is 'NOT_PLAN_MODE'. + */ + protected abstract getNegativeVerdict(): V; + + /** + * Get the error verdict value. + */ + protected abstract getErrorVerdict(): V; + + /** + * Create an error result. + */ + protected abstract createErrorResult(reasoning: string, durationMs: number): R; + + /** + * Create a success result. + */ + protected abstract createResult(verdict: V, reasoning: string, durationMs: number): R; + + constructor(sessionId: string, defaultConfig: C, config: Partial = {}) { + 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; + this.config = { ...defaultConfig, ...filteredConfig }; + } + + /** Get the current status */ + get status(): AiCheckerStatus { + return this._status; + } + + /** Get comprehensive state for UI display */ + getState(): S { + 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, + } as S; + } + + /** 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 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 this.createErrorResult(`Disabled: ${this.disabledReason}`, 0); + } + + if (this.isOnCooldown()) { + return this.createErrorResult('On cooldown', 0); + } + + if (this._status === 'checking') { + return this.createErrorResult('Already checking', 0); + } + + this._status = 'checking'; + this.checkCancelled = false; + this.checkStartTime = Date.now(); + this.totalChecks++; + this.emit('checkStarted'); + this.log(`Starting ${this.checkDescription}`); + + try { + const result = await this.runCheck(terminalBuffer); + + if (this.checkCancelled) { + return this.createErrorResult('Cancelled', Date.now() - this.checkStartTime); + } + + this.lastVerdict = result.verdict; + this.lastReasoning = result.reasoning; + this.lastCheckDurationMs = result.durationMs; + + if (result.verdict === this.getPositiveVerdict()) { + this.consecutiveErrors = 0; + this._status = 'ready'; + this.log(`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`); + } else if (result.verdict === this.getNegativeVerdict()) { + this.consecutiveErrors = 0; + this.startCooldown(this.config.cooldownMs); + this.log(`${this.checkDescription} verdict: ${result.verdict} (${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 = this.createErrorResult(errorMsg, 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 ${this.checkDescription}`); + 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(this.createErrorResult('Cancelled', Date.now() - this.checkStartTime)); + this.checkResolve = null; + } + + this._status = 'ready'; + } + + /** Reset all state for a new cycle */ + reset(): void { + this.cancel(); + this.clearCooldown(); + this.lastVerdict = null; + this.lastReasoning = null; + this.lastCheckDurationMs = null; + this.consecutiveErrors = 0; + this._status = this.disabledReason ? 'disabled' : 'ready'; + } + + /** Update configuration at runtime */ + updateConfig(config: Partial): void { + // Filter out undefined values to prevent overwriting existing config + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + 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(): C { + 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 = this.buildPrompt(trimmed); + + // Generate temp files and screen name + const shortId = this.sessionId.slice(0, 8); + const timestamp = Date.now(); + this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`); + this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`); + this.checkScreenName = `${this.screenNamePrefix}${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 16KB+ 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 "${this.doneMarker}" >> "${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 ${this.checkDescription} 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(this.doneMarker)) { + 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(`${this.checkDescription} timed out after ${this.config.checkTimeoutMs}ms`)); + } + }, this.config.checkTimeoutMs); + }); + } + + private parseOutput(content: string, durationMs: number): R { + // Remove the done marker and trim + const output = content.replace(this.doneMarker, '').trim(); + + if (!output) { + return this.createErrorResult(`Empty output from ${this.checkDescription}`, durationMs); + } + + // Delegate to subclass for verdict parsing + const parsed = this.parseVerdict(output); + if (!parsed) { + return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs); + } + + return this.createResult(parsed.verdict, parsed.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(`${this.checkDescription} 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(`${this.checkDescription} disabled: ${reason}`); + this.emit('disabled', reason); + } + + private log(message: string): void { + this.emit('log', `${this.logPrefix} ${message}`); + } +} diff --git a/src/ai-idle-checker.ts b/src/ai-idle-checker.ts index 6a316616..08f1233c 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -24,51 +24,25 @@ * @module ai-idle-checker */ -import { execSync, spawn as childSpawn } from 'node:child_process'; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { EventEmitter } from 'node:events'; -import { getAugmentedPath } from './session.js'; +import { + AiCheckerBase, + type AiCheckerConfigBase, + type AiCheckerResultBase, + type AiCheckerStateBase, + type AiCheckerStatus, +} from './ai-checker-base.js'; // ========== Types ========== -export interface AiIdleCheckConfig { - /** Whether AI idle check is enabled */ - enabled: boolean; - /** Model to use for the check */ - model: string; - /** Maximum characters of terminal buffer to send */ - maxContextChars: number; - /** Timeout for the check in ms */ - checkTimeoutMs: number; - /** Cooldown after WORKING verdict in ms */ - cooldownMs: number; - /** Cooldown after errors in ms */ - errorCooldownMs: number; - /** Max consecutive errors before disabling */ - maxConsecutiveErrors: number; -} +export interface AiIdleCheckConfig extends AiCheckerConfigBase {} -export type AiCheckStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error'; +// Re-export the status type for backwards compatibility +export type AiCheckStatus = AiCheckerStatus; export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR'; -export interface AiCheckResult { - verdict: AiCheckVerdict; - reasoning: string; - durationMs: number; -} +export interface AiCheckResult extends AiCheckerResultBase {} -export interface AiCheckState { - status: AiCheckStatus; - lastVerdict: AiCheckVerdict | null; - lastReasoning: string | null; - lastCheckDurationMs: number | null; - cooldownEndsAt: number | null; - consecutiveErrors: number; - totalChecks: number; - disabledReason: string | null; -} +export interface AiCheckState extends AiCheckerStateBase {} /** Events emitted by AiIdleChecker */ export interface AiIdleCheckerEvents { @@ -93,15 +67,6 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = { maxConsecutiveErrors: 3, }; -/** ANSI escape code pattern for stripping terminal formatting */ -const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; - -/** Poll interval for checking temp file completion */ -const POLL_INTERVAL_MS = 500; - -/** Marker written to temp file when check is complete */ -const DONE_MARKER = '__AICHECK_DONE__'; - /** Pattern to match IDLE or WORKING as the first word of output */ const VERDICT_PATTERN = /^\s*(IDLE|WORKING)\b/i; @@ -125,397 +90,54 @@ Then optionally explain briefly why.`; * Manages AI-powered idle detection by spawning a fresh Claude CLI session * to analyze terminal output and provide a definitive IDLE/WORKING verdict. */ -export class AiIdleChecker extends EventEmitter { - private config: AiIdleCheckConfig; - private sessionId: string; - - // State - private _status: AiCheckStatus = 'ready'; - private lastVerdict: AiCheckVerdict | null = null; - private lastReasoning: string | null = null; - private lastCheckDurationMs: number | null = null; - private cooldownEndsAt: number | null = null; - private cooldownTimer: NodeJS.Timeout | null = null; - private consecutiveErrors: number = 0; - private totalChecks: number = 0; - private disabledReason: string | null = null; - - // Active check state - private checkScreenName: string | null = null; - private checkTempFile: string | null = null; - private 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: AiCheckResult) => void) | null = null; +export class AiIdleChecker extends AiCheckerBase< + AiCheckVerdict, + AiIdleCheckConfig, + AiCheckResult, + AiCheckState +> { + protected readonly screenNamePrefix = 'claudeman-aicheck-'; + protected readonly doneMarker = '__AICHECK_DONE__'; + protected readonly tempFilePrefix = 'claudeman-aicheck'; + protected readonly logPrefix = '[AiIdleChecker]'; + protected readonly checkDescription = 'AI idle check'; constructor(sessionId: string, config: Partial = {}) { - 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; - this.config = { ...DEFAULT_AI_CHECK_CONFIG, ...filteredConfig }; + super(sessionId, DEFAULT_AI_CHECK_CONFIG, config); } - /** Get the current status */ - get status(): AiCheckStatus { - return this._status; + protected buildPrompt(terminalBuffer: string): string { + return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer); } - /** Get comprehensive state for UI display */ - getState(): AiCheckState { - return { - status: this._status, - lastVerdict: this.lastVerdict, - lastReasoning: this.lastReasoning, - lastCheckDurationMs: this.lastCheckDurationMs, - cooldownEndsAt: this.cooldownEndsAt, - consecutiveErrors: this.consecutiveErrors, - totalChecks: this.totalChecks, - disabledReason: this.disabledReason, - }; - } - - /** Check if the checker is on cooldown */ - isOnCooldown(): boolean { - if (this.cooldownEndsAt === null) return false; - return Date.now() < this.cooldownEndsAt; - } - - /** Get remaining cooldown time in ms */ - getCooldownRemainingMs(): number { - if (this.cooldownEndsAt === null) return 0; - return Math.max(0, this.cooldownEndsAt - Date.now()); - } - - /** - * Run an AI idle check against the provided terminal buffer. - * Spawns a fresh Claude CLI in a screen, captures output to temp file. - * - * @param terminalBuffer - Raw terminal output to analyze - * @returns The verdict result - */ - async check(terminalBuffer: string): Promise { - if (this._status === 'disabled') { - return { verdict: 'ERROR', reasoning: `Disabled: ${this.disabledReason}`, durationMs: 0 }; - } - - if (this.isOnCooldown()) { - return { verdict: 'ERROR', reasoning: 'On cooldown', durationMs: 0 }; - } - - if (this._status === 'checking') { - return { verdict: 'ERROR', reasoning: 'Already checking', durationMs: 0 }; - } - - this._status = 'checking'; - this.checkCancelled = false; - this.checkStartTime = Date.now(); - this.totalChecks++; - this.emit('checkStarted'); - this.log('Starting AI idle check'); - - try { - const result = await this.runCheck(terminalBuffer); - - if (this.checkCancelled) { - return { verdict: 'ERROR', reasoning: 'Cancelled', durationMs: Date.now() - this.checkStartTime }; - } - - this.lastVerdict = result.verdict; - this.lastReasoning = result.reasoning; - this.lastCheckDurationMs = result.durationMs; - - if (result.verdict === 'IDLE') { - this.consecutiveErrors = 0; - this._status = 'ready'; - this.log(`AI check verdict: IDLE (${result.durationMs}ms) - ${result.reasoning}`); - } else if (result.verdict === 'WORKING') { - this.consecutiveErrors = 0; - this.startCooldown(this.config.cooldownMs); - this.log(`AI check verdict: WORKING (${result.durationMs}ms) - ${result.reasoning}`); - } else { - this.handleError('Unexpected verdict'); - } - - this.emit('checkCompleted', result); - return result; - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - this.handleError(errorMsg); - const result: AiCheckResult = { - verdict: 'ERROR', - reasoning: errorMsg, - durationMs: Date.now() - this.checkStartTime, - }; - this.emit('checkFailed', errorMsg); - return result; - } finally { - this.cleanupCheck(); - } - } - - /** - * Cancel an in-progress check. - * Kills the screen session and cleans up. - */ - cancel(): void { - if (this._status !== 'checking') return; - - this.log('Cancelling AI check'); - this.checkCancelled = true; - - // 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 for a new cycle */ - reset(): void { - this.cancel(); - this.clearCooldown(); - this.lastVerdict = null; - this.lastReasoning = null; - this.lastCheckDurationMs = null; - this.consecutiveErrors = 0; - this._status = this.disabledReason ? 'disabled' : 'ready'; - } - - /** Update configuration at runtime */ - updateConfig(config: Partial): void { - // Filter out undefined values to prevent overwriting existing config - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) - ) as Partial; - 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(): AiIdleCheckConfig { - return { ...this.config }; - } - - // ========== Private Methods ========== - - private async runCheck(terminalBuffer: string): Promise { - // Prepare the terminal buffer (strip ANSI, trim to maxContextChars) - const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN, ''); - const trimmed = stripped.length > this.config.maxContextChars - ? stripped.slice(-this.config.maxContextChars) - : stripped; - - // Build the prompt - const prompt = AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', trimmed); - - // Generate temp files and screen name - const shortId = this.sessionId.slice(0, 8); - const timestamp = Date.now(); - this.checkTempFile = join(tmpdir(), `claudeman-aicheck-${shortId}-${timestamp}.txt`); - this.checkPromptFile = join(tmpdir(), `claudeman-aicheck-prompt-${shortId}-${timestamp}.txt`); - this.checkScreenName = `claudeman-aicheck-${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 16KB+ 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 AI check screen: ${err instanceof Error ? err.message : String(err)}`); - } - - // Poll the temp file for completion - return new Promise((resolve, reject) => { - const startTime = this.checkStartTime; - this.checkResolve = resolve; - - this.checkPollTimer = setInterval(() => { - if (this.checkCancelled) { - // Cancel was already handled by cancel() calling resolve - return; - } - - try { - if (!this.checkTempFile || !existsSync(this.checkTempFile)) return; - const content = readFileSync(this.checkTempFile, 'utf-8'); - if (content.includes(DONE_MARKER)) { - const durationMs = Date.now() - startTime; - const result = this.parseOutput(content, durationMs); - this.checkResolve = null; - resolve(result); - } - } catch { - // File might not be ready yet, keep polling - } - }, POLL_INTERVAL_MS); - - // Set timeout - this.checkTimeoutTimer = setTimeout(() => { - if (this._status === 'checking' && !this.checkCancelled) { - this.checkResolve = null; - reject(new Error(`AI check timed out after ${this.config.checkTimeoutMs}ms`)); - } - }, this.config.checkTimeoutMs); - }); - } - - private parseOutput(content: string, durationMs: number): AiCheckResult { - // Remove the done marker and trim - const output = content.replace(DONE_MARKER, '').trim(); - - if (!output) { - return { verdict: 'ERROR', reasoning: 'Empty output from AI check', durationMs }; - } - - // Look for IDLE or WORKING as the first word + protected parseVerdict(output: string): { verdict: AiCheckVerdict; 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 'IDLE' | 'WORKING'; - // Everything after the first line is the reasoning const lines = output.split('\n'); const reasoning = lines.slice(1).join('\n').trim() || `AI determined: ${verdict}`; + return { verdict, reasoning }; + } + + protected getPositiveVerdict(): AiCheckVerdict { + return 'IDLE'; + } + + protected getNegativeVerdict(): AiCheckVerdict { + return 'WORKING'; + } + + protected getErrorVerdict(): AiCheckVerdict { + return 'ERROR'; + } + + protected createErrorResult(reasoning: string, durationMs: number): AiCheckResult { + return { verdict: 'ERROR', reasoning, durationMs }; + } + + protected createResult(verdict: AiCheckVerdict, reasoning: string, durationMs: number): AiCheckResult { 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 check error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`); - - if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) { - this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`); - } else { - this.startCooldown(this.config.errorCooldownMs); - } - } - - private startCooldown(durationMs: number): void { - this.clearCooldown(); - this.cooldownEndsAt = Date.now() + durationMs; - this._status = 'cooldown'; - this.emit('cooldownStarted', this.cooldownEndsAt); - this.log(`Cooldown started: ${Math.round(durationMs / 1000)}s`); - - this.cooldownTimer = setTimeout(() => { - this.cooldownEndsAt = null; - this._status = 'ready'; - this.emit('cooldownEnded'); - this.log('Cooldown ended'); - }, durationMs); - } - - private clearCooldown(): void { - if (this.cooldownTimer) { - clearTimeout(this.cooldownTimer); - this.cooldownTimer = null; - } - this.cooldownEndsAt = null; - if (this._status === 'cooldown') { - this._status = 'ready'; - } - } - - private disable(reason: string): void { - this.disabledReason = reason; - this._status = 'disabled'; - this.clearCooldown(); - this.log(`AI check disabled: ${reason}`); - this.emit('disabled', reason); - } - - private log(message: string): void { - this.emit('log', `[AiIdleChecker] ${message}`); - } } diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts index 3ee33732..baaebee6 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -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 {} -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 {} /** 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 = {}) { - 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; - 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 { - 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): void { - // Filter out undefined values to prevent overwriting existing config - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) - ) as Partial; - 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 { - // 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((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}`); - } } diff --git a/src/types.ts b/src/types.ts index f3a600e3..4d11b86a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -558,6 +558,10 @@ export interface PersistedRespawnConfig { autoAcceptPrompts?: boolean; /** Delay before auto-accepting prompts (ms) */ autoAcceptDelayMs?: number; + /** Time to wait after completion message before confirming idle (ms) */ + completionConfirmMs?: number; + /** Fallback timeout when no output received at all (ms) */ + noOutputTimeoutMs?: number; /** Whether AI idle check is enabled */ aiIdleCheckEnabled?: boolean; /** Model to use for AI idle check */ diff --git a/src/web/server.ts b/src/web/server.ts index cfa5418b..36796825 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1621,6 +1621,8 @@ export class WebServer extends EventEmitter { kickstartPrompt: config.kickstartPrompt, autoAcceptPrompts: config.autoAcceptPrompts, autoAcceptDelayMs: config.autoAcceptDelayMs, + completionConfirmMs: config.completionConfirmMs, + noOutputTimeoutMs: config.noOutputTimeoutMs, aiIdleCheckEnabled: config.aiIdleCheckEnabled, aiIdleCheckModel: config.aiIdleCheckModel, aiIdleCheckMaxContext: config.aiIdleCheckMaxContext, @@ -2060,6 +2062,54 @@ export class WebServer extends EventEmitter { this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now }); } + /** + * Restore a RespawnController from persisted configuration. + * Creates the controller, sets up listeners, starts it, and optionally sets up a timed respawn. + * + * @param session - The session to attach the controller to + * @param config - The persisted respawn configuration + * @param source - Source of the config for logging (e.g., 'state.json' or 'screens.json') + */ + private restoreRespawnController( + session: Session, + config: PersistedRespawnConfig, + source: string + ): void { + const controller = new RespawnController(session, { + idleTimeoutMs: config.idleTimeoutMs, + updatePrompt: config.updatePrompt, + interStepDelayMs: config.interStepDelayMs, + enabled: true, + sendClear: config.sendClear, + sendInit: config.sendInit, + kickstartPrompt: config.kickstartPrompt, + completionConfirmMs: config.completionConfirmMs, + noOutputTimeoutMs: config.noOutputTimeoutMs, + autoAcceptPrompts: config.autoAcceptPrompts, + autoAcceptDelayMs: config.autoAcceptDelayMs, + aiIdleCheckEnabled: config.aiIdleCheckEnabled, + aiIdleCheckModel: config.aiIdleCheckModel, + aiIdleCheckMaxContext: config.aiIdleCheckMaxContext, + aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs, + aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs, + aiPlanCheckEnabled: config.aiPlanCheckEnabled, + aiPlanCheckModel: config.aiPlanCheckModel, + aiPlanCheckMaxContext: config.aiPlanCheckMaxContext, + aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs, + aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs, + }); + + this.respawnControllers.set(session.id, controller); + this.setupRespawnListeners(session.id, controller); + controller.start(); + + if (config.durationMinutes && config.durationMinutes > 0) { + this.setupTimedRespawn(session.id, config.durationMinutes); + } + + console.log(`[Server] Restored respawn controller for session ${session.id} from ${source}`); + } + // Helper to get custom CLAUDE.md template path from settings private getDefaultClaudeMdPath(): string | undefined { const settingsPath = join(homedir(), '.claudeman', 'settings.json'); @@ -2540,38 +2590,7 @@ export class WebServer extends EventEmitter { // Respawn controller if (savedState.respawnEnabled && savedState.respawnConfig) { try { - const controller = new RespawnController(session, { - idleTimeoutMs: savedState.respawnConfig.idleTimeoutMs, - updatePrompt: savedState.respawnConfig.updatePrompt, - interStepDelayMs: savedState.respawnConfig.interStepDelayMs, - enabled: true, - sendClear: savedState.respawnConfig.sendClear, - sendInit: savedState.respawnConfig.sendInit, - kickstartPrompt: savedState.respawnConfig.kickstartPrompt, - completionConfirmMs: savedState.respawnConfig.completionConfirmMs, - noOutputTimeoutMs: savedState.respawnConfig.noOutputTimeoutMs, - autoAcceptPrompts: savedState.respawnConfig.autoAcceptPrompts ?? true, - autoAcceptDelayMs: savedState.respawnConfig.autoAcceptDelayMs ?? 8000, - aiIdleCheckEnabled: savedState.respawnConfig.aiIdleCheckEnabled ?? true, - aiIdleCheckModel: savedState.respawnConfig.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', - aiIdleCheckMaxContext: savedState.respawnConfig.aiIdleCheckMaxContext ?? 16000, - aiIdleCheckTimeoutMs: savedState.respawnConfig.aiIdleCheckTimeoutMs ?? 90000, - aiIdleCheckCooldownMs: savedState.respawnConfig.aiIdleCheckCooldownMs ?? 180000, - 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); - controller.start(); - - if (savedState.respawnConfig.durationMinutes && savedState.respawnConfig.durationMinutes > 0) { - this.setupTimedRespawn(session.id, savedState.respawnConfig.durationMinutes); - } - - console.log(`[Server] Restored respawn controller for session ${session.id}`); + this.restoreRespawnController(session, savedState.respawnConfig, 'state.json'); } catch (err) { console.error(`[Server] Failed to restore respawn for session ${session.id}:`, err); } @@ -2581,36 +2600,7 @@ export class WebServer extends EventEmitter { // Fallback: restore respawn from screens.json if state.json didn't have it if (!this.respawnControllers.has(session.id) && screen.respawnConfig?.enabled) { try { - const controller = new RespawnController(session, { - idleTimeoutMs: screen.respawnConfig.idleTimeoutMs, - updatePrompt: screen.respawnConfig.updatePrompt, - interStepDelayMs: screen.respawnConfig.interStepDelayMs, - enabled: true, - sendClear: screen.respawnConfig.sendClear, - sendInit: screen.respawnConfig.sendInit, - kickstartPrompt: screen.respawnConfig.kickstartPrompt, - autoAcceptPrompts: screen.respawnConfig.autoAcceptPrompts ?? true, - autoAcceptDelayMs: screen.respawnConfig.autoAcceptDelayMs ?? 8000, - aiIdleCheckEnabled: screen.respawnConfig.aiIdleCheckEnabled ?? true, - aiIdleCheckModel: screen.respawnConfig.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', - aiIdleCheckMaxContext: screen.respawnConfig.aiIdleCheckMaxContext ?? 16000, - aiIdleCheckTimeoutMs: screen.respawnConfig.aiIdleCheckTimeoutMs ?? 90000, - aiIdleCheckCooldownMs: screen.respawnConfig.aiIdleCheckCooldownMs ?? 180000, - 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); - controller.start(); - - if (screen.respawnConfig.durationMinutes && screen.respawnConfig.durationMinutes > 0) { - this.setupTimedRespawn(session.id, screen.respawnConfig.durationMinutes); - } - - console.log(`[Server] Restored respawn controller from screens.json for session ${session.id}`); + this.restoreRespawnController(session, screen.respawnConfig, 'screens.json'); } catch (err) { console.error(`[Server] Failed to restore respawn from screens.json for session ${session.id}:`, err); }