SPB Git

Repository Public

hooks.ts 10.0 KB 216 lines typescript

1/**2 * Claude Code hooks → `claude.hook` runtime events (the ONLY source of WAITING_FOR_INPUT).3 *4 * `bin/cr-hook` (run by Claude Code inside a Claude Remote session) POSTs the hook's stdin JSON5 * to `/hooks/claude` with the low-privilege hook token and `X-CR-Session: $CR_SESSION_ID`.6 * The contract's explicit body `{sessionId, event, conversationId?, message?}` is accepted too.7 *8 * `installClaudeHooks` / `uninstallClaudeHooks` merge our entries into a Claude Code9 * `settings.json` without touching anything else. Installation only happens when10 * `CR_INSTALL_AGENT_HOOKS=1` (or its alias `CR_INSTALL_CLAUDE_HOOKS=1`, claude profile only);11 * it is never done implicitly.12 *13 * Codex profile: `bin/cr-codex-notify` (the `notify` program, see codex-notify.ts) POSTs the14 * notify JSON to the same route; `agent-turn-complete` is reported as `Stop`. Codex has no15 * "prompt submitted" signal there, so the server returns to RUNNING on terminal input.16 */17import fs from 'node:fs/promises';18import os from 'node:os';19import path from 'node:path';20import { fileURLToPath } from 'node:url';21import { z } from 'zod';22import { RtError, type RuntimePlugin } from './plugins.js';23import { HOOK_ROUTE } from './auth.js';24import { atomicWrite, getCore, shQuote } from './core-util.js';25import { agentHooksRequested } from './config.js';26import { defaultCodexConfigPath, defaultNotifyScriptPath, installCodexNotify } from './codex-notify.js';2728export const HOOK_EVENTS = ['Notification', 'Stop', 'UserPromptSubmit', 'SessionStart'] as const;29const MESSAGE_MAX_CHARS = 200;30const EVENT_RE = /^[A-Za-z]{1,64}$/;3132const ExplicitBody = z.object({33  sessionId: z.string().uuid(),34  event: z.string().regex(EVENT_RE),35  conversationId: z.string().max(200).nullish(),36  message: z.string().nullish(),37});38/** Raw Claude Code hook input (only the fields we use; everything else is ignored, never stored). */39const RawHookBody = z.object({40  hook_event_name: z.string().regex(EVENT_RE),41  session_id: z.string().max(200).nullish(),42  message: z.unknown().optional(),43});4445/**46 * Codex `notify` payload (forwarded verbatim by `bin/cr-codex-notify`). Only `type` and47 * `thread-id` are read; the prompt and the assistant message it also carries are never kept.48 */49const CodexNotifyBody = z.object({50  type: z.string().max(64),51  'thread-id': z.string().max(200).nullish(),52});53/** Codex notify `type` → hook event name understood by the server. */54export const CODEX_NOTIFY_EVENTS: Record<string, string> = { 'agent-turn-complete': 'Stop' };55/** Event name reported for notify types the server has no use for (it ignores unknown events). */56export const CODEX_NOTIFY_OTHER = 'CodexNotify';5758export interface ParsedHook {59  id: string;60  event: string;61  conversationId: string | null;62  message: string | null;63}6465export function parseHookRequest(body: unknown, sessionHeader: string | undefined): ParsedHook {66  if (typeof body !== 'object' || body === null) throw new RtError(400, 'bad_request', 'JSON object expected');67  let parsed: ParsedHook;68  if ('sessionId' in body) {69    const b = ExplicitBody.parse(body);70    parsed = { id: b.sessionId, event: b.event, conversationId: b.conversationId ?? null, message: b.message ?? null };71  } else {72    const id = z.string().uuid().safeParse(sessionHeader);73    if (!id.success) throw new RtError(400, 'bad_request', 'missing or invalid X-CR-Session header');74    if (!('hook_event_name' in body) && 'type' in body) {75      const n = CodexNotifyBody.parse(body);76      return { id: id.data, event: CODEX_NOTIFY_EVENTS[n.type] ?? CODEX_NOTIFY_OTHER, conversationId: n['thread-id'] ?? null, message: null };77    }78    const b = RawHookBody.parse(body);79    const message = b.hook_event_name === 'Notification' && typeof b.message === 'string' ? b.message : null;80    parsed = { id: id.data, event: b.hook_event_name, conversationId: b.session_id ?? null, message };81  }82  if (parsed.message !== null) parsed.message = parsed.message.slice(0, MESSAGE_MAX_CHARS);83  return parsed;84}8586// ------------------------------------------------------------------ settings.json merge8788type Json = null | boolean | number | string | Json[] | { [k: string]: Json };89type JsonObject = { [k: string]: Json };90const isObject = (v: Json | undefined): v is JsonObject => typeof v === 'object' && v !== null && !Array.isArray(v);9192/** A hook command is ours when it invokes a file named `cr-hook` (whatever its directory). */93function isCrHookCommand(h: Json): boolean {94  return isObject(h) && typeof h.command === 'string' && /(^|[/'"\s])cr-hook(['"\s]|$)/.test(h.command);95}9697async function readSettings(settingsPath: string): Promise<{ settings: JsonObject; mode: number; raw: string | null }> {98  let raw: string;99  try {100    raw = await fs.readFile(settingsPath, 'utf8');101  } catch (err) {102    if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { settings: {}, mode: 0o600, raw: null };103    throw err;104  }105  // An unparsable file is left alone (throwing) rather than overwritten.106  const parsed = (raw.trim() === '' ? {} : JSON.parse(raw)) as Json;107  if (!isObject(parsed)) throw new Error(`${settingsPath} is not a JSON object`);108  return { settings: parsed, mode: (await fs.stat(settingsPath)).mode & 0o777, raw };109}110111/** Removes our hook commands; drops groups / events that become empty because of it. */112function stripCrHooks(settings: JsonObject): void {113  const hooks = settings.hooks;114  if (!isObject(hooks)) return;115  for (const event of Object.keys(hooks)) {116    const groups = hooks[event];117    if (!Array.isArray(groups)) continue;118    const kept: Json[] = [];119    for (const group of groups) {120      if (!isObject(group) || !Array.isArray(group.hooks) || !group.hooks.some(isCrHookCommand)) {121        kept.push(group);122        continue;123      }124      const rest = group.hooks.filter((h) => !isCrHookCommand(h));125      if (rest.length > 0) kept.push({ ...group, hooks: rest });126    }127    if (kept.length > 0) hooks[event] = kept;128    else delete hooks[event];129  }130  if (Object.keys(hooks).length === 0) delete settings.hooks;131}132133async function writeIfChanged(settingsPath: string, settings: JsonObject, raw: string | null, mode: number): Promise<boolean> {134  const next = JSON.stringify(settings, null, 2) + '\n';135  if (raw !== null && JSON.stringify(JSON.parse(raw.trim() === '' ? '{}' : raw)) === JSON.stringify(settings)) return false;136  if (raw === null && Object.keys(settings).length === 0) return false;137  await fs.mkdir(path.dirname(settingsPath), { recursive: true });138  await atomicWrite(settingsPath, next, mode);139  return true;140}141142/**143 * Idempotently registers `hookScriptPath` for Notification, Stop, UserPromptSubmit and144 * SessionStart. Every existing key and hook is preserved; a previous cr-hook entry (even with145 * another path) is replaced, never duplicated. Returns whether the file was modified.146 */147export async function installClaudeHooks(settingsPath: string, hookScriptPath: string): Promise<boolean> {148  if (!path.isAbsolute(hookScriptPath) || path.basename(hookScriptPath) !== 'cr-hook') {149    throw new Error('hookScriptPath must be an absolute path to a file named cr-hook');150  }151  const { settings, mode, raw } = await readSettings(settingsPath);152  stripCrHooks(settings);153  const hooks: JsonObject = isObject(settings.hooks) ? settings.hooks : {};154  settings.hooks = hooks;155  for (const event of HOOK_EVENTS) {156    const groups = hooks[event];157    const list: Json[] = Array.isArray(groups) ? groups : [];158    list.push({ hooks: [{ type: 'command', command: shQuote(hookScriptPath), timeout: 5 }] });159    hooks[event] = list;160  }161  return writeIfChanged(settingsPath, settings, raw, mode);162}163164export async function uninstallClaudeHooks(settingsPath: string): Promise<boolean> {165  const { settings, mode, raw } = await readSettings(settingsPath);166  if (raw === null) return false;167  stripCrHooks(settings);168  return writeIfChanged(settingsPath, settings, raw, mode);169}170171export function defaultHookScriptPath(): string {172  return fileURLToPath(new URL('../bin/cr-hook', import.meta.url)); // same relative place from src/ and dist/173}174175export function defaultClaudeSettingsPath(env: NodeJS.ProcessEnv = process.env): string {176  return path.join(env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude'), 'settings.json');177}178179const hooksPlugin: RuntimePlugin = async (app) => {180  const core = getCore(app);181182  // Installation is explicit (production only): CR_INSTALL_AGENT_HOOKS=1, or the historical183  // CR_INSTALL_CLAUDE_HOOKS=1 for the claude profile. The outcome is reported by GET /health.184  const agent = core.config.agent;185  if (!agentHooksRequested(agent)) {186    core.agentHooks = { requested: false, status: 'not_requested', detail: null };187  } else if (agent === 'codex') {188    const configPath = defaultCodexConfigPath();189    try {190      const r = await installCodexNotify(configPath, defaultNotifyScriptPath());191      core.agentHooks =192        r.status === 'conflict'193          ? { requested: true, status: 'conflict', detail: 'another notify program is already configured in the Codex config.toml; it was left untouched (no WAITING_FOR_INPUT signal)' }194          : { requested: true, status: 'installed', detail: null };195      app.log[r.status === 'conflict' ? 'warn' : 'info']({ configPath, status: r.status, changed: r.changed }, 'codex notify installation');196    } catch (err) {197      core.agentHooks = { requested: true, status: 'error', detail: 'the Codex config.toml could not be updated' };198      app.log.error({ err, configPath }, 'codex notify installation failed');199    }200  } else {201    const settingsPath = defaultClaudeSettingsPath();202    const changed = await installClaudeHooks(settingsPath, defaultHookScriptPath());203    core.agentHooks = { requested: true, status: 'installed', detail: null };204    app.log.info({ settingsPath, changed }, 'claude hooks installed');205  }206207  app.post(HOOK_ROUTE, { bodyLimit: 4 * 1024 * 1024 }, async (req) => {208    const header = req.headers['x-cr-session'];209    const hook = parseHookRequest(req.body, typeof header === 'string' ? header : undefined);210    core.bus.emit({ type: 'claude.hook', ...hook });211    return { ok: true };212  });213};214215export default hooksPlugin;216
Files main
Menu

Theme