diff --git a/CLAUDE.md b/CLAUDE.md index 9f9693e6..d3044f49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | | **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 25 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 26 modules + `sw.js` | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -206,7 +206,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. +**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 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` (which is what makes tab alerts survive reloads), but only with the setting ON; push Approve/Deny buttons are also gated on it (`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`. **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/`. @@ -242,7 +244,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. @@ -296,11 +298,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### SSE Event Registry -149 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown. +154 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown. ### API Routes -~200 handlers across 21 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~200 handlers across 22 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), approvals (3), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/docs/api-reference.md b/docs/api-reference.md index 957cf791..9244985c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -407,6 +407,33 @@ count against the same 16, not 16 of each. An abandoned request no longer holds slot, because the routes release the waiter when the client disconnects, but a client that opens many concurrent waits against one session will still hit the cap. +## Approvals Inbox + +Cross-session queue of prompts waiting on a human (permission dialogs, +AskUserQuestion questions, idle prompts). Claude-mode sessions only; items are +in-memory (a server restart drops them; the next prompt re-fires the hook). +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. +- `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 + `options`), or `{ action: 'text', text }` (idle prompts only; submits the + line as a prompt). `404 NOT_FOUND` when the item is no longer pending, + `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. + +SSE events: `approval:pending` (full item), `approval:updated` (context/options +re-captured), `approval:resolved` (`{ id, sessionId, kind, resolution }` with +`resolution` one of `answered | resolved_in_terminal | superseded | +session_ended | dismissed | expired`). + ## Authentication Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque diff --git a/docs/approvals-inbox-plan.md b/docs/approvals-inbox-plan.md new file mode 100644 index 00000000..7efa96db --- /dev/null +++ b/docs/approvals-inbox-plan.md @@ -0,0 +1,106 @@ +# 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) + +1. **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. +2. **Alerts die on reload.** `pendingHooks` lives only in `app.js` memory, fed by transient SSE `hook:*` events. A page reload (or a phone browser evicting the tab) silently loses every pending alert. There is no server-side record. +3. **Push Approve/Deny buttons are dead.** `PUSH_EVENT_MAP` already attaches `approve`/`deny` actions to permission pushes, and `sw.js` forwards `event.action` to the page, but the `notification-click` handler 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. +4. **Card context is missing.** The frontend handlers read `data.question` / `data.message` / `data.tool`, but `sanitizeHookData` never forwards `message`, 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-primitive `stop`/`blocked` gating. +- Permission prompts occur for sessions running `ClaudeMode` `normal` / `auto` / `allowedTools` (and the trust-folder dialog even under skip-permissions). Question and idle prompts occur in every mode including `dangerously-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`). + +```ts +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 updates `context`/`options` and emits `approval:updated`. +- `resolveForSession(sessionId, reason)`, `dismiss(id)`, `answerable(id)`, `listPending()`, `stop()` (clears timers; tests). +- Option parsing (pure, unit-tested): consecutive `❯? N. label` lines, 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`: on `permission_prompt` / `elicitation_dialog` / `idle_prompt`, call `notePrompt` with sanitized data + a pane capture callback (`mux.capturePaneBuffer(muxName)` visible frame, ANSI-stripped via existing utils; fall back to `session.terminalBuffer` tail). On `stop` / `elicitation_complete` / `elicitation_response`, `resolveForSession(id, 'resolved_in_terminal')`. +- `session-listener-wiring.ts`: `working` listener resolves **idle items only** (`working` is heuristic and can flap mid-turn, so it must never clear a pending permission/question dialog); `exit` resolves with `session_ended`. Same singleton-import pattern as `sessionWaits`. +- Session delete route: resolve with `session_ended`. +- **New hook matchers** `elicitation_complete` + `elicitation_response` added to `generateHooksConfig()`, `HookEventType`, `HookEventSchema`, and both SSE registries. `refreshStaleCodemanHooks` gets 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`: allowlist `message` (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 by `canAccessOwned` (same policy as session lists). +- `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). + - `option` → digit `String(n)`; accepted only when `n` is within the item's parsed options (prevents blind digit-poking at an unparsed dialog). + - `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. + +### 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 + +- `sendPushNotifications` payload gains `approvalId` for the three hook events. Both `approvalId` and the Approve/Deny `actions` are **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.js` `notificationclick`: when `event.action` is `approve`/`deny`, POST `/api/approvals/:id/answer` directly 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-click` handler: honor `action` instead 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. + +## Frontend + +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). +- **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 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. + +## 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; `sanitizeHookData` forwards bounded `message`; 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). diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 8a2cfc1f..f7785bc1 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -15,9 +15,10 @@ * - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings * * Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`, - * `stop`, `teammate_idle`, `task_completed` + * `elicitation_complete`, `elicitation_response`, `stop`, `teammate_idle`, + * `task_completed` * - * Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1), + * Hook categories: `Notification` (5 matchers), `Stop` (1), `SubagentStop` (1), * `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained * background Bash rewake) * @@ -332,6 +333,16 @@ export function generateHooksConfig(): { hooks: Record } { matcher: 'elicitation_dialog', hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }], }, + // The two dialog-closed notifications resolve Approvals Inbox items the + // moment a question is answered IN the terminal (long before `stop`). + { + matcher: 'elicitation_complete', + hooks: [{ type: 'command', command: curlCmd('elicitation_complete'), timeout: HOOK_TIMEOUT_SECONDS }], + }, + { + matcher: 'elicitation_response', + hooks: [{ type: 'command', command: curlCmd('elicitation_response'), timeout: HOOK_TIMEOUT_SECONDS }], + }, ], Stop: [ { @@ -662,7 +673,14 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise // on a self-signed HTTPS install. const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST'); const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER); - if (!isOurs || (hasSecret && hasBackgroundWake && hasSubagentStopGuard && !hasTlsFlaglessCurl)) return; + // Approvals Inbox needs the elicitation_complete/elicitation_response + // matchers; their absence marks a pre-inbox hooks block. + const hasElicitationComplete = hooksJson.includes('elicitation_complete'); + if ( + !isOurs || + (hasSecret && hasBackgroundWake && hasSubagentStopGuard && hasElicitationComplete && !hasTlsFlaglessCurl) + ) + return; const generated = generateHooksConfig(); const merged = { ...existing, diff --git a/src/types/api.ts b/src/types/api.ts index 4795caa7..2bc57493 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -105,6 +105,8 @@ export type HookEventType = | 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' + | 'elicitation_complete' + | 'elicitation_response' | 'stop' | 'teammate_idle' | 'task_completed'; diff --git a/src/web/approval-inbox.ts b/src/web/approval-inbox.ts new file mode 100644 index 00000000..97e34411 --- /dev/null +++ b/src/web/approval-inbox.ts @@ -0,0 +1,377 @@ +/** + * @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` + * and answered via `POST /api/approvals/:id/answer`. Before this store existed, + * pending prompts lived only in `app.js` memory (SSE-transient, lost on reload) + * and the push notification Approve/Deny buttons had nothing to act on. + * Design: `docs/approvals-inbox-plan.md`. + * + * Invariants: + * - At most ONE active item per session: the Claude TUI shows one dialog at a + * time, so a new prompt supersedes the session's previous item. + * - Module-level singleton in the style of `session-wait-registry.ts`: no + * `Session` import, no IO; the server injects emit callbacks (`onPending`/ + * `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free. + * - Items are in-memory only. A server restart drops them; the next prompt + * re-fires the hook. Claude-mode sessions only (hooks fire for nothing else). + * - 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. + * + * @dependencies utils (stripAnsi) + * @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes, + * web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop) + * + * @module web/approval-inbox + */ + +import { stripAnsi } from '../utils/index.js'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ApprovalKind = 'permission' | 'question' | 'idle'; + +export type ApprovalResolution = + | 'answered' + | 'resolved_in_terminal' + | 'superseded' + | 'session_ended' + | 'dismissed' + | 'expired'; + +/** A numbered choice parsed from the captured dialog frame. */ +export interface ApprovalOption { + n: number; + label: string; +} + +export interface ApprovalItem { + /** `${sessionId}:${seq}`, stable across re-captures, unique per prompt. */ + id: string; + sessionId: string; + sessionName: string; + kind: ApprovalKind; + createdAt: number; + /** Sanitized hook fields (already bounded by sanitizeHookData). */ + toolName?: string; + toolSummary?: string; + message?: string; + cwd?: string; + /** ANSI-stripped tail of the visible pane frame at capture time. */ + context?: string; + /** + * Present only when the frame parsed confidently. Gates which digits the + * answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed. + */ + options?: ApprovalOption[]; +} + +export interface ApprovalResolvedInfo { + id: string; + sessionId: string; + kind: ApprovalKind; + resolution: ApprovalResolution; +} + +interface NotePromptArgs { + sessionId: string; + sessionName: string; + kind: ApprovalKind; + toolName?: string; + toolSummary?: string; + message?: string; + cwd?: string; + /** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */ + capture?: () => string | null; +} + +// ─── Tunables ──────────────────────────────────────────────────────────────── + +/** Items older than this are dropped on read: a 12h-old dialog is stale by any measure. */ +const ITEM_TTL_MS = 12 * 60 * 60 * 1000; +/** + * The Notification hook can fire before Ink finishes painting the dialog, so a + * 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. */ +const MAX_CONTEXT_CHARS = 4000; +const MAX_CONTEXT_LINES = 30; +const MAX_OPTION_LABEL_CHARS = 120; + +// ─── Pure helpers ──────────────────────────────────────────────────────────── + +/** + * The visible-frame tmux capture (`formatPaneSnapshot`) carries NO newlines: it + * repaints every row at its absolute position via `ESC[;H`. Verified + * against a live dialog: without this conversion the whole frame collapses to + * one line and no dialog ever parses. Column 1 (or omitted) means a fresh row → + * newline; a mid-row jump becomes a space so adjacent words don't merge. + */ +// eslint-disable-next-line no-control-regex +const CURSOR_POSITION_PATTERN = /\x1b\[(?:(\d+)(?:;(\d+))?)?[Hf]/g; + +/** + * Normalize a raw pane capture into card context: convert row repaints to + * lines, strip ANSI, right-trim lines, drop trailing blanks, keep the last + * MAX_CONTEXT_LINES lines. + */ +export function normalizeCapturedFrame(raw: string | null | undefined): string | undefined { + if (!raw) return undefined; + const rowed = raw.replace(CURSOR_POSITION_PATTERN, (_m, _row, col) => (!col || col === '1' ? '\n' : ' ')); + const lines = stripAnsi(rowed) + .split('\n') + .map((line) => line.replace(/\s+$/, '')); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + while (lines.length > 0 && lines[0] === '') lines.shift(); + if (lines.length === 0) return undefined; + const text = lines.slice(-MAX_CONTEXT_LINES).join('\n'); + return text.length > MAX_CONTEXT_CHARS ? text.slice(-MAX_CONTEXT_CHARS) : text; +} + +/** + * Parse the numbered options of a Claude dialog out of a normalized frame. + * + * Matches the shapes Ink renders for permission prompts and AskUserQuestion: + * + * ❯ 1. Yes ❯ 1. Red + * 2. Yes, allow all edits (shift+tab) Prefer red + * 3. No, tell Claude what to do (esc) 2. Blue + * Prefer blue + * + * 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 + * LAST complete block in the frame wins (dialogs render at the bottom). + * 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. + */ +export function parseDialogOptions(context: string | undefined): ApprovalOption[] | undefined { + if (!context) return undefined; + const lines = context.split('\n'); + let lastComplete: ApprovalOption[] | undefined; + let run: ApprovalOption[] = []; + let gap = 0; + const commit = () => { + if (run.length >= 2 && run.length <= 6) lastComplete = run; + run = []; + gap = 0; + }; + for (const line of lines) { + const m = line.match(/^\s*(?:❯\s*)?(\d)[.)]\s+(.+)$/); + const n = m ? Number(m[1]) : NaN; + if (m && n === run.length + 1) { + run.push({ n, label: m[2].trim().slice(0, MAX_OPTION_LABEL_CHARS) }); + gap = 0; + } else if (m && n === 1) { + 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: + // the block is over. + commit(); + } + } + commit(); + return lastComplete; +} + +// ─── Registry ──────────────────────────────────────────────────────────────── + +export class ApprovalInbox { + /** Keyed by sessionId; the one-active-item-per-session invariant lives here. */ + private items = new Map(); + private recaptureTimers = new Map>(); + /** Capture callbacks kept for answer-time re-verification; dropped on remove. */ + private captures = new Map string | null>(); + private seq = 0; + private stopped = false; + + /** Emit callbacks, injected by the server (SSE broadcast + push). */ + onPending?: (item: ApprovalItem) => void; + onUpdated?: (item: ApprovalItem) => void; + onResolved?: (info: ApprovalResolvedInfo) => void; + + /** + * Record a prompt for a session, superseding any previous item, and return + * the new item. Captures context immediately and once more after a short + * delay (see RECAPTURE_DELAY_MS). + */ + notePrompt(args: NotePromptArgs): ApprovalItem { + this.resolveForSession(args.sessionId, 'superseded'); + const item: ApprovalItem = { + id: `${args.sessionId}:${++this.seq}`, + sessionId: args.sessionId, + sessionName: args.sessionName, + kind: args.kind, + createdAt: Date.now(), + toolName: args.toolName, + toolSummary: args.toolSummary, + message: args.message, + cwd: args.cwd, + }; + this.applyCapture(item, args.capture); + this.items.set(args.sessionId, item); + if (args.capture) this.captures.set(args.sessionId, args.capture); + this.onPending?.(item); + if (args.capture && !this.stopped) { + const timer = setTimeout(() => { + this.recaptureTimers.delete(item.id); + // Only update the item if it is still the live one for the session. + if (this.items.get(args.sessionId)?.id !== item.id) return; + this.applyCapture(item, args.capture); + this.onUpdated?.(item); + }, RECAPTURE_DELAY_MS); + this.recaptureTimers.set(item.id, timer); + } + return item; + } + + /** + * 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), 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. + */ + verifyStillAnswerable(id: string): boolean { + const item = this.getById(id); + if (!item) return false; + if (item.kind === 'idle' || !item.options) return true; + const capture = this.captures.get(item.sessionId); + if (!capture) return true; + let raw: string | null = null; + try { + raw = capture(); + } catch { + return true; // capture hiccup: inconclusive, keep the item answerable + } + const context = normalizeCapturedFrame(raw); + if (!context) return true; + const options = parseDialogOptions(context); + if (!options) { + this.remove(item, 'resolved_in_terminal'); + return false; + } + item.context = context; + item.options = options; + return true; + } + + /** Pending item for a session, TTL-checked. */ + getForSession(sessionId: string): ApprovalItem | undefined { + const item = this.items.get(sessionId); + if (!item) return undefined; + if (this.isExpired(item)) { + this.resolveForSession(sessionId, 'expired'); + return undefined; + } + return item; + } + + /** Pending item by id, TTL-checked. */ + getById(id: string): ApprovalItem | undefined { + const item = this.getForSession(sessionIdOf(id)); + return item?.id === id ? item : undefined; + } + + /** All pending items, TTL-swept, oldest first. */ + listPending(): ApprovalItem[] { + for (const sessionId of [...this.items.keys()]) this.getForSession(sessionId); + return [...this.items.values()].sort((a, b) => a.createdAt - b.createdAt); + } + + /** + * Remove the item as `answered` and return it, or undefined if it is no + * longer pending. Callers send keystrokes AFTER a successful take, and + * `restore()` on a failed write. + */ + take(id: string): ApprovalItem | undefined { + const item = this.getById(id); + if (!item) return undefined; + this.remove(item, 'answered'); + return item; + } + + /** Re-insert a taken item after a failed write, unless superseded meanwhile. */ + restore(item: ApprovalItem): void { + if (this.stopped || this.items.has(item.sessionId)) return; + this.items.set(item.sessionId, item); + this.onPending?.(item); + } + + /** Remove an item without keystrokes (user chose Dismiss). */ + dismiss(id: string): boolean { + const item = this.getById(id); + if (!item) return false; + this.remove(item, 'dismissed'); + return true; + } + + /** + * Resolve a session's pending item, if any (stop hook, exit, ...). `kinds` + * 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. + */ + resolveForSession(sessionId: string, resolution: ApprovalResolution, kinds?: ApprovalKind[]): void { + const item = this.items.get(sessionId); + if (!item) return; + if (kinds && !kinds.includes(item.kind)) return; + this.remove(item, resolution); + } + + /** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */ + stop(): void { + this.stopped = true; + for (const timer of this.recaptureTimers.values()) clearTimeout(timer); + this.recaptureTimers.clear(); + this.items.clear(); + this.captures.clear(); + } + + private applyCapture(item: ApprovalItem, capture?: () => string | null): void { + if (!capture) return; + let raw: string | null = null; + try { + raw = capture(); + } catch { + // Capture is best-effort; the card still renders from hook fields. + } + const context = normalizeCapturedFrame(raw); + if (!context) return; + item.context = context; + // Idle prompts are not dialogs; never offer digit answers for them. + if (item.kind !== 'idle') item.options = parseDialogOptions(context); + } + + private remove(item: ApprovalItem, resolution: ApprovalResolution): void { + this.items.delete(item.sessionId); + this.captures.delete(item.sessionId); + const timer = this.recaptureTimers.get(item.id); + if (timer) { + clearTimeout(timer); + this.recaptureTimers.delete(item.id); + } + if (!this.stopped) { + this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution }); + } + } + + private isExpired(item: ApprovalItem): boolean { + return Date.now() - item.createdAt > ITEM_TTL_MS; + } +} + +function sessionIdOf(itemId: string): string { + return itemId.slice(0, itemId.lastIndexOf(':')); +} + +/** Process-wide singleton, mirroring `sessionWaits`. */ +export const approvalInbox = new ApprovalInbox(); diff --git a/src/web/public/app.js b/src/web/public/app.js index d3d6d34f..f4f95e23 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -237,10 +237,17 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'], [SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'], [SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'], + [SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'], + [SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'], [SSE_EVENTS.HOOK_STOP, '_onHookStop'], [SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'], [SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'], + // Approvals Inbox (handlers in approvals-ui.js) + [SSE_EVENTS.APPROVAL_PENDING, '_onApprovalPending'], + [SSE_EVENTS.APPROVAL_UPDATED, '_onApprovalUpdated'], + [SSE_EVENTS.APPROVAL_RESOLVED, '_onApprovalResolved'], + // Subagents (Claude Code background agents) [SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'], [SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'], @@ -630,6 +637,9 @@ class CodemanApp { // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) this.pendingHooks = new Map(); + // Approvals Inbox: Map (methods in approvals-ui.js) + this.approvals = new Map(); + // WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE) this._ws = null; // WebSocket instance for active session this._wsSessionId = null; // Session ID the WS is connected to @@ -3030,6 +3040,8 @@ class CodemanApp { this._predictiveEcho?.clearPredictions(); // Clear pending hooks this.pendingHooks.clear(); + // Clear approvals (re-seeded from GET /api/approvals right after init) + this.approvals?.clear(); // Clear parent name cache (prevents stale session name entries accumulating) if (this._parentNameCache) this._parentNameCache.clear(); // Clear subagent activity/results maps (prevents leaks if data.subagents is missing) @@ -3170,6 +3182,10 @@ class CodemanApp { this.updateCost(); this.renderSessionTabs(); + // Approvals Inbox: re-seed pending prompts from the server so alerts + // survive reloads and SSE reconnects (methods in approvals-ui.js). + this.seedApprovals?.(); + // Start/stop system stats polling based on session count if (this.sessions.size > 0) { this.startSystemStatsPolling(); diff --git a/src/web/public/approvals-ui.js b/src/web/public/approvals-ui.js new file mode 100644 index 00000000..066ad17e --- /dev/null +++ b/src/web/public/approvals-ui.js @@ -0,0 +1,242 @@ +/** + * @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human. + * + * 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. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @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 + */ + +/** Map an approval kind to the pendingHooks entry that drives tab alerts. */ +function approvalKindToHook(kind) { + return kind === 'permission' ? 'permission_prompt' : kind === 'question' ? 'elicitation_dialog' : 'idle_prompt'; +} + +Object.assign(CodemanApp.prototype, { + /** Synced setting, default OFF, opt-in via App Settings → Panels. */ + approvalsInboxEnabled() { + 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 + * survive a reload (pre-inbox they lived only in SSE-transient memory). + */ + async seedApprovals() { + if (!this.approvals) this.approvals = new Map(); + this.approvals.clear(); + if (this.approvalsInboxEnabled()) { + const data = await this._apiJson('/api/approvals'); + for (const item of (data && data.approvals) || []) { + this.approvals.set(item.id, item); + // Re-arm the tab alert state machine (idempotent set-add). + this.setPendingHook(item.sessionId, approvalKindToHook(item.kind)); + } + } + this.renderApprovals(); + }, + + // ─── SSE handlers ──────────────────────────────────────────── + + _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. + for (const [id, existing] of this.approvals) { + if (existing.sessionId === item.sessionId) this.approvals.delete(id); + } + this.approvals.set(item.id, item); + this.renderApprovals(); + }, + + _onApprovalUpdated(item) { + if (!item || !item.id || !this.approvals?.has(item.id)) return; + this.approvals.set(item.id, item); + this.renderApprovals(); + }, + + _onApprovalResolved(info) { + if (!info || !info.id || !this.approvals) return; + if (this.approvals.delete(info.id)) { + // Clear the matching tab alert: the inbox resolves on more signals than + // the hook handlers do (superseded, expired, answered from another + // device), and clearPendingHooks is a no-op when nothing is set. + this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind)); + this.renderApprovals(); + } + }, + + // ─── Actions ───────────────────────────────────────────────── + + async answerApproval(id, action, option) { + const body = option !== undefined ? { action, option } : { action }; + const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, { + method: 'POST', + body, + }); + if (data) { + 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, the prompt may already be resolved', 'warning'); + this.seedApprovals(); + } + }, + + /** Idle prompts: send the typed line from the card's input as a prompt. */ + async answerApprovalIdleText(id) { + const input = document.getElementById(`approvalText-${id}`); + const text = input ? input.value.trim() : ''; + if (!text) return; + const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, { + method: 'POST', + body: { action: 'text', text }, + }); + if (data) this.showToast('Prompt sent', 'success'); + else { + this.showToast('Could not send, the session may be busy', 'warning'); + this.seedApprovals(); + } + }, + + async dismissApproval(id) { + await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/dismiss`, { method: 'POST', body: {} }); + // The SSE resolved event also lands; delete now for instant feedback. + if (this.approvals?.delete(id)) this.renderApprovals(); + }, + + openApprovalSession(id) { + const item = this.approvals?.get(id); + if (!item) return; + this.closeApprovalsInbox(); + if (this.sessions.has(item.sessionId)) this.selectSession(item.sessionId); + }, + + /** + * Push-notification action relay (sw.js → settings-ui notification-click → + * here). Falls back to opening the session when the item is unknown, or + * when the inbox is disabled (a stale notification from before the toggle + * flipped can still carry an action). + */ + handleNotificationAction(action, approvalId, sessionId) { + if ((action === 'approve' || action === 'deny') && approvalId && this.approvalsInboxEnabled()) { + this.answerApproval(approvalId, action); + return; + } + if (sessionId && this.sessions.has(sessionId)) this.selectSession(sessionId); + }, + + // ─── Rendering ─────────────────────────────────────────────── + + toggleApprovalsInbox() { + const drawer = document.getElementById('approvalsDrawer'); + if (!drawer) return; + if (drawer.classList.contains('open')) this.closeApprovalsInbox(); + else { + drawer.classList.add('open'); + document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'true'); + this.renderApprovals(); + } + }, + + closeApprovalsInbox() { + document.getElementById('approvalsDrawer')?.classList.remove('open'); + document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false'); + }, + + renderApprovals() { + const count = this.approvals ? this.approvals.size : 0; + const btn = document.querySelector('.btn-approvals'); + if (btn) { + // Marker-class visibility (base header rules are display !important): + // the bell exists only while something is pending, so the header stays + // untouched for everyone else. + btn.classList.toggle('btn-approvals--hidden', count === 0 || !this.approvalsInboxEnabled()); + const badge = document.getElementById('approvalsBadge'); + if (badge) badge.textContent = String(count); + } + this.renderApprovalsDrawer(); + // Phone overview NEEDS YOU rows re-render on the tab-render tail; nudge it + // so inline approve/deny buttons appear without a state change elsewhere. + this.renderSessionTabs?.(); + }, + + renderApprovalsDrawer() { + const drawer = document.getElementById('approvalsDrawer'); + if (!drawer || !drawer.classList.contains('open')) return; + const list = drawer.querySelector('.approvals-list'); + if (!list) return; + const items = this.approvals ? [...this.approvals.values()].sort((a, b) => a.createdAt - b.createdAt) : []; + if (items.length === 0) { + list.innerHTML = '
No pending approvals
'; + return; + } + list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join(''); + }, + + _approvalCardHtml(item) { + const id = escapeHtml(item.id); + const kindLabel = item.kind === 'permission' ? 'Permission' : item.kind === 'question' ? 'Question' : 'Idle'; + const summary = item.toolName + ? `${item.toolName}${item.toolSummary ? ': ' + item.toolSummary : ''}` + : item.message || ''; + const age = this._approvalAge(item.createdAt); + let actions = ''; + if (item.kind === 'idle') { + actions = + `
` + + `` + + `` + + `
`; + } else if (item.options && item.options.length) { + actions = item.options + .map( + (o) => + `` + ) + .join(''); + } else { + actions = + `` + + ``; + } + return ( + `
` + + `
` + + `${kindLabel}` + + `${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}` + + `${age}` + + `
` + + (summary ? `
${escapeHtml(summary)}
` : '') + + (item.context ? `
${escapeHtml(item.context)}
` : '') + + `
${actions}
` + + `
` + + `` + + `` + + `
` + + `
` + ); + }, + + _approvalAge(createdAt) { + const s = Math.max(0, Math.floor((Date.now() - createdAt) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + return `${Math.floor(s / 3600)}h`; + }, +}); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1db36064..ffbc08c6 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -408,10 +408,17 @@ const SSE_EVENTS = { HOOK_IDLE_PROMPT: 'hook:idle_prompt', HOOK_PERMISSION_PROMPT: 'hook:permission_prompt', HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog', + HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete', + HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response', HOOK_STOP: 'hook:stop', HOOK_TEAMMATE_IDLE: 'hook:teammate_idle', HOOK_TASK_COMPLETED: 'hook:task_completed', + // Approvals Inbox + APPROVAL_PENDING: 'approval:pending', + APPROVAL_UPDATED: 'approval:updated', + APPROVAL_RESOLVED: 'approval:resolved', + // Subagents (Claude Code background agents) SUBAGENT_DISCOVERED: 'subagent:discovered', SUBAGENT_UPDATED: 'subagent:updated', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index b94c5806..10a84634 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -235,6 +235,22 @@ Subagents: '子智能体', 'Ultracode Agents': 'Ultracode 智能体', 'Ultracode Floating Windows': 'Ultracode 浮动窗口', + 'Approvals Inbox': '审批收件箱', + Approvals: '审批', + 'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示', + 'No pending approvals': '没有待处理的审批', + 'Approvals waiting on you': '等待您审批的请求', + 'Open approvals inbox': '打开审批收件箱', + 'Close approvals inbox': '关闭审批收件箱', + Approve: '批准', + 'Deny (Esc)': '拒绝 (Esc)', + Deny: '拒绝', + 'Open session': '打开会话', + Dismiss: '忽略', + Send: '发送', + Permission: '权限', + Question: '问题', + Idle: '空闲', 'Subagent Options': '子智能体选项', 'Enable Tracking': '启用跟踪', 'Active Tab Only': '仅活动标签页', diff --git a/src/web/public/index.html b/src/web/public/index.html index adebd440..bba6dbd6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -131,6 +131,10 @@ + + +
+ + @@ -2697,6 +2720,7 @@ + diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 79a2c0c3..7c514f5b 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -639,9 +639,58 @@ Object.assign(CodemanApp.prototype, { chevron.textContent = '›'; item.appendChild(chevron); + // Approvals Inbox: a pending dialog for this session gets an answer strip + // BELOW the row (the row itself is a