mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
Fifteen review findings on the dsh mode, the serious ones first: - Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys. _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into every dsh pane and applyEnvOverrides() lands after it, so a non-granted owner who could redirect the base URL would have the operator's key sent as a bearer credential to a host of their choosing. - Wait registry: until=stop/blocked is refused on docker and remote-SSH dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions). The HERDR triple is set via LOCAL tmux setenv, which crosses neither docker exec nor ssh, so such a session can never post a hook event and the wait burned its whole timeout on every turn. - Approvals: a dsh item is an ALERT, not an answerable card. The answer route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the option parser cannot read a third-party TUI's frames, so an answer was a blind keystroke into a foreign composer), and the push notification carries no Approve/Deny actions for dsh sessions. - Status shim (v3): --seq is forwarded and the server drops stale retried reports inside a 60s window (the TUI retries with backoff, so a retried 'working' could land after 'blocked' and resolve an approval whose dialog was still on screen); 4xx responses exit 0 instead of retrying, so one misconfigured session cannot feed the auth rate-limit bucket until the hook endpoint 429s for the whole instance. - Web-UI server: concurrent starts are serialized through a lock (two racing POSTs used to pick the same port and orphan the winner), and the readiness poll / timeout paths only clear or stop the singleton while it is still theirs. First click actually opens the tab now (refreshWebviews, not the nonexistent loadWebviews). DELETE /api/deepseek/web requires the privileged grant in multi-user mode. - Cron: deepseek jobs run the same two-part launch gate as the HTTP create paths (impl moved into the resolver so all three share it) and no longer stamp a Claude default model on the session. - Parity sweeps: quick-start's docker branch rejects deepSeekConfig like the remote branch; the Ralph auto-enable list gained deepseek; HookEventType gained agent_working; the phone overview run menu filters managed webview records like the desktop menu. - install.sh: the dsh identity probe closes stdin (under curl|bash a child that reads stdin eats the rest of the script), bounds the exec with timeout where available, and is memoized to one scan per install. - Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand identity (it rendered as an unstyled UA-grey button); stale markup comment about the web shortcut rewritten; clamp docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
219 lines
10 KiB
TypeScript
219 lines
10 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, sessionHookOptions } 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.
|
|
*
|
|
* `agent_working` is here because it is the DeepSeek status bridge's report that
|
|
* a turn STARTED, and a harness turn cannot be running while one of its own
|
|
* modal approvals is on screen — so the agent moving means the dialog was
|
|
* answered, in the terminal, by the user. That is the same conclusion the claude
|
|
* path reaches through pane capture, which cannot help here because its frame
|
|
* parser is Claude-dialog-shaped.
|
|
*/
|
|
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
|
|
|
|
/**
|
|
* Last DeepSeek status-bridge sequence number seen per session.
|
|
*
|
|
* The Herdr contract the dsh TUI speaks stamps every report with `--seq <n>`
|
|
* and RETRIES failed deliveries with backoff — so a stale report can land
|
|
* AFTER a newer one, and applying it in arrival order resolves an approval
|
|
* with a retried `working` while the harness sits blocked, or releases a wait
|
|
* with a retried `idle` mid-turn. A report whose seq is not newer than the
|
|
* last accepted one is dropped, but only inside a short window: the TUI's
|
|
* retry backoff is seconds, so a LOWER seq arriving after the window is a
|
|
* restarted TUI's fresh numbering (same pane, new generation), not a stale
|
|
* retry, and must be accepted. Insertion-order eviction bounds the map.
|
|
*/
|
|
const dshSeqBySession = new Map<string, { seq: number; at: number }>();
|
|
const DSH_SEQ_STALE_WINDOW_MS = 60_000;
|
|
const DSH_SEQ_MAX_SESSIONS = 500;
|
|
|
|
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');
|
|
}
|
|
|
|
// DeepSeek status-bridge ordering: drop a stale retried report (see
|
|
// dshSeqBySession above). Success rather than an error, so the shim exits 0
|
|
// and the TUI does not keep retrying a report that will stay stale.
|
|
if (data && data.source === 'dsh-status-shim' && typeof data.seq === 'number') {
|
|
const last = dshSeqBySession.get(sessionId);
|
|
const now = Date.now();
|
|
if (last && data.seq <= last.seq && now - last.at < DSH_SEQ_STALE_WINDOW_MS) {
|
|
return {};
|
|
}
|
|
if (!dshSeqBySession.has(sessionId) && dshSeqBySession.size >= DSH_SEQ_MAX_SESSIONS) {
|
|
const oldest = dshSeqBySession.keys().next().value;
|
|
if (oldest !== undefined) dshSeqBySession.delete(oldest);
|
|
}
|
|
dshSeqBySession.set(sessionId, { seq: data.seq, at: now });
|
|
}
|
|
|
|
// 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, sessionHookOptions(waitSession))) {
|
|
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, sessionHookOptions(session))) {
|
|
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. Push Approve/Deny actions ride
|
|
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
|
|
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
|
|
// notification instead of offering buttons whose answer would be refused.
|
|
ctx.sendPushNotifications(`hook:${event}`, {
|
|
sessionId,
|
|
sessionName,
|
|
...safeData,
|
|
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
|
});
|
|
|
|
// Track in run summary
|
|
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
|
if (summaryTracker) {
|
|
summaryTracker.recordHookEvent(event, safeData);
|
|
}
|
|
|
|
return {};
|
|
});
|
|
}
|