aicodeman 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/ai-checker-base.d.ts +175 -0
- package/dist/ai-checker-base.d.ts.map +1 -0
- package/dist/ai-checker-base.js +424 -0
- package/dist/ai-checker-base.js.map +1 -0
- package/dist/ai-idle-checker.d.ts +53 -0
- package/dist/ai-idle-checker.d.ts.map +1 -0
- package/dist/ai-idle-checker.js +141 -0
- package/dist/ai-idle-checker.js.map +1 -0
- package/dist/ai-plan-checker.d.ts +52 -0
- package/dist/ai-plan-checker.d.ts.map +1 -0
- package/dist/ai-plan-checker.js +103 -0
- package/dist/ai-plan-checker.js.map +1 -0
- package/dist/bash-tool-parser.d.ts +191 -0
- package/dist/bash-tool-parser.d.ts.map +1 -0
- package/dist/bash-tool-parser.js +598 -0
- package/dist/bash-tool-parser.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +460 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/buffer-limits.d.ts +59 -0
- package/dist/config/buffer-limits.d.ts.map +1 -0
- package/dist/config/buffer-limits.js +74 -0
- package/dist/config/buffer-limits.js.map +1 -0
- package/dist/config/map-limits.d.ts +40 -0
- package/dist/config/map-limits.d.ts.map +1 -0
- package/dist/config/map-limits.js +52 -0
- package/dist/config/map-limits.js.map +1 -0
- package/dist/file-stream-manager.d.ts +148 -0
- package/dist/file-stream-manager.d.ts.map +1 -0
- package/dist/file-stream-manager.js +351 -0
- package/dist/file-stream-manager.js.map +1 -0
- package/dist/hooks-config.d.ts +31 -0
- package/dist/hooks-config.d.ts.map +1 -0
- package/dist/hooks-config.js +115 -0
- package/dist/hooks-config.js.map +1 -0
- package/dist/image-watcher.d.ts +86 -0
- package/dist/image-watcher.d.ts.map +1 -0
- package/dist/image-watcher.js +275 -0
- package/dist/image-watcher.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/mux-factory.d.ts +13 -0
- package/dist/mux-factory.d.ts.map +1 -0
- package/dist/mux-factory.js +19 -0
- package/dist/mux-factory.js.map +1 -0
- package/dist/mux-interface.d.ts +145 -0
- package/dist/mux-interface.d.ts.map +1 -0
- package/dist/mux-interface.js +9 -0
- package/dist/mux-interface.js.map +1 -0
- package/dist/plan-orchestrator.d.ts +123 -0
- package/dist/plan-orchestrator.d.ts.map +1 -0
- package/dist/plan-orchestrator.js +500 -0
- package/dist/plan-orchestrator.js.map +1 -0
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +9 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/planner.d.ts +14 -0
- package/dist/prompts/planner.d.ts.map +1 -0
- package/dist/prompts/planner.js +83 -0
- package/dist/prompts/planner.js.map +1 -0
- package/dist/prompts/research-agent.d.ts +10 -0
- package/dist/prompts/research-agent.d.ts.map +1 -0
- package/dist/prompts/research-agent.js +143 -0
- package/dist/prompts/research-agent.js.map +1 -0
- package/dist/push-store.d.ts +41 -0
- package/dist/push-store.d.ts.map +1 -0
- package/dist/push-store.js +168 -0
- package/dist/push-store.js.map +1 -0
- package/dist/ralph-config.d.ts +67 -0
- package/dist/ralph-config.d.ts.map +1 -0
- package/dist/ralph-config.js +134 -0
- package/dist/ralph-config.js.map +1 -0
- package/dist/ralph-loop.d.ts +124 -0
- package/dist/ralph-loop.d.ts.map +1 -0
- package/dist/ralph-loop.js +418 -0
- package/dist/ralph-loop.js.map +1 -0
- package/dist/ralph-tracker.d.ts +1081 -0
- package/dist/ralph-tracker.d.ts.map +1 -0
- package/dist/ralph-tracker.js +3343 -0
- package/dist/ralph-tracker.js.map +1 -0
- package/dist/respawn-controller.d.ts +1182 -0
- package/dist/respawn-controller.d.ts.map +1 -0
- package/dist/respawn-controller.js +2754 -0
- package/dist/respawn-controller.js.map +1 -0
- package/dist/run-summary.d.ts +123 -0
- package/dist/run-summary.d.ts.map +1 -0
- package/dist/run-summary.js +325 -0
- package/dist/run-summary.js.map +1 -0
- package/dist/session-lifecycle-log.d.ts +36 -0
- package/dist/session-lifecycle-log.d.ts.map +1 -0
- package/dist/session-lifecycle-log.js +101 -0
- package/dist/session-lifecycle-log.js.map +1 -0
- package/dist/session-manager.d.ts +97 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +224 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session.d.ts +686 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2025 -0
- package/dist/session.js.map +1 -0
- package/dist/state-store.d.ts +189 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +730 -0
- package/dist/state-store.js.map +1 -0
- package/dist/subagent-watcher.d.ts +345 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +1469 -0
- package/dist/subagent-watcher.js.map +1 -0
- package/dist/task-queue.d.ts +108 -0
- package/dist/task-queue.d.ts.map +1 -0
- package/dist/task-queue.js +235 -0
- package/dist/task-queue.js.map +1 -0
- package/dist/task-tracker.d.ts +306 -0
- package/dist/task-tracker.d.ts.map +1 -0
- package/dist/task-tracker.js +488 -0
- package/dist/task-tracker.js.map +1 -0
- package/dist/task.d.ts +73 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +177 -0
- package/dist/task.js.map +1 -0
- package/dist/team-watcher.d.ts +53 -0
- package/dist/team-watcher.d.ts.map +1 -0
- package/dist/team-watcher.js +313 -0
- package/dist/team-watcher.js.map +1 -0
- package/dist/templates/case-template.md +461 -0
- package/dist/templates/claude-md.d.ts +26 -0
- package/dist/templates/claude-md.d.ts.map +1 -0
- package/dist/templates/claude-md.js +74 -0
- package/dist/templates/claude-md.js.map +1 -0
- package/dist/tmux-manager.d.ts +181 -0
- package/dist/tmux-manager.d.ts.map +1 -0
- package/dist/tmux-manager.js +1405 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/transcript-watcher.d.ts +110 -0
- package/dist/transcript-watcher.d.ts.map +1 -0
- package/dist/transcript-watcher.js +338 -0
- package/dist/transcript-watcher.js.map +1 -0
- package/dist/tunnel-manager.d.ts +54 -0
- package/dist/tunnel-manager.d.ts.map +1 -0
- package/dist/tunnel-manager.js +251 -0
- package/dist/tunnel-manager.js.map +1 -0
- package/dist/types.d.ts +1139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +215 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/buffer-accumulator.d.ts +111 -0
- package/dist/utils/buffer-accumulator.d.ts.map +1 -0
- package/dist/utils/buffer-accumulator.js +172 -0
- package/dist/utils/buffer-accumulator.js.map +1 -0
- package/dist/utils/claude-cli-resolver.d.ts +26 -0
- package/dist/utils/claude-cli-resolver.d.ts.map +1 -0
- package/dist/utils/claude-cli-resolver.js +78 -0
- package/dist/utils/claude-cli-resolver.js.map +1 -0
- package/dist/utils/cleanup-manager.d.ts +165 -0
- package/dist/utils/cleanup-manager.d.ts.map +1 -0
- package/dist/utils/cleanup-manager.js +274 -0
- package/dist/utils/cleanup-manager.js.map +1 -0
- package/dist/utils/index.d.ts +19 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +19 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/lru-map.d.ts +140 -0
- package/dist/utils/lru-map.d.ts.map +1 -0
- package/dist/utils/lru-map.js +234 -0
- package/dist/utils/lru-map.js.map +1 -0
- package/dist/utils/nice-wrapper.d.ts +13 -0
- package/dist/utils/nice-wrapper.d.ts.map +1 -0
- package/dist/utils/nice-wrapper.js +17 -0
- package/dist/utils/nice-wrapper.js.map +1 -0
- package/dist/utils/opencode-cli-resolver.d.ts +21 -0
- package/dist/utils/opencode-cli-resolver.d.ts.map +1 -0
- package/dist/utils/opencode-cli-resolver.js +67 -0
- package/dist/utils/opencode-cli-resolver.js.map +1 -0
- package/dist/utils/regex-patterns.d.ts +64 -0
- package/dist/utils/regex-patterns.d.ts.map +1 -0
- package/dist/utils/regex-patterns.js +74 -0
- package/dist/utils/regex-patterns.js.map +1 -0
- package/dist/utils/stale-expiration-map.d.ts +159 -0
- package/dist/utils/stale-expiration-map.d.ts.map +1 -0
- package/dist/utils/stale-expiration-map.js +277 -0
- package/dist/utils/stale-expiration-map.js.map +1 -0
- package/dist/utils/string-similarity.d.ts +108 -0
- package/dist/utils/string-similarity.d.ts.map +1 -0
- package/dist/utils/string-similarity.js +189 -0
- package/dist/utils/string-similarity.js.map +1 -0
- package/dist/utils/token-validation.d.ts +39 -0
- package/dist/utils/token-validation.d.ts.map +1 -0
- package/dist/utils/token-validation.js +59 -0
- package/dist/utils/token-validation.js.map +1 -0
- package/dist/utils/type-safety.d.ts +33 -0
- package/dist/utils/type-safety.d.ts.map +1 -0
- package/dist/utils/type-safety.js +35 -0
- package/dist/utils/type-safety.js.map +1 -0
- package/dist/web/public/app.js +491 -0
- package/dist/web/public/app.js.br +0 -0
- package/dist/web/public/app.js.gz +0 -0
- package/dist/web/public/index.html +1675 -0
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.json +8 -0
- package/dist/web/public/mobile.css +1 -0
- package/dist/web/public/mobile.css.br +0 -0
- package/dist/web/public/mobile.css.gz +0 -0
- package/dist/web/public/ralph-wizard.js +1037 -0
- package/dist/web/public/ralph-wizard.js.br +0 -0
- package/dist/web/public/ralph-wizard.js.gz +0 -0
- package/dist/web/public/styles.css +1 -0
- package/dist/web/public/styles.css.br +0 -0
- package/dist/web/public/styles.css.gz +0 -0
- package/dist/web/public/sw.js +67 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/upload.html +155 -0
- package/dist/web/public/upload.html.br +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js +1 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js +2 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.br +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css +209 -0
- package/dist/web/public/vendor/xterm.css.br +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js +9 -0
- package/dist/web/public/vendor/xterm.min.js.br +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/schemas.d.ts +479 -0
- package/dist/web/schemas.d.ts.map +1 -0
- package/dist/web/schemas.js +448 -0
- package/dist/web/schemas.js.map +1 -0
- package/dist/web/server.d.ts +207 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +5784 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +110 -0
- package/scripts/postinstall.js +390 -0
|
@@ -0,0 +1,2754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Respawn Controller for autonomous Claude Code session cycling
|
|
3
|
+
*
|
|
4
|
+
* The RespawnController manages automatic respawning of Claude Code sessions.
|
|
5
|
+
* When Claude finishes working (detected by completion message + output silence),
|
|
6
|
+
* it automatically cycles through update → clear → init steps to keep the session productive.
|
|
7
|
+
*
|
|
8
|
+
* ## State Machine
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* WATCHING → CONFIRMING_IDLE → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR
|
|
12
|
+
* ↑ │ │
|
|
13
|
+
* │ │ (new output) ▼
|
|
14
|
+
* │ └─────────────► SENDING_INIT → WAITING_INIT → MONITORING_INIT ────────┘
|
|
15
|
+
* │ │
|
|
16
|
+
* │ ▼ (if no work triggered)
|
|
17
|
+
* └──────────────────────── SENDING_KICKSTART → WAITING_KICKSTART ──┘
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* ## Idle Detection (Updated for Claude Code 2024+)
|
|
21
|
+
*
|
|
22
|
+
* Primary detection: Completion message pattern "for Xm Xs" (e.g., "✻ Worked for 2m 46s")
|
|
23
|
+
* Confirmation: No new output for configurable duration (default 5s)
|
|
24
|
+
* Fallback: No output at all for extended period (default 30s)
|
|
25
|
+
*
|
|
26
|
+
* ## Configuration
|
|
27
|
+
*
|
|
28
|
+
* - `sendClear`: Whether to send /clear after update (default: true)
|
|
29
|
+
* - `sendInit`: Whether to send /init after clear (default: true)
|
|
30
|
+
* - `kickstartPrompt`: Optional prompt if /init doesn't trigger work
|
|
31
|
+
* - `completionConfirmMs`: Time to wait after completion message (default: 10000)
|
|
32
|
+
* - `noOutputTimeoutMs`: Fallback timeout with no output at all (default: 30000)
|
|
33
|
+
*
|
|
34
|
+
* @module respawn-controller
|
|
35
|
+
*/
|
|
36
|
+
import { EventEmitter } from 'node:events';
|
|
37
|
+
import { randomUUID } from 'node:crypto';
|
|
38
|
+
import { AiIdleChecker } from './ai-idle-checker.js';
|
|
39
|
+
import { AiPlanChecker } from './ai-plan-checker.js';
|
|
40
|
+
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
|
41
|
+
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
|
|
42
|
+
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
|
43
|
+
// ========== Constants ==========
|
|
44
|
+
/**
|
|
45
|
+
* Pattern to detect completion messages from Claude Code.
|
|
46
|
+
* Requires "Worked for" prefix to avoid false positives from bare time durations
|
|
47
|
+
* in regular text (e.g., "wait for 5s", "run for 2m").
|
|
48
|
+
*
|
|
49
|
+
* Matches: "✻ Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s"
|
|
50
|
+
* Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..."
|
|
51
|
+
*/
|
|
52
|
+
const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i;
|
|
53
|
+
/** Pre-filter: numbered option pattern for plan mode detection */
|
|
54
|
+
const PLAN_MODE_OPTION_PATTERN = /\d+\.\s+(Yes|No|Type|Cancel|Skip|Proceed|Approve|Reject)/i;
|
|
55
|
+
/** Pre-filter: selection indicator arrow for plan mode detection */
|
|
56
|
+
const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./;
|
|
57
|
+
/** Default configuration values */
|
|
58
|
+
const DEFAULT_CONFIG = {
|
|
59
|
+
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
|
60
|
+
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
|
61
|
+
interStepDelayMs: 1000, // 1 second between steps
|
|
62
|
+
enabled: true,
|
|
63
|
+
sendClear: true, // send /clear after update prompt
|
|
64
|
+
sendInit: true, // send /init after /clear
|
|
65
|
+
completionConfirmMs: 10000, // 10 seconds of silence after completion message
|
|
66
|
+
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
|
|
67
|
+
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
|
|
68
|
+
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
|
|
69
|
+
aiIdleCheckEnabled: true, // use AI to confirm idle state
|
|
70
|
+
aiIdleCheckModel: 'claude-opus-4-5-20251101',
|
|
71
|
+
aiIdleCheckMaxContext: 16000, // ~4k tokens
|
|
72
|
+
aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow)
|
|
73
|
+
aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict
|
|
74
|
+
aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept
|
|
75
|
+
aiPlanCheckModel: 'claude-opus-4-5-20251101',
|
|
76
|
+
aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact)
|
|
77
|
+
aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow)
|
|
78
|
+
aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE
|
|
79
|
+
stuckStateDetectionEnabled: true, // detect stuck states
|
|
80
|
+
stuckStateWarningMs: 300000, // 5 minutes warning threshold
|
|
81
|
+
stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold
|
|
82
|
+
maxStuckRecoveries: 3, // max recovery attempts
|
|
83
|
+
// P2-001: Adaptive timing
|
|
84
|
+
adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns
|
|
85
|
+
adaptiveMinConfirmMs: 5000, // Minimum 5 seconds
|
|
86
|
+
adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds
|
|
87
|
+
// P2-002: Skip-clear optimization
|
|
88
|
+
skipClearWhenLowContext: true, // Skip /clear when token count is low
|
|
89
|
+
skipClearThresholdPercent: 30, // Skip if below 30% of max context
|
|
90
|
+
// P2-004: Cycle metrics
|
|
91
|
+
trackCycleMetrics: true, // Track and persist cycle metrics
|
|
92
|
+
// P2-001: Confidence scoring
|
|
93
|
+
minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100)
|
|
94
|
+
confidenceWeightCompletion: 40, // Weight for completion message
|
|
95
|
+
confidenceWeightSilence: 25, // Weight for output silence
|
|
96
|
+
confidenceWeightTokens: 20, // Weight for token stability
|
|
97
|
+
confidenceWeightNoWorking: 15, // Weight for working pattern absence
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* RespawnController - Automatic session cycling for continuous Claude work.
|
|
101
|
+
*
|
|
102
|
+
* Monitors a Claude Code session for idle state and automatically cycles
|
|
103
|
+
* through update → clear → init steps to keep the session productive.
|
|
104
|
+
*
|
|
105
|
+
* ## How It Works
|
|
106
|
+
*
|
|
107
|
+
* 1. **Idle Detection**: Watches for completion message ("for Xm Xs" pattern)
|
|
108
|
+
* 2. **Confirmation**: Waits for output silence (no new tokens for 5s)
|
|
109
|
+
* 3. **Update**: Sends configured prompt (e.g., "update all docs")
|
|
110
|
+
* 4. **Clear**: Sends `/clear` to reset context (optional)
|
|
111
|
+
* 5. **Init**: Sends `/init` to re-initialize with CLAUDE.md (optional)
|
|
112
|
+
* 6. **Kickstart**: If /init doesn't trigger work, sends fallback prompt (optional)
|
|
113
|
+
* 7. **Repeat**: Returns to watching state for next cycle
|
|
114
|
+
*
|
|
115
|
+
* ## Idle Detection (Updated for Claude Code 2024+)
|
|
116
|
+
*
|
|
117
|
+
* Primary: Completion message with time duration (e.g., "✻ Worked for 2m 46s")
|
|
118
|
+
* The pattern "for Xm Xs" indicates Claude finished work and reports duration.
|
|
119
|
+
*
|
|
120
|
+
* Confirmation: After seeing completion message, waits for output silence.
|
|
121
|
+
* If no new output for `completionConfirmMs` (default 10s), confirms idle.
|
|
122
|
+
*
|
|
123
|
+
* Fallback: If no output at all for `noOutputTimeoutMs` (default 30s), assumes idle.
|
|
124
|
+
*
|
|
125
|
+
* Working indicators: Thinking, Writing, spinner characters, etc. reset detection.
|
|
126
|
+
*
|
|
127
|
+
* ## Events
|
|
128
|
+
*
|
|
129
|
+
* - `stateChanged`: State machine transition
|
|
130
|
+
* - `respawnCycleStarted`: New cycle began
|
|
131
|
+
* - `respawnCycleCompleted`: Cycle finished
|
|
132
|
+
* - `stepSent`: Command sent to session
|
|
133
|
+
* - `stepCompleted`: Step finished
|
|
134
|
+
* - `log`: Debug messages
|
|
135
|
+
*
|
|
136
|
+
* @extends EventEmitter
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* const respawn = new RespawnController(session, {
|
|
140
|
+
* updatePrompt: 'continue working on the task',
|
|
141
|
+
* completionConfirmMs: 10000, // Wait 10s after completion message
|
|
142
|
+
* });
|
|
143
|
+
*
|
|
144
|
+
* respawn.on('respawnCycleCompleted', (cycle) => {
|
|
145
|
+
* console.log(`Completed cycle ${cycle}`);
|
|
146
|
+
* });
|
|
147
|
+
*
|
|
148
|
+
* respawn.start();
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export class RespawnController extends EventEmitter {
|
|
152
|
+
/** The session being controlled */
|
|
153
|
+
session;
|
|
154
|
+
/** Optional team watcher for team-aware idle detection */
|
|
155
|
+
teamWatcher = null;
|
|
156
|
+
/** Current configuration */
|
|
157
|
+
config;
|
|
158
|
+
/** Current state machine state */
|
|
159
|
+
_state = 'stopped';
|
|
160
|
+
/** Timer for step delays */
|
|
161
|
+
stepTimer = null;
|
|
162
|
+
/** Timer for completion confirmation (Layer 2) */
|
|
163
|
+
completionConfirmTimer = null;
|
|
164
|
+
/** Timer for no-output fallback (Layer 5) */
|
|
165
|
+
noOutputTimer = null;
|
|
166
|
+
/** Timer for periodic detection status updates */
|
|
167
|
+
detectionUpdateTimer = null;
|
|
168
|
+
/** Timer for auto-accepting plan mode prompts */
|
|
169
|
+
autoAcceptTimer = null;
|
|
170
|
+
/** Timer for pre-filter silence detection (triggers AI check) */
|
|
171
|
+
preFilterTimer = null;
|
|
172
|
+
/** Whether any terminal output has been received since start/last-auto-accept */
|
|
173
|
+
hasReceivedOutput = false;
|
|
174
|
+
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
|
175
|
+
elicitationDetected = false;
|
|
176
|
+
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
|
|
177
|
+
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
|
|
178
|
+
stopHookReceived = false;
|
|
179
|
+
/** Timestamp when Stop hook was received */
|
|
180
|
+
stopHookTime = null;
|
|
181
|
+
/** Whether an idle_prompt notification was received (60s+ idle signal) */
|
|
182
|
+
idlePromptReceived = false;
|
|
183
|
+
/** Timestamp when idle_prompt was received */
|
|
184
|
+
idlePromptTime = null;
|
|
185
|
+
/** Timer for short confirmation after hook signal (handles race conditions) */
|
|
186
|
+
hookConfirmTimer = null;
|
|
187
|
+
/** Confirmation delay after hook signal before confirming idle (ms) */
|
|
188
|
+
static HOOK_CONFIRM_DELAY_MS = 3000;
|
|
189
|
+
/** Number of completed respawn cycles */
|
|
190
|
+
cycleCount = 0;
|
|
191
|
+
/** Timestamp of last terminal activity */
|
|
192
|
+
lastActivityTime = 0;
|
|
193
|
+
/** Buffer for recent terminal output (uses BufferAccumulator to reduce GC pressure) */
|
|
194
|
+
terminalBuffer = new BufferAccumulator(MAX_RESPAWN_BUFFER_SIZE, RESPAWN_BUFFER_TRIM_SIZE);
|
|
195
|
+
/** Whether a prompt indicator was detected */
|
|
196
|
+
promptDetected = false;
|
|
197
|
+
/** Whether a working indicator was detected */
|
|
198
|
+
workingDetected = false;
|
|
199
|
+
/** Reference to terminal event handler (for cleanup) */
|
|
200
|
+
terminalHandler = null;
|
|
201
|
+
/** AI idle checker instance */
|
|
202
|
+
aiChecker;
|
|
203
|
+
/** AI plan mode checker instance */
|
|
204
|
+
planChecker;
|
|
205
|
+
/** Timestamp when plan check was started (to detect stale results) */
|
|
206
|
+
planCheckStartTime = 0;
|
|
207
|
+
/** Unique ID for current AI check request (to detect stale results) */
|
|
208
|
+
_currentAiCheckId = null;
|
|
209
|
+
/** Timer for /clear step fallback (sends /init if no prompt detected) */
|
|
210
|
+
clearFallbackTimer = null;
|
|
211
|
+
/** Timer for step completion confirmation (waits for silence after completion) */
|
|
212
|
+
stepConfirmTimer = null;
|
|
213
|
+
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
|
214
|
+
static CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
|
215
|
+
// ========== Timer Tracking for UI Countdown Display ==========
|
|
216
|
+
/** Active timers being tracked for UI display */
|
|
217
|
+
activeTimers = new Map();
|
|
218
|
+
/** Recent action log entries (for UI display, max 20) */
|
|
219
|
+
recentActions = [];
|
|
220
|
+
// ========== Stuck-State Detection State ==========
|
|
221
|
+
/** Timestamp when the current state was entered */
|
|
222
|
+
stateEnteredAt = 0;
|
|
223
|
+
/** Timer for stuck-state detection */
|
|
224
|
+
stuckStateTimer = null;
|
|
225
|
+
/** Whether a stuck-state warning has been emitted for current state */
|
|
226
|
+
stuckStateWarned = false;
|
|
227
|
+
/** Number of stuck-state recovery attempts */
|
|
228
|
+
stuckRecoveryCount = 0;
|
|
229
|
+
// ========== P2-001: Adaptive Timing State ==========
|
|
230
|
+
/** Historical timing data for adaptive adjustments */
|
|
231
|
+
timingHistory = {
|
|
232
|
+
recentIdleDetectionMs: [],
|
|
233
|
+
recentCycleDurationMs: [],
|
|
234
|
+
adaptiveCompletionConfirmMs: 10000, // Start with default
|
|
235
|
+
sampleCount: 0,
|
|
236
|
+
maxSamples: 20, // Keep last 20 samples for rolling average
|
|
237
|
+
lastUpdatedAt: Date.now(),
|
|
238
|
+
};
|
|
239
|
+
// ========== P2-004: Cycle Metrics State ==========
|
|
240
|
+
/** Current cycle being tracked */
|
|
241
|
+
currentCycleMetrics = null;
|
|
242
|
+
/** Timestamp when idle detection started for current cycle */
|
|
243
|
+
idleDetectionStartTime = 0;
|
|
244
|
+
/** Recent cycle metrics (rolling window for aggregate calculation) */
|
|
245
|
+
recentCycleMetrics = [];
|
|
246
|
+
/** Maximum number of cycle metrics to keep in memory */
|
|
247
|
+
static MAX_CYCLE_METRICS_IN_MEMORY = 100;
|
|
248
|
+
/** Aggregate metrics across all tracked cycles */
|
|
249
|
+
aggregateMetrics = {
|
|
250
|
+
totalCycles: 0,
|
|
251
|
+
successfulCycles: 0,
|
|
252
|
+
stuckRecoveryCycles: 0,
|
|
253
|
+
blockedCycles: 0,
|
|
254
|
+
errorCycles: 0,
|
|
255
|
+
avgCycleDurationMs: 0,
|
|
256
|
+
avgIdleDetectionMs: 0,
|
|
257
|
+
p90CycleDurationMs: 0,
|
|
258
|
+
successRate: 100,
|
|
259
|
+
lastUpdatedAt: Date.now(),
|
|
260
|
+
};
|
|
261
|
+
// ========== Multi-Layer Detection State ==========
|
|
262
|
+
/** Layer 1: Timestamp when completion message was detected */
|
|
263
|
+
completionMessageTime = null;
|
|
264
|
+
/** Layer 2: Timestamp of last terminal output received */
|
|
265
|
+
lastOutputTime = 0;
|
|
266
|
+
/** Layer 3: Last observed token count */
|
|
267
|
+
lastTokenCount = 0;
|
|
268
|
+
/** Layer 3: Timestamp when token count last changed */
|
|
269
|
+
lastTokenChangeTime = 0;
|
|
270
|
+
/** Layer 4: Timestamp when last working pattern was seen */
|
|
271
|
+
lastWorkingPatternTime = 0;
|
|
272
|
+
/**
|
|
273
|
+
* Patterns indicating Claude is ready for input (legacy fallback).
|
|
274
|
+
* Used as secondary signals, not primary detection.
|
|
275
|
+
*/
|
|
276
|
+
PROMPT_PATTERNS = [
|
|
277
|
+
'❯', // Standard prompt
|
|
278
|
+
'\u276f', // Unicode variant
|
|
279
|
+
'⏵', // Claude Code prompt variant
|
|
280
|
+
];
|
|
281
|
+
/**
|
|
282
|
+
* Patterns indicating Claude is actively working.
|
|
283
|
+
* When detected, resets all idle detection timers.
|
|
284
|
+
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
|
285
|
+
*/
|
|
286
|
+
WORKING_PATTERNS = [
|
|
287
|
+
'Thinking',
|
|
288
|
+
'Writing',
|
|
289
|
+
'Reading',
|
|
290
|
+
'Running',
|
|
291
|
+
'Searching',
|
|
292
|
+
'Editing',
|
|
293
|
+
'Creating',
|
|
294
|
+
'Deleting',
|
|
295
|
+
'Analyzing',
|
|
296
|
+
'Executing',
|
|
297
|
+
'Synthesizing',
|
|
298
|
+
'Brewing', // Claude's processing indicators
|
|
299
|
+
'Compiling',
|
|
300
|
+
'Building',
|
|
301
|
+
'Installing',
|
|
302
|
+
'Fetching',
|
|
303
|
+
'Downloading',
|
|
304
|
+
'Processing',
|
|
305
|
+
'Generating',
|
|
306
|
+
'Loading',
|
|
307
|
+
'Starting',
|
|
308
|
+
'Updating',
|
|
309
|
+
'Checking',
|
|
310
|
+
'Validating',
|
|
311
|
+
'Testing',
|
|
312
|
+
'Formatting',
|
|
313
|
+
'Linting',
|
|
314
|
+
'⠋',
|
|
315
|
+
'⠙',
|
|
316
|
+
'⠹',
|
|
317
|
+
'⠸',
|
|
318
|
+
'⠼',
|
|
319
|
+
'⠴',
|
|
320
|
+
'⠦',
|
|
321
|
+
'⠧',
|
|
322
|
+
'⠇',
|
|
323
|
+
'⠏', // Spinner chars
|
|
324
|
+
'◐',
|
|
325
|
+
'◓',
|
|
326
|
+
'◑',
|
|
327
|
+
'◒', // Alternative spinners
|
|
328
|
+
'⣾',
|
|
329
|
+
'⣽',
|
|
330
|
+
'⣻',
|
|
331
|
+
'⢿',
|
|
332
|
+
'⡿',
|
|
333
|
+
'⣟',
|
|
334
|
+
'⣯',
|
|
335
|
+
'⣷', // Braille spinners
|
|
336
|
+
];
|
|
337
|
+
/**
|
|
338
|
+
* Rolling window buffer for working pattern detection.
|
|
339
|
+
* Prevents split-chunk issues where "Thinking" arrives as "Thin" + "king".
|
|
340
|
+
* Size: 300 chars should be enough to catch any split pattern.
|
|
341
|
+
*/
|
|
342
|
+
workingPatternWindow = '';
|
|
343
|
+
static WORKING_PATTERN_WINDOW_SIZE = 300;
|
|
344
|
+
/**
|
|
345
|
+
* Minimum time without working patterns before considering idle (ms).
|
|
346
|
+
* Increased from 3s to 8s to avoid false positives during tool execution gaps.
|
|
347
|
+
*/
|
|
348
|
+
static MIN_WORKING_PATTERN_ABSENCE_MS = 8000;
|
|
349
|
+
/**
|
|
350
|
+
* Creates a new RespawnController.
|
|
351
|
+
*
|
|
352
|
+
* @param session - The Session instance to control
|
|
353
|
+
* @param config - Partial configuration (merged with defaults)
|
|
354
|
+
*/
|
|
355
|
+
constructor(session, config = {}) {
|
|
356
|
+
super();
|
|
357
|
+
this.session = session;
|
|
358
|
+
// Filter out undefined values from config to prevent overwriting defaults
|
|
359
|
+
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined));
|
|
360
|
+
this.config = { ...DEFAULT_CONFIG, ...filteredConfig };
|
|
361
|
+
// Validate configuration values
|
|
362
|
+
this.validateConfig();
|
|
363
|
+
this.aiChecker = new AiIdleChecker(session.id, {
|
|
364
|
+
enabled: this.config.aiIdleCheckEnabled,
|
|
365
|
+
model: this.config.aiIdleCheckModel,
|
|
366
|
+
maxContextChars: this.config.aiIdleCheckMaxContext,
|
|
367
|
+
checkTimeoutMs: this.config.aiIdleCheckTimeoutMs,
|
|
368
|
+
cooldownMs: this.config.aiIdleCheckCooldownMs,
|
|
369
|
+
});
|
|
370
|
+
this.planChecker = new AiPlanChecker(session.id, {
|
|
371
|
+
enabled: this.config.aiPlanCheckEnabled,
|
|
372
|
+
model: this.config.aiPlanCheckModel,
|
|
373
|
+
maxContextChars: this.config.aiPlanCheckMaxContext,
|
|
374
|
+
checkTimeoutMs: this.config.aiPlanCheckTimeoutMs,
|
|
375
|
+
cooldownMs: this.config.aiPlanCheckCooldownMs,
|
|
376
|
+
});
|
|
377
|
+
this.setupAiCheckerListeners();
|
|
378
|
+
this.setupPlanCheckerListeners();
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Validate configuration values and reset invalid ones to defaults.
|
|
382
|
+
* Ensures timeouts are positive and logically consistent.
|
|
383
|
+
*/
|
|
384
|
+
validateConfig() {
|
|
385
|
+
const c = this.config;
|
|
386
|
+
// Ensure timeouts are positive
|
|
387
|
+
if (c.idleTimeoutMs <= 0)
|
|
388
|
+
c.idleTimeoutMs = DEFAULT_CONFIG.idleTimeoutMs;
|
|
389
|
+
if (c.completionConfirmMs <= 0)
|
|
390
|
+
c.completionConfirmMs = DEFAULT_CONFIG.completionConfirmMs;
|
|
391
|
+
if (c.noOutputTimeoutMs <= 0)
|
|
392
|
+
c.noOutputTimeoutMs = DEFAULT_CONFIG.noOutputTimeoutMs;
|
|
393
|
+
if (c.autoAcceptDelayMs < 0)
|
|
394
|
+
c.autoAcceptDelayMs = DEFAULT_CONFIG.autoAcceptDelayMs;
|
|
395
|
+
if (c.interStepDelayMs <= 0)
|
|
396
|
+
c.interStepDelayMs = DEFAULT_CONFIG.interStepDelayMs;
|
|
397
|
+
// Ensure completion confirm doesn't exceed no-output timeout
|
|
398
|
+
if (c.completionConfirmMs > c.noOutputTimeoutMs) {
|
|
399
|
+
c.completionConfirmMs = c.noOutputTimeoutMs;
|
|
400
|
+
}
|
|
401
|
+
// Ensure AI check timeouts are positive
|
|
402
|
+
if (c.aiIdleCheckTimeoutMs <= 0)
|
|
403
|
+
c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs;
|
|
404
|
+
if (c.aiIdleCheckCooldownMs < 0)
|
|
405
|
+
c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs;
|
|
406
|
+
if (c.aiIdleCheckMaxContext <= 0)
|
|
407
|
+
c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext;
|
|
408
|
+
// Ensure plan check timeouts are positive
|
|
409
|
+
if (c.aiPlanCheckTimeoutMs <= 0)
|
|
410
|
+
c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs;
|
|
411
|
+
if (c.aiPlanCheckCooldownMs < 0)
|
|
412
|
+
c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs;
|
|
413
|
+
if (c.aiPlanCheckMaxContext <= 0)
|
|
414
|
+
c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext;
|
|
415
|
+
}
|
|
416
|
+
/** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */
|
|
417
|
+
setupAiCheckerListeners() {
|
|
418
|
+
// Remove any existing listeners to prevent duplicates when restarting
|
|
419
|
+
this.aiChecker.removeAllListeners();
|
|
420
|
+
this.aiChecker.on('log', (message) => {
|
|
421
|
+
this.log(message);
|
|
422
|
+
});
|
|
423
|
+
this.aiChecker.on('cooldownStarted', (endsAt) => {
|
|
424
|
+
this.emit('aiCheckCooldown', true, endsAt);
|
|
425
|
+
});
|
|
426
|
+
this.aiChecker.on('cooldownEnded', () => {
|
|
427
|
+
this.emit('aiCheckCooldown', false, null);
|
|
428
|
+
// Restart pre-filter timer when cooldown expires so a new check can be triggered
|
|
429
|
+
if (this._state === 'watching') {
|
|
430
|
+
this.startPreFilterTimer();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
this.aiChecker.on('disabled', (reason) => {
|
|
434
|
+
this.log(`AI checker disabled: ${reason}. Falling back to noOutputTimeoutMs.`);
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/** Wire up plan checker events to controller events (removes existing listeners first to prevent duplicates) */
|
|
438
|
+
setupPlanCheckerListeners() {
|
|
439
|
+
// Remove any existing listeners to prevent duplicates when restarting
|
|
440
|
+
this.planChecker.removeAllListeners();
|
|
441
|
+
this.planChecker.on('log', (message) => {
|
|
442
|
+
this.log(message);
|
|
443
|
+
});
|
|
444
|
+
this.planChecker.on('disabled', (reason) => {
|
|
445
|
+
this.log(`Plan checker disabled: ${reason}. Falling back to pre-filter only.`);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Get the current state machine state.
|
|
450
|
+
* @returns Current RespawnState
|
|
451
|
+
*/
|
|
452
|
+
get state() {
|
|
453
|
+
return this._state;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Get the current respawn cycle count.
|
|
457
|
+
* Increments each time a new cycle starts.
|
|
458
|
+
* @returns Number of cycles started
|
|
459
|
+
*/
|
|
460
|
+
get currentCycle() {
|
|
461
|
+
return this.cycleCount;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Check if the controller is currently running.
|
|
465
|
+
* @returns True if state is not 'stopped'
|
|
466
|
+
*/
|
|
467
|
+
get isRunning() {
|
|
468
|
+
return this._state !== 'stopped';
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Get current detection status for UI display.
|
|
472
|
+
* Shows all detection layers and their current state.
|
|
473
|
+
* @returns DetectionStatus object
|
|
474
|
+
*/
|
|
475
|
+
getDetectionStatus() {
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const msSinceLastOutput = now - this.lastOutputTime;
|
|
478
|
+
const msSinceTokenChange = now - this.lastTokenChangeTime;
|
|
479
|
+
const msSinceLastWorking = now - this.lastWorkingPatternTime;
|
|
480
|
+
const completionMessageDetected = this.completionMessageTime !== null;
|
|
481
|
+
const outputSilent = msSinceLastOutput >= this.config.completionConfirmMs;
|
|
482
|
+
const tokensStable = msSinceTokenChange >= this.config.completionConfirmMs;
|
|
483
|
+
const workingPatternsAbsent = msSinceLastWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
|
|
484
|
+
// Calculate confidence level (0-100) using configurable weights
|
|
485
|
+
// P2-001: Configurable confidence scoring
|
|
486
|
+
// Hook signals are definitive (100% confidence)
|
|
487
|
+
let confidence = 0;
|
|
488
|
+
if (this.stopHookReceived || this.idlePromptReceived) {
|
|
489
|
+
confidence = 100;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
// Use configured weights (with fallback to defaults)
|
|
493
|
+
const weightCompletion = this.config.confidenceWeightCompletion ?? 40;
|
|
494
|
+
const weightSilence = this.config.confidenceWeightSilence ?? 25;
|
|
495
|
+
const weightTokens = this.config.confidenceWeightTokens ?? 20;
|
|
496
|
+
const weightNoWorking = this.config.confidenceWeightNoWorking ?? 15;
|
|
497
|
+
if (completionMessageDetected)
|
|
498
|
+
confidence += weightCompletion;
|
|
499
|
+
if (outputSilent)
|
|
500
|
+
confidence += weightSilence;
|
|
501
|
+
if (tokensStable)
|
|
502
|
+
confidence += weightTokens;
|
|
503
|
+
if (workingPatternsAbsent)
|
|
504
|
+
confidence += weightNoWorking;
|
|
505
|
+
// Confidence decay: if no output for extended time, add bonus confidence
|
|
506
|
+
// This helps detect stuck states where Claude is truly idle but no completion message
|
|
507
|
+
const extendedSilenceBonus = Math.min(20, Math.floor(msSinceLastOutput / 30000) * 5);
|
|
508
|
+
if (msSinceLastOutput > 30000) {
|
|
509
|
+
confidence += extendedSilenceBonus;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Cap at 100
|
|
513
|
+
confidence = Math.min(100, confidence);
|
|
514
|
+
// Determine status text and what we're waiting for
|
|
515
|
+
let statusText;
|
|
516
|
+
let waitingFor;
|
|
517
|
+
if (this._state === 'stopped') {
|
|
518
|
+
statusText = 'Controller stopped';
|
|
519
|
+
waitingFor = 'Start to begin monitoring';
|
|
520
|
+
}
|
|
521
|
+
else if (this.stopHookReceived || this.idlePromptReceived) {
|
|
522
|
+
const hookType = this.idlePromptReceived ? 'idle_prompt' : 'Stop';
|
|
523
|
+
statusText = `${hookType} hook received - confirming idle`;
|
|
524
|
+
waitingFor = 'Short confirmation (race condition check)';
|
|
525
|
+
}
|
|
526
|
+
else if (this._state === 'ai_checking') {
|
|
527
|
+
statusText = 'AI Check: Analyzing terminal output...';
|
|
528
|
+
waitingFor = 'AI verdict (IDLE or WORKING)';
|
|
529
|
+
}
|
|
530
|
+
else if (this._state === 'confirming_idle') {
|
|
531
|
+
statusText = `Confirming idle (${confidence}% confidence)`;
|
|
532
|
+
waitingFor = `${Math.max(0, Math.ceil((this.config.completionConfirmMs - msSinceLastOutput) / 1000))}s more silence`;
|
|
533
|
+
}
|
|
534
|
+
else if (this._state === 'watching') {
|
|
535
|
+
const aiState = this.aiChecker.getState();
|
|
536
|
+
if (aiState.status === 'cooldown') {
|
|
537
|
+
const remaining = Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000);
|
|
538
|
+
statusText = `AI Check: WORKING (cooldown ${remaining}s)`;
|
|
539
|
+
waitingFor = 'Cooldown to expire';
|
|
540
|
+
}
|
|
541
|
+
else if (completionMessageDetected) {
|
|
542
|
+
statusText = 'Completion detected, confirming...';
|
|
543
|
+
waitingFor = 'Output silence to confirm';
|
|
544
|
+
}
|
|
545
|
+
else if (workingPatternsAbsent && msSinceLastOutput > 5000) {
|
|
546
|
+
statusText = 'No activity detected';
|
|
547
|
+
waitingFor = 'Pre-filter conditions for AI check';
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
statusText = 'Watching for idle signals';
|
|
551
|
+
waitingFor = 'Silence + no working patterns + tokens stable';
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
else if (this._state.startsWith('waiting_') || this._state.startsWith('sending_')) {
|
|
555
|
+
statusText = `Respawn step: ${this._state}`;
|
|
556
|
+
waitingFor = 'Step completion';
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
statusText = `State: ${this._state}`;
|
|
560
|
+
waitingFor = 'Next event';
|
|
561
|
+
}
|
|
562
|
+
// Determine current phase and next action
|
|
563
|
+
let currentPhase;
|
|
564
|
+
let nextAction;
|
|
565
|
+
switch (this._state) {
|
|
566
|
+
case 'stopped':
|
|
567
|
+
currentPhase = 'Stopped';
|
|
568
|
+
nextAction = 'Start to begin';
|
|
569
|
+
break;
|
|
570
|
+
case 'watching':
|
|
571
|
+
currentPhase = 'Monitoring for idle';
|
|
572
|
+
nextAction = 'Waiting for silence + no working patterns';
|
|
573
|
+
break;
|
|
574
|
+
case 'confirming_idle':
|
|
575
|
+
currentPhase = 'Confirming idle state';
|
|
576
|
+
nextAction = 'Waiting for output silence';
|
|
577
|
+
break;
|
|
578
|
+
case 'ai_checking':
|
|
579
|
+
currentPhase = 'AI analyzing terminal';
|
|
580
|
+
nextAction = 'Waiting for IDLE/WORKING verdict';
|
|
581
|
+
break;
|
|
582
|
+
case 'sending_update':
|
|
583
|
+
currentPhase = 'Sending update prompt';
|
|
584
|
+
nextAction = 'Will send prompt after delay';
|
|
585
|
+
break;
|
|
586
|
+
case 'waiting_update':
|
|
587
|
+
currentPhase = 'Waiting for update to complete';
|
|
588
|
+
nextAction = 'Will send /clear when done';
|
|
589
|
+
break;
|
|
590
|
+
case 'sending_clear':
|
|
591
|
+
currentPhase = 'Sending /clear';
|
|
592
|
+
nextAction = 'Will clear context';
|
|
593
|
+
break;
|
|
594
|
+
case 'waiting_clear':
|
|
595
|
+
currentPhase = 'Waiting for /clear to complete';
|
|
596
|
+
nextAction = 'Will send /init when done';
|
|
597
|
+
break;
|
|
598
|
+
case 'sending_init':
|
|
599
|
+
currentPhase = 'Sending /init';
|
|
600
|
+
nextAction = 'Will re-initialize';
|
|
601
|
+
break;
|
|
602
|
+
case 'waiting_init':
|
|
603
|
+
currentPhase = 'Waiting for /init to complete';
|
|
604
|
+
nextAction = 'Monitoring for work';
|
|
605
|
+
break;
|
|
606
|
+
case 'monitoring_init':
|
|
607
|
+
currentPhase = 'Monitoring if /init triggered work';
|
|
608
|
+
nextAction = 'Kickstart if no work started';
|
|
609
|
+
break;
|
|
610
|
+
case 'sending_kickstart':
|
|
611
|
+
currentPhase = 'Sending kickstart prompt';
|
|
612
|
+
nextAction = 'Will send prompt after delay';
|
|
613
|
+
break;
|
|
614
|
+
case 'waiting_kickstart':
|
|
615
|
+
currentPhase = 'Waiting for kickstart to complete';
|
|
616
|
+
nextAction = 'Completing cycle';
|
|
617
|
+
break;
|
|
618
|
+
default:
|
|
619
|
+
currentPhase = this._state;
|
|
620
|
+
nextAction = 'Processing...';
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
stopHookReceived: this.stopHookReceived,
|
|
624
|
+
stopHookTime: this.stopHookTime,
|
|
625
|
+
idlePromptReceived: this.idlePromptReceived,
|
|
626
|
+
idlePromptTime: this.idlePromptTime,
|
|
627
|
+
completionMessageDetected,
|
|
628
|
+
completionMessageTime: this.completionMessageTime,
|
|
629
|
+
outputSilent,
|
|
630
|
+
msSinceLastOutput,
|
|
631
|
+
tokensStable,
|
|
632
|
+
lastTokenCount: this.lastTokenCount,
|
|
633
|
+
msSinceTokenChange,
|
|
634
|
+
workingPatternsAbsent,
|
|
635
|
+
msSinceLastWorking,
|
|
636
|
+
aiCheck: this.config.aiIdleCheckEnabled ? this.aiChecker.getState() : null,
|
|
637
|
+
confidenceLevel: confidence,
|
|
638
|
+
statusText,
|
|
639
|
+
waitingFor,
|
|
640
|
+
activeTimers: this.getActiveTimers(),
|
|
641
|
+
recentActions: this.recentActions.slice(0, 10),
|
|
642
|
+
currentPhase,
|
|
643
|
+
nextAction,
|
|
644
|
+
stuckState: this.getStuckStateMetrics(),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Start periodic detection status updates for UI.
|
|
649
|
+
* Emits 'detectionUpdate' event every 2s while running.
|
|
650
|
+
*/
|
|
651
|
+
startDetectionUpdates() {
|
|
652
|
+
this.stopDetectionUpdates();
|
|
653
|
+
if (this._state === 'stopped')
|
|
654
|
+
return;
|
|
655
|
+
this.detectionUpdateTimer = setInterval(() => {
|
|
656
|
+
try {
|
|
657
|
+
if (this._state !== 'stopped') {
|
|
658
|
+
this.emit('detectionUpdate', this.getDetectionStatus());
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
|
|
663
|
+
}
|
|
664
|
+
}, 2000);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Stop periodic detection status updates.
|
|
668
|
+
*/
|
|
669
|
+
stopDetectionUpdates() {
|
|
670
|
+
if (this.detectionUpdateTimer) {
|
|
671
|
+
clearInterval(this.detectionUpdateTimer);
|
|
672
|
+
this.detectionUpdateTimer = null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Transition to a new state.
|
|
677
|
+
* Emits 'stateChanged' event with old and new states.
|
|
678
|
+
* No-op if already in the target state.
|
|
679
|
+
*
|
|
680
|
+
* @param newState - State to transition to
|
|
681
|
+
* @fires stateChanged
|
|
682
|
+
*/
|
|
683
|
+
setState(newState) {
|
|
684
|
+
if (newState === this._state)
|
|
685
|
+
return;
|
|
686
|
+
const prevState = this._state;
|
|
687
|
+
this._state = newState;
|
|
688
|
+
this.stateEnteredAt = Date.now();
|
|
689
|
+
this.stuckStateWarned = false; // Reset warning for new state
|
|
690
|
+
this.log(`State: ${prevState} → ${newState}`);
|
|
691
|
+
this.logAction('state', `${prevState} → ${newState}`);
|
|
692
|
+
this.emit('stateChanged', newState, prevState);
|
|
693
|
+
// Reset stuck recovery count on successful state transition to normal states
|
|
694
|
+
if (newState === 'watching' && prevState !== 'stopped') {
|
|
695
|
+
this.stuckRecoveryCount = 0;
|
|
696
|
+
}
|
|
697
|
+
// Start/restart stuck-state detection timer
|
|
698
|
+
this.startStuckStateTimer();
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Emit a timestamped log message.
|
|
702
|
+
* @param message - Log message content
|
|
703
|
+
* @fires log
|
|
704
|
+
*/
|
|
705
|
+
log(message) {
|
|
706
|
+
const timestamp = new Date().toISOString();
|
|
707
|
+
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
|
|
708
|
+
}
|
|
709
|
+
/** Set team watcher for team-aware idle detection */
|
|
710
|
+
setTeamWatcher(watcher) {
|
|
711
|
+
this.teamWatcher = watcher;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Start watching the session for idle state.
|
|
715
|
+
*
|
|
716
|
+
* Begins monitoring terminal output for idle indicators.
|
|
717
|
+
* When idle is detected, starts the respawn cycle.
|
|
718
|
+
*
|
|
719
|
+
* No-op if:
|
|
720
|
+
* - `config.enabled` is false
|
|
721
|
+
* - Already running (state !== 'stopped')
|
|
722
|
+
*
|
|
723
|
+
* @fires stateChanged - Transitions to 'watching'
|
|
724
|
+
*/
|
|
725
|
+
start() {
|
|
726
|
+
if (!this.config.enabled) {
|
|
727
|
+
this.log('Respawn is disabled');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (this._state !== 'stopped') {
|
|
731
|
+
this.log('Already running');
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
this.log('Starting respawn controller (multi-layer detection)');
|
|
735
|
+
// Re-setup AI checker listeners in case they were removed by a previous stop()
|
|
736
|
+
// This allows the controller to be restarted after being stopped
|
|
737
|
+
this.setupAiCheckerListeners();
|
|
738
|
+
this.setupPlanCheckerListeners();
|
|
739
|
+
// Initialize all timestamps and reset hook state
|
|
740
|
+
const now = Date.now();
|
|
741
|
+
this.lastActivityTime = now;
|
|
742
|
+
this.lastOutputTime = now;
|
|
743
|
+
this.lastTokenChangeTime = now;
|
|
744
|
+
this.lastWorkingPatternTime = now;
|
|
745
|
+
this.completionMessageTime = null;
|
|
746
|
+
this.hasReceivedOutput = false;
|
|
747
|
+
this.resetHookState();
|
|
748
|
+
// Seed the terminal buffer from the session's existing output.
|
|
749
|
+
// This gives the AI checker context even if no new output arrives.
|
|
750
|
+
const existingBuffer = this.session.terminalBuffer;
|
|
751
|
+
if (existingBuffer) {
|
|
752
|
+
this.terminalBuffer.clear();
|
|
753
|
+
this.terminalBuffer.append(existingBuffer);
|
|
754
|
+
}
|
|
755
|
+
this.aiChecker.reset();
|
|
756
|
+
this.planChecker.reset();
|
|
757
|
+
this.setState('watching');
|
|
758
|
+
this.setupTerminalListener();
|
|
759
|
+
this.startDetectionUpdates();
|
|
760
|
+
this.startNoOutputTimer();
|
|
761
|
+
this.startPreFilterTimer();
|
|
762
|
+
if (this.config.autoAcceptPrompts) {
|
|
763
|
+
this.startAutoAcceptTimer();
|
|
764
|
+
}
|
|
765
|
+
// P2-001: Initialize idle detection start time
|
|
766
|
+
this.idleDetectionStartTime = Date.now();
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Stop the respawn controller.
|
|
770
|
+
*
|
|
771
|
+
* Clears all timers, removes terminal listener, and sets state to 'stopped'.
|
|
772
|
+
* Safe to call multiple times.
|
|
773
|
+
*
|
|
774
|
+
* @fires stateChanged - Transitions to 'stopped'
|
|
775
|
+
*/
|
|
776
|
+
stop() {
|
|
777
|
+
this.log('Stopping respawn controller');
|
|
778
|
+
this.aiChecker.cancel();
|
|
779
|
+
this.planChecker.cancel();
|
|
780
|
+
// Remove event listeners from checkers to prevent memory leaks
|
|
781
|
+
this.aiChecker.removeAllListeners();
|
|
782
|
+
this.planChecker.removeAllListeners();
|
|
783
|
+
this.clearTimers();
|
|
784
|
+
this.stopDetectionUpdates();
|
|
785
|
+
this.recentActions.length = 0;
|
|
786
|
+
this.setState('stopped');
|
|
787
|
+
if (this.terminalHandler) {
|
|
788
|
+
this.session.off('terminal', this.terminalHandler);
|
|
789
|
+
this.terminalHandler = null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Pause respawn without stopping.
|
|
794
|
+
*
|
|
795
|
+
* Clears timers but keeps listening to terminal.
|
|
796
|
+
* State is preserved; won't trigger idle detection while paused.
|
|
797
|
+
* Use resume() to continue.
|
|
798
|
+
*/
|
|
799
|
+
pause() {
|
|
800
|
+
this.log('Pausing respawn');
|
|
801
|
+
this.clearTimers();
|
|
802
|
+
// Stay in current state but clear timers
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Resume respawn after pause.
|
|
806
|
+
*
|
|
807
|
+
* If in 'watching' state, immediately checks for idle condition.
|
|
808
|
+
* Otherwise, continues from current state.
|
|
809
|
+
* Re-setups terminal listener if it was removed (e.g., after stop()).
|
|
810
|
+
*/
|
|
811
|
+
resume() {
|
|
812
|
+
this.log('Resuming respawn');
|
|
813
|
+
// After a full stop(), use start() for clean restart to avoid double-registering listeners
|
|
814
|
+
if (this._state === 'stopped') {
|
|
815
|
+
this.start();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
// Re-setup terminal listener if it was removed
|
|
819
|
+
if (!this.terminalHandler) {
|
|
820
|
+
this.setupTerminalListener();
|
|
821
|
+
}
|
|
822
|
+
if (this._state === 'watching') {
|
|
823
|
+
this.checkIdleAndMaybeStart();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Set up terminal output listener on the session.
|
|
828
|
+
* Removes any previous listener first to avoid duplicates.
|
|
829
|
+
*/
|
|
830
|
+
setupTerminalListener() {
|
|
831
|
+
// Remove our previous listener if any (don't remove other listeners!)
|
|
832
|
+
if (this.terminalHandler) {
|
|
833
|
+
this.session.off('terminal', this.terminalHandler);
|
|
834
|
+
}
|
|
835
|
+
this.terminalHandler = (data) => {
|
|
836
|
+
this.handleTerminalData(data);
|
|
837
|
+
};
|
|
838
|
+
this.session.on('terminal', this.terminalHandler);
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Process terminal data for idle/working detection using multi-layer approach.
|
|
842
|
+
*
|
|
843
|
+
* Detection Layers:
|
|
844
|
+
* 1. Completion message ("for Xm Xs") - PRIMARY signal
|
|
845
|
+
* 2. Output silence - confirms completion
|
|
846
|
+
* 3. Token stability - additional confirmation
|
|
847
|
+
* 4. Working pattern absence - supports idle detection
|
|
848
|
+
* 5. No-output fallback - catches edge cases
|
|
849
|
+
*
|
|
850
|
+
* @param data - Raw terminal output data
|
|
851
|
+
*/
|
|
852
|
+
handleTerminalData(data) {
|
|
853
|
+
// Guard against null/undefined/empty data
|
|
854
|
+
if (!data || typeof data !== 'string') {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const now = Date.now();
|
|
858
|
+
// BufferAccumulator handles auto-trimming when max size exceeded
|
|
859
|
+
this.terminalBuffer.append(data);
|
|
860
|
+
// Track output time (Layer 2)
|
|
861
|
+
this.lastOutputTime = now;
|
|
862
|
+
this.lastActivityTime = now;
|
|
863
|
+
this.hasReceivedOutput = true;
|
|
864
|
+
this.resetNoOutputTimer();
|
|
865
|
+
this.resetPreFilterTimer();
|
|
866
|
+
this.resetAutoAcceptTimer();
|
|
867
|
+
// Cancel plan check if running (new output makes result stale)
|
|
868
|
+
if (this.planChecker.status === 'checking') {
|
|
869
|
+
this.log('New output during plan check, cancelling (stale)');
|
|
870
|
+
this.planChecker.cancel();
|
|
871
|
+
}
|
|
872
|
+
// Track token count (Layer 3)
|
|
873
|
+
const tokenCount = this.extractTokenCount(data);
|
|
874
|
+
if (tokenCount !== null && tokenCount !== this.lastTokenCount) {
|
|
875
|
+
this.lastTokenCount = tokenCount;
|
|
876
|
+
this.lastTokenChangeTime = now;
|
|
877
|
+
}
|
|
878
|
+
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
|
|
879
|
+
// Check this before working patterns because completion message indicates
|
|
880
|
+
// the work is done, even if working patterns are still in the rolling window
|
|
881
|
+
if (this.isCompletionMessage(data)) {
|
|
882
|
+
// Clear the rolling window - completion marks a transition point
|
|
883
|
+
this.clearWorkingPatternWindow();
|
|
884
|
+
this.workingDetected = false;
|
|
885
|
+
this.completionMessageTime = now;
|
|
886
|
+
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
|
|
887
|
+
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
|
|
888
|
+
// In watching state, start completion confirmation timer
|
|
889
|
+
if (this._state === 'watching') {
|
|
890
|
+
this.startCompletionConfirmTimer();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// In waiting states, also use confirmation timer (same detection logic)
|
|
894
|
+
// This ensures we wait for Claude to finish before proceeding
|
|
895
|
+
// Note: 'watching' is already handled above and returns early
|
|
896
|
+
switch (this._state) {
|
|
897
|
+
case 'waiting_update':
|
|
898
|
+
this.startStepConfirmTimer('update');
|
|
899
|
+
break;
|
|
900
|
+
case 'waiting_clear':
|
|
901
|
+
this.checkClearComplete(); // /clear is quick, no need to wait
|
|
902
|
+
break;
|
|
903
|
+
case 'waiting_init':
|
|
904
|
+
this.startStepConfirmTimer('init');
|
|
905
|
+
break;
|
|
906
|
+
case 'waiting_kickstart':
|
|
907
|
+
this.startStepConfirmTimer('kickstart');
|
|
908
|
+
break;
|
|
909
|
+
// Non-waiting states: completion message is ignored
|
|
910
|
+
case 'confirming_idle':
|
|
911
|
+
case 'ai_checking':
|
|
912
|
+
case 'sending_update':
|
|
913
|
+
case 'sending_clear':
|
|
914
|
+
case 'sending_init':
|
|
915
|
+
case 'monitoring_init':
|
|
916
|
+
case 'sending_kickstart':
|
|
917
|
+
case 'stopped':
|
|
918
|
+
// Completion message during these states is ignored
|
|
919
|
+
break;
|
|
920
|
+
default:
|
|
921
|
+
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
|
|
922
|
+
}
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
// Detect working patterns (Layer 4)
|
|
926
|
+
const isWorking = this.hasWorkingPattern(data);
|
|
927
|
+
if (isWorking) {
|
|
928
|
+
this.workingDetected = true;
|
|
929
|
+
this.promptDetected = false;
|
|
930
|
+
this.elicitationDetected = false; // Clear on new work cycle
|
|
931
|
+
this.resetHookState(); // Clear hook signals on new work
|
|
932
|
+
this.lastWorkingPatternTime = now;
|
|
933
|
+
// Cancel hook confirmation timer if running
|
|
934
|
+
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected');
|
|
935
|
+
this.hookConfirmTimer = null;
|
|
936
|
+
// Cancel any pending completion confirmation
|
|
937
|
+
this.cancelCompletionConfirm();
|
|
938
|
+
// Cancel any pending step confirmation (Claude is still working)
|
|
939
|
+
this.cancelStepConfirm();
|
|
940
|
+
// If AI check is running, cancel it (Claude is working)
|
|
941
|
+
if (this._state === 'ai_checking') {
|
|
942
|
+
this.log('Working patterns detected during AI check, cancelling');
|
|
943
|
+
this.aiChecker.cancel();
|
|
944
|
+
this.setState('watching');
|
|
945
|
+
}
|
|
946
|
+
// Cancel plan check if running (Claude started working)
|
|
947
|
+
if (this.planChecker.status === 'checking') {
|
|
948
|
+
this.log('Working patterns detected during plan check, cancelling');
|
|
949
|
+
this.planChecker.cancel();
|
|
950
|
+
}
|
|
951
|
+
// If we're monitoring init and work started, go to watching (no kickstart needed)
|
|
952
|
+
if (this._state === 'monitoring_init') {
|
|
953
|
+
this.log('/init triggered work, skipping kickstart');
|
|
954
|
+
this.emit('stepCompleted', 'init');
|
|
955
|
+
this.completeCycle();
|
|
956
|
+
}
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
// In confirming_idle or ai_checking state, substantial output cancels the flow.
|
|
960
|
+
// This prevents false triggers when Claude pauses briefly mid-work.
|
|
961
|
+
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
|
|
962
|
+
// Strip ANSI escape codes to check if there's real content
|
|
963
|
+
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
|
|
964
|
+
const stripped = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').trim();
|
|
965
|
+
if (stripped.length > 2) {
|
|
966
|
+
if (this._state === 'ai_checking') {
|
|
967
|
+
this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`);
|
|
968
|
+
this.aiChecker.cancel();
|
|
969
|
+
this.setState('watching');
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
// Real content (not just escape codes or single chars) - cancel confirmation
|
|
973
|
+
this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`);
|
|
974
|
+
this.cancelCompletionConfirm();
|
|
975
|
+
}
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// Legacy fallback: detect prompt characters (still useful for waiting_* states)
|
|
980
|
+
const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern));
|
|
981
|
+
if (hasPrompt) {
|
|
982
|
+
this.promptDetected = true;
|
|
983
|
+
this.workingDetected = false;
|
|
984
|
+
// Handle legacy detection in waiting states - also use confirmation timers
|
|
985
|
+
switch (this._state) {
|
|
986
|
+
case 'waiting_update':
|
|
987
|
+
this.startStepConfirmTimer('update');
|
|
988
|
+
break;
|
|
989
|
+
case 'waiting_clear':
|
|
990
|
+
this.checkClearComplete(); // /clear is quick, no need to wait
|
|
991
|
+
break;
|
|
992
|
+
case 'waiting_init':
|
|
993
|
+
this.startStepConfirmTimer('init');
|
|
994
|
+
break;
|
|
995
|
+
case 'monitoring_init':
|
|
996
|
+
this.checkMonitoringInitIdle();
|
|
997
|
+
break;
|
|
998
|
+
case 'waiting_kickstart':
|
|
999
|
+
this.startStepConfirmTimer('kickstart');
|
|
1000
|
+
break;
|
|
1001
|
+
// Non-waiting states: prompt detection is informational only
|
|
1002
|
+
case 'watching':
|
|
1003
|
+
case 'confirming_idle':
|
|
1004
|
+
case 'ai_checking':
|
|
1005
|
+
case 'sending_update':
|
|
1006
|
+
case 'sending_clear':
|
|
1007
|
+
case 'sending_init':
|
|
1008
|
+
case 'sending_kickstart':
|
|
1009
|
+
case 'stopped':
|
|
1010
|
+
// Prompt detection during these states doesn't trigger action
|
|
1011
|
+
break;
|
|
1012
|
+
default:
|
|
1013
|
+
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Handle update step completion.
|
|
1019
|
+
* Called when ready indicator detected in waiting_update state.
|
|
1020
|
+
* Proceeds to clear, init, or completes cycle based on config.
|
|
1021
|
+
* @fires stepCompleted - With step 'update'
|
|
1022
|
+
*/
|
|
1023
|
+
checkUpdateComplete() {
|
|
1024
|
+
this.log('Update completed (ready indicator)');
|
|
1025
|
+
this.emit('stepCompleted', 'update');
|
|
1026
|
+
// P2-004: Record step completion
|
|
1027
|
+
this.recordCycleStep('update');
|
|
1028
|
+
if (this.config.sendClear) {
|
|
1029
|
+
// P2-002: Check if we should skip /clear
|
|
1030
|
+
if (this.shouldSkipClear()) {
|
|
1031
|
+
if (this.currentCycleMetrics) {
|
|
1032
|
+
this.currentCycleMetrics.clearSkipped = true;
|
|
1033
|
+
}
|
|
1034
|
+
// Skip /clear, go directly to /init or complete
|
|
1035
|
+
if (this.config.sendInit) {
|
|
1036
|
+
this.sendInit();
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
this.completeCycle();
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
this.sendClear();
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
else if (this.config.sendInit) {
|
|
1047
|
+
this.sendInit();
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
this.completeCycle();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Handle /clear step completion.
|
|
1055
|
+
* Proceeds to init or completes cycle based on config.
|
|
1056
|
+
* @fires stepCompleted - With step 'clear'
|
|
1057
|
+
*/
|
|
1058
|
+
checkClearComplete() {
|
|
1059
|
+
// Clear the fallback timer since we got prompt detection
|
|
1060
|
+
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected');
|
|
1061
|
+
this.clearFallbackTimer = null;
|
|
1062
|
+
this.logAction('step', '/clear completed');
|
|
1063
|
+
this.emit('stepCompleted', 'clear');
|
|
1064
|
+
// P2-004: Record step completion
|
|
1065
|
+
this.recordCycleStep('clear');
|
|
1066
|
+
if (this.config.sendInit) {
|
|
1067
|
+
this.sendInit();
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
this.completeCycle();
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Handle /init step completion.
|
|
1075
|
+
* If kickstart is configured, monitors for work.
|
|
1076
|
+
* Otherwise completes cycle.
|
|
1077
|
+
* @fires stepCompleted - With step 'init' (if no kickstart)
|
|
1078
|
+
*/
|
|
1079
|
+
checkInitComplete() {
|
|
1080
|
+
this.log('/init completed (ready indicator)');
|
|
1081
|
+
// P2-004: Record step completion
|
|
1082
|
+
this.recordCycleStep('init');
|
|
1083
|
+
// If kickstart prompt is configured, monitor to see if /init triggered work
|
|
1084
|
+
if (this.config.kickstartPrompt) {
|
|
1085
|
+
this.startMonitoringInit();
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
this.emit('stepCompleted', 'init');
|
|
1089
|
+
this.completeCycle();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Start monitoring to see if /init triggered work.
|
|
1094
|
+
* Enters 'monitoring_init' state and waits 3s grace period.
|
|
1095
|
+
* If no work detected, sends kickstart prompt.
|
|
1096
|
+
*/
|
|
1097
|
+
startMonitoringInit() {
|
|
1098
|
+
this.setState('monitoring_init');
|
|
1099
|
+
this.terminalBuffer.clear();
|
|
1100
|
+
this.clearWorkingPatternWindow();
|
|
1101
|
+
this.workingDetected = false;
|
|
1102
|
+
this.logAction('step', 'Monitoring if /init triggered work...');
|
|
1103
|
+
// Give Claude a moment to start working before checking for idle
|
|
1104
|
+
this.stepTimer = this.startTrackedTimer('init-monitor', 3000, () => {
|
|
1105
|
+
this.stepTimer = null;
|
|
1106
|
+
// If still in monitoring state and no work detected, consider it idle
|
|
1107
|
+
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
|
1108
|
+
this.checkMonitoringInitIdle();
|
|
1109
|
+
}
|
|
1110
|
+
}, 'grace period for /init');
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Handle monitoring timeout when /init didn't trigger work.
|
|
1114
|
+
* Sends kickstart prompt as fallback.
|
|
1115
|
+
* @fires stepCompleted - With step 'init'
|
|
1116
|
+
*/
|
|
1117
|
+
checkMonitoringInitIdle() {
|
|
1118
|
+
if (this.stepTimer) {
|
|
1119
|
+
clearTimeout(this.stepTimer);
|
|
1120
|
+
this.stepTimer = null;
|
|
1121
|
+
}
|
|
1122
|
+
this.log('/init did not trigger work, sending kickstart prompt');
|
|
1123
|
+
this.emit('stepCompleted', 'init');
|
|
1124
|
+
this.sendKickstart();
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Send the kickstart prompt to get Claude working.
|
|
1128
|
+
* @fires stepSent - With step 'kickstart'
|
|
1129
|
+
*/
|
|
1130
|
+
sendKickstart() {
|
|
1131
|
+
this.setState('sending_kickstart');
|
|
1132
|
+
this.terminalBuffer.clear();
|
|
1133
|
+
this.clearWorkingPatternWindow();
|
|
1134
|
+
this.stepTimer = this.startTrackedTimer('step-delay', this.config.interStepDelayMs, async () => {
|
|
1135
|
+
this.stepTimer = null;
|
|
1136
|
+
if (this._state === 'stopped')
|
|
1137
|
+
return;
|
|
1138
|
+
const prompt = this.config.kickstartPrompt;
|
|
1139
|
+
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
|
1140
|
+
await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
|
|
1141
|
+
this.emit('stepSent', 'kickstart', prompt);
|
|
1142
|
+
this.setState('waiting_kickstart');
|
|
1143
|
+
this.promptDetected = false;
|
|
1144
|
+
this.workingDetected = false;
|
|
1145
|
+
}, 'delay before kickstart');
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Handle kickstart step completion.
|
|
1149
|
+
* @fires stepCompleted - With step 'kickstart'
|
|
1150
|
+
*/
|
|
1151
|
+
checkKickstartComplete() {
|
|
1152
|
+
this.log('Kickstart completed (ready indicator)');
|
|
1153
|
+
this.emit('stepCompleted', 'kickstart');
|
|
1154
|
+
// P2-004: Record step completion
|
|
1155
|
+
this.recordCycleStep('kickstart');
|
|
1156
|
+
this.completeCycle();
|
|
1157
|
+
}
|
|
1158
|
+
/** Clear all timers (step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
|
|
1159
|
+
clearTimers() {
|
|
1160
|
+
// Clear tracked timers map first to avoid stale entries during individual cleanup
|
|
1161
|
+
this.activeTimers.clear();
|
|
1162
|
+
if (this.stepTimer) {
|
|
1163
|
+
clearTimeout(this.stepTimer);
|
|
1164
|
+
this.stepTimer = null;
|
|
1165
|
+
}
|
|
1166
|
+
if (this.clearFallbackTimer) {
|
|
1167
|
+
clearTimeout(this.clearFallbackTimer);
|
|
1168
|
+
this.clearFallbackTimer = null;
|
|
1169
|
+
}
|
|
1170
|
+
if (this.completionConfirmTimer) {
|
|
1171
|
+
clearTimeout(this.completionConfirmTimer);
|
|
1172
|
+
this.completionConfirmTimer = null;
|
|
1173
|
+
}
|
|
1174
|
+
if (this.stepConfirmTimer) {
|
|
1175
|
+
clearTimeout(this.stepConfirmTimer);
|
|
1176
|
+
this.stepConfirmTimer = null;
|
|
1177
|
+
}
|
|
1178
|
+
if (this.autoAcceptTimer) {
|
|
1179
|
+
clearTimeout(this.autoAcceptTimer);
|
|
1180
|
+
this.autoAcceptTimer = null;
|
|
1181
|
+
}
|
|
1182
|
+
if (this.preFilterTimer) {
|
|
1183
|
+
clearTimeout(this.preFilterTimer);
|
|
1184
|
+
this.preFilterTimer = null;
|
|
1185
|
+
}
|
|
1186
|
+
if (this.noOutputTimer) {
|
|
1187
|
+
clearTimeout(this.noOutputTimer);
|
|
1188
|
+
this.noOutputTimer = null;
|
|
1189
|
+
}
|
|
1190
|
+
if (this.hookConfirmTimer) {
|
|
1191
|
+
clearTimeout(this.hookConfirmTimer);
|
|
1192
|
+
this.hookConfirmTimer = null;
|
|
1193
|
+
}
|
|
1194
|
+
if (this.stuckStateTimer) {
|
|
1195
|
+
clearInterval(this.stuckStateTimer);
|
|
1196
|
+
this.stuckStateTimer = null;
|
|
1197
|
+
}
|
|
1198
|
+
if (this.detectionUpdateTimer) {
|
|
1199
|
+
clearInterval(this.detectionUpdateTimer);
|
|
1200
|
+
this.detectionUpdateTimer = null;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// ========== Stuck-State Detection Methods ==========
|
|
1204
|
+
/**
|
|
1205
|
+
* Start or restart the stuck-state detection timer.
|
|
1206
|
+
* Emits warning after stuckStateWarningMs, triggers recovery after stuckStateRecoveryMs.
|
|
1207
|
+
*/
|
|
1208
|
+
startStuckStateTimer() {
|
|
1209
|
+
if (!this.config.stuckStateDetectionEnabled)
|
|
1210
|
+
return;
|
|
1211
|
+
if (this._state === 'stopped')
|
|
1212
|
+
return;
|
|
1213
|
+
// Clear existing timer
|
|
1214
|
+
if (this.stuckStateTimer) {
|
|
1215
|
+
clearInterval(this.stuckStateTimer);
|
|
1216
|
+
this.stuckStateTimer = null;
|
|
1217
|
+
}
|
|
1218
|
+
// Check interval for stuck state
|
|
1219
|
+
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
|
|
1220
|
+
this.stuckStateTimer = setInterval(() => {
|
|
1221
|
+
try {
|
|
1222
|
+
this.checkStuckState();
|
|
1223
|
+
}
|
|
1224
|
+
catch (err) {
|
|
1225
|
+
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
|
|
1226
|
+
}
|
|
1227
|
+
}, checkIntervalMs);
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Check if the controller is stuck in the current state.
|
|
1231
|
+
* Emits warnings and triggers recovery actions as appropriate.
|
|
1232
|
+
*/
|
|
1233
|
+
checkStuckState() {
|
|
1234
|
+
if (this._state === 'stopped')
|
|
1235
|
+
return;
|
|
1236
|
+
const durationMs = Date.now() - this.stateEnteredAt;
|
|
1237
|
+
// Check for recovery threshold (more severe)
|
|
1238
|
+
if (durationMs >= this.config.stuckStateRecoveryMs) {
|
|
1239
|
+
if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) {
|
|
1240
|
+
this.stuckRecoveryCount++;
|
|
1241
|
+
this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`);
|
|
1242
|
+
this.log(`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`);
|
|
1243
|
+
this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount);
|
|
1244
|
+
this.handleStuckStateRecovery();
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
this.logAction('stuck', `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`);
|
|
1248
|
+
this.log(`Stuck-state: max recoveries reached, manual intervention needed`);
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
// Check for warning threshold
|
|
1253
|
+
if (durationMs >= this.config.stuckStateWarningMs && !this.stuckStateWarned) {
|
|
1254
|
+
this.stuckStateWarned = true;
|
|
1255
|
+
this.logAction('stuck', `Warning: in state '${this._state}' for ${Math.round(durationMs / 1000)}s`);
|
|
1256
|
+
this.log(`Stuck-state warning: state '${this._state}' for ${Math.round(durationMs / 1000)}s without progress`);
|
|
1257
|
+
this.emit('stuckStateWarning', this._state, durationMs);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Handle stuck-state recovery by resetting to a known good state.
|
|
1262
|
+
* Uses escalating recovery strategies based on current state.
|
|
1263
|
+
*/
|
|
1264
|
+
handleStuckStateRecovery() {
|
|
1265
|
+
const currentState = this._state;
|
|
1266
|
+
// P2-004: Complete current cycle metrics with stuck_recovery outcome
|
|
1267
|
+
if (this.currentCycleMetrics) {
|
|
1268
|
+
this.completeCycleMetrics('stuck_recovery', `Stuck in state: ${currentState}`);
|
|
1269
|
+
}
|
|
1270
|
+
// Cancel any running AI checks
|
|
1271
|
+
if (this.aiChecker.status === 'checking') {
|
|
1272
|
+
this.aiChecker.cancel();
|
|
1273
|
+
}
|
|
1274
|
+
if (this.planChecker.status === 'checking') {
|
|
1275
|
+
this.planChecker.cancel();
|
|
1276
|
+
}
|
|
1277
|
+
// Clear all timers and reset detection state
|
|
1278
|
+
this.clearTimers();
|
|
1279
|
+
this.completionMessageTime = null;
|
|
1280
|
+
this.promptDetected = false;
|
|
1281
|
+
this.workingDetected = false;
|
|
1282
|
+
this.resetHookState();
|
|
1283
|
+
// Escalating recovery strategies
|
|
1284
|
+
switch (currentState) {
|
|
1285
|
+
case 'watching':
|
|
1286
|
+
case 'confirming_idle':
|
|
1287
|
+
case 'ai_checking':
|
|
1288
|
+
// For detection states, try forcing idle confirmation
|
|
1289
|
+
this.log('Recovery: forcing idle confirmation');
|
|
1290
|
+
this.onIdleConfirmed(`stuck-state recovery (was ${currentState})`);
|
|
1291
|
+
break;
|
|
1292
|
+
case 'waiting_update':
|
|
1293
|
+
case 'waiting_clear':
|
|
1294
|
+
case 'waiting_init':
|
|
1295
|
+
case 'waiting_kickstart':
|
|
1296
|
+
case 'monitoring_init':
|
|
1297
|
+
// For waiting states, skip to next step or complete cycle
|
|
1298
|
+
this.log('Recovery: skipping stuck step');
|
|
1299
|
+
this.completeCycle();
|
|
1300
|
+
break;
|
|
1301
|
+
case 'sending_update':
|
|
1302
|
+
case 'sending_clear':
|
|
1303
|
+
case 'sending_init':
|
|
1304
|
+
case 'sending_kickstart':
|
|
1305
|
+
// For sending states, retry the send
|
|
1306
|
+
this.log('Recovery: returning to watching state');
|
|
1307
|
+
this.setState('watching');
|
|
1308
|
+
this.startNoOutputTimer();
|
|
1309
|
+
this.startPreFilterTimer();
|
|
1310
|
+
if (this.config.autoAcceptPrompts) {
|
|
1311
|
+
this.startAutoAcceptTimer();
|
|
1312
|
+
}
|
|
1313
|
+
break;
|
|
1314
|
+
default:
|
|
1315
|
+
// Fallback: reset to watching
|
|
1316
|
+
this.log('Recovery: fallback to watching state');
|
|
1317
|
+
this.setState('watching');
|
|
1318
|
+
this.startNoOutputTimer();
|
|
1319
|
+
this.startPreFilterTimer();
|
|
1320
|
+
if (this.config.autoAcceptPrompts) {
|
|
1321
|
+
this.startAutoAcceptTimer();
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Get stuck-state metrics for UI display.
|
|
1327
|
+
*/
|
|
1328
|
+
getStuckStateMetrics() {
|
|
1329
|
+
return {
|
|
1330
|
+
currentStateDurationMs: this._state !== 'stopped' ? Date.now() - this.stateEnteredAt : 0,
|
|
1331
|
+
warningThresholdMs: this.config.stuckStateWarningMs,
|
|
1332
|
+
recoveryThresholdMs: this.config.stuckStateRecoveryMs,
|
|
1333
|
+
recoveryAttempts: this.stuckRecoveryCount,
|
|
1334
|
+
maxRecoveries: this.config.maxStuckRecoveries,
|
|
1335
|
+
isWarned: this.stuckStateWarned,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
// ========== Timer Tracking Methods ==========
|
|
1339
|
+
/**
|
|
1340
|
+
* Start a tracked timer with UI countdown support.
|
|
1341
|
+
* Emits timerStarted event and tracks the timer for UI display.
|
|
1342
|
+
*/
|
|
1343
|
+
startTrackedTimer(name, durationMs, callback, reason) {
|
|
1344
|
+
const now = Date.now();
|
|
1345
|
+
const endsAt = now + durationMs;
|
|
1346
|
+
this.activeTimers.set(name, { name, startedAt: now, durationMs, endsAt });
|
|
1347
|
+
this.emit('timerStarted', { name, durationMs, endsAt, reason });
|
|
1348
|
+
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
|
|
1349
|
+
return setTimeout(() => {
|
|
1350
|
+
this.activeTimers.delete(name);
|
|
1351
|
+
this.emit('timerCompleted', name);
|
|
1352
|
+
callback();
|
|
1353
|
+
}, durationMs);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Cancel a tracked timer and emit cancellation event.
|
|
1357
|
+
*/
|
|
1358
|
+
cancelTrackedTimer(name, timerRef, reason) {
|
|
1359
|
+
if (timerRef) {
|
|
1360
|
+
clearTimeout(timerRef);
|
|
1361
|
+
if (this.activeTimers.has(name)) {
|
|
1362
|
+
this.activeTimers.delete(name);
|
|
1363
|
+
this.emit('timerCancelled', name, reason);
|
|
1364
|
+
this.logAction('timer-cancel', `${name}${reason ? `: ${reason}` : ''}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Get all active timers with remaining time for UI display.
|
|
1370
|
+
*/
|
|
1371
|
+
getActiveTimers() {
|
|
1372
|
+
const now = Date.now();
|
|
1373
|
+
return Array.from(this.activeTimers.values()).map((t) => ({
|
|
1374
|
+
name: t.name,
|
|
1375
|
+
remainingMs: Math.max(0, t.endsAt - now),
|
|
1376
|
+
totalMs: t.durationMs,
|
|
1377
|
+
}));
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Log an action for detailed UI feedback.
|
|
1381
|
+
* Keeps the last 20 entries.
|
|
1382
|
+
*/
|
|
1383
|
+
logAction(type, detail) {
|
|
1384
|
+
const action = { type, detail, timestamp: Date.now() };
|
|
1385
|
+
this.recentActions.unshift(action);
|
|
1386
|
+
if (this.recentActions.length > 20) {
|
|
1387
|
+
this.recentActions.pop();
|
|
1388
|
+
}
|
|
1389
|
+
this.emit('actionLog', action);
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Get recent action log entries.
|
|
1393
|
+
*/
|
|
1394
|
+
getRecentActions() {
|
|
1395
|
+
return [...this.recentActions];
|
|
1396
|
+
}
|
|
1397
|
+
// ========== Multi-Layer Detection Methods ==========
|
|
1398
|
+
/**
|
|
1399
|
+
* Check if data contains a completion message pattern.
|
|
1400
|
+
* Matches "for Xh Xm Xs" time duration patterns.
|
|
1401
|
+
*/
|
|
1402
|
+
isCompletionMessage(data) {
|
|
1403
|
+
return COMPLETION_TIME_PATTERN.test(data);
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Check if data contains working patterns.
|
|
1407
|
+
* Uses rolling window to catch patterns split across chunks (e.g., "Thin" + "king").
|
|
1408
|
+
*/
|
|
1409
|
+
hasWorkingPattern(data) {
|
|
1410
|
+
// Always update the rolling window first to maintain continuity
|
|
1411
|
+
this.workingPatternWindow += data;
|
|
1412
|
+
if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) {
|
|
1413
|
+
this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE);
|
|
1414
|
+
}
|
|
1415
|
+
// Check the rolling window (includes current data, catches both complete and split patterns)
|
|
1416
|
+
return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern));
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Clear the working pattern rolling window.
|
|
1420
|
+
* Called when starting a new detection cycle.
|
|
1421
|
+
*/
|
|
1422
|
+
clearWorkingPatternWindow() {
|
|
1423
|
+
this.workingPatternWindow = '';
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Extract token count from data if present.
|
|
1427
|
+
* Returns null if no token pattern found.
|
|
1428
|
+
*/
|
|
1429
|
+
extractTokenCount(data) {
|
|
1430
|
+
const match = data.match(TOKEN_PATTERN);
|
|
1431
|
+
if (!match)
|
|
1432
|
+
return null;
|
|
1433
|
+
let count = parseFloat(match[1]);
|
|
1434
|
+
const suffix = match[2]?.toLowerCase();
|
|
1435
|
+
if (suffix === 'k')
|
|
1436
|
+
count *= 1000;
|
|
1437
|
+
else if (suffix === 'm')
|
|
1438
|
+
count *= 1000000;
|
|
1439
|
+
return Math.round(count);
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Start the no-output fallback timer.
|
|
1443
|
+
* If no output for noOutputTimeoutMs, triggers idle detection as safety net
|
|
1444
|
+
* (used when AI check is disabled or has too many errors).
|
|
1445
|
+
*/
|
|
1446
|
+
startNoOutputTimer() {
|
|
1447
|
+
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting');
|
|
1448
|
+
this.noOutputTimer = null;
|
|
1449
|
+
this.noOutputTimer = this.startTrackedTimer('no-output-fallback', this.config.noOutputTimeoutMs, () => {
|
|
1450
|
+
this.noOutputTimer = null;
|
|
1451
|
+
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
|
1452
|
+
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
1453
|
+
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
|
|
1454
|
+
// If AI check is disabled or errored out, go directly to idle
|
|
1455
|
+
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
|
1456
|
+
this.onIdleConfirmed('no-output fallback (AI check disabled)');
|
|
1457
|
+
}
|
|
1458
|
+
else {
|
|
1459
|
+
this.tryStartAiCheck('no-output fallback');
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}, 'fallback if no output at all');
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Reset the no-output fallback timer.
|
|
1466
|
+
* Called whenever output is received.
|
|
1467
|
+
*/
|
|
1468
|
+
resetNoOutputTimer() {
|
|
1469
|
+
this.startNoOutputTimer();
|
|
1470
|
+
}
|
|
1471
|
+
// ========== Pre-Filter & AI Check Methods ==========
|
|
1472
|
+
/**
|
|
1473
|
+
* Start the pre-filter timer.
|
|
1474
|
+
* Fires after completionConfirmMs of silence. When it fires, checks if
|
|
1475
|
+
* all pre-filter conditions are met and starts the AI check if so.
|
|
1476
|
+
* This provides an additional path to AI check even without a completion message.
|
|
1477
|
+
*/
|
|
1478
|
+
startPreFilterTimer() {
|
|
1479
|
+
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting');
|
|
1480
|
+
this.preFilterTimer = null;
|
|
1481
|
+
// Only set up pre-filter when AI check is enabled
|
|
1482
|
+
if (!this.config.aiIdleCheckEnabled)
|
|
1483
|
+
return;
|
|
1484
|
+
this.preFilterTimer = this.startTrackedTimer('pre-filter', this.config.completionConfirmMs, () => {
|
|
1485
|
+
this.preFilterTimer = null;
|
|
1486
|
+
if (this._state === 'watching') {
|
|
1487
|
+
const now = Date.now();
|
|
1488
|
+
const msSinceOutput = now - this.lastOutputTime;
|
|
1489
|
+
const msSinceWorking = now - this.lastWorkingPatternTime;
|
|
1490
|
+
const msSinceTokenChange = now - this.lastTokenChangeTime;
|
|
1491
|
+
// Check pre-filter conditions
|
|
1492
|
+
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
|
|
1493
|
+
const noWorkingMet = msSinceWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
|
|
1494
|
+
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
|
|
1495
|
+
if (silenceMet && noWorkingMet && tokensStableMet) {
|
|
1496
|
+
this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`);
|
|
1497
|
+
this.tryStartAiCheck('pre-filter');
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}, 'checking idle conditions');
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Reset the pre-filter timer.
|
|
1504
|
+
* Called whenever output is received.
|
|
1505
|
+
*/
|
|
1506
|
+
resetPreFilterTimer() {
|
|
1507
|
+
this.startPreFilterTimer();
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Attempt to start an AI idle check.
|
|
1511
|
+
* Checks if AI check is enabled, not on cooldown, and not already checking.
|
|
1512
|
+
* Falls back to direct idle confirmation if AI check is unavailable.
|
|
1513
|
+
*
|
|
1514
|
+
* @param reason - What triggered this attempt (for logging)
|
|
1515
|
+
*/
|
|
1516
|
+
tryStartAiCheck(reason) {
|
|
1517
|
+
// P0-006: Check Session.isWorking first to skip expensive AI call if session reports working
|
|
1518
|
+
if (this.session.isWorking) {
|
|
1519
|
+
this.log(`Skipping AI check - Session reports isWorking=true (reason: ${reason})`);
|
|
1520
|
+
this.logAction('detection', 'Skipped AI check: Session is working');
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
// If AI check is disabled or errored out, fall back to direct idle confirmation
|
|
1524
|
+
if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') {
|
|
1525
|
+
this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`);
|
|
1526
|
+
this.onIdleConfirmed(reason);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
// If on cooldown, don't start check - wait for cooldown to expire
|
|
1530
|
+
if (this.aiChecker.isOnCooldown()) {
|
|
1531
|
+
this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
// If already checking, don't start another
|
|
1535
|
+
if (this.aiChecker.status === 'checking') {
|
|
1536
|
+
this.log('AI check already in progress');
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
// Start the AI check
|
|
1540
|
+
this.startAiCheck(reason);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Start the AI idle check.
|
|
1544
|
+
* Transitions to 'ai_checking' state and runs the check asynchronously.
|
|
1545
|
+
*
|
|
1546
|
+
* @param reason - What triggered this check (for logging)
|
|
1547
|
+
*/
|
|
1548
|
+
startAiCheck(reason) {
|
|
1549
|
+
this.setState('ai_checking');
|
|
1550
|
+
this.logAction('ai-check', `Spawning AI idle checker (${reason})`);
|
|
1551
|
+
this.emit('aiCheckStarted');
|
|
1552
|
+
// Generate unique ID for this check to detect stale results
|
|
1553
|
+
const checkId = randomUUID();
|
|
1554
|
+
this._currentAiCheckId = checkId;
|
|
1555
|
+
// Get the terminal buffer for analysis
|
|
1556
|
+
const buffer = this.terminalBuffer.value;
|
|
1557
|
+
this.aiChecker
|
|
1558
|
+
.check(buffer)
|
|
1559
|
+
.then((result) => {
|
|
1560
|
+
// If state changed while checking (e.g., cancelled), ignore result
|
|
1561
|
+
if (this._state !== 'ai_checking') {
|
|
1562
|
+
this.log(`AI check result ignored (state is now ${this._state})`);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
// Validate this is the result for the current check (not a stale one)
|
|
1566
|
+
if (this._currentAiCheckId !== checkId) {
|
|
1567
|
+
this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (result.verdict === 'IDLE') {
|
|
1571
|
+
// Cancel any pending confirmation timers - AI has spoken
|
|
1572
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
|
1573
|
+
this.completionConfirmTimer = null;
|
|
1574
|
+
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
|
1575
|
+
this.preFilterTimer = null;
|
|
1576
|
+
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
|
1577
|
+
this.emit('aiCheckCompleted', result);
|
|
1578
|
+
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
|
1579
|
+
}
|
|
1580
|
+
else if (result.verdict === 'WORKING') {
|
|
1581
|
+
// Cancel timers and go to cooldown
|
|
1582
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
|
1583
|
+
this.completionConfirmTimer = null;
|
|
1584
|
+
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
|
1585
|
+
this.emit('aiCheckCompleted', result);
|
|
1586
|
+
this.setState('watching');
|
|
1587
|
+
this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`);
|
|
1588
|
+
// Restart timers so the controller retries after cooldown expires
|
|
1589
|
+
this.startNoOutputTimer();
|
|
1590
|
+
this.startPreFilterTimer();
|
|
1591
|
+
}
|
|
1592
|
+
else {
|
|
1593
|
+
// ERROR verdict
|
|
1594
|
+
this.logAction('ai-check', `Error: ${result.reasoning}`);
|
|
1595
|
+
this.emit('aiCheckFailed', result.reasoning);
|
|
1596
|
+
this.setState('watching');
|
|
1597
|
+
// Restart timers to allow retry
|
|
1598
|
+
this.startNoOutputTimer();
|
|
1599
|
+
this.startPreFilterTimer();
|
|
1600
|
+
}
|
|
1601
|
+
})
|
|
1602
|
+
.catch((err) => {
|
|
1603
|
+
// Validate this is the error for the current check
|
|
1604
|
+
if (this._currentAiCheckId !== checkId) {
|
|
1605
|
+
return; // Stale check, ignore error
|
|
1606
|
+
}
|
|
1607
|
+
if (this._state === 'stopped')
|
|
1608
|
+
return; // Guard against stopped state
|
|
1609
|
+
if (this._state === 'ai_checking') {
|
|
1610
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1611
|
+
this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
|
1612
|
+
this.emit('aiCheckFailed', errorMsg);
|
|
1613
|
+
this.setState('watching');
|
|
1614
|
+
this.log(`AI check error: ${errorMsg}`);
|
|
1615
|
+
// Restart timers to allow retry
|
|
1616
|
+
this.startNoOutputTimer();
|
|
1617
|
+
this.startPreFilterTimer();
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
// ========== Auto-Accept Prompt Methods ==========
|
|
1622
|
+
/**
|
|
1623
|
+
* Reset the auto-accept timer.
|
|
1624
|
+
* Called whenever output is received. After autoAcceptDelayMs of silence
|
|
1625
|
+
* (without a completion message), sends Enter to accept prompts.
|
|
1626
|
+
*/
|
|
1627
|
+
resetAutoAcceptTimer() {
|
|
1628
|
+
if (!this.config.autoAcceptPrompts)
|
|
1629
|
+
return;
|
|
1630
|
+
this.startAutoAcceptTimer();
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Start the auto-accept timer.
|
|
1634
|
+
* Fires after autoAcceptDelayMs of no output when no completion message
|
|
1635
|
+
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
|
1636
|
+
*/
|
|
1637
|
+
startAutoAcceptTimer() {
|
|
1638
|
+
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting');
|
|
1639
|
+
this.autoAcceptTimer = null;
|
|
1640
|
+
this.autoAcceptTimer = this.startTrackedTimer('auto-accept', this.config.autoAcceptDelayMs, () => {
|
|
1641
|
+
this.autoAcceptTimer = null;
|
|
1642
|
+
this.tryAutoAccept();
|
|
1643
|
+
}, 'plan mode detection');
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Cancel the auto-accept timer.
|
|
1647
|
+
* Called when a completion message is detected (normal idle flow handles it).
|
|
1648
|
+
*/
|
|
1649
|
+
cancelAutoAcceptTimer() {
|
|
1650
|
+
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled');
|
|
1651
|
+
this.autoAcceptTimer = null;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Attempt to auto-accept a plan mode prompt by sending Enter.
|
|
1655
|
+
* Two-stage gate:
|
|
1656
|
+
* 1. Strict regex pre-filter — check if terminal buffer contains plan mode UI elements
|
|
1657
|
+
* 2. AI confirmation — spawn Opus to classify buffer as PLAN_MODE or NOT_PLAN_MODE
|
|
1658
|
+
*
|
|
1659
|
+
* Only sends Enter if both stages confirm (or pre-filter only if AI disabled).
|
|
1660
|
+
*
|
|
1661
|
+
* @fires autoAcceptSent
|
|
1662
|
+
* @fires planCheckStarted
|
|
1663
|
+
*/
|
|
1664
|
+
tryAutoAccept() {
|
|
1665
|
+
// Only auto-accept in watching state (not during a respawn cycle)
|
|
1666
|
+
if (this._state !== 'watching')
|
|
1667
|
+
return;
|
|
1668
|
+
// Don't auto-accept if a completion message was detected (normal idle handles it)
|
|
1669
|
+
if (this.completionMessageTime !== null)
|
|
1670
|
+
return;
|
|
1671
|
+
// Don't auto-accept if disabled
|
|
1672
|
+
if (!this.config.autoAcceptPrompts)
|
|
1673
|
+
return;
|
|
1674
|
+
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
|
|
1675
|
+
if (!this.hasReceivedOutput)
|
|
1676
|
+
return;
|
|
1677
|
+
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
|
|
1678
|
+
if (this.elicitationDetected) {
|
|
1679
|
+
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
// Stage 1: Pre-filter — check if buffer looks like plan mode
|
|
1683
|
+
const buffer = this.terminalBuffer.value;
|
|
1684
|
+
if (!this.isPlanModePreFilterMatch(buffer)) {
|
|
1685
|
+
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
// Stage 2: AI confirmation (if enabled and available)
|
|
1689
|
+
if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') {
|
|
1690
|
+
if (this.planChecker.isOnCooldown()) {
|
|
1691
|
+
this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`);
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (this.planChecker.status === 'checking') {
|
|
1695
|
+
this.log('Skipping auto-accept: plan check already in progress');
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
// Start async AI plan check
|
|
1699
|
+
this.startPlanCheck(buffer);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
// AI plan check disabled — pre-filter passed, send Enter directly
|
|
1703
|
+
this.sendAutoAcceptEnter();
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Check if the terminal buffer matches plan mode pre-filter patterns.
|
|
1707
|
+
* Only checks the last 2000 chars (plan mode UI appears at the bottom).
|
|
1708
|
+
*
|
|
1709
|
+
* Must find:
|
|
1710
|
+
* - Numbered option pattern (e.g., "1. Yes", "2. No")
|
|
1711
|
+
* - Selection indicator (❯ or > followed by number)
|
|
1712
|
+
* Must NOT find:
|
|
1713
|
+
* - Recent working patterns (spinners, "Thinking", etc.) in the tail
|
|
1714
|
+
*/
|
|
1715
|
+
isPlanModePreFilterMatch(buffer) {
|
|
1716
|
+
// Only check the last 2000 chars (plan mode UI is at the bottom)
|
|
1717
|
+
const tail = buffer.slice(-2000);
|
|
1718
|
+
// Strip ANSI codes for pattern matching
|
|
1719
|
+
ANSI_ESCAPE_PATTERN_SIMPLE.lastIndex = 0;
|
|
1720
|
+
const stripped = tail.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
|
|
1721
|
+
// Must find numbered option pattern
|
|
1722
|
+
if (!PLAN_MODE_OPTION_PATTERN.test(stripped))
|
|
1723
|
+
return false;
|
|
1724
|
+
// Must find selection indicator
|
|
1725
|
+
const selectorMatch = stripped.match(PLAN_MODE_SELECTOR_PATTERN);
|
|
1726
|
+
if (!selectorMatch)
|
|
1727
|
+
return false;
|
|
1728
|
+
// Must NOT have working patterns AFTER the selector position.
|
|
1729
|
+
// Working patterns before the selector are from earlier work and don't matter.
|
|
1730
|
+
const selectorIndex = stripped.lastIndexOf(selectorMatch[0]);
|
|
1731
|
+
const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length);
|
|
1732
|
+
const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern));
|
|
1733
|
+
if (hasWorking)
|
|
1734
|
+
return false;
|
|
1735
|
+
return true;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Start an AI plan check to confirm plan mode before auto-accepting.
|
|
1739
|
+
* Async — result handled by then/catch.
|
|
1740
|
+
*
|
|
1741
|
+
* @param buffer - Terminal buffer to analyze
|
|
1742
|
+
* @fires planCheckStarted
|
|
1743
|
+
* @fires planCheckCompleted
|
|
1744
|
+
* @fires planCheckFailed
|
|
1745
|
+
*/
|
|
1746
|
+
startPlanCheck(buffer) {
|
|
1747
|
+
this.planCheckStartTime = Date.now();
|
|
1748
|
+
this.logAction('plan-check', 'Spawning AI plan checker');
|
|
1749
|
+
this.emit('planCheckStarted');
|
|
1750
|
+
this.planChecker
|
|
1751
|
+
.check(buffer)
|
|
1752
|
+
.then((result) => {
|
|
1753
|
+
// Discard stale result if new output arrived during check
|
|
1754
|
+
if (this.lastOutputTime > this.planCheckStartTime) {
|
|
1755
|
+
this.logAction('plan-check', 'Result discarded (output arrived during check)');
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (result.verdict === 'PLAN_MODE') {
|
|
1759
|
+
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
|
|
1760
|
+
if (this._state !== 'watching') {
|
|
1761
|
+
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
this.emit('planCheckCompleted', result);
|
|
1765
|
+
this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately');
|
|
1766
|
+
this.sendAutoAcceptEnter();
|
|
1767
|
+
// No cooldown needed - we're taking action
|
|
1768
|
+
}
|
|
1769
|
+
else if (result.verdict === 'NOT_PLAN_MODE') {
|
|
1770
|
+
this.emit('planCheckCompleted', result);
|
|
1771
|
+
this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`);
|
|
1772
|
+
}
|
|
1773
|
+
else {
|
|
1774
|
+
// ERROR verdict
|
|
1775
|
+
this.emit('planCheckFailed', result.reasoning);
|
|
1776
|
+
this.logAction('plan-check', `Error: ${result.reasoning}`);
|
|
1777
|
+
}
|
|
1778
|
+
})
|
|
1779
|
+
.catch((err) => {
|
|
1780
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1781
|
+
this.emit('planCheckFailed', errorMsg);
|
|
1782
|
+
this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`);
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Send the actual Enter keystroke for auto-accept.
|
|
1787
|
+
* Factored out so both pre-filter-only and AI-confirmed paths can call it.
|
|
1788
|
+
* @fires autoAcceptSent
|
|
1789
|
+
*/
|
|
1790
|
+
sendAutoAcceptEnter() {
|
|
1791
|
+
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
1792
|
+
this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)`);
|
|
1793
|
+
// Cancel any pending AI idle checks - we're about to make Claude work
|
|
1794
|
+
if (this.aiChecker.status === 'checking') {
|
|
1795
|
+
this.log('Cancelling AI idle check before auto-accept');
|
|
1796
|
+
this.aiChecker.cancel();
|
|
1797
|
+
}
|
|
1798
|
+
// Cancel completion confirmation - auto-accept takes precedence
|
|
1799
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept');
|
|
1800
|
+
this.completionConfirmTimer = null;
|
|
1801
|
+
this.completionMessageTime = null;
|
|
1802
|
+
// Ensure we're in watching state (not confirming_idle or ai_checking)
|
|
1803
|
+
if (this._state !== 'watching') {
|
|
1804
|
+
this.setState('watching');
|
|
1805
|
+
}
|
|
1806
|
+
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
|
|
1807
|
+
this.emit('stepSent', 'auto-accept', '↵');
|
|
1808
|
+
void this.session.writeViaMux('\r');
|
|
1809
|
+
this.emit('autoAcceptSent');
|
|
1810
|
+
// Reset so we don't keep spamming Enter if Claude doesn't respond
|
|
1811
|
+
this.hasReceivedOutput = false;
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
|
|
1815
|
+
* This prevents auto-accept from firing, since the user needs to make a selection.
|
|
1816
|
+
* The flag is cleared when working patterns are detected (new turn starts).
|
|
1817
|
+
*/
|
|
1818
|
+
signalElicitation() {
|
|
1819
|
+
this.elicitationDetected = true;
|
|
1820
|
+
this.cancelAutoAcceptTimer();
|
|
1821
|
+
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Signal that a Stop hook was received from Claude Code.
|
|
1825
|
+
* This is a DEFINITIVE signal that Claude has finished responding.
|
|
1826
|
+
* Skips AI idle check and uses a short confirmation period to handle race conditions.
|
|
1827
|
+
*
|
|
1828
|
+
* @fires log
|
|
1829
|
+
*/
|
|
1830
|
+
signalStopHook() {
|
|
1831
|
+
// Only process in states where we're watching for idle
|
|
1832
|
+
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
|
1833
|
+
this.log(`Stop hook received but ignoring (state is ${this._state})`);
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
const now = Date.now();
|
|
1837
|
+
this.stopHookReceived = true;
|
|
1838
|
+
this.stopHookTime = now;
|
|
1839
|
+
this.logAction('hook', 'Stop hook received - definitive idle signal');
|
|
1840
|
+
this.log('Stop hook received from Claude Code - definitive idle signal');
|
|
1841
|
+
// Cancel any running AI check - we have a definitive signal
|
|
1842
|
+
if (this._state === 'ai_checking') {
|
|
1843
|
+
this.log('Cancelling AI check - Stop hook is definitive');
|
|
1844
|
+
this.aiChecker.cancel();
|
|
1845
|
+
}
|
|
1846
|
+
// Cancel completion confirm timer - hook takes precedence
|
|
1847
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received');
|
|
1848
|
+
this.completionConfirmTimer = null;
|
|
1849
|
+
// Cancel pre-filter timer - hook takes precedence
|
|
1850
|
+
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received');
|
|
1851
|
+
this.preFilterTimer = null;
|
|
1852
|
+
// Start short confirmation timer to handle race conditions
|
|
1853
|
+
// (e.g., Stop hook arrives but Claude immediately starts new work)
|
|
1854
|
+
this.startHookConfirmTimer('stop');
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Signal that an idle_prompt notification was received from Claude Code.
|
|
1858
|
+
* This fires after 60+ seconds of Claude waiting for user input.
|
|
1859
|
+
* This is a DEFINITIVE signal that Claude is idle.
|
|
1860
|
+
*
|
|
1861
|
+
* @fires log
|
|
1862
|
+
*/
|
|
1863
|
+
signalIdlePrompt() {
|
|
1864
|
+
// Only process in states where we're watching for idle
|
|
1865
|
+
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
|
1866
|
+
this.log(`idle_prompt received but ignoring (state is ${this._state})`);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const now = Date.now();
|
|
1870
|
+
this.idlePromptReceived = true;
|
|
1871
|
+
this.idlePromptTime = now;
|
|
1872
|
+
this.logAction('hook', 'idle_prompt received - 60s+ idle confirmed');
|
|
1873
|
+
this.log('idle_prompt notification received - Claude has been idle for 60+ seconds');
|
|
1874
|
+
// Cancel any running AI check - we have a definitive signal
|
|
1875
|
+
if (this._state === 'ai_checking') {
|
|
1876
|
+
this.log('Cancelling AI check - idle_prompt is definitive');
|
|
1877
|
+
this.aiChecker.cancel();
|
|
1878
|
+
}
|
|
1879
|
+
// Cancel all other detection timers - this is definitive
|
|
1880
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received');
|
|
1881
|
+
this.completionConfirmTimer = null;
|
|
1882
|
+
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received');
|
|
1883
|
+
this.preFilterTimer = null;
|
|
1884
|
+
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received');
|
|
1885
|
+
this.noOutputTimer = null;
|
|
1886
|
+
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
|
|
1887
|
+
// Skip confirmation and go directly to idle
|
|
1888
|
+
this.onIdleConfirmed('idle_prompt hook (60s+ idle)');
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Start a short confirmation timer after receiving a hook signal.
|
|
1892
|
+
* This handles race conditions where a hook arrives but Claude immediately starts new work.
|
|
1893
|
+
*
|
|
1894
|
+
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
|
|
1895
|
+
*/
|
|
1896
|
+
startHookConfirmTimer(hookType) {
|
|
1897
|
+
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting');
|
|
1898
|
+
this.hookConfirmTimer = null;
|
|
1899
|
+
this.hookConfirmTimer = this.startTrackedTimer('hook-confirm', RespawnController.HOOK_CONFIRM_DELAY_MS, () => {
|
|
1900
|
+
this.hookConfirmTimer = null;
|
|
1901
|
+
// Verify we haven't received new output since the hook arrived
|
|
1902
|
+
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
|
|
1903
|
+
if (hookTime && this.lastOutputTime > hookTime) {
|
|
1904
|
+
// Output arrived after hook - Claude started new work
|
|
1905
|
+
this.log(`Output received after ${hookType} hook, cancelling idle confirmation`);
|
|
1906
|
+
this.logAction('hook', `${hookType} cancelled - new output detected`);
|
|
1907
|
+
// Set state before resetting flags so event handlers see consistent state
|
|
1908
|
+
this.setState('watching');
|
|
1909
|
+
this.resetHookState();
|
|
1910
|
+
this.startNoOutputTimer();
|
|
1911
|
+
this.startPreFilterTimer();
|
|
1912
|
+
if (this.config.autoAcceptPrompts) {
|
|
1913
|
+
this.startAutoAcceptTimer();
|
|
1914
|
+
}
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
// No new output - confirm idle via hook signal
|
|
1918
|
+
this.logAction('hook', `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`);
|
|
1919
|
+
this.onIdleConfirmed(`${hookType} hook (confirmed)`);
|
|
1920
|
+
}, `confirming ${hookType} hook`);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Reset hook-based detection state.
|
|
1924
|
+
* Called when hooks are cancelled due to new activity.
|
|
1925
|
+
*/
|
|
1926
|
+
resetHookState() {
|
|
1927
|
+
this.stopHookReceived = false;
|
|
1928
|
+
this.stopHookTime = null;
|
|
1929
|
+
this.idlePromptReceived = false;
|
|
1930
|
+
this.idlePromptTime = null;
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Signal that the transcript indicates completion.
|
|
1934
|
+
* This is a supporting signal from transcript file monitoring.
|
|
1935
|
+
* Unlike hooks, this doesn't immediately trigger idle - it boosts confidence.
|
|
1936
|
+
*/
|
|
1937
|
+
signalTranscriptComplete() {
|
|
1938
|
+
// Transcript completion is a supporting signal, not definitive
|
|
1939
|
+
// It can help reduce the confirmation time needed
|
|
1940
|
+
if (this._state === 'watching') {
|
|
1941
|
+
this.logAction('transcript', 'Transcript shows completion - boosting confidence');
|
|
1942
|
+
this.log('Transcript completion detected - may accelerate idle detection');
|
|
1943
|
+
// If we have a completion message and transcript confirms, try AI check
|
|
1944
|
+
if (this.completionMessageTime !== null) {
|
|
1945
|
+
this.tryStartAiCheck('transcript + completion message');
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Signal that the transcript indicates plan mode.
|
|
1951
|
+
* This helps prevent auto-accept from triggering on AskUserQuestion.
|
|
1952
|
+
*/
|
|
1953
|
+
signalTranscriptPlanMode() {
|
|
1954
|
+
// Plan mode from transcript = potential AskUserQuestion
|
|
1955
|
+
// This is similar to elicitation detection
|
|
1956
|
+
if (this._state === 'watching') {
|
|
1957
|
+
this.logAction('transcript', 'Plan mode / AskUserQuestion detected');
|
|
1958
|
+
this.cancelAutoAcceptTimer();
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Start completion confirmation timer.
|
|
1963
|
+
* After completion message, waits for output silence then triggers AI check.
|
|
1964
|
+
*/
|
|
1965
|
+
startCompletionConfirmTimer() {
|
|
1966
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting');
|
|
1967
|
+
this.completionConfirmTimer = null;
|
|
1968
|
+
this.setState('confirming_idle');
|
|
1969
|
+
this.logAction('detection', 'Completion message found in output');
|
|
1970
|
+
this.completionConfirmTimer = this.startTrackedTimer('completion-confirm', this.config.completionConfirmMs, () => {
|
|
1971
|
+
this.completionConfirmTimer = null;
|
|
1972
|
+
if (this._state === 'stopped')
|
|
1973
|
+
return;
|
|
1974
|
+
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
1975
|
+
if (msSinceOutput >= this.config.completionConfirmMs) {
|
|
1976
|
+
this.logAction('detection', `Silence confirmed: ${Math.round(msSinceOutput / 1000)}s`);
|
|
1977
|
+
this.tryStartAiCheck('completion + silence');
|
|
1978
|
+
}
|
|
1979
|
+
else {
|
|
1980
|
+
// Output received during wait, stay in confirming state and re-check
|
|
1981
|
+
this.logAction('detection', 'Output during confirmation, resetting');
|
|
1982
|
+
if (this._state !== 'confirming_idle')
|
|
1983
|
+
return;
|
|
1984
|
+
this.startCompletionConfirmTimer();
|
|
1985
|
+
}
|
|
1986
|
+
}, 'waiting for silence after completion');
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Cancel completion confirmation if new activity detected.
|
|
1990
|
+
*/
|
|
1991
|
+
cancelCompletionConfirm() {
|
|
1992
|
+
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected');
|
|
1993
|
+
this.completionConfirmTimer = null;
|
|
1994
|
+
if (this._state === 'confirming_idle') {
|
|
1995
|
+
this.setState('watching');
|
|
1996
|
+
this.completionMessageTime = null;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Start step confirmation timer for waiting states.
|
|
2001
|
+
* Waits for output silence before proceeding to next step.
|
|
2002
|
+
* This ensures Claude has finished processing before we send the next command.
|
|
2003
|
+
*/
|
|
2004
|
+
startStepConfirmTimer(step) {
|
|
2005
|
+
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting');
|
|
2006
|
+
this.stepConfirmTimer = null;
|
|
2007
|
+
this.stepConfirmTimer = this.startTrackedTimer('step-confirm', this.config.completionConfirmMs, () => {
|
|
2008
|
+
this.stepConfirmTimer = null;
|
|
2009
|
+
if (this._state === 'stopped')
|
|
2010
|
+
return;
|
|
2011
|
+
const msSinceOutput = Date.now() - this.lastOutputTime;
|
|
2012
|
+
if (msSinceOutput >= this.config.completionConfirmMs) {
|
|
2013
|
+
this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`);
|
|
2014
|
+
// Proceed with the step completion
|
|
2015
|
+
switch (step) {
|
|
2016
|
+
case 'update':
|
|
2017
|
+
this.checkUpdateComplete();
|
|
2018
|
+
break;
|
|
2019
|
+
case 'init':
|
|
2020
|
+
this.checkInitComplete();
|
|
2021
|
+
break;
|
|
2022
|
+
case 'kickstart':
|
|
2023
|
+
this.checkKickstartComplete();
|
|
2024
|
+
break;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
else {
|
|
2028
|
+
// Output received during wait, restart timer
|
|
2029
|
+
this.logAction('step', `Output during ${step} confirmation, resetting`);
|
|
2030
|
+
this.startStepConfirmTimer(step);
|
|
2031
|
+
}
|
|
2032
|
+
}, `confirming ${step} completion`);
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Cancel step confirmation if working patterns detected.
|
|
2036
|
+
*/
|
|
2037
|
+
cancelStepConfirm() {
|
|
2038
|
+
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected');
|
|
2039
|
+
this.stepConfirmTimer = null;
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Called when idle is confirmed through any detection layer.
|
|
2043
|
+
* @param reason - What triggered the confirmation
|
|
2044
|
+
*/
|
|
2045
|
+
onIdleConfirmed(reason) {
|
|
2046
|
+
// Safety check: if Session thinks it's still working, don't trigger idle
|
|
2047
|
+
// This catches cases where our detection missed working patterns
|
|
2048
|
+
if (this.session.isWorking) {
|
|
2049
|
+
this.log(`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`);
|
|
2050
|
+
this.logAction('detection', 'Rejected: Session still working');
|
|
2051
|
+
this.setState('watching');
|
|
2052
|
+
this.startNoOutputTimer();
|
|
2053
|
+
this.startPreFilterTimer();
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
this.log(`Idle confirmed via: ${reason}`);
|
|
2057
|
+
const status = this.getDetectionStatus();
|
|
2058
|
+
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
|
2059
|
+
`completion=${status.completionMessageDetected}, ` +
|
|
2060
|
+
`silent=${status.outputSilent}, ` +
|
|
2061
|
+
`tokensStable=${status.tokensStable}, ` +
|
|
2062
|
+
`noWorking=${status.workingPatternsAbsent}`);
|
|
2063
|
+
// ========== Agent Teams Integration ==========
|
|
2064
|
+
// Check if session has active teammates — don't respawn while team is working
|
|
2065
|
+
if (this.teamWatcher?.hasActiveTeammates(this.session.id)) {
|
|
2066
|
+
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
|
2067
|
+
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
|
2068
|
+
this.logAction('team', `Active teammates: ${count}`);
|
|
2069
|
+
this.emit('respawnBlocked', {
|
|
2070
|
+
reason: 'active_teammates',
|
|
2071
|
+
details: `${count} teammate(s) still working`,
|
|
2072
|
+
});
|
|
2073
|
+
this.setState('watching');
|
|
2074
|
+
this.startNoOutputTimer();
|
|
2075
|
+
this.startPreFilterTimer();
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
// ========== RALPH_STATUS Integration ==========
|
|
2079
|
+
// Check circuit breaker status - if OPEN, pause respawn
|
|
2080
|
+
const ralphTracker = this.session.ralphTracker;
|
|
2081
|
+
if (ralphTracker) {
|
|
2082
|
+
const circuitBreaker = ralphTracker.circuitBreakerStatus;
|
|
2083
|
+
if (circuitBreaker.state === 'OPEN') {
|
|
2084
|
+
this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
|
2085
|
+
this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`);
|
|
2086
|
+
this.emit('respawnBlocked', {
|
|
2087
|
+
reason: 'circuit_breaker_open',
|
|
2088
|
+
details: circuitBreaker.reason,
|
|
2089
|
+
});
|
|
2090
|
+
this.setState('watching');
|
|
2091
|
+
// Don't restart timers - wait for manual reset or circuit breaker resolution
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
// Check RALPH_STATUS EXIT_SIGNAL - if true, loop is complete
|
|
2096
|
+
const statusBlock = ralphTracker?.lastStatusBlock;
|
|
2097
|
+
if (statusBlock?.exitSignal) {
|
|
2098
|
+
this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`);
|
|
2099
|
+
this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`);
|
|
2100
|
+
this.emit('respawnBlocked', {
|
|
2101
|
+
reason: 'exit_signal',
|
|
2102
|
+
details: statusBlock.recommendation || 'Task complete',
|
|
2103
|
+
});
|
|
2104
|
+
this.setState('watching');
|
|
2105
|
+
// Don't restart timers - loop is complete
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
// Check if STATUS=BLOCKED - trigger circuit breaker
|
|
2109
|
+
if (statusBlock?.status === 'BLOCKED') {
|
|
2110
|
+
this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`);
|
|
2111
|
+
this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`);
|
|
2112
|
+
this.emit('respawnBlocked', {
|
|
2113
|
+
reason: 'status_blocked',
|
|
2114
|
+
details: statusBlock.recommendation || 'Needs human intervention',
|
|
2115
|
+
});
|
|
2116
|
+
this.setState('watching');
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
// Reset detection state
|
|
2120
|
+
this.completionMessageTime = null;
|
|
2121
|
+
this.cancelCompletionConfirm();
|
|
2122
|
+
// Trigger the respawn cycle
|
|
2123
|
+
this.onIdleDetected();
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Handle confirmed idle detection.
|
|
2127
|
+
* Starts a new respawn cycle.
|
|
2128
|
+
* @fires respawnCycleStarted
|
|
2129
|
+
*/
|
|
2130
|
+
onIdleDetected() {
|
|
2131
|
+
// Accept watching, confirming_idle, and ai_checking states
|
|
2132
|
+
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
// Clear all detection timers before starting cycle to prevent stale callbacks
|
|
2136
|
+
this.clearTimers();
|
|
2137
|
+
// P1-006: Session health check before respawn cycle
|
|
2138
|
+
// Skip if session is in error state or not running
|
|
2139
|
+
if (this.session.status === 'error') {
|
|
2140
|
+
this.log('Skipping respawn cycle - session is in error state');
|
|
2141
|
+
this.logAction('health', 'Respawn skipped: Session error state');
|
|
2142
|
+
this.emit('respawnBlocked', {
|
|
2143
|
+
reason: 'session_error',
|
|
2144
|
+
details: 'Session is in error state',
|
|
2145
|
+
});
|
|
2146
|
+
this.setState('watching');
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
if (this.session.status === 'stopped') {
|
|
2150
|
+
this.log('Skipping respawn cycle - session is stopped');
|
|
2151
|
+
this.logAction('health', 'Respawn skipped: Session stopped');
|
|
2152
|
+
this.emit('respawnBlocked', { reason: 'session_stopped', details: 'Session is stopped' });
|
|
2153
|
+
this.setState('watching');
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
// Check if session PTY is still alive (via PID)
|
|
2157
|
+
if (!this.session.pid) {
|
|
2158
|
+
this.log('Skipping respawn cycle - session PTY not running (no PID)');
|
|
2159
|
+
this.logAction('health', 'Respawn skipped: No PTY process');
|
|
2160
|
+
this.emit('respawnBlocked', { reason: 'no_pty', details: 'Session PTY process not running' });
|
|
2161
|
+
this.setState('watching');
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
// Start the respawn cycle
|
|
2165
|
+
this.cycleCount++;
|
|
2166
|
+
this.log(`Starting respawn cycle #${this.cycleCount}`);
|
|
2167
|
+
this.emit('respawnCycleStarted', this.cycleCount);
|
|
2168
|
+
// P2-004: Start tracking cycle metrics
|
|
2169
|
+
this.startCycleMetrics('idle_confirmed');
|
|
2170
|
+
this.sendUpdateDocs();
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Send the update docs prompt (first step of cycle).
|
|
2174
|
+
* Uses RALPH_STATUS RECOMMENDATION if available, otherwise falls back to configured prompt.
|
|
2175
|
+
* @fires stepSent - With step 'update'
|
|
2176
|
+
*/
|
|
2177
|
+
sendUpdateDocs() {
|
|
2178
|
+
this.setState('sending_update');
|
|
2179
|
+
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
|
2180
|
+
this.clearWorkingPatternWindow(); // Clear rolling window
|
|
2181
|
+
this.stepTimer = this.startTrackedTimer('step-delay', this.config.interStepDelayMs, async () => {
|
|
2182
|
+
this.stepTimer = null;
|
|
2183
|
+
if (this._state === 'stopped')
|
|
2184
|
+
return;
|
|
2185
|
+
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
|
2186
|
+
const statusBlock = this.session.ralphTracker?.lastStatusBlock;
|
|
2187
|
+
let updatePrompt = this.config.updatePrompt;
|
|
2188
|
+
if (statusBlock?.recommendation) {
|
|
2189
|
+
// Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines)
|
|
2190
|
+
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
|
|
2191
|
+
updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`;
|
|
2192
|
+
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
|
2193
|
+
}
|
|
2194
|
+
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
|
2195
|
+
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
|
|
2196
|
+
await this.session.writeViaMux(input);
|
|
2197
|
+
this.emit('stepSent', 'update', updatePrompt);
|
|
2198
|
+
this.setState('waiting_update');
|
|
2199
|
+
this.promptDetected = false;
|
|
2200
|
+
this.workingDetected = false;
|
|
2201
|
+
}, 'delay before update prompt');
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Send /clear command.
|
|
2205
|
+
* Starts a 10-second fallback timer - if no prompt is detected after /clear,
|
|
2206
|
+
* proceeds to /init anyway (workaround for when Claude doesn't show prompt after /clear).
|
|
2207
|
+
* @fires stepSent - With step 'clear'
|
|
2208
|
+
*/
|
|
2209
|
+
sendClear() {
|
|
2210
|
+
this.setState('sending_clear');
|
|
2211
|
+
this.terminalBuffer.clear();
|
|
2212
|
+
this.clearWorkingPatternWindow();
|
|
2213
|
+
this.stepTimer = this.startTrackedTimer('step-delay', this.config.interStepDelayMs, async () => {
|
|
2214
|
+
this.stepTimer = null;
|
|
2215
|
+
if (this._state === 'stopped')
|
|
2216
|
+
return;
|
|
2217
|
+
this.logAction('command', 'Sending: /clear');
|
|
2218
|
+
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
|
2219
|
+
this.emit('stepSent', 'clear', '/clear');
|
|
2220
|
+
this.setState('waiting_clear');
|
|
2221
|
+
this.promptDetected = false;
|
|
2222
|
+
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
|
2223
|
+
this.clearFallbackTimer = this.startTrackedTimer('clear-fallback', RespawnController.CLEAR_FALLBACK_TIMEOUT_MS, () => {
|
|
2224
|
+
this.clearFallbackTimer = null;
|
|
2225
|
+
if (this._state === 'waiting_clear') {
|
|
2226
|
+
this.logAction('step', '/clear fallback: proceeding to /init');
|
|
2227
|
+
this.emit('stepCompleted', 'clear');
|
|
2228
|
+
if (this.config.sendInit) {
|
|
2229
|
+
this.sendInit();
|
|
2230
|
+
}
|
|
2231
|
+
else {
|
|
2232
|
+
this.completeCycle();
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}, 'fallback if no prompt after /clear');
|
|
2236
|
+
}, 'delay before /clear');
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* Send /init command.
|
|
2240
|
+
* @fires stepSent - With step 'init'
|
|
2241
|
+
*/
|
|
2242
|
+
sendInit() {
|
|
2243
|
+
this.setState('sending_init');
|
|
2244
|
+
this.terminalBuffer.clear();
|
|
2245
|
+
this.clearWorkingPatternWindow();
|
|
2246
|
+
this.stepTimer = this.startTrackedTimer('step-delay', this.config.interStepDelayMs, async () => {
|
|
2247
|
+
this.stepTimer = null;
|
|
2248
|
+
if (this._state === 'stopped')
|
|
2249
|
+
return;
|
|
2250
|
+
this.logAction('command', 'Sending: /init');
|
|
2251
|
+
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
|
2252
|
+
this.emit('stepSent', 'init', '/init');
|
|
2253
|
+
this.setState('waiting_init');
|
|
2254
|
+
this.promptDetected = false;
|
|
2255
|
+
this.workingDetected = false;
|
|
2256
|
+
}, 'delay before /init');
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Complete the current respawn cycle.
|
|
2260
|
+
* Returns to watching state for next cycle.
|
|
2261
|
+
* @fires respawnCycleCompleted
|
|
2262
|
+
*/
|
|
2263
|
+
completeCycle() {
|
|
2264
|
+
this.log(`Respawn cycle #${this.cycleCount} completed`);
|
|
2265
|
+
this.emit('respawnCycleCompleted', this.cycleCount);
|
|
2266
|
+
// P2-004: Complete cycle metrics with success outcome
|
|
2267
|
+
this.completeCycleMetrics('success');
|
|
2268
|
+
// Go back to watching state for next cycle
|
|
2269
|
+
this.setState('watching');
|
|
2270
|
+
this.terminalBuffer.clear();
|
|
2271
|
+
this.clearWorkingPatternWindow(); // Clear rolling window for fresh detection
|
|
2272
|
+
this.promptDetected = false;
|
|
2273
|
+
this.workingDetected = false;
|
|
2274
|
+
this.resetHookState(); // Clear hook signals for next cycle
|
|
2275
|
+
// P2-001: Reset idle detection start time for next cycle
|
|
2276
|
+
this.idleDetectionStartTime = Date.now();
|
|
2277
|
+
// Restart detection timers for next cycle
|
|
2278
|
+
this.startNoOutputTimer();
|
|
2279
|
+
this.startPreFilterTimer();
|
|
2280
|
+
if (this.config.autoAcceptPrompts) {
|
|
2281
|
+
this.startAutoAcceptTimer();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Check if already idle and start cycle if so.
|
|
2286
|
+
* Used when resuming from pause.
|
|
2287
|
+
*/
|
|
2288
|
+
checkIdleAndMaybeStart() {
|
|
2289
|
+
if (this._state === 'stopped')
|
|
2290
|
+
return;
|
|
2291
|
+
// Check if already idle
|
|
2292
|
+
const timeSinceActivity = Date.now() - this.lastActivityTime;
|
|
2293
|
+
if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) {
|
|
2294
|
+
this.onIdleDetected();
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Update configuration at runtime.
|
|
2299
|
+
*
|
|
2300
|
+
* Merges provided config with existing config.
|
|
2301
|
+
* Takes effect immediately for new operations.
|
|
2302
|
+
*
|
|
2303
|
+
* @param config - Partial configuration to merge
|
|
2304
|
+
* @fires log - With updated config details
|
|
2305
|
+
*/
|
|
2306
|
+
updateConfig(config) {
|
|
2307
|
+
// Filter out undefined values to prevent overwriting existing config with undefined
|
|
2308
|
+
const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined));
|
|
2309
|
+
this.config = { ...this.config, ...filteredConfig };
|
|
2310
|
+
// Sync AI checker config if relevant fields changed
|
|
2311
|
+
if (config.aiIdleCheckEnabled !== undefined ||
|
|
2312
|
+
config.aiIdleCheckModel !== undefined ||
|
|
2313
|
+
config.aiIdleCheckMaxContext !== undefined ||
|
|
2314
|
+
config.aiIdleCheckTimeoutMs !== undefined ||
|
|
2315
|
+
config.aiIdleCheckCooldownMs !== undefined) {
|
|
2316
|
+
this.aiChecker.updateConfig({
|
|
2317
|
+
enabled: this.config.aiIdleCheckEnabled,
|
|
2318
|
+
model: this.config.aiIdleCheckModel,
|
|
2319
|
+
maxContextChars: this.config.aiIdleCheckMaxContext,
|
|
2320
|
+
checkTimeoutMs: this.config.aiIdleCheckTimeoutMs,
|
|
2321
|
+
cooldownMs: this.config.aiIdleCheckCooldownMs,
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
// Sync plan checker config if relevant fields changed
|
|
2325
|
+
if (config.aiPlanCheckEnabled !== undefined ||
|
|
2326
|
+
config.aiPlanCheckModel !== undefined ||
|
|
2327
|
+
config.aiPlanCheckMaxContext !== undefined ||
|
|
2328
|
+
config.aiPlanCheckTimeoutMs !== undefined ||
|
|
2329
|
+
config.aiPlanCheckCooldownMs !== undefined) {
|
|
2330
|
+
this.planChecker.updateConfig({
|
|
2331
|
+
enabled: this.config.aiPlanCheckEnabled,
|
|
2332
|
+
model: this.config.aiPlanCheckModel,
|
|
2333
|
+
maxContextChars: this.config.aiPlanCheckMaxContext,
|
|
2334
|
+
checkTimeoutMs: this.config.aiPlanCheckTimeoutMs,
|
|
2335
|
+
cooldownMs: this.config.aiPlanCheckCooldownMs,
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
this.log(`Config updated: ${JSON.stringify(config)}`);
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Get current configuration.
|
|
2342
|
+
* @returns Copy of current config (safe to modify)
|
|
2343
|
+
*/
|
|
2344
|
+
getConfig() {
|
|
2345
|
+
return { ...this.config };
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Get comprehensive status information.
|
|
2349
|
+
*
|
|
2350
|
+
* Useful for debugging and monitoring.
|
|
2351
|
+
*
|
|
2352
|
+
* @returns Status object with:
|
|
2353
|
+
* - state: Current state machine state
|
|
2354
|
+
* - cycleCount: Number of cycles started
|
|
2355
|
+
* - lastActivityTime: Timestamp of last activity
|
|
2356
|
+
* - timeSinceActivity: Milliseconds since last activity
|
|
2357
|
+
* - promptDetected: Whether prompt indicator seen
|
|
2358
|
+
* - workingDetected: Whether working indicator seen
|
|
2359
|
+
* - detection: Multi-layer detection status
|
|
2360
|
+
* - config: Current configuration
|
|
2361
|
+
*/
|
|
2362
|
+
getStatus() {
|
|
2363
|
+
return {
|
|
2364
|
+
state: this._state,
|
|
2365
|
+
cycleCount: this.cycleCount,
|
|
2366
|
+
lastActivityTime: this.lastActivityTime,
|
|
2367
|
+
timeSinceActivity: Date.now() - this.lastActivityTime,
|
|
2368
|
+
promptDetected: this.promptDetected,
|
|
2369
|
+
workingDetected: this.workingDetected,
|
|
2370
|
+
detection: this.getDetectionStatus(),
|
|
2371
|
+
config: this.config,
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
// ========== P2-001: Adaptive Timing Methods ==========
|
|
2375
|
+
/**
|
|
2376
|
+
* Get the current completion confirm timeout, potentially adjusted by adaptive timing.
|
|
2377
|
+
* Uses historical idle detection durations to calculate an optimal timeout.
|
|
2378
|
+
*
|
|
2379
|
+
* @returns Completion confirm timeout in milliseconds
|
|
2380
|
+
*/
|
|
2381
|
+
getAdaptiveCompletionConfirmMs() {
|
|
2382
|
+
if (!this.config.adaptiveTimingEnabled) {
|
|
2383
|
+
return this.config.completionConfirmMs ?? 10000;
|
|
2384
|
+
}
|
|
2385
|
+
// Need at least 5 samples before adjusting
|
|
2386
|
+
if (this.timingHistory.sampleCount < 5) {
|
|
2387
|
+
return this.config.completionConfirmMs ?? 10000;
|
|
2388
|
+
}
|
|
2389
|
+
return this.timingHistory.adaptiveCompletionConfirmMs;
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Record timing data from a completed cycle for adaptive adjustments.
|
|
2393
|
+
*
|
|
2394
|
+
* @param idleDetectionMs - Time spent detecting idle
|
|
2395
|
+
* @param cycleDurationMs - Total cycle duration
|
|
2396
|
+
*/
|
|
2397
|
+
recordTimingData(idleDetectionMs, cycleDurationMs) {
|
|
2398
|
+
if (!this.config.adaptiveTimingEnabled)
|
|
2399
|
+
return;
|
|
2400
|
+
const history = this.timingHistory;
|
|
2401
|
+
// Add to rolling windows
|
|
2402
|
+
history.recentIdleDetectionMs.push(idleDetectionMs);
|
|
2403
|
+
history.recentCycleDurationMs.push(cycleDurationMs);
|
|
2404
|
+
// Trim to max samples
|
|
2405
|
+
if (history.recentIdleDetectionMs.length > history.maxSamples) {
|
|
2406
|
+
history.recentIdleDetectionMs.shift();
|
|
2407
|
+
}
|
|
2408
|
+
if (history.recentCycleDurationMs.length > history.maxSamples) {
|
|
2409
|
+
history.recentCycleDurationMs.shift();
|
|
2410
|
+
}
|
|
2411
|
+
history.sampleCount = history.recentIdleDetectionMs.length;
|
|
2412
|
+
history.lastUpdatedAt = Date.now();
|
|
2413
|
+
// Recalculate adaptive timing
|
|
2414
|
+
this.updateAdaptiveTiming();
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Recalculate the adaptive completion confirm timeout based on historical data.
|
|
2418
|
+
* Uses the 75th percentile of recent idle detection times as the new timeout,
|
|
2419
|
+
* with a 20% buffer for safety.
|
|
2420
|
+
*/
|
|
2421
|
+
updateAdaptiveTiming() {
|
|
2422
|
+
const history = this.timingHistory;
|
|
2423
|
+
const minMs = this.config.adaptiveMinConfirmMs ?? 5000;
|
|
2424
|
+
const maxMs = this.config.adaptiveMaxConfirmMs ?? 30000;
|
|
2425
|
+
if (history.recentIdleDetectionMs.length < 5)
|
|
2426
|
+
return;
|
|
2427
|
+
// Sort for percentile calculation
|
|
2428
|
+
const sorted = [...history.recentIdleDetectionMs].sort((a, b) => a - b);
|
|
2429
|
+
// Use 75th percentile with 20% buffer
|
|
2430
|
+
const p75Index = Math.floor(sorted.length * 0.75);
|
|
2431
|
+
const p75Value = sorted[p75Index];
|
|
2432
|
+
const withBuffer = Math.round(p75Value * 1.2);
|
|
2433
|
+
// Clamp to configured bounds
|
|
2434
|
+
const clamped = Math.max(minMs, Math.min(maxMs, withBuffer));
|
|
2435
|
+
history.adaptiveCompletionConfirmMs = clamped;
|
|
2436
|
+
this.log(`Adaptive timing updated: ${clamped}ms (p75=${p75Value}ms, samples=${sorted.length})`);
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Get the current timing history for monitoring.
|
|
2440
|
+
* @returns Copy of timing history
|
|
2441
|
+
*/
|
|
2442
|
+
getTimingHistory() {
|
|
2443
|
+
return { ...this.timingHistory };
|
|
2444
|
+
}
|
|
2445
|
+
// ========== P2-002: Skip-Clear Optimization Methods ==========
|
|
2446
|
+
/**
|
|
2447
|
+
* Determine whether to skip the /clear step based on current context usage.
|
|
2448
|
+
* Skips if token count is below the configured threshold percentage.
|
|
2449
|
+
*
|
|
2450
|
+
* @returns True if /clear should be skipped
|
|
2451
|
+
*/
|
|
2452
|
+
shouldSkipClear() {
|
|
2453
|
+
if (!this.config.skipClearWhenLowContext)
|
|
2454
|
+
return false;
|
|
2455
|
+
const thresholdPercent = this.config.skipClearThresholdPercent ?? 30;
|
|
2456
|
+
const maxContext = 200000; // Approximate max context for Claude
|
|
2457
|
+
// Use the session's token count if available
|
|
2458
|
+
const currentTokens = this.lastTokenCount;
|
|
2459
|
+
if (currentTokens === 0)
|
|
2460
|
+
return false; // Can't determine, don't skip
|
|
2461
|
+
const usagePercent = (currentTokens / maxContext) * 100;
|
|
2462
|
+
if (usagePercent < thresholdPercent) {
|
|
2463
|
+
this.log(`Skip-clear optimization: ${usagePercent.toFixed(1)}% < ${thresholdPercent}% threshold`);
|
|
2464
|
+
this.logAction('optimization', `Skipping /clear (${usagePercent.toFixed(1)}% context used)`);
|
|
2465
|
+
return true;
|
|
2466
|
+
}
|
|
2467
|
+
return false;
|
|
2468
|
+
}
|
|
2469
|
+
// ========== P2-004: Cycle Metrics Methods ==========
|
|
2470
|
+
/**
|
|
2471
|
+
* Start tracking metrics for a new cycle.
|
|
2472
|
+
* Called when a respawn cycle begins.
|
|
2473
|
+
*/
|
|
2474
|
+
startCycleMetrics(idleReason) {
|
|
2475
|
+
if (!this.config.trackCycleMetrics)
|
|
2476
|
+
return;
|
|
2477
|
+
const now = Date.now();
|
|
2478
|
+
this.currentCycleMetrics = {
|
|
2479
|
+
cycleId: `${this.session.id}:${this.cycleCount}`,
|
|
2480
|
+
sessionId: this.session.id,
|
|
2481
|
+
cycleNumber: this.cycleCount,
|
|
2482
|
+
startedAt: now,
|
|
2483
|
+
idleReason,
|
|
2484
|
+
idleDetectionMs: now - this.idleDetectionStartTime,
|
|
2485
|
+
stepsCompleted: [],
|
|
2486
|
+
clearSkipped: false,
|
|
2487
|
+
tokenCountAtStart: this.lastTokenCount,
|
|
2488
|
+
completionConfirmMsUsed: this.getAdaptiveCompletionConfirmMs(),
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Record a completed step in the current cycle.
|
|
2493
|
+
* @param step - Name of the step (e.g., 'update', 'clear', 'init')
|
|
2494
|
+
*/
|
|
2495
|
+
recordCycleStep(step) {
|
|
2496
|
+
if (!this.config.trackCycleMetrics || !this.currentCycleMetrics)
|
|
2497
|
+
return;
|
|
2498
|
+
this.currentCycleMetrics.stepsCompleted?.push(step);
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Complete the current cycle metrics with outcome.
|
|
2502
|
+
* Adds to recent metrics and updates aggregates.
|
|
2503
|
+
*
|
|
2504
|
+
* @param outcome - Outcome of the cycle
|
|
2505
|
+
* @param errorMessage - Optional error message if outcome is 'error'
|
|
2506
|
+
*/
|
|
2507
|
+
completeCycleMetrics(outcome, errorMessage) {
|
|
2508
|
+
if (!this.config.trackCycleMetrics || !this.currentCycleMetrics)
|
|
2509
|
+
return;
|
|
2510
|
+
const now = Date.now();
|
|
2511
|
+
const metrics = {
|
|
2512
|
+
...this.currentCycleMetrics,
|
|
2513
|
+
completedAt: now,
|
|
2514
|
+
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
|
2515
|
+
outcome,
|
|
2516
|
+
errorMessage,
|
|
2517
|
+
tokenCountAtEnd: this.lastTokenCount,
|
|
2518
|
+
};
|
|
2519
|
+
// Add to recent metrics
|
|
2520
|
+
this.recentCycleMetrics.push(metrics);
|
|
2521
|
+
if (this.recentCycleMetrics.length > RespawnController.MAX_CYCLE_METRICS_IN_MEMORY) {
|
|
2522
|
+
this.recentCycleMetrics.shift();
|
|
2523
|
+
}
|
|
2524
|
+
// Record timing data for adaptive timing
|
|
2525
|
+
this.recordTimingData(metrics.idleDetectionMs, metrics.durationMs);
|
|
2526
|
+
// Update aggregate metrics
|
|
2527
|
+
this.updateAggregateMetrics(metrics);
|
|
2528
|
+
// Clear current cycle
|
|
2529
|
+
this.currentCycleMetrics = null;
|
|
2530
|
+
this.log(`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`);
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Update aggregate metrics with a new cycle's data.
|
|
2534
|
+
* @param metrics - The completed cycle metrics
|
|
2535
|
+
*/
|
|
2536
|
+
updateAggregateMetrics(metrics) {
|
|
2537
|
+
const agg = this.aggregateMetrics;
|
|
2538
|
+
agg.totalCycles++;
|
|
2539
|
+
switch (metrics.outcome) {
|
|
2540
|
+
case 'success':
|
|
2541
|
+
agg.successfulCycles++;
|
|
2542
|
+
break;
|
|
2543
|
+
case 'stuck_recovery':
|
|
2544
|
+
agg.stuckRecoveryCycles++;
|
|
2545
|
+
break;
|
|
2546
|
+
case 'blocked':
|
|
2547
|
+
agg.blockedCycles++;
|
|
2548
|
+
break;
|
|
2549
|
+
case 'error':
|
|
2550
|
+
agg.errorCycles++;
|
|
2551
|
+
break;
|
|
2552
|
+
case 'cancelled':
|
|
2553
|
+
// Cancelled cycles don't count towards any specific category
|
|
2554
|
+
// but are still counted in totalCycles
|
|
2555
|
+
break;
|
|
2556
|
+
default:
|
|
2557
|
+
assertNever(metrics.outcome, `Unhandled CycleOutcome: ${metrics.outcome}`);
|
|
2558
|
+
}
|
|
2559
|
+
// Recalculate averages using all recent metrics
|
|
2560
|
+
const durations = this.recentCycleMetrics.map((m) => m.durationMs);
|
|
2561
|
+
const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
|
|
2562
|
+
if (durations.length > 0) {
|
|
2563
|
+
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
|
2564
|
+
agg.avgIdleDetectionMs = Math.round(idleTimes.reduce((a, b) => a + b, 0) / idleTimes.length);
|
|
2565
|
+
// Calculate P90
|
|
2566
|
+
const sortedDurations = [...durations].sort((a, b) => a - b);
|
|
2567
|
+
const p90Index = Math.floor(sortedDurations.length * 0.9);
|
|
2568
|
+
agg.p90CycleDurationMs = sortedDurations[p90Index];
|
|
2569
|
+
}
|
|
2570
|
+
// Calculate success rate
|
|
2571
|
+
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
|
|
2572
|
+
agg.lastUpdatedAt = Date.now();
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* Get aggregate metrics for monitoring.
|
|
2576
|
+
* @returns Copy of aggregate metrics
|
|
2577
|
+
*/
|
|
2578
|
+
getAggregateMetrics() {
|
|
2579
|
+
return { ...this.aggregateMetrics };
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Get recent cycle metrics for analysis.
|
|
2583
|
+
* @param limit - Maximum number of metrics to return (default: 20)
|
|
2584
|
+
* @returns Recent cycle metrics, newest first
|
|
2585
|
+
*/
|
|
2586
|
+
getRecentCycleMetrics(limit = 20) {
|
|
2587
|
+
return this.recentCycleMetrics.slice(-limit).reverse();
|
|
2588
|
+
}
|
|
2589
|
+
// ========== P2-005: Health Score Methods ==========
|
|
2590
|
+
/**
|
|
2591
|
+
* Calculate a comprehensive health score for the Ralph Loop system.
|
|
2592
|
+
* Aggregates multiple health signals into a single score (0-100).
|
|
2593
|
+
*
|
|
2594
|
+
* @returns Health score with component breakdown
|
|
2595
|
+
*/
|
|
2596
|
+
calculateHealthScore() {
|
|
2597
|
+
const now = Date.now();
|
|
2598
|
+
const components = {
|
|
2599
|
+
cycleSuccess: this.calculateCycleSuccessScore(),
|
|
2600
|
+
circuitBreaker: this.calculateCircuitBreakerScore(),
|
|
2601
|
+
iterationProgress: this.calculateIterationProgressScore(),
|
|
2602
|
+
aiChecker: this.calculateAiCheckerScore(),
|
|
2603
|
+
stuckRecovery: this.calculateStuckRecoveryScore(),
|
|
2604
|
+
};
|
|
2605
|
+
// Weighted average (cycle success is most important)
|
|
2606
|
+
const weights = {
|
|
2607
|
+
cycleSuccess: 0.35,
|
|
2608
|
+
circuitBreaker: 0.2,
|
|
2609
|
+
iterationProgress: 0.2,
|
|
2610
|
+
aiChecker: 0.15,
|
|
2611
|
+
stuckRecovery: 0.1,
|
|
2612
|
+
};
|
|
2613
|
+
const score = Math.round(components.cycleSuccess * weights.cycleSuccess +
|
|
2614
|
+
components.circuitBreaker * weights.circuitBreaker +
|
|
2615
|
+
components.iterationProgress * weights.iterationProgress +
|
|
2616
|
+
components.aiChecker * weights.aiChecker +
|
|
2617
|
+
components.stuckRecovery * weights.stuckRecovery);
|
|
2618
|
+
// Determine status
|
|
2619
|
+
let status;
|
|
2620
|
+
if (score >= 90)
|
|
2621
|
+
status = 'excellent';
|
|
2622
|
+
else if (score >= 70)
|
|
2623
|
+
status = 'good';
|
|
2624
|
+
else if (score >= 50)
|
|
2625
|
+
status = 'degraded';
|
|
2626
|
+
else
|
|
2627
|
+
status = 'critical';
|
|
2628
|
+
// Generate recommendations
|
|
2629
|
+
const recommendations = this.generateHealthRecommendations(components);
|
|
2630
|
+
// Generate summary
|
|
2631
|
+
const summary = this.generateHealthSummary(score, status, components);
|
|
2632
|
+
return {
|
|
2633
|
+
score,
|
|
2634
|
+
status,
|
|
2635
|
+
components,
|
|
2636
|
+
summary,
|
|
2637
|
+
recommendations,
|
|
2638
|
+
calculatedAt: now,
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Calculate score based on recent cycle success rate.
|
|
2643
|
+
*/
|
|
2644
|
+
calculateCycleSuccessScore() {
|
|
2645
|
+
if (this.aggregateMetrics.totalCycles === 0)
|
|
2646
|
+
return 100; // No data = assume healthy
|
|
2647
|
+
return this.aggregateMetrics.successRate;
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* Calculate score based on circuit breaker state.
|
|
2651
|
+
*/
|
|
2652
|
+
calculateCircuitBreakerScore() {
|
|
2653
|
+
const tracker = this.session.ralphTracker;
|
|
2654
|
+
if (!tracker)
|
|
2655
|
+
return 100;
|
|
2656
|
+
const cb = tracker.circuitBreakerStatus;
|
|
2657
|
+
switch (cb.state) {
|
|
2658
|
+
case 'CLOSED':
|
|
2659
|
+
return 100;
|
|
2660
|
+
case 'HALF_OPEN':
|
|
2661
|
+
return 50;
|
|
2662
|
+
case 'OPEN':
|
|
2663
|
+
return 0;
|
|
2664
|
+
default:
|
|
2665
|
+
return 100;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
/**
|
|
2669
|
+
* Calculate score based on iteration progress.
|
|
2670
|
+
*/
|
|
2671
|
+
calculateIterationProgressScore() {
|
|
2672
|
+
const tracker = this.session.ralphTracker;
|
|
2673
|
+
if (!tracker)
|
|
2674
|
+
return 100;
|
|
2675
|
+
const stallMetrics = tracker.getIterationStallMetrics();
|
|
2676
|
+
const { stallDurationMs, warningThresholdMs, criticalThresholdMs } = stallMetrics;
|
|
2677
|
+
if (stallDurationMs >= criticalThresholdMs)
|
|
2678
|
+
return 0;
|
|
2679
|
+
if (stallDurationMs >= warningThresholdMs)
|
|
2680
|
+
return 30;
|
|
2681
|
+
if (stallDurationMs >= warningThresholdMs / 2)
|
|
2682
|
+
return 70;
|
|
2683
|
+
return 100;
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* Calculate score based on AI checker health.
|
|
2687
|
+
*/
|
|
2688
|
+
calculateAiCheckerScore() {
|
|
2689
|
+
const state = this.aiChecker.getState();
|
|
2690
|
+
if (state.status === 'disabled')
|
|
2691
|
+
return 30;
|
|
2692
|
+
if (state.status === 'cooldown')
|
|
2693
|
+
return 70;
|
|
2694
|
+
if (state.consecutiveErrors > 0)
|
|
2695
|
+
return 50;
|
|
2696
|
+
return 100;
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* Calculate score based on stuck-state recovery count.
|
|
2700
|
+
*/
|
|
2701
|
+
calculateStuckRecoveryScore() {
|
|
2702
|
+
const maxRecoveries = this.config.maxStuckRecoveries ?? 3;
|
|
2703
|
+
if (this.stuckRecoveryCount === 0)
|
|
2704
|
+
return 100;
|
|
2705
|
+
if (this.stuckRecoveryCount >= maxRecoveries)
|
|
2706
|
+
return 0;
|
|
2707
|
+
return Math.round(100 - (this.stuckRecoveryCount / maxRecoveries) * 100);
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Generate health recommendations based on component scores.
|
|
2711
|
+
*/
|
|
2712
|
+
generateHealthRecommendations(components) {
|
|
2713
|
+
const recommendations = [];
|
|
2714
|
+
if (components.cycleSuccess < 70) {
|
|
2715
|
+
recommendations.push('Cycle success rate is low. Check for recurring errors or stuck states.');
|
|
2716
|
+
}
|
|
2717
|
+
if (components.circuitBreaker < 50) {
|
|
2718
|
+
recommendations.push('Circuit breaker is open or half-open. Review recent errors and consider manual reset.');
|
|
2719
|
+
}
|
|
2720
|
+
if (components.iterationProgress < 50) {
|
|
2721
|
+
recommendations.push('Iteration progress has stalled. Check if Claude is stuck on a task.');
|
|
2722
|
+
}
|
|
2723
|
+
if (components.aiChecker < 50) {
|
|
2724
|
+
recommendations.push('AI idle checker has errors. May need to check Claude CLI availability.');
|
|
2725
|
+
}
|
|
2726
|
+
if (components.stuckRecovery < 50) {
|
|
2727
|
+
recommendations.push('Multiple stuck-state recoveries occurred. Consider increasing timeouts.');
|
|
2728
|
+
}
|
|
2729
|
+
if (recommendations.length === 0) {
|
|
2730
|
+
recommendations.push('System is healthy. No action needed.');
|
|
2731
|
+
}
|
|
2732
|
+
return recommendations;
|
|
2733
|
+
}
|
|
2734
|
+
/**
|
|
2735
|
+
* Generate a human-readable health summary.
|
|
2736
|
+
*/
|
|
2737
|
+
generateHealthSummary(score, status, components) {
|
|
2738
|
+
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
|
|
2739
|
+
key: '',
|
|
2740
|
+
val: 100,
|
|
2741
|
+
});
|
|
2742
|
+
if (status === 'excellent') {
|
|
2743
|
+
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
|
2744
|
+
}
|
|
2745
|
+
if (status === 'good') {
|
|
2746
|
+
return `Ralph Loop is operating well (${score}/100). Minor issues in ${lowest.key}.`;
|
|
2747
|
+
}
|
|
2748
|
+
if (status === 'degraded') {
|
|
2749
|
+
return `Ralph Loop is degraded (${score}/100). Primary issue: ${lowest.key} (${lowest.val}/100).`;
|
|
2750
|
+
}
|
|
2751
|
+
return `Ralph Loop is in critical state (${score}/100). Immediate attention needed: ${lowest.key}.`;
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
//# sourceMappingURL=respawn-controller.js.map
|