Compare commits

...
Author SHA1 Message Date
Codeman maintainer f7e2975883 chore: version packages
Gate the idle-alert acknowledgement to human selections: the boot restore, a solo window opening its target, and the post-close fallback no longer spend a yellow tab alert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 21:42:25 +02:00
Codeman maintainer f60bf93c99 chore: version packages
Red tab alerts track the dialog, not the keyboard: typing no longer clears them, and a dialog answered in the terminal resolves itself on the next listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 01:46:48 +02:00
11 changed files with 259 additions and 21 deletions
+20
View File
@@ -1,5 +1,25 @@
# aicodeman
## 1.19.4
### Patch Changes
- Only a human opening a session clears its yellow "waiting for input" tab alert.
1.19.2 made that clear durable and cross-device, which also meant the app itself could spend it: restoring your last session on page load, a popped-out window opening its target, and the fallback to another tab after you close the active one all counted as "I checked it", so a yellow tab could clear itself before you ever saw it. Those three app-driven selections are now marked and skip the acknowledgement, so the alert survives until you actually open the session.
Everything a human does still clears it, on every surface: tapping a tab, tapping a row on the phone home screen, the keyboard tab shortcuts, and submitting a prompt into the session. The flag defaults to user-initiated, so a selection path nobody marked keeps acknowledging rather than leaving an alert nothing can clear.
## 1.19.3
### Patch Changes
- Red "needs you" tab alerts now follow the dialog instead of the keyboard.
Typing in the terminal no longer clears a red alert. It used to clear every pending alert on the device you typed on, but a permission or question dialog ignores keystrokes that are not one of its options, so the dialog was still open and still blocking: the other devices stayed red and a reload brought the red back on the first one. Input now spends the yellow idle alert only, and it does that through the server-side acknowledgement added in 1.19.2, so the clear is durable and reaches every device.
A dialog answered in the terminal now clears by itself. Claude Code fires no "permission answered" hook, so the item stayed pending until the whole turn ended, and any page load in between re-armed a red alert for a dialog that was long gone. Listing approvals now re-captures the pane and resolves items whose dialog is no longer on screen, using the same conservative check the answer path already uses: only an item whose original frame parsed numbered options can be dropped this way, so an unreadable capture keeps the alert rather than losing a live one. Measured against a real AskUserQuestion dialog: the stale item cleared 5 seconds ahead of the stop hook that used to be the only signal, while a dialog still on screen survived 11 consecutive listings over 55 seconds untouched.
## 1.19.2
### Patch Changes
+2 -2
View File
@@ -74,7 +74,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**: 1.19.2 (must match `package.json`)
**Version**: 1.19.4 (must match `package.json`)
## Project Overview
@@ -210,7 +210,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. ⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
+6 -1
View File
@@ -446,7 +446,12 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
acknowledgedAt? }`. `context` is the ANSI-stripped visible pane frame;
`options` is present only when the dialog's numbered choices parsed
confidently; `acknowledgedAt` marks an item a human has already looked at
(see `/viewed` below) and tells clients not to re-arm its tab alert.
(see `/viewed` below) and tells clients not to re-arm its tab alert. Listing
also runs a staleness sweep over the caller's own items: the pane is
re-captured, and an item whose dialog no longer parses is resolved as
`resolved_in_terminal` instead of being returned (only items whose original
frame parsed `options` can be dropped this way, so an unreadable capture
keeps the item).
- `POST /api/v1/approvals/:id/answer` with `{ action: 'approve' }` (sends the
digit `1`), `{ action: 'deny' }` (sends Esc), `{ action: 'option', option: n }`
(sends the digit; accepted only when `n` is among the item's parsed
+1 -1
View File
@@ -60,7 +60,7 @@ Module-level singleton in the style of `session-wait-registry.ts` (pure, no `Ses
Normal authed API (NOT the hook-secret bypass), `ApiResponse` envelope, Zod schemas in `schemas.ts`:
- `GET /api/approvals` → pending items, multi-user filtered by `canAccessOwned` (same policy as session lists).
- `GET /api/approvals` → pending items, multi-user filtered by `canAccessOwned` (same policy as session lists). Also sweeps the caller's own items for staleness through `verifyStillAnswerable()`: Claude Code fires no "permission answered" hook, so a dialog answered in the terminal used to sit pending until `stop` and re-arm a red tab alert on the next page load. Only items whose original frame parsed options can be dropped this way, so an unreadable capture keeps the alert.
- `POST /api/approvals/:id/answer` body `{ action: 'approve' | 'deny' | 'option' | 'text', option?, text? }`:
- `approve` → `writeViaMux('1')` (option 1 is always plain Yes; no Enter, menus react to the digit).
- `deny` → `writeViaMux('\x1b')` (Esc is the official No/cancel; precedent: auto-resume sends Esc the same way).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.19.2",
"version": "1.19.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.19.2",
"version": "1.19.4",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.19.2",
"version": "1.19.4",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+36 -12
View File
@@ -858,7 +858,9 @@ class CodemanApp {
* is on screen, and looking at one does not answer it.
*/
markIdleAlertSeen(sessionId) {
if (!this.pendingHooks.get(sessionId)?.has('idle_prompt')) return;
// `pendingHooks?` because _ackDelivery calls this from the input hot path,
// which partial app instances (the vm-loaded delivery tests) also drive.
if (!this.pendingHooks?.get(sessionId)?.has('idle_prompt')) return;
this.clearPendingHooks(sessionId, 'idle_prompt');
this.acknowledgeIdleApprovalOnView?.(sessionId);
}
@@ -1441,9 +1443,10 @@ class CodemanApp {
document.body.classList.add('solo-mode');
const session = this.sessions.get(this.soloSessionId);
if (!session) { this._showSoloSessionGone(); return; }
// Force re-select (handleInit cleared terminal state above).
// Force re-select (handleInit cleared terminal state above). `auto`: the
// window is opening its own target, which is not a human checking on it.
this.activeSessionId = null;
this.selectSession(this.soloSessionId);
this.selectSession(this.soloSessionId, { auto: true });
const name = this.getSessionName(session) || 'Session';
const titleEl = document.getElementById('soloSessionTitle');
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
@@ -2957,7 +2960,14 @@ class CodemanApp {
this._updateConnectionIndicator();
}
}
this.clearPendingHooks?.(sessionId);
// ⚠️ IDLE ONLY, and acknowledged server-side rather than cleared in memory.
// Delivering input answers "Claude is waiting for a prompt" by definition,
// so this is the same "I am on it" signal as opening the tab. It does NOT
// answer a permission/question dialog: those ignore any keystroke that is
// not one of their options, so the dialog is still up and still needs you.
// Clearing action alerts here hid a LIVE alert on this device alone (the
// other devices stayed red and a reload re-seeded it straight back).
this.markIdleAlertSeen?.(sessionId);
}
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
@@ -3642,10 +3652,13 @@ class CodemanApp {
if (!restoreId || !this.sessions.has(restoreId)) {
try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
}
// `auto`: the app is restoring a session on load, not a human opening
// one, so a pending idle alert on that tab stays armed until it is
// actually tapped (see the userInitiated note in selectSession).
if (restoreId && this.sessions.has(restoreId)) {
this.selectSession(restoreId);
this.selectSession(restoreId, { auto: true });
} else {
this.selectSession(this.sessionOrder[0]);
this.selectSession(this.sessionOrder[0], { auto: true });
}
}
}
@@ -5162,13 +5175,21 @@ class CodemanApp {
if (this._raiseDetached(sessionId)) return;
}
const forceReload = options?.forceReload === true;
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
// the boot restore, a solo window opening its target, the fallback after
// the active session is deleted. Those must NOT spend a pending idle alert
// (the yellow survives until a real tap), because "the app put this on
// screen" is not "I checked it". The DEFAULT is user-initiated, so a call
// site nobody tagged fails toward acknowledging rather than toward an
// alert that can never be cleared.
const userInitiated = options?.auto !== true;
if (this.activeSessionId === sessionId && !forceReload) {
// Clicking the tab you are already on is still "I checked it". The alert
// Tapping the tab you are already on is still "I checked it". The alert
// can be armed on the ACTIVE tab (a live idle_prompt fires regardless of
// which tab is showing, and so does the reload seed), and every other
// clear path runs on the switch this early return skips, leaving a
// yellow tab that no click could clear.
this.markIdleAlertSeen(sessionId);
// yellow tab that no tap could clear.
if (userInitiated) this.markIdleAlertSeen(sessionId);
return;
}
if (this.activeSessionId === sessionId && forceReload) {
@@ -5228,8 +5249,9 @@ class CodemanApp {
this.playTerminalEntrance?.(sessionId);
// Clear idle hooks on view, but keep action hooks until user interacts.
// Also acknowledged server-side, so the yellow does not come back on the
// next reload and the user's other devices clear it too.
this.markIdleAlertSeen(sessionId);
// next reload and the user's other devices clear it too. Skipped for an
// `auto` selection (see userInitiated above).
if (userInitiated) this.markIdleAlertSeen(sessionId);
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
this._updateActiveTabImmediate(sessionId);
// Handheld: the session drawer overlays the terminal, so slide it away now
@@ -5708,8 +5730,10 @@ class CodemanApp {
try { localStorage.removeItem('codeman-active-session'); } catch {}
// Select another session or show welcome (use sessionOrder for consistent ordering)
if (this.sessionOrder.length > 0 && this.sessions.size > 0) {
// `auto`: this tab was chosen by the app because the previous one
// went away, so it must not spend that session's idle alert.
const nextSessionId = this.sessionOrder[0];
this.selectSession(nextSessionId);
this.selectSession(nextSessionId, { auto: true });
} else {
this.terminal.clear();
this.showWelcome();
+14 -2
View File
@@ -3,7 +3,9 @@
*
* The cross-session queue of prompts waiting on a human (see
* web/approval-inbox.ts, docs/approvals-inbox-plan.md):
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode,
* with a pane-capture staleness sweep (a dialog answered in the terminal is
* resolved here rather than re-arming a tab alert on the next page load)
* - `POST /api/approvals/:id/answer`: answer in place by sending the
* corresponding keystrokes to the session (digit / Esc / idle-prompt text)
* - `POST /api/approvals/:id/dismiss`: drop the item without keystrokes
@@ -72,7 +74,17 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
approvalInbox.resolveForSession(item.sessionId, 'session_ended');
return false;
}
return canAccessOwned(user, session.owner);
if (!canAccessOwned(user, session.owner)) return false;
// Staleness sweep, on the caller's own items only. Claude Code fires no
// "permission answered" hook, so a dialog answered IN the terminal leaves
// its item pending until `stop`, and this list is what re-arms tab alerts
// on every page load: a red "needs you" would come back for a dialog that
// is long gone. The pane is the truth, so ask it, using the SAME
// conservative rule the answer path uses (`verifyStillAnswerable`): only
// an item whose original frame parsed options can be resolved this way, so
// an unreadable capture keeps the alert rather than dropping it. Resolving
// here broadcasts `approval:resolved`, so the other devices clear too.
return approvalInbox.verifyStillAnswerable(item.id);
});
return { success: true, data: { approvals } };
});
+3
View File
@@ -78,6 +78,9 @@ function makeApp(): App {
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.clearPendingHooks = vi.fn();
// _ackDelivery spends a pending IDLE alert through markIdleAlertSeen, which
// reads this map; without it the real prototype method throws on every ACK.
app.pendingHooks = new Map();
app.activeSessionId = 'session-1';
app.isOnline = true;
app._connectionStatus = 'connected';
+42
View File
@@ -283,6 +283,48 @@ describe('approval routes', () => {
expect(await listApprovals(harness)).toHaveLength(0);
});
describe('staleness sweep on GET /api/approvals', () => {
it('resolves an item whose dialog left the pane, and tells the other clients', async () => {
const resolved: Array<Record<string, unknown>> = [];
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
expect((await listApprovals(harness))[0].options).toHaveLength(3);
// Answered in the terminal: Claude Code fires no hook for that, so only
// the pane knows. The dialog is gone from the frame the next capture sees.
approvalInbox.onResolved = (info) => resolved.push({ ...info });
session.terminalBuffer = 'claude> back at the composer';
expect(await listApprovals(harness)).toHaveLength(0);
expect(resolved).toEqual([expect.objectContaining({ resolution: 'resolved_in_terminal' })]);
});
it('keeps an item whose dialog is still on screen', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
// Pane unchanged (PERMISSION_DIALOG): the human has not answered yet.
expect(await listApprovals(harness)).toHaveLength(1);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('never drops an item that could not be read in the first place', async () => {
// No parseable dialog at capture time, so a later "it does not parse" says
// nothing new. Conservative by design: an unreadable pane keeps the alert.
session.terminalBuffer = 'some output with no dialog in it';
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
expect(item.options).toBeUndefined();
session.terminalBuffer = 'still nothing that parses';
expect(await listApprovals(harness)).toHaveLength(1);
});
it('leaves idle prompts alone (they are not dialogs)', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', {});
session.terminalBuffer = 'claude> still waiting, different frame';
const [item] = await listApprovals(harness);
expect(item.kind).toBe('idle');
});
});
it('viewing a session acknowledges its idle prompt (item stays pending) and broadcasts it', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', {});
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview A pending IDLE tab alert is spent by a HUMAN opening a session,
* never by the app putting one on screen.
*
* `selectSession()` acknowledges the session's idle approval item server-side
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
* what makes "I checked it" survive a reload and reach the user's other
* devices. Three call sites are the APP choosing a session rather than the
* user: the boot restore, a solo (popped-out) window opening its target, and
* the fallback after the active session is deleted. Those pass `auto: true`
* and must not spend the alert, or a yellow tab would clear itself every time
* the page loaded and the user would never see it.
*
* The gate defaults to user-initiated on purpose: an untagged call site fails
* toward acknowledging (today's behavior) rather than toward an alert nothing
* can clear. This suite pins both halves, the runtime gate through the real
* `selectSession`, and the three tagged call sites as a source guard.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts.
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const APP_PATH = resolve(import.meta.dirname, '../src/web/public/app.js');
const APP_SOURCE = readFileSync(APP_PATH, 'utf8');
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
});
vm.runInContext(`${constants}\n${APP_SOURCE}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
const SID = 'session-with-a-yellow-tab';
/**
* Minimal instance: enough surface for selectSession to reach the
* acknowledgement line. Everything after it is DOM work that throws in this
* context, which is why callers swallow the rejection.
*/
function makeApp(activeSessionId: string | null) {
const app = Object.create((CodemanApp as { prototype: object }).prototype) as Record<string, unknown>;
app.markIdleAlertSeen = vi.fn();
app.pendingHooks = new Map([[SID, new Set(['idle_prompt'])]]);
app.activeSessionId = activeSessionId;
app.detachedSessions = new Set();
app.isSoloWindow = false;
app._selectGeneration = 0;
app._shouldFocusTerminalForTabSwitch = () => false;
app._setTerminalLoadState = vi.fn();
app._clearTerminalLoadState = vi.fn();
app._cleanupPreviousSession = vi.fn();
app._renderHistoryTruncationBanner = vi.fn();
app._updateSseSubscription = vi.fn();
app.hideWelcome = vi.fn();
app.sessions = new Map([[SID, { id: SID, name: 'w1' }]]);
return app as Record<string, unknown> & {
selectSession: (id: string, opts?: Record<string, unknown>) => Promise<void>;
markIdleAlertSeen: ReturnType<typeof vi.fn>;
};
}
describe('selectSession acknowledgement gate', () => {
describe('switching to a session (the main path)', () => {
it('a user-initiated selection spends the idle alert', async () => {
const app = makeApp(null);
await app.selectSession(SID).catch(() => {});
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
});
it('an `auto` selection leaves it armed', async () => {
const app = makeApp(null);
await app.selectSession(SID, { auto: true }).catch(() => {});
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
});
describe('re-selecting the session already on screen (the early return)', () => {
it('a tap on the active tab spends the idle alert', async () => {
const app = makeApp(SID);
await app.selectSession(SID);
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
});
it('an `auto` re-select leaves it armed', async () => {
const app = makeApp(SID);
await app.selectSession(SID, { auto: true });
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
});
});
describe('the call sites the app drives itself', () => {
// Source guard: these three are the reason the flag exists. If a refactor
// moves or reformats them, fail loudly rather than silently going back to
// "every page load clears the user's yellow tab".
it.each([
['boot restore, stored session', 'this.selectSession(restoreId, { auto: true });'],
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
])('%s passes auto: true', (_label, call) => {
expect(APP_SOURCE).toContain(call);
});
it('keyboard tab switching stays user-initiated', () => {
// Alt+1..9 and Alt+[/] are a human asking for that tab, so they keep
// acknowledging; only app-chosen selections are tagged.
expect(APP_SOURCE).toContain('this.selectSession(live[idx]);');
expect(APP_SOURCE).toContain('this.selectSession(this.sessionOrder[nextIndex]);');
});
});
});