fix: restrict auto-accept to plan mode only, block AskUserQuestion prompts

The auto-accept feature was too aggressive - it would press Enter for any
silence without a completion message, including AskUserQuestion prompts.
Now uses the elicitation_dialog notification hook to detect when Claude is
asking a question, and blocks auto-accept in that case. Only plan mode
approvals (silence with no completion message AND no elicitation signal)
trigger auto-accept.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-24 03:10:02 +01:00
co-authored by Claude Opus 4.5
parent b4aea57d9c
commit 10222075cb
9 changed files with 136 additions and 31 deletions
+1 -1
View File
@@ -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
+4
View File
@@ -36,6 +36,10 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
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: [
{
+43 -18
View File
@@ -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.
+4 -4
View File
@@ -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;
+3 -3
View File
@@ -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'}`,
});
});
+2 -2
View File
@@ -338,11 +338,11 @@
</div>
<div class="form-row">
<label>Auto-Accept Prompts</label>
<label>Auto-Accept Plan Mode</label>
<div class="checkbox-group-inline">
<label class="checkbox-inline">
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
<span>Accept plan mode &amp; questions</span>
<span>Accept plan mode approvals</span>
</label>
</div>
<span class="form-hint">Presses Enter to accept plan approvals and default question options when Claude is waiting</span>
+10 -1
View File
@@ -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 };
});
+10 -2
View File
@@ -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);
});
+59
View File
@@ -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();
});
});
});