feat: auto-accept plan mode and question prompts in respawn controller

When Claude enters plan mode or asks a question (AskUserQuestion), output
stops without a completion message. The new autoAcceptPrompts feature
detects this state and sends Enter after a configurable delay (default 8s)
to accept the plan or select the default option, keeping Claude working
autonomously.

Enabled by default. Adds UI checkbox in the respawn config panel.
Safety: only fires once per silence period, requires prior output,
and won't fire during active respawn cycles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-23 01:25:09 +01:00
co-authored by Claude Opus 4.5
parent 619f2a3403
commit 0e8c8b8c60
6 changed files with 354 additions and 2 deletions
+113 -2
View File
@@ -267,6 +267,22 @@ export interface RespawnConfig {
* @default 30000 (30 seconds)
*/
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.
* @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.
* @default 8000 (8 seconds)
*/
autoAcceptDelayMs: number;
}
/**
@@ -294,6 +310,8 @@ 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.) */
autoAcceptSent: () => void;
/** Error occurred */
error: (error: Error) => void;
/** Debug log message */
@@ -310,6 +328,8 @@ 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
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
};
/**
@@ -389,6 +409,12 @@ 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) */
private autoAcceptTimer: NodeJS.Timeout | null = null;
/** Whether any terminal output has been received since start/last-auto-accept */
private hasReceivedOutput: boolean = false;
/** Number of completed respawn cycles */
private cycleCount: number = 0;
@@ -642,11 +668,15 @@ export class RespawnController extends EventEmitter {
this.lastTokenChangeTime = now;
this.lastWorkingPatternTime = now;
this.completionMessageTime = null;
this.hasReceivedOutput = false;
this.setState('watching');
this.setupTerminalListener();
this.startDetectionUpdates();
this.startNoOutputTimer();
if (this.config.autoAcceptPrompts) {
this.startAutoAcceptTimer();
}
}
/**
@@ -736,7 +766,9 @@ export class RespawnController extends EventEmitter {
// Track output time (Layer 2)
this.lastOutputTime = now;
this.lastActivityTime = now;
this.hasReceivedOutput = true;
this.resetNoOutputTimer();
this.resetAutoAcceptTimer();
// Track token count (Layer 3)
const tokenCount = this.extractTokenCount(data);
@@ -772,6 +804,7 @@ export class RespawnController extends EventEmitter {
if (this.isCompletionMessage(data)) {
this.completionMessageTime = now;
this.workingDetected = false;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
@@ -975,7 +1008,7 @@ export class RespawnController extends EventEmitter {
}
}
/** Clear all timers (idle, step, completion confirm, no-output, step confirm, and clear fallback) */
/** Clear all timers (idle, step, completion confirm, no-output, step confirm, auto-accept, and clear fallback) */
private clearTimers(): void {
this.clearIdleTimer();
if (this.stepTimer) {
@@ -993,7 +1026,10 @@ export class RespawnController extends EventEmitter {
if (this.stepConfirmTimer) {
clearTimeout(this.stepConfirmTimer);
this.stepConfirmTimer = null;
}
if (this.autoAcceptTimer) {
clearTimeout(this.autoAcceptTimer);
this.autoAcceptTimer = null;
}
if (this.noOutputTimer) {
clearTimeout(this.noOutputTimer);
@@ -1059,6 +1095,81 @@ export class RespawnController extends EventEmitter {
this.startNoOutputTimer();
}
// ========== Auto-Accept Prompt Methods ==========
/**
* Reset the auto-accept timer.
* Called whenever output is received. After autoAcceptDelayMs of silence
* (without a completion message), sends Enter to accept prompts.
*/
private resetAutoAcceptTimer(): void {
if (!this.config.autoAcceptPrompts) return;
this.startAutoAcceptTimer();
}
/**
* 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.
*/
private startAutoAcceptTimer(): void {
if (this.autoAcceptTimer) {
clearTimeout(this.autoAcceptTimer);
}
this.autoAcceptTimer = setTimeout(() => {
this.autoAcceptTimer = null;
this.tryAutoAccept();
}, this.config.autoAcceptDelayMs);
}
/**
* Cancel the auto-accept timer.
* Called when a completion message is detected (normal idle flow handles it).
*/
private cancelAutoAcceptTimer(): void {
if (this.autoAcceptTimer) {
clearTimeout(this.autoAcceptTimer);
this.autoAcceptTimer = null;
}
}
/**
* Attempt to auto-accept a 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)
* - 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.
*
* @fires autoAcceptSent
*/
private tryAutoAccept(): void {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return;
// 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)`);
// Send Enter to accept the current prompt/selection
this.session.writeViaScreen('\r');
this.emit('autoAcceptSent');
// Reset so we don't keep spamming Enter if Claude doesn't respond
this.hasReceivedOutput = false;
}
/**
* Start completion confirmation timer.
* After completion message, waits for output silence.
+8
View File
@@ -203,6 +203,10 @@ 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 */
autoAcceptPrompts?: boolean;
/** Delay before auto-accepting prompts when no output and no completion message (ms) */
autoAcceptDelayMs?: number;
}
/**
@@ -484,6 +488,10 @@ 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 */
autoAcceptPrompts?: boolean;
/** Delay before auto-accepting prompts (ms) */
autoAcceptDelayMs?: number;
/** Duration in minutes if timed respawn was set */
durationMinutes?: number;
}
+11
View File
@@ -503,6 +503,13 @@ 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';
}
});
this.eventSource.addEventListener('respawn:detectionUpdate', (e) => {
const data = JSON.parse(e.data);
if (this.respawnStatus[data.sessionId]) {
@@ -1667,6 +1674,7 @@ class ClaudemanApp {
sendClear: document.getElementById('modalRespawnSendClear').checked,
sendInit: document.getElementById('modalRespawnSendInit').checked,
kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined,
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
};
try {
await fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, {
@@ -1689,6 +1697,7 @@ class ClaudemanApp {
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || '';
document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true;
// Restore duration if set
if (c.durationMinutes) {
const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`);
@@ -1749,6 +1758,7 @@ class ClaudemanApp {
const sendClear = document.getElementById('modalRespawnSendClear').checked;
const sendInit = document.getElementById('modalRespawnSendInit').checked;
const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined;
const autoAcceptPrompts = document.getElementById('modalRespawnAutoAccept').checked;
const durationMinutes = this.getSelectedDuration();
// Auto-compact settings
@@ -1766,6 +1776,7 @@ class ClaudemanApp {
sendClear,
sendInit,
kickstartPrompt,
autoAcceptPrompts,
},
durationMinutes,
autoCompactEnabled,
+11
View File
@@ -333,6 +333,17 @@
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
</div>
<div class="form-row">
<label>Auto-Accept Prompts</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>
</label>
</div>
<span class="form-hint">Presses Enter to accept plan approvals and default question options when Claude is waiting</span>
</div>
<div class="form-row">
<label>Auto-Compact Context</label>
<div class="auto-clear-row">
+12
View File
@@ -690,6 +690,8 @@ export class WebServer extends EventEmitter {
sendClear: config.sendClear ?? currentConfig?.sendClear ?? true,
sendInit: config.sendInit ?? currentConfig?.sendInit ?? true,
kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt,
autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true,
autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000,
durationMinutes: currentConfig?.durationMinutes,
};
this.screenManager.updateRespawnConfig(id, merged);
@@ -1306,6 +1308,8 @@ export class WebServer extends EventEmitter {
sendClear: config.sendClear,
sendInit: config.sendInit,
kickstartPrompt: config.kickstartPrompt,
autoAcceptPrompts: config.autoAcceptPrompts,
autoAcceptDelayMs: config.autoAcceptDelayMs,
durationMinutes,
};
this.screenManager.updateRespawnConfig(sessionId, persistedConfig);
@@ -1537,6 +1541,10 @@ export class WebServer extends EventEmitter {
this.broadcast('respawn:detectionUpdate', { sessionId, detection });
});
controller.on('autoAcceptSent', () => {
this.broadcast('respawn:autoAcceptSent', { sessionId });
});
controller.on('log', (message: string) => {
this.broadcast('respawn:log', { sessionId, message });
});
@@ -2008,6 +2016,8 @@ export class WebServer extends EventEmitter {
kickstartPrompt: savedState.respawnConfig.kickstartPrompt,
completionConfirmMs: savedState.respawnConfig.completionConfirmMs,
noOutputTimeoutMs: savedState.respawnConfig.noOutputTimeoutMs,
autoAcceptPrompts: savedState.respawnConfig.autoAcceptPrompts ?? true,
autoAcceptDelayMs: savedState.respawnConfig.autoAcceptDelayMs ?? 8000,
});
this.respawnControllers.set(session.id, controller);
this.setupRespawnListeners(session.id, controller);
@@ -2035,6 +2045,8 @@ export class WebServer extends EventEmitter {
sendClear: screen.respawnConfig.sendClear,
sendInit: screen.respawnConfig.sendInit,
kickstartPrompt: screen.respawnConfig.kickstartPrompt,
autoAcceptPrompts: screen.respawnConfig.autoAcceptPrompts ?? true,
autoAcceptDelayMs: screen.respawnConfig.autoAcceptDelayMs ?? 8000,
});
this.respawnControllers.set(session.id, controller);
this.setupRespawnListeners(session.id, controller);
+199
View File
@@ -857,4 +857,203 @@ describe('RespawnController Edge Cases', () => {
expect(status.timeSinceActivity).toBeLessThan(100);
controller.stop();
});
describe('Auto-Accept Prompts', () => {
it('should have autoAcceptPrompts enabled by default', () => {
const defaultController = new RespawnController(session as unknown as Session);
const config = defaultController.getConfig();
expect(config.autoAcceptPrompts).toBe(true);
expect(config.autoAcceptDelayMs).toBe(8000);
defaultController.stop();
});
it('should send Enter after silence without completion message', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100, // Short delay for testing
completionConfirmMs: 50,
noOutputTimeoutMs: 5000,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
// Simulate output (e.g., plan content), then silence
session.simulateTerminalOutput('Plan: Here is my implementation plan...');
// Wait for autoAcceptDelayMs to expire
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
expect(session.writeBuffer).toContain('\r');
autoAcceptController.stop();
});
it('should NOT send Enter when completion message was detected', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 200, // Longer than autoAcceptDelay
noOutputTimeoutMs: 5000,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
// Simulate completion message - normal idle flow should handle this
session.simulateCompletionMessage();
// Wait for autoAcceptDelayMs
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
});
it('should NOT send Enter when disabled', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: false,
autoAcceptDelayMs: 100,
completionConfirmMs: 50,
noOutputTimeoutMs: 5000,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
session.simulateTerminalOutput('Plan: Waiting for approval...');
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
});
it('should NOT send Enter before any output is received', 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();
// Don't simulate any output - just wait
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
});
it('should reset timer when new output arrives', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 150,
completionConfirmMs: 50,
noOutputTimeoutMs: 5000,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
session.simulateTerminalOutput('First output');
// Wait 100ms (less than 150ms delay), then send more output
await new Promise(resolve => setTimeout(resolve, 100));
session.simulateTerminalOutput('More output');
// Wait another 100ms - total 200ms from start but only 100ms from last output
await new Promise(resolve => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(false);
// Wait the remaining time
await new Promise(resolve => setTimeout(resolve, 100));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
it('should only send Enter once per silence period', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 50,
noOutputTimeoutMs: 5000,
});
let autoAcceptCount = 0;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptCount++;
});
autoAcceptController.start();
session.simulateTerminalOutput('Plan waiting...');
// Wait for first auto-accept
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// Wait more - should NOT fire again (hasReceivedOutput is false)
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(1);
// New output comes in, then silence again - should fire again
session.simulateTerminalOutput('Another question...');
await new Promise(resolve => setTimeout(resolve, 200));
expect(autoAcceptCount).toBe(2);
autoAcceptController.stop();
});
it('should NOT auto-accept during respawn cycle (non-watching state)', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 50,
completionConfirmMs: 50,
interStepDelayMs: 50,
noOutputTimeoutMs: 5000,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
// Trigger a respawn cycle via completion message
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 150));
// Now in sending_update or waiting_update state
expect(autoAcceptController.state).not.toBe('watching');
// Simulate output in the waiting state, then silence
session.simulateTerminalOutput('Processing update...');
await new Promise(resolve => setTimeout(resolve, 150));
// Auto-accept should NOT fire because we're not in watching state
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
});
});
});