mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 & 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
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user