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:
Codeman maintainer
2026-08-09 14:04:34 +02:00
parent b1614e89fc
commit ff10a50bc0
28 changed files with 2169 additions and 41 deletions
+21 -3
View File
@@ -15,9 +15,10 @@
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
*
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
* `stop`, `teammate_idle`, `task_completed`
* `elicitation_complete`, `elicitation_response`, `stop`, `teammate_idle`,
* `task_completed`
*
* Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1),
* Hook categories: `Notification` (5 matchers), `Stop` (1), `SubagentStop` (1),
* `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained
* background Bash rewake)
*
@@ -332,6 +333,16 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
matcher: 'elicitation_dialog',
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }],
},
// The two dialog-closed notifications resolve Approvals Inbox items the
// moment a question is answered IN the terminal (long before `stop`).
{
matcher: 'elicitation_complete',
hooks: [{ type: 'command', command: curlCmd('elicitation_complete'), timeout: HOOK_TIMEOUT_SECONDS }],
},
{
matcher: 'elicitation_response',
hooks: [{ type: 'command', command: curlCmd('elicitation_response'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
Stop: [
{
@@ -662,7 +673,14 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
// on a self-signed HTTPS install.
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER);
if (!isOurs || (hasSecret && hasBackgroundWake && hasSubagentStopGuard && !hasTlsFlaglessCurl)) return;
// Approvals Inbox needs the elicitation_complete/elicitation_response
// matchers; their absence marks a pre-inbox hooks block.
const hasElicitationComplete = hooksJson.includes('elicitation_complete');
if (
!isOurs ||
(hasSecret && hasBackgroundWake && hasSubagentStopGuard && hasElicitationComplete && !hasTlsFlaglessCurl)
)
return;
const generated = generateHooksConfig();
const merged = {
...existing,
+2
View File
@@ -105,6 +105,8 @@ export type HookEventType =
| 'idle_prompt'
| 'permission_prompt'
| 'elicitation_dialog'
| 'elicitation_complete'
| 'elicitation_response'
| 'stop'
| 'teammate_idle'
| 'task_completed';
+377
View File
@@ -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();
+16
View File
@@ -237,10 +237,17 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'],
[SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'],
[SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'],
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
// Approvals Inbox (handlers in approvals-ui.js)
[SSE_EVENTS.APPROVAL_PENDING, '_onApprovalPending'],
[SSE_EVENTS.APPROVAL_UPDATED, '_onApprovalUpdated'],
[SSE_EVENTS.APPROVAL_RESOLVED, '_onApprovalResolved'],
// Subagents (Claude Code background agents)
[SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'],
[SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'],
@@ -630,6 +637,9 @@ class CodemanApp {
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
this.pendingHooks = new Map();
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
this.approvals = new Map();
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
this._ws = null; // WebSocket instance for active session
this._wsSessionId = null; // Session ID the WS is connected to
@@ -3030,6 +3040,8 @@ class CodemanApp {
this._predictiveEcho?.clearPredictions();
// Clear pending hooks
this.pendingHooks.clear();
// Clear approvals (re-seeded from GET /api/approvals right after init)
this.approvals?.clear();
// Clear parent name cache (prevents stale session name entries accumulating)
if (this._parentNameCache) this._parentNameCache.clear();
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
@@ -3170,6 +3182,10 @@ class CodemanApp {
this.updateCost();
this.renderSessionTabs();
// Approvals Inbox: re-seed pending prompts from the server so alerts
// survive reloads and SSE reconnects (methods in approvals-ui.js).
this.seedApprovals?.();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
+238
View File
@@ -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`;
},
});
+7
View File
@@ -408,10 +408,17 @@ const SSE_EVENTS = {
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
// Approvals Inbox
APPROVAL_PENDING: 'approval:pending',
APPROVAL_UPDATED: 'approval:updated',
APPROVAL_RESOLVED: 'approval:resolved',
// Subagents (Claude Code background agents)
SUBAGENT_DISCOVERED: 'subagent:discovered',
SUBAGENT_UPDATED: 'subagent:updated',
+16
View File
@@ -235,6 +235,22 @@
Subagents: '子智能体',
'Ultracode Agents': 'Ultracode 智能体',
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
'Approvals waiting on you': '等待您审批的请求',
'Open approvals inbox': '打开审批收件箱',
'Close approvals inbox': '关闭审批收件箱',
Approve: '批准',
'Deny (Esc)': '拒绝 (Esc)',
Deny: '拒绝',
'Open session': '打开会话',
Dismiss: '忽略',
Send: '发送',
Permission: '权限',
Question: '问题',
Idle: '空闲',
'Subagent Options': '子智能体选项',
'Enable Tracking': '启用跟踪',
'Active Tab Only': '仅活动标签页',
+24
View File
@@ -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-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-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">
<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>
@@ -1522,6 +1526,13 @@
<span class="slider"></span>
</label>
</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)">
<span class="settings-item-label">Ultracode Agents</span>
<label class="switch switch-sm">
@@ -2679,6 +2690,18 @@
<!-- Lines drawn dynamically -->
</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="i18n.js"></script>
<script defer src="mobile-handlers.js"></script>
@@ -2697,6 +2720,7 @@
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.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="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
+49
View File
@@ -639,9 +639,58 @@ Object.assign(CodemanApp.prototype, {
chevron.textContent = '›';
item.appendChild(chevron);
// Approvals Inbox: a pending dialog for this session gets an answer strip
// BELOW the row (the row itself is a <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;
},
/** 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. */
_buildMobileOverviewPastRow(row) {
const item = document.createElement('button');
+40 -1
View File
@@ -479,7 +479,11 @@ html.mobile-init .file-browser-panel {
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest,
.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;
}
@@ -2503,6 +2507,41 @@ html.mobile-init .file-browser-panel {
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
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
+22 -2
View File
@@ -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');
},
_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) {
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
@@ -158,8 +170,12 @@ Object.assign(CodemanApp.prototype, {
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'notification-click') {
const { sessionId } = event.data;
if (sessionId && this.sessions.has(sessionId)) {
const { sessionId, action, approvalId } = event.data;
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);
}
window.focus();
@@ -326,6 +342,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
// Approvals Inbox: synced, default ON (only an explicit false disables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled !== false;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
@@ -1525,6 +1543,7 @@ Object.assign(CodemanApp.prototype, {
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
@@ -1690,6 +1709,7 @@ Object.assign(CodemanApp.prototype, {
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
this.renderApprovals?.(); // Approvals Inbox toggle (hide/show bell + drawer)
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
+216
View File
@@ -10643,6 +10643,222 @@ kbd {
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
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. */
+44 -20
View File
@@ -111,14 +111,14 @@ self.addEventListener('push', (event) => {
return;
}
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
const { title, hostTitle, body, tag, sessionId, approvalId, urgency, actions } = payload;
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
renotify: true,
requireInteraction: urgency === 'critical',
};
@@ -142,24 +142,48 @@ self.addEventListener('push', (event) => {
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, url } = event.notification.data || {};
const { sessionId, approvalId, url } = event.notification.data || {};
const targetUrl = url || '/';
const action = event.action || null;
event.waitUntil(
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,
action: event.action || null,
});
return client.focus();
}
}
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
})
);
// Approve/Deny action buttons answer the Approvals Inbox item directly from
// the worker, so they work with NO Codeman tab open (lock-screen approvals).
// Same-origin POST with cookie credentials; the CSRF Origin check passes
// because a service worker fetch carries the worker's own (same) origin.
if ((action === 'approve' || action === 'deny') && approvalId) {
event.waitUntil(
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}).then((res) => {
if (res && res.ok) return undefined;
// 401/404/409: let the human see the state — fall back to a tab.
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);
});
}
+10
View File
@@ -335,6 +335,7 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
'permission_mode',
'stop_hook_active',
'transcript_path',
'message',
];
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)
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
const input = safeFields.tool_input as Record<string, unknown>;
+125
View File
@@ -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 } };
});
}
+66 -4
View File
@@ -2,6 +2,9 @@
* @fileoverview Hook event route.
* Receives Claude Code hook events and broadcasts to SSE clients.
* 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';
@@ -11,8 +14,19 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.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';
/** 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(
app: FastifyInstance,
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
@@ -88,12 +102,60 @@ export function registerHookEventRoutes(
// Sanitize forwarded data: only include known safe fields, limit size
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 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
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
+1
View File
@@ -10,6 +10,7 @@ export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerApprovalRoutes } from './approval-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerSessionRoutes } from './session-routes.js';
+29 -1
View File
@@ -663,11 +663,33 @@ export const QuickStartSchema = z.object({
* Receives Claude Code hook events.
*/
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),
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 ==========
/**
@@ -768,6 +790,12 @@ export const SettingsUpdateSchema = z
* add-only at create; a marker keeps user-authored copies untouched.
*/
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(),
// Action field (NOT persisted): explicit per-request acknowledgment that the
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
+16
View File
@@ -86,6 +86,7 @@ import {
detachSessionListeners,
} from './session-listener-wiring.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
import {
wireRespawnListeners,
setupTimedRespawn,
@@ -147,6 +148,7 @@ import {
registerFileRoutes,
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -343,6 +345,13 @@ export class WebServer extends EventEmitter {
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
this.mux.on('sessionCreated', (session) => {
this.broadcast(SseEvent.MuxCreated, session);
@@ -945,6 +954,7 @@ export class WebServer extends EventEmitter {
registerFileRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(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.
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
@@ -2028,6 +2039,7 @@ export class WebServer extends EventEmitter {
'plan:',
'orchestrator:',
'hook:',
'approval:',
'image:',
'scheduled:',
'team:',
@@ -2140,6 +2152,9 @@ export class WebServer extends EventEmitter {
body,
tag: `codeman-${event}-${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,
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
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
approvalInbox.stop();
this.lastRecordedTokens.clear();
+9
View File
@@ -28,6 +28,7 @@ import { SseEvent } from './sse-events.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
@@ -163,6 +164,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// burning the caller's entire timeout learning nothing.
sessionWaits.notifySignal(session.id, 'exit');
sessionWaits.cancelAll(session.id);
approvalInbox.resolveForSession(session.id, 'session_ended');
getLifecycleLog().log({
event: 'exit',
sessionId: session.id,
@@ -214,6 +216,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:working` — Claude started processing */
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 });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
+23 -2
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 149 event constants organized by category:
* 154 event constants organized by category:
* - **Core** (1): init
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
@@ -24,7 +24,8 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **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
* - **Clipboard** (1): write
* - **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;
/** Claude Code hook: elicitation dialog (Claude asking a question). */
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. */
export const HookStop = 'hook:stop' as const;
/** Claude Code hook: teammate went idle. */
@@ -343,6 +348,15 @@ export const HookTeammateIdle = 'hook:teammate_idle' as const;
/** Claude Code hook: teammate task completed. */
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 state machine transitioned. */
@@ -580,10 +594,17 @@ export const SseEvent = {
HookIdlePrompt,
HookPermissionPrompt,
HookElicitationDialog,
HookElicitationComplete,
HookElicitationResponse,
HookStop,
HookTeammateIdle,
HookTaskCompleted,
// Approvals Inbox
ApprovalPending,
ApprovalUpdated,
ApprovalResolved,
// Orchestrator
OrchestratorStateChanged,
OrchestratorPlanProgress,