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:
arkon
2026-01-25 21:29:35 +01:00
co-authored by Claude Opus 4.5
parent ddc55b7697
commit 1ff1e300f6
9 changed files with 1527 additions and 13 deletions
+21 -2
View File
@@ -2153,6 +2153,19 @@ class ClaudemanApp {
const waitingEl = this.$('detectionWaiting');
const confidenceEl = this.$('detectionConfidence');
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
if (detection.statusText && detection.statusText !== 'Watching...') {
@@ -2171,8 +2184,14 @@ class ClaudemanApp {
confidenceEl.textContent = `${confidence}%`;
confidenceEl.style.display = '';
confidenceEl.className = 'detection-confidence';
if (confidence >= 60) confidenceEl.classList.add('high');
else if (confidence >= 30) confidenceEl.classList.add('medium');
// Hook signals give 100% confidence
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 {
confidenceEl.style.display = 'none';
}
+1
View File
@@ -73,6 +73,7 @@
<span class="detection-status" id="detectionStatus"></span>
<span class="detection-waiting" id="detectionWaiting" style="display: none;"></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="respawn-spacer"></span>
<span class="respawn-cycles">#<span id="respawnCycleCount">0</span></span>
+25
View File
@@ -671,6 +671,31 @@ body {
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 {
to { transform: rotate(360deg); }
}
+82 -5
View File
@@ -28,6 +28,7 @@ import { generateClaudeMd } from '../templates/claude-md.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { writeHooksConfig } from '../hooks-config.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 { createRequire } from 'node:module';
@@ -112,7 +113,7 @@ function sanitizeHookData(data: Record<string, unknown> | undefined): Record<str
const safeFields: Record<string, unknown> = {};
const allowedKeys = [
'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) {
@@ -233,6 +234,7 @@ export class WebServer extends EventEmitter {
private sessions: Map<string, Session> = new Map();
private respawnControllers: Map<string, RespawnController> = 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 sseClients: Set<FastifyReply> = new Set();
private store = getStore();
@@ -1698,11 +1700,26 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Signal the respawn controller to block auto-accept for question prompts
if (event === 'elicitation_dialog') {
const controller = this.respawnControllers.get(sessionId);
if (controller) {
// Signal the respawn controller based on hook event type
const controller = this.respawnControllers.get(sessionId);
if (controller) {
if (event === 'elicitation_dialog') {
// Block auto-accept for question prompts
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 */
private persistSessionState(session: Session): void {
const state = session.toState();
@@ -1852,6 +1926,9 @@ export class WebServer extends EventEmitter {
this.respawnTimers.delete(sessionId);
}
// Stop transcript watcher
this.stopTranscriptWatcher(sessionId);
// Clear batches and pending state updates
this.terminalBatches.delete(sessionId);
this.outputBatches.delete(sessionId);