One cross-session inbox for every prompt that is waiting on a human: permission dialogs, questions (AskUserQuestion / elicitation), and idle prompts. Cards are answerable in place (option digits, Esc, or a typed prompt) from desktop, phone overview, and push notification action buttons. Inspired by Cloudflare OS's Gatekeeper approval queue (https://github.com/cloudflare/cloudflare-os, asynchronous human-in-the-loop approvals): with a fleet of sessions the human is the bottleneck, and today answering means finding the right tab.
- No cross-session surface. Pending prompts exist only as per-tab alert colors (
tab-alert-action/tab-alert-idle) and NEEDS YOU rows on the phone overview. Answering means switching to the session and typing. - Alerts die on reload.
pendingHookslives only inapp.jsmemory, fed by transient SSEhook:*events. A page reload (or a phone browser evicting the tab) silently loses every pending alert. There is no server-side record. - Push Approve/Deny buttons are dead.
PUSH_EVENT_MAPalready attachesapprove/denyactions to permission pushes, andsw.jsforwardsevent.actionto the page, but thenotification-clickhandler in settings-ui.js ignores it (and when no tab is open, the action is dropped entirely). The buttons render on the lock screen and do nothing. - Card context is missing. The frontend handlers read
data.question/data.message/data.tool, butsanitizeHookDatanever forwardsmessage, so notifications show generic fallback text.
- Claude mode only (hooks fire only for
claude; external CLIs keep their output-stabilization heuristics and get no inbox items). This mirrors the wait-primitivestop/blockedgating. - Permission prompts occur for sessions running
ClaudeModenormal/auto/allowedTools(and the trust-folder dialog even under skip-permissions). Question and idle prompts occur in every mode includingdangerously-skip-permissions. - In-memory store (plus the frontend seeding from it on load). Server restart drops items; hooks re-fire on the next prompt. No new state file in v1.
At most one active item per session: the Claude TUI shows one dialog at a time, so a new prompt event supersedes the session's previous item (resolution superseded).
interface ApprovalItem {
id: string; // `${sessionId}:${seq}`
sessionId: string;
sessionName: string;
kind: 'permission' | 'question' | 'idle';
createdAt: number;
toolName?: string; // from sanitized hook data
toolSummary?: string; // command / file_path / description, already bounded
message?: string; // Notification hook `message` (newly allowlisted)
cwd?: string;
context?: string; // ANSI-stripped visible pane frame tail, ≤ 4000 chars
options?: { n: number; label: string }[]; // parsed from context when confident
}Resolutions (server-emitted, item removed from pending): answered (via inbox), resolved_in_terminal (stop / elicitation_complete / elicitation_response / session went working), superseded, session_ended, dismissed, expired (12h TTL sweep).
Module-level singleton in the style of session-wait-registry.ts (pure, no Session import, injected emit callback so there is no import cycle with the server):
notePrompt(info)creates/supersedes the session's item; schedules ONE re-capture ~600ms later (the Notification hook can fire before the dialog finishes painting) which updatescontext/optionsand emitsapproval:updated.resolveForSession(sessionId, reason),dismiss(id),answerable(id),listPending(),stop()(clears timers; tests).- Option parsing (pure, unit-tested): consecutive
❯? N. labellines, 2..6 options, labels ≤ 120 chars. Parsed options gate which digits the answer endpoint accepts; when parsing fails the card falls back to Approve(1)/Deny(Esc) only. - TTL: items expire after 12h (checked on read + a lazy sweep; no standing interval).
hook-event-routes.ts: onpermission_prompt/elicitation_dialog/idle_prompt, callnotePromptwith sanitized data + a pane capture callback (mux.capturePaneBuffer(muxName)visible frame, ANSI-stripped via existing utils; fall back tosession.terminalBuffertail). Onstop/elicitation_complete/elicitation_response,resolveForSession(id, 'resolved_in_terminal').session-listener-wiring.ts:workinglistener resolves idle items only (workingis heuristic and can flap mid-turn, so it must never clear a pending permission/question dialog);exitresolves withsession_ended. Same singleton-import pattern assessionWaits.- Session delete route: resolve with
session_ended. - New hook matchers
elicitation_complete+elicitation_responseadded togenerateHooksConfig(),HookEventType,HookEventSchema, and both SSE registries.refreshStaleCodemanHooksgets a staleness probe for them (hooksJson.includes('elicitation_complete')) so existing cases heal on next Claude spawn, exactly like the-k/secret/marker probes. sanitizeHookData: allowlistmessage(bounded 500 chars). This also un-deadens the existing notification text paths.
Normal authed API (NOT the hook-secret bypass), ApiResponse envelope, Zod schemas in schemas.ts:
GET /api/approvals→ pending items, multi-user filtered bycanAccessOwned(same policy as session lists). Also sweeps the caller's own items for staleness throughverifyStillAnswerable(): Claude Code fires no "permission answered" hook, so a dialog answered in the terminal used to sit pending untilstopand 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/answerbody{ 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).option→ digitString(n); accepted only whennis within the item's parsed options (prevents blind digit-poking at an unparsed dialog).text→idleitems only: single line, embedded newlines stripped, sent astext\r(the\rdiscipline 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). MarksansweredBEFORE 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 asapproval: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).
approval:pending, approval:updated, approval:resolved in sse-events.ts + SSE_EVENTS in constants.js (the parity test pins the sync). Broadcasts carry sessionId, so multi-user SSE scoping applies unchanged.
sendPushNotificationspayload gainsapprovalIdfor the three hook events. BothapprovalIdand the Approve/Denyactionsare gated on the opt-in setting: with it off, permission pushes carry no buttons at all (pre-inbox they rendered and did nothing, so stripping them is the honest shape).sw.jsnotificationclick: whenevent.actionisapprove/deny, POST/api/approvals/:id/answerdirectly from the worker (same-origin, cookie credentials) so the buttons work with no tab open; on failure fall back to focusing/opening a tab. Non-action clicks keep today's behavior.- Page-side
notification-clickhandler: honoractioninstead of dropping it (also setting-gated, for stale notifications sent before the toggle flipped). - Question/idle pushes keep no action buttons (options vary per dialog); tapping opens the inbox.
New module approvals-ui.js (@loadorder 11.2, after panels-ui.js), prettier-formatted (not added to .prettierignore).
- Seed on connect:
GET /api/approvalson init and SSE reconnect; each pending item re-feedssetPendingHook(...)so tab alerts and the phone overview survive reload (fixes problem 2 with zero changes to the alert state machine). Items carryingacknowledgedAtare skipped, andmarkIdleAlertSeen()(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 throughapproval:updated. - Desktop: header bell
btn-approvalswith count badge. Ships default-hidden via marker classbtn-approvals--hidden(same policy as the attachments button, sotest/mobile-header-buttons-policy.test.tsexcludes 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-skipwhere they would collide (mirroring the overview pills). - Setting:
approvalsInboxEnabled, synced (inSettingsUpdateSchema), default OFF (owner decision: the entire feature is opt-in, meaning no bell, no drawer, no overview strips, no seeding, and no push action buttons until enabled in App Settings → Panels). Only the store and answer endpoints keep running regardless, so flipping the toggle ON surfaces anything already pending immediately, with no restart.
The prompt can be answered in the terminal a moment before an inbox answer lands; then the keystroke would hit whatever now has focus (worst case: a digit typed into the composer, not submitted, since no \r is ever sent for menu answers). Mitigations, in order: answer-time re-capture (the dialog must still parse on screen or the answer is refused), answered-before-write marking, digit-only/Esc-only writes for menus, and the card's context block showing what the pane looked like when captured. This is the same class of risk writeViaMux automation (auto-resume, respawn) already accepts.
test/approval-inbox.test.ts: supersede per session, every resolution path, TTL, option parsing fixtures (2-option, 3-option with ❯, unparseable frame), re-capture update.test/routes/approval-routes.test.ts(app.inject, no port): list; hook event creates item; answer approve/deny/option writes the exact bytes (test-PTY echo asserts them); text answers restricted to idle; 404 unknown id; 409 answered twice; option out of range rejected; multi-user scoping.- Existing suites extended: hook-event schema accepts the two new events;
sanitizeHookDataforwards boundedmessage; SSE parity + mobile-header policy pass as-is by construction.
- CLAUDE.md: Key Patterns entry + SSE/route counts + frontend load order.
docs/api-reference.md: the two endpoints + three SSE events (additive, fine under the 0.9.x contract).