Compare commits

...
Author SHA1 Message Date
arkonandClaude Opus 4.7 257695ff8e chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 05:17:07 +02:00
arkonandClaude Opus 4.7 2cfccc745f docs: correct sendPendingCtrlL comment (it has no callers)
The prior wording claimed the no-op stub was kept so SSE idle/working
handlers could call it without guards, but there are no callers anywhere.
Reword to reflect that it's a vestigial, intentionally-retained guard
documenting why Ctrl+L must not be auto-sent. Comment-only; minified
build output is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:52:15 +02:00
8 changed files with 179 additions and 45 deletions
+21
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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": [
+1 -1
View File
@@ -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",
+16 -9
View File
@@ -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):
---
+33 -25
View File
@@ -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();
}
}
/**
+2 -2
View File
@@ -1731,8 +1731,8 @@ Object.assign(CodemanApp.prototype, {
}
},
// Legacy hook for newly-created sessions; kept as a no-op so the SSE
// idle/working handlers can still call it without conditional guards.
// Vestigial no-op: this method has no callers today. It's kept (not deleted)
// as a documented guard so the Ctrl+L behavior below isn't reintroduced.
//
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
// idle/working to scrub mux-init junk from the screen. Two problems:
+103 -5
View File
@@ -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();
});