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
+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';