Owner decision: every Approvals Inbox UI surface (header bell, drawer, phone overview answer strips, reload seeding) now requires enabling approvalsInboxEnabled in App Settings -> Panels; only an explicit true turns it on. The store, endpoints, and push Approve/Deny actions keep running regardless (the push buttons are already opt-in per subscription). Also replaces em-dashes with plain punctuation across the newly authored comments, docs, and strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 KiB
Approvals Inbox (design)
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.
Problems this fixes (all real today)
- 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.
Scope
- 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.
Data model
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).
Backend
Store: src/approval-inbox.ts
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).
Wiring
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.
Routes: src/web/routes/approval-routes.ts
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).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.
SSE
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.
Push
sendPushNotificationspayload gainsapprovalIdfor the three hook events.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. - Question/idle pushes keep no action buttons (options vary per dialog); tapping opens the inbox.
Frontend
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). - 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: every UI surface is opt-in, meaning no bell, no drawer, no overview strips, no seeding until enabled in App Settings → Panels). The store and answer endpoints keep running regardless, so the push Approve/Deny actions work either way (they are already opt-in per-subscription via push preferences).
Race honesty
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.
Tests
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.
Docs
- 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).