mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +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:
+82
-5
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user