feat(approvals): make the inbox opt-in (default OFF) and drop em-dashes

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>
This commit is contained in:
Codeman maintainer
2026-08-09 15:55:51 +02:00
parent ff10a50bc0
commit 6c744f8677
13 changed files with 54 additions and 51 deletions
+1 -1
View File
@@ -206,7 +206,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`.
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default ON): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event` — 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 — 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`, which is what makes tab alerts survive reloads; push Approve/Deny actions are answered from `sw.js` directly so they work with no tab open. Surfaces: 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 UI surface is opt-in, though the store/endpoints/push actions run regardless): `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` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny actions are answered from `sw.js` directly so they work with no tab open, setting-independent. 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`.
**Agent Teams**: `TeamWatcher` polls `~/.claude/teams/`, matches to sessions via `leadSessionId`. Teammates are in-process threads appearing as subagents. Enable: `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`. See `docs/agent-teams/`.
+1 -1
View File
@@ -88,7 +88,7 @@ New module `approvals-ui.js` (@loadorder 11.2, after panels-ui.js), prettier-for
- **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).
- **Setting**: `approvalsInboxEnabled`, synced (in `SettingsUpdateSchema`), default ON, resolved from `merged` per the partial-PUT rule. OFF hides the UI surfaces and stops seeding; the store itself keeps running (harmless, and push actions keep working).
- **Setting**: `approvalsInboxEnabled`, synced (in `SettingsUpdateSchema`), **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
+11 -11
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Approvals Inbox — server-side registry of prompts waiting on a human.
* @fileoverview Approvals Inbox: server-side registry of prompts waiting on a human.
*
* One cross-session queue of pending Claude prompts (permission dialogs,
* AskUserQuestion/elicitation questions, idle prompts), fed by `/api/hook-event`
@@ -48,7 +48,7 @@ export interface ApprovalOption {
}
export interface ApprovalItem {
/** `${sessionId}:${seq}` — stable across re-captures, unique per prompt. */
/** `${sessionId}:${seq}`, stable across re-captures, unique per prompt. */
id: string;
sessionId: string;
sessionName: string;
@@ -96,7 +96,7 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
* single delayed re-capture picks up the frame the immediate capture missed.
*/
const RECAPTURE_DELAY_MS = 600;
/** Context kept per item — enough for a dialog plus a few lines above it. */
/** Context kept per item: enough for a dialog plus a few lines above it. */
const MAX_CONTEXT_CHARS = 4000;
const MAX_CONTEXT_LINES = 30;
const MAX_OPTION_LABEL_CHARS = 120;
@@ -144,9 +144,9 @@ export function normalizeCapturedFrame(raw: string | null | undefined): string |
* Options must be consecutively numbered from 1 (2..6 of them); description /
* wrap / separator lines between options are tolerated up to a small gap
* (AskUserQuestion puts a description under every option and a ─ separator
* before its "Chat about this" entry — measured against the live dialog). The
* before its "Chat about this" entry, measured against the live dialog). The
* LAST complete block in the frame wins (dialogs render at the bottom).
* Returns undefined when nothing parses — callers then fall back to
* Returns undefined when nothing parses; callers then fall back to
* approve/deny only, so a mis-parse can never route a digit at a dialog that
* does not have it.
*/
@@ -171,7 +171,7 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
commit();
run = [{ n: 1, label: m[2].trim().slice(0, MAX_OPTION_LABEL_CHARS) }];
} else if (run.length > 0 && ++gap > 3) {
// Too far past the last option for this to still be its description —
// Too far past the last option for this to still be its description:
// the block is over.
commit();
}
@@ -183,7 +183,7 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
// ─── Registry ────────────────────────────────────────────────────────────────
export class ApprovalInbox {
/** Keyed by sessionId — the one-active-item-per-session invariant lives here. */
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
private items = new Map<string, ApprovalItem>();
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
@@ -235,7 +235,7 @@ export class ApprovalInbox {
* Answer-time guard: re-capture the pane and check the dialog is still on
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
* frame parsed options: if a fresh capture then parses none, the dialog is
* gone (answered in the terminal moments ago) — the item resolves and the
* gone (answered in the terminal moments ago), so the item resolves and the
* answer must be refused, because the digit would land in whatever now has
* focus. Unparseable-from-the-start items stay answerable (approve/deny
* only), same risk the terminal user already carries.
@@ -250,7 +250,7 @@ export class ApprovalInbox {
try {
raw = capture();
} catch {
return true; // capture hiccup — inconclusive, keep the item answerable
return true; // capture hiccup: inconclusive, keep the item answerable
}
const context = normalizeCapturedFrame(raw);
if (!context) return true;
@@ -316,7 +316,7 @@ export class ApprovalInbox {
/**
* Resolve a session's pending item, if any (stop hook, exit, ...). `kinds`
* restricts which item kinds the signal may clear — the heuristic `working`
* restricts which item kinds the signal may clear: the heuristic `working`
* transition passes `['idle']` so a mid-turn flap cannot false-clear a
* pending permission/question dialog.
*/
@@ -347,7 +347,7 @@ export class ApprovalInbox {
const context = normalizeCapturedFrame(raw);
if (!context) return;
item.context = context;
// Idle prompts are not dialogs — never offer digit answers for them.
// Idle prompts are not dialogs; never offer digit answers for them.
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
}
+14 -12
View File
@@ -1,10 +1,12 @@
/**
* @fileoverview Approvals Inbox UI — cross-session queue of prompts waiting on a human.
* @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human.
*
* Renders the header bell (count badge, shown only while items are pending) and
* the right-side drawer of approval cards, seeds pending items from
* `GET /api/approvals` on init/reconnect (so tab alerts survive a reload), and
* answers items in place via `POST /api/approvals/:id/answer`. Cards render
* Everything here is gated on the OPT-IN `approvalsInboxEnabled` setting
* (synced, default OFF): with it off, no bell, no drawer, no overview strips,
* no seeding. When on, the header bell renders only while items are pending
* (count badge), opening a right-side drawer of approval cards; pending items
* are seeded from `GET /api/approvals` on init/reconnect (so tab alerts
* survive a reload) and answered in place via `POST /api/approvals/:id/answer`. Cards render
* buttons from the server-parsed dialog options; without parsed options they
* fall back to Approve/Deny (permission/question) or a text prompt (idle).
* Backend: src/web/approval-inbox.ts, design: docs/approvals-inbox-plan.md.
@@ -13,7 +15,7 @@
* @dependency app.js (CodemanApp class, this.approvals, setPendingHook/clearPendingHooks, selectSession)
* @dependency constants.js (escapeHtml)
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
* @loadorder 11.6 of 17 — after ultracode-panel.js, before admin-ui.js
* @loadorder 11.6 of 17, after ultracode-panel.js, before admin-ui.js
*/
/** Map an approval kind to the pendingHooks entry that drives tab alerts. */
@@ -22,14 +24,14 @@ function approvalKindToHook(kind) {
}
Object.assign(CodemanApp.prototype, {
/** Synced setting, default ON (only an explicit false disables). */
/** Synced setting, default OFF, opt-in via App Settings → Panels. */
approvalsInboxEnabled() {
return this.loadAppSettingsFromStorage().approvalsInboxEnabled !== false;
return this.loadAppSettingsFromStorage().approvalsInboxEnabled === true;
},
/**
* Seed pending approvals from the server. Called from handleInit, i.e. on
* every page load AND SSE reconnect — this is what makes pending alerts
* every page load AND SSE reconnect; this is what makes pending alerts
* survive a reload (pre-inbox they lived only in SSE-transient memory).
*/
async seedApprovals() {
@@ -51,7 +53,7 @@ Object.assign(CodemanApp.prototype, {
_onApprovalPending(item) {
if (!item || !item.id) return;
if (!this.approvals) this.approvals = new Map();
// One active item per session (server invariant) — drop any stale sibling.
// One active item per session (server invariant): drop any stale sibling.
for (const [id, existing] of this.approvals) {
if (existing.sessionId === item.sessionId) this.approvals.delete(id);
}
@@ -88,7 +90,7 @@ Object.assign(CodemanApp.prototype, {
this.showToast(action === 'deny' ? 'Denied' : 'Answer sent', 'success');
} else {
// 404/409 = resolved elsewhere or the dialog left the screen; refresh truth.
this.showToast('Could not answer — prompt may already be resolved', 'warning');
this.showToast('Could not answer, the prompt may already be resolved', 'warning');
this.seedApprovals();
}
},
@@ -104,7 +106,7 @@ Object.assign(CodemanApp.prototype, {
});
if (data) this.showToast('Prompt sent', 'success');
else {
this.showToast('Could not send — session may be busy', 'warning');
this.showToast('Could not send, the session may be busy', 'warning');
this.seedApprovals();
}
},
+1 -1
View File
@@ -2509,7 +2509,7 @@ html.mobile-init .file-browser-panel {
/* Approvals Inbox answer strip: sits under a NEEDS YOU row (sibling of the
row <button>, see _buildMobileOverviewApprovalStrip). Buttons inherit no
toolbar styling on purpose — they are one-tap dialog answers, not runs. */
toolbar styling on purpose; they are one-tap dialog answers, not runs. */
.mobile-overview-row-wrap {
width: 100%;
}
+4 -4
View File
@@ -39,7 +39,7 @@ Object.assign(CodemanApp.prototype, {
},
_onHookElicitationComplete(data) {
// Question answered in the terminal — clear the action alert without
// Question answered in the terminal: clear the action alert without
// waiting for `stop` (the turn may keep running for a long time).
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
@@ -172,7 +172,7 @@ Object.assign(CodemanApp.prototype, {
if (event.data?.type === 'notification-click') {
const { sessionId, action, approvalId } = event.data;
if (action) {
// Approve/Deny action buttons on a push — answer via the
// Approve/Deny action buttons on a push: answer via the
// Approvals Inbox instead of just focusing the session.
this.handleNotificationAction?.(action, approvalId, sessionId);
} else if (sessionId && this.sessions.has(sessionId)) {
@@ -342,8 +342,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
// Approvals Inbox: synced, default ON (only an explicit false disables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled !== false;
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
+2 -2
View File
@@ -10643,7 +10643,7 @@ kbd {
display: none !important;
}
/* "Approvals" header bell — appears ONLY while prompts are pending (JS toggles
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
the marker class on count changes), so it ships hidden and stays out of the
default header. Same marker pattern as the attachments button. */
.btn-approvals {
@@ -10672,7 +10672,7 @@ kbd {
pointer-events: none;
}
/* Approvals Inbox drawer — same shell as the attachment history drawer. */
/* Approvals Inbox drawer: same shell as the attachment history drawer. */
.approvals-drawer {
position: fixed;
top: var(--header-height);
+1 -1
View File
@@ -159,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
body: JSON.stringify({ action }),
}).then((res) => {
if (res && res.ok) return undefined;
// 401/404/409: let the human see the state — fall back to a tab.
// 401/404/409: let the human see the state by falling back to a tab.
return openOrFocus(sessionId, action, approvalId, targetUrl);
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
);
+6 -6
View File
@@ -3,10 +3,10 @@
*
* 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
* - `POST /api/approvals/:id/answer` — answer in place by sending the
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode
* - `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/:id/dismiss`: drop the item without keystrokes
*
* 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
@@ -24,8 +24,8 @@ import type { SessionPort } from '../ports/index.js';
/**
* Keystrokes for an answer, or an error string. Menu answers are a single digit
* or Esc — dialogs react to the keypress itself, so no Enter is ever sent for
* them. Free text is allowed only for idle prompts (there IS no dialog; the
* or Esc (dialogs react to the keypress itself, so no Enter is ever sent for
* them). Free text is allowed only for idle prompts (there IS no dialog; the
* text lands in the composer and `\r` submits it, per the CLAUDE.md input
* discipline). `option` digits must match a PARSED option so a blind digit can
* never be routed at a dialog we could not read.
@@ -82,7 +82,7 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Covers unknown, already-answered, superseded and expired ids alike.
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Approval not found or no longer pending');
}
// Throws 404 (not 403) for sessions the caller does not own — same
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode)) {
+5 -4
View File
@@ -680,7 +680,7 @@ export const HookEventSchema = z.object({
/**
* Body of POST /api/approvals/:id/answer (Approvals Inbox).
* `option` digits are additionally validated against the item's PARSED options
* in the route — the schema alone must not authorize blind digit-poking.
* in the route; the schema alone must not authorize blind digit-poking.
*/
export const ApprovalAnswerSchema = z
.object({
@@ -791,9 +791,10 @@ export const SettingsUpdateSchema = z
*/
agentSkillEnabled: z.boolean().optional(),
/**
* Approvals Inbox UI (header badge + drawer, phone overview answer buttons).
* SYNCED, default ON: the surfaces only appear while a prompt is pending.
* The server-side store runs regardless (push actions keep working).
* Approvals Inbox UI (header bell + drawer, phone overview answer buttons).
* SYNCED, default OFF (opt-in): even with items pending, no surface renders
* until this is enabled. The server-side store and answer endpoints run
* regardless, so push Approve/Deny actions keep working either way.
*/
approvalsInboxEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
+1 -1
View File
@@ -2152,7 +2152,7 @@ export class WebServer extends EventEmitter {
body,
tag: `codeman-${event}-${sessionId}`,
sessionId,
// Approvals Inbox item id — lets sw.js answer an Approve/Deny action
// Approvals Inbox item id: lets sw.js answer an Approve/Deny action
// click directly (POST /api/approvals/:id/answer) with no tab open.
approvalId: typeof data.approvalId === 'string' ? data.approvalId : undefined,
urgency: template.urgency,
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Approvals Inbox store unit tests (src/web/approval-inbox.ts).
*
* Pure in-memory registry — no ports, no server. Constructs its own
* Pure in-memory registry: no ports, no server. Constructs its own
* ApprovalInbox instances (never the process singleton) so tests cannot
* leak state into the route tests that share the module.
*/
@@ -118,7 +118,7 @@ describe('normalizeCapturedFrame', () => {
});
it('converts absolute row repaints (formatPaneSnapshot frames) into lines', () => {
// The visible tmux capture carries NO newlines — every row is painted at
// The visible tmux capture carries NO newlines; every row is painted at
// `ESC[<row>;1H`. Measured against a live dialog frame.
const raw = '\x1b[12;1H Which color do you prefer?\x1b[13;1H❯ 1. Red\x1b[14;1H Prefer red\x1b[15;1H 2. Blue';
const out = normalizeCapturedFrame(raw)!;
+5 -5
View File
@@ -1,11 +1,11 @@
/**
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject()
* — no live port. The hook-event route is registered alongside so items are
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject(),
* no live port. The hook-event route is registered alongside so items are
* created through the REAL ingestion path (sanitize → notePrompt with the
* terminal-buffer capture fallback), not by poking the store directly.
*
* The routes read the process-wide `approvalInbox` singleton, so every test
* drains it in afterEach — a leaked pending item would bleed into the next test.
* drains it in afterEach; a leaked pending item would bleed into the next test.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
@@ -203,7 +203,7 @@ describe('approval routes', () => {
it('refuses with 409 when the dialog left the screen since capture', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
// The dialog scrolled away — the re-capture at answer time must refuse.
// The dialog scrolled away, so the re-capture at answer time must refuse.
session.terminalBuffer = 'claude is off doing something else now';
const res = await harness.app.inject({
method: 'POST',
@@ -301,7 +301,7 @@ describe('approval routes', () => {
});
});
describe('approval routes — multi-user scoping', () => {
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
beforeEach(() => {