Compare commits

...
Author SHA1 Message Date
Codeman maintainer 5080390e2c chore: version packages
Give the active-session handoff one owner: closeSession captures wasActive before its await and the session_deleted handler stands down for a close this tab started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:54:46 +02:00
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
Codeman maintainer f4ba4d2cb1 chore: version packages
Persist the 'I checked it' state of yellow idle tab alerts across reloads and devices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 00:46:03 +02:00
15 changed files with 697 additions and 29 deletions

No files matched your search

+40
View File
@@ -1,5 +1,45 @@
# aicodeman
## 1.19.5
### Patch Changes
- Closing the session you are looking at now always moves you to the next tab.
The delete request and its own `session_deleted` broadcast raced each other: the close path selected the next tab, while the broadcast handler cleared the active session and showed the home screen, and whichever ran first decided what you saw. On one build, closing a tab either switched sessions or dumped you on the welcome screen depending on timing. The close now owns that handoff from beginning to end, and the broadcast handler stays out of the way for a close started in that tab. A session deleted from somewhere else still returns you to the home screen, which is the honest answer when what you were looking at was taken away.
The next tab is also picked from sessions that still exist, so a stale entry in the tab order can no longer name a tab that is already gone.
## 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
- Yellow "waiting for input" tab alerts now stay cleared once you have checked them, on every device.
Viewing a session used to clear its idle alert in that browser's memory only. The server-side approval store still held the prompt, so the next page load seeded the alert straight back and a tab you had already checked went yellow again, while your other devices never heard about the click at all. Opening a session now acknowledges its pending idle prompt server-side (`POST /api/approvals/session/:sessionId/viewed`, a new `acknowledgedAt` field on approval items, broadcast as `approval:updated`), so the clear survives reloads and reaches every connected client.
Acknowledgement is deliberately not resolution: the prompt is still unanswered, so the item stays in the Approvals Inbox, stays answerable, and stays available as Read My Mind context, it just stops arming the tab alert. Permission and question dialogs are never acknowledged this way, since looking at a dialog does not answer it, so the red "needs you" alert survives being viewed. Clicking the tab you are already on now clears the alert as well; that path returned early before, so an alert armed on the active tab could not be cleared by clicking at all.
## 1.19.1
### Patch Changes
+3 -3
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.1 (must match `package.json`)
**Version**: 1.19.5 (must match `package.json`)
## Project Overview
@@ -202,7 +202,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All five **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi)
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. Colors cycle per CHILD in first-seen order from `CodemanLineage.COLORS` (first entry empty = the skin-tuned `--session-blue`; the rest vivid fixed hexes), set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
@@ -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). 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`.
+19 -4
View File
@@ -442,9 +442,16 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
- `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first,
ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId,
sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?,
toolSummary?, message?, cwd?, context?, options?: {n, label}[] }`. `context`
is the ANSI-stripped visible pane frame; `options` is present only when the
dialog's numbered choices parsed confidently.
toolSummary?, message?, cwd?, context?, options?: {n, label}[],
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. 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
@@ -453,9 +460,17 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
`409 CONFLICT` when the dialog left the screen or another actor answered
first, `422 OPERATION_FAILED` when the session refused input.
- `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes.
- `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId,
acknowledged: itemId | null }`. Marks the session's pending **idle** item as
seen by a human (the web UI calls it when you open the session's tab): the
item stays pending and answerable, but stops arming the yellow tab alert on
every client, including after a reload. Permission/question items are never
acknowledged this way, since looking at a dialog does not answer it. `404`
for an unknown or inaccessible session; acknowledging twice is a no-op
(`acknowledged: null`).
SSE events: `approval:pending` (full item), `approval:updated` (context/options
re-captured), `approval:resolved` (`{ id, sessionId, kind, resolution }` with
re-captured, or the item acknowledged), `approval:resolved` (`{ id, sessionId, kind, resolution }` with
`resolution` one of `answered | resolved_in_terminal | superseded |
session_ended | dismissed | expired`).
+3 -2
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).
@@ -68,6 +68,7 @@ Normal authed API (NOT the hook-secret bypass), `ApiResponse` envelope, Zod sche
- `text` → `idle` items only: single line, embedded newlines stripped, sent as `text\r` (the `\r` discipline from CLAUDE.md).
- Guards: item still pending (404 otherwise), session exists + ownership via `findSessionOrFail`, session mode installs hooks. **Answer-time re-capture**: for items whose frame parsed options, the pane is re-captured before sending; if the dialog no longer parses, the item resolves and the answer is refused with 409 (the keystroke would land in whatever now has focus). Marks `answered` BEFORE the write so a double-tap cannot double-send; rolls back to pending if the write fails.
- `POST /api/approvals/:id/dismiss` → remove without keystrokes.
- `POST /api/approvals/session/:sessionId/viewed` → acknowledge the session's pending **idle** item (`acknowledgedAt`, emitted as `approval:updated`). Added after the owner reported that a yellow tab clicked and checked went yellow again on reload: the view-clears-idle rule lived in one browser's memory, so the seed re-armed it and other devices never saw the clear. Acknowledgement is deliberately **not** resolution (the prompt is still unanswered, so it stays in the inbox and stays available as Read My Mind context), and deliberately **idle-only** (looking at a permission/question dialog does not answer it, so the red alert survives being viewed).
### SSE
@@ -84,7 +85,7 @@ Normal authed API (NOT the hook-secret bypass), `ApiResponse` envelope, Zod sche
New module `approvals-ui.js` (@loadorder 11.2, after panels-ui.js), prettier-formatted (not added to `.prettierignore`).
- **Seed on connect**: `GET /api/approvals` on init and SSE reconnect; each pending item re-feeds `setPendingHook(...)` so tab alerts and the phone overview survive reload (fixes problem 2 with zero changes to the alert state machine).
- **Seed on connect**: `GET /api/approvals` on init and SSE reconnect; each pending item re-feeds `setPendingHook(...)` so tab alerts and the phone overview survive reload (fixes problem 2 with zero changes to the alert state machine). Items carrying `acknowledgedAt` are skipped, and `markIdleAlertSeen()` (app.js) is what sets it: viewing a session clears its yellow locally and POSTs `.../viewed`, so "I checked it" survives the reload and reaches the user's other devices through `approval:updated`.
- **Desktop**: header bell `btn-approvals` with count badge. Ships default-hidden via marker class `btn-approvals--hidden` (same policy as the attachments button, so `test/mobile-header-buttons-policy.test.ts` excludes it from the default-visible enumeration); JS shows it only while count > 0. Click toggles a drawer of cards: session name + kind, tool/message summary, mono context block, buttons rendered from parsed options (else Approve/Deny), plus Dismiss and Open session. Esc closes; existing z-index layers respected.
- **Phone**: header button stays hidden (`mobile.css`); the phone surface is the overview's NEEDS YOU section, whose rows gain inline ✓/✗ buttons for permission items (tap-through to the session remains the row's main action). Toolbar classes/status language rules from the mobile-overview section of CLAUDE.md apply.
- **i18n**: new strings registered in i18n.js (en + zh-CN); status words carry `data-i18n-skip` where they would collide (mirroring the overview pills).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.19.1",
"version": "1.19.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.19.1",
"version": "1.19.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.19.1",
"version": "1.19.5",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+29
View File
@@ -19,6 +19,8 @@
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
* item stays pending, it just stops arming the tab alert on every client.
*
* @dependencies utils (stripAnsi)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
@@ -61,6 +63,14 @@ export interface ApprovalItem {
cwd?: string;
/** ANSI-stripped tail of the visible pane frame at capture time. */
context?: string;
/**
* Set when a human looked at the session (the web UI selecting its tab). The
* item stays PENDING and answerable, only its tab alert is spent: clients
* skip re-arming the alert for an acknowledged item when they seed from
* `GET /api/approvals`, which is what makes "I checked it" survive a reload
* and reach the user's other devices. See `acknowledge()`.
*/
acknowledgedAt?: number;
/**
* Present only when the frame parsed confidently. Gates which digits the
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
@@ -306,6 +316,25 @@ export class ApprovalInbox {
this.onPending?.(item);
}
/**
* Mark a session's pending item as SEEN by a human, and return it (undefined
* when there is nothing to acknowledge or it is already acknowledged). The
* item is NOT resolved: an idle prompt a human glanced at is still unanswered,
* so it stays in the inbox, stays answerable, and stays available as Read My
* Mind context. Only the tab alert it armed is spent.
*
* ⚠️ `kinds` defaults to `['idle']` and callers must keep it that narrow:
* looking at a permission/question dialog does not answer it, so the red
* "needs you" alert has to survive being viewed.
*/
acknowledge(sessionId: string, kinds: ApprovalKind[] = ['idle']): ApprovalItem | undefined {
const item = this.getForSession(sessionId);
if (!item || !kinds.includes(item.kind) || item.acknowledgedAt) return undefined;
item.acknowledgedAt = Date.now();
if (!this.stopped) this.onUpdated?.(item);
return item;
}
/** Remove an item without keystrokes (user chose Dismiss). */
dismiss(id: string): boolean {
const item = this.getById(id);
+92 -14
View File
@@ -652,6 +652,11 @@ class CodemanApp {
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
this.pendingHooks = new Map();
// Sessions THIS tab is closing right now. closeSession() owns the follow-up
// selection, so _onSessionDeleted must not race its own delete's SSE
// broadcast to the welcome screen. Set<sessionId>, cleared in a finally.
this._closingSessions = new Set();
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
this.approvals = new Map();
@@ -845,6 +850,26 @@ class CodemanApp {
this.updateTabAlertFromHooks(sessionId);
}
/**
* "I looked at this session": spend its pending IDLE tab alert (the yellow
* one), locally AND server-side. The clear used to live only in this tab's
* memory, so `seedApprovals()` re-armed it from `GET /api/approvals` on the
* next reload (a tab you had already checked went yellow again), and the
* user's other devices never heard about it. The server marks the approval
* item acknowledged (it stays pending and answerable) and broadcasts
* `approval:updated`, which is what clears the alert everywhere else.
*
* ⚠️ Idle only: action alerts (permission/question) mean an unanswered dialog
* is on screen, and looking at one does not answer it.
*/
markIdleAlertSeen(sessionId) {
// `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);
}
updateTabAlertFromHooks(sessionId) {
const hooks = this.pendingHooks.get(sessionId);
if (!hooks || hooks.size === 0) {
@@ -1423,9 +1448,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 = ''; }
@@ -1792,7 +1818,13 @@ class CodemanApp {
// Dashboard: a detached session ended → clear its detached state/timers.
if (this.detachedSessions.has(data.id)) this._redock(data.id);
this._cleanupSessionData(data.id);
if (this.activeSessionId === data.id) {
// ⚠️ Skip the whole active-session handoff while THIS tab is closing that
// session: closeSession() owns the follow-up selection and moves you to the
// next tab, so acting here would race it and flash the welcome screen (or
// strand you on it) for a close the user initiated right here. A delete from
// anywhere else still lands on the home screen, which is the honest answer
// when the thing you were looking at was taken away.
if (this.activeSessionId === data.id && !this._closingSessions.has(data.id)) {
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
this.terminal.clear();
@@ -2939,7 +2971,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. */
@@ -3624,10 +3663,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 });
}
}
}
@@ -5144,7 +5186,23 @@ class CodemanApp {
if (this._raiseDetached(sessionId)) return;
}
const forceReload = options?.forceReload === true;
if (this.activeSessionId === sessionId && !forceReload) return;
// ⚠️ `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) {
// 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 tap could clear.
if (userInitiated) this.markIdleAlertSeen(sessionId);
return;
}
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
@@ -5200,8 +5258,11 @@ class CodemanApp {
// switch when that option is on. Transform/opacity/clip-path only, xterm's
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
this.playTerminalEntrance?.(sessionId);
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
// 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. 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
@@ -5671,17 +5732,32 @@ class CodemanApp {
}
async closeSession(sessionId, killMux = true) {
// ⚠️ Captured BEFORE the await, and the delete is announced to
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
// broadcast for THIS delete routinely lands while the request is still in
// flight, and that handler nulls activeSessionId and shows the welcome
// screen. Re-reading the field after the await therefore made the fallback
// below a coin flip: closing the tab you were on either moved you to the
// next session or dumped you on the home screen, depending on which path
// won the race (both outcomes measured on one build, 2026-08-17).
const wasActive = this.activeSessionId === sessionId;
this._closingSessions.add(sessionId);
try {
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
if (this.activeSessionId === sessionId) {
if (wasActive) {
this.activeSessionId = null;
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) {
const nextSessionId = this.sessionOrder[0];
this.selectSession(nextSessionId);
// Next tab in the user's own order, skipping ids the cleanup has not
// caught up with yet: sessionOrder can transiently hold a dead id
// (delete racing the order sync), which is the same reason Alt+N
// indexes a live-filtered list rather than sessionOrder directly.
const nextSessionId = this.sessionOrder.find((id) => id !== sessionId && this.sessions.has(id));
if (nextSessionId) {
// `auto`: this tab was chosen by the app because the previous one
// went away, so it must not spend that session's idle alert.
this.selectSession(nextSessionId, { auto: true });
} else {
this.terminal.clear();
this.showWelcome();
@@ -5698,6 +5774,8 @@ class CodemanApp {
}
} catch (err) {
this.showToast('Failed to close session', 'error');
} finally {
this._closingSessions.delete(sessionId);
}
}
+30 -1
View File
@@ -50,6 +50,11 @@ Object.assign(CodemanApp.prototype, {
const inboxOn = this.approvalsInboxEnabled();
for (const item of (data && data.approvals) || []) {
if (inboxOn) this.approvals.set(item.id, item);
// ⚠ Skip items a human already looked at (`acknowledgedAt`, set by
// markIdleAlertSeen → POST .../viewed). Re-arming those is exactly the
// bug this flag exists for: clicking a yellow tab cleared the alert in
// this tab's memory only, so the next reload seeded it right back.
if (item.acknowledgedAt) continue;
// Re-arm the tab alert state machine (idempotent set-add).
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
}
@@ -70,7 +75,14 @@ Object.assign(CodemanApp.prototype, {
},
_onApprovalUpdated(item) {
if (!item || !item.id || !this.approvals?.has(item.id)) return;
if (!item || !item.id) return;
// Acknowledged elsewhere (this user opened the session on another device):
// spend the tab alert UNCONDITIONALLY, for the same reason
// _onApprovalResolved does: with the inbox setting OFF the item was never
// stored in `this.approvals`, yet seedApprovals armed its alert, so gating
// this on a map hit would strand a yellow tab on every other device.
if (item.acknowledgedAt) this.clearPendingHooks(item.sessionId, approvalKindToHook(item.kind));
if (!this.approvals?.has(item.id)) return;
this.approvals.set(item.id, item);
this.renderApprovals();
},
@@ -89,6 +101,23 @@ Object.assign(CodemanApp.prototype, {
// ─── Actions ─────────────────────────────────────────────────
/**
* Tell the server the session's pending IDLE prompt has been looked at, so
* the yellow tab alert stays gone: `seedApprovals()` skips acknowledged
* items on the next reload, and the resulting `approval:updated` broadcast
* clears the alert on the user's other devices. Called by markIdleAlertSeen
* (app.js), which owns the local half of the clear.
*
* Fire-and-forget: the alert is already down locally, `_apiJson` swallows
* failures, and the worst case of a lost POST is today's behavior (yellow
* returns after a reload). Runs regardless of `approvalsInboxEnabled`,
* since the tab alert predates the inbox and is not gated on it.
*/
acknowledgeIdleApprovalOnView(sessionId) {
if (!sessionId) return;
this._apiJson(`/api/approvals/session/${encodeURIComponent(sessionId)}/viewed`, { method: 'POST' });
},
async answerApproval(id, action, option) {
const body = option !== undefined ? { action, option } : { action };
const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, {
+34 -2
View File
@@ -3,10 +3,14 @@
*
* 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
* - `POST /api/approvals/session/:sessionId/viewed`: mark the session's pending
* IDLE prompt as seen (tab alert spent, item still pending)
*
* Normal authed API surface (NOT the localhost hook-secret bypass). Answering
* is take-then-write: the item is removed BEFORE keystrokes go out so a
@@ -70,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 } };
});
@@ -113,6 +127,24 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
return { success: true, data: { id: item.id, sessionId: item.sessionId, action: answer.action } };
});
/**
* "A human is looking at this session": acknowledge its pending IDLE prompt.
* The yellow tab alert used to be cleared in the browser's memory only, so
* `GET /api/approvals` re-armed it on the next reload (a tab you had already
* checked went yellow again) and the user's other devices never heard about
* it at all. The item is NOT resolved, only marked seen; the
* `approval:updated` broadcast is what clears the alert everywhere else.
*
* ⚠️ Idle only, by construction (`acknowledge()` defaults to `['idle']`):
* viewing a permission/question dialog does not answer it, so the red alert
* must survive being viewed.
*/
app.post<{ Params: { sessionId: string } }>('/api/approvals/session/:sessionId/viewed', async (req) => {
const session = findSessionOrFail(ctx, req.params.sessionId, req);
const item = approvalInbox.acknowledge(session.id);
return { success: true, data: { sessionId: session.id, acknowledged: item?.id ?? null } };
});
app.post<{ Params: { id: string } }>('/api/approvals/:id/dismiss', async (req) => {
const item = approvalInbox.getById(req.params.id);
if (!item) {
+38
View File
@@ -216,6 +216,44 @@ describe('ApprovalInbox', () => {
expect(inbox.getForSession('s1')?.id).toBe(newer.id);
});
it('acknowledge marks an idle item seen without resolving it, and emits onUpdated once', () => {
const { updated, resolved } = collect(inbox);
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
const acked = inbox.acknowledge('s1');
expect(acked?.id).toBe(item.id);
expect(acked?.acknowledgedAt).toBeGreaterThan(0);
// Still pending and still answerable: the human looked, they did not answer.
expect(inbox.getById(item.id)?.acknowledgedAt).toBeGreaterThan(0);
expect(inbox.listPending()).toHaveLength(1);
expect(resolved).toHaveLength(0);
expect(updated).toEqual([expect.objectContaining({ id: item.id })]);
// Idempotent: a second view does not re-broadcast.
expect(inbox.acknowledge('s1')).toBeUndefined();
expect(updated).toHaveLength(1);
});
it('acknowledge never touches a permission/question item (viewing is not answering)', () => {
const { updated } = collect(inbox);
const permission = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
expect(inbox.acknowledge('s1')).toBeUndefined();
expect(inbox.getById(permission.id)?.acknowledgedAt).toBeUndefined();
const question = inbox.notePrompt({ sessionId: 's2', sessionName: 'w2', kind: 'question' });
expect(inbox.acknowledge('s2')).toBeUndefined();
expect(inbox.getById(question.id)?.acknowledgedAt).toBeUndefined();
expect(updated).toHaveLength(0);
expect(inbox.acknowledge('nope')).toBeUndefined();
});
it('a new prompt after an acknowledgement arms the alert again', () => {
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
inbox.acknowledge('s1');
const next = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
expect(next.acknowledgedAt).toBeUndefined();
expect(inbox.getForSession('s1')?.acknowledgedAt).toBeUndefined();
});
it('dismiss removes without answering', () => {
const { resolved } = collect(inbox);
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
+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';
+95
View File
@@ -283,6 +283,101 @@ 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', {});
const [before] = await listApprovals(harness);
expect(before.acknowledgedAt).toBeUndefined();
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/session/${SESSION_ID}/viewed`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ sessionId: SESSION_ID, acknowledged: before.id });
// Seen, not answered: still listed (so it stays answerable), no keystrokes,
// and clients skip re-arming the tab alert because of acknowledgedAt.
const [after] = await listApprovals(harness);
expect(after.id).toBe(before.id);
expect(after.acknowledgedAt).toBeGreaterThan(0);
expect(session.writeBuffer).toEqual([]);
// Second view is a no-op (nothing new to tell the other devices).
const again = await harness.app.inject({
method: 'POST',
url: `/api/approvals/session/${SESSION_ID}/viewed`,
payload: {},
});
expect(again.json().data.acknowledged).toBeNull();
});
it('viewing a session leaves a permission dialog alerting (looking is not answering)', async () => {
await postHook(harness, 'permission_prompt', {});
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/session/${SESSION_ID}/viewed`,
payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().data.acknowledged).toBeNull();
const [item] = await listApprovals(harness);
expect(item.kind).toBe('permission');
expect(item.acknowledgedAt).toBeUndefined();
});
it('viewing an unknown session 404s', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/approvals/session/not-a-session/viewed',
payload: {},
});
expect(res.statusCode).toBe(404);
});
it('non-claude sessions never get inbox items', async () => {
session.mode = 'codex';
await postHook(harness, 'permission_prompt', {});
+176
View File
@@ -0,0 +1,176 @@
/**
* @fileoverview Closing the session you are looking at moves you to the next
* tab, deterministically.
*
* `closeSession()` and the `session_deleted` SSE handler both react to the same
* delete, and the broadcast routinely lands while the DELETE request is still in
* flight. Both used to read `this.activeSessionId` and act on it, so whichever
* won decided what the user saw: the SSE handler nulls the field and shows the
* welcome screen, which then made closeSession's own "select the next tab"
* branch a no-op. Closing a tab therefore either switched sessions or dumped you
* on the home screen, on the same build, depending on timing (measured
* 2026-08-17 while testing the idle-alert gate).
*
* The fix is one owner per outcome: closeSession captures `wasActive` BEFORE the
* await and announces the delete through `_closingSessions`, and the SSE handler
* leaves the active-session handoff alone for a close this tab started. A delete
* from anywhere else still lands on the welcome screen.
*
* 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';
function loadCodemanAppClass() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
const source = readFileSync(resolve(dir, 'app.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${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
const A = 'session-a';
const B = 'session-b';
type TestApp = Record<string, unknown> & {
closeSession: (id: string, killMux?: boolean) => Promise<void>;
_onSessionDeleted: (data: { id: string }) => void;
selectSession: ReturnType<typeof vi.fn>;
showWelcome: ReturnType<typeof vi.fn>;
activeSessionId: string | null;
sessionOrder: string[];
sessions: Map<string, unknown>;
};
/** Instance with the session bookkeeping real and everything visual stubbed. */
function makeApp(active: string | null, order = [A, B]): TestApp {
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
app.activeSessionId = active;
app.sessionOrder = [...order];
app.sessions = new Map(order.map((id) => [id, { id }]));
app._closingSessions = new Set();
app.detachedSessions = new Set();
app.isSoloWindow = false;
app._wsSessionId = null;
app.terminal = { clear: vi.fn() };
app._apiDelete = vi.fn(async () => ({ success: true }));
// The real one touches ~20 maps; the parts this behavior depends on are the
// session map and the tab order, so those are pruned for real.
app._cleanupSessionData = vi.fn((id: string) => {
app.sessions.delete(id);
const i = app.sessionOrder.indexOf(id);
if (i !== -1) app.sessionOrder.splice(i, 1);
});
app.selectSession = vi.fn();
app.showWelcome = vi.fn();
app.renderSessionTabs = vi.fn();
app.renderRalphStatePanel = vi.fn();
app.renderProjectInsightsPanel = vi.fn();
app.stopSystemStatsPolling = vi.fn();
app.showToast = vi.fn();
app._disconnectWs = vi.fn();
app._redock = vi.fn();
return app;
}
describe('closing the active session', () => {
it('moves to the next tab when the SSE broadcast arrives DURING the delete', async () => {
const app = makeApp(A);
// The losing order that used to strand the user: the broadcast for this very
// delete lands before the request resolves.
app._apiDelete = vi.fn(async () => {
app._onSessionDeleted({ id: A });
return { success: true };
});
await app.closeSession(A);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
expect(app.showWelcome).not.toHaveBeenCalled();
});
it('moves to the next tab when the broadcast arrives AFTER the delete', async () => {
const app = makeApp(A);
await app.closeSession(A);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
// The late broadcast must not bounce the user off the tab they just landed on.
app.activeSessionId = B;
app._onSessionDeleted({ id: A });
expect(app.showWelcome).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe(B);
});
it('skips ids the cleanup has not caught up with', async () => {
const app = makeApp(A, [A, 'ghost', B]);
app.sessions.delete('ghost'); // in the order, already gone from the session map
await app.closeSession(A);
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
});
it('falls back to the welcome screen when nothing is left', async () => {
const app = makeApp(A, [A]);
await app.closeSession(A);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.showWelcome).toHaveBeenCalled();
});
it('closing a session you are NOT on changes nothing on screen', async () => {
const app = makeApp(B, [A, B]);
await app.closeSession(A);
expect(app.selectSession).not.toHaveBeenCalled();
expect(app.showWelcome).not.toHaveBeenCalled();
expect(app.activeSessionId).toBe(B);
});
it('a delete from ANOTHER client still shows the welcome screen', () => {
// Nobody here initiated it, so there is no follow-up selection to own: the
// honest answer is that what you were looking at is gone.
const app = makeApp(A);
app._onSessionDeleted({ id: A });
expect(app.activeSessionId).toBeNull();
expect(app.showWelcome).toHaveBeenCalled();
});
it('stops tracking the close once it finishes, even when the delete throws', async () => {
const app = makeApp(A);
app._apiDelete = vi.fn(async () => {
throw new Error('network');
});
await app.closeSession(A);
expect((app._closingSessions as Set<string>).size).toBe(0);
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
});
});
+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]);');
});
});
});