diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index f0734eb9..0d326f95 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -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. diff --git a/src/types.ts b/src/types.ts index bdf3c2c2..c260fabb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 4dcfaacf..2fb65b2d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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, diff --git a/src/web/public/index.html b/src/web/public/index.html index 200150a5..d2e6e571 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -333,6 +333,17 @@ Sent only when /init completes but Claude stays idle +
+ +
+ +
+ 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 fd08403a..d09d6426 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index d18624f7..6351a837 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -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(); + }); + }); });