mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59: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:
+21
-2
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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