diff --git a/CLAUDE.md b/CLAUDE.md index 903227fb..5ba307c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -387,7 +387,7 @@ Uses `confirming_idle` state to prevent false positives. Fallback: `noOutputTime **Session**: emits `idle`/`working` events on prompt detection + 2s activity timeout. -**Auto-Accept Prompts** (enabled by default): When Claude enters plan mode or asks a question (AskUserQuestion), output stops without a completion message. After `autoAcceptDelayMs` (8s) of silence with no completion message detected, sends Enter to accept the plan/default option. Safety: only fires once per silence period, requires prior output, won't fire during active respawn cycles. +**Auto-Accept Plan Mode** (enabled by default): When Claude enters plan mode and presents a plan for approval, output stops without a completion message. After `autoAcceptDelayMs` (8s) of silence with no completion message and no `elicitation_dialog` hook signal detected, sends Enter to accept the plan. Does NOT auto-accept AskUserQuestion prompts — those are blocked via the `elicitation_dialog` notification hook which signals the respawn controller to skip auto-accept. Safety: only fires once per silence period, requires prior output, won't fire during active respawn cycles. ### Token Tracking diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 8dc7000f..c862c712 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -36,6 +36,10 @@ export function generateHooksConfig(): { hooks: Record } { matcher: 'permission_prompt', hooks: [{ type: 'command', command: curlCmd('permission_prompt'), timeout: 10000 }], }, + { + matcher: 'elicitation_dialog', + hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: 10000 }], + }, ], Stop: [ { diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 0d326f95..cc5586de 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -269,17 +269,20 @@ export interface RespawnConfig { noOutputTimeoutMs: number; /** - * Whether to auto-accept prompts (plan mode approvals, question selections). - * When Claude enters plan mode or asks a question, output stops without a completion - * message. This feature detects that state and sends Enter to accept the default option. + * Whether to auto-accept plan mode prompts by pressing Enter. + * When Claude enters plan mode and presents a plan for approval, output stops + * without a completion message. This feature detects that state and sends Enter + * to accept the plan. Does NOT auto-accept AskUserQuestion prompts (those are + * blocked via the elicitation_dialog hook signal). * @default true */ autoAcceptPrompts: boolean; /** - * Delay before auto-accepting prompts (ms). - * After no output for this duration AND no completion message detected, - * sends Enter to accept the current prompt. Must be shorter than noOutputTimeoutMs. + * Delay before auto-accepting plan mode prompts (ms). + * After no output for this duration AND no completion message detected + * AND no elicitation dialog signaled, sends Enter to accept the plan. + * Must be shorter than noOutputTimeoutMs. * @default 8000 (8 seconds) */ autoAcceptDelayMs: number; @@ -310,7 +313,7 @@ export interface RespawnEvents { stepCompleted: (step: string) => void; /** Detection status update for UI display */ detectionUpdate: (status: DetectionStatus) => void; - /** Auto-accept prompt sent (plan mode, question, etc.) */ + /** Auto-accept sent for plan mode approval */ autoAcceptSent: () => void; /** Error occurred */ error: (error: Error) => void; @@ -328,7 +331,7 @@ const DEFAULT_CONFIG: RespawnConfig = { sendInit: true, // send /init after /clear completionConfirmMs: 5000, // 5 seconds of silence after completion message noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all - autoAcceptPrompts: true, // auto-accept plan mode and question prompts + autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting }; @@ -409,12 +412,15 @@ export class RespawnController extends EventEmitter { /** Timer for periodic detection status updates */ private detectionUpdateTimer: NodeJS.Timeout | null = null; - /** Timer for auto-accepting prompts (plan mode, questions) */ + /** Timer for auto-accepting plan mode prompts */ private autoAcceptTimer: NodeJS.Timeout | null = null; /** Whether any terminal output has been received since start/last-auto-accept */ private hasReceivedOutput: boolean = false; + /** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */ + private elicitationDetected: boolean = false; + /** Number of completed respawn cycles */ private cycleCount: number = 0; @@ -782,6 +788,7 @@ export class RespawnController extends EventEmitter { if (isWorking) { this.workingDetected = true; this.promptDetected = false; + this.elicitationDetected = false; // Clear on new work cycle this.lastWorkingPatternTime = now; this.clearIdleTimer(); @@ -1109,8 +1116,8 @@ export class RespawnController extends EventEmitter { /** * Start the auto-accept timer. - * Fires after autoAcceptDelayMs of no output when no completion message was detected. - * This handles plan mode approvals and question prompts by pressing Enter. + * Fires after autoAcceptDelayMs of no output when no completion message + * and no elicitation dialog was detected. Only handles plan mode approvals. */ private startAutoAcceptTimer(): void { if (this.autoAcceptTimer) { @@ -1134,15 +1141,16 @@ export class RespawnController extends EventEmitter { } /** - * Attempt to auto-accept a prompt by sending Enter. + * Attempt to auto-accept a plan mode prompt by sending Enter. * Only fires when: * - In 'watching' state (not mid-cycle) * - No completion message was detected (Claude is waiting for input, not truly idle) + * - No elicitation dialog was detected (not an AskUserQuestion prompt) * - autoAcceptPrompts is enabled * - * This handles Claude's plan mode (waiting for approval) and - * AskUserQuestion (waiting for option selection) by pressing Enter - * to accept the default/currently-selected option. + * This handles Claude's plan mode (waiting for approval) by pressing Enter + * to accept the plan. It does NOT auto-accept AskUserQuestion prompts - + * those require explicit user interaction. * * @fires autoAcceptSent */ @@ -1159,10 +1167,16 @@ export class RespawnController extends EventEmitter { // Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start) if (!this.hasReceivedOutput) return; - const msSinceOutput = Date.now() - this.lastOutputTime; - this.log(`Auto-accepting prompt (${msSinceOutput}ms silence, no completion message)`); + // Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected + if (this.elicitationDetected) { + this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)'); + return; + } - // Send Enter to accept the current prompt/selection + const msSinceOutput = Date.now() - this.lastOutputTime; + this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, no completion message, no elicitation)`); + + // Send Enter to accept the plan this.session.writeViaScreen('\r'); this.emit('autoAcceptSent'); @@ -1170,6 +1184,17 @@ export class RespawnController extends EventEmitter { this.hasReceivedOutput = false; } + /** + * Signal that an elicitation dialog (AskUserQuestion) was detected via hook. + * This prevents auto-accept from firing, since the user needs to make a selection. + * The flag is cleared when working patterns are detected (new turn starts). + */ + signalElicitation(): void { + this.elicitationDetected = true; + this.cancelAutoAcceptTimer(); + this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle'); + } + /** * Start completion confirmation timer. * After completion message, waits for output silence. diff --git a/src/types.ts b/src/types.ts index 406445cf..8c6bb28c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -207,9 +207,9 @@ export interface RespawnConfig { completionConfirmMs?: number; /** Fallback timeout when no output received at all (ms) */ noOutputTimeoutMs?: number; - /** Whether to auto-accept prompts (plan mode approvals, question selections) by pressing Enter */ + /** Whether to auto-accept plan mode prompts by pressing Enter (not questions) */ autoAcceptPrompts?: boolean; - /** Delay before auto-accepting prompts when no output and no completion message (ms) */ + /** Delay before auto-accepting plan mode prompts when no output and no completion message (ms) */ autoAcceptDelayMs?: number; } @@ -372,7 +372,7 @@ export interface QuickRunRequest { /** * Hook event types triggered by Claude Code's hooks system */ -export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'stop'; +export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop'; /** * Request body for the hook-event API endpoint @@ -509,7 +509,7 @@ export interface PersistedRespawnConfig { sendInit: boolean; /** Optional prompt to send if /init doesn't trigger work */ kickstartPrompt?: string; - /** Whether to auto-accept prompts (plan mode, questions) by pressing Enter */ + /** Whether to auto-accept plan mode prompts by pressing Enter (not questions) */ autoAcceptPrompts?: boolean; /** Delay before auto-accepting prompts (ms) */ autoAcceptDelayMs?: number; diff --git a/src/web/public/app.js b/src/web/public/app.js index c8a7b30f..2db8b175 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -888,7 +888,7 @@ class ClaudemanApp { this.eventSource.addEventListener('respawn:autoAcceptSent', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { - document.getElementById('respawnStep').textContent = '⏎ Auto-accepted prompt'; + document.getElementById('respawnStep').textContent = '⏎ Auto-accepted plan'; } const session = this.sessions.get(data.sessionId); this.notificationManager?.notify({ @@ -896,8 +896,8 @@ class ClaudemanApp { category: 'auto-accept', sessionId: data.sessionId, sessionName: session?.name || data.sessionId?.slice(0, 8), - title: 'Auto-Accepted', - message: `Accepted prompt for ${session?.name || 'session'}`, + title: 'Plan Accepted', + message: `Accepted plan mode for ${session?.name || 'session'}`, }); }); diff --git a/src/web/public/index.html b/src/web/public/index.html index 30406fdd..24989dfa 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -338,11 +338,11 @@
- +
Presses Enter to accept plan approvals and default question options when Claude is waiting diff --git a/src/web/server.ts b/src/web/server.ts index dcb586c4..82481a08 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1460,13 +1460,22 @@ export class WebServer extends EventEmitter { this.app.post('/api/hook-event', async (req) => { const { event, sessionId } = req.body as HookEventRequest; - const validEvents = ['idle_prompt', 'permission_prompt', 'stop'] as const; + const validEvents = ['idle_prompt', 'permission_prompt', 'elicitation_dialog', 'stop'] as const; if (!event || !validEvents.includes(event as typeof validEvents[number])) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid event type'); } if (!sessionId || !this.sessions.has(sessionId)) { 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) { + controller.signalElicitation(); + } + } + this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now() }); return { success: true }; }); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 54ccfc2a..2ae07695 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -20,7 +20,7 @@ describe('generateHooksConfig', () => { it('should have Notification hooks array', () => { const config = generateHooksConfig(); expect(config.hooks.Notification).toBeInstanceOf(Array); - expect(config.hooks.Notification).toHaveLength(2); + expect(config.hooks.Notification).toHaveLength(3); }); it('should have Stop hooks array', () => { @@ -43,6 +43,13 @@ describe('generateHooksConfig', () => { expect(permHook).toBeDefined(); }); + it('should configure elicitation_dialog matcher', () => { + const config = generateHooksConfig(); + const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; + const elicitHook = notifHooks.find(h => h.matcher === 'elicitation_dialog'); + expect(elicitHook).toBeDefined(); + }); + it('should use env vars in curl commands (not hardcoded URLs)', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; @@ -69,6 +76,7 @@ describe('generateHooksConfig', () => { const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; expect(notifHooks[0].hooks[0].command).toContain('"idle_prompt"'); expect(notifHooks[1].hooks[0].command).toContain('"permission_prompt"'); + expect(notifHooks[2].hooks[0].command).toContain('"elicitation_dialog"'); const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ command: string }> }>; expect(stopHooks[0].hooks[0].command).toContain('"stop"'); }); @@ -118,7 +126,7 @@ describe('writeHooksConfig', () => { const settingsPath = join(testDir, '.claude', 'settings.local.json'); const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(parsed.hooks).toBeDefined(); - expect(parsed.hooks.Notification).toHaveLength(2); + expect(parsed.hooks.Notification).toHaveLength(3); expect(parsed.hooks.Stop).toHaveLength(1); }); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index 6351a837..404fd894 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -1055,5 +1055,64 @@ describe('RespawnController Edge Cases', () => { expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); }); + + it('should NOT auto-accept when elicitation dialog is signaled', async () => { + const autoAcceptController = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + }); + + let autoAcceptFired = false; + autoAcceptController.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + autoAcceptController.start(); + session.simulateTerminalOutput('Which option do you prefer?'); + + // Signal that an elicitation dialog (AskUserQuestion) was detected + autoAcceptController.signalElicitation(); + + // Wait for autoAcceptDelayMs to expire + await new Promise(resolve => setTimeout(resolve, 200)); + + // Auto-accept should NOT fire because elicitation was signaled + expect(autoAcceptFired).toBe(false); + autoAcceptController.stop(); + }); + + it('should clear elicitation flag when working patterns detected', async () => { + const autoAcceptController = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 50, + noOutputTimeoutMs: 5000, + }); + + let autoAcceptFired = false; + autoAcceptController.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + autoAcceptController.start(); + session.simulateTerminalOutput('Question output'); + + // Signal elicitation + autoAcceptController.signalElicitation(); + + // Working pattern clears the elicitation flag (new turn started) + session.simulateTerminalOutput('Thinking'); + + // New silence after work - plan mode approval + session.simulateTerminalOutput('Plan: Here is the plan...'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // Auto-accept should fire now (elicitation cleared by working pattern) + expect(autoAcceptFired).toBe(true); + autoAcceptController.stop(); + }); }); });