mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
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:
+48
-426
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user