mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: Approvals Inbox, one cross-session queue for prompts waiting on a human
Permission dialogs, AskUserQuestion questions and idle prompts from every session now land in a server-side inbox (web/approval-inbox.ts, one item per session, claude-mode only) and are answerable in place: a header bell + drawer on desktop, inline answer strips on the phone overview's NEEDS YOU rows, and working push Approve/Deny buttons (previously dead ends, now answered straight from sw.js with no tab open). Pending alerts survive reloads because the frontend seeds from GET /api/approvals on init. Answering sends the digit / Esc / prompt text through the existing tmux input path; option digits are accepted only when they match options parsed from the captured pane frame, and the answer path re-captures the pane first so a dialog that already left the screen refuses with 409 instead of typing into the composer. New elicitation_complete / elicitation_response hook matchers resolve question items the moment they are answered in the terminal; refreshStaleCodemanHooks heals existing cases. Verified end-to-end against a live claude session: a real AskUserQuestion dialog parsed into 5 option buttons and was answered from the drawer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| **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 |
|
| **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` | |
|
| **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 |
|
| **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`.
|
★ = 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`.
|
||||||
@@ -204,7 +204,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)
|
**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 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`.
|
||||||
|
|
||||||
**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/`.
|
**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/`.
|
||||||
|
|
||||||
@@ -240,7 +242,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
### Frontend
|
### 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 `<html>`. 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`.
|
**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 `<html>`. 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`.
|
||||||
|
|
||||||
@@ -294,11 +296,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### SSE Event Registry
|
### 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
|
### 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<T>` 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`).
|
**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<T>` 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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Authentication
|
||||||
|
|
||||||
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
||||||
|
|||||||
@@ -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.
|
||||||
|
- `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.
|
||||||
|
- 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 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).
|
||||||
|
|
||||||
|
## 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).
|
||||||
+21
-3
@@ -15,9 +15,10 @@
|
|||||||
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
||||||
*
|
*
|
||||||
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
* 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
|
* `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained
|
||||||
* background Bash rewake)
|
* background Bash rewake)
|
||||||
*
|
*
|
||||||
@@ -332,6 +333,16 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
|||||||
matcher: 'elicitation_dialog',
|
matcher: 'elicitation_dialog',
|
||||||
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }],
|
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: [
|
Stop: [
|
||||||
{
|
{
|
||||||
@@ -662,7 +673,14 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
|
|||||||
// on a self-signed HTTPS install.
|
// on a self-signed HTTPS install.
|
||||||
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
|
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
|
||||||
const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER);
|
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 generated = generateHooksConfig();
|
||||||
const merged = {
|
const merged = {
|
||||||
...existing,
|
...existing,
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export type HookEventType =
|
|||||||
| 'idle_prompt'
|
| 'idle_prompt'
|
||||||
| 'permission_prompt'
|
| 'permission_prompt'
|
||||||
| 'elicitation_dialog'
|
| 'elicitation_dialog'
|
||||||
|
| 'elicitation_complete'
|
||||||
|
| 'elicitation_response'
|
||||||
| 'stop'
|
| 'stop'
|
||||||
| 'teammate_idle'
|
| 'teammate_idle'
|
||||||
| 'task_completed';
|
| 'task_completed';
|
||||||
|
|||||||
@@ -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[<row>;<col>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<string, ApprovalItem>();
|
||||||
|
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
||||||
|
private captures = new Map<string, () => 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) — 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();
|
||||||
@@ -237,10 +237,17 @@ const _SSE_HANDLER_MAP = [
|
|||||||
[SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'],
|
[SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'],
|
||||||
[SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'],
|
[SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'],
|
||||||
[SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'],
|
[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_STOP, '_onHookStop'],
|
||||||
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
|
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
|
||||||
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
|
[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)
|
// Subagents (Claude Code background agents)
|
||||||
[SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'],
|
[SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'],
|
||||||
[SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'],
|
[SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'],
|
||||||
@@ -630,6 +637,9 @@ class CodemanApp {
|
|||||||
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
|
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
|
||||||
this.pendingHooks = new Map();
|
this.pendingHooks = new Map();
|
||||||
|
|
||||||
|
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
|
||||||
|
this.approvals = new Map();
|
||||||
|
|
||||||
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
|
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
|
||||||
this._ws = null; // WebSocket instance for active session
|
this._ws = null; // WebSocket instance for active session
|
||||||
this._wsSessionId = null; // Session ID the WS is connected to
|
this._wsSessionId = null; // Session ID the WS is connected to
|
||||||
@@ -3030,6 +3040,8 @@ class CodemanApp {
|
|||||||
this._predictiveEcho?.clearPredictions();
|
this._predictiveEcho?.clearPredictions();
|
||||||
// Clear pending hooks
|
// Clear pending hooks
|
||||||
this.pendingHooks.clear();
|
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)
|
// Clear parent name cache (prevents stale session name entries accumulating)
|
||||||
if (this._parentNameCache) this._parentNameCache.clear();
|
if (this._parentNameCache) this._parentNameCache.clear();
|
||||||
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
|
||||||
@@ -3170,6 +3182,10 @@ class CodemanApp {
|
|||||||
this.updateCost();
|
this.updateCost();
|
||||||
this.renderSessionTabs();
|
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
|
// Start/stop system stats polling based on session count
|
||||||
if (this.sessions.size > 0) {
|
if (this.sessions.size > 0) {
|
||||||
this.startSystemStatsPolling();
|
this.startSystemStatsPolling();
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* @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
|
||||||
|
* 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 ON (only an explicit false disables). */
|
||||||
|
approvalsInboxEnabled() {
|
||||||
|
return this.loadAppSettingsFromStorage().approvalsInboxEnabled !== false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 — 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 — 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.
|
||||||
|
*/
|
||||||
|
handleNotificationAction(action, approvalId, sessionId) {
|
||||||
|
if ((action === 'approve' || action === 'deny') && approvalId) {
|
||||||
|
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 = '<div class="approvals-empty">No pending approvals</div>';
|
||||||
|
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 =
|
||||||
|
`<div class="approval-text-row">` +
|
||||||
|
`<input type="text" id="approvalText-${id}" class="approval-text-input" placeholder="Send a prompt…" data-i18n-skip ` +
|
||||||
|
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
|
||||||
|
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
|
||||||
|
`</div>`;
|
||||||
|
} else if (item.options && item.options.length) {
|
||||||
|
actions = item.options
|
||||||
|
.map(
|
||||||
|
(o) =>
|
||||||
|
`<button class="approval-btn ${o.n === 1 ? 'approval-btn-primary' : ''}" data-i18n-skip ` +
|
||||||
|
`title="${escapeHtml(o.label)}" onclick="app.answerApproval('${id}','option',${o.n})">` +
|
||||||
|
`${o.n}. ${escapeHtml(o.label.length > 42 ? o.label.slice(0, 42) + '…' : o.label)}</button>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
} else {
|
||||||
|
actions =
|
||||||
|
`<button class="approval-btn approval-btn-primary" onclick="app.answerApproval('${id}','approve')">Approve</button>` +
|
||||||
|
`<button class="approval-btn approval-btn-danger" onclick="app.answerApproval('${id}','deny')">Deny (Esc)</button>`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
`<div class="approval-card approval-kind-${item.kind}" data-approval-id="${id}">` +
|
||||||
|
`<div class="approval-card-head">` +
|
||||||
|
`<span class="approval-kind-badge">${kindLabel}</span>` +
|
||||||
|
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
|
||||||
|
`<span class="approval-age" data-i18n-skip>${age}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
|
||||||
|
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
|
||||||
|
`<div class="approval-actions">${actions}</div>` +
|
||||||
|
`<div class="approval-meta-actions">` +
|
||||||
|
`<button class="approval-link" onclick="app.openApprovalSession('${id}')">Open session</button>` +
|
||||||
|
`<button class="approval-link" onclick="app.dismissApproval('${id}')">Dismiss</button>` +
|
||||||
|
`</div>` +
|
||||||
|
`</div>`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
_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`;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -408,10 +408,17 @@ const SSE_EVENTS = {
|
|||||||
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
|
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
|
||||||
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
|
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
|
||||||
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
|
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
|
||||||
|
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
|
||||||
|
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
|
||||||
HOOK_STOP: 'hook:stop',
|
HOOK_STOP: 'hook:stop',
|
||||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
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)
|
// Subagents (Claude Code background agents)
|
||||||
SUBAGENT_DISCOVERED: 'subagent:discovered',
|
SUBAGENT_DISCOVERED: 'subagent:discovered',
|
||||||
SUBAGENT_UPDATED: 'subagent:updated',
|
SUBAGENT_UPDATED: 'subagent:updated',
|
||||||
|
|||||||
@@ -235,6 +235,22 @@
|
|||||||
Subagents: '子智能体',
|
Subagents: '子智能体',
|
||||||
'Ultracode Agents': 'Ultracode 智能体',
|
'Ultracode Agents': 'Ultracode 智能体',
|
||||||
'Ultracode Floating Windows': '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': '子智能体选项',
|
'Subagent Options': '子智能体选项',
|
||||||
'Enable Tracking': '启用跟踪',
|
'Enable Tracking': '启用跟踪',
|
||||||
'Active Tab Only': '仅活动标签页',
|
'Active Tab Only': '仅活动标签页',
|
||||||
|
|||||||
@@ -131,6 +131,10 @@
|
|||||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
|
||||||
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
|
||||||
|
<button class="btn-icon-header btn-approvals btn-approvals--hidden" id="approvalsBtn" onclick="app.toggleApprovalsInbox()" title="Approvals waiting on you" aria-label="Open approvals inbox" aria-expanded="false">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
<span class="approvals-badge" id="approvalsBadge">0</span>
|
||||||
|
</button>
|
||||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||||
@@ -1522,6 +1526,13 @@
|
|||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-item" title="Cross-session inbox of prompts waiting on you (permission dialogs, questions, idle prompts) with answer-in-place buttons; the header bell appears only while something is pending">
|
||||||
|
<span class="settings-item-label">Approvals Inbox</span>
|
||||||
|
<label class="switch switch-sm">
|
||||||
|
<input type="checkbox" id="appSettingsApprovalsInbox">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
|
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
|
||||||
<span class="settings-item-label">Ultracode Agents</span>
|
<span class="settings-item-label">Ultracode Agents</span>
|
||||||
<label class="switch switch-sm">
|
<label class="switch switch-sm">
|
||||||
@@ -2679,6 +2690,18 @@
|
|||||||
<!-- Lines drawn dynamically -->
|
<!-- Lines drawn dynamically -->
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<!-- Approvals Inbox drawer (populated by approvals-ui.js; opened from the header bell) -->
|
||||||
|
<div class="approvals-drawer" id="approvalsDrawer" role="complementary" aria-label="Approvals inbox">
|
||||||
|
<div class="approvals-header">
|
||||||
|
<div>
|
||||||
|
<div class="approvals-title">Approvals</div>
|
||||||
|
<div class="approvals-subtitle">Prompts waiting on you, across all sessions</div>
|
||||||
|
</div>
|
||||||
|
<button class="approvals-close" onclick="app.closeApprovalsInbox()" title="Close" aria-label="Close approvals inbox">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="approvals-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script defer src="constants.js"></script>
|
<script defer src="constants.js"></script>
|
||||||
<script defer src="i18n.js"></script>
|
<script defer src="i18n.js"></script>
|
||||||
<script defer src="mobile-handlers.js"></script>
|
<script defer src="mobile-handlers.js"></script>
|
||||||
@@ -2697,6 +2720,7 @@
|
|||||||
<script defer src="settings-ui.js"></script>
|
<script defer src="settings-ui.js"></script>
|
||||||
<script defer src="panels-ui.js"></script>
|
<script defer src="panels-ui.js"></script>
|
||||||
<script defer src="ultracode-panel.js"></script>
|
<script defer src="ultracode-panel.js"></script>
|
||||||
|
<script defer src="approvals-ui.js"></script>
|
||||||
<script defer src="admin-ui.js"></script>
|
<script defer src="admin-ui.js"></script>
|
||||||
<script defer src="session-ui.js"></script>
|
<script defer src="session-ui.js"></script>
|
||||||
<script defer src="webview-tabs.js"></script>
|
<script defer src="webview-tabs.js"></script>
|
||||||
|
|||||||
@@ -639,9 +639,58 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
chevron.textContent = '›';
|
chevron.textContent = '›';
|
||||||
item.appendChild(chevron);
|
item.appendChild(chevron);
|
||||||
|
|
||||||
|
// Approvals Inbox: a pending dialog for this session gets an answer strip
|
||||||
|
// BELOW the row (the row itself is a <button>, so actions cannot nest
|
||||||
|
// inside it). Tapping the row still opens the session, unchanged.
|
||||||
|
const approval = this._pendingApprovalForSession(row.id);
|
||||||
|
if (approval) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'mobile-overview-row-wrap';
|
||||||
|
wrap.appendChild(item);
|
||||||
|
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The session's pending approval, when the strip should render (dialogs only). */
|
||||||
|
_pendingApprovalForSession(sessionId) {
|
||||||
|
if (!this.approvals || !this.approvalsInboxEnabled || !this.approvalsInboxEnabled()) return null;
|
||||||
|
for (const item of this.approvals.values()) {
|
||||||
|
if (item.sessionId === sessionId && item.kind !== 'idle') return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
|
||||||
|
_buildMobileOverviewApprovalStrip(approval) {
|
||||||
|
const strip = document.createElement('div');
|
||||||
|
strip.className = 'mobile-overview-approval-strip';
|
||||||
|
strip.setAttribute('data-i18n-skip', '');
|
||||||
|
const addBtn = (label, cls, onTap) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'mobile-overview-approval-btn' + (cls ? ' ' + cls : '');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
onTap();
|
||||||
|
});
|
||||||
|
strip.appendChild(btn);
|
||||||
|
};
|
||||||
|
if (approval.options && approval.options.length) {
|
||||||
|
for (const o of approval.options) {
|
||||||
|
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
|
||||||
|
addBtn(`${o.n}. ${label}`, o.n === 1 ? 'primary' : '', () => this.answerApproval(approval.id, 'option', o.n));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addBtn('Approve', 'primary', () => this.answerApproval(approval.id, 'approve'));
|
||||||
|
addBtn('Deny', 'danger', () => this.answerApproval(approval.id, 'deny'));
|
||||||
|
}
|
||||||
|
return strip;
|
||||||
|
},
|
||||||
|
|
||||||
/** A past conversation. Tapping it resumes, which creates a fresh session. */
|
/** A past conversation. Tapping it resumes, which creates a fresh session. */
|
||||||
_buildMobileOverviewPastRow(row) {
|
_buildMobileOverviewPastRow(row) {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
|
|||||||
@@ -479,7 +479,11 @@ html.mobile-init .file-browser-panel {
|
|||||||
.btn-icon-header.btn-lifecycle-log,
|
.btn-icon-header.btn-lifecycle-log,
|
||||||
.btn-icon-header.btn-away-digest,
|
.btn-icon-header.btn-away-digest,
|
||||||
.btn-icon-header.btn-session-manager,
|
.btn-icon-header.btn-session-manager,
|
||||||
.btn-icon-header.btn-file-viewer {
|
.btn-icon-header.btn-file-viewer,
|
||||||
|
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
|
||||||
|
(inline approve/deny in mobile-overview.js); the bell would only crowd the
|
||||||
|
header it was designed to stay out of. */
|
||||||
|
.btn-icon-header.btn-approvals {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2503,6 +2507,41 @@ html.mobile-init .file-browser-panel {
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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. */
|
||||||
|
.mobile-overview-row-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.mobile-overview-approval-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.2rem 0.1rem;
|
||||||
|
}
|
||||||
|
.mobile-overview-approval-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mobile-overview-approval-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.mobile-overview-approval-btn.danger {
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
/* Attention states mirror the session tabs exactly: red blink when the agent
|
/* Attention states mirror the session tabs exactly: red blink when the agent
|
||||||
asked something (permission / question), yellow blink when it is waiting for
|
asked something (permission / question), yellow blink when it is waiting for
|
||||||
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
|
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
|
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_onHookElicitationComplete(data) {
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onHookElicitationResponse(data) {
|
||||||
|
this._onHookElicitationComplete(data);
|
||||||
|
},
|
||||||
|
|
||||||
_onHookStop(data) {
|
_onHookStop(data) {
|
||||||
// Clear all pending hooks when Claude finishes responding
|
// Clear all pending hooks when Claude finishes responding
|
||||||
if (data.sessionId) {
|
if (data.sessionId) {
|
||||||
@@ -158,8 +170,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Listen for messages from service worker (notification clicks)
|
// Listen for messages from service worker (notification clicks)
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'notification-click') {
|
if (event.data?.type === 'notification-click') {
|
||||||
const { sessionId } = event.data;
|
const { sessionId, action, approvalId } = event.data;
|
||||||
if (sessionId && this.sessions.has(sessionId)) {
|
if (action) {
|
||||||
|
// 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)) {
|
||||||
this.selectSession(sessionId);
|
this.selectSession(sessionId);
|
||||||
}
|
}
|
||||||
window.focus();
|
window.focus();
|
||||||
@@ -326,6 +342,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||||
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? 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;
|
||||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||||
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
|
||||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||||
@@ -1525,6 +1543,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||||
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
|
||||||
|
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
|
||||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||||
@@ -1690,6 +1709,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.applyTabWrapSettings();
|
this.applyTabWrapSettings();
|
||||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||||
this.applyMonitorVisibility();
|
this.applyMonitorVisibility();
|
||||||
|
this.renderApprovals?.(); // Approvals Inbox toggle (hide/show bell + drawer)
|
||||||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||||||
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
|
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
|
||||||
|
|
||||||
|
|||||||
@@ -10643,6 +10643,222 @@ kbd {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "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 {
|
||||||
|
display: inline-flex !important;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.btn-approvals.btn-approvals--hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approvals-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 1px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
background: var(--error, #e5484d);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approvals Inbox drawer — same shell as the attachment history drawer. */
|
||||||
|
.approvals-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--header-height);
|
||||||
|
right: 0;
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
height: calc(100vh - var(--header-height) - var(--toolbar-height));
|
||||||
|
height: calc(100dvh - var(--header-height) - var(--toolbar-height));
|
||||||
|
background: var(--floating-bg);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.36);
|
||||||
|
}
|
||||||
|
.approvals-drawer.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.approvals-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.approvals-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.approvals-subtitle {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.approvals-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.approvals-close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.approvals-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.approvals-empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-secondary, rgba(255, 255, 255, 0.02));
|
||||||
|
}
|
||||||
|
.approval-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.approval-kind-badge {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.approval-kind-question .approval-kind-badge {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
.approval-kind-idle .approval-kind-badge {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
.approval-session {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.approval-age {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.approval-summary {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.approval-context {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.approval-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.approval-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary, rgba(255, 255, 255, 0.05));
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.approval-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.approval-btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.approval-btn-danger {
|
||||||
|
border-color: var(--error, #e5484d);
|
||||||
|
color: var(--error, #e5484d);
|
||||||
|
}
|
||||||
|
.approval-text-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.approval-text-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg, rgba(0, 0, 0, 0.3));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
.approval-meta-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.approval-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.approval-link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
|
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
|
||||||
default. Same pattern as the response viewer: a base inline-flex !important so
|
default. Same pattern as the response viewer: a base inline-flex !important so
|
||||||
an inline style can't override it, and a more-specific marker rule to hide. */
|
an inline style can't override it, and a more-specific marker rule to hide. */
|
||||||
|
|||||||
+44
-20
@@ -111,14 +111,14 @@ self.addEventListener('push', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
|
const { title, hostTitle, body, tag, sessionId, approvalId, urgency, actions } = payload;
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
body: body || '',
|
body: body || '',
|
||||||
tag: tag || 'codeman-default',
|
tag: tag || 'codeman-default',
|
||||||
icon: '/icon-192.png',
|
icon: '/icon-192.png',
|
||||||
badge: '/icon-192.png',
|
badge: '/icon-192.png',
|
||||||
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||||
renotify: true,
|
renotify: true,
|
||||||
requireInteraction: urgency === 'critical',
|
requireInteraction: urgency === 'critical',
|
||||||
};
|
};
|
||||||
@@ -142,24 +142,48 @@ self.addEventListener('push', (event) => {
|
|||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
const { sessionId, url } = event.notification.data || {};
|
const { sessionId, approvalId, url } = event.notification.data || {};
|
||||||
const targetUrl = url || '/';
|
const targetUrl = url || '/';
|
||||||
|
const action = event.action || null;
|
||||||
|
|
||||||
event.waitUntil(
|
// Approve/Deny action buttons answer the Approvals Inbox item directly from
|
||||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
// the worker, so they work with NO Codeman tab open (lock-screen approvals).
|
||||||
// Try to find an existing Codeman tab
|
// Same-origin POST with cookie credentials; the CSRF Origin check passes
|
||||||
for (const client of clients) {
|
// because a service worker fetch carries the worker's own (same) origin.
|
||||||
if (client.url.includes(self.location.origin)) {
|
if ((action === 'approve' || action === 'deny') && approvalId) {
|
||||||
client.postMessage({
|
event.waitUntil(
|
||||||
type: 'notification-click',
|
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
|
||||||
sessionId,
|
method: 'POST',
|
||||||
action: event.action || null,
|
credentials: 'include',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
return client.focus();
|
body: JSON.stringify({ action }),
|
||||||
}
|
}).then((res) => {
|
||||||
}
|
if (res && res.ok) return undefined;
|
||||||
// No existing tab -- open a new one
|
// 401/404/409: let the human see the state — fall back to a tab.
|
||||||
return self.clients.openWindow(targetUrl);
|
return openOrFocus(sessionId, action, approvalId, targetUrl);
|
||||||
})
|
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(openOrFocus(sessionId, action, approvalId, targetUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openOrFocus(sessionId, action, approvalId, targetUrl) {
|
||||||
|
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||||
|
// Try to find an existing Codeman tab
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.url.includes(self.location.origin)) {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'notification-click',
|
||||||
|
sessionId,
|
||||||
|
approvalId,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No existing tab -- open a new one
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
|
|||||||
'permission_mode',
|
'permission_mode',
|
||||||
'stop_hook_active',
|
'stop_hook_active',
|
||||||
'transcript_path',
|
'transcript_path',
|
||||||
|
'message',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of allowedKeys) {
|
for (const key of allowedKeys) {
|
||||||
@@ -343,6 +344,15 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification hooks carry the human-readable prompt text in `message`
|
||||||
|
// ("Claude needs your permission to use Bash"). Bound it like the
|
||||||
|
// tool_input summaries; the frontend and the Approvals Inbox both read it.
|
||||||
|
if (typeof safeFields.message === 'string') {
|
||||||
|
safeFields.message = safeFields.message.slice(0, 500);
|
||||||
|
} else if ('message' in safeFields) {
|
||||||
|
delete safeFields.message;
|
||||||
|
}
|
||||||
|
|
||||||
// For tool_input, extract only summary fields (not full file content)
|
// For tool_input, extract only summary fields (not full file content)
|
||||||
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
|
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
|
||||||
const input = safeFields.tool_input as Record<string, unknown>;
|
const input = safeFields.tool_input as Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Approvals Inbox routes.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* corresponding keystrokes to the session (digit / Esc / idle-prompt text)
|
||||||
|
* - `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
|
||||||
|
* double-tap (or the service worker retrying a push action) cannot
|
||||||
|
* double-send; a failed write restores the item.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||||
|
import { ApprovalAnswerSchema } from '../schemas.js';
|
||||||
|
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
|
||||||
|
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
|
||||||
|
import { hooksAvailableForMode } from '../session-wait-registry.js';
|
||||||
|
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
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function keystrokesFor(
|
||||||
|
item: ApprovalItem,
|
||||||
|
answer: { action: 'approve' | 'deny' | 'option' | 'text'; option?: number; text?: string }
|
||||||
|
): { keys: string } | { error: string } {
|
||||||
|
switch (answer.action) {
|
||||||
|
case 'approve':
|
||||||
|
if (item.kind === 'idle') return { error: 'Idle prompts take a text answer, not approve/deny' };
|
||||||
|
return { keys: '1' };
|
||||||
|
case 'deny':
|
||||||
|
if (item.kind === 'idle') return { error: 'Idle prompts take a text answer, not approve/deny' };
|
||||||
|
return { keys: '\x1b' };
|
||||||
|
case 'option': {
|
||||||
|
if (item.kind === 'idle') return { error: 'Idle prompts take a text answer, not an option digit' };
|
||||||
|
if (answer.option === undefined) return { error: 'action "option" requires the option field' };
|
||||||
|
if (!item.options?.some((o) => o.n === answer.option)) {
|
||||||
|
return { error: `Option ${answer.option} is not among the parsed dialog options` };
|
||||||
|
}
|
||||||
|
return { keys: String(answer.option) };
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
if (item.kind !== 'idle') return { error: 'Text answers are only valid for idle prompts' };
|
||||||
|
const text = (answer.text ?? '').replace(/[\r\n]+/g, ' ').trim();
|
||||||
|
if (!text) return { error: 'action "text" requires non-empty text' };
|
||||||
|
return { keys: `${text}\r` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||||
|
// List pending approvals. Items whose session is gone resolve lazily; items
|
||||||
|
// whose session the caller cannot access are filtered (never 403-leaked),
|
||||||
|
// matching the session-list scoping policy.
|
||||||
|
app.get('/api/approvals', async (req) => {
|
||||||
|
const user = getAuthUser(req);
|
||||||
|
const approvals = approvalInbox.listPending().filter((item) => {
|
||||||
|
const session = ctx.sessions.get(item.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
approvalInbox.resolveForSession(item.sessionId, 'session_ended');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canAccessOwned(user, session.owner);
|
||||||
|
});
|
||||||
|
return { success: true, data: { approvals } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/api/approvals/:id/answer', async (req) => {
|
||||||
|
const answer = parseBody(ApprovalAnswerSchema, req.body);
|
||||||
|
const item = approvalInbox.getById(req.params.id);
|
||||||
|
if (!item) {
|
||||||
|
// 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
|
||||||
|
// no-existence-leak rule as every other session route.
|
||||||
|
const session = findSessionOrFail(ctx, item.sessionId, req);
|
||||||
|
if (!hooksAvailableForMode(session.mode)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-capture the pane before aiming keystrokes at it: if the dialog was
|
||||||
|
// answered in the terminal moments ago, the digit would land in whatever
|
||||||
|
// now has focus. Conclusive only for items whose frame parsed options.
|
||||||
|
if (!approvalInbox.verifyStillAnswerable(item.id)) {
|
||||||
|
return createErrorResponse(ApiErrorCode.CONFLICT, 'The dialog is no longer on screen');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = keystrokesFor(item, answer);
|
||||||
|
if ('error' in resolved) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = approvalInbox.take(item.id);
|
||||||
|
if (!taken) {
|
||||||
|
return createErrorResponse(ApiErrorCode.CONFLICT, 'Approval was resolved by another actor');
|
||||||
|
}
|
||||||
|
const written = await session.writeViaMux(resolved.keys);
|
||||||
|
if (!written) {
|
||||||
|
approvalInbox.restore(taken);
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not accepting input');
|
||||||
|
}
|
||||||
|
return { success: true, data: { id: item.id, sessionId: item.sessionId, action: answer.action } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/api/approvals/:id/dismiss', async (req) => {
|
||||||
|
const item = approvalInbox.getById(req.params.id);
|
||||||
|
if (!item) {
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Approval not found or no longer pending');
|
||||||
|
}
|
||||||
|
findSessionOrFail(ctx, item.sessionId, req);
|
||||||
|
approvalInbox.dismiss(item.id);
|
||||||
|
return { success: true, data: { id: item.id } };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
* @fileoverview Hook event route.
|
* @fileoverview Hook event route.
|
||||||
* Receives Claude Code hook events and broadcasts to SSE clients.
|
* Receives Claude Code hook events and broadcasts to SSE clients.
|
||||||
* This endpoint bypasses auth (Claude Code hooks curl from localhost).
|
* This endpoint bypasses auth (Claude Code hooks curl from localhost).
|
||||||
|
* Prompt events (permission_prompt / elicitation_dialog / idle_prompt) also
|
||||||
|
* open Approvals Inbox items; stop and the elicitation-closed events clear
|
||||||
|
* them (see web/approval-inbox.ts and docs/approvals-inbox-plan.md).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
@@ -11,8 +14,19 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
|
|||||||
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
|
||||||
import { getDataDir } from '../../config/instance.js';
|
import { getDataDir } from '../../config/instance.js';
|
||||||
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
|
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
|
||||||
|
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
|
||||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||||
|
|
||||||
|
/** Hook events that open an Approvals Inbox item. */
|
||||||
|
const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
|
||||||
|
permission_prompt: 'permission',
|
||||||
|
elicitation_dialog: 'question',
|
||||||
|
idle_prompt: 'idle',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hook events that close a session's pending item without an inbox answer. */
|
||||||
|
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']);
|
||||||
|
|
||||||
export function registerHookEventRoutes(
|
export function registerHookEventRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||||
@@ -88,12 +102,60 @@ export function registerHookEventRoutes(
|
|||||||
|
|
||||||
// Sanitize forwarded data: only include known safe fields, limit size
|
// Sanitize forwarded data: only include known safe fields, limit size
|
||||||
const safeData = sanitizeHookData(data);
|
const safeData = sanitizeHookData(data);
|
||||||
ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
|
|
||||||
|
|
||||||
// Send push notifications for hook events
|
|
||||||
const session = ctx.sessions.get(sessionId);
|
const session = ctx.sessions.get(sessionId);
|
||||||
const sessionName = session?.name ?? sessionId.slice(0, 8);
|
const sessionName = session?.name ?? sessionId.slice(0, 8);
|
||||||
ctx.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
|
|
||||||
|
// Approvals Inbox: prompt events open an item, dialog-closed/stop events
|
||||||
|
// clear it. Mode-gated like the wait signals above (hook events carry no
|
||||||
|
// identity beyond the shared per-instance secret, so a prompt claimed for a
|
||||||
|
// session that can never show one must not create an answerable item).
|
||||||
|
let approvalId: string | undefined;
|
||||||
|
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||||
|
if (session && hooksAvailableForMode(session.mode)) {
|
||||||
|
if (approvalKind) {
|
||||||
|
const toolInput =
|
||||||
|
safeData.tool_input && typeof safeData.tool_input === 'object'
|
||||||
|
? (safeData.tool_input as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
const toolSummary = toolInput
|
||||||
|
? [toolInput.command, toolInput.file_path, toolInput.description].find((v) => typeof v === 'string')
|
||||||
|
: undefined;
|
||||||
|
const item = approvalInbox.notePrompt({
|
||||||
|
sessionId,
|
||||||
|
sessionName,
|
||||||
|
kind: approvalKind,
|
||||||
|
toolName: typeof safeData.tool_name === 'string' ? safeData.tool_name : undefined,
|
||||||
|
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
|
||||||
|
message: typeof safeData.message === 'string' ? safeData.message : undefined,
|
||||||
|
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
|
||||||
|
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||||
|
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||||
|
capture: () => {
|
||||||
|
const muxName = session.muxName;
|
||||||
|
const frame = muxName ? (ctx.mux.capturePaneBuffer?.(muxName) ?? null) : null;
|
||||||
|
return frame ?? session.terminalBuffer.slice(-8192) ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
approvalId = item.id;
|
||||||
|
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
|
||||||
|
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.broadcast(`hook:${event}`, {
|
||||||
|
sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
...safeData,
|
||||||
|
...(approvalId && { approvalId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send push notifications for hook events
|
||||||
|
ctx.sendPushNotifications(`hook:${event}`, {
|
||||||
|
sessionId,
|
||||||
|
sessionName,
|
||||||
|
...safeData,
|
||||||
|
...(approvalId && { approvalId }),
|
||||||
|
});
|
||||||
|
|
||||||
// Track in run summary
|
// Track in run summary
|
||||||
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export { registerScheduledRoutes } from './scheduled-routes.js';
|
|||||||
export { registerCronRoutes } from './cron-routes.js';
|
export { registerCronRoutes } from './cron-routes.js';
|
||||||
export { registerSystemRoutes } from './system-routes.js';
|
export { registerSystemRoutes } from './system-routes.js';
|
||||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||||
|
export { registerApprovalRoutes } from './approval-routes.js';
|
||||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||||
export { registerCaseRoutes } from './case-routes.js';
|
export { registerCaseRoutes } from './case-routes.js';
|
||||||
export { registerSessionRoutes } from './session-routes.js';
|
export { registerSessionRoutes } from './session-routes.js';
|
||||||
|
|||||||
+29
-1
@@ -663,11 +663,33 @@ export const QuickStartSchema = z.object({
|
|||||||
* Receives Claude Code hook events.
|
* Receives Claude Code hook events.
|
||||||
*/
|
*/
|
||||||
export const HookEventSchema = z.object({
|
export const HookEventSchema = z.object({
|
||||||
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
|
event: z.enum([
|
||||||
|
'permission_prompt',
|
||||||
|
'elicitation_dialog',
|
||||||
|
'elicitation_complete',
|
||||||
|
'elicitation_response',
|
||||||
|
'idle_prompt',
|
||||||
|
'stop',
|
||||||
|
'teammate_idle',
|
||||||
|
'task_completed',
|
||||||
|
]),
|
||||||
sessionId: z.string().min(1),
|
sessionId: z.string().min(1),
|
||||||
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const ApprovalAnswerSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(['approve', 'deny', 'option', 'text']),
|
||||||
|
option: z.number().int().min(1).max(9).optional(),
|
||||||
|
text: z.string().min(1).max(4000).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
// ========== Configuration ==========
|
// ========== Configuration ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -768,6 +790,12 @@ export const SettingsUpdateSchema = z
|
|||||||
* add-only at create; a marker keeps user-authored copies untouched.
|
* add-only at create; a marker keeps user-authored copies untouched.
|
||||||
*/
|
*/
|
||||||
agentSkillEnabled: z.boolean().optional(),
|
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).
|
||||||
|
*/
|
||||||
|
approvalsInboxEnabled: z.boolean().optional(),
|
||||||
tunnelEnabled: z.boolean().optional(),
|
tunnelEnabled: z.boolean().optional(),
|
||||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import {
|
|||||||
detachSessionListeners,
|
detachSessionListeners,
|
||||||
} from './session-listener-wiring.js';
|
} from './session-listener-wiring.js';
|
||||||
import { sessionWaits } from './session-wait-registry.js';
|
import { sessionWaits } from './session-wait-registry.js';
|
||||||
|
import { approvalInbox } from './approval-inbox.js';
|
||||||
import {
|
import {
|
||||||
wireRespawnListeners,
|
wireRespawnListeners,
|
||||||
setupTimedRespawn,
|
setupTimedRespawn,
|
||||||
@@ -147,6 +148,7 @@ import {
|
|||||||
registerFileRoutes,
|
registerFileRoutes,
|
||||||
registerScheduledRoutes,
|
registerScheduledRoutes,
|
||||||
registerHookEventRoutes,
|
registerHookEventRoutes,
|
||||||
|
registerApprovalRoutes,
|
||||||
registerStatusTelemetryRoutes,
|
registerStatusTelemetryRoutes,
|
||||||
registerSystemRoutes,
|
registerSystemRoutes,
|
||||||
registerCaseRoutes,
|
registerCaseRoutes,
|
||||||
@@ -343,6 +345,13 @@ export class WebServer extends EventEmitter {
|
|||||||
this.cleanup
|
this.cleanup
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Approvals Inbox → SSE. The singleton has no server reference; these
|
||||||
|
// callbacks are its only way out. Broadcasts carry sessionId, so the
|
||||||
|
// multi-user SSE scoping applies to them like any session event.
|
||||||
|
approvalInbox.onPending = (item) => this.broadcast(SseEvent.ApprovalPending, { ...item });
|
||||||
|
approvalInbox.onUpdated = (item) => this.broadcast(SseEvent.ApprovalUpdated, { ...item });
|
||||||
|
approvalInbox.onResolved = (info) => this.broadcast(SseEvent.ApprovalResolved, { ...info });
|
||||||
|
|
||||||
// Set up mux event listeners
|
// Set up mux event listeners
|
||||||
this.mux.on('sessionCreated', (session) => {
|
this.mux.on('sessionCreated', (session) => {
|
||||||
this.broadcast(SseEvent.MuxCreated, session);
|
this.broadcast(SseEvent.MuxCreated, session);
|
||||||
@@ -945,6 +954,7 @@ export class WebServer extends EventEmitter {
|
|||||||
registerFileRoutes(this.app, ctx);
|
registerFileRoutes(this.app, ctx);
|
||||||
registerScheduledRoutes(this.app, ctx);
|
registerScheduledRoutes(this.app, ctx);
|
||||||
registerHookEventRoutes(this.app, ctx);
|
registerHookEventRoutes(this.app, ctx);
|
||||||
|
registerApprovalRoutes(this.app, ctx);
|
||||||
registerStatusTelemetryRoutes(this.app, ctx);
|
registerStatusTelemetryRoutes(this.app, ctx);
|
||||||
registerSystemRoutes(this.app, ctx);
|
registerSystemRoutes(this.app, ctx);
|
||||||
registerCaseRoutes(this.app, ctx);
|
registerCaseRoutes(this.app, ctx);
|
||||||
@@ -1258,6 +1268,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// session's own exit event never reaches the registry.
|
// session's own exit event never reaches the registry.
|
||||||
sessionWaits.notifySignal(sessionId, 'exit');
|
sessionWaits.notifySignal(sessionId, 'exit');
|
||||||
sessionWaits.cancelAll(sessionId);
|
sessionWaits.cancelAll(sessionId);
|
||||||
|
approvalInbox.resolveForSession(sessionId, 'session_ended');
|
||||||
|
|
||||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||||
}
|
}
|
||||||
@@ -2028,6 +2039,7 @@ export class WebServer extends EventEmitter {
|
|||||||
'plan:',
|
'plan:',
|
||||||
'orchestrator:',
|
'orchestrator:',
|
||||||
'hook:',
|
'hook:',
|
||||||
|
'approval:',
|
||||||
'image:',
|
'image:',
|
||||||
'scheduled:',
|
'scheduled:',
|
||||||
'team:',
|
'team:',
|
||||||
@@ -2140,6 +2152,9 @@ export class WebServer extends EventEmitter {
|
|||||||
body,
|
body,
|
||||||
tag: `codeman-${event}-${sessionId}`,
|
tag: `codeman-${event}-${sessionId}`,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
// 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,
|
urgency: template.urgency,
|
||||||
actions: template.actions,
|
actions: template.actions,
|
||||||
});
|
});
|
||||||
@@ -2868,6 +2883,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// unref'd (an unref'd timer can let the process exit mid-wait and strand the
|
// unref'd (an unref'd timer can let the process exit mid-wait and strand the
|
||||||
// response), so without this a 10-minute wait holds shutdown open.
|
// response), so without this a 10-minute wait holds shutdown open.
|
||||||
sessionWaits.cancelEverything();
|
sessionWaits.cancelEverything();
|
||||||
|
approvalInbox.stop();
|
||||||
|
|
||||||
this.lastRecordedTokens.clear();
|
this.lastRecordedTokens.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { SseEvent } from './sse-events.js';
|
|||||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||||
import { fileStreamManager } from '../file-stream-manager.js';
|
import { fileStreamManager } from '../file-stream-manager.js';
|
||||||
import { sessionWaits } from './session-wait-registry.js';
|
import { sessionWaits } from './session-wait-registry.js';
|
||||||
|
import { approvalInbox } from './approval-inbox.js';
|
||||||
|
|
||||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||||
export interface SessionListenerRefs {
|
export interface SessionListenerRefs {
|
||||||
@@ -163,6 +164,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
// burning the caller's entire timeout learning nothing.
|
// burning the caller's entire timeout learning nothing.
|
||||||
sessionWaits.notifySignal(session.id, 'exit');
|
sessionWaits.notifySignal(session.id, 'exit');
|
||||||
sessionWaits.cancelAll(session.id);
|
sessionWaits.cancelAll(session.id);
|
||||||
|
approvalInbox.resolveForSession(session.id, 'session_ended');
|
||||||
getLifecycleLog().log({
|
getLifecycleLog().log({
|
||||||
event: 'exit',
|
event: 'exit',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
@@ -214,6 +216,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
/** Broadcasts `session:working` — Claude started processing */
|
/** Broadcasts `session:working` — Claude started processing */
|
||||||
working: () => {
|
working: () => {
|
||||||
sessionWaits.notifySignal(session.id, 'working');
|
sessionWaits.notifySignal(session.id, 'working');
|
||||||
|
// An idle-prompt inbox item means "composer is waiting"; any working
|
||||||
|
// transition means input arrived, so the item is moot. ONLY the idle
|
||||||
|
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||||
|
// pending permission/question dialog on it would false-clear real
|
||||||
|
// approvals (those resolve via stop / elicitation hooks / answer-time
|
||||||
|
// re-capture instead).
|
||||||
|
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||||
const tracker = deps.getRunSummaryTracker(session.id);
|
const tracker = deps.getRunSummaryTracker(session.id);
|
||||||
if (tracker) {
|
if (tracker) {
|
||||||
|
|||||||
+23
-2
@@ -5,7 +5,7 @@
|
|||||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||||
* Both files MUST be kept in sync.
|
* Both files MUST be kept in sync.
|
||||||
*
|
*
|
||||||
* 149 event constants organized by category:
|
* 154 event constants organized by category:
|
||||||
* - **Core** (1): init
|
* - **Core** (1): init
|
||||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||||
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
|
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
|
||||||
|
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
||||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||||
* - **Clipboard** (1): write
|
* - **Clipboard** (1): write
|
||||||
* - **Cases** (4): created, linked, deleted, order-changed
|
* - **Cases** (4): created, linked, deleted, order-changed
|
||||||
@@ -336,6 +337,10 @@ export const HookIdlePrompt = 'hook:idle_prompt' as const;
|
|||||||
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
|
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
|
||||||
/** Claude Code hook: elicitation dialog (Claude asking a question). */
|
/** Claude Code hook: elicitation dialog (Claude asking a question). */
|
||||||
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
|
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
|
||||||
|
/** Claude Code hook: elicitation dialog closed (question answered in the terminal). */
|
||||||
|
export const HookElicitationComplete = 'hook:elicitation_complete' as const;
|
||||||
|
/** Claude Code hook: elicitation answer submitted. */
|
||||||
|
export const HookElicitationResponse = 'hook:elicitation_response' as const;
|
||||||
/** Claude Code hook: response complete. */
|
/** Claude Code hook: response complete. */
|
||||||
export const HookStop = 'hook:stop' as const;
|
export const HookStop = 'hook:stop' as const;
|
||||||
/** Claude Code hook: teammate went idle. */
|
/** Claude Code hook: teammate went idle. */
|
||||||
@@ -343,6 +348,15 @@ export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
|||||||
/** Claude Code hook: teammate task completed. */
|
/** Claude Code hook: teammate task completed. */
|
||||||
export const HookTaskCompleted = 'hook:task_completed' as const;
|
export const HookTaskCompleted = 'hook:task_completed' as const;
|
||||||
|
|
||||||
|
// ─── Approvals Inbox ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A prompt is waiting on a human (permission dialog, question, idle prompt). */
|
||||||
|
export const ApprovalPending = 'approval:pending' as const;
|
||||||
|
/** A pending approval's captured context/options were refreshed. */
|
||||||
|
export const ApprovalUpdated = 'approval:updated' as const;
|
||||||
|
/** A pending approval left the inbox (answered, superseded, expired, ...). */
|
||||||
|
export const ApprovalResolved = 'approval:resolved' as const;
|
||||||
|
|
||||||
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Orchestrator state machine transitioned. */
|
/** Orchestrator state machine transitioned. */
|
||||||
@@ -580,10 +594,17 @@ export const SseEvent = {
|
|||||||
HookIdlePrompt,
|
HookIdlePrompt,
|
||||||
HookPermissionPrompt,
|
HookPermissionPrompt,
|
||||||
HookElicitationDialog,
|
HookElicitationDialog,
|
||||||
|
HookElicitationComplete,
|
||||||
|
HookElicitationResponse,
|
||||||
HookStop,
|
HookStop,
|
||||||
HookTeammateIdle,
|
HookTeammateIdle,
|
||||||
HookTaskCompleted,
|
HookTaskCompleted,
|
||||||
|
|
||||||
|
// Approvals Inbox
|
||||||
|
ApprovalPending,
|
||||||
|
ApprovalUpdated,
|
||||||
|
ApprovalResolved,
|
||||||
|
|
||||||
// Orchestrator
|
// Orchestrator
|
||||||
OrchestratorStateChanged,
|
OrchestratorStateChanged,
|
||||||
OrchestratorPlanProgress,
|
OrchestratorPlanProgress,
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Approvals Inbox store unit tests (src/web/approval-inbox.ts).
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
ApprovalInbox,
|
||||||
|
normalizeCapturedFrame,
|
||||||
|
parseDialogOptions,
|
||||||
|
type ApprovalItem,
|
||||||
|
type ApprovalResolvedInfo,
|
||||||
|
} from '../src/web/approval-inbox.js';
|
||||||
|
|
||||||
|
const PERMISSION_FRAME = [
|
||||||
|
' Do you want to make this edit to foo.ts?',
|
||||||
|
' ❯ 1. Yes',
|
||||||
|
' 2. Yes, allow all edits during this session (shift+tab)',
|
||||||
|
' 3. No, and tell Claude what to do differently (esc)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const TWO_OPTION_FRAME = [' Trust the files in this folder?', ' ❯ 1. Yes, proceed', ' 2. No, exit'].join('\n');
|
||||||
|
|
||||||
|
// The live AskUserQuestion shape (measured on Claude Code v2.1.226): a
|
||||||
|
// description row under every option and a ─ separator before "Chat about this".
|
||||||
|
const ASK_USER_QUESTION_FRAME = [
|
||||||
|
' ☐ Color',
|
||||||
|
' Which color do you prefer?',
|
||||||
|
'❯ 1. Red',
|
||||||
|
' Prefer red',
|
||||||
|
' 2. Blue',
|
||||||
|
' Prefer blue',
|
||||||
|
' 3. Green',
|
||||||
|
' Prefer green',
|
||||||
|
' 4. Type something.',
|
||||||
|
'────────────────────────────────────────',
|
||||||
|
' 5. Chat about this',
|
||||||
|
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function collect(inbox: ApprovalInbox) {
|
||||||
|
const pending: ApprovalItem[] = [];
|
||||||
|
const updated: ApprovalItem[] = [];
|
||||||
|
const resolved: ApprovalResolvedInfo[] = [];
|
||||||
|
inbox.onPending = (i) => pending.push(i);
|
||||||
|
inbox.onUpdated = (i) => updated.push(i);
|
||||||
|
inbox.onResolved = (i) => resolved.push(i);
|
||||||
|
return { pending, updated, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseDialogOptions', () => {
|
||||||
|
it('parses a 3-option permission dialog with the ❯ cursor', () => {
|
||||||
|
const options = parseDialogOptions(PERMISSION_FRAME);
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ n: 1, label: 'Yes' },
|
||||||
|
{ n: 2, label: 'Yes, allow all edits during this session (shift+tab)' },
|
||||||
|
{ n: 3, label: 'No, and tell Claude what to do differently (esc)' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a 2-option dialog', () => {
|
||||||
|
expect(parseDialogOptions(TWO_OPTION_FRAME)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing parses', () => {
|
||||||
|
expect(parseDialogOptions('just some terminal output\nwith no menu')).toBeUndefined();
|
||||||
|
expect(parseDialogOptions(undefined)).toBeUndefined();
|
||||||
|
// A single numbered line is not a dialog.
|
||||||
|
expect(parseDialogOptions('1. lonely item')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires consecutive numbering from 1', () => {
|
||||||
|
expect(parseDialogOptions('2. Yes\n3. No')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the LAST complete block in the frame (dialogs render at the bottom)', () => {
|
||||||
|
const frame = ['1. old option', '2. old option two', 'some output in between', TWO_OPTION_FRAME].join('\n');
|
||||||
|
const options = parseDialogOptions(frame);
|
||||||
|
expect(options?.[0].label).toBe('Yes, proceed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps option labels at 120 chars', () => {
|
||||||
|
const long = 'x'.repeat(300);
|
||||||
|
const options = parseDialogOptions(`1. ${long}\n2. No`);
|
||||||
|
expect(options?.[0].label).toHaveLength(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the AskUserQuestion shape (descriptions between options, separator before the last)', () => {
|
||||||
|
const options = parseDialogOptions(ASK_USER_QUESTION_FRAME);
|
||||||
|
expect(options?.map((o) => o.label)).toEqual(['Red', 'Blue', 'Green', 'Type something.', 'Chat about this']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a gap of more than 3 lines ends the option block', () => {
|
||||||
|
const frame = ['1. Yes', '2. No', 'a', 'b', 'c', 'd', 'unrelated 3. text'].join('\n');
|
||||||
|
const options = parseDialogOptions(frame);
|
||||||
|
expect(options).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeCapturedFrame', () => {
|
||||||
|
it('strips ANSI, right-trims, and drops trailing blank lines', () => {
|
||||||
|
const raw = '\x1b[31mred\x1b[0m \nline2\n\n\n';
|
||||||
|
expect(normalizeCapturedFrame(raw)).toBe('red\nline2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only the last 30 lines', () => {
|
||||||
|
const raw = Array.from({ length: 50 }, (_, i) => `line${i}`).join('\n');
|
||||||
|
const out = normalizeCapturedFrame(raw)!;
|
||||||
|
expect(out.split('\n')).toHaveLength(30);
|
||||||
|
expect(out.startsWith('line20')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for empty/null captures', () => {
|
||||||
|
expect(normalizeCapturedFrame(null)).toBeUndefined();
|
||||||
|
expect(normalizeCapturedFrame('\n\n')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts absolute row repaints (formatPaneSnapshot frames) into lines', () => {
|
||||||
|
// 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)!;
|
||||||
|
expect(out.split('\n')).toEqual([' Which color do you prefer?', '❯ 1. Red', ' Prefer red', ' 2. Blue']);
|
||||||
|
expect(parseDialogOptions(out)).toEqual([
|
||||||
|
{ n: 1, label: 'Red' },
|
||||||
|
{ n: 2, label: 'Blue' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns mid-row cursor jumps into spaces instead of gluing words', () => {
|
||||||
|
const out = normalizeCapturedFrame('\x1b[5;1Hstatus:\x1b[5;20Hready');
|
||||||
|
expect(out).toBe('status: ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ApprovalInbox', () => {
|
||||||
|
let inbox: ApprovalInbox;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
inbox = new ApprovalInbox();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
inbox.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notePrompt creates a pending item with parsed options and emits onPending', () => {
|
||||||
|
const { pending } = collect(inbox);
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1-case',
|
||||||
|
kind: 'permission',
|
||||||
|
toolName: 'Edit',
|
||||||
|
capture: () => PERMISSION_FRAME,
|
||||||
|
});
|
||||||
|
expect(item.options).toHaveLength(3);
|
||||||
|
expect(item.context).toContain('Do you want to make this edit');
|
||||||
|
expect(pending).toHaveLength(1);
|
||||||
|
expect(inbox.listPending()).toHaveLength(1);
|
||||||
|
expect(inbox.getById(item.id)?.id).toBe(item.id);
|
||||||
|
expect(inbox.getForSession('s1')?.id).toBe(item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a new prompt supersedes the session previous item', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
const first = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
const second = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
|
||||||
|
expect(inbox.listPending()).toHaveLength(1);
|
||||||
|
expect(inbox.getById(first.id)).toBeUndefined();
|
||||||
|
expect(inbox.getById(second.id)).toBeDefined();
|
||||||
|
expect(resolved).toEqual([expect.objectContaining({ id: first.id, resolution: 'superseded' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idle prompts never get digit options', () => {
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'idle',
|
||||||
|
capture: () => PERMISSION_FRAME,
|
||||||
|
});
|
||||||
|
expect(item.options).toBeUndefined();
|
||||||
|
expect(item.context).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveForSession with a kinds filter skips other kinds (working-flap guard)', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
inbox.resolveForSession('s1', 'resolved_in_terminal', ['idle']);
|
||||||
|
expect(inbox.listPending()).toHaveLength(1);
|
||||||
|
|
||||||
|
inbox.notePrompt({ sessionId: 's2', sessionName: 'w2', kind: 'idle' });
|
||||||
|
inbox.resolveForSession('s2', 'resolved_in_terminal', ['idle']);
|
||||||
|
expect(inbox.getForSession('s2')).toBeUndefined();
|
||||||
|
expect(resolved.filter((r) => r.resolution === 'resolved_in_terminal')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('take removes as answered; restore re-inserts unless superseded', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
const taken = inbox.take(item.id)!;
|
||||||
|
expect(taken.id).toBe(item.id);
|
||||||
|
expect(inbox.take(item.id)).toBeUndefined();
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'answered' });
|
||||||
|
|
||||||
|
inbox.restore(taken);
|
||||||
|
expect(inbox.getById(item.id)).toBeDefined();
|
||||||
|
|
||||||
|
// A newer prompt wins over a restore.
|
||||||
|
const taken2 = inbox.take(item.id)!;
|
||||||
|
const newer = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
|
||||||
|
inbox.restore(taken2);
|
||||||
|
expect(inbox.getForSession('s1')?.id).toBe(newer.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss removes without answering', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
|
||||||
|
expect(inbox.dismiss(item.id)).toBe(true);
|
||||||
|
expect(inbox.dismiss(item.id)).toBe(false);
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ resolution: 'dismissed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('items expire after the TTL on read', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
vi.advanceTimersByTime(13 * 60 * 60 * 1000);
|
||||||
|
expect(inbox.listPending()).toHaveLength(0);
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ resolution: 'expired' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-captures once after a short delay and emits onUpdated', () => {
|
||||||
|
const { updated } = collect(inbox);
|
||||||
|
let frame = 'still painting...';
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => frame,
|
||||||
|
});
|
||||||
|
expect(item.options).toBeUndefined();
|
||||||
|
frame = PERMISSION_FRAME;
|
||||||
|
vi.advanceTimersByTime(700);
|
||||||
|
expect(updated).toHaveLength(1);
|
||||||
|
expect(inbox.getById(item.id)?.options).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the delayed re-capture never touches a superseded item', () => {
|
||||||
|
let frame = 'first';
|
||||||
|
const first = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||||
|
const second = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question', capture: () => frame });
|
||||||
|
frame = PERMISSION_FRAME;
|
||||||
|
const { updated } = collect(inbox);
|
||||||
|
vi.advanceTimersByTime(700);
|
||||||
|
expect(updated.every((i) => i.id !== first.id)).toBe(true);
|
||||||
|
expect(inbox.getById(second.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyStillAnswerable', () => {
|
||||||
|
it('resolves the item and refuses when a parsed dialog left the screen', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
let frame = PERMISSION_FRAME;
|
||||||
|
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||||
|
expect(item.options).toHaveLength(3);
|
||||||
|
frame = 'the dialog is gone, claude is typing';
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||||
|
expect(inbox.getById(item.id)).toBeUndefined();
|
||||||
|
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes context/options when the dialog is still up', () => {
|
||||||
|
let frame = PERMISSION_FRAME;
|
||||||
|
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||||
|
frame = TWO_OPTION_FRAME;
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||||
|
expect(inbox.getById(item.id)?.options).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is inconclusive (allows) for items that never parsed options', () => {
|
||||||
|
const item = inbox.notePrompt({
|
||||||
|
sessionId: 's1',
|
||||||
|
sessionName: 'w1',
|
||||||
|
kind: 'permission',
|
||||||
|
capture: () => 'unparseable dialog',
|
||||||
|
});
|
||||||
|
expect(item.options).toBeUndefined();
|
||||||
|
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for unknown ids only as false (missing item refuses)', () => {
|
||||||
|
expect(inbox.verifyStillAnswerable('nope:1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() clears items and silences events', () => {
|
||||||
|
const { resolved } = collect(inbox);
|
||||||
|
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||||
|
inbox.stop();
|
||||||
|
expect(inbox.listPending()).toHaveLength(0);
|
||||||
|
expect(resolved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,25 @@ describe('refreshStaleCodemanHooks', () => {
|
|||||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
|
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('heals a hooks block that predates the elicitation-closed matchers (Approvals Inbox)', async () => {
|
||||||
|
// A current-at-the-time block from before elicitation_complete/response
|
||||||
|
// existed: secret + markers all present, so ONLY the new-matcher probe can
|
||||||
|
// mark it stale. Build one by healing, then stripping the two matchers.
|
||||||
|
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
|
||||||
|
await refreshStaleCodemanHooks(dir);
|
||||||
|
const healed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||||
|
healed.hooks.Notification = (healed.hooks.Notification as Array<{ matcher?: string }>).filter(
|
||||||
|
(n) => n.matcher !== 'elicitation_complete' && n.matcher !== 'elicitation_response'
|
||||||
|
);
|
||||||
|
writeFileSync(settingsPath, JSON.stringify(healed, null, 2));
|
||||||
|
expect(readFileSync(settingsPath, 'utf-8')).not.toContain('elicitation_complete');
|
||||||
|
|
||||||
|
await refreshStaleCodemanHooks(dir);
|
||||||
|
const after = readFileSync(settingsPath, 'utf-8');
|
||||||
|
expect(after).toContain('elicitation_complete');
|
||||||
|
expect(after).toContain('elicitation_response');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
|
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
|
||||||
const foreign = JSON.stringify(
|
const foreign = JSON.stringify(
|
||||||
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
|
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('generateHooksConfig', () => {
|
|||||||
it('should have Notification hooks array', () => {
|
it('should have Notification hooks array', () => {
|
||||||
const config = generateHooksConfig();
|
const config = generateHooksConfig();
|
||||||
expect(config.hooks.Notification).toBeInstanceOf(Array);
|
expect(config.hooks.Notification).toBeInstanceOf(Array);
|
||||||
expect(config.hooks.Notification).toHaveLength(3);
|
expect(config.hooks.Notification).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have Stop hooks array', () => {
|
it('should have Stop hooks array', () => {
|
||||||
@@ -194,7 +194,7 @@ describe('writeHooksConfig', () => {
|
|||||||
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||||
expect(parsed.hooks).toBeDefined();
|
expect(parsed.hooks).toBeDefined();
|
||||||
expect(parsed.hooks.Notification).toHaveLength(3);
|
expect(parsed.hooks.Notification).toHaveLength(5);
|
||||||
expect(parsed.hooks.Stop).toHaveLength(1);
|
expect(parsed.hooks.Stop).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1085,7 +1085,7 @@ describe('Hook Config Generation - Extended', () => {
|
|||||||
it('should generate valid JSON structure', () => {
|
it('should generate valid JSON structure', () => {
|
||||||
const config = generateHooksConfig();
|
const config = generateHooksConfig();
|
||||||
expect(config.hooks).toBeDefined();
|
expect(config.hooks).toBeDefined();
|
||||||
expect(config.hooks.Notification).toHaveLength(3);
|
expect(config.hooks.Notification).toHaveLength(5);
|
||||||
expect(config.hooks.Stop).toHaveLength(1);
|
expect(config.hooks.Stop).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1096,6 +1096,9 @@ describe('Hook Config Generation - Extended', () => {
|
|||||||
expect(matchers).toContain('idle_prompt');
|
expect(matchers).toContain('idle_prompt');
|
||||||
expect(matchers).toContain('permission_prompt');
|
expect(matchers).toContain('permission_prompt');
|
||||||
expect(matchers).toContain('elicitation_dialog');
|
expect(matchers).toContain('elicitation_dialog');
|
||||||
|
// Approvals Inbox resolution signals (dialog answered in the terminal).
|
||||||
|
expect(matchers).toContain('elicitation_complete');
|
||||||
|
expect(matchers).toContain('elicitation_response');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use environment variable placeholders', () => {
|
it('should use environment variable placeholders', () => {
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
|
||||||
|
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
|
||||||
|
import { approvalInbox } from '../../src/web/approval-inbox.js';
|
||||||
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
|
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||||
|
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
|
||||||
|
|
||||||
|
type MockRouteContext = ReturnType<typeof createMockRouteContext>;
|
||||||
|
|
||||||
|
interface RouteTestHarness {
|
||||||
|
app: FastifyInstance;
|
||||||
|
ctx: MockRouteContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local harness mirroring production's uniform-envelope preSerialization hook
|
||||||
|
* (server.ts), so `{success:false}` bodies carry their conventional 4xx status.
|
||||||
|
* The shared createRouteTestHarness deliberately omits that hook; these routes
|
||||||
|
* signal every guard through returned error envelopes, so the status IS the
|
||||||
|
* behavior under test. Pattern copied from hook-event-routes.test.ts.
|
||||||
|
*/
|
||||||
|
async function createEnvelopeHarness(authUser?: {
|
||||||
|
username: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
}): Promise<RouteTestHarness> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
if (authUser) {
|
||||||
|
app.addHook('onRequest', async (req) => {
|
||||||
|
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||||
|
registerHookEventRoutes(app, ctx as never);
|
||||||
|
registerApprovalRoutes(app, ctx as never);
|
||||||
|
|
||||||
|
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||||
|
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||||
|
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||||
|
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||||
|
if (p.success === false) {
|
||||||
|
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||||
|
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||||
|
}
|
||||||
|
return done(null, payload);
|
||||||
|
}
|
||||||
|
if (p.success === true) return done(null, payload);
|
||||||
|
return done(null, { success: true, data: payload });
|
||||||
|
});
|
||||||
|
|
||||||
|
installRouteErrorHandler(app);
|
||||||
|
await app.ready();
|
||||||
|
return { app, ctx };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_ID = 'approval-test-session';
|
||||||
|
|
||||||
|
const PERMISSION_DIALOG = [
|
||||||
|
' Claude needs your permission to use Bash',
|
||||||
|
' ❯ 1. Yes',
|
||||||
|
' 2. Yes, and don’t ask again for this command',
|
||||||
|
' 3. No, and tell Claude what to do differently (esc)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
async function postHook(harness: RouteTestHarness, event: string, data: Record<string, unknown> = {}): Promise<void> {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/hook-event',
|
||||||
|
payload: { event, sessionId: SESSION_ID, data },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listApprovals(harness: RouteTestHarness): Promise<Array<Record<string, unknown>>> {
|
||||||
|
const res = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.json().data.approvals;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('approval routes', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
let session: MockSession;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createEnvelopeHarness();
|
||||||
|
session = harness.ctx.sessions.get(SESSION_ID)!;
|
||||||
|
session.terminalBuffer = PERMISSION_DIALOG;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const item of approvalInbox.listPending()) {
|
||||||
|
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
|
||||||
|
}
|
||||||
|
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
|
||||||
|
await harness.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a permission_prompt hook creates a pending item with parsed options and context', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', {
|
||||||
|
tool_name: 'Bash',
|
||||||
|
tool_input: { command: 'rm -rf node_modules' },
|
||||||
|
message: 'Claude needs your permission to use Bash',
|
||||||
|
cwd: '/tmp/case',
|
||||||
|
});
|
||||||
|
const approvals = await listApprovals(harness);
|
||||||
|
expect(approvals).toHaveLength(1);
|
||||||
|
expect(approvals[0]).toMatchObject({
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
kind: 'permission',
|
||||||
|
toolName: 'Bash',
|
||||||
|
toolSummary: 'rm -rf node_modules',
|
||||||
|
message: 'Claude needs your permission to use Bash',
|
||||||
|
});
|
||||||
|
expect(approvals[0].options).toHaveLength(3);
|
||||||
|
expect(String(approvals[0].context)).toContain('permission to use Bash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcast and push for the prompt carry the approvalId', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
const hookBroadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||||
|
expect(hookBroadcast?.[1]).toMatchObject({ approvalId: item.id });
|
||||||
|
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||||
|
expect(push?.[1]).toMatchObject({ approvalId: item.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answering with a parsed option sends exactly that digit (no Enter)', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'option', option: 2 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(session.writeBuffer).toEqual(['2']);
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approve sends "1", deny sends Esc', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
let [item] = await listApprovals(harness);
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'approve' },
|
||||||
|
});
|
||||||
|
expect(session.writeBuffer).toEqual(['1']);
|
||||||
|
|
||||||
|
session.writeBuffer.length = 0;
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
[item] = await listApprovals(harness);
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'deny' },
|
||||||
|
});
|
||||||
|
expect(session.writeBuffer).toEqual(['\x1b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second answer 404s (answered items leave the inbox)', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'approve' },
|
||||||
|
});
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'deny' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(session.writeBuffer).toEqual(['1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects option digits outside the parsed options', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'option', option: 7 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(session.writeBuffer).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
session.terminalBuffer = 'claude is off doing something else now';
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'approve' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(session.writeBuffer).toEqual([]);
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idle prompts take a text answer, submitted with \\r; approve/deny are rejected', async () => {
|
||||||
|
session.terminalBuffer = 'claude> waiting at the composer';
|
||||||
|
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
expect(item.kind).toBe('idle');
|
||||||
|
|
||||||
|
const bad = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'approve' },
|
||||||
|
});
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'text', text: 'continue with the plan\nplease' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
// Embedded newlines are flattened; the trailing \r submits.
|
||||||
|
expect(session.writeBuffer).toEqual(['continue with the plan please\r']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text answers on dialog items are rejected', async () => {
|
||||||
|
await postHook(harness, 'elicitation_dialog', {});
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'text', text: 'hello' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop resolves the pending item; elicitation_complete resolves questions', async () => {
|
||||||
|
await postHook(harness, 'elicitation_dialog', {});
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(1);
|
||||||
|
await postHook(harness, 'elicitation_complete', {});
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(0);
|
||||||
|
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(1);
|
||||||
|
await postHook(harness, 'stop', {});
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failed write restores the item and reports 422', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
session.failWrites = true;
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'approve' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(422);
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss removes without keystrokes', async () => {
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
const [item] = await listApprovals(harness);
|
||||||
|
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(session.writeBuffer).toEqual([]);
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-claude sessions never get inbox items', async () => {
|
||||||
|
session.mode = 'codex';
|
||||||
|
await postHook(harness, 'permission_prompt', {});
|
||||||
|
expect(await listApprovals(harness)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown ids 404 on answer and dismiss', async () => {
|
||||||
|
for (const url of ['/api/approvals/nope:1/answer', '/api/approvals/nope:1/dismiss']) {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url,
|
||||||
|
payload: url.endsWith('answer') ? { action: 'approve' } : {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('approval routes — multi-user scoping', () => {
|
||||||
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
|
||||||
|
for (const item of approvalInbox.listPending()) {
|
||||||
|
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-admin neither lists nor answers another user's approvals (404, not 403)", async () => {
|
||||||
|
const harness = await createEnvelopeHarness({ username: 'bob', role: 'user' });
|
||||||
|
const session = harness.ctx.sessions.get(SESSION_ID)!;
|
||||||
|
session.terminalBuffer = PERMISSION_DIALOG;
|
||||||
|
(session as unknown as { owner?: string }).owner = 'alice';
|
||||||
|
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/hook-event',
|
||||||
|
payload: { event: 'permission_prompt', sessionId: SESSION_ID, data: {} },
|
||||||
|
});
|
||||||
|
// The item exists in the store...
|
||||||
|
expect(approvalInbox.listPending()).toHaveLength(1);
|
||||||
|
const [item] = approvalInbox.listPending();
|
||||||
|
|
||||||
|
// ...but bob sees an empty list and cannot act on the id.
|
||||||
|
const list = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
|
||||||
|
expect(list.json().data.approvals).toHaveLength(0);
|
||||||
|
const answer = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/approvals/${item.id}/answer`,
|
||||||
|
payload: { action: 'approve' },
|
||||||
|
});
|
||||||
|
expect(answer.statusCode).toBe(404);
|
||||||
|
expect(session.writeBuffer).toEqual([]);
|
||||||
|
|
||||||
|
await harness.app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user