mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
feat: add hook-based idle detection for respawn controller
Implement multi-phase improvement to respawn controller's idle detection: Phase 1 - Stop Hook Detection: - Add signalStopHook() method - definitive signal when Claude finishes - 3s confirmation timer to handle race conditions - Skip AI check when hook received (100% confidence) Phase 2 - idle_prompt Detection: - Add signalIdlePrompt() method - fires after 60s+ of idle - Immediately confirms idle (no confirmation timer needed) Phase 3 - Transcript File Monitoring: - New TranscriptWatcher class watches session JSONL files - Detects: completion, tool execution, plan mode, errors - Signals respawn controller for supporting detection Web UI Updates: - Hook indicator with purple styling and pulse animation - Shows "Stop hook received" or "idle_prompt hook received" - 100% confidence displayed with hook-confirmed style This significantly improves idle detection reliability by using definitive signals from Claude Code rather than parsing terminal output. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,385 @@
|
|||||||
|
# Respawn Controller Idle Detection Improvement Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The current respawn controller relies primarily on **parsing terminal output** to detect idle states. This approach is fragile and leads to false positives/negatives (e.g., the w3-reddit-analyse session).
|
||||||
|
|
||||||
|
**Key insight**: Claude Code provides **direct, authoritative signals** via hooks and files that definitively indicate session state. We're receiving some of these signals but not using them for idle detection!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Detection Layers (What We Have)
|
||||||
|
|
||||||
|
| Layer | Signal | Source | Reliability |
|
||||||
|
|-------|--------|--------|-------------|
|
||||||
|
| 1 | Completion message ("Worked for Xm Xs") | Terminal parsing | Medium - can miss edge cases |
|
||||||
|
| 2 | Output silence (configurable duration) | Terminal activity | Low - Claude can be processing silently |
|
||||||
|
| 3 | Token stability | Terminal parsing | Low - tokens don't change during I/O waits |
|
||||||
|
| 4 | Working pattern absence | Terminal parsing | Medium - patterns can be missed |
|
||||||
|
| 5 | AI idle check | Spawned Claude CLI | High but slow (90s timeout) |
|
||||||
|
|
||||||
|
**Problem**: All layers depend on **parsing terminal output**, which is inherently unreliable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Claude Code Signals (Not Fully Utilized)
|
||||||
|
|
||||||
|
### 1. `Stop` Hook ⭐ CRITICAL - DEFINITIVE SIGNAL
|
||||||
|
|
||||||
|
**What it is**: Fires when the main Claude Code agent **finishes responding**.
|
||||||
|
|
||||||
|
**From docs**: "Runs when the main Claude Code agent has finished responding. Does not run if the stoppage occurred due to a user interrupt."
|
||||||
|
|
||||||
|
**Current status**: We receive it via `/api/hook-event` but **don't use it for idle detection**!
|
||||||
|
|
||||||
|
**Input received**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "abc123",
|
||||||
|
"transcript_path": "~/.claude/projects/.../00893aaf.jsonl",
|
||||||
|
"hook_event_name": "Stop",
|
||||||
|
"stop_hook_active": true // Important for preventing loops
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Action needed**: The `Stop` hook should be the **PRIMARY** idle detection signal. When Claude fires Stop, the agent has definitively finished its response cycle.
|
||||||
|
|
||||||
|
### 2. `idle_prompt` Notification ⭐ HIGH VALUE
|
||||||
|
|
||||||
|
**What it is**: Fires after **60+ seconds of idle time** when Claude is waiting for user input.
|
||||||
|
|
||||||
|
**From docs**: "When Claude is waiting for user input (after 60+ seconds of idle time)"
|
||||||
|
|
||||||
|
**Current status**: We receive it but only forward it to the UI for notification display.
|
||||||
|
|
||||||
|
**Action needed**: Use `idle_prompt` as a **definitive confirmation** that Claude is idle. If we receive this, there's no need for AI idle checks or output silence timers.
|
||||||
|
|
||||||
|
### 3. Transcript JSONL File ⭐ HIGH VALUE
|
||||||
|
|
||||||
|
**What it is**: Complete conversation history at `~/.claude/projects/{project-hash}/{session-id}.jsonl`
|
||||||
|
|
||||||
|
**Current status**: We already watch subagent transcripts but **not the main session transcript**.
|
||||||
|
|
||||||
|
**Data available**:
|
||||||
|
- Every message (user, assistant, system)
|
||||||
|
- Every tool call with inputs/outputs
|
||||||
|
- Progress events
|
||||||
|
- Structured, parseable JSON
|
||||||
|
|
||||||
|
**Action needed**:
|
||||||
|
- Monitor the main transcript file (path provided in every hook input)
|
||||||
|
- Parse the last few entries to detect:
|
||||||
|
- Tool completion
|
||||||
|
- Assistant message completion
|
||||||
|
- Error states
|
||||||
|
- Plan mode prompts
|
||||||
|
|
||||||
|
### 4. `PostToolUse` Hook - Tool Completion Tracking
|
||||||
|
|
||||||
|
**What it is**: Fires immediately after any tool completes successfully.
|
||||||
|
|
||||||
|
**Use case**: Track exactly when tools finish to understand execution flow.
|
||||||
|
|
||||||
|
**Current status**: Not implemented.
|
||||||
|
|
||||||
|
**Action needed**: Add PostToolUse hooks to track tool completion events.
|
||||||
|
|
||||||
|
### 5. `SubagentStop` Hook - Background Agent Completion
|
||||||
|
|
||||||
|
**What it is**: Fires when a subagent (Task tool) finishes responding.
|
||||||
|
|
||||||
|
**Current status**: Not implemented in hooks config (we watch JSONL files separately).
|
||||||
|
|
||||||
|
**Action needed**: Add to hooks config for redundant detection.
|
||||||
|
|
||||||
|
### 6. `permission_prompt` and `elicitation_dialog` - Blocking State Detection
|
||||||
|
|
||||||
|
**What it is**: Fires when Claude needs user input (permission or question).
|
||||||
|
|
||||||
|
**Current status**: We receive and use for auto-accept blocking.
|
||||||
|
|
||||||
|
**Enhancement**: Use as definitive "Claude is NOT idle - it's waiting for user action".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Architecture: Multi-Signal Idle Detection
|
||||||
|
|
||||||
|
### New Detection Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
Priority 1 (Definitive):
|
||||||
|
└── Stop hook received → CONFIRMED IDLE
|
||||||
|
└── idle_prompt received → CONFIRMED IDLE (60s+ idle)
|
||||||
|
|
||||||
|
Priority 2 (Blocking):
|
||||||
|
└── permission_prompt received → NOT IDLE (waiting for permission)
|
||||||
|
└── elicitation_dialog received → NOT IDLE (waiting for answer)
|
||||||
|
└── Working patterns in terminal → NOT IDLE
|
||||||
|
|
||||||
|
Priority 3 (Supporting):
|
||||||
|
└── Transcript analysis → Check last entries for completion
|
||||||
|
└── Output silence + token stability → Weak idle signal
|
||||||
|
|
||||||
|
Priority 4 (Fallback):
|
||||||
|
└── AI idle check → Only if no definitive signals after timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Machine Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ WATCHING │◄──────────────────────────────┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ Stop hook or idle_prompt │
|
||||||
|
│ └────────────────────────┐ │
|
||||||
|
│ ▼ │
|
||||||
|
│ Output silence ┌────────────┐ │
|
||||||
|
│ (no definitive signals) │ HOOK_IDLE │───────┤
|
||||||
|
│ └────────────┘ │
|
||||||
|
▼ (skip AI check) │
|
||||||
|
┌────────────────────┐ │
|
||||||
|
│ CONFIRMING_IDLE │ │
|
||||||
|
└────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Silence confirmed │
|
||||||
|
▼ │
|
||||||
|
┌────────────────────┐ │
|
||||||
|
│ AI_CHECKING │──── IDLE verdict ─────────────┤
|
||||||
|
└────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ WORKING verdict │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### New State: `hook_idle`
|
||||||
|
|
||||||
|
When a definitive hook signal is received:
|
||||||
|
1. Skip AI idle check entirely (saves time and API calls)
|
||||||
|
2. Short confirmation period (2-3s) to handle race conditions
|
||||||
|
3. Proceed directly to respawn sequence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Use Stop Hook for Idle Detection ✅ COMPLETED
|
||||||
|
|
||||||
|
**Files modified**:
|
||||||
|
- `src/respawn-controller.ts`
|
||||||
|
- `src/web/server.ts`
|
||||||
|
- `test/respawn-controller.test.ts`
|
||||||
|
|
||||||
|
**Changes implemented**:
|
||||||
|
1. Added `stopHookReceived`, `stopHookTime`, `idlePromptReceived`, `idlePromptTime` fields to `DetectionStatus`
|
||||||
|
2. Added `hookConfirmTimer` for short confirmation after hook signal (3s)
|
||||||
|
3. Added `signalStopHook()` method:
|
||||||
|
- Sets `stopHookReceived = true` and timestamp
|
||||||
|
- Cancels any running AI check (hook is definitive)
|
||||||
|
- Starts 3s confirmation timer
|
||||||
|
- If no new output during confirmation → triggers respawn cycle
|
||||||
|
4. Added `signalIdlePrompt()` method:
|
||||||
|
- Sets `idlePromptReceived = true` and timestamp
|
||||||
|
- Immediately confirms idle (skips confirmation timer - 60s+ already proven)
|
||||||
|
5. Added `resetHookState()` to clear hook flags on:
|
||||||
|
- Controller start
|
||||||
|
- Working patterns detected
|
||||||
|
- Cycle completion
|
||||||
|
6. Updated server.ts `/api/hook-event` endpoint to call:
|
||||||
|
- `controller.signalStopHook()` for `stop` events
|
||||||
|
- `controller.signalIdlePrompt()` for `idle_prompt` events
|
||||||
|
7. Updated `getDetectionStatus()`:
|
||||||
|
- Returns hook signal states
|
||||||
|
- Sets confidence to 100% when hook received
|
||||||
|
- Updates statusText to show hook status
|
||||||
|
|
||||||
|
**Tests added** (9 new tests in `RespawnController Hook-Based Idle Detection` describe block):
|
||||||
|
- `should expose signalStopHook method`
|
||||||
|
- `should expose signalIdlePrompt method`
|
||||||
|
- `should set stopHookReceived in detection status when Stop hook signaled`
|
||||||
|
- `should include hook status in statusText when Stop hook received`
|
||||||
|
- `should trigger respawn cycle after Stop hook confirmation`
|
||||||
|
- `should immediately confirm idle when idle_prompt signaled (skip confirmation)`
|
||||||
|
- `should cancel Stop hook confirmation if working patterns detected`
|
||||||
|
- `should ignore Stop hook when not in watching state`
|
||||||
|
- `should have 100% confidence when hook signal is received`
|
||||||
|
|
||||||
|
**Detection status update** (implemented):
|
||||||
|
```typescript
|
||||||
|
interface DetectionStatus {
|
||||||
|
/** Layer 0: Stop hook received (highest priority - definitive signal) */
|
||||||
|
stopHookReceived: boolean;
|
||||||
|
stopHookTime: number | null;
|
||||||
|
/** Layer 0: idle_prompt notification received (definitive signal) */
|
||||||
|
idlePromptReceived: boolean;
|
||||||
|
idlePromptTime: number | null;
|
||||||
|
|
||||||
|
// Existing fields...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Use idle_prompt for Definitive Idle ✅ COMPLETED (in Phase 1)
|
||||||
|
|
||||||
|
**Already implemented in Phase 1**:
|
||||||
|
1. `signalIdlePrompt()` method sets `idlePromptReceived = true`
|
||||||
|
2. Immediately calls `onIdleConfirmed()` - skips all other detection
|
||||||
|
3. Server.ts calls `controller.signalIdlePrompt()` when `idle_prompt` event received
|
||||||
|
4. 60s+ of Claude waiting = definitive idle signal
|
||||||
|
|
||||||
|
### Phase 3: Transcript File Monitoring ✅ COMPLETED
|
||||||
|
|
||||||
|
**New file**: `src/transcript-watcher.ts`
|
||||||
|
|
||||||
|
**Functionality implemented**:
|
||||||
|
1. Watch the session's transcript JSONL file using `fs.watch()`
|
||||||
|
2. Parse new entries as they're appended (incremental reading from last position)
|
||||||
|
3. Detect:
|
||||||
|
- `result` entry → `transcript:complete` event (isComplete = true)
|
||||||
|
- `tool_use` content block → `transcript:tool_start` event
|
||||||
|
- `tool_result` content block → `transcript:tool_end` event
|
||||||
|
- `AskUserQuestion` or `ExitPlanMode` tools → `transcript:plan_mode` event
|
||||||
|
- Error conditions in result entries
|
||||||
|
4. Emit structured events consumed by respawn controller
|
||||||
|
|
||||||
|
**Integration implemented**:
|
||||||
|
- `transcript_path` added to allowed hook data fields in `sanitizeHookData()`
|
||||||
|
- `transcriptWatchers` Map added to WebServer for per-session watchers
|
||||||
|
- `startTranscriptWatcher()` creates watcher and wires up events:
|
||||||
|
- `transcript:complete` → `controller.signalTranscriptComplete()`
|
||||||
|
- `transcript:plan_mode` → `controller.signalTranscriptPlanMode()`
|
||||||
|
- `stopTranscriptWatcher()` cleans up on session cleanup
|
||||||
|
- Hook events with `transcript_path` automatically start watching
|
||||||
|
|
||||||
|
**RespawnController methods added**:
|
||||||
|
- `signalTranscriptComplete()` - Supporting signal that can accelerate idle detection
|
||||||
|
- `signalTranscriptPlanMode()` - Cancels auto-accept timer (like elicitation)
|
||||||
|
|
||||||
|
**Tests added** (13 tests in `test/transcript-watcher.test.ts`):
|
||||||
|
- Initialization tests
|
||||||
|
- File watching tests (existing file, non-existent file, stop, updatePath)
|
||||||
|
- Entry processing tests (user entry, result entry, tool execution, plan mode, errors)
|
||||||
|
- State management tests
|
||||||
|
|
||||||
|
### Phase 4: Enhanced Hook Configuration
|
||||||
|
|
||||||
|
**Update `src/hooks-config.ts`**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||||
|
return {
|
||||||
|
hooks: {
|
||||||
|
Notification: [
|
||||||
|
{ matcher: 'idle_prompt', hooks: [...] },
|
||||||
|
{ matcher: 'permission_prompt', hooks: [...] },
|
||||||
|
{ matcher: 'elicitation_dialog', hooks: [...] },
|
||||||
|
],
|
||||||
|
Stop: [{ hooks: [...] }],
|
||||||
|
// NEW: Add these
|
||||||
|
PostToolUse: [
|
||||||
|
{ matcher: '*', hooks: [...] } // Track all tool completions
|
||||||
|
],
|
||||||
|
SubagentStop: [{ hooks: [...] }],
|
||||||
|
PreCompact: [
|
||||||
|
{ matcher: '*', hooks: [...] } // Track compaction
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5: Confidence Scoring Overhaul
|
||||||
|
|
||||||
|
Replace current confidence calculation with weighted signals:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function calculateConfidence(): number {
|
||||||
|
let confidence = 0;
|
||||||
|
|
||||||
|
// Definitive signals (100% confidence)
|
||||||
|
if (this.stopHookReceived) confidence = 100;
|
||||||
|
if (this.idlePromptReceived) confidence = 100;
|
||||||
|
|
||||||
|
// Blocking signals (0% confidence)
|
||||||
|
if (this.permissionPromptReceived) return 0;
|
||||||
|
if (this.elicitationReceived) return 0;
|
||||||
|
if (this.workingPatternRecent) return 0;
|
||||||
|
|
||||||
|
// Supporting signals (build up to ~80%)
|
||||||
|
if (confidence < 100) {
|
||||||
|
if (this.outputSilent) confidence += 30;
|
||||||
|
if (this.tokensStable) confidence += 20;
|
||||||
|
if (this.transcriptShowsCompletion) confidence += 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, confidence);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Benefits
|
||||||
|
|
||||||
|
| Metric | Current | After Implementation |
|
||||||
|
|--------|---------|---------------------|
|
||||||
|
| False positive rate | ~15-20% | <5% |
|
||||||
|
| Detection latency | 10-90s (AI check) | 3-5s (hook-based) |
|
||||||
|
| API calls for AI check | Every idle detection | Only when hooks unavailable |
|
||||||
|
| Reliability | Medium | High (definitive signals) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
1. `Stop` hook triggers immediate idle confirmation
|
||||||
|
2. `idle_prompt` skips all other detection
|
||||||
|
3. `permission_prompt` blocks idle detection
|
||||||
|
4. Transcript parsing correctly identifies completion
|
||||||
|
5. Fallback to AI check when no hooks received
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
1. End-to-end with real Claude session
|
||||||
|
2. Hook event delivery and handling
|
||||||
|
3. Transcript file monitoring
|
||||||
|
4. Race condition handling
|
||||||
|
|
||||||
|
### Scenarios to Test
|
||||||
|
1. Normal completion → Stop hook → respawn
|
||||||
|
2. Long-running task → idle_prompt → respawn
|
||||||
|
3. Permission needed → wait for user action
|
||||||
|
4. AskUserQuestion → wait for user answer
|
||||||
|
5. Plan mode → auto-accept → continue
|
||||||
|
6. Hooks disabled/unavailable → fallback to AI check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. **Implement Phase 1** - Stop hook detection (low risk, high value)
|
||||||
|
2. **Deploy and monitor** - Verify Stop hooks are reliable
|
||||||
|
3. **Implement Phase 2** - idle_prompt (simple addition)
|
||||||
|
4. **Implement Phase 3** - Transcript monitoring (more complex)
|
||||||
|
5. **Implement Phase 4** - Enhanced hooks (optional, for completeness)
|
||||||
|
6. **Implement Phase 5** - Refactor confidence scoring
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Stop hook reliability**: Does it fire 100% of the time? Edge cases?
|
||||||
|
2. **Transcript file location**: Always at the path in hook input?
|
||||||
|
3. **Hook delivery latency**: How quickly do hooks fire after state change?
|
||||||
|
4. **Race conditions**: What if Stop hook and new work happen simultaneously?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)
|
||||||
|
- [Agent SDK Documentation](https://platform.claude.com/docs/en/agent-sdk/overview)
|
||||||
|
- Current implementation: `src/respawn-controller.ts`
|
||||||
|
- Hooks config: `src/hooks-config.ts`
|
||||||
|
- Subagent watcher: `src/subagent-watcher.ts`
|
||||||
+223
-6
@@ -94,6 +94,15 @@ export interface ActiveTimerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectionStatus {
|
export interface DetectionStatus {
|
||||||
|
/** Layer 0: Stop hook received (highest priority - definitive signal) */
|
||||||
|
stopHookReceived: boolean;
|
||||||
|
/** Timestamp when Stop hook was received */
|
||||||
|
stopHookTime: number | null;
|
||||||
|
/** Layer 0: idle_prompt notification received (definitive signal) */
|
||||||
|
idlePromptReceived: boolean;
|
||||||
|
/** Timestamp when idle_prompt was received */
|
||||||
|
idlePromptTime: number | null;
|
||||||
|
|
||||||
/** Layer 1: Completion message detected ("for Xm Xs") */
|
/** Layer 1: Completion message detected ("for Xm Xs") */
|
||||||
completionMessageDetected: boolean;
|
completionMessageDetected: boolean;
|
||||||
/** Timestamp when completion message was last seen */
|
/** Timestamp when completion message was last seen */
|
||||||
@@ -570,6 +579,26 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
|
||||||
private elicitationDetected: boolean = false;
|
private elicitationDetected: boolean = false;
|
||||||
|
|
||||||
|
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
|
||||||
|
|
||||||
|
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
|
||||||
|
private stopHookReceived: boolean = false;
|
||||||
|
|
||||||
|
/** Timestamp when Stop hook was received */
|
||||||
|
private stopHookTime: number | null = null;
|
||||||
|
|
||||||
|
/** Whether an idle_prompt notification was received (60s+ idle signal) */
|
||||||
|
private idlePromptReceived: boolean = false;
|
||||||
|
|
||||||
|
/** Timestamp when idle_prompt was received */
|
||||||
|
private idlePromptTime: number | null = null;
|
||||||
|
|
||||||
|
/** Timer for short confirmation after hook signal (handles race conditions) */
|
||||||
|
private hookConfirmTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/** Confirmation delay after hook signal before confirming idle (ms) */
|
||||||
|
private static readonly HOOK_CONFIRM_DELAY_MS = 3000;
|
||||||
|
|
||||||
/** Number of completed respawn cycles */
|
/** Number of completed respawn cycles */
|
||||||
private cycleCount: number = 0;
|
private cycleCount: number = 0;
|
||||||
|
|
||||||
@@ -795,11 +824,16 @@ export class RespawnController extends EventEmitter {
|
|||||||
const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns
|
const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns
|
||||||
|
|
||||||
// Calculate confidence level (0-100)
|
// Calculate confidence level (0-100)
|
||||||
|
// Hook signals are definitive (100% confidence)
|
||||||
let confidence = 0;
|
let confidence = 0;
|
||||||
if (completionMessageDetected) confidence += 40;
|
if (this.stopHookReceived || this.idlePromptReceived) {
|
||||||
if (outputSilent) confidence += 25;
|
confidence = 100;
|
||||||
if (tokensStable) confidence += 20;
|
} else {
|
||||||
if (workingPatternsAbsent) confidence += 15;
|
if (completionMessageDetected) confidence += 40;
|
||||||
|
if (outputSilent) confidence += 25;
|
||||||
|
if (tokensStable) confidence += 20;
|
||||||
|
if (workingPatternsAbsent) confidence += 15;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine status text and what we're waiting for
|
// Determine status text and what we're waiting for
|
||||||
let statusText: string;
|
let statusText: string;
|
||||||
@@ -808,6 +842,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this._state === 'stopped') {
|
if (this._state === 'stopped') {
|
||||||
statusText = 'Controller stopped';
|
statusText = 'Controller stopped';
|
||||||
waitingFor = 'Start to begin monitoring';
|
waitingFor = 'Start to begin monitoring';
|
||||||
|
} else if (this.stopHookReceived || this.idlePromptReceived) {
|
||||||
|
const hookType = this.idlePromptReceived ? 'idle_prompt' : 'Stop';
|
||||||
|
statusText = `${hookType} hook received - confirming idle`;
|
||||||
|
waitingFor = 'Short confirmation (race condition check)';
|
||||||
} else if (this._state === 'ai_checking') {
|
} else if (this._state === 'ai_checking') {
|
||||||
statusText = 'AI Check: Analyzing terminal output...';
|
statusText = 'AI Check: Analyzing terminal output...';
|
||||||
waitingFor = 'AI verdict (IDLE or WORKING)';
|
waitingFor = 'AI verdict (IDLE or WORKING)';
|
||||||
@@ -901,6 +939,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
stopHookReceived: this.stopHookReceived,
|
||||||
|
stopHookTime: this.stopHookTime,
|
||||||
|
idlePromptReceived: this.idlePromptReceived,
|
||||||
|
idlePromptTime: this.idlePromptTime,
|
||||||
completionMessageDetected,
|
completionMessageDetected,
|
||||||
completionMessageTime: this.completionMessageTime,
|
completionMessageTime: this.completionMessageTime,
|
||||||
outputSilent,
|
outputSilent,
|
||||||
@@ -997,7 +1039,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
this.log('Starting respawn controller (multi-layer detection)');
|
this.log('Starting respawn controller (multi-layer detection)');
|
||||||
|
|
||||||
// Initialize all timestamps
|
// Initialize all timestamps and reset hook state
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.lastActivityTime = now;
|
this.lastActivityTime = now;
|
||||||
this.lastOutputTime = now;
|
this.lastOutputTime = now;
|
||||||
@@ -1005,6 +1047,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.lastWorkingPatternTime = now;
|
this.lastWorkingPatternTime = now;
|
||||||
this.completionMessageTime = null;
|
this.completionMessageTime = null;
|
||||||
this.hasReceivedOutput = false;
|
this.hasReceivedOutput = false;
|
||||||
|
this.resetHookState();
|
||||||
|
|
||||||
// Seed the terminal buffer from the session's existing output.
|
// Seed the terminal buffer from the session's existing output.
|
||||||
// This gives the AI checker context even if no new output arrives.
|
// This gives the AI checker context even if no new output arrives.
|
||||||
@@ -1139,9 +1182,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.workingDetected = true;
|
this.workingDetected = true;
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
this.elicitationDetected = false; // Clear on new work cycle
|
this.elicitationDetected = false; // Clear on new work cycle
|
||||||
|
this.resetHookState(); // Clear hook signals on new work
|
||||||
this.lastWorkingPatternTime = now;
|
this.lastWorkingPatternTime = now;
|
||||||
this.clearIdleTimer();
|
this.clearIdleTimer();
|
||||||
|
|
||||||
|
// Cancel hook confirmation timer if running
|
||||||
|
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected');
|
||||||
|
this.hookConfirmTimer = null;
|
||||||
|
|
||||||
// Cancel any pending completion confirmation
|
// Cancel any pending completion confirmation
|
||||||
this.cancelCompletionConfirm();
|
this.cancelCompletionConfirm();
|
||||||
|
|
||||||
@@ -1396,7 +1444,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, and clear fallback) */
|
/** Clear all timers (idle, step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
|
||||||
private clearTimers(): void {
|
private clearTimers(): void {
|
||||||
this.clearIdleTimer();
|
this.clearIdleTimer();
|
||||||
if (this.stepTimer) {
|
if (this.stepTimer) {
|
||||||
@@ -1427,6 +1475,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
clearTimeout(this.noOutputTimer);
|
clearTimeout(this.noOutputTimer);
|
||||||
this.noOutputTimer = null;
|
this.noOutputTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.hookConfirmTimer) {
|
||||||
|
clearTimeout(this.hookConfirmTimer);
|
||||||
|
this.hookConfirmTimer = null;
|
||||||
|
}
|
||||||
// Clear all tracked timers
|
// Clear all tracked timers
|
||||||
this.activeTimers.clear();
|
this.activeTimers.clear();
|
||||||
}
|
}
|
||||||
@@ -1940,6 +1992,163 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
|
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal that a Stop hook was received from Claude Code.
|
||||||
|
* This is a DEFINITIVE signal that Claude has finished responding.
|
||||||
|
* Skips AI idle check and uses a short confirmation period to handle race conditions.
|
||||||
|
*
|
||||||
|
* @fires log
|
||||||
|
*/
|
||||||
|
signalStopHook(): void {
|
||||||
|
// Only process in states where we're watching for idle
|
||||||
|
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
||||||
|
this.log(`Stop hook received but ignoring (state is ${this._state})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.stopHookReceived = true;
|
||||||
|
this.stopHookTime = now;
|
||||||
|
this.logAction('hook', 'Stop hook received - definitive idle signal');
|
||||||
|
this.log('Stop hook received from Claude Code - definitive idle signal');
|
||||||
|
|
||||||
|
// Cancel any running AI check - we have a definitive signal
|
||||||
|
if (this._state === 'ai_checking') {
|
||||||
|
this.log('Cancelling AI check - Stop hook is definitive');
|
||||||
|
this.aiChecker.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel completion confirm timer - hook takes precedence
|
||||||
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received');
|
||||||
|
this.completionConfirmTimer = null;
|
||||||
|
|
||||||
|
// Cancel pre-filter timer - hook takes precedence
|
||||||
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received');
|
||||||
|
this.preFilterTimer = null;
|
||||||
|
|
||||||
|
// Start short confirmation timer to handle race conditions
|
||||||
|
// (e.g., Stop hook arrives but Claude immediately starts new work)
|
||||||
|
this.startHookConfirmTimer('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal that an idle_prompt notification was received from Claude Code.
|
||||||
|
* This fires after 60+ seconds of Claude waiting for user input.
|
||||||
|
* This is a DEFINITIVE signal that Claude is idle.
|
||||||
|
*
|
||||||
|
* @fires log
|
||||||
|
*/
|
||||||
|
signalIdlePrompt(): void {
|
||||||
|
// Only process in states where we're watching for idle
|
||||||
|
if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') {
|
||||||
|
this.log(`idle_prompt received but ignoring (state is ${this._state})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.idlePromptReceived = true;
|
||||||
|
this.idlePromptTime = now;
|
||||||
|
this.logAction('hook', 'idle_prompt received - 60s+ idle confirmed');
|
||||||
|
this.log('idle_prompt notification received - Claude has been idle for 60+ seconds');
|
||||||
|
|
||||||
|
// Cancel any running AI check - we have a definitive signal
|
||||||
|
if (this._state === 'ai_checking') {
|
||||||
|
this.log('Cancelling AI check - idle_prompt is definitive');
|
||||||
|
this.aiChecker.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel all other detection timers - this is definitive
|
||||||
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received');
|
||||||
|
this.completionConfirmTimer = null;
|
||||||
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received');
|
||||||
|
this.preFilterTimer = null;
|
||||||
|
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received');
|
||||||
|
this.noOutputTimer = null;
|
||||||
|
|
||||||
|
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
|
||||||
|
// Skip confirmation and go directly to idle
|
||||||
|
this.onIdleConfirmed('idle_prompt hook (60s+ idle)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a short confirmation timer after receiving a hook signal.
|
||||||
|
* This handles race conditions where a hook arrives but Claude immediately starts new work.
|
||||||
|
*
|
||||||
|
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
|
||||||
|
*/
|
||||||
|
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
|
||||||
|
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting');
|
||||||
|
this.hookConfirmTimer = null;
|
||||||
|
|
||||||
|
this.hookConfirmTimer = this.startTrackedTimer(
|
||||||
|
'hook-confirm',
|
||||||
|
RespawnController.HOOK_CONFIRM_DELAY_MS,
|
||||||
|
() => {
|
||||||
|
this.hookConfirmTimer = null;
|
||||||
|
|
||||||
|
// Verify we haven't received new output since the hook arrived
|
||||||
|
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
|
||||||
|
if (hookTime && this.lastOutputTime > hookTime) {
|
||||||
|
// Output arrived after hook - Claude started new work
|
||||||
|
this.log(`Output received after ${hookType} hook, cancelling idle confirmation`);
|
||||||
|
this.logAction('hook', `${hookType} cancelled - new output detected`);
|
||||||
|
this.resetHookState();
|
||||||
|
this.setState('watching');
|
||||||
|
this.startNoOutputTimer();
|
||||||
|
this.startPreFilterTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No new output - confirm idle via hook signal
|
||||||
|
this.logAction('hook', `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`);
|
||||||
|
this.onIdleConfirmed(`${hookType} hook (confirmed)`);
|
||||||
|
},
|
||||||
|
`confirming ${hookType} hook`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset hook-based detection state.
|
||||||
|
* Called when hooks are cancelled due to new activity.
|
||||||
|
*/
|
||||||
|
private resetHookState(): void {
|
||||||
|
this.stopHookReceived = false;
|
||||||
|
this.stopHookTime = null;
|
||||||
|
this.idlePromptReceived = false;
|
||||||
|
this.idlePromptTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal that the transcript indicates completion.
|
||||||
|
* This is a supporting signal from transcript file monitoring.
|
||||||
|
* Unlike hooks, this doesn't immediately trigger idle - it boosts confidence.
|
||||||
|
*/
|
||||||
|
signalTranscriptComplete(): void {
|
||||||
|
// Transcript completion is a supporting signal, not definitive
|
||||||
|
// It can help reduce the confirmation time needed
|
||||||
|
if (this._state === 'watching') {
|
||||||
|
this.logAction('transcript', 'Transcript shows completion - boosting confidence');
|
||||||
|
this.log('Transcript completion detected - may accelerate idle detection');
|
||||||
|
// If we have a completion message and transcript confirms, try AI check
|
||||||
|
if (this.completionMessageTime !== null) {
|
||||||
|
this.tryStartAiCheck('transcript + completion message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal that the transcript indicates plan mode.
|
||||||
|
* This helps prevent auto-accept from triggering on AskUserQuestion.
|
||||||
|
*/
|
||||||
|
signalTranscriptPlanMode(): void {
|
||||||
|
// Plan mode from transcript = potential AskUserQuestion
|
||||||
|
// This is similar to elicitation detection
|
||||||
|
if (this._state === 'watching') {
|
||||||
|
this.logAction('transcript', 'Plan mode / AskUserQuestion detected');
|
||||||
|
this.cancelAutoAcceptTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start completion confirmation timer.
|
* Start completion confirmation timer.
|
||||||
* After completion message, waits for output silence then triggers AI check.
|
* After completion message, waits for output silence then triggers AI check.
|
||||||
@@ -2183,6 +2392,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
this.promptDetected = false;
|
this.promptDetected = false;
|
||||||
this.workingDetected = false;
|
this.workingDetected = false;
|
||||||
|
this.resetHookState(); // Clear hook signals for next cycle
|
||||||
|
|
||||||
|
// Restart detection timers for next cycle
|
||||||
|
this.startNoOutputTimer();
|
||||||
|
this.startPreFilterTimer();
|
||||||
|
if (this.config.autoAcceptPrompts) {
|
||||||
|
this.startAutoAcceptTimer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Transcript Watcher - Real-time monitoring of Claude Code session transcripts
|
||||||
|
*
|
||||||
|
* Watches the main session transcript JSONL file and emits structured events for:
|
||||||
|
* - Assistant message completion
|
||||||
|
* - Tool execution state
|
||||||
|
* - Error conditions
|
||||||
|
* - Plan mode prompts
|
||||||
|
*
|
||||||
|
* The transcript path is provided by Claude Code hooks in the `transcript_path` field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { watch, statSync, existsSync, FSWatcher } from 'fs';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { createInterface } from 'readline';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed transcript entry from the JSONL file
|
||||||
|
*/
|
||||||
|
export interface TranscriptEntry {
|
||||||
|
type: 'user' | 'assistant' | 'system' | 'result';
|
||||||
|
timestamp: string;
|
||||||
|
message?: {
|
||||||
|
role: string;
|
||||||
|
content: string | TranscriptContentBlock[];
|
||||||
|
};
|
||||||
|
total_cost_usd?: number;
|
||||||
|
duration_ms?: number;
|
||||||
|
error?: {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptContentBlock {
|
||||||
|
type: 'text' | 'tool_use' | 'tool_result';
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
content?: string;
|
||||||
|
is_error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detected state from transcript analysis
|
||||||
|
*/
|
||||||
|
export interface TranscriptState {
|
||||||
|
/** Whether the last entry indicates completion */
|
||||||
|
isComplete: boolean;
|
||||||
|
/** Whether a tool is currently executing */
|
||||||
|
toolExecuting: boolean;
|
||||||
|
/** Current tool name if executing */
|
||||||
|
currentTool: string | null;
|
||||||
|
/** Whether an error was detected */
|
||||||
|
hasError: boolean;
|
||||||
|
/** Error message if any */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** Whether a plan mode prompt was detected */
|
||||||
|
planModeDetected: boolean;
|
||||||
|
/** Last assistant message (truncated) */
|
||||||
|
lastAssistantMessage: string | null;
|
||||||
|
/** Total entries processed */
|
||||||
|
entryCount: number;
|
||||||
|
/** Last update timestamp */
|
||||||
|
lastUpdateAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptWatcherEvents {
|
||||||
|
'transcript:update': (state: TranscriptState) => void;
|
||||||
|
'transcript:complete': (state: TranscriptState) => void;
|
||||||
|
'transcript:tool_start': (toolName: string) => void;
|
||||||
|
'transcript:tool_end': (toolName: string, isError: boolean) => void;
|
||||||
|
'transcript:error': (error: Error) => void;
|
||||||
|
'transcript:plan_mode': () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Constants ==========
|
||||||
|
|
||||||
|
/** How often to check for new content when file watching fails */
|
||||||
|
const POLL_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
/** Max characters to keep for lastAssistantMessage */
|
||||||
|
const MAX_MESSAGE_LENGTH = 500;
|
||||||
|
|
||||||
|
/** Patterns that indicate plan mode / approval prompt */
|
||||||
|
const PLAN_MODE_PATTERNS = [
|
||||||
|
/ExitPlanMode/i,
|
||||||
|
/AskUserQuestion/i,
|
||||||
|
/Ready for user approval/i,
|
||||||
|
/approve.*plan/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ========== TranscriptWatcher Class ==========
|
||||||
|
|
||||||
|
export class TranscriptWatcher extends EventEmitter {
|
||||||
|
private transcriptPath: string | null = null;
|
||||||
|
private fileWatcher: FSWatcher | null = null;
|
||||||
|
private pollInterval: NodeJS.Timeout | null = null;
|
||||||
|
private filePosition: number = 0;
|
||||||
|
private _isRunning: boolean = false;
|
||||||
|
private state: TranscriptState = this.getInitialState();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialState(): TranscriptState {
|
||||||
|
return {
|
||||||
|
isComplete: false,
|
||||||
|
toolExecuting: false,
|
||||||
|
currentTool: null,
|
||||||
|
hasError: false,
|
||||||
|
errorMessage: null,
|
||||||
|
planModeDetected: false,
|
||||||
|
lastAssistantMessage: null,
|
||||||
|
entryCount: 0,
|
||||||
|
lastUpdateAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Public API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching a transcript file
|
||||||
|
* @param transcriptPath - Path to the JSONL transcript file
|
||||||
|
*/
|
||||||
|
start(transcriptPath: string): void {
|
||||||
|
if (this._isRunning && this.transcriptPath === transcriptPath) {
|
||||||
|
return; // Already watching this file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any existing watcher
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
this.transcriptPath = transcriptPath;
|
||||||
|
this._isRunning = true;
|
||||||
|
this.state = this.getInitialState();
|
||||||
|
this.filePosition = 0;
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!existsSync(transcriptPath)) {
|
||||||
|
// File doesn't exist yet, poll until it does
|
||||||
|
this.startPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial file size
|
||||||
|
try {
|
||||||
|
const stat = statSync(transcriptPath);
|
||||||
|
// Start from the end to only process new entries
|
||||||
|
this.filePosition = stat.size;
|
||||||
|
} catch {
|
||||||
|
this.filePosition = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start watching
|
||||||
|
this.setupFileWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop watching
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this._isRunning = false;
|
||||||
|
|
||||||
|
if (this.fileWatcher) {
|
||||||
|
this.fileWatcher.close();
|
||||||
|
this.fileWatcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pollInterval) {
|
||||||
|
clearInterval(this.pollInterval);
|
||||||
|
this.pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transcriptPath = null;
|
||||||
|
this.state = this.getInitialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if watcher is running
|
||||||
|
*/
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this._isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current state
|
||||||
|
*/
|
||||||
|
getState(): TranscriptState {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the transcript path (e.g., from a new hook event)
|
||||||
|
*/
|
||||||
|
updatePath(transcriptPath: string): void {
|
||||||
|
if (this.transcriptPath !== transcriptPath) {
|
||||||
|
this.start(transcriptPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Private Methods ==========
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
if (this.pollInterval) return;
|
||||||
|
|
||||||
|
this.pollInterval = setInterval(() => {
|
||||||
|
if (!this.transcriptPath || !this._isRunning) return;
|
||||||
|
|
||||||
|
if (existsSync(this.transcriptPath)) {
|
||||||
|
// File now exists, switch to file watching
|
||||||
|
clearInterval(this.pollInterval!);
|
||||||
|
this.pollInterval = null;
|
||||||
|
this.setupFileWatcher();
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupFileWatcher(): void {
|
||||||
|
if (!this.transcriptPath || !this._isRunning) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.fileWatcher = watch(this.transcriptPath, (eventType) => {
|
||||||
|
if (eventType === 'change') {
|
||||||
|
this.processNewContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial read
|
||||||
|
this.processNewContent();
|
||||||
|
} catch (err) {
|
||||||
|
// Fall back to polling if watch fails
|
||||||
|
this.emit('transcript:error', err as Error);
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processNewContent(): Promise<void> {
|
||||||
|
if (!this.transcriptPath || !this._isRunning) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = statSync(this.transcriptPath);
|
||||||
|
if (stat.size <= this.filePosition) {
|
||||||
|
return; // No new content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read new content
|
||||||
|
const newEntries = await this.readNewEntries();
|
||||||
|
|
||||||
|
for (const entry of newEntries) {
|
||||||
|
this.processEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEntries.length > 0) {
|
||||||
|
this.emit('transcript:update', this.getState());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.emit('transcript:error', err as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readNewEntries(): Promise<TranscriptEntry[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.transcriptPath) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
const stream = createReadStream(this.transcriptPath, {
|
||||||
|
start: this.filePosition,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rl = createInterface({
|
||||||
|
input: stream,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
let bytesRead = this.filePosition;
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
bytesRead += Buffer.byteLength(line, 'utf-8') + 1; // +1 for newline
|
||||||
|
|
||||||
|
if (!line.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as TranscriptEntry;
|
||||||
|
entries.push(entry);
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
this.filePosition = bytesRead;
|
||||||
|
resolve(entries);
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('error', reject);
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private processEntry(entry: TranscriptEntry): void {
|
||||||
|
this.state.entryCount++;
|
||||||
|
this.state.lastUpdateAt = entry.timestamp || new Date().toISOString();
|
||||||
|
|
||||||
|
// Handle based on entry type
|
||||||
|
switch (entry.type) {
|
||||||
|
case 'assistant':
|
||||||
|
this.handleAssistantEntry(entry);
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
this.handleResultEntry(entry);
|
||||||
|
break;
|
||||||
|
case 'user':
|
||||||
|
// User message means new turn, reset some state
|
||||||
|
this.state.isComplete = false;
|
||||||
|
this.state.hasError = false;
|
||||||
|
this.state.errorMessage = null;
|
||||||
|
break;
|
||||||
|
case 'system':
|
||||||
|
// System messages are informational
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for plan mode patterns
|
||||||
|
this.checkPlanMode(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAssistantEntry(entry: TranscriptEntry): void {
|
||||||
|
if (!entry.message?.content) return;
|
||||||
|
|
||||||
|
const content = entry.message.content;
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
this.state.lastAssistantMessage = content.slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
// Process content blocks
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
this.state.lastAssistantMessage = block.text.slice(0, MAX_MESSAGE_LENGTH);
|
||||||
|
} else if (block.type === 'tool_use' && block.name) {
|
||||||
|
// Tool started
|
||||||
|
this.state.toolExecuting = true;
|
||||||
|
this.state.currentTool = block.name;
|
||||||
|
this.emit('transcript:tool_start', block.name);
|
||||||
|
} else if (block.type === 'tool_result') {
|
||||||
|
// Tool completed
|
||||||
|
const wasError = block.is_error === true;
|
||||||
|
const toolName = this.state.currentTool;
|
||||||
|
this.state.toolExecuting = false;
|
||||||
|
this.state.currentTool = null;
|
||||||
|
if (toolName) {
|
||||||
|
this.emit('transcript:tool_end', toolName, wasError);
|
||||||
|
}
|
||||||
|
if (wasError && block.content) {
|
||||||
|
this.state.hasError = true;
|
||||||
|
this.state.errorMessage = String(block.content).slice(0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleResultEntry(entry: TranscriptEntry): void {
|
||||||
|
// Result entry indicates completion
|
||||||
|
this.state.isComplete = true;
|
||||||
|
this.state.toolExecuting = false;
|
||||||
|
this.state.currentTool = null;
|
||||||
|
|
||||||
|
if (entry.error) {
|
||||||
|
this.state.hasError = true;
|
||||||
|
this.state.errorMessage = entry.error.message?.slice(0, 200) || 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('transcript:complete', this.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkPlanMode(entry: TranscriptEntry): void {
|
||||||
|
// Check assistant messages for plan mode patterns
|
||||||
|
if (entry.type !== 'assistant' || !entry.message?.content) return;
|
||||||
|
|
||||||
|
const content = entry.message.content;
|
||||||
|
const textToCheck = typeof content === 'string'
|
||||||
|
? content
|
||||||
|
: content
|
||||||
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text)
|
||||||
|
.map(b => b.text)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
// Also check for tool_use with ExitPlanMode or AskUserQuestion
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'tool_use' && block.name) {
|
||||||
|
if (block.name === 'ExitPlanMode' || block.name === 'AskUserQuestion') {
|
||||||
|
this.state.planModeDetected = true;
|
||||||
|
this.emit('transcript:plan_mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of PLAN_MODE_PATTERNS) {
|
||||||
|
if (pattern.test(textToCheck)) {
|
||||||
|
this.state.planModeDetected = true;
|
||||||
|
this.emit('transcript:plan_mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Singleton Export ==========
|
||||||
|
|
||||||
|
export const transcriptWatcher = new TranscriptWatcher();
|
||||||
+21
-2
@@ -2153,6 +2153,19 @@ class ClaudemanApp {
|
|||||||
const waitingEl = this.$('detectionWaiting');
|
const waitingEl = this.$('detectionWaiting');
|
||||||
const confidenceEl = this.$('detectionConfidence');
|
const confidenceEl = this.$('detectionConfidence');
|
||||||
const aiCheckEl = document.getElementById('detectionAiCheck');
|
const aiCheckEl = document.getElementById('detectionAiCheck');
|
||||||
|
const hookEl = document.getElementById('detectionHook');
|
||||||
|
|
||||||
|
// Hook-based detection indicator (highest priority signals)
|
||||||
|
if (hookEl) {
|
||||||
|
if (detection.stopHookReceived || detection.idlePromptReceived) {
|
||||||
|
const hookType = detection.idlePromptReceived ? 'idle_prompt' : 'Stop';
|
||||||
|
hookEl.textContent = `🎯 ${hookType} hook received`;
|
||||||
|
hookEl.className = 'detection-hook hook-active';
|
||||||
|
hookEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
hookEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Simplified status - only show when meaningful
|
// Simplified status - only show when meaningful
|
||||||
if (detection.statusText && detection.statusText !== 'Watching...') {
|
if (detection.statusText && detection.statusText !== 'Watching...') {
|
||||||
@@ -2171,8 +2184,14 @@ class ClaudemanApp {
|
|||||||
confidenceEl.textContent = `${confidence}%`;
|
confidenceEl.textContent = `${confidence}%`;
|
||||||
confidenceEl.style.display = '';
|
confidenceEl.style.display = '';
|
||||||
confidenceEl.className = 'detection-confidence';
|
confidenceEl.className = 'detection-confidence';
|
||||||
if (confidence >= 60) confidenceEl.classList.add('high');
|
// Hook signals give 100% confidence
|
||||||
else if (confidence >= 30) confidenceEl.classList.add('medium');
|
if (detection.stopHookReceived || detection.idlePromptReceived) {
|
||||||
|
confidenceEl.classList.add('hook-confirmed');
|
||||||
|
} else if (confidence >= 60) {
|
||||||
|
confidenceEl.classList.add('high');
|
||||||
|
} else if (confidence >= 30) {
|
||||||
|
confidenceEl.classList.add('medium');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
confidenceEl.style.display = 'none';
|
confidenceEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
<span class="detection-status" id="detectionStatus"></span>
|
<span class="detection-status" id="detectionStatus"></span>
|
||||||
<span class="detection-waiting" id="detectionWaiting" style="display: none;"></span>
|
<span class="detection-waiting" id="detectionWaiting" style="display: none;"></span>
|
||||||
<span class="detection-confidence" id="detectionConfidence"></span>
|
<span class="detection-confidence" id="detectionConfidence"></span>
|
||||||
|
<span class="detection-hook" id="detectionHook" style="display: none;"></span>
|
||||||
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
|
<span class="detection-ai-check" id="detectionAiCheck" style="display: none;"></span>
|
||||||
<span class="respawn-spacer"></span>
|
<span class="respawn-spacer"></span>
|
||||||
<span class="respawn-cycles">#<span id="respawnCycleCount">0</span></span>
|
<span class="respawn-cycles">#<span id="respawnCycleCount">0</span></span>
|
||||||
|
|||||||
@@ -671,6 +671,31 @@ body {
|
|||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detection-confidence.hook-confirmed {
|
||||||
|
background: rgba(139, 92, 246, 0.3);
|
||||||
|
color: var(--purple, #a78bfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detection-hook {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
color: var(--purple, #a78bfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detection-hook.hook-active {
|
||||||
|
background: rgba(139, 92, 246, 0.25);
|
||||||
|
color: var(--purple, #a78bfa);
|
||||||
|
font-weight: 500;
|
||||||
|
animation: pulse-hook 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-hook {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-5
@@ -28,6 +28,7 @@ import { generateClaudeMd } from '../templates/claude-md.js';
|
|||||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||||
import { writeHooksConfig } from '../hooks-config.js';
|
import { writeHooksConfig } from '../hooks-config.js';
|
||||||
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage } from '../subagent-watcher.js';
|
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage } from '../subagent-watcher.js';
|
||||||
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ function sanitizeHookData(data: Record<string, unknown> | undefined): Record<str
|
|||||||
const safeFields: Record<string, unknown> = {};
|
const safeFields: Record<string, unknown> = {};
|
||||||
const allowedKeys = [
|
const allowedKeys = [
|
||||||
'hook_event_name', 'tool_name', 'tool_input', 'session_id',
|
'hook_event_name', 'tool_name', 'tool_input', 'session_id',
|
||||||
'cwd', 'permission_mode', 'stop_hook_active',
|
'cwd', 'permission_mode', 'stop_hook_active', 'transcript_path',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of allowedKeys) {
|
for (const key of allowedKeys) {
|
||||||
@@ -233,6 +234,7 @@ export class WebServer extends EventEmitter {
|
|||||||
private sessions: Map<string, Session> = new Map();
|
private sessions: Map<string, Session> = new Map();
|
||||||
private respawnControllers: Map<string, RespawnController> = new Map();
|
private respawnControllers: Map<string, RespawnController> = new Map();
|
||||||
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
|
private respawnTimers: Map<string, { timer: NodeJS.Timeout; endAt: number; startedAt: number }> = new Map();
|
||||||
|
private transcriptWatchers: Map<string, TranscriptWatcher> = new Map();
|
||||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||||
private sseClients: Set<FastifyReply> = new Set();
|
private sseClients: Set<FastifyReply> = new Set();
|
||||||
private store = getStore();
|
private store = getStore();
|
||||||
@@ -1698,11 +1700,26 @@ export class WebServer extends EventEmitter {
|
|||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal the respawn controller to block auto-accept for question prompts
|
// Signal the respawn controller based on hook event type
|
||||||
if (event === 'elicitation_dialog') {
|
const controller = this.respawnControllers.get(sessionId);
|
||||||
const controller = this.respawnControllers.get(sessionId);
|
if (controller) {
|
||||||
if (controller) {
|
if (event === 'elicitation_dialog') {
|
||||||
|
// Block auto-accept for question prompts
|
||||||
controller.signalElicitation();
|
controller.signalElicitation();
|
||||||
|
} else if (event === 'stop') {
|
||||||
|
// DEFINITIVE idle signal - Claude finished responding
|
||||||
|
controller.signalStopHook();
|
||||||
|
} else if (event === 'idle_prompt') {
|
||||||
|
// DEFINITIVE idle signal - Claude has been idle for 60+ seconds
|
||||||
|
controller.signalIdlePrompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transcript watching if transcript_path is provided
|
||||||
|
if (data && typeof data === 'object' && 'transcript_path' in data) {
|
||||||
|
const transcriptPath = String(data.transcript_path);
|
||||||
|
if (transcriptPath) {
|
||||||
|
this.startTranscriptWatcher(sessionId, transcriptPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1713,6 +1730,63 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a transcript watcher for a session.
|
||||||
|
* Creates a new watcher or updates an existing one with the new transcript path.
|
||||||
|
*/
|
||||||
|
private startTranscriptWatcher(sessionId: string, transcriptPath: string): void {
|
||||||
|
let watcher = this.transcriptWatchers.get(sessionId);
|
||||||
|
|
||||||
|
if (!watcher) {
|
||||||
|
watcher = new TranscriptWatcher();
|
||||||
|
|
||||||
|
// Wire up transcript events to the respawn controller
|
||||||
|
watcher.on('transcript:complete', () => {
|
||||||
|
const controller = this.respawnControllers.get(sessionId);
|
||||||
|
if (controller) {
|
||||||
|
controller.signalTranscriptComplete();
|
||||||
|
}
|
||||||
|
this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('transcript:plan_mode', () => {
|
||||||
|
const controller = this.respawnControllers.get(sessionId);
|
||||||
|
if (controller) {
|
||||||
|
controller.signalTranscriptPlanMode();
|
||||||
|
}
|
||||||
|
this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('transcript:tool_start', (toolName: string) => {
|
||||||
|
this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => {
|
||||||
|
this.broadcast('transcript:tool_end', { sessionId, toolName, isError, timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('transcript:error', (error: Error) => {
|
||||||
|
console.error(`[Transcript] Error for session ${sessionId}:`, error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.transcriptWatchers.set(sessionId, watcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start or update the watcher with the transcript path
|
||||||
|
watcher.updatePath(transcriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the transcript watcher for a session.
|
||||||
|
*/
|
||||||
|
private stopTranscriptWatcher(sessionId: string): void {
|
||||||
|
const watcher = this.transcriptWatchers.get(sessionId);
|
||||||
|
if (watcher) {
|
||||||
|
watcher.stop();
|
||||||
|
this.transcriptWatchers.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Persists full session state including respawn config to state.json */
|
/** Persists full session state including respawn config to state.json */
|
||||||
private persistSessionState(session: Session): void {
|
private persistSessionState(session: Session): void {
|
||||||
const state = session.toState();
|
const state = session.toState();
|
||||||
@@ -1852,6 +1926,9 @@ export class WebServer extends EventEmitter {
|
|||||||
this.respawnTimers.delete(sessionId);
|
this.respawnTimers.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop transcript watcher
|
||||||
|
this.stopTranscriptWatcher(sessionId);
|
||||||
|
|
||||||
// Clear batches and pending state updates
|
// Clear batches and pending state updates
|
||||||
this.terminalBatches.delete(sessionId);
|
this.terminalBatches.delete(sessionId);
|
||||||
this.outputBatches.delete(sessionId);
|
this.outputBatches.delete(sessionId);
|
||||||
|
|||||||
@@ -2531,4 +2531,158 @@ describe('RespawnController Timer Cleanup', () => {
|
|||||||
// Should end in stopped state without errors
|
// Should end in stopped state without errors
|
||||||
expect(controller.state).toBe('stopped');
|
expect(controller.state).toBe('stopped');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Hook-Based Detection Tests (Phase 1) ==========
|
||||||
|
|
||||||
|
describe('RespawnController Hook-Based Idle Detection', () => {
|
||||||
|
let session: MockSession;
|
||||||
|
let controller: RespawnController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
session = new MockSession();
|
||||||
|
controller = new RespawnController(session as unknown as Session, {
|
||||||
|
idleTimeoutMs: 100,
|
||||||
|
interStepDelayMs: 50,
|
||||||
|
completionConfirmMs: 50,
|
||||||
|
noOutputTimeoutMs: 500,
|
||||||
|
aiIdleCheckEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose signalStopHook method', () => {
|
||||||
|
expect(typeof controller.signalStopHook).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should expose signalIdlePrompt method', () => {
|
||||||
|
expect(typeof controller.signalIdlePrompt).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stopHookReceived in detection status when Stop hook signaled', () => {
|
||||||
|
controller.start();
|
||||||
|
expect(controller.state).toBe('watching');
|
||||||
|
|
||||||
|
controller.signalStopHook();
|
||||||
|
|
||||||
|
const status = controller.getDetectionStatus();
|
||||||
|
expect(status.stopHookReceived).toBe(true);
|
||||||
|
expect(status.stopHookTime).not.toBeNull();
|
||||||
|
expect(status.confidenceLevel).toBe(100); // Hook signals are definitive
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include hook status in statusText when Stop hook received', () => {
|
||||||
|
controller.start();
|
||||||
|
controller.signalStopHook();
|
||||||
|
|
||||||
|
const status = controller.getDetectionStatus();
|
||||||
|
expect(status.statusText).toContain('Stop hook received');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger respawn cycle after Stop hook confirmation', async () => {
|
||||||
|
const testController = new RespawnController(session as unknown as Session, {
|
||||||
|
completionConfirmMs: 50,
|
||||||
|
noOutputTimeoutMs: 500,
|
||||||
|
aiIdleCheckEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
testController.on('respawnCycleStarted', cycleStarted);
|
||||||
|
|
||||||
|
testController.start();
|
||||||
|
testController.signalStopHook();
|
||||||
|
|
||||||
|
// Wait for hook confirmation timer (3s default)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3100));
|
||||||
|
|
||||||
|
expect(cycleStarted).toHaveBeenCalled();
|
||||||
|
testController.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should immediately confirm idle when idle_prompt signaled (skip confirmation)', async () => {
|
||||||
|
const testController = new RespawnController(session as unknown as Session, {
|
||||||
|
completionConfirmMs: 50,
|
||||||
|
noOutputTimeoutMs: 500,
|
||||||
|
aiIdleCheckEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
testController.on('respawnCycleStarted', cycleStarted);
|
||||||
|
|
||||||
|
testController.start();
|
||||||
|
testController.signalIdlePrompt();
|
||||||
|
|
||||||
|
// idle_prompt skips confirmation and goes directly to idle
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(cycleStarted).toHaveBeenCalled();
|
||||||
|
testController.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel Stop hook confirmation if working patterns detected', async () => {
|
||||||
|
const testController = new RespawnController(session as unknown as Session, {
|
||||||
|
completionConfirmMs: 5000, // Long enough to not interfere with test timing
|
||||||
|
noOutputTimeoutMs: 10000,
|
||||||
|
aiIdleCheckEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
testController.on('respawnCycleStarted', cycleStarted);
|
||||||
|
|
||||||
|
testController.start();
|
||||||
|
testController.signalStopHook();
|
||||||
|
|
||||||
|
// Simulate working patterns IMMEDIATELY after hook (before confirmation)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
session.simulateWorking();
|
||||||
|
|
||||||
|
// Wait longer than hook confirmation delay (3s)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3500));
|
||||||
|
|
||||||
|
// Cycle should NOT have started because working was detected
|
||||||
|
expect(cycleStarted).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const status = testController.getDetectionStatus();
|
||||||
|
expect(status.stopHookReceived).toBe(false); // Reset by working detection
|
||||||
|
testController.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore Stop hook when not in watching state', async () => {
|
||||||
|
const testController = new RespawnController(session as unknown as Session, {
|
||||||
|
completionConfirmMs: 50,
|
||||||
|
noOutputTimeoutMs: 500,
|
||||||
|
aiIdleCheckEnabled: false,
|
||||||
|
sendClear: false,
|
||||||
|
sendInit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
testController.start();
|
||||||
|
testController.signalIdlePrompt(); // Start a cycle
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Now in sending_update state - Stop hook should be ignored
|
||||||
|
testController.signalStopHook();
|
||||||
|
|
||||||
|
const status = testController.getDetectionStatus();
|
||||||
|
expect(status.stopHookReceived).toBe(false);
|
||||||
|
testController.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have 100% confidence when hook signal is received', () => {
|
||||||
|
controller.start();
|
||||||
|
|
||||||
|
// Before hook - confidence should be low
|
||||||
|
let status = controller.getDetectionStatus();
|
||||||
|
expect(status.confidenceLevel).toBeLessThan(100);
|
||||||
|
|
||||||
|
// After Stop hook - confidence should be 100%
|
||||||
|
controller.signalStopHook();
|
||||||
|
status = controller.getDetectionStatus();
|
||||||
|
expect(status.confidenceLevel).toBe(100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { TranscriptWatcher, TranscriptState } from '../src/transcript-watcher.js';
|
||||||
|
import { writeFileSync, unlinkSync, mkdirSync, existsSync, appendFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
describe('TranscriptWatcher', () => {
|
||||||
|
let watcher: TranscriptWatcher;
|
||||||
|
let testDir: string;
|
||||||
|
let testFile: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
watcher = new TranscriptWatcher();
|
||||||
|
testDir = join(tmpdir(), `transcript-test-${Date.now()}`);
|
||||||
|
if (!existsSync(testDir)) {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
}
|
||||||
|
testFile = join(testDir, 'test-transcript.jsonl');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
watcher.stop();
|
||||||
|
// Clean up test file
|
||||||
|
try {
|
||||||
|
if (existsSync(testFile)) {
|
||||||
|
unlinkSync(testFile);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('should start in stopped state', () => {
|
||||||
|
expect(watcher.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have initial state with defaults', () => {
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.isComplete).toBe(false);
|
||||||
|
expect(state.toolExecuting).toBe(false);
|
||||||
|
expect(state.currentTool).toBeNull();
|
||||||
|
expect(state.hasError).toBe(false);
|
||||||
|
expect(state.planModeDetected).toBe(false);
|
||||||
|
expect(state.entryCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('File Watching', () => {
|
||||||
|
it('should start watching an existing file', () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
expect(watcher.isRunning()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent file by polling', () => {
|
||||||
|
const nonExistent = join(testDir, 'nonexistent.jsonl');
|
||||||
|
watcher.start(nonExistent);
|
||||||
|
expect(watcher.isRunning()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop watching on stop()', () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
expect(watcher.isRunning()).toBe(true);
|
||||||
|
watcher.stop();
|
||||||
|
expect(watcher.isRunning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update path with updatePath()', () => {
|
||||||
|
const file1 = join(testDir, 'file1.jsonl');
|
||||||
|
const file2 = join(testDir, 'file2.jsonl');
|
||||||
|
writeFileSync(file1, '');
|
||||||
|
writeFileSync(file2, '');
|
||||||
|
|
||||||
|
watcher.start(file1);
|
||||||
|
expect(watcher.isRunning()).toBe(true);
|
||||||
|
|
||||||
|
watcher.updatePath(file2);
|
||||||
|
expect(watcher.isRunning()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Entry Processing', () => {
|
||||||
|
it('should process user entry and reset state', async () => {
|
||||||
|
// Start with some state
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
|
||||||
|
// Add user entry
|
||||||
|
const userEntry = { type: 'user', timestamp: new Date().toISOString(), message: { role: 'user', content: 'test' } };
|
||||||
|
appendFileSync(testFile, JSON.stringify(userEntry) + '\n');
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.entryCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit transcript:complete on result entry', async () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
|
||||||
|
const completeHandler = vi.fn();
|
||||||
|
watcher.on('transcript:complete', completeHandler);
|
||||||
|
|
||||||
|
// Add result entry
|
||||||
|
const resultEntry = { type: 'result', timestamp: new Date().toISOString() };
|
||||||
|
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(completeHandler).toHaveBeenCalled();
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.isComplete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track tool execution', async () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
|
||||||
|
const toolStartHandler = vi.fn();
|
||||||
|
watcher.on('transcript:tool_start', toolStartHandler);
|
||||||
|
|
||||||
|
// Add assistant entry with tool_use
|
||||||
|
const assistantEntry = {
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(toolStartHandler).toHaveBeenCalledWith('Read');
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.toolExecuting).toBe(true);
|
||||||
|
expect(state.currentTool).toBe('Read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect plan mode from AskUserQuestion tool', async () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
|
||||||
|
const planModeHandler = vi.fn();
|
||||||
|
watcher.on('transcript:plan_mode', planModeHandler);
|
||||||
|
|
||||||
|
// Add assistant entry with AskUserQuestion
|
||||||
|
const assistantEntry = {
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(planModeHandler).toHaveBeenCalled();
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.planModeDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect errors in result entry', async () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
|
||||||
|
// Add result entry with error
|
||||||
|
const resultEntry = {
|
||||||
|
type: 'result',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: { type: 'api_error', message: 'Rate limited' }
|
||||||
|
};
|
||||||
|
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.hasError).toBe(true);
|
||||||
|
expect(state.errorMessage).toContain('Rate limited');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('State Management', () => {
|
||||||
|
it('should return a copy of state', () => {
|
||||||
|
const state1 = watcher.getState();
|
||||||
|
const state2 = watcher.getState();
|
||||||
|
expect(state1).not.toBe(state2); // Different objects
|
||||||
|
expect(state1).toEqual(state2); // Same content
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset state on stop()', () => {
|
||||||
|
writeFileSync(testFile, '');
|
||||||
|
watcher.start(testFile);
|
||||||
|
watcher.stop();
|
||||||
|
const state = watcher.getState();
|
||||||
|
expect(state.entryCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user