mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
1. Rewrote getActiveChildProcesses() to use a single `ps --ppid` call instead of two-level pgrep. The pane PID is typically claude itself (bash exec'd into it), not a bash wrapper — so direct children of pane_pid ARE the tool processes. 2. Added timer restart in tryStartAiCheck() when skipping due to child processes. Without this, the pre-filter and no-output timers (both one-shot) would never fire again, permanently stalling idle detection for sessions with silent long-running processes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3241 lines
110 KiB
TypeScript
3241 lines
110 KiB
TypeScript
/**
|
||
* @fileoverview Respawn Controller for autonomous Claude Code session cycling.
|
||
*
|
||
* Manages automatic respawning of Claude Code sessions. When Claude finishes
|
||
* working (detected by completion message + output silence), it cycles through
|
||
* update → clear → init steps to keep the session productive.
|
||
*
|
||
* ## State Machine
|
||
*
|
||
* ```
|
||
* 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 ──┘
|
||
* ```
|
||
*
|
||
* ## Idle Detection (multi-layer)
|
||
* - Layer 0: Stop hook / idle_prompt notification (definitive)
|
||
* - Layer 1: Completion message pattern "for Xm Xs"
|
||
* - Layer 2: AI idle check via `AiIdleChecker` (optional)
|
||
* - Layer 3: No-output timeout fallback
|
||
*
|
||
* Key exports:
|
||
* - `RespawnController` class — state machine, extends EventEmitter
|
||
* - `RespawnConfig` interface — all configuration options
|
||
* - `RespawnState` type — union of all state machine states
|
||
* - `DetectionStatus`, `ActiveTimerInfo`, `RespawnEvents` — status/event types
|
||
*
|
||
* Key methods: `start()`, `stop()`, `getStatus()`, `getConfig()`,
|
||
* `getDetectionStatus()`, `getActiveTimers()`, `getAggregateMetrics()`,
|
||
* `getTimingHistory()`, `getHealthScore()`
|
||
*
|
||
* @dependencies session (PTY output), ai-idle-checker, ai-plan-checker,
|
||
* respawn-patterns, respawn-adaptive-timing, respawn-metrics, respawn-health,
|
||
* team-watcher (blocks respawn if teammates active)
|
||
* @consumedby web/server (respawn routes, SSE), ralph-loop
|
||
* @emits respawn:stateChanged, respawn:started, respawn:stopped, respawn:cycleStarted,
|
||
* respawn:cycleCompleted, respawn:detectionUpdate, respawn:aiCheck*, respawn:log
|
||
*
|
||
* @module respawn-controller
|
||
*/
|
||
|
||
import { EventEmitter } from 'node:events';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { Session } from './session.js';
|
||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||
import type { TeamWatcher } from './team-watcher.js';
|
||
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
|
||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||
import {
|
||
isCompletionMessage,
|
||
hasWorkingPattern,
|
||
extractTokenCount,
|
||
PROMPT_PATTERNS,
|
||
WORKING_PATTERNS,
|
||
} from './respawn-patterns.js';
|
||
import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js';
|
||
import { RespawnCycleMetricsTracker } from './respawn-metrics.js';
|
||
import { calculateHealthScore, shouldSkipClear, type HealthInputs } from './respawn-health.js';
|
||
import {
|
||
AI_CHECK_MODEL,
|
||
AI_IDLE_CHECK_MAX_CONTEXT,
|
||
AI_PLAN_CHECK_MAX_CONTEXT,
|
||
AI_IDLE_CHECK_TIMEOUT_MS,
|
||
AI_IDLE_CHECK_COOLDOWN_MS,
|
||
AI_PLAN_CHECK_TIMEOUT_MS,
|
||
AI_PLAN_CHECK_COOLDOWN_MS,
|
||
} from './config/ai-defaults.js';
|
||
import {
|
||
getErrorMessage,
|
||
type RespawnCycleMetrics,
|
||
type RespawnAggregateMetrics,
|
||
type RalphLoopHealthScore,
|
||
type TimingHistory,
|
||
type CycleOutcome,
|
||
} from './types.js';
|
||
|
||
// ========== Constants ==========
|
||
|
||
// COMPLETION_TIME_PATTERN moved to ./respawn-patterns.ts
|
||
|
||
/** Pre-filter: numbered option pattern for plan mode detection */
|
||
const PLAN_MODE_OPTION_PATTERN = /\d+\.\s+(Yes|No|Type|Cancel|Skip|Proceed|Approve|Reject)/i;
|
||
|
||
/** Pre-filter: selection indicator arrow for plan mode detection */
|
||
const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
|
||
|
||
// 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.
|
||
*/
|
||
/** Active timer info for UI display */
|
||
export interface ActiveTimerInfo {
|
||
name: string;
|
||
remainingMs: number;
|
||
totalMs: number;
|
||
}
|
||
|
||
export interface DetectionStatus {
|
||
/** Layer 0: Stop hook received (highest priority - definitive signal) */
|
||
stopHookReceived: boolean;
|
||
/** Timestamp when Stop hook was received */
|
||
stopHookTime: number | null;
|
||
/** Layer 0: idle_prompt notification received (definitive signal) */
|
||
idlePromptReceived: boolean;
|
||
/** Timestamp when idle_prompt was received */
|
||
idlePromptTime: number | null;
|
||
|
||
/** 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;
|
||
|
||
/** Layer 5: AI idle check status */
|
||
aiCheck: AiCheckState | null;
|
||
|
||
/** Overall confidence level (0-100) */
|
||
confidenceLevel: number;
|
||
|
||
/** Human-readable status for UI */
|
||
statusText: string;
|
||
|
||
/** What the controller is currently waiting for */
|
||
waitingFor: string;
|
||
|
||
/** Active countdown timers */
|
||
activeTimers: ActiveTimerInfo[];
|
||
|
||
/** Recent action log entries (last 10) */
|
||
recentActions: ActionLogEntry[];
|
||
|
||
/** Current phase description */
|
||
currentPhase: string;
|
||
|
||
/** Next expected action */
|
||
nextAction: string;
|
||
|
||
/** Stuck-state detection metrics */
|
||
stuckState: {
|
||
/** How long the controller has been in the current state (ms) */
|
||
currentStateDurationMs: number;
|
||
/** Warning threshold (ms) */
|
||
warningThresholdMs: number;
|
||
/** Recovery threshold (ms) */
|
||
recoveryThresholdMs: number;
|
||
/** Number of recovery attempts made */
|
||
recoveryAttempts: number;
|
||
/** Maximum allowed recoveries */
|
||
maxRecoveries: number;
|
||
/** Whether a warning has been emitted for current state */
|
||
isWarned: boolean;
|
||
};
|
||
}
|
||
|
||
// ========== Type Definitions ==========
|
||
|
||
/**
|
||
* Respawn sequence states.
|
||
*
|
||
* The controller cycles through these states:
|
||
* ```
|
||
* 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`).
|
||
*/
|
||
export type RespawnState =
|
||
/** Watching for idle, ready to start respawn sequence */
|
||
| '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 */
|
||
| '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';
|
||
|
||
/**
|
||
* Configuration options for the RespawnController.
|
||
*/
|
||
export interface RespawnConfig {
|
||
/**
|
||
* How long to wait after seeing prompt before considering truly idle.
|
||
* Prevents premature cycling when user is about to type.
|
||
* @default 10000 (10 seconds)
|
||
*/
|
||
idleTimeoutMs: number;
|
||
|
||
/**
|
||
* The prompt to send when updating docs.
|
||
* Sent at the start of each respawn cycle.
|
||
* @default 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.'
|
||
*/
|
||
updatePrompt: string;
|
||
|
||
/**
|
||
* Delay between sending steps (ms).
|
||
* Gives Claude time to process each command.
|
||
* @default 1000 (1 second)
|
||
*/
|
||
interStepDelayMs: number;
|
||
|
||
/**
|
||
* Whether the respawn loop is enabled.
|
||
* When false, start() will be a no-op.
|
||
* @default true
|
||
*/
|
||
enabled: boolean;
|
||
|
||
/**
|
||
* Whether to send /clear after update prompt completes.
|
||
* Resets Claude's context for fresh start.
|
||
* @default true
|
||
*/
|
||
sendClear: boolean;
|
||
|
||
/**
|
||
* Whether to send /init after /clear completes.
|
||
* Re-initializes Claude with CLAUDE.md context.
|
||
* @default true
|
||
*/
|
||
sendInit: boolean;
|
||
|
||
/**
|
||
* 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
|
||
*/
|
||
kickstartPrompt?: string;
|
||
|
||
/**
|
||
* Time to wait after completion message before confirming idle (ms).
|
||
* After seeing "for Xm Xs" pattern, waits this long with no new output.
|
||
* @default 10000 (10 seconds)
|
||
*/
|
||
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;
|
||
|
||
/**
|
||
* 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).
|
||
* @default true
|
||
*/
|
||
autoAcceptPrompts: boolean;
|
||
|
||
/**
|
||
* 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.
|
||
* @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;
|
||
|
||
/**
|
||
* Whether AI plan mode check is enabled for auto-accept.
|
||
* When enabled, spawns a fresh Claude CLI to confirm the terminal is
|
||
* showing a plan mode approval prompt before auto-accepting.
|
||
* @default true
|
||
*/
|
||
aiPlanCheckEnabled: boolean;
|
||
|
||
/**
|
||
* Model to use for AI plan mode check.
|
||
* @default 'claude-opus-4-5-20251101' (thinking enabled by default)
|
||
*/
|
||
aiPlanCheckModel: string;
|
||
|
||
/**
|
||
* Maximum characters of terminal buffer to send to plan checker.
|
||
* @default 8000
|
||
*/
|
||
aiPlanCheckMaxContext: number;
|
||
|
||
/**
|
||
* Timeout for the AI plan check in ms.
|
||
* @default 60000 (60 seconds, allows time for thinking)
|
||
*/
|
||
aiPlanCheckTimeoutMs: number;
|
||
|
||
/**
|
||
* Cooldown after NOT_PLAN_MODE verdict in ms.
|
||
* @default 30000 (30 seconds)
|
||
*/
|
||
aiPlanCheckCooldownMs: number;
|
||
|
||
/**
|
||
* Enable stuck-state detection.
|
||
* Detects when the controller stays in the same state for too long.
|
||
* @default true
|
||
*/
|
||
stuckStateDetectionEnabled: boolean;
|
||
|
||
/**
|
||
* Threshold for stuck-state warning in ms.
|
||
* If state doesn't change for this duration, emits a warning.
|
||
* @default 300000 (5 minutes)
|
||
*/
|
||
stuckStateWarningMs: number;
|
||
|
||
/**
|
||
* Threshold for stuck-state recovery in ms.
|
||
* If state doesn't change for this duration, triggers recovery action.
|
||
* @default 600000 (10 minutes)
|
||
*/
|
||
stuckStateRecoveryMs: number;
|
||
|
||
/**
|
||
* Maximum consecutive stuck-state recoveries before giving up.
|
||
* @default 3
|
||
*/
|
||
maxStuckRecoveries: number;
|
||
|
||
// ========== P2-001: Adaptive Timing ==========
|
||
|
||
/**
|
||
* Enable adaptive timing based on historical patterns.
|
||
* Adjusts completion confirm timeout dynamically.
|
||
* @default true
|
||
*/
|
||
adaptiveTimingEnabled?: boolean;
|
||
|
||
/**
|
||
* Minimum adaptive completion confirm timeout (ms).
|
||
* @default 5000
|
||
*/
|
||
adaptiveMinConfirmMs?: number;
|
||
|
||
/**
|
||
* Maximum adaptive completion confirm timeout (ms).
|
||
* @default 30000
|
||
*/
|
||
adaptiveMaxConfirmMs?: number;
|
||
|
||
// ========== P2-002: Skip-Clear Optimization ==========
|
||
|
||
/**
|
||
* Skip /clear when context usage is low.
|
||
* @default true
|
||
*/
|
||
skipClearWhenLowContext?: boolean;
|
||
|
||
/**
|
||
* Context threshold percentage below which to skip /clear.
|
||
* @default 30
|
||
*/
|
||
skipClearThresholdPercent?: number;
|
||
|
||
// ========== P2-004: Cycle Metrics ==========
|
||
|
||
/**
|
||
* Enable tracking of respawn cycle metrics.
|
||
* @default true
|
||
*/
|
||
trackCycleMetrics?: boolean;
|
||
|
||
// ========== P2-001: Confidence Scoring ==========
|
||
|
||
/**
|
||
* Minimum confidence level required to trigger idle detection.
|
||
* Below this threshold, the controller waits for more signals.
|
||
* @default 65
|
||
*/
|
||
minIdleConfidence?: number;
|
||
|
||
/**
|
||
* Confidence weight for completion message detection.
|
||
* @default 40
|
||
*/
|
||
confidenceWeightCompletion?: number;
|
||
|
||
/**
|
||
* Confidence weight for output silence.
|
||
* @default 25
|
||
*/
|
||
confidenceWeightSilence?: number;
|
||
|
||
/**
|
||
* Confidence weight for token stability.
|
||
* @default 20
|
||
*/
|
||
confidenceWeightTokens?: number;
|
||
|
||
/**
|
||
* Confidence weight for working pattern absence.
|
||
* @default 15
|
||
*/
|
||
confidenceWeightNoWorking?: number;
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
* @event detectionUpdate - Fired when detection status changes (for UI)
|
||
* @event error - Fired on errors
|
||
* @event log - Fired for debug logging
|
||
*/
|
||
/** Timer info for countdown display */
|
||
export interface TimerInfo {
|
||
name: string;
|
||
durationMs: number;
|
||
endsAt: number;
|
||
reason?: string;
|
||
}
|
||
|
||
/** Action log entry for detailed UI feedback */
|
||
export interface ActionLogEntry {
|
||
type: string;
|
||
detail: string;
|
||
timestamp: number;
|
||
}
|
||
|
||
export interface RespawnEvents {
|
||
/** State machine transition */
|
||
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
|
||
/** New respawn cycle started */
|
||
respawnCycleStarted: (cycleNumber: number) => void;
|
||
/** Respawn cycle finished */
|
||
respawnCycleCompleted: (cycleNumber: number) => void;
|
||
/** Command sent to session */
|
||
stepSent: (step: string, input: string) => void;
|
||
/** Step completed (ready indicator detected) */
|
||
stepCompleted: (step: string) => void;
|
||
/** Detection status update for UI display */
|
||
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;
|
||
/** AI plan check started */
|
||
planCheckStarted: () => void;
|
||
/** AI plan check completed with verdict */
|
||
planCheckCompleted: (result: AiPlanCheckResult) => void;
|
||
/** AI plan check failed */
|
||
planCheckFailed: (error: string) => void;
|
||
/** Timer started for countdown display */
|
||
timerStarted: (timer: TimerInfo) => void;
|
||
/** Timer cancelled */
|
||
timerCancelled: (timerName: string, reason?: string) => void;
|
||
/** Timer completed */
|
||
timerCompleted: (timerName: string) => void;
|
||
/** Verbose action log for detailed UI feedback */
|
||
actionLog: (action: ActionLogEntry) => void;
|
||
/** Error occurred */
|
||
error: (error: Error) => void;
|
||
/** Debug log message */
|
||
log: (message: string) => void;
|
||
/** Stuck state warning emitted */
|
||
stuckStateWarning: (state: RespawnState, durationMs: number) => void;
|
||
/** Stuck state recovery triggered */
|
||
stuckStateRecovery: (state: RespawnState, durationMs: number, attempt: number) => void;
|
||
/** Respawn blocked by external signal */
|
||
respawnBlocked: (data: { reason: string; details: string }) => void;
|
||
}
|
||
|
||
/**
|
||
* Convert milliseconds to a non-negative whole number of seconds for countdown display.
|
||
* Rounds up so that e.g. 1200 ms shows as 2 s (never under-reports remaining time).
|
||
*/
|
||
function formatRemainingSeconds(ms: number): number {
|
||
return Math.max(0, Math.ceil(ms / 1000));
|
||
}
|
||
|
||
/** Default configuration values */
|
||
const DEFAULT_CONFIG: RespawnConfig = {
|
||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
||
interStepDelayMs: 1000, // 1 second between steps
|
||
enabled: true,
|
||
sendClear: true, // send /clear after update prompt
|
||
sendInit: true, // send /init after /clear
|
||
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
||
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: AI_CHECK_MODEL,
|
||
aiIdleCheckMaxContext: AI_IDLE_CHECK_MAX_CONTEXT,
|
||
aiIdleCheckTimeoutMs: AI_IDLE_CHECK_TIMEOUT_MS,
|
||
aiIdleCheckCooldownMs: AI_IDLE_CHECK_COOLDOWN_MS,
|
||
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
||
aiPlanCheckModel: AI_CHECK_MODEL,
|
||
aiPlanCheckMaxContext: AI_PLAN_CHECK_MAX_CONTEXT,
|
||
aiPlanCheckTimeoutMs: AI_PLAN_CHECK_TIMEOUT_MS,
|
||
aiPlanCheckCooldownMs: AI_PLAN_CHECK_COOLDOWN_MS,
|
||
stuckStateDetectionEnabled: true, // detect stuck states
|
||
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
||
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
||
maxStuckRecoveries: 3, // max recovery attempts
|
||
// P2-001: Adaptive timing
|
||
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
||
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
||
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
||
// P2-002: Skip-clear optimization
|
||
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
||
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
||
// P2-004: Cycle metrics
|
||
trackCycleMetrics: true, // Track and persist cycle metrics
|
||
// P2-001: Confidence scoring
|
||
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
||
confidenceWeightCompletion: 40, // Weight for completion message
|
||
confidenceWeightSilence: 25, // Weight for output silence
|
||
confidenceWeightTokens: 20, // Weight for token stability
|
||
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
||
};
|
||
|
||
/**
|
||
* RespawnController - Automatic session cycling for continuous Claude work.
|
||
*
|
||
* Monitors a Claude Code session for idle state and automatically cycles
|
||
* through update → clear → init steps to keep the session productive.
|
||
*
|
||
* ## How It Works
|
||
*
|
||
* 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
|
||
*
|
||
* ## Idle Detection (Updated for Claude Code 2024+)
|
||
*
|
||
* Primary: Completion message with time duration (e.g., "✻ Worked for 2m 46s")
|
||
* The pattern "for Xm Xs" indicates Claude finished work and reports duration.
|
||
*
|
||
* Confirmation: After seeing completion message, waits for output silence.
|
||
* If no new output for `completionConfirmMs` (default 10s), confirms idle.
|
||
*
|
||
* Fallback: If no output at all for `noOutputTimeoutMs` (default 30s), assumes idle.
|
||
*
|
||
* Working indicators: Thinking, Writing, spinner characters, etc. reset detection.
|
||
*
|
||
* ## 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',
|
||
* completionConfirmMs: 10000, // Wait 10s after completion message
|
||
* });
|
||
*
|
||
* respawn.on('respawnCycleCompleted', (cycle) => {
|
||
* console.log(`Completed cycle ${cycle}`);
|
||
* });
|
||
*
|
||
* respawn.start();
|
||
* ```
|
||
*/
|
||
export class RespawnController extends EventEmitter {
|
||
/** The session being controlled */
|
||
private session: Session;
|
||
|
||
/** Optional team watcher for team-aware idle detection */
|
||
private teamWatcher: TeamWatcher | null = null;
|
||
|
||
/** Current configuration */
|
||
private config: RespawnConfig;
|
||
|
||
/** Current state machine state */
|
||
private _state: RespawnState = 'stopped';
|
||
|
||
/** Centralized timer lifecycle manager — disposed and recreated on clearTimers() */
|
||
private cleanup = new CleanupManager();
|
||
|
||
/** Maps timer names to CleanupManager registration IDs (for individual cancel) */
|
||
private timerIds = new Map<string, string>();
|
||
|
||
/** Cached key fields from last emitted detection status (for dedup) */
|
||
private lastEmittedDetectionKey: string = '';
|
||
|
||
/** Whether any terminal output has been received since start/last-auto-accept */
|
||
private hasReceivedOutput: boolean = false;
|
||
|
||
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
||
private elicitationDetected: boolean = false;
|
||
|
||
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
|
||
|
||
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
|
||
private stopHookReceived: boolean = false;
|
||
|
||
/** Timestamp when Stop hook was received */
|
||
private stopHookTime: number | null = null;
|
||
|
||
/** Whether an idle_prompt notification was received (60s+ idle signal) */
|
||
private idlePromptReceived: boolean = false;
|
||
|
||
/** Timestamp when idle_prompt was received */
|
||
private idlePromptTime: number | null = null;
|
||
|
||
/** Confirmation delay after hook signal before confirming idle (ms) */
|
||
private static readonly HOOK_CONFIRM_DELAY_MS = 3000;
|
||
|
||
/** Number of completed respawn cycles */
|
||
private cycleCount: number = 0;
|
||
|
||
/** Timestamp of last terminal activity */
|
||
private lastActivityTime: number = 0;
|
||
|
||
/** Buffer for recent terminal output (uses BufferAccumulator to reduce GC pressure) */
|
||
private terminalBuffer = new BufferAccumulator(MAX_RESPAWN_BUFFER_SIZE, RESPAWN_BUFFER_TRIM_SIZE);
|
||
|
||
/** Whether a prompt indicator was detected */
|
||
private promptDetected: boolean = false;
|
||
|
||
/** Whether a working indicator was detected */
|
||
private workingDetected: boolean = false;
|
||
|
||
/** Reference to terminal event handler (for cleanup) */
|
||
private terminalHandler: ((data: string) => void) | null = null;
|
||
|
||
/** AI idle checker instance */
|
||
private aiChecker: AiIdleChecker;
|
||
|
||
/** AI plan mode checker instance */
|
||
private planChecker: AiPlanChecker;
|
||
|
||
/** Timestamp when plan check was started (to detect stale results) */
|
||
private planCheckStartTime: number = 0;
|
||
|
||
/** Unique ID for current AI check request (to detect stale results) */
|
||
private _currentAiCheckId: string | null = null;
|
||
|
||
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
||
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
||
|
||
// ========== Timer Tracking for UI Countdown Display ==========
|
||
|
||
/** Active timers being tracked for UI display */
|
||
private activeTimers: Map<string, { name: string; startedAt: number; durationMs: number; endsAt: number }> =
|
||
new Map();
|
||
|
||
/** Recent action log entries (for UI display, max 20) */
|
||
private recentActions: ActionLogEntry[] = [];
|
||
|
||
// ========== Stuck-State Detection State ==========
|
||
|
||
/** Timestamp when the current state was entered */
|
||
private stateEnteredAt: number = 0;
|
||
|
||
/** Whether a stuck-state warning has been emitted for current state */
|
||
private stuckStateWarned: boolean = false;
|
||
|
||
/** Number of stuck-state recovery attempts */
|
||
private stuckRecoveryCount: number = 0;
|
||
|
||
// ========== P2-001: Adaptive Timing (delegated to RespawnAdaptiveTiming) ==========
|
||
|
||
/** Adaptive timing controller */
|
||
private adaptiveTiming: RespawnAdaptiveTiming;
|
||
|
||
// ========== P2-004: Cycle Metrics (delegated to RespawnCycleMetricsTracker) ==========
|
||
|
||
/** Cycle metrics tracker */
|
||
private cycleMetrics: RespawnCycleMetricsTracker = new RespawnCycleMetricsTracker();
|
||
|
||
/** Timestamp when idle detection started for current cycle */
|
||
private idleDetectionStartTime: number = 0;
|
||
|
||
// ========== 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;
|
||
|
||
// PROMPT_PATTERNS and WORKING_PATTERNS are now imported from ./respawn-patterns.js
|
||
|
||
/**
|
||
* Rolling window buffer for working pattern detection.
|
||
* Prevents split-chunk issues where "Thinking" arrives as "Thin" + "king".
|
||
* Size: 300 chars should be enough to catch any split pattern.
|
||
*/
|
||
private workingPatternWindow: string = '';
|
||
private static readonly WORKING_PATTERN_WINDOW_SIZE = 300;
|
||
|
||
/**
|
||
* Minimum time without working patterns before considering idle (ms).
|
||
* Increased from 3s to 8s to avoid false positives during tool execution gaps.
|
||
*/
|
||
private static readonly MIN_WORKING_PATTERN_ABSENCE_MS = 8000;
|
||
|
||
/**
|
||
* Creates a new RespawnController.
|
||
*
|
||
* @param session - The Session instance to control
|
||
* @param config - Partial configuration (merged with defaults)
|
||
*/
|
||
constructor(session: Session, config: Partial<RespawnConfig> = {}) {
|
||
super();
|
||
this.session = session;
|
||
// Filter out undefined values from config to prevent overwriting defaults
|
||
const filteredConfig = Object.fromEntries(
|
||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||
) as Partial<RespawnConfig>;
|
||
this.config = { ...DEFAULT_CONFIG, ...filteredConfig };
|
||
|
||
// Validate configuration values
|
||
this.validateConfig();
|
||
|
||
// Initialize sub-modules
|
||
this.adaptiveTiming = new RespawnAdaptiveTiming({
|
||
adaptiveMinConfirmMs: this.config.adaptiveMinConfirmMs ?? 5000,
|
||
adaptiveMaxConfirmMs: this.config.adaptiveMaxConfirmMs ?? 30000,
|
||
});
|
||
|
||
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.planChecker = new AiPlanChecker(session.id, {
|
||
enabled: this.config.aiPlanCheckEnabled,
|
||
model: this.config.aiPlanCheckModel,
|
||
maxContextChars: this.config.aiPlanCheckMaxContext,
|
||
checkTimeoutMs: this.config.aiPlanCheckTimeoutMs,
|
||
cooldownMs: this.config.aiPlanCheckCooldownMs,
|
||
});
|
||
this.setupAiCheckerListeners();
|
||
this.setupPlanCheckerListeners();
|
||
}
|
||
|
||
/**
|
||
* Validate configuration values and reset invalid ones to defaults.
|
||
* Ensures timeouts are positive and logically consistent.
|
||
*/
|
||
private validateConfig(): void {
|
||
const c = this.config;
|
||
|
||
/**
|
||
* Validate that a timeout value is positive (or non-negative when allowZero is true).
|
||
* Falls back to the DEFAULT_CONFIG value if invalid.
|
||
*/
|
||
const validatePositiveTimeout = (field: keyof RespawnConfig, allowZero = false): void => {
|
||
const value = c[field] as number;
|
||
const invalid = allowZero ? value < 0 : value <= 0;
|
||
if (invalid) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(c as any)[field] = DEFAULT_CONFIG[field];
|
||
}
|
||
};
|
||
|
||
const REQUIRED_TIMEOUT_FIELDS = [
|
||
'idleTimeoutMs',
|
||
'completionConfirmMs',
|
||
'noOutputTimeoutMs',
|
||
'interStepDelayMs',
|
||
'aiIdleCheckTimeoutMs',
|
||
'aiIdleCheckMaxContext',
|
||
'aiPlanCheckTimeoutMs',
|
||
'aiPlanCheckMaxContext',
|
||
] as const;
|
||
for (const field of REQUIRED_TIMEOUT_FIELDS) {
|
||
validatePositiveTimeout(field);
|
||
}
|
||
|
||
const ALLOW_ZERO_FIELDS = ['autoAcceptDelayMs', 'aiIdleCheckCooldownMs', 'aiPlanCheckCooldownMs'] as const;
|
||
for (const field of ALLOW_ZERO_FIELDS) {
|
||
validatePositiveTimeout(field, true);
|
||
}
|
||
|
||
// Ensure completion confirm doesn't exceed no-output timeout
|
||
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
|
||
c.completionConfirmMs = c.noOutputTimeoutMs;
|
||
}
|
||
}
|
||
|
||
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
|
||
private setupAiCheckerListeners(): void {
|
||
// Remove any existing listeners to prevent duplicates when restarting
|
||
this.aiChecker.removeAllListeners();
|
||
|
||
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);
|
||
// Restart pre-filter timer when cooldown expires so a new check can be triggered
|
||
if (this._state === 'watching') {
|
||
this.startPreFilterTimer();
|
||
}
|
||
});
|
||
|
||
this.aiChecker.on('disabled', (reason: string) => {
|
||
this.log(`AI checker disabled: ${reason}. Falling back to noOutputTimeoutMs.`);
|
||
});
|
||
}
|
||
|
||
/** Wire up plan checker events to controller events (removes existing listeners first to prevent duplicates) */
|
||
private setupPlanCheckerListeners(): void {
|
||
// Remove any existing listeners to prevent duplicates when restarting
|
||
this.planChecker.removeAllListeners();
|
||
|
||
this.planChecker.on('log', (message: string) => {
|
||
this.log(message);
|
||
});
|
||
|
||
this.planChecker.on('disabled', (reason: string) => {
|
||
this.log(`Plan checker disabled: ${reason}. Falling back to pre-filter only.`);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get the current state machine state.
|
||
* @returns Current RespawnState
|
||
*/
|
||
get state(): RespawnState {
|
||
return this._state;
|
||
}
|
||
|
||
/**
|
||
* Get the current respawn cycle count.
|
||
* Increments each time a new cycle starts.
|
||
* @returns Number of cycles started
|
||
*/
|
||
get currentCycle(): number {
|
||
return this.cycleCount;
|
||
}
|
||
|
||
/**
|
||
* Check if the controller is currently running.
|
||
* @returns True if state is not 'stopped'
|
||
*/
|
||
get isRunning(): boolean {
|
||
return this._state !== 'stopped';
|
||
}
|
||
|
||
/**
|
||
* 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 >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
|
||
|
||
// Calculate confidence level (0-100) using configurable weights
|
||
// P2-001: Configurable confidence scoring
|
||
// Hook signals are definitive (100% confidence)
|
||
let confidence = 0;
|
||
if (this.stopHookReceived || this.idlePromptReceived) {
|
||
confidence = 100;
|
||
} else {
|
||
// Use configured weights (with fallback to defaults)
|
||
const weightCompletion = this.config.confidenceWeightCompletion ?? 40;
|
||
const weightSilence = this.config.confidenceWeightSilence ?? 25;
|
||
const weightTokens = this.config.confidenceWeightTokens ?? 20;
|
||
const weightNoWorking = this.config.confidenceWeightNoWorking ?? 15;
|
||
|
||
if (completionMessageDetected) confidence += weightCompletion;
|
||
if (outputSilent) confidence += weightSilence;
|
||
if (tokensStable) confidence += weightTokens;
|
||
if (workingPatternsAbsent) confidence += weightNoWorking;
|
||
|
||
// Confidence decay: if no output for extended time, add bonus confidence
|
||
// This helps detect stuck states where Claude is truly idle but no completion message
|
||
const extendedSilenceBonus = Math.min(20, Math.floor(msSinceLastOutput / 30000) * 5);
|
||
if (msSinceLastOutput > 30000) {
|
||
confidence += extendedSilenceBonus;
|
||
}
|
||
}
|
||
// Cap at 100
|
||
confidence = Math.min(100, confidence);
|
||
|
||
// 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';
|
||
} else if (this.stopHookReceived || this.idlePromptReceived) {
|
||
const hookType = this.idlePromptReceived ? 'idle_prompt' : 'Stop';
|
||
statusText = `${hookType} hook received - confirming idle`;
|
||
waitingFor = 'Short confirmation (race condition check)';
|
||
} 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 = `${formatRemainingSeconds(this.config.completionConfirmMs - msSinceLastOutput)}s more silence`;
|
||
} else if (this._state === 'watching') {
|
||
const aiState = this.aiChecker.getState();
|
||
if (aiState.status === 'cooldown') {
|
||
const remaining = formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs());
|
||
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 = 'Pre-filter conditions for AI check';
|
||
} else {
|
||
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}`;
|
||
waitingFor = 'Step completion';
|
||
} else {
|
||
statusText = `State: ${this._state}`;
|
||
waitingFor = 'Next event';
|
||
}
|
||
|
||
// Determine current phase and next action
|
||
let currentPhase: string;
|
||
let nextAction: string;
|
||
|
||
switch (this._state) {
|
||
case 'stopped':
|
||
currentPhase = 'Stopped';
|
||
nextAction = 'Start to begin';
|
||
break;
|
||
case 'watching':
|
||
currentPhase = 'Monitoring for idle';
|
||
nextAction = 'Waiting for silence + no working patterns';
|
||
break;
|
||
case 'confirming_idle':
|
||
currentPhase = 'Confirming idle state';
|
||
nextAction = 'Waiting for output silence';
|
||
break;
|
||
case 'ai_checking':
|
||
currentPhase = 'AI analyzing terminal';
|
||
nextAction = 'Waiting for IDLE/WORKING verdict';
|
||
break;
|
||
case 'sending_update':
|
||
currentPhase = 'Sending update prompt';
|
||
nextAction = 'Will send prompt after delay';
|
||
break;
|
||
case 'waiting_update':
|
||
currentPhase = 'Waiting for update to complete';
|
||
nextAction = 'Will send /clear when done';
|
||
break;
|
||
case 'sending_clear':
|
||
currentPhase = 'Sending /clear';
|
||
nextAction = 'Will clear context';
|
||
break;
|
||
case 'waiting_clear':
|
||
currentPhase = 'Waiting for /clear to complete';
|
||
nextAction = 'Will send /init when done';
|
||
break;
|
||
case 'sending_init':
|
||
currentPhase = 'Sending /init';
|
||
nextAction = 'Will re-initialize';
|
||
break;
|
||
case 'waiting_init':
|
||
currentPhase = 'Waiting for /init to complete';
|
||
nextAction = 'Monitoring for work';
|
||
break;
|
||
case 'monitoring_init':
|
||
currentPhase = 'Monitoring if /init triggered work';
|
||
nextAction = 'Kickstart if no work started';
|
||
break;
|
||
case 'sending_kickstart':
|
||
currentPhase = 'Sending kickstart prompt';
|
||
nextAction = 'Will send prompt after delay';
|
||
break;
|
||
case 'waiting_kickstart':
|
||
currentPhase = 'Waiting for kickstart to complete';
|
||
nextAction = 'Completing cycle';
|
||
break;
|
||
default:
|
||
currentPhase = this._state;
|
||
nextAction = 'Processing...';
|
||
}
|
||
|
||
return {
|
||
stopHookReceived: this.stopHookReceived,
|
||
stopHookTime: this.stopHookTime,
|
||
idlePromptReceived: this.idlePromptReceived,
|
||
idlePromptTime: this.idlePromptTime,
|
||
completionMessageDetected,
|
||
completionMessageTime: this.completionMessageTime,
|
||
outputSilent,
|
||
msSinceLastOutput,
|
||
tokensStable,
|
||
lastTokenCount: this.lastTokenCount,
|
||
msSinceTokenChange,
|
||
workingPatternsAbsent,
|
||
msSinceLastWorking,
|
||
aiCheck: this.config.aiIdleCheckEnabled ? this.aiChecker.getState() : null,
|
||
confidenceLevel: confidence,
|
||
statusText,
|
||
waitingFor,
|
||
activeTimers: this.getActiveTimers(),
|
||
recentActions: this.recentActions.slice(0, 10),
|
||
currentPhase,
|
||
nextAction,
|
||
stuckState: this.getStuckStateMetrics(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Start periodic detection status updates for UI.
|
||
* Emits 'detectionUpdate' event every 2s while running.
|
||
*/
|
||
private startDetectionUpdates(): void {
|
||
this.stopDetectionUpdates();
|
||
if (this._state === 'stopped') return;
|
||
this.lastEmittedDetectionKey = '';
|
||
const id = this.cleanup.setInterval(
|
||
() => {
|
||
try {
|
||
if (this._state !== 'stopped') {
|
||
const status = this.getDetectionStatus();
|
||
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
||
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
||
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
||
if (key !== this.lastEmittedDetectionKey) {
|
||
this.lastEmittedDetectionKey = key;
|
||
this.emit('detectionUpdate', status);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
|
||
}
|
||
},
|
||
2000,
|
||
{ description: 'detection-update' }
|
||
);
|
||
this.timerIds.set('detection-update', id);
|
||
}
|
||
|
||
/**
|
||
* Stop periodic detection status updates.
|
||
*/
|
||
private stopDetectionUpdates(): void {
|
||
this.cancelTrackedTimer('detection-update');
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
*/
|
||
private setState(newState: RespawnState): void {
|
||
if (newState === this._state) return;
|
||
|
||
const prevState = this._state;
|
||
this._state = newState;
|
||
this.stateEnteredAt = Date.now();
|
||
this.stuckStateWarned = false; // Reset warning for new state
|
||
this.log(`State: ${prevState} → ${newState}`);
|
||
this.logAction('state', `${prevState} → ${newState}`);
|
||
this.emit('stateChanged', newState, prevState);
|
||
|
||
// Reset stuck recovery count on successful state transition to normal states
|
||
if (newState === 'watching' && prevState !== 'stopped') {
|
||
this.stuckRecoveryCount = 0;
|
||
}
|
||
|
||
// Start/restart stuck-state detection timer
|
||
this.startStuckStateTimer();
|
||
}
|
||
|
||
/**
|
||
* Emit a timestamped log message.
|
||
* @param message - Log message content
|
||
* @fires log
|
||
*/
|
||
private log(message: string): void {
|
||
const timestamp = new Date().toISOString();
|
||
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
|
||
}
|
||
|
||
/** Set team watcher for team-aware idle detection */
|
||
setTeamWatcher(watcher: TeamWatcher): void {
|
||
this.teamWatcher = watcher;
|
||
}
|
||
|
||
/**
|
||
* 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'
|
||
*/
|
||
start(): void {
|
||
if (!this.config.enabled) {
|
||
this.log('Respawn is disabled');
|
||
return;
|
||
}
|
||
|
||
if (this._state !== 'stopped') {
|
||
this.log('Already running');
|
||
return;
|
||
}
|
||
|
||
this.log('Starting respawn controller (multi-layer detection)');
|
||
|
||
// Re-setup AI checker listeners in case they were removed by a previous stop()
|
||
// This allows the controller to be restarted after being stopped
|
||
this.setupAiCheckerListeners();
|
||
this.setupPlanCheckerListeners();
|
||
|
||
// Initialize all timestamps and reset hook state
|
||
const now = Date.now();
|
||
this.lastActivityTime = now;
|
||
this.lastOutputTime = now;
|
||
this.lastTokenChangeTime = now;
|
||
this.lastWorkingPatternTime = now;
|
||
this.completionMessageTime = null;
|
||
this.hasReceivedOutput = false;
|
||
this.resetHookState();
|
||
|
||
// Seed the terminal buffer from the session's existing output.
|
||
// This gives the AI checker context even if no new output arrives.
|
||
const existingBuffer = this.session.terminalBuffer;
|
||
if (existingBuffer) {
|
||
this.terminalBuffer.clear();
|
||
this.terminalBuffer.append(existingBuffer);
|
||
}
|
||
|
||
this.aiChecker.reset();
|
||
this.planChecker.reset();
|
||
this.setState('watching');
|
||
this.setupTerminalListener();
|
||
this.startDetectionUpdates();
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
if (this.config.autoAcceptPrompts) {
|
||
this.startAutoAcceptTimer();
|
||
}
|
||
|
||
// P2-001: Initialize idle detection start time
|
||
this.idleDetectionStartTime = Date.now();
|
||
}
|
||
|
||
/**
|
||
* 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'
|
||
*/
|
||
stop(): void {
|
||
this.log('Stopping respawn controller');
|
||
this.aiChecker.cancel();
|
||
this.planChecker.cancel();
|
||
// Remove event listeners from checkers to prevent memory leaks
|
||
this.aiChecker.removeAllListeners();
|
||
this.planChecker.removeAllListeners();
|
||
this.clearTimers();
|
||
this.recentActions.length = 0;
|
||
this.setState('stopped');
|
||
if (this.terminalHandler) {
|
||
this.session.off('terminal', this.terminalHandler);
|
||
this.terminalHandler = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
pause(): void {
|
||
this.log('Pausing respawn');
|
||
this.clearTimers();
|
||
// Stay in current state but clear timers
|
||
}
|
||
|
||
/**
|
||
* Resume respawn after pause.
|
||
*
|
||
* If in 'watching' state, immediately checks for idle condition.
|
||
* Otherwise, continues from current state.
|
||
* Re-setups terminal listener if it was removed (e.g., after stop()).
|
||
*/
|
||
resume(): void {
|
||
this.log('Resuming respawn');
|
||
// After a full stop(), use start() for clean restart to avoid double-registering listeners
|
||
if (this._state === 'stopped') {
|
||
this.start();
|
||
return;
|
||
}
|
||
// Re-setup terminal listener if it was removed
|
||
if (!this.terminalHandler) {
|
||
this.setupTerminalListener();
|
||
}
|
||
if (this._state === 'watching') {
|
||
this.checkIdleAndMaybeStart();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set up terminal output listener on the session.
|
||
* Removes any previous listener first to avoid duplicates.
|
||
*/
|
||
private setupTerminalListener(): void {
|
||
// Remove our previous listener if any (don't remove other listeners!)
|
||
if (this.terminalHandler) {
|
||
this.session.off('terminal', this.terminalHandler);
|
||
}
|
||
|
||
this.terminalHandler = (data: string) => {
|
||
this.handleTerminalData(data);
|
||
};
|
||
this.session.on('terminal', this.terminalHandler);
|
||
}
|
||
|
||
/**
|
||
* Process terminal data for idle/working detection using multi-layer approach.
|
||
*
|
||
* 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
|
||
*
|
||
* @param data - Raw terminal output data
|
||
*/
|
||
private handleTerminalData(data: string): void {
|
||
// Guard against null/undefined/empty data
|
||
if (!data || typeof data !== 'string') {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
|
||
// BufferAccumulator handles auto-trimming when max size exceeded
|
||
this.terminalBuffer.append(data);
|
||
|
||
// Track output time (Layer 2)
|
||
this.lastOutputTime = now;
|
||
this.lastActivityTime = now;
|
||
this.hasReceivedOutput = true;
|
||
this.resetNoOutputTimer();
|
||
this.resetPreFilterTimer();
|
||
this.resetAutoAcceptTimer();
|
||
|
||
// Cancel plan check if running (new output makes result stale)
|
||
if (this.planChecker.status === 'checking') {
|
||
this.log('New output during plan check, cancelling (stale)');
|
||
this.planChecker.cancel();
|
||
}
|
||
|
||
// Track token count (Layer 3)
|
||
const tokenCount = extractTokenCount(data);
|
||
if (tokenCount !== null && tokenCount !== this.lastTokenCount) {
|
||
this.lastTokenCount = tokenCount;
|
||
this.lastTokenChangeTime = now;
|
||
}
|
||
|
||
// Layer 1: Completion message (PRIMARY) — checked before working patterns
|
||
if (this._detectCompletionMessage(data, now)) return;
|
||
|
||
// Layer 4: Working patterns
|
||
if (this._detectWorkingPattern(data, now)) return;
|
||
|
||
// Substantial output during confirming_idle/ai_checking cancels the flow
|
||
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
|
||
// Strip ANSI escape codes to check if there's real content
|
||
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
|
||
const stripped = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').trim();
|
||
if (stripped.length > 2) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Legacy fallback: prompt detection
|
||
this._detectPrompt(data);
|
||
}
|
||
|
||
private _detectCompletionMessage(data: string, now: number): boolean {
|
||
if (!isCompletionMessage(data)) return false;
|
||
|
||
// Clear the rolling window - completion marks a transition point
|
||
this.clearWorkingPatternWindow();
|
||
this.workingDetected = false;
|
||
this.completionMessageTime = now;
|
||
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
||
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
||
|
||
// In watching state, start completion confirmation timer
|
||
if (this._state === 'watching') {
|
||
this.startCompletionConfirmTimer();
|
||
return true;
|
||
}
|
||
|
||
// In waiting states, also use confirmation timer (same detection logic)
|
||
// This ensures we wait for Claude to finish before proceeding
|
||
// Note: 'watching' is already handled above and returns early
|
||
switch (this._state) {
|
||
case 'waiting_update':
|
||
this.startStepConfirmTimer('update');
|
||
break;
|
||
case 'waiting_clear':
|
||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||
break;
|
||
case 'waiting_init':
|
||
this.startStepConfirmTimer('init');
|
||
break;
|
||
case 'waiting_kickstart':
|
||
this.startStepConfirmTimer('kickstart');
|
||
break;
|
||
// Non-waiting states: completion message is ignored
|
||
case 'confirming_idle':
|
||
case 'ai_checking':
|
||
case 'sending_update':
|
||
case 'sending_clear':
|
||
case 'sending_init':
|
||
case 'monitoring_init':
|
||
case 'sending_kickstart':
|
||
case 'stopped':
|
||
// Completion message during these states is ignored
|
||
break;
|
||
default:
|
||
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private _detectWorkingPattern(data: string, now: number): boolean {
|
||
const isWorking = this.checkWorkingPattern(data);
|
||
if (!isWorking) return false;
|
||
|
||
this.workingDetected = true;
|
||
this.promptDetected = false;
|
||
this.elicitationDetected = false; // Clear on new work cycle
|
||
this.resetHookState(); // Clear hook signals on new work
|
||
this.lastWorkingPatternTime = now;
|
||
|
||
// Cancel hook confirmation timer if running
|
||
this.cancelTrackedTimer('hook-confirm', 'working patterns detected');
|
||
|
||
// Cancel any pending completion confirmation
|
||
this.cancelCompletionConfirm();
|
||
|
||
// 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');
|
||
}
|
||
|
||
// Cancel plan check if running (Claude started working)
|
||
if (this.planChecker.status === 'checking') {
|
||
this.log('Working patterns detected during plan check, cancelling');
|
||
this.planChecker.cancel();
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private _detectPrompt(data: string): void {
|
||
const hasPrompt = PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
||
if (!hasPrompt) return;
|
||
|
||
this.promptDetected = true;
|
||
this.workingDetected = false;
|
||
|
||
// Handle legacy detection in waiting states - also use confirmation timers
|
||
switch (this._state) {
|
||
case 'waiting_update':
|
||
this.startStepConfirmTimer('update');
|
||
break;
|
||
case 'waiting_clear':
|
||
this.checkClearComplete(); // /clear is quick, no need to wait
|
||
break;
|
||
case 'waiting_init':
|
||
this.startStepConfirmTimer('init');
|
||
break;
|
||
case 'monitoring_init':
|
||
this.checkMonitoringInitIdle();
|
||
break;
|
||
case 'waiting_kickstart':
|
||
this.startStepConfirmTimer('kickstart');
|
||
break;
|
||
// Non-waiting states: prompt detection is informational only
|
||
case 'watching':
|
||
case 'confirming_idle':
|
||
case 'ai_checking':
|
||
case 'sending_update':
|
||
case 'sending_clear':
|
||
case 'sending_init':
|
||
case 'sending_kickstart':
|
||
case 'stopped':
|
||
// Prompt detection during these states doesn't trigger action
|
||
break;
|
||
default:
|
||
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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'
|
||
*/
|
||
private checkUpdateComplete(): void {
|
||
this.log('Update completed (ready indicator)');
|
||
this.emit('stepCompleted', 'update');
|
||
|
||
// P2-004: Record step completion
|
||
this.recordCycleStep('update');
|
||
|
||
if (this.config.sendClear) {
|
||
// P2-002: Check if we should skip /clear
|
||
if (this.checkShouldSkipClear()) {
|
||
this.cycleMetrics.markClearSkipped();
|
||
// Skip /clear, go directly to /init or complete
|
||
if (this.config.sendInit) {
|
||
this.sendInit();
|
||
} else {
|
||
this.completeCycle();
|
||
}
|
||
} else {
|
||
this.sendClear();
|
||
}
|
||
} else if (this.config.sendInit) {
|
||
this.sendInit();
|
||
} else {
|
||
this.completeCycle();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle /clear step completion.
|
||
* Proceeds to init or completes cycle based on config.
|
||
* @fires stepCompleted - With step 'clear'
|
||
*/
|
||
private checkClearComplete(): void {
|
||
// Clear the fallback timer since we got prompt detection
|
||
this.cancelTrackedTimer('clear-fallback', 'prompt detected');
|
||
|
||
this.logAction('step', '/clear completed');
|
||
this.emit('stepCompleted', 'clear');
|
||
|
||
// P2-004: Record step completion
|
||
this.recordCycleStep('clear');
|
||
|
||
if (this.config.sendInit) {
|
||
this.sendInit();
|
||
} else {
|
||
this.completeCycle();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle /init step completion.
|
||
* If kickstart is configured, monitors for work.
|
||
* Otherwise completes cycle.
|
||
* @fires stepCompleted - With step 'init' (if no kickstart)
|
||
*/
|
||
private checkInitComplete(): void {
|
||
this.log('/init completed (ready indicator)');
|
||
|
||
// P2-004: Record step completion
|
||
this.recordCycleStep('init');
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start monitoring to see if /init triggered work.
|
||
* Enters 'monitoring_init' state and waits 3s grace period.
|
||
* If no work detected, sends kickstart prompt.
|
||
*/
|
||
private startMonitoringInit(): void {
|
||
this.setState('monitoring_init');
|
||
this.terminalBuffer.clear();
|
||
this.clearWorkingPatternWindow();
|
||
this.workingDetected = false;
|
||
this.logAction('step', 'Monitoring if /init triggered work...');
|
||
|
||
// Give Claude a moment to start working before checking for idle
|
||
this.startTrackedTimer(
|
||
'init-monitor',
|
||
3000,
|
||
() => {
|
||
// If still in monitoring state and no work detected, consider it idle
|
||
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
||
this.checkMonitoringInitIdle();
|
||
}
|
||
},
|
||
'grace period for /init'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Handle monitoring timeout when /init didn't trigger work.
|
||
* Sends kickstart prompt as fallback.
|
||
* @fires stepCompleted - With step 'init'
|
||
*/
|
||
private checkMonitoringInitIdle(): void {
|
||
this.cancelTrackedTimer('init-monitor');
|
||
this.log('/init did not trigger work, sending kickstart prompt');
|
||
this.emit('stepCompleted', 'init');
|
||
this.sendKickstart();
|
||
}
|
||
|
||
/**
|
||
* Send the kickstart prompt to get Claude working.
|
||
* @fires stepSent - With step 'kickstart'
|
||
*/
|
||
private sendKickstart(): void {
|
||
this.setState('sending_kickstart');
|
||
this.terminalBuffer.clear();
|
||
this.clearWorkingPatternWindow();
|
||
|
||
this.startTrackedTimer(
|
||
'step-delay',
|
||
this.config.interStepDelayMs,
|
||
async () => {
|
||
if (this._state === 'stopped') return;
|
||
const prompt = this.config.kickstartPrompt!;
|
||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
||
this.emit('stepSent', 'kickstart', prompt);
|
||
this.setState('waiting_kickstart');
|
||
this.promptDetected = false;
|
||
this.workingDetected = false;
|
||
},
|
||
'delay before kickstart'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Handle kickstart step completion.
|
||
* @fires stepCompleted - With step 'kickstart'
|
||
*/
|
||
private checkKickstartComplete(): void {
|
||
this.log('Kickstart completed (ready indicator)');
|
||
this.emit('stepCompleted', 'kickstart');
|
||
|
||
// P2-004: Record step completion
|
||
this.recordCycleStep('kickstart');
|
||
|
||
this.completeCycle();
|
||
}
|
||
|
||
/** Clear all timers (step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
|
||
private clearTimers(): void {
|
||
this.activeTimers.clear();
|
||
this.timerIds.clear();
|
||
this.cleanup.dispose();
|
||
this.cleanup = new CleanupManager();
|
||
}
|
||
|
||
// ========== Stuck-State Detection Methods ==========
|
||
|
||
/**
|
||
* Start or restart the stuck-state detection timer.
|
||
* Emits warning after stuckStateWarningMs, triggers recovery after stuckStateRecoveryMs.
|
||
*/
|
||
private startStuckStateTimer(): void {
|
||
if (!this.config.stuckStateDetectionEnabled) return;
|
||
if (this._state === 'stopped') return;
|
||
|
||
// Clear existing timer
|
||
this.cancelTrackedTimer('stuck-state');
|
||
|
||
// Check interval for stuck state
|
||
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
|
||
|
||
const id = this.cleanup.setInterval(
|
||
() => {
|
||
try {
|
||
this.checkStuckState();
|
||
} catch (err) {
|
||
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
|
||
}
|
||
},
|
||
checkIntervalMs,
|
||
{ description: 'stuck-state' }
|
||
);
|
||
this.timerIds.set('stuck-state', id);
|
||
}
|
||
|
||
/**
|
||
* Check if the controller is stuck in the current state.
|
||
* Emits warnings and triggers recovery actions as appropriate.
|
||
*/
|
||
private checkStuckState(): void {
|
||
if (this._state === 'stopped') return;
|
||
|
||
const durationMs = Date.now() - this.stateEnteredAt;
|
||
|
||
// Check for recovery threshold (more severe)
|
||
if (durationMs >= this.config.stuckStateRecoveryMs) {
|
||
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
||
this.stuckRecoveryCount++;
|
||
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
||
this.log(
|
||
`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`
|
||
);
|
||
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
||
this.handleStuckStateRecovery();
|
||
} else {
|
||
this.logAction(
|
||
'stuck',
|
||
`Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`
|
||
);
|
||
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Check for warning threshold
|
||
if (durationMs >= this.config.stuckStateWarningMs && !this.stuckStateWarned) {
|
||
this.stuckStateWarned = true;
|
||
this.logAction('stuck', `Warning: in state '${this._state}' for ${Math.round(durationMs / 1000)}s`);
|
||
this.log(`Stuck-state warning: state '${this._state}' for ${Math.round(durationMs / 1000)}s without progress`);
|
||
this.emit('stuckStateWarning', this._state, durationMs);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle stuck-state recovery by resetting to a known good state.
|
||
* Uses escalating recovery strategies based on current state.
|
||
*/
|
||
private handleStuckStateRecovery(): void {
|
||
const currentState = this._state;
|
||
|
||
// P2-004: Complete current cycle metrics with stuck_recovery outcome
|
||
if (this.cycleMetrics.getCurrentCycle()) {
|
||
this.completeCycleMetrics('stuck_recovery', `Stuck in state: ${currentState}`);
|
||
}
|
||
|
||
// Cancel any running AI checks
|
||
if (this.aiChecker.status === 'checking') {
|
||
this.aiChecker.cancel();
|
||
}
|
||
if (this.planChecker.status === 'checking') {
|
||
this.planChecker.cancel();
|
||
}
|
||
|
||
// Clear all timers and reset detection state
|
||
this.clearTimers();
|
||
this.completionMessageTime = null;
|
||
this.promptDetected = false;
|
||
this.workingDetected = false;
|
||
this.resetHookState();
|
||
|
||
// Escalating recovery strategies
|
||
switch (currentState) {
|
||
case 'watching':
|
||
case 'confirming_idle':
|
||
case 'ai_checking':
|
||
// For detection states, try forcing idle confirmation
|
||
this.log('Recovery: forcing idle confirmation');
|
||
this.onIdleConfirmed(`stuck-state recovery (was ${currentState})`);
|
||
break;
|
||
|
||
case 'waiting_update':
|
||
case 'waiting_clear':
|
||
case 'waiting_init':
|
||
case 'waiting_kickstart':
|
||
case 'monitoring_init':
|
||
// For waiting states, skip to next step or complete cycle
|
||
this.log('Recovery: skipping stuck step');
|
||
this.completeCycle();
|
||
break;
|
||
|
||
case 'sending_update':
|
||
case 'sending_clear':
|
||
case 'sending_init':
|
||
case 'sending_kickstart':
|
||
// For sending states, retry the send
|
||
this.recoveryResetToWatching('returning to watching state');
|
||
break;
|
||
|
||
default:
|
||
// Fallback: reset to watching
|
||
this.recoveryResetToWatching('fallback to watching state');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset the controller to watching state during stuck-state recovery.
|
||
* Sets state to watching and restarts all detection timers.
|
||
*
|
||
* @param reason - Human-readable reason for the reset (logged)
|
||
*/
|
||
private recoveryResetToWatching(reason: string): void {
|
||
this.log(`Recovery: ${reason}`);
|
||
this.setState('watching');
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
if (this.config.autoAcceptPrompts) {
|
||
this.startAutoAcceptTimer();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get stuck-state metrics for UI display.
|
||
*/
|
||
getStuckStateMetrics(): {
|
||
currentStateDurationMs: number;
|
||
warningThresholdMs: number;
|
||
recoveryThresholdMs: number;
|
||
recoveryAttempts: number;
|
||
maxRecoveries: number;
|
||
isWarned: boolean;
|
||
} {
|
||
return {
|
||
currentStateDurationMs: this._state !== 'stopped' ? Date.now() - this.stateEnteredAt : 0,
|
||
warningThresholdMs: this.config.stuckStateWarningMs,
|
||
recoveryThresholdMs: this.config.stuckStateRecoveryMs,
|
||
recoveryAttempts: this.stuckRecoveryCount,
|
||
maxRecoveries: this.config.maxStuckRecoveries,
|
||
isWarned: this.stuckStateWarned,
|
||
};
|
||
}
|
||
|
||
// ========== Timer Tracking Methods ==========
|
||
|
||
/**
|
||
* Start a tracked timer with UI countdown support.
|
||
* Emits timerStarted event and tracks the timer for UI display.
|
||
*/
|
||
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): void {
|
||
// Cancel any existing timer with this name
|
||
this.cancelTrackedTimer(name);
|
||
|
||
const now = Date.now();
|
||
const endsAt = now + durationMs;
|
||
|
||
this.activeTimers.set(name, { name, startedAt: now, durationMs, endsAt });
|
||
this.emit('timerStarted', { name, durationMs, endsAt, reason });
|
||
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
|
||
|
||
const id = this.cleanup.setTimeout(
|
||
() => {
|
||
this.timerIds.delete(name);
|
||
this.activeTimers.delete(name);
|
||
this.emit('timerCompleted', name);
|
||
callback();
|
||
},
|
||
durationMs,
|
||
{ description: name }
|
||
);
|
||
this.timerIds.set(name, id);
|
||
}
|
||
|
||
/**
|
||
* Cancel a tracked timer and emit cancellation event.
|
||
*/
|
||
private cancelTrackedTimer(name: string, reason?: string): void {
|
||
const id = this.timerIds.get(name);
|
||
if (id) {
|
||
this.cleanup.unregister(id);
|
||
this.timerIds.delete(name);
|
||
if (this.activeTimers.has(name)) {
|
||
this.activeTimers.delete(name);
|
||
this.emit('timerCancelled', name, reason);
|
||
this.logAction('timer-cancel', `${name}${reason ? `: ${reason}` : ''}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get all active timers with remaining time for UI display.
|
||
*/
|
||
getActiveTimers(): ActiveTimerInfo[] {
|
||
const now = Date.now();
|
||
return Array.from(this.activeTimers.values()).map((t) => ({
|
||
name: t.name,
|
||
remainingMs: Math.max(0, t.endsAt - now),
|
||
totalMs: t.durationMs,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Log an action for detailed UI feedback.
|
||
* Keeps the last 20 entries.
|
||
*/
|
||
private logAction(type: string, detail: string): void {
|
||
const action: ActionLogEntry = { type, detail, timestamp: Date.now() };
|
||
this.recentActions.unshift(action);
|
||
if (this.recentActions.length > 20) {
|
||
this.recentActions.pop();
|
||
}
|
||
this.emit('actionLog', action);
|
||
}
|
||
|
||
/**
|
||
* Get recent action log entries.
|
||
*/
|
||
getRecentActions(): ActionLogEntry[] {
|
||
return [...this.recentActions];
|
||
}
|
||
|
||
// ========== Multi-Layer Detection Methods ==========
|
||
// Pattern detection delegated to ./respawn-patterns.js (isCompletionMessage, hasWorkingPattern, extractTokenCount)
|
||
|
||
/**
|
||
* Check if data contains working patterns using the rolling window.
|
||
* Updates the window and delegates to the pure function from respawn-patterns.
|
||
*/
|
||
private checkWorkingPattern(data: string): boolean {
|
||
// Always update the rolling window first to maintain continuity
|
||
this.workingPatternWindow += data;
|
||
if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) {
|
||
this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE);
|
||
}
|
||
|
||
// Delegate to pure function
|
||
return hasWorkingPattern(this.workingPatternWindow);
|
||
}
|
||
|
||
/**
|
||
* Clear the working pattern rolling window.
|
||
* Called when starting a new detection cycle.
|
||
*/
|
||
private clearWorkingPatternWindow(): void {
|
||
this.workingPatternWindow = '';
|
||
}
|
||
|
||
/**
|
||
* Start the no-output fallback timer.
|
||
* 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 {
|
||
this.cancelTrackedTimer('no-output-fallback', 'restarting');
|
||
|
||
this.startTrackedTimer(
|
||
'no-output-fallback',
|
||
this.config.noOutputTimeoutMs,
|
||
() => {
|
||
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
|
||
// 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');
|
||
}
|
||
}
|
||
},
|
||
'fallback if no output at all'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reset the no-output fallback timer.
|
||
* Called whenever output is received.
|
||
*/
|
||
private resetNoOutputTimer(): void {
|
||
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 {
|
||
this.cancelTrackedTimer('pre-filter', 'restarting');
|
||
|
||
// Only set up pre-filter when AI check is enabled
|
||
if (!this.config.aiIdleCheckEnabled) return;
|
||
|
||
this.startTrackedTimer(
|
||
'pre-filter',
|
||
this.config.completionConfirmMs,
|
||
() => {
|
||
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 >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
|
||
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
|
||
|
||
if (silenceMet && noWorkingMet && tokensStableMet) {
|
||
this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`);
|
||
this.tryStartAiCheck('pre-filter');
|
||
}
|
||
}
|
||
},
|
||
'checking idle conditions'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
// P0-006: Check Session.isWorking first to skip expensive AI call if session reports working
|
||
if (this.session.isWorking) {
|
||
this.log(`Skipping AI check - Session reports isWorking=true (reason: ${reason})`);
|
||
this.logAction('detection', 'Skipped AI check: Session is working');
|
||
return;
|
||
}
|
||
|
||
// Check for active child processes (bash tools, test suites, builds, etc.)
|
||
// These may produce no terminal output, so restart timers to retry periodically.
|
||
const activeProcesses = this.session.getActiveChildProcesses();
|
||
if (activeProcesses.length > 0) {
|
||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||
this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`);
|
||
this.logAction('detection', `Skipped AI check: child processes running (${names})`);
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
return;
|
||
}
|
||
|
||
// 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 (${formatRemainingSeconds(this.aiChecker.getCooldownRemainingMs())}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.logAction('ai-check', `Spawning AI idle checker (${reason})`);
|
||
this.emit('aiCheckStarted');
|
||
|
||
// Generate unique ID for this check to detect stale results
|
||
const checkId = randomUUID();
|
||
this._currentAiCheckId = checkId;
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Validate this is the result for the current check (not a stale one)
|
||
if (this._currentAiCheckId !== checkId) {
|
||
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
||
return;
|
||
}
|
||
|
||
if (result.verdict === 'IDLE') {
|
||
// Cancel any pending confirmation timers - AI has spoken
|
||
this.cancelTrackedTimer('completion-confirm', 'AI verdict: IDLE');
|
||
|
||
this.cancelTrackedTimer('pre-filter', 'AI verdict: IDLE');
|
||
|
||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||
this.emit('aiCheckCompleted', result);
|
||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||
} else if (result.verdict === 'WORKING') {
|
||
// Cancel timers and go to cooldown
|
||
this.cancelTrackedTimer('completion-confirm', 'AI verdict: WORKING');
|
||
|
||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||
this.emit('aiCheckCompleted', result);
|
||
this.setState('watching');
|
||
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
||
// Restart timers so the controller retries after cooldown expires
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
} else {
|
||
// ERROR verdict
|
||
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
||
this.emit('aiCheckFailed', result.reasoning);
|
||
this.setState('watching');
|
||
// Restart timers to allow retry
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
// Validate this is the error for the current check
|
||
if (this._currentAiCheckId !== checkId) {
|
||
return; // Stale check, ignore error
|
||
}
|
||
if (this._state === 'stopped') return; // Guard against stopped state
|
||
if (this._state === 'ai_checking') {
|
||
const errorMsg = getErrorMessage(err);
|
||
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||
this.emit('aiCheckFailed', errorMsg);
|
||
this.setState('watching');
|
||
this.log(`AI check error: ${errorMsg}`);
|
||
// Restart timers to allow retry
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== 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.
|
||
* Fires after autoAcceptDelayMs of no output when no completion message
|
||
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
||
*/
|
||
private startAutoAcceptTimer(): void {
|
||
this.cancelTrackedTimer('auto-accept', 'restarting');
|
||
|
||
this.startTrackedTimer(
|
||
'auto-accept',
|
||
this.config.autoAcceptDelayMs,
|
||
() => {
|
||
this.tryAutoAccept();
|
||
},
|
||
'plan mode detection'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Cancel the auto-accept timer.
|
||
* Called when a completion message is detected (normal idle flow handles it).
|
||
*/
|
||
private cancelAutoAcceptTimer(): void {
|
||
this.cancelTrackedTimer('auto-accept', 'cancelled');
|
||
}
|
||
|
||
/**
|
||
* Attempt to auto-accept a plan mode prompt by sending Enter.
|
||
* Two-stage gate:
|
||
* 1. Strict regex pre-filter — check if terminal buffer contains plan mode UI elements
|
||
* 2. AI confirmation — spawn Opus to classify buffer as PLAN_MODE or NOT_PLAN_MODE
|
||
*
|
||
* Only sends Enter if both stages confirm (or pre-filter only if AI disabled).
|
||
*
|
||
* @fires autoAcceptSent
|
||
* @fires planCheckStarted
|
||
*/
|
||
private tryAutoAccept(): void {
|
||
if (!this.canAutoAccept()) return;
|
||
|
||
const buffer = this.terminalBuffer.value;
|
||
|
||
// Stage 2: AI confirmation (if enabled and available)
|
||
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
||
if (this.planChecker.isOnCooldown()) {
|
||
this.log(
|
||
`Skipping auto-accept: plan checker on cooldown (${formatRemainingSeconds(this.planChecker.getCooldownRemainingMs())}s remaining)`
|
||
);
|
||
return;
|
||
}
|
||
if (this.planChecker.status === 'checking') {
|
||
this.log('Skipping auto-accept: plan check already in progress');
|
||
return;
|
||
}
|
||
// Start async AI plan check
|
||
this.startPlanCheck(buffer);
|
||
return;
|
||
}
|
||
|
||
// AI plan check disabled — pre-filter passed, send Enter directly
|
||
this.sendAutoAcceptEnter();
|
||
}
|
||
|
||
/**
|
||
* Check whether all preconditions for auto-accept are met.
|
||
* Validates state, config, and pre-filter conditions before attempting auto-accept.
|
||
*
|
||
* @returns True if auto-accept should proceed to the AI confirmation stage
|
||
*/
|
||
private canAutoAccept(): boolean {
|
||
// Only auto-accept in watching state (not during a respawn cycle)
|
||
if (this._state !== 'watching') return false;
|
||
|
||
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
||
if (this.completionMessageTime !== null) return false;
|
||
|
||
// Don't auto-accept if disabled
|
||
if (!this.config.autoAcceptPrompts) return false;
|
||
|
||
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
||
if (!this.hasReceivedOutput) return false;
|
||
|
||
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
||
if (this.elicitationDetected) {
|
||
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
||
return false;
|
||
}
|
||
|
||
// Stage 1: Pre-filter — check if buffer looks like plan mode
|
||
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
|
||
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Check if the terminal buffer matches plan mode pre-filter patterns.
|
||
* Only checks the last 2000 chars (plan mode UI appears at the bottom).
|
||
*
|
||
* Must find:
|
||
* - Numbered option pattern (e.g., "1. Yes", "2. No")
|
||
* - Selection indicator (❯ or > followed by number)
|
||
* Must NOT find:
|
||
* - Recent working patterns (spinners, "Thinking", etc.) in the tail
|
||
*/
|
||
private isPlanModePreFilterMatch(buffer: string): boolean {
|
||
// Only check the last 2000 chars (plan mode UI is at the bottom)
|
||
const tail = buffer.slice(-2000);
|
||
|
||
// Strip ANSI codes for pattern matching
|
||
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
|
||
const stripped = tail.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
||
|
||
// Must find numbered option pattern
|
||
if (!PLAN_MODE_OPTION_PATTERN.test(stripped)) return false;
|
||
|
||
// Must find selection indicator
|
||
const selectorMatch = stripped.match(PLAN_MODE_SELECTOR_PATTERN);
|
||
if (!selectorMatch) return false;
|
||
|
||
// Must NOT have working patterns AFTER the selector position.
|
||
// Working patterns before the selector are from earlier work and don't matter.
|
||
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
||
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
||
const hasWorking = WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern));
|
||
if (hasWorking) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Start an AI plan check to confirm plan mode before auto-accepting.
|
||
* Async — result handled by then/catch.
|
||
*
|
||
* @param buffer - Terminal buffer to analyze
|
||
* @fires planCheckStarted
|
||
* @fires planCheckCompleted
|
||
* @fires planCheckFailed
|
||
*/
|
||
private startPlanCheck(buffer: string): void {
|
||
this.planCheckStartTime = Date.now();
|
||
this.logAction('plan-check', 'Spawning AI plan checker');
|
||
this.emit('planCheckStarted');
|
||
|
||
this.planChecker
|
||
.check(buffer)
|
||
.then((result) => {
|
||
// Discard stale result if new output arrived during check
|
||
if (this.lastOutputTime > this.planCheckStartTime) {
|
||
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
||
return;
|
||
}
|
||
|
||
if (result.verdict === 'PLAN_MODE') {
|
||
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
||
if (this._state !== 'watching') {
|
||
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
||
return;
|
||
}
|
||
this.emit('planCheckCompleted', result);
|
||
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
||
this.sendAutoAcceptEnter();
|
||
// No cooldown needed - we're taking action
|
||
} else if (result.verdict === 'NOT_PLAN_MODE') {
|
||
this.emit('planCheckCompleted', result);
|
||
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
||
} else {
|
||
// ERROR verdict
|
||
this.emit('planCheckFailed', result.reasoning);
|
||
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
const errorMsg = getErrorMessage(err);
|
||
this.emit('planCheckFailed', errorMsg);
|
||
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Send the actual Enter keystroke for auto-accept.
|
||
* Factored out so both pre-filter-only and AI-confirmed paths can call it.
|
||
* @fires autoAcceptSent
|
||
*/
|
||
private sendAutoAcceptEnter(): void {
|
||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||
this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)`);
|
||
|
||
// Cancel any pending AI idle checks - we're about to make Claude work
|
||
if (this.aiChecker.status === 'checking') {
|
||
this.log('Cancelling AI idle check before auto-accept');
|
||
this.aiChecker.cancel();
|
||
}
|
||
|
||
// Cancel completion confirmation - auto-accept takes precedence
|
||
this.cancelTrackedTimer('completion-confirm', 'auto-accept');
|
||
|
||
this.completionMessageTime = null;
|
||
|
||
// Ensure we're in watching state (not confirming_idle or ai_checking)
|
||
if (this._state !== 'watching') {
|
||
this.setState('watching');
|
||
}
|
||
|
||
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
|
||
this.emit('stepSent', 'auto-accept', '↵');
|
||
void this.session.writeViaMux('\r');
|
||
this.emit('autoAcceptSent');
|
||
// Reset so we don't keep spamming Enter if Claude doesn't respond
|
||
this.hasReceivedOutput = false;
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
}
|
||
|
||
/**
|
||
* Signal that a Stop hook was received from Claude Code.
|
||
* This is a DEFINITIVE signal that Claude has finished responding.
|
||
* Skips AI idle check and uses a short confirmation period to handle race conditions.
|
||
*
|
||
* @fires log
|
||
*/
|
||
signalStopHook(): void {
|
||
// Only process in states where we're watching for idle
|
||
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
||
this.log(`Stop hook received but ignoring (state is ${this._state})`);
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
this.stopHookReceived = true;
|
||
this.stopHookTime = now;
|
||
this.logAction('hook', 'Stop hook received - definitive idle signal');
|
||
this.log('Stop hook received from Claude Code - definitive idle signal');
|
||
|
||
// Cancel any running AI check - we have a definitive signal
|
||
if (this._state === 'ai_checking') {
|
||
this.log('Cancelling AI check - Stop hook is definitive');
|
||
this.aiChecker.cancel();
|
||
}
|
||
|
||
// Cancel completion confirm timer - hook takes precedence
|
||
this.cancelTrackedTimer('completion-confirm', 'Stop hook received');
|
||
|
||
// Cancel pre-filter timer - hook takes precedence
|
||
this.cancelTrackedTimer('pre-filter', 'Stop hook received');
|
||
|
||
// Start short confirmation timer to handle race conditions
|
||
// (e.g., Stop hook arrives but Claude immediately starts new work)
|
||
this.startHookConfirmTimer('stop');
|
||
}
|
||
|
||
/**
|
||
* Signal that an idle_prompt notification was received from Claude Code.
|
||
* This fires after 60+ seconds of Claude waiting for user input.
|
||
* This is a DEFINITIVE signal that Claude is idle.
|
||
*
|
||
* @fires log
|
||
*/
|
||
signalIdlePrompt(): void {
|
||
// Only process in states where we're watching for idle
|
||
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
||
this.log(`idle_prompt received but ignoring (state is ${this._state})`);
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
this.idlePromptReceived = true;
|
||
this.idlePromptTime = now;
|
||
this.logAction('hook', 'idle_prompt received - 60s+ idle confirmed');
|
||
this.log('idle_prompt notification received - Claude has been idle for 60+ seconds');
|
||
|
||
// Cancel any running AI check - we have a definitive signal
|
||
if (this._state === 'ai_checking') {
|
||
this.log('Cancelling AI check - idle_prompt is definitive');
|
||
this.aiChecker.cancel();
|
||
}
|
||
|
||
// Cancel all other detection timers - this is definitive
|
||
this.cancelTrackedTimer('completion-confirm', 'idle_prompt received');
|
||
|
||
this.cancelTrackedTimer('pre-filter', 'idle_prompt received');
|
||
|
||
this.cancelTrackedTimer('no-output-fallback', 'idle_prompt received');
|
||
|
||
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
|
||
// Skip confirmation and go directly to idle
|
||
this.onIdleConfirmed('idle_prompt hook (60s+ idle)');
|
||
}
|
||
|
||
/**
|
||
* Start a short confirmation timer after receiving a hook signal.
|
||
* This handles race conditions where a hook arrives but Claude immediately starts new work.
|
||
*
|
||
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
|
||
*/
|
||
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
|
||
this.cancelTrackedTimer('hook-confirm', 'restarting');
|
||
|
||
this.startTrackedTimer(
|
||
'hook-confirm',
|
||
RespawnController.HOOK_CONFIRM_DELAY_MS,
|
||
() => {
|
||
// Verify we haven't received new output since the hook arrived
|
||
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
|
||
if (hookTime && this.lastOutputTime > hookTime) {
|
||
// Output arrived after hook - Claude started new work
|
||
this.log(`Output received after ${hookType} hook, cancelling idle confirmation`);
|
||
this.logAction('hook', `${hookType} cancelled - new output detected`);
|
||
// Set state before resetting flags so event handlers see consistent state
|
||
this.setState('watching');
|
||
this.resetHookState();
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
if (this.config.autoAcceptPrompts) {
|
||
this.startAutoAcceptTimer();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// No new output - confirm idle via hook signal
|
||
this.logAction('hook', `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`);
|
||
this.onIdleConfirmed(`${hookType} hook (confirmed)`);
|
||
},
|
||
`confirming ${hookType} hook`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reset hook-based detection state.
|
||
* Called when hooks are cancelled due to new activity.
|
||
*/
|
||
private resetHookState(): void {
|
||
this.stopHookReceived = false;
|
||
this.stopHookTime = null;
|
||
this.idlePromptReceived = false;
|
||
this.idlePromptTime = null;
|
||
}
|
||
|
||
/**
|
||
* Signal that the transcript indicates completion.
|
||
* This is a supporting signal from transcript file monitoring.
|
||
* Unlike hooks, this doesn't immediately trigger idle - it boosts confidence.
|
||
*/
|
||
signalTranscriptComplete(): void {
|
||
// Transcript completion is a supporting signal, not definitive
|
||
// It can help reduce the confirmation time needed
|
||
if (this._state === 'watching') {
|
||
this.logAction('transcript', 'Transcript shows completion - boosting confidence');
|
||
this.log('Transcript completion detected - may accelerate idle detection');
|
||
// If we have a completion message and transcript confirms, try AI check
|
||
if (this.completionMessageTime !== null) {
|
||
this.tryStartAiCheck('transcript + completion message');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Signal that the transcript indicates plan mode.
|
||
* This helps prevent auto-accept from triggering on AskUserQuestion.
|
||
*/
|
||
signalTranscriptPlanMode(): void {
|
||
// Plan mode from transcript = potential AskUserQuestion
|
||
// This is similar to elicitation detection
|
||
if (this._state === 'watching') {
|
||
this.logAction('transcript', 'Plan mode / AskUserQuestion detected');
|
||
this.cancelAutoAcceptTimer();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start completion confirmation timer.
|
||
* After completion message, waits for output silence then triggers AI check.
|
||
*/
|
||
private startCompletionConfirmTimer(): void {
|
||
this.cancelTrackedTimer('completion-confirm', 'restarting');
|
||
|
||
this.setState('confirming_idle');
|
||
this.logAction('detection', 'Completion message found in output');
|
||
|
||
this.startTrackedTimer(
|
||
'completion-confirm',
|
||
this.config.completionConfirmMs,
|
||
() => {
|
||
if (this._state === 'stopped') return;
|
||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||
this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`);
|
||
this.tryStartAiCheck('completion + silence');
|
||
} else {
|
||
// Output received during wait, stay in confirming state and re-check
|
||
this.logAction('detection', 'Output during confirmation, resetting');
|
||
if (this._state !== 'confirming_idle') return;
|
||
this.startCompletionConfirmTimer();
|
||
}
|
||
},
|
||
'waiting for silence after completion'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Cancel completion confirmation if new activity detected.
|
||
*/
|
||
private cancelCompletionConfirm(): void {
|
||
this.cancelTrackedTimer('completion-confirm', 'activity detected');
|
||
|
||
if (this._state === 'confirming_idle') {
|
||
this.setState('watching');
|
||
this.completionMessageTime = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
this.cancelTrackedTimer('step-confirm', 'restarting');
|
||
|
||
this.startTrackedTimer(
|
||
'step-confirm',
|
||
this.config.completionConfirmMs,
|
||
() => {
|
||
if (this._state === 'stopped') return;
|
||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||
|
||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||
this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`);
|
||
|
||
// 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.logAction('step', `Output during ${step} confirmation, resetting`);
|
||
this.startStepConfirmTimer(step);
|
||
}
|
||
},
|
||
`confirming ${step} completion`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Cancel step confirmation if working patterns detected.
|
||
*/
|
||
private cancelStepConfirm(): void {
|
||
this.cancelTrackedTimer('step-confirm', 'working detected');
|
||
}
|
||
|
||
/**
|
||
* Called when idle is confirmed through any detection layer.
|
||
* @param reason - What triggered the confirmation
|
||
*/
|
||
private onIdleConfirmed(reason: string): void {
|
||
// Safety check: if Session thinks it's still working, don't trigger idle
|
||
// This catches cases where our detection missed working patterns
|
||
if (this.session.isWorking) {
|
||
this.log(`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`);
|
||
this.logAction('detection', 'Rejected: Session still working');
|
||
this.setState('watching');
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
return;
|
||
}
|
||
|
||
// Safety check: if child processes are running (bash tools, test suites, builds, etc.)
|
||
const activeProcesses = this.session.getActiveChildProcesses();
|
||
if (activeProcesses.length > 0) {
|
||
const names = activeProcesses.map((p) => p.command).join(', ');
|
||
this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`);
|
||
this.logAction('detection', `Rejected: child processes running (${names})`);
|
||
this.setState('watching');
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
return;
|
||
}
|
||
|
||
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}`
|
||
);
|
||
|
||
// ========== Agent Teams Integration ==========
|
||
// Check if session has active teammates — don't respawn while team is working
|
||
if (this.teamWatcher?.hasActiveTeammates(this.session.id)) {
|
||
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
||
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
||
this.logAction('team', `Active teammates: ${count}`);
|
||
this.emit('respawnBlocked', {
|
||
reason: 'active_teammates',
|
||
details: `${count} teammate(s) still working`,
|
||
});
|
||
this.setState('watching');
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
return;
|
||
}
|
||
|
||
// ========== RALPH_STATUS Integration ==========
|
||
// Check circuit breaker status - if OPEN, pause respawn
|
||
const ralphTracker = this.session.ralphTracker;
|
||
if (ralphTracker) {
|
||
const circuitBreaker = ralphTracker.circuitBreakerStatus;
|
||
if (circuitBreaker.state === 'OPEN') {
|
||
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
||
this.emit('respawnBlocked', {
|
||
reason: 'circuit_breaker_open',
|
||
details: circuitBreaker.reason,
|
||
});
|
||
this.setState('watching');
|
||
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Check RALPH_STATUS EXIT_SIGNAL - if true, loop is complete
|
||
const statusBlock = ralphTracker?.lastStatusBlock;
|
||
if (statusBlock?.exitSignal) {
|
||
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
||
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
||
this.emit('respawnBlocked', {
|
||
reason: 'exit_signal',
|
||
details: statusBlock.recommendation || 'Task complete',
|
||
});
|
||
this.setState('watching');
|
||
// Don't restart timers - loop is complete
|
||
return;
|
||
}
|
||
|
||
// Check if STATUS=BLOCKED - trigger circuit breaker
|
||
if (statusBlock?.status === 'BLOCKED') {
|
||
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
||
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
||
this.emit('respawnBlocked', {
|
||
reason: 'status_blocked',
|
||
details: statusBlock.recommendation || 'Needs human intervention',
|
||
});
|
||
this.setState('watching');
|
||
return;
|
||
}
|
||
|
||
// Reset detection state
|
||
this.completionMessageTime = null;
|
||
this.cancelCompletionConfirm();
|
||
|
||
// Trigger the respawn cycle
|
||
this.onIdleDetected();
|
||
}
|
||
|
||
/**
|
||
* Handle confirmed idle detection.
|
||
* Starts a new respawn cycle.
|
||
* @fires respawnCycleStarted
|
||
*/
|
||
private onIdleDetected(): void {
|
||
// Accept watching, confirming_idle, and ai_checking states
|
||
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
||
return;
|
||
}
|
||
|
||
// Clear all detection timers before starting cycle to prevent stale callbacks
|
||
this.clearTimers();
|
||
|
||
// P1-006: Session health check before respawn cycle
|
||
// Skip if session is in error state or not running
|
||
if (this.session.status === 'error') {
|
||
this.log('Skipping respawn cycle - session is in error state');
|
||
this.logAction('health', 'Respawn skipped: Session error state');
|
||
this.emit('respawnBlocked', {
|
||
reason: 'session_error',
|
||
details: 'Session is in error state',
|
||
});
|
||
this.setState('watching');
|
||
return;
|
||
}
|
||
|
||
if (this.session.status === 'stopped') {
|
||
this.log('Skipping respawn cycle - session is stopped');
|
||
this.logAction('health', 'Respawn skipped: Session stopped');
|
||
this.emit('respawnBlocked', { reason: 'session_stopped', details: 'Session is stopped' });
|
||
this.setState('watching');
|
||
return;
|
||
}
|
||
|
||
// Check if session PTY is still alive (via PID)
|
||
if (!this.session.pid) {
|
||
this.log('Skipping respawn cycle - session PTY not running (no PID)');
|
||
this.logAction('health', 'Respawn skipped: No PTY process');
|
||
this.emit('respawnBlocked', { reason: 'no_pty', details: 'Session PTY process not running' });
|
||
this.setState('watching');
|
||
return;
|
||
}
|
||
|
||
// Start the respawn cycle
|
||
this.cycleCount++;
|
||
this.log(`Starting respawn cycle #${this.cycleCount}`);
|
||
this.emit('respawnCycleStarted', this.cycleCount);
|
||
|
||
// P2-004: Start tracking cycle metrics
|
||
this.startCycleMetrics('idle_confirmed');
|
||
|
||
this.sendUpdateDocs();
|
||
}
|
||
|
||
/**
|
||
* Send the update docs prompt (first step of cycle).
|
||
* Uses RALPH_STATUS RECOMMENDATION if available, otherwise falls back to configured prompt.
|
||
* @fires stepSent - With step 'update'
|
||
*/
|
||
private sendUpdateDocs(): void {
|
||
this.setState('sending_update');
|
||
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
||
this.clearWorkingPatternWindow(); // Clear rolling window
|
||
|
||
this.startTrackedTimer(
|
||
'step-delay',
|
||
this.config.interStepDelayMs,
|
||
async () => {
|
||
if (this._state === 'stopped') return;
|
||
|
||
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
||
const statusBlock = this.session.ralphTracker?.lastStatusBlock;
|
||
let updatePrompt = this.config.updatePrompt;
|
||
|
||
if (statusBlock?.recommendation) {
|
||
// Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines)
|
||
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
|
||
updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`;
|
||
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||
}
|
||
|
||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
||
await this.session.writeViaMux(input);
|
||
this.emit('stepSent', 'update', updatePrompt);
|
||
this.setState('waiting_update');
|
||
this.promptDetected = false;
|
||
this.workingDetected = false;
|
||
},
|
||
'delay before update prompt'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Send /clear command.
|
||
* 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).
|
||
* @fires stepSent - With step 'clear'
|
||
*/
|
||
private sendClear(): void {
|
||
this.setState('sending_clear');
|
||
this.terminalBuffer.clear();
|
||
this.clearWorkingPatternWindow();
|
||
|
||
this.startTrackedTimer(
|
||
'step-delay',
|
||
this.config.interStepDelayMs,
|
||
async () => {
|
||
if (this._state === 'stopped') return;
|
||
this.logAction('command', 'Sending: /clear');
|
||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||
this.emit('stepSent', 'clear', '/clear');
|
||
this.setState('waiting_clear');
|
||
this.promptDetected = false;
|
||
|
||
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
||
this.startTrackedTimer(
|
||
'clear-fallback',
|
||
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
|
||
() => {
|
||
if (this._state === 'waiting_clear') {
|
||
this.logAction('step', '/clear fallback: proceeding to /init');
|
||
this.emit('stepCompleted', 'clear');
|
||
if (this.config.sendInit) {
|
||
this.sendInit();
|
||
} else {
|
||
this.completeCycle();
|
||
}
|
||
}
|
||
},
|
||
'fallback if no prompt after /clear'
|
||
);
|
||
},
|
||
'delay before /clear'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Send /init command.
|
||
* @fires stepSent - With step 'init'
|
||
*/
|
||
private sendInit(): void {
|
||
this.setState('sending_init');
|
||
this.terminalBuffer.clear();
|
||
this.clearWorkingPatternWindow();
|
||
|
||
this.startTrackedTimer(
|
||
'step-delay',
|
||
this.config.interStepDelayMs,
|
||
async () => {
|
||
if (this._state === 'stopped') return;
|
||
this.logAction('command', 'Sending: /init');
|
||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||
this.emit('stepSent', 'init', '/init');
|
||
this.setState('waiting_init');
|
||
this.promptDetected = false;
|
||
this.workingDetected = false;
|
||
},
|
||
'delay before /init'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Complete the current respawn cycle.
|
||
* Returns to watching state for next cycle.
|
||
* @fires respawnCycleCompleted
|
||
*/
|
||
private completeCycle(): void {
|
||
this.log(`Respawn cycle #${this.cycleCount} completed`);
|
||
this.emit('respawnCycleCompleted', this.cycleCount);
|
||
|
||
// P2-004: Complete cycle metrics with success outcome
|
||
this.completeCycleMetrics('success');
|
||
|
||
// Go back to watching state for next cycle
|
||
this.setState('watching');
|
||
this.terminalBuffer.clear();
|
||
this.clearWorkingPatternWindow(); // Clear rolling window for fresh detection
|
||
this.promptDetected = false;
|
||
this.workingDetected = false;
|
||
this.resetHookState(); // Clear hook signals for next cycle
|
||
|
||
// P2-001: Reset idle detection start time for next cycle
|
||
this.idleDetectionStartTime = Date.now();
|
||
|
||
// Restart detection timers for next cycle
|
||
this.startNoOutputTimer();
|
||
this.startPreFilterTimer();
|
||
if (this.config.autoAcceptPrompts) {
|
||
this.startAutoAcceptTimer();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if already idle and start cycle if so.
|
||
* Used when resuming from pause.
|
||
*/
|
||
private checkIdleAndMaybeStart(): void {
|
||
if (this._state === 'stopped') return;
|
||
// Check if already idle
|
||
const timeSinceActivity = Date.now() - this.lastActivityTime;
|
||
if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) {
|
||
this.onIdleDetected();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
*/
|
||
updateConfig(config: Partial<RespawnConfig>): void {
|
||
// Filter out undefined values to prevent overwriting existing config with undefined
|
||
const filteredConfig = Object.fromEntries(
|
||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||
) as Partial<RespawnConfig>;
|
||
this.config = { ...this.config, ...filteredConfig };
|
||
|
||
// 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,
|
||
});
|
||
}
|
||
|
||
// Sync plan checker config if relevant fields changed
|
||
if (
|
||
config.aiPlanCheckEnabled !== undefined ||
|
||
config.aiPlanCheckModel !== undefined ||
|
||
config.aiPlanCheckMaxContext !== undefined ||
|
||
config.aiPlanCheckTimeoutMs !== undefined ||
|
||
config.aiPlanCheckCooldownMs !== undefined
|
||
) {
|
||
this.planChecker.updateConfig({
|
||
enabled: this.config.aiPlanCheckEnabled,
|
||
model: this.config.aiPlanCheckModel,
|
||
maxContextChars: this.config.aiPlanCheckMaxContext,
|
||
checkTimeoutMs: this.config.aiPlanCheckTimeoutMs,
|
||
cooldownMs: this.config.aiPlanCheckCooldownMs,
|
||
});
|
||
}
|
||
|
||
this.log(`Config updated: ${JSON.stringify(config)}`);
|
||
}
|
||
|
||
/**
|
||
* Get current configuration.
|
||
* @returns Copy of current config (safe to modify)
|
||
*/
|
||
getConfig(): RespawnConfig {
|
||
return { ...this.config };
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* - detection: Multi-layer detection status
|
||
* - config: Current configuration
|
||
*/
|
||
getStatus() {
|
||
return {
|
||
state: this._state,
|
||
cycleCount: this.cycleCount,
|
||
lastActivityTime: this.lastActivityTime,
|
||
timeSinceActivity: Date.now() - this.lastActivityTime,
|
||
promptDetected: this.promptDetected,
|
||
workingDetected: this.workingDetected,
|
||
detection: this.getDetectionStatus(),
|
||
config: this.config,
|
||
};
|
||
}
|
||
|
||
// ========== P2-001: Adaptive Timing (delegated to RespawnAdaptiveTiming) ==========
|
||
|
||
/**
|
||
* Get the current completion confirm timeout, potentially adjusted by adaptive timing.
|
||
* Uses historical idle detection durations to calculate an optimal timeout.
|
||
*
|
||
* @returns Completion confirm timeout in milliseconds
|
||
*/
|
||
getAdaptiveCompletionConfirmMs(): number {
|
||
if (!this.config.adaptiveTimingEnabled) {
|
||
return this.config.completionConfirmMs ?? 10000;
|
||
}
|
||
|
||
// Need at least 5 samples before adjusting
|
||
const history = this.adaptiveTiming.getTimingHistory();
|
||
if (history.sampleCount < 5) {
|
||
return this.config.completionConfirmMs ?? 10000;
|
||
}
|
||
|
||
return this.adaptiveTiming.getAdaptiveCompletionConfirmMs();
|
||
}
|
||
|
||
/**
|
||
* Get the current timing history for monitoring.
|
||
* @returns Copy of timing history
|
||
*/
|
||
getTimingHistory(): TimingHistory {
|
||
return this.adaptiveTiming.getTimingHistory();
|
||
}
|
||
|
||
// ========== P2-002: Skip-Clear Optimization (delegated to respawn-health.ts) ==========
|
||
|
||
/**
|
||
* Determine whether to skip the /clear step based on current context usage.
|
||
* Skips if token count is below the configured threshold percentage.
|
||
*
|
||
* @returns True if /clear should be skipped
|
||
*/
|
||
private checkShouldSkipClear(): boolean {
|
||
if (!this.config.skipClearWhenLowContext) return false;
|
||
|
||
const thresholdPercent = this.config.skipClearThresholdPercent ?? 30;
|
||
const maxContext = 200000; // Approximate max context for Claude
|
||
|
||
const skip = shouldSkipClear(this.lastTokenCount, thresholdPercent, maxContext);
|
||
|
||
if (skip) {
|
||
const usagePercent = ((this.lastTokenCount / maxContext) * 100).toFixed(1);
|
||
this.log(`Skip-clear optimization: ${usagePercent}% < ${thresholdPercent}% threshold`);
|
||
this.logAction('optimization', `Skipping /clear (${usagePercent}% context used)`);
|
||
}
|
||
|
||
return skip;
|
||
}
|
||
|
||
// ========== P2-004: Cycle Metrics (delegated to RespawnCycleMetricsTracker) ==========
|
||
|
||
/**
|
||
* Start tracking metrics for a new cycle.
|
||
* Called when a respawn cycle begins.
|
||
*/
|
||
private startCycleMetrics(idleReason: string): void {
|
||
if (!this.config.trackCycleMetrics) return;
|
||
|
||
this.cycleMetrics.startCycle(
|
||
this.session.id,
|
||
this.cycleCount,
|
||
idleReason,
|
||
this.idleDetectionStartTime,
|
||
this.lastTokenCount,
|
||
this.getAdaptiveCompletionConfirmMs()
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Record a completed step in the current cycle.
|
||
* @param step - Name of the step (e.g., 'update', 'clear', 'init')
|
||
*/
|
||
private recordCycleStep(step: string): void {
|
||
if (!this.config.trackCycleMetrics) return;
|
||
this.cycleMetrics.recordStep(step);
|
||
}
|
||
|
||
/**
|
||
* Complete the current cycle metrics with outcome.
|
||
* Adds to recent metrics and updates aggregates.
|
||
*
|
||
* @param outcome - Outcome of the cycle
|
||
* @param errorMessage - Optional error message if outcome is 'error'
|
||
*/
|
||
private completeCycleMetrics(outcome: CycleOutcome, errorMessage?: string): void {
|
||
if (!this.config.trackCycleMetrics) return;
|
||
|
||
const metrics = this.cycleMetrics.completeCycle(outcome, this.lastTokenCount, errorMessage);
|
||
|
||
if (metrics) {
|
||
// Record timing data for adaptive timing
|
||
if (this.config.adaptiveTimingEnabled) {
|
||
this.adaptiveTiming.recordTimingData(metrics.idleDetectionMs, metrics.durationMs);
|
||
}
|
||
|
||
this.log(
|
||
`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get aggregate metrics for monitoring.
|
||
* @returns Copy of aggregate metrics
|
||
*/
|
||
getAggregateMetrics(): RespawnAggregateMetrics {
|
||
return this.cycleMetrics.getAggregate();
|
||
}
|
||
|
||
/**
|
||
* Get recent cycle metrics for analysis.
|
||
* @param limit - Maximum number of metrics to return (default: 20)
|
||
* @returns Recent cycle metrics, newest first
|
||
*/
|
||
getRecentCycleMetrics(limit: number = 20): RespawnCycleMetrics[] {
|
||
return this.cycleMetrics.getRecent(limit);
|
||
}
|
||
|
||
// ========== P2-005: Health Score (delegated to respawn-health.ts) ==========
|
||
|
||
/**
|
||
* Calculate a comprehensive health score for the Ralph Loop system.
|
||
* Aggregates multiple health signals into a single score (0-100).
|
||
*
|
||
* @returns Health score with component breakdown
|
||
*/
|
||
calculateHealthScore(): RalphLoopHealthScore {
|
||
const tracker = this.session.ralphTracker;
|
||
const stallMetrics = tracker?.getIterationStallMetrics();
|
||
const aiState = this.aiChecker.getState();
|
||
|
||
const inputs: HealthInputs = {
|
||
aggregateMetrics: this.cycleMetrics.getAggregate(),
|
||
circuitBreakerStatus: tracker?.circuitBreakerStatus ?? null,
|
||
iterationStallMetrics: stallMetrics
|
||
? {
|
||
stallDurationMs: stallMetrics.stallDurationMs,
|
||
warningThresholdMs: stallMetrics.warningThresholdMs,
|
||
criticalThresholdMs: stallMetrics.criticalThresholdMs,
|
||
}
|
||
: null,
|
||
aiCheckerState: {
|
||
status: aiState.status,
|
||
consecutiveErrors: aiState.consecutiveErrors,
|
||
},
|
||
stuckRecoveryCount: this.stuckRecoveryCount,
|
||
maxStuckRecoveries: this.config.maxStuckRecoveries ?? 3,
|
||
};
|
||
|
||
return calculateHealthScore(inputs);
|
||
}
|
||
}
|