diff --git a/CHANGELOG.md b/CHANGELOG.md index 376b9abc..a055ef31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # aicodeman +## 0.7.1 + +### Patch Changes + +- **fix(respawn): auto-accept now fires on plan approvals after `Worked for X` line, and on AskUserQuestion menus** + + Two related blockers in the respawn controller's auto-accept path: + - Modern Claude Code emits `✻ Worked for Xm Ys` immediately before a plan-approval menu. `_detectCompletionMessage()` cancelled the auto-accept timer and `canAutoAccept()` then rejected on `completionMessageTime !== null`, so plan approvals **never** auto-accepted — the 10 s completion-confirm timer instead started a respawn cycle while the menu sat unanswered. + - The same logic in `signalElicitation()` set a hard flag that blocked auto-accept whenever Claude Code fired the `elicitation_dialog` hook, contradicting the in-UI hint ("Auto-accept presses Enter for plan approvals **and default question options**"). AskUserQuestion menus were therefore never auto-accepted either. + + Fix: + - `_detectCompletionMessage()` no longer cancels the auto-accept timer; the auto-accept pre-filter is now the authoritative "is there a numbered selection menu?" gate. + - `canAutoAccept()` and the AI-plan-check callback both accept `'watching'` AND `'confirming_idle'` states (covers the single-PTY-burst case where `Worked for` and the menu arrive together — `_detectCompletionMessage` returns early before the substantial-output check can demote state back to watching). `sendAutoAcceptEnter()` self-transitions back to `'watching'` before sending Enter. + - `signalElicitation()` is now an affirmative hint that primes the auto-accept timer instead of blocking. Still gated on `config.autoAcceptPrompts` AND state ∈ {`watching`, `confirming_idle`} — never fires Enter when respawn is off or auto-accept is disabled. + - AI plan-check prompt broadened to recognize AskUserQuestion / elicitation menus as valid for auto-accept (the verdict name `PLAN_MODE` is preserved for compatibility but now means "auto-accept this selection menu"). + - Removed the now-unused `elicitationDetected` field and its assignments. + + Two new regression tests cover both the separate-PTY-chunk and single-PTY-chunk cases; the previously misleading "should NOT send Enter when completion message was detected" test was renamed and re-scoped to clarify it tests the **no-menu** path (which still correctly rejects via the pre-filter). + + **docs(web): correct `sendPendingCtrlL` comment** — removed the stale "called by foo/bar" note from the dead-call-graph helper after #99. + ## 0.7.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index 61d446e8..b172ede4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. -**Version**: 0.7.0 (must match `package.json`) +**Version**: 0.7.1 (must match `package.json`) ## Project Overview diff --git a/package-lock.json b/package-lock.json index 2f1778d0..6b683da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "0.7.0", + "version": "0.7.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 486eabc6..f10feb7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.7.0", + "version": "0.7.1", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts index 711c98a4..d8dfdd8d 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -64,20 +64,27 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = { const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i; /** The prompt sent to the AI plan checker */ -const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not. +const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a NUMBERED SELECTION MENU that is waiting for the user to press Enter on the highlighted default option. -A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics: +A qualifying menu has all of these characteristics: - A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own") -- A selection indicator arrow (❯ or >) pointing to one of the options -- Text asking for approval like "Would you like to proceed?" or "Ready to implement?" -- The prompt appears at the BOTTOM of the output (most recent content) +- A selection indicator arrow (❯ or >) pointing to one of the options (the default) +- The menu appears at the BOTTOM of the output (most recent content) +- It is asking the user to choose, not just displaying numbered information -NOT a plan mode prompt: +This includes BOTH: +- Plan-mode approval prompts ("Would you like to proceed?" / "Ready to implement?") +- AskUserQuestion / elicitation dialogs (Claude Code's numbered question menus) + +NOT a qualifying menu: - Claude actively working (spinners, "Thinking", tool execution) -- A completed response with no selection menu -- An AskUserQuestion/elicitation dialog (different format, free-text input) +- A completed response with no selection menu visible +- A free-text input field with no numbered options +- A numbered LIST in the assistant's prose with no selection arrow - Network lag or mid-output pause -- Any state without a visible numbered selection menu +- Any state without a visible selector arrow on a numbered option + +The verdict name PLAN_MODE is historical — it now means "auto-accept this selection menu by pressing Enter on the default". Terminal output (most recent at bottom): --- diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 6ed14dac..baceef4f 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -513,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = { sendInit: true, // send /init after /clear completionConfirmMs: 10000, // 10 seconds of silence after completion message noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all - autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) + autoAcceptPrompts: true, // auto-accept numbered selection menus (plan approvals + question dialogs) autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckModel: AI_CHECK_MODEL, @@ -623,9 +623,6 @@ export class RespawnController extends EventEmitter { /** 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; - // ========== Hook-Based Detection State (Layer 0 - Highest Priority) ========== /** Whether a Stop hook was received (definitive idle signal from Claude Code) */ @@ -1369,7 +1366,12 @@ export class RespawnController extends EventEmitter { this.clearWorkingPatternWindow(); this.workingDetected = false; this.completionMessageTime = now; - this.cancelAutoAcceptTimer(); // Normal idle flow handles this + // Don't cancel the auto-accept timer here — modern Claude Code emits "Worked for X" + // immediately before a plan-approval menu, and the auto-accept pre-filter is + // responsible for distinguishing menu-present from menu-absent. Cancelling here + // would silently block auto-accept for every plan approval and AskUserQuestion + // dialog. If no menu is in the buffer, the pre-filter rejects and the + // completion-confirm timer (started below) drives the normal idle flow. this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`); // In watching state, start completion confirmation timer @@ -1417,7 +1419,6 @@ export class RespawnController extends EventEmitter { this.workingDetected = true; this.promptDetected = false; - this.elicitationDetected = false; // Clear on new work cycle this.resetHookState(); // Clear hook signals on new work this.lastWorkingPatternTime = now; @@ -2222,11 +2223,11 @@ export class RespawnController extends EventEmitter { * @returns True if auto-accept should proceed to the AI confirmation stage */ private canAutoAccept(): boolean { - // Only auto-accept in watching state (not during a respawn cycle) - if (this._state !== 'watching') return false; - - // Don't auto-accept if a completion message was detected (normal idle handles it) - if (this.completionMessageTime !== null) return false; + // Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached + // when "Worked for X" was detected — which Claude Code now emits in the same PTY + // burst as a plan-approval menu. `sendAutoAcceptEnter()` self-transitions back to + // 'watching' before sending Enter. Reject any other state (respawn cycle, etc.). + if (this._state !== 'watching' && this._state !== 'confirming_idle') return false; // Don't auto-accept if disabled if (!this.config.autoAcceptPrompts) return false; @@ -2234,15 +2235,15 @@ 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 false; - // Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected - if (this.elicitationDetected) { - this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)'); - return false; - } + // Note: completionMessageTime and elicitationDetected used to block here, but both + // legitimately co-occur with selection menus (Claude Code emits "Worked for X" + // before plan approvals, and AskUserQuestion fires the elicitation hook). The + // pre-filter below is the authoritative gate for "is there a numbered menu?". - // Stage 1: Pre-filter — check if buffer looks like plan mode + // Stage 1: Pre-filter — check if buffer looks like a numbered selection menu + // (covers both plan-mode approvals and AskUserQuestion dialogs) if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) { - this.log('Skipping auto-accept: pre-filter did not match plan mode patterns'); + this.log('Skipping auto-accept: pre-filter did not match selection-menu patterns'); return false; } @@ -2308,8 +2309,10 @@ export class RespawnController extends EventEmitter { } if (result.verdict === 'PLAN_MODE') { - // Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began) - if (this._state !== 'watching') { + // Don't send Enter if state moved into a respawn cycle while the check ran. + // 'watching' and 'confirming_idle' are both valid — sendAutoAcceptEnter() + // self-transitions to 'watching' before sending. + if (this._state !== 'watching' && this._state !== 'confirming_idle') { this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`); return; } @@ -2368,13 +2371,18 @@ export class RespawnController extends EventEmitter { /** * 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). + * Used as a positive hint that a numbered selection menu is about to render — + * we restart the auto-accept timer so the pre-filter gets a fresh shot at it + * once the menu finishes drawing. The actual gate is `isPlanModePreFilterMatch()` + * plus (optionally) the AI plan check; this hook just primes the timer. + * No-op if respawn isn't `'watching'`/`'confirming_idle'` or `autoAcceptPrompts` + * is off, so this can never fire Enter when the user has disabled auto-accept. */ signalElicitation(): void { - this.elicitationDetected = true; - this.cancelAutoAcceptTimer(); - this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle'); + this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches'); + if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) { + this.startAutoAcceptTimer(); + } } /** diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index 2ab87a5e..0b7bb2ea 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -931,13 +931,17 @@ describe('RespawnController Edge Cases', () => { autoAcceptController.stop(); }); - it('should NOT send Enter when completion message was detected', async () => { + it('should NOT send Enter when completion message has no selection menu', async () => { + // A bare "Worked for X" with no menu following must not trigger auto-accept — + // the pre-filter is what gates this (since v0.7+, completion-message + menu IS + // allowed; see "should send Enter even when a completion message preceded the menu"). const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 200, // Longer than autoAcceptDelay noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, }); let autoAcceptFired = false; @@ -947,7 +951,7 @@ describe('RespawnController Edge Cases', () => { autoAcceptController.start(); - // Simulate completion message - normal idle flow should handle this + // Completion message with no menu — pre-filter must reject session.simulateCompletionMessage(); // Wait for autoAcceptDelayMs @@ -957,6 +961,97 @@ describe('RespawnController Edge Cases', () => { autoAcceptController.stop(); }); + it('should send Enter when completion message and menu arrive in separate PTY chunks', async () => { + // Modern Claude Code emits "Worked for X" right before a plan-approval menu. + // Two-chunk path: completion detected → confirming_idle, menu chunk hits the + // substantial-output cancel and brings state back to watching. + const autoAcceptController = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 1000, // Longer than autoAcceptDelay so timer wins + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, // Pre-filter only for this test + }); + + let autoAcceptFired = false; + autoAcceptController.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + autoAcceptController.start(); + + // "Worked for X" first (as Claude Code emits it before the menu)… + session.simulateCompletionMessage(); + // …then the plan-approval menu renders. + session.simulateTerminalOutput('\nWould you like to proceed?\n❯ 1. Yes\n 2. No\n'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(autoAcceptFired).toBe(true); + autoAcceptController.stop(); + }); + + it('should send Enter when completion message and menu arrive in a single PTY chunk', async () => { + // Same-burst path: "Worked for X" and the menu arrive in one data chunk. + // _detectCompletionMessage returns early so the substantial-output cancel + // never fires — state stays 'confirming_idle'. canAutoAccept must accept it. + const autoAcceptController = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 1000, // Longer than autoAcceptDelay so auto-accept wins + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, + }); + + let autoAcceptFired = false; + autoAcceptController.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + autoAcceptController.start(); + + // Single chunk: completion message immediately followed by the menu. + session.simulateTerminalOutput('✻ Worked for 1m 30s\n\nWould you like to proceed?\n❯ 1. Yes\n 2. No\n'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(autoAcceptFired).toBe(true); + autoAcceptController.stop(); + }); + + it('should send Enter on AskUserQuestion menu after elicitation hook fires', async () => { + // The elicitation_dialog hook now hints "menu coming" rather than blocking. + // Once the menu renders, pre-filter matches and auto-accept fires. + const autoAcceptController = new RespawnController(session as unknown as Session, { + autoAcceptPrompts: true, + autoAcceptDelayMs: 100, + completionConfirmMs: 5000, + noOutputTimeoutMs: 5000, + aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, + }); + + let autoAcceptFired = false; + autoAcceptController.on('autoAcceptSent', () => { + autoAcceptFired = true; + }); + + autoAcceptController.start(); + + // Question prose, then hook fires (Claude Code signals dialog opening)… + session.simulateTerminalOutput('Which option do you prefer?'); + autoAcceptController.signalElicitation(); + // …then the numbered menu renders. + session.simulateTerminalOutput('\n❯ 1. Yes\n 2. No\n'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(autoAcceptFired).toBe(true); + autoAcceptController.stop(); + }); + it('should NOT send Enter when disabled', async () => { const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: false, @@ -1102,13 +1197,16 @@ describe('RespawnController Edge Cases', () => { autoAcceptController.stop(); }); - it('should NOT auto-accept when elicitation dialog is signaled', async () => { + it('should NOT auto-accept on elicitation hook alone (without menu pattern)', async () => { + // The elicitation hook hints "menu coming", but if the menu never actually + // renders (no numbered options + selector), the pre-filter rejects. const autoAcceptController = new RespawnController(session as unknown as Session, { autoAcceptPrompts: true, autoAcceptDelayMs: 100, completionConfirmMs: 50, noOutputTimeoutMs: 5000, aiIdleCheckEnabled: false, + aiPlanCheckEnabled: false, }); let autoAcceptFired = false; @@ -1119,13 +1217,13 @@ describe('RespawnController Edge Cases', () => { autoAcceptController.start(); session.simulateTerminalOutput('Which option do you prefer?'); - // Signal that an elicitation dialog (AskUserQuestion) was detected + // Hook fires but no menu actually renders autoAcceptController.signalElicitation(); // Wait for autoAcceptDelayMs to expire await new Promise((resolve) => setTimeout(resolve, 200)); - // Auto-accept should NOT fire because elicitation was signaled + // Pre-filter rejects (no numbered option / no selector arrow) expect(autoAcceptFired).toBe(false); autoAcceptController.stop(); });