2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* @fileoverview Respawn Controller for autonomous Claude Code session cycling
|
|
|
|
|
|
*
|
|
|
|
|
|
* The RespawnController manages automatic respawning of Claude Code sessions.
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* When Claude finishes working (detected by completion message + output silence),
|
|
|
|
|
|
* it automatically cycles through update → clear → init steps to keep the session productive.
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
|
|
|
|
|
* ## State Machine
|
|
|
|
|
|
*
|
|
|
|
|
|
* ```
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* WATCHING → CONFIRMING_IDLE → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR
|
|
|
|
|
|
* ↑ │ │
|
|
|
|
|
|
* │ │ (new output) ▼
|
|
|
|
|
|
* │ └─────────────► SENDING_INIT → WAITING_INIT → MONITORING_INIT ────────┘
|
|
|
|
|
|
* │ │
|
|
|
|
|
|
* │ ▼ (if no work triggered)
|
|
|
|
|
|
* └──────────────────────── SENDING_KICKSTART → WAITING_KICKSTART ──┘
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* ```
|
|
|
|
|
|
*
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* ## Idle Detection (Updated for Claude Code 2024+)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Primary detection: Completion message pattern "for Xm Xs" (e.g., "✻ Worked for 2m 46s")
|
|
|
|
|
|
* Confirmation: No new output for configurable duration (default 5s)
|
|
|
|
|
|
* Fallback: No output at all for extended period (default 30s)
|
|
|
|
|
|
*
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* ## Configuration
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `sendClear`: Whether to send /clear after update (default: true)
|
|
|
|
|
|
* - `sendInit`: Whether to send /init after clear (default: true)
|
|
|
|
|
|
* - `kickstartPrompt`: Optional prompt if /init doesn't trigger work
|
2026-01-24 04:35:20 +01:00
|
|
|
|
* - `completionConfirmMs`: Time to wait after completion message (default: 10000)
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* - `noOutputTimeoutMs`: Fallback timeout with no output at all (default: 30000)
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @module respawn-controller
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-01-18 07:31:19 +01:00
|
|
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
|
|
import { Session } from './session.js';
|
2026-01-24 18:48:37 +01:00
|
|
|
|
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
// ========== Configuration Constants ==========
|
2026-01-19 12:32:36 +01:00
|
|
|
|
|
2026-01-18 07:31:19 +01:00
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Maximum terminal buffer size for respawn controller.
|
|
|
|
|
|
* Buffer is trimmed when this limit is exceeded to prevent memory issues.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const MAX_RESPAWN_BUFFER_SIZE = 1024 * 1024; // 1MB
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Size to trim buffer to when MAX_RESPAWN_BUFFER_SIZE is exceeded.
|
|
|
|
|
|
* Keeps the most recent output for pattern detection.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const RESPAWN_BUFFER_TRIM_SIZE = 512 * 1024; // 512KB
|
|
|
|
|
|
|
2026-01-21 04:59:15 +01:00
|
|
|
|
// ========== Constants ==========
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-24 04:35:20 +01:00
|
|
|
|
* Pattern to detect completion messages from Claude Code.
|
|
|
|
|
|
* Requires "Worked for" prefix to avoid false positives from bare time durations
|
|
|
|
|
|
* in regular text (e.g., "wait for 5s", "run for 2m").
|
|
|
|
|
|
*
|
|
|
|
|
|
* Matches: "✻ Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s"
|
|
|
|
|
|
* Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..."
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*/
|
2026-01-24 04:35:20 +01:00
|
|
|
|
const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i;
|
2026-01-22 19:24:36 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Pattern to extract token count from Claude's status line.
|
|
|
|
|
|
* Matches: "123.4k tokens", "5234 tokens", "1.2M tokens"
|
|
|
|
|
|
*/
|
|
|
|
|
|
const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
|
|
|
|
|
|
|
|
|
|
|
|
// Note: The old '↵ send' indicator is no longer reliable in Claude Code 2024+
|
|
|
|
|
|
// Detection now uses completion message patterns ("for Xm Xs") instead.
|
|
|
|
|
|
|
|
|
|
|
|
// ========== Detection Layer Types ==========
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Detection layers for multi-signal idle detection.
|
|
|
|
|
|
* Each layer provides a confidence signal that Claude has finished working.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface DetectionStatus {
|
|
|
|
|
|
/** Layer 1: Completion message detected ("for Xm Xs") */
|
|
|
|
|
|
completionMessageDetected: boolean;
|
|
|
|
|
|
/** Timestamp when completion message was last seen */
|
|
|
|
|
|
completionMessageTime: number | null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 2: Output silence - no new output for threshold duration */
|
|
|
|
|
|
outputSilent: boolean;
|
|
|
|
|
|
/** Milliseconds since last output */
|
|
|
|
|
|
msSinceLastOutput: number;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 3: Token count stability - tokens haven't changed */
|
|
|
|
|
|
tokensStable: boolean;
|
|
|
|
|
|
/** Last observed token count */
|
|
|
|
|
|
lastTokenCount: number;
|
|
|
|
|
|
/** Milliseconds since token count changed */
|
|
|
|
|
|
msSinceTokenChange: number;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 4: Working patterns absent - no spinners/activity words */
|
|
|
|
|
|
workingPatternsAbsent: boolean;
|
|
|
|
|
|
/** Milliseconds since last working pattern */
|
|
|
|
|
|
msSinceLastWorking: number;
|
|
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
/** Layer 5: AI idle check status */
|
|
|
|
|
|
aiCheck: AiCheckState | null;
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/** Overall confidence level (0-100) */
|
|
|
|
|
|
confidenceLevel: number;
|
|
|
|
|
|
|
|
|
|
|
|
/** Human-readable status for UI */
|
|
|
|
|
|
statusText: string;
|
|
|
|
|
|
|
|
|
|
|
|
/** What the controller is currently waiting for */
|
|
|
|
|
|
waitingFor: string;
|
|
|
|
|
|
}
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
2026-01-21 04:55:48 +01:00
|
|
|
|
// ========== Buffer Accumulator ==========
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* High-performance buffer accumulator using array-based collection.
|
|
|
|
|
|
* Reduces GC pressure by avoiding repeated string concatenation.
|
|
|
|
|
|
*/
|
|
|
|
|
|
class BufferAccumulator {
|
|
|
|
|
|
private chunks: string[] = [];
|
|
|
|
|
|
private totalLength: number = 0;
|
|
|
|
|
|
private readonly maxSize: number;
|
|
|
|
|
|
private readonly trimSize: number;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(maxSize: number, trimSize: number) {
|
|
|
|
|
|
this.maxSize = maxSize;
|
|
|
|
|
|
this.trimSize = trimSize;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
append(data: string): void {
|
|
|
|
|
|
if (!data) return;
|
|
|
|
|
|
this.chunks.push(data);
|
|
|
|
|
|
this.totalLength += data.length;
|
|
|
|
|
|
if (this.totalLength > this.maxSize) {
|
|
|
|
|
|
this.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get value(): string {
|
|
|
|
|
|
if (this.chunks.length === 0) return '';
|
|
|
|
|
|
if (this.chunks.length === 1) return this.chunks[0];
|
|
|
|
|
|
const result = this.chunks.join('');
|
|
|
|
|
|
this.chunks = [result];
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get length(): number {
|
|
|
|
|
|
return this.totalLength;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clear(): void {
|
|
|
|
|
|
this.chunks = [];
|
|
|
|
|
|
this.totalLength = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private trim(): void {
|
|
|
|
|
|
const full = this.chunks.join('');
|
|
|
|
|
|
const trimmed = full.slice(-this.trimSize);
|
|
|
|
|
|
this.chunks = [trimmed];
|
|
|
|
|
|
this.totalLength = trimmed.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
// ========== Type Definitions ==========
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Respawn sequence states.
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*
|
|
|
|
|
|
* The controller cycles through these states:
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* ```
|
|
|
|
|
|
* WATCHING → SENDING_UPDATE → WAITING_UPDATE →
|
|
|
|
|
|
* SENDING_CLEAR → WAITING_CLEAR →
|
|
|
|
|
|
* SENDING_INIT → WAITING_INIT →
|
|
|
|
|
|
* MONITORING_INIT → (maybe SENDING_KICKSTART → WAITING_KICKSTART) →
|
|
|
|
|
|
* WATCHING (repeat)
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*
|
|
|
|
|
|
* Steps can be skipped via config (`sendClear: false`, `sendInit: false`).
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
export type RespawnState =
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Watching for idle, ready to start respawn sequence */
|
|
|
|
|
|
| 'watching'
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/** Completion message detected, waiting for output silence to confirm */
|
|
|
|
|
|
| 'confirming_idle'
|
2026-01-24 18:48:37 +01:00
|
|
|
|
/** AI checker is analyzing terminal output for IDLE/WORKING verdict */
|
|
|
|
|
|
| 'ai_checking'
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** About to send the update docs prompt */
|
|
|
|
|
|
| 'sending_update'
|
|
|
|
|
|
/** Waiting for update to complete */
|
|
|
|
|
|
| 'waiting_update'
|
|
|
|
|
|
/** About to send /clear command */
|
|
|
|
|
|
| 'sending_clear'
|
|
|
|
|
|
/** Waiting for clear to complete */
|
|
|
|
|
|
| 'waiting_clear'
|
|
|
|
|
|
/** About to send /init command */
|
|
|
|
|
|
| 'sending_init'
|
|
|
|
|
|
/** Waiting for init to complete */
|
|
|
|
|
|
| 'waiting_init'
|
|
|
|
|
|
/** Monitoring if /init triggered work */
|
|
|
|
|
|
| 'monitoring_init'
|
|
|
|
|
|
/** About to send kickstart prompt */
|
|
|
|
|
|
| 'sending_kickstart'
|
|
|
|
|
|
/** Waiting for kickstart to complete */
|
|
|
|
|
|
| 'waiting_kickstart'
|
|
|
|
|
|
/** Controller stopped (not running) */
|
|
|
|
|
|
| 'stopped';
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Configuration options for the RespawnController.
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
export interface RespawnConfig {
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* How long to wait after seeing prompt before considering truly idle.
|
|
|
|
|
|
* Prevents premature cycling when user is about to type.
|
|
|
|
|
|
* @default 10000 (10 seconds)
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
idleTimeoutMs: number;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The prompt to send when updating docs.
|
|
|
|
|
|
* Sent at the start of each respawn cycle.
|
|
|
|
|
|
* @default 'update all the docs and CLAUDE.md'
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
updatePrompt: string;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Delay between sending steps (ms).
|
|
|
|
|
|
* Gives Claude time to process each command.
|
|
|
|
|
|
* @default 1000 (1 second)
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
interStepDelayMs: number;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether the respawn loop is enabled.
|
|
|
|
|
|
* When false, start() will be a no-op.
|
|
|
|
|
|
* @default true
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
enabled: boolean;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether to send /clear after update prompt completes.
|
|
|
|
|
|
* Resets Claude's context for fresh start.
|
|
|
|
|
|
* @default true
|
|
|
|
|
|
*/
|
2026-01-19 00:38:11 +01:00
|
|
|
|
sendClear: boolean;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Whether to send /init after /clear completes.
|
|
|
|
|
|
* Re-initializes Claude with CLAUDE.md context.
|
|
|
|
|
|
* @default true
|
|
|
|
|
|
*/
|
2026-01-19 00:38:11 +01:00
|
|
|
|
sendInit: boolean;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Optional prompt to send if /init doesn't trigger work.
|
|
|
|
|
|
* Used as a fallback when /init completes but Claude doesn't start working.
|
|
|
|
|
|
* @default undefined
|
|
|
|
|
|
*/
|
2026-01-19 13:08:53 +01:00
|
|
|
|
kickstartPrompt?: string;
|
2026-01-22 19:24:36 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Time to wait after completion message before confirming idle (ms).
|
|
|
|
|
|
* After seeing "for Xm Xs" pattern, waits this long with no new output.
|
2026-01-24 04:35:20 +01:00
|
|
|
|
* @default 10000 (10 seconds)
|
2026-01-22 19:24:36 +01:00
|
|
|
|
*/
|
|
|
|
|
|
completionConfirmMs: number;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Fallback timeout when no output received at all (ms).
|
|
|
|
|
|
* If no terminal output for this duration, assumes idle even without completion message.
|
|
|
|
|
|
* @default 30000 (30 seconds)
|
|
|
|
|
|
*/
|
|
|
|
|
|
noOutputTimeoutMs: number;
|
2026-01-23 01:25:09 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-24 03:10:02 +01:00
|
|
|
|
* Whether to auto-accept plan mode prompts by pressing Enter.
|
|
|
|
|
|
* When Claude enters plan mode and presents a plan for approval, output stops
|
|
|
|
|
|
* without a completion message. This feature detects that state and sends Enter
|
|
|
|
|
|
* to accept the plan. Does NOT auto-accept AskUserQuestion prompts (those are
|
|
|
|
|
|
* blocked via the elicitation_dialog hook signal).
|
2026-01-23 01:25:09 +01:00
|
|
|
|
* @default true
|
|
|
|
|
|
*/
|
|
|
|
|
|
autoAcceptPrompts: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-24 03:10:02 +01:00
|
|
|
|
* Delay before auto-accepting plan mode prompts (ms).
|
|
|
|
|
|
* After no output for this duration AND no completion message detected
|
|
|
|
|
|
* AND no elicitation dialog signaled, sends Enter to accept the plan.
|
|
|
|
|
|
* Must be shorter than noOutputTimeoutMs.
|
2026-01-23 01:25:09 +01:00
|
|
|
|
* @default 8000 (8 seconds)
|
|
|
|
|
|
*/
|
|
|
|
|
|
autoAcceptDelayMs: number;
|
2026-01-24 18:48:37 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Events emitted by RespawnController.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @event stateChanged - Fired when state machine transitions
|
|
|
|
|
|
* @event respawnCycleStarted - Fired when a new cycle begins
|
|
|
|
|
|
* @event respawnCycleCompleted - Fired when a cycle finishes
|
|
|
|
|
|
* @event stepSent - Fired when a command is sent to the session
|
|
|
|
|
|
* @event stepCompleted - Fired when a step finishes (ready indicator detected)
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* @event detectionUpdate - Fired when detection status changes (for UI)
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* @event error - Fired on errors
|
|
|
|
|
|
* @event log - Fired for debug logging
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
export interface RespawnEvents {
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** State machine transition */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** New respawn cycle started */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
respawnCycleStarted: (cycleNumber: number) => void;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Respawn cycle finished */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
respawnCycleCompleted: (cycleNumber: number) => void;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Command sent to session */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
stepSent: (step: string, input: string) => void;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Step completed (ready indicator detected) */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
stepCompleted: (step: string) => void;
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/** Detection status update for UI display */
|
|
|
|
|
|
detectionUpdate: (status: DetectionStatus) => void;
|
2026-01-24 03:10:02 +01:00
|
|
|
|
/** Auto-accept sent for plan mode approval */
|
2026-01-23 01:25:09 +01:00
|
|
|
|
autoAcceptSent: () => void;
|
2026-01-24 18:48:37 +01:00
|
|
|
|
/** 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;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Error occurred */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
error: (error: Error) => void;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Debug log message */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
log: (message: string) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Default configuration values */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
const DEFAULT_CONFIG: RespawnConfig = {
|
2026-01-22 19:24:36 +01:00
|
|
|
|
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
2026-01-18 07:31:19 +01:00
|
|
|
|
updatePrompt: 'update all the docs and CLAUDE.md',
|
|
|
|
|
|
interStepDelayMs: 1000, // 1 second between steps
|
|
|
|
|
|
enabled: true,
|
2026-01-19 00:38:11 +01:00
|
|
|
|
sendClear: true, // send /clear after update prompt
|
|
|
|
|
|
sendInit: true, // send /init after /clear
|
2026-01-24 04:35:20 +01:00
|
|
|
|
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
2026-01-22 19:24:36 +01:00
|
|
|
|
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
2026-01-24 03:10:02 +01:00
|
|
|
|
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
2026-01-23 01:25:09 +01:00
|
|
|
|
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
2026-01-24 18:48:37 +01:00
|
|
|
|
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
|
2026-01-18 07:31:19 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* RespawnController - Automatic session cycling for continuous Claude work.
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Monitors a Claude Code session for idle state and automatically cycles
|
|
|
|
|
|
* through update → clear → init steps to keep the session productive.
|
|
|
|
|
|
*
|
|
|
|
|
|
* ## How It Works
|
|
|
|
|
|
*
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* 1. **Idle Detection**: Watches for completion message ("for Xm Xs" pattern)
|
|
|
|
|
|
* 2. **Confirmation**: Waits for output silence (no new tokens for 5s)
|
|
|
|
|
|
* 3. **Update**: Sends configured prompt (e.g., "update all docs")
|
|
|
|
|
|
* 4. **Clear**: Sends `/clear` to reset context (optional)
|
|
|
|
|
|
* 5. **Init**: Sends `/init` to re-initialize with CLAUDE.md (optional)
|
|
|
|
|
|
* 6. **Kickstart**: If /init doesn't trigger work, sends fallback prompt (optional)
|
|
|
|
|
|
* 7. **Repeat**: Returns to watching state for next cycle
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* ## Idle Detection (Updated for Claude Code 2024+)
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* Primary: Completion message with time duration (e.g., "✻ Worked for 2m 46s")
|
|
|
|
|
|
* The pattern "for Xm Xs" indicates Claude finished work and reports duration.
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* Confirmation: After seeing completion message, waits for output silence.
|
2026-01-24 04:35:20 +01:00
|
|
|
|
* If no new output for `completionConfirmMs` (default 10s), confirms idle.
|
2026-01-22 19:24:36 +01:00
|
|
|
|
*
|
|
|
|
|
|
* Fallback: If no output at all for `noOutputTimeoutMs` (default 30s), assumes idle.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Working indicators: Thinking, Writing, spinner characters, etc. reset detection.
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
|
|
|
|
|
* ## Events
|
|
|
|
|
|
*
|
|
|
|
|
|
* - `stateChanged`: State machine transition
|
|
|
|
|
|
* - `respawnCycleStarted`: New cycle began
|
|
|
|
|
|
* - `respawnCycleCompleted`: Cycle finished
|
|
|
|
|
|
* - `stepSent`: Command sent to session
|
|
|
|
|
|
* - `stepCompleted`: Step finished
|
|
|
|
|
|
* - `log`: Debug messages
|
|
|
|
|
|
*
|
|
|
|
|
|
* @extends EventEmitter
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* ```typescript
|
|
|
|
|
|
* const respawn = new RespawnController(session, {
|
|
|
|
|
|
* updatePrompt: 'continue working on the task',
|
2026-01-24 04:35:20 +01:00
|
|
|
|
* completionConfirmMs: 10000, // Wait 10s after completion message
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* });
|
|
|
|
|
|
*
|
|
|
|
|
|
* respawn.on('respawnCycleCompleted', (cycle) => {
|
|
|
|
|
|
* console.log(`Completed cycle ${cycle}`);
|
|
|
|
|
|
* });
|
|
|
|
|
|
*
|
|
|
|
|
|
* respawn.start();
|
|
|
|
|
|
* ```
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
export class RespawnController extends EventEmitter {
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** The session being controlled */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private session: Session;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Current configuration */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private config: RespawnConfig;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Current state machine state */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private _state: RespawnState = 'stopped';
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Timer for idle detection timeout */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private idleTimer: NodeJS.Timeout | null = null;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Timer for step delays */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private stepTimer: NodeJS.Timeout | null = null;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/** Timer for completion confirmation (Layer 2) */
|
|
|
|
|
|
private completionConfirmTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Timer for no-output fallback (Layer 5) */
|
|
|
|
|
|
private noOutputTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Timer for periodic detection status updates */
|
|
|
|
|
|
private detectionUpdateTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
2026-01-24 03:10:02 +01:00
|
|
|
|
/** Timer for auto-accepting plan mode prompts */
|
2026-01-23 01:25:09 +01:00
|
|
|
|
private autoAcceptTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
/** Timer for pre-filter silence detection (triggers AI check) */
|
|
|
|
|
|
private preFilterTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
2026-01-23 01:25:09 +01:00
|
|
|
|
/** Whether any terminal output has been received since start/last-auto-accept */
|
|
|
|
|
|
private hasReceivedOutput: boolean = false;
|
|
|
|
|
|
|
2026-01-24 03:10:02 +01:00
|
|
|
|
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
|
|
|
|
|
private elicitationDetected: boolean = false;
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/** Number of completed respawn cycles */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private cycleCount: number = 0;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Timestamp of last terminal activity */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private lastActivityTime: number = 0;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
2026-01-21 04:55:48 +01:00
|
|
|
|
/** Buffer for recent terminal output (uses BufferAccumulator to reduce GC pressure) */
|
|
|
|
|
|
private terminalBuffer = new BufferAccumulator(MAX_RESPAWN_BUFFER_SIZE, RESPAWN_BUFFER_TRIM_SIZE);
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Whether a prompt indicator was detected */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private promptDetected: boolean = false;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Whether a working indicator was detected */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private workingDetected: boolean = false;
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/** Reference to terminal event handler (for cleanup) */
|
2026-01-19 15:12:36 +01:00
|
|
|
|
private terminalHandler: ((data: string) => void) | null = null;
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
/** AI idle checker instance */
|
|
|
|
|
|
private aiChecker: AiIdleChecker;
|
|
|
|
|
|
|
2026-01-21 18:19:22 +01:00
|
|
|
|
/** Timer for /clear step fallback (sends /init if no prompt detected) */
|
|
|
|
|
|
private clearFallbackTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
2026-01-22 19:31:24 +01:00
|
|
|
|
/** Timer for step completion confirmation (waits for silence after completion) */
|
|
|
|
|
|
private stepConfirmTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
2026-01-21 18:19:22 +01:00
|
|
|
|
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
|
|
|
|
|
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// ========== Multi-Layer Detection State ==========
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 1: Timestamp when completion message was detected */
|
|
|
|
|
|
private completionMessageTime: number | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 2: Timestamp of last terminal output received */
|
|
|
|
|
|
private lastOutputTime: number = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 3: Last observed token count */
|
|
|
|
|
|
private lastTokenCount: number = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 3: Timestamp when token count last changed */
|
|
|
|
|
|
private lastTokenChangeTime: number = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** Layer 4: Timestamp when last working pattern was seen */
|
|
|
|
|
|
private lastWorkingPatternTime: number = 0;
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* Patterns indicating Claude is ready for input (legacy fallback).
|
|
|
|
|
|
* Used as secondary signals, not primary detection.
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private readonly PROMPT_PATTERNS = [
|
|
|
|
|
|
'❯', // Standard prompt
|
|
|
|
|
|
'\u276f', // Unicode variant
|
|
|
|
|
|
'⏵', // Claude Code prompt variant
|
|
|
|
|
|
];
|
2026-01-21 03:46:11 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Patterns indicating Claude is actively working.
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* When detected, resets all idle detection timers.
|
|
|
|
|
|
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private readonly WORKING_PATTERNS = [
|
|
|
|
|
|
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
|
|
|
|
|
|
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
|
2026-01-19 12:26:37 +01:00
|
|
|
|
'Synthesizing', 'Brewing', // Claude's processing indicators
|
2026-01-18 07:31:19 +01:00
|
|
|
|
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Creates a new RespawnController.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param session - The Session instance to control
|
|
|
|
|
|
* @param config - Partial configuration (merged with defaults)
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
constructor(session: Session, config: Partial<RespawnConfig> = {}) {
|
|
|
|
|
|
super();
|
|
|
|
|
|
this.session = session;
|
|
|
|
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
2026-01-24 18:48:37 +01:00
|
|
|
|
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.`);
|
|
|
|
|
|
});
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Get the current state machine state.
|
|
|
|
|
|
* @returns Current RespawnState
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
get state(): RespawnState {
|
|
|
|
|
|
return this._state;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Get the current respawn cycle count.
|
|
|
|
|
|
* Increments each time a new cycle starts.
|
|
|
|
|
|
* @returns Number of cycles started
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
get currentCycle(): number {
|
|
|
|
|
|
return this.cycleCount;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Check if the controller is currently running.
|
|
|
|
|
|
* @returns True if state is not 'stopped'
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
get isRunning(): boolean {
|
|
|
|
|
|
return this._state !== 'stopped';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Get current detection status for UI display.
|
|
|
|
|
|
* Shows all detection layers and their current state.
|
|
|
|
|
|
* @returns DetectionStatus object
|
|
|
|
|
|
*/
|
|
|
|
|
|
getDetectionStatus(): DetectionStatus {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const msSinceLastOutput = now - this.lastOutputTime;
|
|
|
|
|
|
const msSinceTokenChange = now - this.lastTokenChangeTime;
|
|
|
|
|
|
const msSinceLastWorking = now - this.lastWorkingPatternTime;
|
|
|
|
|
|
|
|
|
|
|
|
const completionMessageDetected = this.completionMessageTime !== null;
|
|
|
|
|
|
const outputSilent = msSinceLastOutput >= this.config.completionConfirmMs;
|
|
|
|
|
|
const tokensStable = msSinceTokenChange >= this.config.completionConfirmMs;
|
|
|
|
|
|
const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate confidence level (0-100)
|
|
|
|
|
|
let confidence = 0;
|
|
|
|
|
|
if (completionMessageDetected) confidence += 40;
|
|
|
|
|
|
if (outputSilent) confidence += 25;
|
|
|
|
|
|
if (tokensStable) confidence += 20;
|
|
|
|
|
|
if (workingPatternsAbsent) confidence += 15;
|
|
|
|
|
|
|
|
|
|
|
|
// Determine status text and what we're waiting for
|
|
|
|
|
|
let statusText: string;
|
|
|
|
|
|
let waitingFor: string;
|
|
|
|
|
|
|
|
|
|
|
|
if (this._state === 'stopped') {
|
|
|
|
|
|
statusText = 'Controller stopped';
|
|
|
|
|
|
waitingFor = 'Start to begin monitoring';
|
2026-01-24 18:48:37 +01:00
|
|
|
|
} else if (this._state === 'ai_checking') {
|
|
|
|
|
|
statusText = 'AI Check: Analyzing terminal output...';
|
|
|
|
|
|
waitingFor = 'AI verdict (IDLE or WORKING)';
|
2026-01-22 19:24:36 +01:00
|
|
|
|
} 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') {
|
2026-01-24 18:48:37 +01:00
|
|
|
|
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) {
|
2026-01-22 19:24:36 +01:00
|
|
|
|
statusText = 'Completion detected, confirming...';
|
|
|
|
|
|
waitingFor = 'Output silence to confirm';
|
|
|
|
|
|
} else if (workingPatternsAbsent && msSinceLastOutput > 5000) {
|
|
|
|
|
|
statusText = 'No activity detected';
|
2026-01-24 18:48:37 +01:00
|
|
|
|
waitingFor = 'Pre-filter conditions for AI check';
|
2026-01-22 19:24:36 +01:00
|
|
|
|
} else {
|
2026-01-24 18:48:37 +01:00
|
|
|
|
statusText = 'Watching for idle signals';
|
|
|
|
|
|
waitingFor = 'Silence + no working patterns + tokens stable';
|
2026-01-22 19:24:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
} else if (this._state.startsWith('waiting_') || this._state.startsWith('sending_')) {
|
|
|
|
|
|
statusText = `Respawn step: ${this._state}`;
|
|
|
|
|
|
waitingFor = 'Step completion';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
statusText = `State: ${this._state}`;
|
|
|
|
|
|
waitingFor = 'Next event';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
completionMessageDetected,
|
|
|
|
|
|
completionMessageTime: this.completionMessageTime,
|
|
|
|
|
|
outputSilent,
|
|
|
|
|
|
msSinceLastOutput,
|
|
|
|
|
|
tokensStable,
|
|
|
|
|
|
lastTokenCount: this.lastTokenCount,
|
|
|
|
|
|
msSinceTokenChange,
|
|
|
|
|
|
workingPatternsAbsent,
|
|
|
|
|
|
msSinceLastWorking,
|
2026-01-24 18:48:37 +01:00
|
|
|
|
aiCheck: this.config.aiIdleCheckEnabled ? this.aiChecker.getState() : null,
|
2026-01-22 19:24:36 +01:00
|
|
|
|
confidenceLevel: confidence,
|
|
|
|
|
|
statusText,
|
|
|
|
|
|
waitingFor,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Start periodic detection status updates for UI.
|
|
|
|
|
|
* Emits 'detectionUpdate' event every 500ms while running.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private startDetectionUpdates(): void {
|
|
|
|
|
|
this.stopDetectionUpdates();
|
|
|
|
|
|
this.detectionUpdateTimer = setInterval(() => {
|
|
|
|
|
|
if (this._state !== 'stopped') {
|
|
|
|
|
|
this.emit('detectionUpdate', this.getDetectionStatus());
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Stop periodic detection status updates.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private stopDetectionUpdates(): void {
|
|
|
|
|
|
if (this.detectionUpdateTimer) {
|
|
|
|
|
|
clearInterval(this.detectionUpdateTimer);
|
|
|
|
|
|
this.detectionUpdateTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Transition to a new state.
|
|
|
|
|
|
* Emits 'stateChanged' event with old and new states.
|
|
|
|
|
|
* No-op if already in the target state.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param newState - State to transition to
|
|
|
|
|
|
* @fires stateChanged
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private setState(newState: RespawnState): void {
|
|
|
|
|
|
if (newState === this._state) return;
|
|
|
|
|
|
|
|
|
|
|
|
const prevState = this._state;
|
|
|
|
|
|
this._state = newState;
|
|
|
|
|
|
this.log(`State: ${prevState} → ${newState}`);
|
|
|
|
|
|
this.emit('stateChanged', newState, prevState);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Emit a timestamped log message.
|
|
|
|
|
|
* @param message - Log message content
|
|
|
|
|
|
* @fires log
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private log(message: string): void {
|
|
|
|
|
|
const timestamp = new Date().toISOString();
|
|
|
|
|
|
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Start watching the session for idle state.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Begins monitoring terminal output for idle indicators.
|
|
|
|
|
|
* When idle is detected, starts the respawn cycle.
|
|
|
|
|
|
*
|
|
|
|
|
|
* No-op if:
|
|
|
|
|
|
* - `config.enabled` is false
|
|
|
|
|
|
* - Already running (state !== 'stopped')
|
|
|
|
|
|
*
|
|
|
|
|
|
* @fires stateChanged - Transitions to 'watching'
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
start(): void {
|
|
|
|
|
|
if (!this.config.enabled) {
|
|
|
|
|
|
this.log('Respawn is disabled');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this._state !== 'stopped') {
|
|
|
|
|
|
this.log('Already running');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
this.log('Starting respawn controller (multi-layer detection)');
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize all timestamps
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
this.lastActivityTime = now;
|
|
|
|
|
|
this.lastOutputTime = now;
|
|
|
|
|
|
this.lastTokenChangeTime = now;
|
|
|
|
|
|
this.lastWorkingPatternTime = now;
|
|
|
|
|
|
this.completionMessageTime = null;
|
2026-01-23 01:25:09 +01:00
|
|
|
|
this.hasReceivedOutput = false;
|
2026-01-22 19:24:36 +01:00
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
this.aiChecker.reset();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.setState('watching');
|
|
|
|
|
|
this.setupTerminalListener();
|
2026-01-22 19:24:36 +01:00
|
|
|
|
this.startDetectionUpdates();
|
|
|
|
|
|
this.startNoOutputTimer();
|
2026-01-24 18:48:37 +01:00
|
|
|
|
this.startPreFilterTimer();
|
2026-01-23 01:25:09 +01:00
|
|
|
|
if (this.config.autoAcceptPrompts) {
|
|
|
|
|
|
this.startAutoAcceptTimer();
|
|
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Stop the respawn controller.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Clears all timers, removes terminal listener, and sets state to 'stopped'.
|
|
|
|
|
|
* Safe to call multiple times.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @fires stateChanged - Transitions to 'stopped'
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
stop(): void {
|
|
|
|
|
|
this.log('Stopping respawn controller');
|
2026-01-24 18:48:37 +01:00
|
|
|
|
this.aiChecker.cancel();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.clearTimers();
|
2026-01-22 19:24:36 +01:00
|
|
|
|
this.stopDetectionUpdates();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.setState('stopped');
|
2026-01-19 15:12:36 +01:00
|
|
|
|
if (this.terminalHandler) {
|
|
|
|
|
|
this.session.off('terminal', this.terminalHandler);
|
|
|
|
|
|
this.terminalHandler = null;
|
|
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Pause respawn without stopping.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Clears timers but keeps listening to terminal.
|
|
|
|
|
|
* State is preserved; won't trigger idle detection while paused.
|
|
|
|
|
|
* Use resume() to continue.
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
pause(): void {
|
|
|
|
|
|
this.log('Pausing respawn');
|
|
|
|
|
|
this.clearTimers();
|
|
|
|
|
|
// Stay in current state but clear timers
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Resume respawn after pause.
|
|
|
|
|
|
*
|
|
|
|
|
|
* If in 'watching' state, immediately checks for idle condition.
|
|
|
|
|
|
* Otherwise, continues from current state.
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
resume(): void {
|
|
|
|
|
|
this.log('Resuming respawn');
|
|
|
|
|
|
if (this._state === 'watching') {
|
|
|
|
|
|
this.checkIdleAndMaybeStart();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Set up terminal output listener on the session.
|
|
|
|
|
|
* Removes any previous listener first to avoid duplicates.
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private setupTerminalListener(): void {
|
2026-01-19 15:12:36 +01:00
|
|
|
|
// Remove our previous listener if any (don't remove other listeners!)
|
|
|
|
|
|
if (this.terminalHandler) {
|
|
|
|
|
|
this.session.off('terminal', this.terminalHandler);
|
|
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-19 15:12:36 +01:00
|
|
|
|
this.terminalHandler = (data: string) => {
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.handleTerminalData(data);
|
2026-01-19 15:12:36 +01:00
|
|
|
|
};
|
|
|
|
|
|
this.session.on('terminal', this.terminalHandler);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* Process terminal data for idle/working detection using multi-layer approach.
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* Detection Layers:
|
|
|
|
|
|
* 1. Completion message ("for Xm Xs") - PRIMARY signal
|
|
|
|
|
|
* 2. Output silence - confirms completion
|
|
|
|
|
|
* 3. Token stability - additional confirmation
|
|
|
|
|
|
* 4. Working pattern absence - supports idle detection
|
|
|
|
|
|
* 5. No-output fallback - catches edge cases
|
2026-01-21 03:46:11 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @param data - Raw terminal output data
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private handleTerminalData(data: string): void {
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// Guard against null/undefined/empty data
|
|
|
|
|
|
if (!data || typeof data !== 'string') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
2026-01-21 04:55:48 +01:00
|
|
|
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
|
|
|
|
|
this.terminalBuffer.append(data);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// Track output time (Layer 2)
|
|
|
|
|
|
this.lastOutputTime = now;
|
|
|
|
|
|
this.lastActivityTime = now;
|
2026-01-23 01:25:09 +01:00
|
|
|
|
this.hasReceivedOutput = true;
|
2026-01-22 19:24:36 +01:00
|
|
|
|
this.resetNoOutputTimer();
|
2026-01-24 18:48:37 +01:00
|
|
|
|
this.resetPreFilterTimer();
|
2026-01-23 01:25:09 +01:00
|
|
|
|
this.resetAutoAcceptTimer();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// Track token count (Layer 3)
|
|
|
|
|
|
const tokenCount = this.extractTokenCount(data);
|
|
|
|
|
|
if (tokenCount !== null && tokenCount !== this.lastTokenCount) {
|
|
|
|
|
|
this.lastTokenCount = tokenCount;
|
|
|
|
|
|
this.lastTokenChangeTime = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Detect working patterns (Layer 4)
|
|
|
|
|
|
const isWorking = this.hasWorkingPattern(data);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
if (isWorking) {
|
|
|
|
|
|
this.workingDetected = true;
|
|
|
|
|
|
this.promptDetected = false;
|
2026-01-24 03:10:02 +01:00
|
|
|
|
this.elicitationDetected = false; // Clear on new work cycle
|
2026-01-22 19:24:36 +01:00
|
|
|
|
this.lastWorkingPatternTime = now;
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.clearIdleTimer();
|
2026-01-19 13:08:53 +01:00
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// Cancel any pending completion confirmation
|
|
|
|
|
|
this.cancelCompletionConfirm();
|
|
|
|
|
|
|
2026-01-22 19:31:24 +01:00
|
|
|
|
// Cancel any pending step confirmation (Claude is still working)
|
|
|
|
|
|
this.cancelStepConfirm();
|
|
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
// 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');
|
|
|
|
|
|
this.emit('stepCompleted', 'init');
|
|
|
|
|
|
this.completeCycle();
|
|
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// Detect completion message (Layer 1) - PRIMARY DETECTION
|
|
|
|
|
|
if (this.isCompletionMessage(data)) {
|
|
|
|
|
|
this.completionMessageTime = now;
|
|
|
|
|
|
this.workingDetected = false;
|
2026-01-23 01:25:09 +01:00
|
|
|
|
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
2026-01-22 19:24:36 +01:00
|
|
|
|
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
|
|
|
|
|
|
|
|
|
|
|
// In watching state, start completion confirmation timer
|
|
|
|
|
|
if (this._state === 'watching') {
|
|
|
|
|
|
this.startCompletionConfirmTimer();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:31:24 +01:00
|
|
|
|
// In waiting states, also use confirmation timer (same detection logic)
|
|
|
|
|
|
// This ensures we wait for Claude to finish before proceeding
|
2026-01-22 19:24:36 +01:00
|
|
|
|
switch (this._state) {
|
|
|
|
|
|
case 'waiting_update':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.startStepConfirmTimer('update');
|
2026-01-22 19:24:36 +01:00
|
|
|
|
break;
|
|
|
|
|
|
case 'waiting_clear':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.checkClearComplete(); // /clear is quick, no need to wait
|
2026-01-22 19:24:36 +01:00
|
|
|
|
break;
|
|
|
|
|
|
case 'waiting_init':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.startStepConfirmTimer('init');
|
2026-01-22 19:24:36 +01:00
|
|
|
|
break;
|
|
|
|
|
|
case 'waiting_kickstart':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.startStepConfirmTimer('kickstart');
|
2026-01-22 19:24:36 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
// In confirming_idle or ai_checking state, substantial output cancels the flow.
|
2026-01-24 04:35:20 +01:00
|
|
|
|
// This prevents false triggers when Claude pauses briefly mid-work.
|
2026-01-24 18:48:37 +01:00
|
|
|
|
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
|
2026-01-24 04:35:20 +01:00
|
|
|
|
// 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) {
|
2026-01-24 18:48:37 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-01-24 04:35:20 +01:00
|
|
|
|
return;
|
2026-01-22 19:24:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
|
|
|
|
|
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern));
|
|
|
|
|
|
if (hasPrompt) {
|
2026-01-19 12:32:36 +01:00
|
|
|
|
this.promptDetected = true;
|
|
|
|
|
|
this.workingDetected = false;
|
|
|
|
|
|
|
2026-01-22 19:31:24 +01:00
|
|
|
|
// Handle legacy detection in waiting states - also use confirmation timers
|
2026-01-19 12:32:36 +01:00
|
|
|
|
switch (this._state) {
|
|
|
|
|
|
case 'waiting_update':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.startStepConfirmTimer('update');
|
2026-01-19 12:32:36 +01:00
|
|
|
|
break;
|
|
|
|
|
|
case 'waiting_clear':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.checkClearComplete(); // /clear is quick, no need to wait
|
2026-01-19 12:32:36 +01:00
|
|
|
|
break;
|
|
|
|
|
|
case 'waiting_init':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.startStepConfirmTimer('init');
|
2026-01-19 12:32:36 +01:00
|
|
|
|
break;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
case 'monitoring_init':
|
|
|
|
|
|
this.checkMonitoringInitIdle();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'waiting_kickstart':
|
2026-01-22 19:31:24 +01:00
|
|
|
|
this.startStepConfirmTimer('kickstart');
|
2026-01-19 13:08:53 +01:00
|
|
|
|
break;
|
2026-01-19 12:32:36 +01:00
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
2026-01-19 12:32:36 +01:00
|
|
|
|
}
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle update step completion.
|
|
|
|
|
|
* Called when ready indicator detected in waiting_update state.
|
|
|
|
|
|
* Proceeds to clear, init, or completes cycle based on config.
|
|
|
|
|
|
* @fires stepCompleted - With step 'update'
|
|
|
|
|
|
*/
|
2026-01-19 12:32:36 +01:00
|
|
|
|
private checkUpdateComplete(): void {
|
|
|
|
|
|
this.clearIdleTimer();
|
|
|
|
|
|
this.log('Update completed (ready indicator)');
|
|
|
|
|
|
this.emit('stepCompleted', 'update');
|
|
|
|
|
|
|
|
|
|
|
|
if (this.config.sendClear) {
|
|
|
|
|
|
this.sendClear();
|
|
|
|
|
|
} else if (this.config.sendInit) {
|
|
|
|
|
|
this.sendInit();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.completeCycle();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle /clear step completion.
|
|
|
|
|
|
* Proceeds to init or completes cycle based on config.
|
|
|
|
|
|
* @fires stepCompleted - With step 'clear'
|
|
|
|
|
|
*/
|
2026-01-19 12:32:36 +01:00
|
|
|
|
private checkClearComplete(): void {
|
|
|
|
|
|
this.clearIdleTimer();
|
2026-01-21 18:19:22 +01:00
|
|
|
|
// Clear the fallback timer since we got prompt detection
|
|
|
|
|
|
if (this.clearFallbackTimer) {
|
|
|
|
|
|
clearTimeout(this.clearFallbackTimer);
|
|
|
|
|
|
this.clearFallbackTimer = null;
|
|
|
|
|
|
}
|
2026-01-19 12:32:36 +01:00
|
|
|
|
this.log('/clear completed (ready indicator)');
|
|
|
|
|
|
this.emit('stepCompleted', 'clear');
|
|
|
|
|
|
|
|
|
|
|
|
if (this.config.sendInit) {
|
|
|
|
|
|
this.sendInit();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.completeCycle();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle /init step completion.
|
|
|
|
|
|
* If kickstart is configured, monitors for work.
|
|
|
|
|
|
* Otherwise completes cycle.
|
|
|
|
|
|
* @fires stepCompleted - With step 'init' (if no kickstart)
|
|
|
|
|
|
*/
|
2026-01-19 12:32:36 +01:00
|
|
|
|
private checkInitComplete(): void {
|
|
|
|
|
|
this.clearIdleTimer();
|
|
|
|
|
|
this.log('/init completed (ready indicator)');
|
2026-01-19 13:08:53 +01:00
|
|
|
|
|
|
|
|
|
|
// If kickstart prompt is configured, monitor to see if /init triggered work
|
|
|
|
|
|
if (this.config.kickstartPrompt) {
|
|
|
|
|
|
this.startMonitoringInit();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.emit('stepCompleted', 'init');
|
|
|
|
|
|
this.completeCycle();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Start monitoring to see if /init triggered work.
|
|
|
|
|
|
* Enters 'monitoring_init' state and waits 3s grace period.
|
|
|
|
|
|
* If no work detected, sends kickstart prompt.
|
|
|
|
|
|
*/
|
2026-01-19 13:08:53 +01:00
|
|
|
|
private startMonitoringInit(): void {
|
|
|
|
|
|
this.setState('monitoring_init');
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this.terminalBuffer.clear();
|
2026-01-19 13:08:53 +01:00
|
|
|
|
this.workingDetected = false;
|
|
|
|
|
|
this.log('Monitoring if /init triggered work...');
|
|
|
|
|
|
|
|
|
|
|
|
// Give Claude a moment to start working before checking for idle
|
|
|
|
|
|
this.stepTimer = setTimeout(() => {
|
|
|
|
|
|
// If still in monitoring state and no work detected, consider it idle
|
|
|
|
|
|
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
|
|
|
|
|
this.checkMonitoringInitIdle();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 3000); // 3 second grace period for /init to trigger work
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle monitoring timeout when /init didn't trigger work.
|
|
|
|
|
|
* Sends kickstart prompt as fallback.
|
|
|
|
|
|
* @fires stepCompleted - With step 'init'
|
|
|
|
|
|
*/
|
2026-01-19 13:08:53 +01:00
|
|
|
|
private checkMonitoringInitIdle(): void {
|
|
|
|
|
|
this.clearIdleTimer();
|
|
|
|
|
|
if (this.stepTimer) {
|
|
|
|
|
|
clearTimeout(this.stepTimer);
|
|
|
|
|
|
this.stepTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.log('/init did not trigger work, sending kickstart prompt');
|
2026-01-19 12:32:36 +01:00
|
|
|
|
this.emit('stepCompleted', 'init');
|
2026-01-19 13:08:53 +01:00
|
|
|
|
this.sendKickstart();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Send the kickstart prompt to get Claude working.
|
|
|
|
|
|
* @fires stepSent - With step 'kickstart'
|
|
|
|
|
|
*/
|
2026-01-19 13:08:53 +01:00
|
|
|
|
private sendKickstart(): void {
|
|
|
|
|
|
this.setState('sending_kickstart');
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this.terminalBuffer.clear();
|
2026-01-19 13:08:53 +01:00
|
|
|
|
|
|
|
|
|
|
this.stepTimer = setTimeout(() => {
|
|
|
|
|
|
const prompt = this.config.kickstartPrompt!;
|
|
|
|
|
|
this.log(`Sending kickstart prompt: "${prompt}"`);
|
2026-01-19 15:35:41 +01:00
|
|
|
|
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
2026-01-19 13:08:53 +01:00
|
|
|
|
this.emit('stepSent', 'kickstart', prompt);
|
|
|
|
|
|
this.setState('waiting_kickstart');
|
|
|
|
|
|
this.promptDetected = false;
|
|
|
|
|
|
this.workingDetected = false;
|
|
|
|
|
|
}, this.config.interStepDelayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle kickstart step completion.
|
|
|
|
|
|
* @fires stepCompleted - With step 'kickstart'
|
|
|
|
|
|
*/
|
2026-01-19 13:08:53 +01:00
|
|
|
|
private checkKickstartComplete(): void {
|
|
|
|
|
|
this.clearIdleTimer();
|
|
|
|
|
|
this.log('Kickstart completed (ready indicator)');
|
|
|
|
|
|
this.emit('stepCompleted', 'kickstart');
|
2026-01-19 12:32:36 +01:00
|
|
|
|
this.completeCycle();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
// Note: Legacy startIdleTimer removed - now using completion-based detection
|
|
|
|
|
|
// with startCompletionConfirmTimer() and startNoOutputTimer() instead.
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/** Clear the idle detection timer if running (legacy cleanup) */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private clearIdleTimer(): void {
|
|
|
|
|
|
if (this.idleTimer) {
|
|
|
|
|
|
clearTimeout(this.idleTimer);
|
|
|
|
|
|
this.idleTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
/** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, and clear fallback) */
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private clearTimers(): void {
|
|
|
|
|
|
this.clearIdleTimer();
|
|
|
|
|
|
if (this.stepTimer) {
|
|
|
|
|
|
clearTimeout(this.stepTimer);
|
|
|
|
|
|
this.stepTimer = null;
|
|
|
|
|
|
}
|
2026-01-21 18:19:22 +01:00
|
|
|
|
if (this.clearFallbackTimer) {
|
|
|
|
|
|
clearTimeout(this.clearFallbackTimer);
|
|
|
|
|
|
this.clearFallbackTimer = null;
|
|
|
|
|
|
}
|
2026-01-22 19:24:36 +01:00
|
|
|
|
if (this.completionConfirmTimer) {
|
|
|
|
|
|
clearTimeout(this.completionConfirmTimer);
|
|
|
|
|
|
this.completionConfirmTimer = null;
|
|
|
|
|
|
}
|
2026-01-22 19:31:24 +01:00
|
|
|
|
if (this.stepConfirmTimer) {
|
|
|
|
|
|
clearTimeout(this.stepConfirmTimer);
|
|
|
|
|
|
this.stepConfirmTimer = null;
|
2026-01-23 01:25:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
if (this.autoAcceptTimer) {
|
|
|
|
|
|
clearTimeout(this.autoAcceptTimer);
|
|
|
|
|
|
this.autoAcceptTimer = null;
|
2026-01-22 19:31:24 +01:00
|
|
|
|
}
|
2026-01-24 18:48:37 +01:00
|
|
|
|
if (this.preFilterTimer) {
|
|
|
|
|
|
clearTimeout(this.preFilterTimer);
|
|
|
|
|
|
this.preFilterTimer = null;
|
|
|
|
|
|
}
|
2026-01-22 19:24:36 +01:00
|
|
|
|
if (this.noOutputTimer) {
|
|
|
|
|
|
clearTimeout(this.noOutputTimer);
|
|
|
|
|
|
this.noOutputTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ========== Multi-Layer Detection Methods ==========
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Check if data contains a completion message pattern.
|
|
|
|
|
|
* Matches "for Xh Xm Xs" time duration patterns.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private isCompletionMessage(data: string): boolean {
|
|
|
|
|
|
return COMPLETION_TIME_PATTERN.test(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Check if data contains working patterns.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private hasWorkingPattern(data: string): boolean {
|
|
|
|
|
|
return this.WORKING_PATTERNS.some(pattern => data.includes(pattern));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Extract token count from data if present.
|
|
|
|
|
|
* Returns null if no token pattern found.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private extractTokenCount(data: string): number | null {
|
|
|
|
|
|
const match = data.match(TOKEN_PATTERN);
|
|
|
|
|
|
if (!match) return null;
|
|
|
|
|
|
|
|
|
|
|
|
let count = parseFloat(match[1]);
|
|
|
|
|
|
const suffix = match[2]?.toLowerCase();
|
|
|
|
|
|
if (suffix === 'k') count *= 1000;
|
|
|
|
|
|
else if (suffix === 'm') count *= 1000000;
|
|
|
|
|
|
|
|
|
|
|
|
return Math.round(count);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Start the no-output fallback timer.
|
2026-01-24 18:48:37 +01:00
|
|
|
|
* If no output for noOutputTimeoutMs, triggers idle detection as safety net
|
|
|
|
|
|
* (used when AI check is disabled or has too many errors).
|
2026-01-22 19:24:36 +01:00
|
|
|
|
*/
|
|
|
|
|
|
private startNoOutputTimer(): void {
|
|
|
|
|
|
if (this.noOutputTimer) {
|
|
|
|
|
|
clearTimeout(this.noOutputTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.noOutputTimer = setTimeout(() => {
|
|
|
|
|
|
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)`);
|
2026-01-24 18:48:37 +01:00
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
2026-01-22 19:24:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
}, this.config.noOutputTimeoutMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Reset the no-output fallback timer.
|
|
|
|
|
|
* Called whenever output is received.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private resetNoOutputTimer(): void {
|
|
|
|
|
|
this.startNoOutputTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 18:48:37 +01:00
|
|
|
|
// ========== 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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 01:25:09 +01:00
|
|
|
|
// ========== Auto-Accept Prompt Methods ==========
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Reset the auto-accept timer.
|
|
|
|
|
|
* Called whenever output is received. After autoAcceptDelayMs of silence
|
|
|
|
|
|
* (without a completion message), sends Enter to accept prompts.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private resetAutoAcceptTimer(): void {
|
|
|
|
|
|
if (!this.config.autoAcceptPrompts) return;
|
|
|
|
|
|
this.startAutoAcceptTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Start the auto-accept timer.
|
2026-01-24 03:10:02 +01:00
|
|
|
|
* Fires after autoAcceptDelayMs of no output when no completion message
|
|
|
|
|
|
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
2026-01-23 01:25:09 +01:00
|
|
|
|
*/
|
|
|
|
|
|
private startAutoAcceptTimer(): void {
|
|
|
|
|
|
if (this.autoAcceptTimer) {
|
|
|
|
|
|
clearTimeout(this.autoAcceptTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.autoAcceptTimer = setTimeout(() => {
|
|
|
|
|
|
this.autoAcceptTimer = null;
|
|
|
|
|
|
this.tryAutoAccept();
|
|
|
|
|
|
}, this.config.autoAcceptDelayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cancel the auto-accept timer.
|
|
|
|
|
|
* Called when a completion message is detected (normal idle flow handles it).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private cancelAutoAcceptTimer(): void {
|
|
|
|
|
|
if (this.autoAcceptTimer) {
|
|
|
|
|
|
clearTimeout(this.autoAcceptTimer);
|
|
|
|
|
|
this.autoAcceptTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-24 03:10:02 +01:00
|
|
|
|
* Attempt to auto-accept a plan mode prompt by sending Enter.
|
2026-01-23 01:25:09 +01:00
|
|
|
|
* Only fires when:
|
|
|
|
|
|
* - In 'watching' state (not mid-cycle)
|
|
|
|
|
|
* - No completion message was detected (Claude is waiting for input, not truly idle)
|
2026-01-24 03:10:02 +01:00
|
|
|
|
* - No elicitation dialog was detected (not an AskUserQuestion prompt)
|
2026-01-23 01:25:09 +01:00
|
|
|
|
* - autoAcceptPrompts is enabled
|
|
|
|
|
|
*
|
2026-01-24 03:10:02 +01:00
|
|
|
|
* This handles Claude's plan mode (waiting for approval) by pressing Enter
|
|
|
|
|
|
* to accept the plan. It does NOT auto-accept AskUserQuestion prompts -
|
|
|
|
|
|
* those require explicit user interaction.
|
2026-01-23 01:25:09 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @fires autoAcceptSent
|
|
|
|
|
|
*/
|
|
|
|
|
|
private tryAutoAccept(): void {
|
|
|
|
|
|
// Only auto-accept in watching state (not during a respawn cycle)
|
|
|
|
|
|
if (this._state !== 'watching') return;
|
|
|
|
|
|
|
|
|
|
|
|
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
|
|
|
|
|
if (this.completionMessageTime !== null) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Don't auto-accept if disabled
|
|
|
|
|
|
if (!this.config.autoAcceptPrompts) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
|
|
|
|
|
if (!this.hasReceivedOutput) return;
|
|
|
|
|
|
|
2026-01-24 03:10:02 +01:00
|
|
|
|
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
|
|
|
|
|
if (this.elicitationDetected) {
|
|
|
|
|
|
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-23 01:25:09 +01:00
|
|
|
|
|
2026-01-24 03:10:02 +01:00
|
|
|
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
|
|
|
|
this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, no completion message, no elicitation)`);
|
|
|
|
|
|
|
|
|
|
|
|
// Send Enter to accept the plan
|
2026-01-23 01:25:09 +01:00
|
|
|
|
this.session.writeViaScreen('\r');
|
|
|
|
|
|
this.emit('autoAcceptSent');
|
|
|
|
|
|
|
|
|
|
|
|
// Reset so we don't keep spamming Enter if Claude doesn't respond
|
|
|
|
|
|
this.hasReceivedOutput = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-24 03:10:02 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
|
|
|
|
|
|
* This prevents auto-accept from firing, since the user needs to make a selection.
|
|
|
|
|
|
* The flag is cleared when working patterns are detected (new turn starts).
|
|
|
|
|
|
*/
|
|
|
|
|
|
signalElicitation(): void {
|
|
|
|
|
|
this.elicitationDetected = true;
|
|
|
|
|
|
this.cancelAutoAcceptTimer();
|
|
|
|
|
|
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Start completion confirmation timer.
|
2026-01-24 18:48:37 +01:00
|
|
|
|
* After completion message, waits for output silence then triggers AI check.
|
2026-01-22 19:24:36 +01:00
|
|
|
|
*/
|
|
|
|
|
|
private startCompletionConfirmTimer(): void {
|
|
|
|
|
|
if (this.completionConfirmTimer) {
|
|
|
|
|
|
clearTimeout(this.completionConfirmTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.setState('confirming_idle');
|
|
|
|
|
|
this.log(`Completion message detected, waiting ${this.config.completionConfirmMs}ms for silence...`);
|
|
|
|
|
|
|
|
|
|
|
|
this.completionConfirmTimer = setTimeout(() => {
|
|
|
|
|
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
|
|
|
|
if (msSinceOutput >= this.config.completionConfirmMs) {
|
2026-01-24 18:48:37 +01:00
|
|
|
|
this.log(`Silence confirmed after completion message (${msSinceOutput}ms)`);
|
|
|
|
|
|
this.tryStartAiCheck('completion + silence');
|
2026-01-22 19:24:36 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
// Output received during wait, stay in confirming state and re-check
|
|
|
|
|
|
this.log(`Output received during confirmation, resetting timer`);
|
|
|
|
|
|
this.startCompletionConfirmTimer();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, this.config.completionConfirmMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cancel completion confirmation if new activity detected.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private cancelCompletionConfirm(): void {
|
|
|
|
|
|
if (this.completionConfirmTimer) {
|
|
|
|
|
|
clearTimeout(this.completionConfirmTimer);
|
|
|
|
|
|
this.completionConfirmTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this._state === 'confirming_idle') {
|
|
|
|
|
|
this.setState('watching');
|
|
|
|
|
|
this.completionMessageTime = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:31:24 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Start step confirmation timer for waiting states.
|
|
|
|
|
|
* Waits for output silence before proceeding to next step.
|
|
|
|
|
|
* This ensures Claude has finished processing before we send the next command.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
|
|
|
|
|
|
// Clear any existing step confirm timer
|
|
|
|
|
|
if (this.stepConfirmTimer) {
|
|
|
|
|
|
clearTimeout(this.stepConfirmTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.log(`Step '${step}' completion detected, waiting ${this.config.completionConfirmMs}ms for silence...`);
|
|
|
|
|
|
|
|
|
|
|
|
this.stepConfirmTimer = setTimeout(() => {
|
|
|
|
|
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
|
|
|
|
|
|
|
|
|
|
if (msSinceOutput >= this.config.completionConfirmMs) {
|
|
|
|
|
|
this.log(`Step '${step}' confirmed: ${msSinceOutput}ms silence`);
|
|
|
|
|
|
this.stepConfirmTimer = null;
|
2026-01-22 20:36:06 +01:00
|
|
|
|
|
2026-01-22 19:31:24 +01:00
|
|
|
|
|
|
|
|
|
|
// Proceed with the step completion
|
|
|
|
|
|
switch (step) {
|
|
|
|
|
|
case 'update':
|
|
|
|
|
|
this.checkUpdateComplete();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'init':
|
|
|
|
|
|
this.checkInitComplete();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'kickstart':
|
|
|
|
|
|
this.checkKickstartComplete();
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Output received during wait, restart timer
|
|
|
|
|
|
this.log(`Output during step confirmation, resetting timer`);
|
|
|
|
|
|
this.startStepConfirmTimer(step);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, this.config.completionConfirmMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cancel step confirmation if working patterns detected.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private cancelStepConfirm(): void {
|
|
|
|
|
|
if (this.stepConfirmTimer) {
|
|
|
|
|
|
clearTimeout(this.stepConfirmTimer);
|
|
|
|
|
|
this.stepConfirmTimer = null;
|
|
|
|
|
|
this.log(`Step confirmation cancelled (working detected)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 19:24:36 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Called when idle is confirmed through any detection layer.
|
|
|
|
|
|
* @param reason - What triggered the confirmation
|
|
|
|
|
|
*/
|
|
|
|
|
|
private onIdleConfirmed(reason: string): void {
|
|
|
|
|
|
this.log(`Idle confirmed via: ${reason}`);
|
|
|
|
|
|
const status = this.getDetectionStatus();
|
|
|
|
|
|
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
|
|
|
|
|
`completion=${status.completionMessageDetected}, ` +
|
|
|
|
|
|
`silent=${status.outputSilent}, ` +
|
|
|
|
|
|
`tokensStable=${status.tokensStable}, ` +
|
|
|
|
|
|
`noWorking=${status.workingPatternsAbsent}`);
|
|
|
|
|
|
|
|
|
|
|
|
// Reset detection state
|
|
|
|
|
|
this.completionMessageTime = null;
|
|
|
|
|
|
this.cancelCompletionConfirm();
|
|
|
|
|
|
|
|
|
|
|
|
// Trigger the respawn cycle
|
|
|
|
|
|
this.onIdleDetected();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Handle confirmed idle detection.
|
|
|
|
|
|
* Starts a new respawn cycle.
|
|
|
|
|
|
* @fires respawnCycleStarted
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private onIdleDetected(): void {
|
2026-01-24 18:48:37 +01:00
|
|
|
|
// Accept watching, confirming_idle, and ai_checking states
|
|
|
|
|
|
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
2026-01-18 07:31:19 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start the respawn cycle
|
|
|
|
|
|
this.cycleCount++;
|
|
|
|
|
|
this.log(`Starting respawn cycle #${this.cycleCount}`);
|
|
|
|
|
|
this.emit('respawnCycleStarted', this.cycleCount);
|
|
|
|
|
|
|
|
|
|
|
|
this.sendUpdateDocs();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Send the update docs prompt (first step of cycle).
|
|
|
|
|
|
* @fires stepSent - With step 'update'
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private sendUpdateDocs(): void {
|
|
|
|
|
|
this.setState('sending_update');
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
|
|
|
|
|
this.stepTimer = setTimeout(() => {
|
2026-01-19 16:39:45 +01:00
|
|
|
|
const input = this.config.updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.log(`Sending update prompt: "${this.config.updatePrompt}"`);
|
2026-01-19 15:35:41 +01:00
|
|
|
|
this.session.writeViaScreen(input);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.emit('stepSent', 'update', this.config.updatePrompt);
|
|
|
|
|
|
this.setState('waiting_update');
|
|
|
|
|
|
this.promptDetected = false;
|
|
|
|
|
|
this.workingDetected = false;
|
|
|
|
|
|
}, this.config.interStepDelayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Send /clear command.
|
2026-01-21 18:19:22 +01:00
|
|
|
|
* Starts a 10-second fallback timer - if no prompt is detected after /clear,
|
|
|
|
|
|
* proceeds to /init anyway (workaround for when Claude doesn't show prompt after /clear).
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* @fires stepSent - With step 'clear'
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private sendClear(): void {
|
|
|
|
|
|
this.setState('sending_clear');
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this.terminalBuffer.clear();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
|
|
|
|
|
this.stepTimer = setTimeout(() => {
|
|
|
|
|
|
this.log('Sending /clear');
|
2026-01-19 16:39:45 +01:00
|
|
|
|
this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.emit('stepSent', 'clear', '/clear');
|
|
|
|
|
|
this.setState('waiting_clear');
|
|
|
|
|
|
this.promptDetected = false;
|
2026-01-21 18:19:22 +01:00
|
|
|
|
|
|
|
|
|
|
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
|
|
|
|
|
this.clearFallbackTimer = setTimeout(() => {
|
|
|
|
|
|
if (this._state === 'waiting_clear') {
|
|
|
|
|
|
this.log('/clear fallback: no prompt detected after 10s, proceeding to /init');
|
|
|
|
|
|
this.emit('stepCompleted', 'clear');
|
|
|
|
|
|
if (this.config.sendInit) {
|
|
|
|
|
|
this.sendInit();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.completeCycle();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, RespawnController.CLEAR_FALLBACK_TIMEOUT_MS);
|
2026-01-18 07:31:19 +01:00
|
|
|
|
}, this.config.interStepDelayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Send /init command.
|
|
|
|
|
|
* @fires stepSent - With step 'init'
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private sendInit(): void {
|
|
|
|
|
|
this.setState('sending_init');
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this.terminalBuffer.clear();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
|
|
|
|
|
|
this.stepTimer = setTimeout(() => {
|
|
|
|
|
|
this.log('Sending /init');
|
2026-01-19 16:39:45 +01:00
|
|
|
|
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.emit('stepSent', 'init', '/init');
|
|
|
|
|
|
this.setState('waiting_init');
|
|
|
|
|
|
this.promptDetected = false;
|
|
|
|
|
|
this.workingDetected = false;
|
|
|
|
|
|
}, this.config.interStepDelayMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Complete the current respawn cycle.
|
|
|
|
|
|
* Returns to watching state for next cycle.
|
|
|
|
|
|
* @fires respawnCycleCompleted
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private completeCycle(): void {
|
|
|
|
|
|
this.log(`Respawn cycle #${this.cycleCount} completed`);
|
|
|
|
|
|
this.emit('respawnCycleCompleted', this.cycleCount);
|
|
|
|
|
|
|
|
|
|
|
|
// Go back to watching state for next cycle
|
|
|
|
|
|
this.setState('watching');
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this.terminalBuffer.clear();
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.promptDetected = false;
|
|
|
|
|
|
this.workingDetected = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 03:46:11 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Check if already idle and start cycle if so.
|
|
|
|
|
|
* Used when resuming from pause.
|
|
|
|
|
|
*/
|
2026-01-18 07:31:19 +01:00
|
|
|
|
private checkIdleAndMaybeStart(): void {
|
|
|
|
|
|
// Check if already idle
|
|
|
|
|
|
const timeSinceActivity = Date.now() - this.lastActivityTime;
|
|
|
|
|
|
if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) {
|
|
|
|
|
|
this.onIdleDetected();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Update configuration at runtime.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Merges provided config with existing config.
|
|
|
|
|
|
* Takes effect immediately for new operations.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param config - Partial configuration to merge
|
|
|
|
|
|
* @fires log - With updated config details
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
updateConfig(config: Partial<RespawnConfig>): void {
|
|
|
|
|
|
this.config = { ...this.config, ...config };
|
2026-01-24 18:48:37 +01:00
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 07:31:19 +01:00
|
|
|
|
this.log(`Config updated: ${JSON.stringify(config)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Get current configuration.
|
|
|
|
|
|
* @returns Copy of current config (safe to modify)
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
getConfig(): RespawnConfig {
|
|
|
|
|
|
return { ...this.config };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* Get comprehensive status information.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Useful for debugging and monitoring.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns Status object with:
|
|
|
|
|
|
* - state: Current state machine state
|
|
|
|
|
|
* - cycleCount: Number of cycles started
|
|
|
|
|
|
* - lastActivityTime: Timestamp of last activity
|
|
|
|
|
|
* - timeSinceActivity: Milliseconds since last activity
|
|
|
|
|
|
* - promptDetected: Whether prompt indicator seen
|
|
|
|
|
|
* - workingDetected: Whether working indicator seen
|
2026-01-22 19:24:36 +01:00
|
|
|
|
* - detection: Multi-layer detection status
|
2026-01-21 03:46:11 +01:00
|
|
|
|
* - config: Current configuration
|
2026-01-18 07:31:19 +01:00
|
|
|
|
*/
|
|
|
|
|
|
getStatus() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
state: this._state,
|
|
|
|
|
|
cycleCount: this.cycleCount,
|
|
|
|
|
|
lastActivityTime: this.lastActivityTime,
|
|
|
|
|
|
timeSinceActivity: Date.now() - this.lastActivityTime,
|
|
|
|
|
|
promptDetected: this.promptDetected,
|
|
|
|
|
|
workingDetected: this.workingDetected,
|
2026-01-22 19:24:36 +01:00
|
|
|
|
detection: this.getDetectionStatus(),
|
2026-01-18 07:31:19 +01:00
|
|
|
|
config: this.config,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|