mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: add AI-powered idle check for respawn controller
Replace the "Worked for Xm Xs" pattern as the sole primary idle detection signal with a final AI-powered check. When pre-filter conditions are met (output silence, no working patterns, tokens stable), a fresh Claude CLI session is spawned in a screen to analyze terminal output and provide a definitive IDLE/WORKING verdict before proceeding with the respawn cycle. New state in state machine: `ai_checking` (between pre-filter confirmation and `sending_update`). WORKING verdict triggers a 3-minute cooldown. Errors auto-disable after 3 consecutive failures, falling back to the existing noOutputTimeoutMs safety net. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* @fileoverview AI-Powered Idle Checker for Respawn Controller
|
||||
*
|
||||
* Spawns a fresh Claude CLI session in a screen to analyze terminal output
|
||||
* and provide a definitive IDLE/WORKING verdict. This replaces the "Worked for Xm Xs"
|
||||
* pattern as the primary idle detection signal.
|
||||
*
|
||||
* ## How It Works
|
||||
*
|
||||
* 1. Generate temp file path for output capture
|
||||
* 2. Spawn screen: `screen -dmS claudeman-aicheck-<short> bash -c 'claude -p ...'`
|
||||
* 3. Poll the temp file every 500ms for `__AICHECK_DONE__` marker
|
||||
* 4. Parse the file content for IDLE/WORKING on the first word
|
||||
* 5. Kill screen and delete temp file
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* - Screen spawn fails: 1-min cooldown, increment error counter
|
||||
* - Check times out (90s): Kill screen, 1-min cooldown
|
||||
* - Can't parse IDLE/WORKING: Treat as WORKING, 1-min cooldown
|
||||
* - 3 consecutive errors: Disable AI check, fall back to noOutputTimeoutMs
|
||||
* - Claude CLI not found: Disable permanently
|
||||
*
|
||||
* @module ai-idle-checker
|
||||
*/
|
||||
|
||||
import { execSync, spawn as childSpawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface AiIdleCheckConfig {
|
||||
/** Whether AI idle check is enabled */
|
||||
enabled: boolean;
|
||||
/** Model to use for the check */
|
||||
model: string;
|
||||
/** Maximum characters of terminal buffer to send */
|
||||
maxContextChars: number;
|
||||
/** Timeout for the check in ms */
|
||||
checkTimeoutMs: number;
|
||||
/** Cooldown after WORKING verdict in ms */
|
||||
cooldownMs: number;
|
||||
/** Cooldown after errors in ms */
|
||||
errorCooldownMs: number;
|
||||
/** Max consecutive errors before disabling */
|
||||
maxConsecutiveErrors: number;
|
||||
}
|
||||
|
||||
export type AiCheckStatus = 'ready' | 'checking' | 'cooldown' | 'disabled' | 'error';
|
||||
export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR';
|
||||
|
||||
export interface AiCheckResult {
|
||||
verdict: AiCheckVerdict;
|
||||
reasoning: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface AiCheckState {
|
||||
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 */
|
||||
export interface AiIdleCheckerEvents {
|
||||
checkStarted: () => void;
|
||||
checkCompleted: (result: AiCheckResult) => void;
|
||||
checkFailed: (error: string) => void;
|
||||
cooldownStarted: (endsAt: number) => void;
|
||||
cooldownEnded: () => void;
|
||||
disabled: (reason: string) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const DEFAULT_AI_CHECK_CONFIG: AiIdleCheckConfig = {
|
||||
enabled: true,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
maxContextChars: 16000,
|
||||
checkTimeoutMs: 90000,
|
||||
cooldownMs: 180000,
|
||||
errorCooldownMs: 60000,
|
||||
maxConsecutiveErrors: 3,
|
||||
};
|
||||
|
||||
/** ANSI escape code pattern for stripping terminal formatting */
|
||||
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
||||
|
||||
/** Poll interval for checking temp file completion */
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
/** Marker written to temp file when check is complete */
|
||||
const DONE_MARKER = '__AICHECK_DONE__';
|
||||
|
||||
/** Pattern to match IDLE or WORKING as the first word of output */
|
||||
const VERDICT_PATTERN = /^\s*(IDLE|WORKING)\b/i;
|
||||
|
||||
/** The prompt sent to the AI checker */
|
||||
const AI_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the session is IDLE (done working, waiting for new input) or WORKING (still actively processing).
|
||||
|
||||
IDLE indicators: prompt character at the end, completion summary shown ("Worked for Xm Xs"), clear stopping point, no spinners, cost summary displayed
|
||||
WORKING indicators: spinner chars, "Thinking"/"Writing"/"Reading"/"Running" text, active tool execution, truncated mid-output, partial lines
|
||||
|
||||
Terminal output (most recent at bottom):
|
||||
---
|
||||
{TERMINAL_BUFFER}
|
||||
---
|
||||
|
||||
Answer with EXACTLY one word on the first line: IDLE or WORKING
|
||||
Then optionally explain briefly why.`;
|
||||
|
||||
// ========== AiIdleChecker Class ==========
|
||||
|
||||
/**
|
||||
* Manages AI-powered idle detection by spawning a fresh Claude CLI session
|
||||
* to analyze terminal output and provide a definitive IDLE/WORKING verdict.
|
||||
*/
|
||||
export class AiIdleChecker extends EventEmitter {
|
||||
private config: AiIdleCheckConfig;
|
||||
private sessionId: string;
|
||||
|
||||
// State
|
||||
private _status: AiCheckStatus = 'ready';
|
||||
private lastVerdict: AiCheckVerdict | null = null;
|
||||
private lastReasoning: string | null = null;
|
||||
private lastCheckDurationMs: number | null = null;
|
||||
private cooldownEndsAt: number | null = null;
|
||||
private cooldownTimer: NodeJS.Timeout | null = null;
|
||||
private consecutiveErrors: number = 0;
|
||||
private totalChecks: number = 0;
|
||||
private disabledReason: string | null = null;
|
||||
|
||||
// Active check state
|
||||
private checkScreenName: string | null = null;
|
||||
private checkTempFile: string | null = null;
|
||||
private 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> = {}) {
|
||||
super();
|
||||
this.sessionId = sessionId;
|
||||
this.config = { ...DEFAULT_AI_CHECK_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/** Get the current status */
|
||||
get status(): AiCheckStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** Get comprehensive state for UI display */
|
||||
getState(): AiCheckState {
|
||||
return {
|
||||
status: this._status,
|
||||
lastVerdict: this.lastVerdict,
|
||||
lastReasoning: this.lastReasoning,
|
||||
lastCheckDurationMs: this.lastCheckDurationMs,
|
||||
cooldownEndsAt: this.cooldownEndsAt,
|
||||
consecutiveErrors: this.consecutiveErrors,
|
||||
totalChecks: this.totalChecks,
|
||||
disabledReason: this.disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if the checker is on cooldown */
|
||||
isOnCooldown(): boolean {
|
||||
if (this.cooldownEndsAt === null) return false;
|
||||
return Date.now() < this.cooldownEndsAt;
|
||||
}
|
||||
|
||||
/** Get remaining cooldown time in ms */
|
||||
getCooldownRemainingMs(): number {
|
||||
if (this.cooldownEndsAt === null) return 0;
|
||||
return Math.max(0, this.cooldownEndsAt - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an AI idle check against the provided terminal buffer.
|
||||
* Spawns a fresh Claude CLI in a screen, captures output to temp file.
|
||||
*
|
||||
* @param terminalBuffer - Raw terminal output to analyze
|
||||
* @returns The verdict result
|
||||
*/
|
||||
async check(terminalBuffer: string): Promise<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;
|
||||
|
||||
// Resolve the pending promise before cleanup
|
||||
if (this.checkResolve) {
|
||||
this.checkResolve({ verdict: 'ERROR', reasoning: 'Cancelled', durationMs: Date.now() - this.checkStartTime });
|
||||
this.checkResolve = null;
|
||||
}
|
||||
|
||||
this.cleanupCheck();
|
||||
this._status = 'ready';
|
||||
}
|
||||
|
||||
/** Reset all state 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 {
|
||||
this.config = { ...this.config, ...config };
|
||||
if (config.enabled === false) {
|
||||
this.disable('Disabled by config');
|
||||
} else if (config.enabled === true && this._status === 'disabled') {
|
||||
this.disabledReason = null;
|
||||
this._status = 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current config */
|
||||
getConfig(): 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 file and screen name
|
||||
const shortId = this.sessionId.slice(0, 8);
|
||||
const timestamp = Date.now();
|
||||
this.checkTempFile = join(tmpdir(), `claudeman-aicheck-${shortId}-${timestamp}.txt`);
|
||||
this.checkScreenName = `claudeman-aicheck-${shortId}`;
|
||||
|
||||
// Ensure temp file exists (empty) so we can poll it
|
||||
writeFileSync(this.checkTempFile, '');
|
||||
|
||||
// Build the command - escape the prompt for shell
|
||||
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
||||
const modelArg = `--model ${this.config.model}`;
|
||||
const claudeCmd = `claude -p ${modelArg} --output-format text '${escapedPrompt}'`;
|
||||
const fullCmd = `${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${DONE_MARKER}" >> "${this.checkTempFile}"`;
|
||||
|
||||
// Spawn screen
|
||||
try {
|
||||
// Kill any leftover screen with this name first
|
||||
try {
|
||||
execSync(`screen -X -S ${this.checkScreenName} quit 2>/dev/null`, { timeout: 3000 });
|
||||
} catch {
|
||||
// No existing screen, that's fine
|
||||
}
|
||||
|
||||
const screenProcess = childSpawn('screen', [
|
||||
'-dmS', this.checkScreenName,
|
||||
'-c', '/dev/null',
|
||||
'bash', '-c', fullCmd
|
||||
], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
screenProcess.unref();
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to spawn 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);
|
||||
if (!match) {
|
||||
return { verdict: 'ERROR', reasoning: `Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs };
|
||||
}
|
||||
|
||||
const verdict = match[1].toUpperCase() as 'IDLE' | 'WORKING';
|
||||
// Everything after the first line is the reasoning
|
||||
const lines = output.split('\n');
|
||||
const reasoning = lines.slice(1).join('\n').trim() || `AI determined: ${verdict}`;
|
||||
|
||||
return { verdict, reasoning, durationMs };
|
||||
}
|
||||
|
||||
private cleanupCheck(): void {
|
||||
// Clear poll timer
|
||||
if (this.checkPollTimer) {
|
||||
clearInterval(this.checkPollTimer);
|
||||
this.checkPollTimer = null;
|
||||
}
|
||||
|
||||
// Clear timeout timer
|
||||
if (this.checkTimeoutTimer) {
|
||||
clearTimeout(this.checkTimeoutTimer);
|
||||
this.checkTimeoutTimer = null;
|
||||
}
|
||||
|
||||
// Kill the screen
|
||||
if (this.checkScreenName) {
|
||||
try {
|
||||
execSync(`screen -X -S ${this.checkScreenName} quit 2>/dev/null`, { timeout: 3000 });
|
||||
} catch {
|
||||
// Screen may already be dead
|
||||
}
|
||||
this.checkScreenName = null;
|
||||
}
|
||||
|
||||
// Delete temp file
|
||||
if (this.checkTempFile) {
|
||||
try {
|
||||
if (existsSync(this.checkTempFile)) {
|
||||
unlinkSync(this.checkTempFile);
|
||||
}
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
this.checkTempFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(errorMsg: string): void {
|
||||
this.consecutiveErrors++;
|
||||
this.log(`AI 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}`);
|
||||
}
|
||||
}
|
||||
+268
-17
@@ -36,6 +36,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session } from './session.js';
|
||||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -101,6 +102,9 @@ export interface DetectionStatus {
|
||||
/** Milliseconds since last working pattern */
|
||||
msSinceLastWorking: number;
|
||||
|
||||
/** Layer 5: AI idle check status */
|
||||
aiCheck: AiCheckState | null;
|
||||
|
||||
/** Overall confidence level (0-100) */
|
||||
confidenceLevel: number;
|
||||
|
||||
@@ -183,6 +187,8 @@ export type RespawnState =
|
||||
| 'watching'
|
||||
/** Completion message detected, waiting for output silence to confirm */
|
||||
| 'confirming_idle'
|
||||
/** AI checker is analyzing terminal output for IDLE/WORKING verdict */
|
||||
| 'ai_checking'
|
||||
/** About to send the update docs prompt */
|
||||
| 'sending_update'
|
||||
/** Waiting for update to complete */
|
||||
@@ -289,6 +295,38 @@ export interface RespawnConfig {
|
||||
* @default 8000 (8 seconds)
|
||||
*/
|
||||
autoAcceptDelayMs: number;
|
||||
|
||||
/**
|
||||
* Whether AI idle check is enabled.
|
||||
* When enabled, spawns a fresh Claude CLI to analyze terminal output
|
||||
* and provide a definitive IDLE/WORKING verdict before starting respawn.
|
||||
* @default true
|
||||
*/
|
||||
aiIdleCheckEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Model to use for AI idle check.
|
||||
* @default 'claude-opus-4-5-20251101'
|
||||
*/
|
||||
aiIdleCheckModel: string;
|
||||
|
||||
/**
|
||||
* Maximum characters of terminal buffer to send to AI checker.
|
||||
* @default 16000
|
||||
*/
|
||||
aiIdleCheckMaxContext: number;
|
||||
|
||||
/**
|
||||
* Timeout for the AI check in ms.
|
||||
* @default 90000 (90 seconds)
|
||||
*/
|
||||
aiIdleCheckTimeoutMs: number;
|
||||
|
||||
/**
|
||||
* Cooldown after WORKING verdict in ms.
|
||||
* @default 180000 (3 minutes)
|
||||
*/
|
||||
aiIdleCheckCooldownMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +356,14 @@ export interface RespawnEvents {
|
||||
detectionUpdate: (status: DetectionStatus) => void;
|
||||
/** Auto-accept sent for plan mode approval */
|
||||
autoAcceptSent: () => void;
|
||||
/** AI idle check started */
|
||||
aiCheckStarted: () => void;
|
||||
/** AI idle check completed with verdict */
|
||||
aiCheckCompleted: (result: AiCheckResult) => void;
|
||||
/** AI idle check failed */
|
||||
aiCheckFailed: (error: string) => void;
|
||||
/** AI idle check cooldown state changed */
|
||||
aiCheckCooldown: (active: boolean, endsAt: number | null) => void;
|
||||
/** Error occurred */
|
||||
error: (error: Error) => void;
|
||||
/** Debug log message */
|
||||
@@ -336,6 +382,11 @@ const DEFAULT_CONFIG: RespawnConfig = {
|
||||
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
||||
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
||||
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
||||
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
||||
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
||||
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
||||
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
||||
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -418,6 +469,9 @@ export class RespawnController extends EventEmitter {
|
||||
/** Timer for auto-accepting plan mode prompts */
|
||||
private autoAcceptTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Timer for pre-filter silence detection (triggers AI check) */
|
||||
private preFilterTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Whether any terminal output has been received since start/last-auto-accept */
|
||||
private hasReceivedOutput: boolean = false;
|
||||
|
||||
@@ -442,6 +496,9 @@ export class RespawnController extends EventEmitter {
|
||||
/** Reference to terminal event handler (for cleanup) */
|
||||
private terminalHandler: ((data: string) => void) | null = null;
|
||||
|
||||
/** AI idle checker instance */
|
||||
private aiChecker: AiIdleChecker;
|
||||
|
||||
/** Timer for /clear step fallback (sends /init if no prompt detected) */
|
||||
private clearFallbackTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -500,6 +557,33 @@ export class RespawnController extends EventEmitter {
|
||||
super();
|
||||
this.session = session;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.aiChecker = new AiIdleChecker(session.id, {
|
||||
enabled: this.config.aiIdleCheckEnabled,
|
||||
model: this.config.aiIdleCheckModel,
|
||||
maxContextChars: this.config.aiIdleCheckMaxContext,
|
||||
checkTimeoutMs: this.config.aiIdleCheckTimeoutMs,
|
||||
cooldownMs: this.config.aiIdleCheckCooldownMs,
|
||||
});
|
||||
this.setupAiCheckerListeners();
|
||||
}
|
||||
|
||||
/** Wire up AI checker events to controller events */
|
||||
private setupAiCheckerListeners(): void {
|
||||
this.aiChecker.on('log', (message: string) => {
|
||||
this.log(message);
|
||||
});
|
||||
|
||||
this.aiChecker.on('cooldownStarted', (endsAt: number) => {
|
||||
this.emit('aiCheckCooldown', true, endsAt);
|
||||
});
|
||||
|
||||
this.aiChecker.on('cooldownEnded', () => {
|
||||
this.emit('aiCheckCooldown', false, null);
|
||||
});
|
||||
|
||||
this.aiChecker.on('disabled', (reason: string) => {
|
||||
this.log(`AI checker disabled: ${reason}. Falling back to noOutputTimeoutMs.`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -557,19 +641,27 @@ export class RespawnController extends EventEmitter {
|
||||
if (this._state === 'stopped') {
|
||||
statusText = 'Controller stopped';
|
||||
waitingFor = 'Start to begin monitoring';
|
||||
} else if (this._state === 'ai_checking') {
|
||||
statusText = 'AI Check: Analyzing terminal output...';
|
||||
waitingFor = 'AI verdict (IDLE or WORKING)';
|
||||
} else if (this._state === 'confirming_idle') {
|
||||
statusText = `Confirming idle (${confidence}% confidence)`;
|
||||
waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`;
|
||||
} else if (this._state === 'watching') {
|
||||
if (completionMessageDetected) {
|
||||
const aiState = this.aiChecker.getState();
|
||||
if (aiState.status === 'cooldown') {
|
||||
const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000);
|
||||
statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
|
||||
waitingFor = 'Cooldown to expire';
|
||||
} else if (completionMessageDetected) {
|
||||
statusText = 'Completion detected, confirming...';
|
||||
waitingFor = 'Output silence to confirm';
|
||||
} else if (workingPatternsAbsent && msSinceLastOutput > 5000) {
|
||||
statusText = 'No activity detected';
|
||||
waitingFor = 'Completion message or timeout';
|
||||
waitingFor = 'Pre-filter conditions for AI check';
|
||||
} else {
|
||||
statusText = 'Watching for completion';
|
||||
waitingFor = 'Completion message (for Xm Xs)';
|
||||
statusText = 'Watching for idle signals';
|
||||
waitingFor = 'Silence + no working patterns + tokens stable';
|
||||
}
|
||||
} else if (this._state.startsWith('waiting_') || this._state.startsWith('sending_')) {
|
||||
statusText = `Respawn step: ${this._state}`;
|
||||
@@ -589,6 +681,7 @@ export class RespawnController extends EventEmitter {
|
||||
msSinceTokenChange,
|
||||
workingPatternsAbsent,
|
||||
msSinceLastWorking,
|
||||
aiCheck: this.config.aiIdleCheckEnabled ? this.aiChecker.getState() : null,
|
||||
confidenceLevel: confidence,
|
||||
statusText,
|
||||
waitingFor,
|
||||
@@ -679,10 +772,12 @@ export class RespawnController extends EventEmitter {
|
||||
this.completionMessageTime = null;
|
||||
this.hasReceivedOutput = false;
|
||||
|
||||
this.aiChecker.reset();
|
||||
this.setState('watching');
|
||||
this.setupTerminalListener();
|
||||
this.startDetectionUpdates();
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
if (this.config.autoAcceptPrompts) {
|
||||
this.startAutoAcceptTimer();
|
||||
}
|
||||
@@ -698,6 +793,7 @@ export class RespawnController extends EventEmitter {
|
||||
*/
|
||||
stop(): void {
|
||||
this.log('Stopping respawn controller');
|
||||
this.aiChecker.cancel();
|
||||
this.clearTimers();
|
||||
this.stopDetectionUpdates();
|
||||
this.setState('stopped');
|
||||
@@ -777,6 +873,7 @@ export class RespawnController extends EventEmitter {
|
||||
this.lastActivityTime = now;
|
||||
this.hasReceivedOutput = true;
|
||||
this.resetNoOutputTimer();
|
||||
this.resetPreFilterTimer();
|
||||
this.resetAutoAcceptTimer();
|
||||
|
||||
// Track token count (Layer 3)
|
||||
@@ -801,6 +898,13 @@ export class RespawnController extends EventEmitter {
|
||||
// Cancel any pending step confirmation (Claude is still working)
|
||||
this.cancelStepConfirm();
|
||||
|
||||
// If AI check is running, cancel it (Claude is working)
|
||||
if (this._state === 'ai_checking') {
|
||||
this.log('Working patterns detected during AI check, cancelling');
|
||||
this.aiChecker.cancel();
|
||||
this.setState('watching');
|
||||
}
|
||||
|
||||
// If we're monitoring init and work started, go to watching (no kickstart needed)
|
||||
if (this._state === 'monitoring_init') {
|
||||
this.log('/init triggered work, skipping kickstart');
|
||||
@@ -842,15 +946,21 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// In confirming_idle state, substantial output cancels the confirmation.
|
||||
// In confirming_idle or ai_checking state, substantial output cancels the flow.
|
||||
// This prevents false triggers when Claude pauses briefly mid-work.
|
||||
if (this._state === 'confirming_idle') {
|
||||
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
|
||||
// Strip ANSI escape codes to check if there's real content
|
||||
const stripped = data.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim();
|
||||
if (stripped.length > 2) {
|
||||
// Real content (not just escape codes or single chars) - cancel confirmation
|
||||
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`);
|
||||
this.cancelCompletionConfirm();
|
||||
if (this._state === 'ai_checking') {
|
||||
this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`);
|
||||
this.aiChecker.cancel();
|
||||
this.setState('watching');
|
||||
} else {
|
||||
// Real content (not just escape codes or single chars) - cancel confirmation
|
||||
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`);
|
||||
this.cancelCompletionConfirm();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1020,7 +1130,7 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all timers (idle, step, completion confirm, no-output, step confirm, auto-accept, and clear fallback) */
|
||||
/** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, and clear fallback) */
|
||||
private clearTimers(): void {
|
||||
this.clearIdleTimer();
|
||||
if (this.stepTimer) {
|
||||
@@ -1043,6 +1153,10 @@ export class RespawnController extends EventEmitter {
|
||||
clearTimeout(this.autoAcceptTimer);
|
||||
this.autoAcceptTimer = null;
|
||||
}
|
||||
if (this.preFilterTimer) {
|
||||
clearTimeout(this.preFilterTimer);
|
||||
this.preFilterTimer = null;
|
||||
}
|
||||
if (this.noOutputTimer) {
|
||||
clearTimeout(this.noOutputTimer);
|
||||
this.noOutputTimer = null;
|
||||
@@ -1084,7 +1198,8 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Start the no-output fallback timer.
|
||||
* If no output for noOutputTimeoutMs, triggers idle detection.
|
||||
* If no output for noOutputTimeoutMs, triggers idle detection as safety net
|
||||
* (used when AI check is disabled or has too many errors).
|
||||
*/
|
||||
private startNoOutputTimer(): void {
|
||||
if (this.noOutputTimer) {
|
||||
@@ -1094,7 +1209,12 @@ export class RespawnController extends EventEmitter {
|
||||
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||
this.log(`No-output fallback triggered (${msSinceOutput}ms since last output)`);
|
||||
this.onIdleConfirmed('no-output fallback');
|
||||
// If AI check is disabled or errored out, go directly to idle
|
||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
||||
this.onIdleConfirmed('no-output fallback (AI check disabled)');
|
||||
} else {
|
||||
this.tryStartAiCheck('no-output fallback');
|
||||
}
|
||||
}
|
||||
}, this.config.noOutputTimeoutMs);
|
||||
}
|
||||
@@ -1107,6 +1227,123 @@ export class RespawnController extends EventEmitter {
|
||||
this.startNoOutputTimer();
|
||||
}
|
||||
|
||||
// ========== Pre-Filter & AI Check Methods ==========
|
||||
|
||||
/**
|
||||
* Start the pre-filter timer.
|
||||
* Fires after completionConfirmMs of silence. When it fires, checks if
|
||||
* all pre-filter conditions are met and starts the AI check if so.
|
||||
* This provides an additional path to AI check even without a completion message.
|
||||
*/
|
||||
private startPreFilterTimer(): void {
|
||||
if (this.preFilterTimer) {
|
||||
clearTimeout(this.preFilterTimer);
|
||||
}
|
||||
// Only set up pre-filter when AI check is enabled
|
||||
if (!this.config.aiIdleCheckEnabled) return;
|
||||
|
||||
this.preFilterTimer = setTimeout(() => {
|
||||
if (this._state === 'watching') {
|
||||
const now = Date.now();
|
||||
const msSinceOutput = now - this.lastOutputTime;
|
||||
const msSinceWorking = now - this.lastWorkingPatternTime;
|
||||
const msSinceTokenChange = now - this.lastTokenChangeTime;
|
||||
|
||||
// Check pre-filter conditions
|
||||
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
|
||||
const noWorkingMet = msSinceWorking >= 3000;
|
||||
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
|
||||
|
||||
if (silenceMet && noWorkingMet && tokensStableMet) {
|
||||
this.log(`Pre-filter conditions met: silence=${msSinceOutput}ms, noWorking=${msSinceWorking}ms, tokensStable=${msSinceTokenChange}ms`);
|
||||
this.tryStartAiCheck('pre-filter');
|
||||
}
|
||||
}
|
||||
}, this.config.completionConfirmMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the pre-filter timer.
|
||||
* Called whenever output is received.
|
||||
*/
|
||||
private resetPreFilterTimer(): void {
|
||||
this.startPreFilterTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start an AI idle check.
|
||||
* Checks if AI check is enabled, not on cooldown, and not already checking.
|
||||
* Falls back to direct idle confirmation if AI check is unavailable.
|
||||
*
|
||||
* @param reason - What triggered this attempt (for logging)
|
||||
*/
|
||||
private tryStartAiCheck(reason: string): void {
|
||||
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
||||
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
||||
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
||||
this.onIdleConfirmed(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// If on cooldown, don't start check - wait for cooldown to expire
|
||||
if (this.aiChecker.isOnCooldown()) {
|
||||
this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If already checking, don't start another
|
||||
if (this.aiChecker.status === 'checking') {
|
||||
this.log('AI check already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the AI check
|
||||
this.startAiCheck(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the AI idle check.
|
||||
* Transitions to 'ai_checking' state and runs the check asynchronously.
|
||||
*
|
||||
* @param reason - What triggered this check (for logging)
|
||||
*/
|
||||
private startAiCheck(reason: string): void {
|
||||
this.setState('ai_checking');
|
||||
this.log(`Starting AI idle check (triggered by: ${reason})`);
|
||||
this.emit('aiCheckStarted');
|
||||
|
||||
// Get the terminal buffer for analysis
|
||||
const buffer = this.terminalBuffer.value;
|
||||
|
||||
this.aiChecker.check(buffer).then((result) => {
|
||||
// If state changed while checking (e.g., cancelled), ignore result
|
||||
if (this._state !== 'ai_checking') {
|
||||
this.log(`AI check result ignored (state is now ${this._state})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.verdict === 'IDLE') {
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||
} else if (result.verdict === 'WORKING') {
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.setState('watching');
|
||||
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
||||
} else {
|
||||
// ERROR verdict
|
||||
this.emit('aiCheckFailed', result.reasoning);
|
||||
this.setState('watching');
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (this._state === 'ai_checking') {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.emit('aiCheckFailed', errorMsg);
|
||||
this.setState('watching');
|
||||
this.log(`AI check error: ${errorMsg}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Auto-Accept Prompt Methods ==========
|
||||
|
||||
/**
|
||||
@@ -1202,7 +1439,7 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Start completion confirmation timer.
|
||||
* After completion message, waits for output silence.
|
||||
* After completion message, waits for output silence then triggers AI check.
|
||||
*/
|
||||
private startCompletionConfirmTimer(): void {
|
||||
if (this.completionConfirmTimer) {
|
||||
@@ -1215,8 +1452,8 @@ export class RespawnController extends EventEmitter {
|
||||
this.completionConfirmTimer = setTimeout(() => {
|
||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||||
this.log(`Idle confirmed: ${msSinceOutput}ms silence after completion message`);
|
||||
this.onIdleConfirmed('completion + silence');
|
||||
this.log(`Silence confirmed after completion message (${msSinceOutput}ms)`);
|
||||
this.tryStartAiCheck('completion + silence');
|
||||
} else {
|
||||
// Output received during wait, stay in confirming state and re-check
|
||||
this.log(`Output received during confirmation, resetting timer`);
|
||||
@@ -1318,8 +1555,8 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires respawnCycleStarted
|
||||
*/
|
||||
private onIdleDetected(): void {
|
||||
// Accept both watching and confirming_idle states
|
||||
if (this._state !== 'watching' && this._state !== 'confirming_idle') {
|
||||
// Accept watching, confirming_idle, and ai_checking states
|
||||
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1439,6 +1676,20 @@ export class RespawnController extends EventEmitter {
|
||||
*/
|
||||
updateConfig(config: Partial<RespawnConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
// Sync AI checker config if relevant fields changed
|
||||
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
|
||||
config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined ||
|
||||
config.aiIdleCheckCooldownMs !== undefined) {
|
||||
this.aiChecker.updateConfig({
|
||||
enabled: this.config.aiIdleCheckEnabled,
|
||||
model: this.config.aiIdleCheckModel,
|
||||
maxContextChars: this.config.aiIdleCheckMaxContext,
|
||||
checkTimeoutMs: this.config.aiIdleCheckTimeoutMs,
|
||||
cooldownMs: this.config.aiIdleCheckCooldownMs,
|
||||
});
|
||||
}
|
||||
|
||||
this.log(`Config updated: ${JSON.stringify(config)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,16 @@ export interface RespawnConfig {
|
||||
autoAcceptPrompts?: boolean;
|
||||
/** Delay before auto-accepting plan mode prompts when no output and no completion message (ms) */
|
||||
autoAcceptDelayMs?: number;
|
||||
/** Whether AI idle check is enabled */
|
||||
aiIdleCheckEnabled?: boolean;
|
||||
/** Model to use for AI idle check */
|
||||
aiIdleCheckModel?: string;
|
||||
/** Maximum characters of terminal buffer for AI check */
|
||||
aiIdleCheckMaxContext?: number;
|
||||
/** Timeout for AI check in ms */
|
||||
aiIdleCheckTimeoutMs?: number;
|
||||
/** Cooldown after WORKING verdict in ms */
|
||||
aiIdleCheckCooldownMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -515,6 +525,16 @@ export interface PersistedRespawnConfig {
|
||||
autoAcceptPrompts?: boolean;
|
||||
/** Delay before auto-accepting prompts (ms) */
|
||||
autoAcceptDelayMs?: number;
|
||||
/** Whether AI idle check is enabled */
|
||||
aiIdleCheckEnabled?: boolean;
|
||||
/** Model to use for AI idle check */
|
||||
aiIdleCheckModel?: string;
|
||||
/** Maximum characters of terminal buffer for AI check */
|
||||
aiIdleCheckMaxContext?: number;
|
||||
/** Timeout for AI check in ms */
|
||||
aiIdleCheckTimeoutMs?: number;
|
||||
/** Cooldown after WORKING verdict in ms */
|
||||
aiIdleCheckCooldownMs?: number;
|
||||
/** Duration in minutes if timed respawn was set */
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
@@ -939,6 +939,36 @@ class ClaudemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('respawn:aiCheckStarted', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
document.getElementById('respawnStep').textContent = 'AI Check: Analyzing...';
|
||||
}
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('respawn:aiCheckCompleted', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
const icon = data.verdict === 'IDLE' ? '✓' : '⏳';
|
||||
document.getElementById('respawnStep').textContent = `AI Check: ${data.verdict} ${icon}`;
|
||||
}
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('respawn:aiCheckFailed', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
document.getElementById('respawnStep').textContent = `AI Check: Error - ${data.error?.substring(0, 50)}`;
|
||||
}
|
||||
});
|
||||
|
||||
this.eventSource.addEventListener('respawn:aiCheckCooldown', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.sessionId === this.activeSessionId && data.active) {
|
||||
const remaining = Math.ceil((data.endsAt - Date.now()) / 1000);
|
||||
document.getElementById('respawnStep').textContent = `AI Check: WORKING (cooldown ${remaining}s)`;
|
||||
}
|
||||
});
|
||||
|
||||
// Respawn timer events
|
||||
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
@@ -1904,6 +1934,26 @@ class ClaudemanApp {
|
||||
} else {
|
||||
confidenceEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show AI check info if available
|
||||
const aiCheckEl = document.getElementById('detectionAiCheck');
|
||||
if (aiCheckEl && detection.aiCheck) {
|
||||
const ai = detection.aiCheck;
|
||||
let aiText = '';
|
||||
if (ai.status === 'checking') {
|
||||
aiText = 'AI: Analyzing...';
|
||||
} else if (ai.status === 'cooldown' && ai.cooldownEndsAt) {
|
||||
const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000);
|
||||
aiText = `AI: WORKING (${remaining}s cooldown)`;
|
||||
} else if (ai.status === 'disabled') {
|
||||
aiText = `AI: Disabled (${ai.disabledReason || 'errors'})`;
|
||||
} else if (ai.lastVerdict) {
|
||||
aiText = `AI: Last=${ai.lastVerdict}`;
|
||||
if (ai.lastCheckDurationMs) aiText += ` (${Math.round(ai.lastCheckDurationMs / 1000)}s)`;
|
||||
}
|
||||
aiCheckEl.textContent = aiText;
|
||||
aiCheckEl.style.display = aiText ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showRespawnTimer() {
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<span class="detection-status" id="detectionStatus"></span>
|
||||
<span class="detection-waiting" id="detectionWaiting"></span>
|
||||
<span class="detection-confidence" id="detectionConfidence"></span>
|
||||
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
|
||||
</span>
|
||||
<span class="respawn-cycles">Cycle <span id="respawnCycleCount">0</span></span>
|
||||
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
|
||||
|
||||
@@ -815,6 +815,11 @@ export class WebServer extends EventEmitter {
|
||||
kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt,
|
||||
autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs,
|
||||
durationMinutes: currentConfig?.durationMinutes,
|
||||
};
|
||||
this.screenManager.updateRespawnConfig(id, merged);
|
||||
@@ -1566,6 +1571,11 @@ export class WebServer extends EventEmitter {
|
||||
kickstartPrompt: config.kickstartPrompt,
|
||||
autoAcceptPrompts: config.autoAcceptPrompts,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs,
|
||||
durationMinutes,
|
||||
};
|
||||
this.screenManager.updateRespawnConfig(sessionId, persistedConfig);
|
||||
@@ -1818,6 +1828,22 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast('respawn:autoAcceptSent', { sessionId });
|
||||
});
|
||||
|
||||
controller.on('aiCheckStarted', () => {
|
||||
this.broadcast('respawn:aiCheckStarted', { sessionId });
|
||||
});
|
||||
|
||||
controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => {
|
||||
this.broadcast('respawn:aiCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs });
|
||||
});
|
||||
|
||||
controller.on('aiCheckFailed', (error: string) => {
|
||||
this.broadcast('respawn:aiCheckFailed', { sessionId, error });
|
||||
});
|
||||
|
||||
controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => {
|
||||
this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt });
|
||||
});
|
||||
|
||||
controller.on('log', (message: string) => {
|
||||
this.broadcast('respawn:log', { sessionId, message });
|
||||
});
|
||||
@@ -2401,6 +2427,11 @@ export class WebServer extends EventEmitter {
|
||||
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,
|
||||
});
|
||||
this.respawnControllers.set(session.id, controller);
|
||||
this.setupRespawnListeners(session.id, controller);
|
||||
@@ -2430,6 +2461,11 @@ export class WebServer extends EventEmitter {
|
||||
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,
|
||||
});
|
||||
this.respawnControllers.set(session.id, controller);
|
||||
this.setupRespawnListeners(session.id, controller);
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { AiIdleChecker, AiCheckVerdict } from '../src/ai-idle-checker.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('node:child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
spawn: vi.fn(() => ({
|
||||
unref: vi.fn(),
|
||||
pid: 12345,
|
||||
on: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock fs
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
readFileSync: vi.fn(() => ''),
|
||||
unlinkSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedSpawn = vi.mocked(spawn);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedReadFileSync = vi.mocked(readFileSync);
|
||||
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
||||
|
||||
describe('AiIdleChecker', () => {
|
||||
let checker: AiIdleChecker;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
checker = new AiIdleChecker('test-session-1234', {
|
||||
checkTimeoutMs: 5000,
|
||||
cooldownMs: 3000,
|
||||
errorCooldownMs: 1000,
|
||||
maxConsecutiveErrors: 3,
|
||||
maxContextChars: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
checker.cancel();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should start in ready status', () => {
|
||||
expect(checker.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should not be on cooldown initially', () => {
|
||||
expect(checker.isOnCooldown()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have clean initial state', () => {
|
||||
const state = checker.getState();
|
||||
expect(state.status).toBe('ready');
|
||||
expect(state.lastVerdict).toBeNull();
|
||||
expect(state.lastReasoning).toBeNull();
|
||||
expect(state.consecutiveErrors).toBe(0);
|
||||
expect(state.totalChecks).toBe(0);
|
||||
expect(state.disabledReason).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Parsing', () => {
|
||||
it('should parse IDLE verdict', async () => {
|
||||
// Set up mock to return IDLE result after polling
|
||||
mockedReadFileSync.mockReturnValueOnce('') // writeFileSync creates empty file
|
||||
.mockReturnValueOnce('IDLE\nSession shows completion message and prompt.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
|
||||
// First poll - empty
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
// Second poll - has result
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('IDLE');
|
||||
expect(result.reasoning).toContain('completion message');
|
||||
});
|
||||
|
||||
it('should parse WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nSpinner characters detected, still processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('some terminal output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('WORKING');
|
||||
expect(result.reasoning).toContain('Spinner');
|
||||
});
|
||||
|
||||
it('should handle lowercase verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('idle\nDone.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('IDLE');
|
||||
});
|
||||
|
||||
it('should return ERROR for unparseable output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('Something unexpected happened.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Could not parse');
|
||||
});
|
||||
|
||||
it('should return ERROR for empty output', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Empty output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Spawn', () => {
|
||||
it('should spawn a screen session for the check', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('terminal output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// Verify screen was spawned with correct args
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'screen',
|
||||
expect.arrayContaining(['-dmS', expect.stringContaining('claudeman-aicheck-')]),
|
||||
expect.objectContaining({ detached: true, stdio: 'ignore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should kill existing screen with same name before spawning', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
// First call should try to kill existing screen
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('screen -X -S claudeman-aicheck-'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should create temp file for output capture', async () => {
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('claudeman-aicheck-'),
|
||||
''
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timeout', () => {
|
||||
it('should timeout after checkTimeoutMs', async () => {
|
||||
// Never return a result
|
||||
mockedReadFileSync.mockReturnValue('');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
|
||||
// Advance past timeout
|
||||
await vi.advanceTimersByTimeAsync(5100);
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancellation', () => {
|
||||
it('should cancel an in-progress check', async () => {
|
||||
mockedReadFileSync.mockReturnValue(''); // Never complete
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
|
||||
// Cancel immediately - the resolve callback will be called synchronously
|
||||
checker.cancel();
|
||||
expect(checker.status).toBe('ready');
|
||||
|
||||
const result = await checkPromise;
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toBe('Cancelled');
|
||||
});
|
||||
|
||||
it('should clean up screen on cancel', async () => {
|
||||
mockedReadFileSync.mockReturnValue('');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
|
||||
checker.cancel();
|
||||
await checkPromise;
|
||||
|
||||
// Should have tried to kill the screen (initial kill + cleanup kill)
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
call => typeof call[0] === 'string' && call[0].includes('quit')
|
||||
);
|
||||
expect(killCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should be a no-op if not checking', () => {
|
||||
checker.cancel(); // Should not throw
|
||||
expect(checker.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cooldown', () => {
|
||||
it('should start cooldown after WORKING verdict', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nStill processing.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
expect(checker.isOnCooldown()).toBe(true);
|
||||
expect(checker.getCooldownRemainingMs()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return to ready after cooldown expires', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
|
||||
// Advance past cooldown
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
|
||||
expect(checker.status).toBe('ready');
|
||||
expect(checker.isOnCooldown()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start new check during cooldown', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await firstCheck;
|
||||
|
||||
// Try to check during cooldown
|
||||
const result = await checker.check('output');
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toBe('On cooldown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should start error cooldown after parse error', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage output\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
expect(checker.getState().consecutiveErrors).toBe(1);
|
||||
});
|
||||
|
||||
it('should disable after maxConsecutiveErrors', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
// Clear cooldown for next check (except after the last one which disables)
|
||||
if (i < 2) {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
}
|
||||
}
|
||||
|
||||
expect(checker.status).toBe('disabled');
|
||||
expect(checker.getState().disabledReason).toContain('3 consecutive errors');
|
||||
});
|
||||
|
||||
it('should reset error counter on successful check', async () => {
|
||||
// First check: error
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const firstCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await firstCheck;
|
||||
expect(checker.getState().consecutiveErrors).toBe(1);
|
||||
|
||||
// Wait for cooldown
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
|
||||
// Second check: success
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nDone.\n__AICHECK_DONE__');
|
||||
const secondCheck = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await secondCheck;
|
||||
|
||||
expect(checker.getState().consecutiveErrors).toBe(0);
|
||||
});
|
||||
|
||||
it('should return ERROR if disabled', async () => {
|
||||
checker.updateConfig({ enabled: false });
|
||||
|
||||
const result = await checker.check('output');
|
||||
expect(result.verdict).toBe('ERROR');
|
||||
expect(result.reasoning).toContain('Disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buffer Handling', () => {
|
||||
it('should strip ANSI codes from terminal buffer', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const ansiBuffer = '\x1b[1mBold\x1b[0m \x1b[32mGreen\x1b[0m text';
|
||||
const checkPromise = checker.check(ansiBuffer);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
// Verify the spawn command was called (which means the prompt was built)
|
||||
expect(mockedSpawn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trim buffer to maxContextChars', async () => {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
// Create buffer longer than maxContextChars (1000)
|
||||
const longBuffer = 'x'.repeat(2000);
|
||||
const checkPromise = checker.check(longBuffer);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
// The check should complete successfully (trimming happened internally)
|
||||
expect(mockedSpawn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Updates', () => {
|
||||
it('should disable when config sets enabled=false', () => {
|
||||
checker.updateConfig({ enabled: false });
|
||||
expect(checker.status).toBe('disabled');
|
||||
});
|
||||
|
||||
it('should re-enable when config sets enabled=true', () => {
|
||||
checker.updateConfig({ enabled: false });
|
||||
expect(checker.status).toBe('disabled');
|
||||
|
||||
checker.updateConfig({ enabled: true });
|
||||
expect(checker.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('should update model in config', () => {
|
||||
checker.updateConfig({ model: 'claude-sonnet-4-20250514' });
|
||||
expect(checker.getConfig().model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset', () => {
|
||||
it('should clear all state on reset', async () => {
|
||||
// Trigger a WORKING verdict to set state
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(checker.status).toBe('cooldown');
|
||||
|
||||
// Reset
|
||||
checker.reset();
|
||||
|
||||
expect(checker.status).toBe('ready');
|
||||
expect(checker.isOnCooldown()).toBe(false);
|
||||
const state = checker.getState();
|
||||
expect(state.lastVerdict).toBeNull();
|
||||
expect(state.consecutiveErrors).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
it('should emit checkStarted event', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('checkStarted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValue('IDLE\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit checkCompleted event with result', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('checkCompleted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('IDLE\nAll done.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
verdict: 'IDLE',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should emit cooldownStarted event after WORKING', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('cooldownStarted', handler);
|
||||
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('WORKING\nBusy.\n__AICHECK_DONE__');
|
||||
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.any(Number));
|
||||
});
|
||||
|
||||
it('should emit disabled event after max errors', async () => {
|
||||
const handler = vi.fn();
|
||||
checker.on('disabled', handler);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
mockedReadFileSync.mockReturnValueOnce('')
|
||||
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
|
||||
const checkPromise = checker.check('output');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await checkPromise;
|
||||
if (i < 2) await vi.advanceTimersByTimeAsync(1100);
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.stringContaining('consecutive errors'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Check Prevention', () => {
|
||||
it('should reject if already checking', async () => {
|
||||
mockedReadFileSync.mockReturnValue(''); // Never complete
|
||||
|
||||
const firstCheck = checker.check('output');
|
||||
|
||||
const secondResult = await checker.check('output');
|
||||
expect(secondResult.verdict).toBe('ERROR');
|
||||
expect(secondResult.reasoning).toBe('Already checking');
|
||||
|
||||
// Clean up first check
|
||||
checker.cancel();
|
||||
await firstCheck;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ describe('RespawnController', () => {
|
||||
interStepDelayMs: 50,
|
||||
completionConfirmMs: 50, // Short confirmation delay for testing
|
||||
noOutputTimeoutMs: 500, // Short fallback timeout for testing
|
||||
aiIdleCheckEnabled: false, // Disable AI check for legacy tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -614,6 +615,7 @@ describe('RespawnController State Transitions', () => {
|
||||
interStepDelayMs: 20,
|
||||
completionConfirmMs: 50, // Short confirmation for testing
|
||||
noOutputTimeoutMs: 300, // Short fallback for testing
|
||||
aiIdleCheckEnabled: false, // Disable AI check for legacy tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -814,6 +816,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
interStepDelayMs: 10,
|
||||
completionConfirmMs: 30, // Short confirmation for testing
|
||||
noOutputTimeoutMs: 200, // Short fallback for testing
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
expect(controller.currentCycle).toBe(0);
|
||||
@@ -873,6 +876,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100, // Short delay for testing
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -899,6 +903,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 200, // Longer than autoAcceptDelay
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -924,6 +929,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -946,6 +952,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -968,6 +975,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 150,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -998,6 +1006,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptCount = 0;
|
||||
@@ -1031,6 +1040,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
completionConfirmMs: 50,
|
||||
interStepDelayMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1062,6 +1072,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1089,6 +1100,7 @@ describe('RespawnController Edge Cases', () => {
|
||||
autoAcceptDelayMs: 100,
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let autoAcceptFired = false;
|
||||
@@ -1116,3 +1128,231 @@ describe('RespawnController Edge Cases', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RespawnController AI Idle Check', () => {
|
||||
let session: MockSession;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new MockSession();
|
||||
});
|
||||
|
||||
it('should have AI idle check enabled by default', () => {
|
||||
const controller = new RespawnController(session as unknown as Session);
|
||||
const config = controller.getConfig();
|
||||
expect(config.aiIdleCheckEnabled).toBe(true);
|
||||
expect(config.aiIdleCheckModel).toBe('claude-opus-4-5-20251101');
|
||||
expect(config.aiIdleCheckMaxContext).toBe(16000);
|
||||
expect(config.aiIdleCheckTimeoutMs).toBe(90000);
|
||||
expect(config.aiIdleCheckCooldownMs).toBe(180000);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should include AI check state in detection status when enabled', () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
aiIdleCheckEnabled: true,
|
||||
});
|
||||
controller.start();
|
||||
|
||||
const detection = controller.getDetectionStatus();
|
||||
expect(detection.aiCheck).not.toBeNull();
|
||||
expect(detection.aiCheck?.status).toBe('ready');
|
||||
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should not include AI check state when disabled', () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
controller.start();
|
||||
|
||||
const detection = controller.getDetectionStatus();
|
||||
expect(detection.aiCheck).toBeNull();
|
||||
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should transition to ai_checking state when pre-filter is met', async () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 500, // Short timeout for test
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
controller.on('stateChanged', (state: string) => states.push(state));
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate completion message
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for completion confirm timer to fire and AI check to start
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Should have transitioned to ai_checking
|
||||
expect(states).toContain('ai_checking');
|
||||
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should cancel AI check when working patterns detected', async () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 5000, // Long timeout so we can interrupt
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for AI check to start
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Simulate working patterns during AI check
|
||||
session.simulateWorking();
|
||||
|
||||
// Should be back to watching
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(controller.state).toBe('watching');
|
||||
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should cancel AI check when substantial output arrives', async () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for AI check to start
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Simulate substantial output during AI check
|
||||
session.simulateTerminalOutput('Some meaningful output that is more than 2 chars');
|
||||
|
||||
// Should be back to watching
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(controller.state).toBe('watching');
|
||||
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should fall back to direct idle when AI check is disabled', async () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: false,
|
||||
});
|
||||
|
||||
let cycleStarted = false;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for completion confirm and direct idle
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(cycleStarted).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should emit aiCheckStarted event', async () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 500,
|
||||
});
|
||||
|
||||
let aiCheckStarted = false;
|
||||
controller.on('aiCheckStarted', () => {
|
||||
aiCheckStarted = true;
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
expect(aiCheckStarted).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should update AI checker config on updateConfig', () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
aiIdleCheckEnabled: true,
|
||||
});
|
||||
|
||||
controller.updateConfig({
|
||||
aiIdleCheckModel: 'claude-sonnet-4-20250514',
|
||||
aiIdleCheckCooldownMs: 60000,
|
||||
});
|
||||
|
||||
const config = controller.getConfig();
|
||||
expect(config.aiIdleCheckModel).toBe('claude-sonnet-4-20250514');
|
||||
expect(config.aiIdleCheckCooldownMs).toBe(60000);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should trigger AI check via completion message path (not requiring 3s working-absent)', async () => {
|
||||
// The pre-filter timer requires 3s without working patterns,
|
||||
// but the completion message path (startCompletionConfirmTimer) bypasses
|
||||
// the working-absent check and goes directly through tryStartAiCheck.
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 500,
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
controller.on('stateChanged', (state: string) => states.push(state));
|
||||
|
||||
controller.start();
|
||||
|
||||
// Completion message triggers the completion confirm timer
|
||||
// which routes through tryStartAiCheck after silence
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for completion confirm timer + AI check start
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
// Should have triggered ai_checking via completion path
|
||||
expect(states).toContain('ai_checking');
|
||||
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should handle AI check timeout gracefully', async () => {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
completionConfirmMs: 50,
|
||||
noOutputTimeoutMs: 5000,
|
||||
aiIdleCheckEnabled: true,
|
||||
aiIdleCheckTimeoutMs: 100, // Very short timeout
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
controller.on('stateChanged', (state: string) => states.push(state));
|
||||
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
|
||||
// Wait for AI check to start and timeout
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Should return to watching after timeout (with cooldown)
|
||||
expect(controller.state).toBe('watching');
|
||||
controller.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user