mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29: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:
@@ -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.
|
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
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -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<V extends string> {
|
||||||
|
verdict: V;
|
||||||
|
reasoning: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base state structure for all AI checkers */
|
||||||
|
export interface AiCheckerStateBase<V extends string> {
|
||||||
|
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<R> {
|
||||||
|
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<V>,
|
||||||
|
S extends AiCheckerStateBase<V>
|
||||||
|
> 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<C> = {}) {
|
||||||
|
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<C>;
|
||||||
|
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<R> {
|
||||||
|
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<C>): void {
|
||||||
|
// Filter out undefined values to prevent overwriting existing config
|
||||||
|
const filteredConfig = Object.fromEntries(
|
||||||
|
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||||
|
) as Partial<C>;
|
||||||
|
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<R> {
|
||||||
|
// 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<R>((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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-426
@@ -24,51 +24,25 @@
|
|||||||
* @module ai-idle-checker
|
* @module ai-idle-checker
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, spawn as childSpawn } from 'node:child_process';
|
import {
|
||||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
AiCheckerBase,
|
||||||
import { tmpdir } from 'node:os';
|
type AiCheckerConfigBase,
|
||||||
import { join } from 'node:path';
|
type AiCheckerResultBase,
|
||||||
import { EventEmitter } from 'node:events';
|
type AiCheckerStateBase,
|
||||||
import { getAugmentedPath } from './session.js';
|
type AiCheckerStatus,
|
||||||
|
} from './ai-checker-base.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export interface AiIdleCheckConfig {
|
export interface AiIdleCheckConfig extends AiCheckerConfigBase {}
|
||||||
/** 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 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 type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||||
|
|
||||||
export interface AiCheckResult {
|
export interface AiCheckResult extends AiCheckerResultBase<AiCheckVerdict> {}
|
||||||
verdict: AiCheckVerdict;
|
|
||||||
reasoning: string;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiCheckState {
|
export interface AiCheckState extends AiCheckerStateBase<AiCheckVerdict> {}
|
||||||
status: AiCheckStatus;
|
|
||||||
lastVerdict: AiCheckVerdict | null;
|
|
||||||
lastReasoning: string | null;
|
|
||||||
lastCheckDurationMs: number | null;
|
|
||||||
cooldownEndsAt: number | null;
|
|
||||||
consecutiveErrors: number;
|
|
||||||
totalChecks: number;
|
|
||||||
disabledReason: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Events emitted by AiIdleChecker */
|
/** Events emitted by AiIdleChecker */
|
||||||
export interface AiIdleCheckerEvents {
|
export interface AiIdleCheckerEvents {
|
||||||
@@ -93,15 +67,6 @@ const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = {
|
|||||||
maxConsecutiveErrors: 3,
|
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 */
|
/** Pattern to match IDLE or WORKING as the first word of output */
|
||||||
const VERDICT_PATTERN = /^\s*(IDLE|WORKING)\b/i;
|
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
|
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
|
||||||
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
|
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
|
||||||
*/
|
*/
|
||||||
export class AiIdleChecker extends EventEmitter {
|
export class AiIdleChecker extends AiCheckerBase<
|
||||||
private config: AiIdleCheckConfig;
|
AiCheckVerdict,
|
||||||
private sessionId: string;
|
AiIdleCheckConfig,
|
||||||
|
AiCheckResult,
|
||||||
// State
|
AiCheckState
|
||||||
private _status: AiCheckStatus = 'ready';
|
> {
|
||||||
private lastVerdict: AiCheckVerdict | null = null;
|
protected readonly screenNamePrefix = 'claudeman-aicheck-';
|
||||||
private lastReasoning: string | null = null;
|
protected readonly doneMarker = '__AICHECK_DONE__';
|
||||||
private lastCheckDurationMs: number | null = null;
|
protected readonly tempFilePrefix = 'claudeman-aicheck';
|
||||||
private cooldownEndsAt: number | null = null;
|
protected readonly logPrefix = '[AiIdleChecker]';
|
||||||
private cooldownTimer: NodeJS.Timeout | null = null;
|
protected readonly checkDescription = 'AI idle check';
|
||||||
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;
|
|
||||||
|
|
||||||
constructor(sessionId: string, config: Partial<AiIdleCheckConfig> = {}) {
|
constructor(sessionId: string, config: Partial<AiIdleCheckConfig> = {}) {
|
||||||
super();
|
super(sessionId, DEFAULT_AI_CHECK_CONFIG, config);
|
||||||
this.sessionId = sessionId;
|
|
||||||
// Filter out undefined values to prevent overwriting defaults
|
|
||||||
const filteredConfig = Object.fromEntries(
|
|
||||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
|
||||||
) as Partial<AiIdleCheckConfig>;
|
|
||||||
this.config = { ...DEFAULT_AI_CHECK_CONFIG, ...filteredConfig };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the current status */
|
protected buildPrompt(terminalBuffer: string): string {
|
||||||
get status(): AiCheckStatus {
|
return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
|
||||||
return this._status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get comprehensive state for UI display */
|
protected parseVerdict(output: string): { verdict: AiCheckVerdict; reasoning: string } | null {
|
||||||
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<AiCheckResult> {
|
|
||||||
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<AiIdleCheckConfig>): void {
|
|
||||||
// Filter out undefined values to prevent overwriting existing config
|
|
||||||
const filteredConfig = Object.fromEntries(
|
|
||||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
|
||||||
) as Partial<AiIdleCheckConfig>;
|
|
||||||
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<AiCheckResult> {
|
|
||||||
// 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<AiCheckResult>((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
|
|
||||||
const match = output.match(VERDICT_PATTERN);
|
const match = output.match(VERDICT_PATTERN);
|
||||||
if (!match) {
|
if (!match) return null;
|
||||||
return { verdict: 'ERROR', reasoning: `Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
const verdict = match[1].toUpperCase() as 'IDLE' | 'WORKING';
|
const verdict = match[1].toUpperCase() as 'IDLE' | 'WORKING';
|
||||||
// Everything after the first line is the reasoning
|
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
const reasoning = lines.slice(1).join('\n').trim() || `AI determined: ${verdict}`;
|
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 };
|
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-426
@@ -23,51 +23,25 @@
|
|||||||
* @module ai-plan-checker
|
* @module ai-plan-checker
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, spawn as childSpawn } from 'node:child_process';
|
import {
|
||||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
AiCheckerBase,
|
||||||
import { tmpdir } from 'node:os';
|
type AiCheckerConfigBase,
|
||||||
import { join } from 'node:path';
|
type AiCheckerResultBase,
|
||||||
import { EventEmitter } from 'node:events';
|
type AiCheckerStateBase,
|
||||||
import { getAugmentedPath } from './session.js';
|
type AiCheckerStatus,
|
||||||
|
} from './ai-checker-base.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
export interface AiPlanCheckConfig {
|
export interface AiPlanCheckConfig extends AiCheckerConfigBase {}
|
||||||
/** Whether AI plan check is enabled */
|
|
||||||
enabled: boolean;
|
|
||||||
/** Model to use for the check (thinking enabled by default with opus) */
|
|
||||||
model: string;
|
|
||||||
/** Maximum characters of terminal buffer to send */
|
|
||||||
maxContextChars: number;
|
|
||||||
/** Timeout for the check in ms */
|
|
||||||
checkTimeoutMs: number;
|
|
||||||
/** Cooldown after NOT_PLAN_MODE verdict in ms */
|
|
||||||
cooldownMs: number;
|
|
||||||
/** Cooldown after errors in ms */
|
|
||||||
errorCooldownMs: number;
|
|
||||||
/** Max consecutive errors before disabling */
|
|
||||||
maxConsecutiveErrors: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AiPlanCheckStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error';
|
// Re-export the status type for backwards compatibility
|
||||||
|
export type AiPlanCheckStatus = AiCheckerStatus;
|
||||||
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR';
|
||||||
|
|
||||||
export interface AiPlanCheckResult {
|
export interface AiPlanCheckResult extends AiCheckerResultBase<AiPlanCheckVerdict> {}
|
||||||
verdict: AiPlanCheckVerdict;
|
|
||||||
reasoning: string;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiPlanCheckState {
|
export interface AiPlanCheckState extends AiCheckerStateBase<AiPlanCheckVerdict> {}
|
||||||
status: AiPlanCheckStatus;
|
|
||||||
lastVerdict: AiPlanCheckVerdict | null;
|
|
||||||
lastReasoning: string | null;
|
|
||||||
lastCheckDurationMs: number | null;
|
|
||||||
cooldownEndsAt: number | null;
|
|
||||||
consecutiveErrors: number;
|
|
||||||
totalChecks: number;
|
|
||||||
disabledReason: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Events emitted by AiPlanChecker */
|
/** Events emitted by AiPlanChecker */
|
||||||
export interface AiPlanCheckerEvents {
|
export interface AiPlanCheckerEvents {
|
||||||
@@ -92,15 +66,6 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
|
|||||||
maxConsecutiveErrors: 3,
|
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 */
|
/** 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;
|
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
|
* Manages AI-powered plan mode detection by spawning a fresh Claude CLI session
|
||||||
* to analyze terminal output and confirm plan mode approval prompts.
|
* to analyze terminal output and confirm plan mode approval prompts.
|
||||||
*/
|
*/
|
||||||
export class AiPlanChecker extends EventEmitter {
|
export class AiPlanChecker extends AiCheckerBase<
|
||||||
private config: AiPlanCheckConfig;
|
AiPlanCheckVerdict,
|
||||||
private sessionId: string;
|
AiPlanCheckConfig,
|
||||||
|
AiPlanCheckResult,
|
||||||
// State
|
AiPlanCheckState
|
||||||
private _status: AiPlanCheckStatus = 'ready';
|
> {
|
||||||
private lastVerdict: AiPlanCheckVerdict | null = null;
|
protected readonly screenNamePrefix = 'claudeman-plancheck-';
|
||||||
private lastReasoning: string | null = null;
|
protected readonly doneMarker = '__PLANCHECK_DONE__';
|
||||||
private lastCheckDurationMs: number | null = null;
|
protected readonly tempFilePrefix = 'claudeman-plancheck';
|
||||||
private cooldownEndsAt: number | null = null;
|
protected readonly logPrefix = '[AiPlanChecker]';
|
||||||
private cooldownTimer: NodeJS.Timeout | null = null;
|
protected readonly checkDescription = 'AI plan check';
|
||||||
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;
|
|
||||||
|
|
||||||
constructor(sessionId: string, config: Partial<AiPlanCheckConfig> = {}) {
|
constructor(sessionId: string, config: Partial<AiPlanCheckConfig> = {}) {
|
||||||
super();
|
super(sessionId, DEFAULT_PLAN_CHECK_CONFIG, config);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the current status */
|
protected buildPrompt(terminalBuffer: string): string {
|
||||||
get status(): AiPlanCheckStatus {
|
return AI_PLAN_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
|
||||||
return this._status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get comprehensive state for UI display */
|
protected parseVerdict(output: string): { verdict: AiPlanCheckVerdict; reasoning: string } | null {
|
||||||
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)
|
|
||||||
const match = output.match(VERDICT_PATTERN);
|
const match = output.match(VERDICT_PATTERN);
|
||||||
if (!match) {
|
if (!match) return null;
|
||||||
return { verdict: 'ERROR', reasoning: `Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs };
|
|
||||||
}
|
|
||||||
|
|
||||||
const verdict = match[1].toUpperCase() as 'PLAN_MODE' | 'NOT_PLAN_MODE';
|
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 lines = output.split('\n');
|
||||||
const reasoning = lines.slice(1).join('\n').trim() || `AI determined: ${verdict}`;
|
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 };
|
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -558,6 +558,10 @@ export interface PersistedRespawnConfig {
|
|||||||
autoAcceptPrompts?: boolean;
|
autoAcceptPrompts?: boolean;
|
||||||
/** Delay before auto-accepting prompts (ms) */
|
/** Delay before auto-accepting prompts (ms) */
|
||||||
autoAcceptDelayMs?: number;
|
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 */
|
/** Whether AI idle check is enabled */
|
||||||
aiIdleCheckEnabled?: boolean;
|
aiIdleCheckEnabled?: boolean;
|
||||||
/** Model to use for AI idle check */
|
/** Model to use for AI idle check */
|
||||||
|
|||||||
+52
-62
@@ -1621,6 +1621,8 @@ export class WebServer extends EventEmitter {
|
|||||||
kickstartPrompt: config.kickstartPrompt,
|
kickstartPrompt: config.kickstartPrompt,
|
||||||
autoAcceptPrompts: config.autoAcceptPrompts,
|
autoAcceptPrompts: config.autoAcceptPrompts,
|
||||||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||||||
|
completionConfirmMs: config.completionConfirmMs,
|
||||||
|
noOutputTimeoutMs: config.noOutputTimeoutMs,
|
||||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||||||
aiIdleCheckModel: config.aiIdleCheckModel,
|
aiIdleCheckModel: config.aiIdleCheckModel,
|
||||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||||||
@@ -2060,6 +2062,54 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now });
|
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
|
// Helper to get custom CLAUDE.md template path from settings
|
||||||
private getDefaultClaudeMdPath(): string | undefined {
|
private getDefaultClaudeMdPath(): string | undefined {
|
||||||
const settingsPath = join(homedir(), '.claudeman', 'settings.json');
|
const settingsPath = join(homedir(), '.claudeman', 'settings.json');
|
||||||
@@ -2540,38 +2590,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// Respawn controller
|
// Respawn controller
|
||||||
if (savedState.respawnEnabled && savedState.respawnConfig) {
|
if (savedState.respawnEnabled && savedState.respawnConfig) {
|
||||||
try {
|
try {
|
||||||
const controller = new RespawnController(session, {
|
this.restoreRespawnController(session, savedState.respawnConfig, 'state.json');
|
||||||
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}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Server] Failed to restore respawn for session ${session.id}:`, 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
|
// Fallback: restore respawn from screens.json if state.json didn't have it
|
||||||
if (!this.respawnControllers.has(session.id) && screen.respawnConfig?.enabled) {
|
if (!this.respawnControllers.has(session.id) && screen.respawnConfig?.enabled) {
|
||||||
try {
|
try {
|
||||||
const controller = new RespawnController(session, {
|
this.restoreRespawnController(session, screen.respawnConfig, 'screens.json');
|
||||||
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}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Server] Failed to restore respawn from screens.json for session ${session.id}:`, err);
|
console.error(`[Server] Failed to restore respawn from screens.json for session ${session.id}:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user