Repository Public
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