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
+223 -6
View File
@@ -94,6 +94,15 @@ export interface ActiveTimerInfo {
}
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") */
completionMessageDetected: boolean;
/** 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 */
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 */
private cycleCount: number = 0;
@@ -795,11 +824,16 @@ export class RespawnController extends EventEmitter {
const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns
// Calculate confidence level (0-100)
// Hook signals are definitive (100% confidence)
let confidence = 0;
if (completionMessageDetected) confidence += 40;
if (outputSilent) confidence += 25;
if (tokensStable) confidence += 20;
if (workingPatternsAbsent) confidence += 15;
if (this.stopHookReceived || this.idlePromptReceived) {
confidence = 100;
} else {
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
let statusText: string;
@@ -808,6 +842,10 @@ export class RespawnController extends EventEmitter {
if (this._state === 'stopped') {
statusText = 'Controller stopped';
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') {
statusText = 'AI Check: Analyzing terminal output...';
waitingFor = 'AI verdict (IDLE or WORKING)';
@@ -901,6 +939,10 @@ export class RespawnController extends EventEmitter {
}
return {
stopHookReceived: this.stopHookReceived,
stopHookTime: this.stopHookTime,
idlePromptReceived: this.idlePromptReceived,
idlePromptTime: this.idlePromptTime,
completionMessageDetected,
completionMessageTime: this.completionMessageTime,
outputSilent,
@@ -997,7 +1039,7 @@ export class RespawnController extends EventEmitter {
this.log('Starting respawn controller (multi-layer detection)');
// Initialize all timestamps
// Initialize all timestamps and reset hook state
const now = Date.now();
this.lastActivityTime = now;
this.lastOutputTime = now;
@@ -1005,6 +1047,7 @@ export class RespawnController extends EventEmitter {
this.lastWorkingPatternTime = now;
this.completionMessageTime = null;
this.hasReceivedOutput = false;
this.resetHookState();
// Seed the terminal buffer from the session's existing output.
// 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.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
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
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 {
this.clearIdleTimer();
if (this.stepTimer) {
@@ -1427,6 +1475,10 @@ export class RespawnController extends EventEmitter {
clearTimeout(this.noOutputTimer);
this.noOutputTimer = null;
}
if (this.hookConfirmTimer) {
clearTimeout(this.hookConfirmTimer);
this.hookConfirmTimer = null;
}
// Clear all tracked timers
this.activeTimers.clear();
}
@@ -1940,6 +1992,163 @@ export class RespawnController extends EventEmitter {
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.
* After completion message, waits for output silence then triggers AI check.
@@ -2183,6 +2392,14 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear();
this.promptDetected = 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();
}
}
/**
+422
View File
@@ -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
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);