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
+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();