Files
Codeman/src/web/routes/hook-event-routes.ts
T
Codeman maintainer cdbde9f36f home-order follow-ups: fresh stamps for the blocked group, sort/display agreement, live Alt+N projection
Three follow-ups from the 1.19.0 review of the activity-ordered home screens:

- Hook events now ride the same debounced session state broadcast the
  working/idle handlers use. The blocked group ranks on lastActivityAt, and
  without this a permission prompt raised after page load kept ranking by
  whatever stamp the browser loaded with.

- A working row with no submit stamp now shows the lastActivityAt fallback
  its sort anchor already uses: a row must never be ranked by a number it
  does not display.

- Alt+digit resolves through the live-session projection the render paints
  (sessionOrder minus dead ids), so a stale id cannot shift every painted
  number off its target, web tabs included. New tests pin both surfaces to
  one shared order and the numbering to the live projection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:21:54 +02:00

174 lines
7.8 KiB
TypeScript

/**
* @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';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
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
): void {
app.post('/api/hook-event', async (req) => {
const { event, sessionId, data } = parseBody(HookEventSchema, req.body);
if (!ctx.sessions.has(sessionId)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
// and can flap mid-turn), so these two are what an orchestrating agent should
// wait on.
//
// Gated on the session's MODE, matching `resolveWaitSignals` on the read side.
// Without it the guard is one-sided: a caller cannot ASK for `stop` on a shell or
// codex session, but this endpoint would happily deliver one for it. Hook events
// carry no identity beyond a per-instance secret shared by every case, so this is
// also the cheap half of the forgery surface — a `stop` claimed for a session that
// could never legitimately emit one is now dropped instead of steering another
// agent's control flow.
const waitSession = ctx.sessions.get(sessionId);
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
if (event === 'stop') {
sessionWaits.notifySignal(sessionId, 'stop');
} else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
sessionWaits.notifySignal(sessionId, 'blocked');
}
}
// Signal the respawn controller based on hook event type
const controller = ctx.respawnControllers.get(sessionId);
if (controller) {
if (event === 'elicitation_dialog') {
// Block auto-accept for question prompts
controller.signalElicitation();
} else if (event === 'stop') {
// DEFINITIVE idle signal - Claude finished responding
controller.signalStopHook();
} else if (event === 'idle_prompt') {
// DEFINITIVE idle signal - Claude has been idle for 60+ seconds
controller.signalIdlePrompt();
}
}
// Start transcript watching if transcript_path is provided and safe
if (data && 'transcript_path' in data) {
const transcriptPath = String(data.transcript_path);
if (transcriptPath && isValidWorkingDir(transcriptPath)) {
ctx.startTranscriptWatcher(sessionId, transcriptPath);
}
}
// Sync Claude's current conversation id. Interactive PTY mode never emits
// `session_id` on stdout, so hooks are the only reliable way to learn that
// the user ran `/clear` (which spins up a new conversation jsonl).
if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId);
const prevClaudeSessionId = session?.claudeSessionId;
session?.adoptClaudeSessionId(data.session_id);
// Docker sessions: keep the case's resume seed following the LIVE
// conversation (post-/clear id switches), so a container stop/reboot
// relaunch resumes the right transcript.
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
void persistDockerCaseClaudeSessionId(
getDataDir(),
session.docker.containerName,
session.claudeSessionId
).catch(() => {});
}
}
// Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data);
const session = ctx.sessions.get(sessionId);
const sessionName = session?.name ?? sessionId.slice(0, 8);
// 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 }),
});
// Full state ride-along, same shape as the working/idle handlers: the home
// screens rank the blocked group on lastActivityAt, and without this a
// permission prompt raised after page load kept ranking by whatever stamp
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
ctx.broadcastSessionStateDebounced(sessionId);
// 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);
if (summaryTracker) {
summaryTracker.recordHookEvent(event, safeData);
}
return {};
});
}