/** * vWhale: lifecycle hook runner. Hooks are shell commands that fire at key * points in the inference pipeline. Each hook receives a JSON payload on * stdin and can return JSON on stdout to influence behavior. * * Inspired by Whale's hook system with 11 lifecycle events. BooCode * implements the most relevant subset: PreToolUse, PostToolUse, * UserPromptSubmit, Stop, PreCompact, PostCompact. * * Config: JSON file at HOOKS_CONFIG_PATH (default /data/hooks.json). * Format: * ```json * { * "hooks": { * "PreToolUse": [ * { "match": "shell_run", "command": "python3 /data/hooks/check_shell.py", "timeout": 30 } * ], * "Stop": [ * { "command": "node /data/hooks/log_turn.mjs" } * ] * } * } * ``` */ import { spawn } from 'node:child_process'; import { readFileSync, existsSync } from 'node:fs'; import type { FastifyBaseLogger } from 'fastify'; // ─── Events ─────────────────────────────────────────────────────────────── export type HookEvent = | 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'Stop' | 'PreCompact' | 'PostCompact'; const ALL_EVENTS: HookEvent[] = [ 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'PreCompact', 'PostCompact', ]; // ─── Config ──────────────────────────────────────────────────────────────── export interface HookConfig { /** Glob or exact tool name to match (PreToolUse/PostToolUse only). Omit or '*' for all. */ match?: string; /** Shell command to run. Receives JSON payload on stdin. */ command: string; /** Timeout in seconds (default 30). */ timeout?: number; } export interface HooksConfig { hooks: Partial>; } // ─── Payloads ────────────────────────────────────────────────────────────── export interface PreToolUsePayload { event: 'PreToolUse'; session_id: string; tool_name: string; tool_args: Record; } export interface PostToolUsePayload { event: 'PostToolUse'; session_id: string; tool_name: string; tool_args: Record; tool_result: unknown; tool_error?: string; } export interface UserPromptSubmitPayload { event: 'UserPromptSubmit'; session_id: string; chat_id: string; prompt: string; } export interface StopPayload { event: 'Stop'; session_id: string; chat_id: string; last_assistant_text: string; turn: number; } export interface PreCompactPayload { event: 'PreCompact'; session_id: string; chat_id: string; messages_before: number; } export interface PostCompactPayload { event: 'PostCompact'; session_id: string; chat_id: string; messages_before: number; messages_after: number; summary: string; } export type HookPayload = | PreToolUsePayload | PostToolUsePayload | UserPromptSubmitPayload | StopPayload | PreCompactPayload | PostCompactPayload; // ─── Response ────────────────────────────────────────────────────────────── export type HookDecision = 'pass' | 'warn' | 'block'; export interface HookResponse { decision?: HookDecision; reason?: string; /** When present, replaces the original tool args / user prompt. */ updated_input?: Record | string; /** Injected into the model's context for the next turn. */ additional_context?: string; } // ─── Runner ──────────────────────────────────────────────────────────────── export interface HookRunner { /** Run all hooks for the given event. Returns the effective response. */ run(event: HookEvent, payload: HookPayload, log?: FastifyBaseLogger): Promise; } let hooksConfig: HooksConfig | null = null; let hooksPath: string | null = null; /** Load hooks config from disk. Missing file = no hooks. Never throws. */ export function loadHooksConfig(path: string): HooksConfig { hooksPath = path; if (!existsSync(path)) { hooksConfig = { hooks: {} }; return hooksConfig; } try { const raw = readFileSync(path, 'utf8'); const parsed = JSON.parse(raw) as HooksConfig; hooksConfig = { hooks: { ...parsed.hooks }, }; // Validate event names for (const event of Object.keys(hooksConfig.hooks)) { if (!ALL_EVENTS.includes(event as HookEvent)) { console.warn(`hooks: unknown event '${event}' in ${path} — ignoring`); delete hooksConfig.hooks[event as HookEvent]; } } } catch (err) { console.error(`hooks: failed to load ${path}`, err); hooksConfig = { hooks: {} }; } return hooksConfig; } /** Reload the config file (call after a PATCH). */ export function reloadHooksConfig(): HooksConfig { if (hooksPath) return loadHooksConfig(hooksPath); hooksConfig = { hooks: {} }; return hooksConfig; } function getConfig(): HooksConfig { return hooksConfig ?? { hooks: {} }; } /** Create a HookRunner for the current config. */ export function createHookRunner(): HookRunner { return { async run(event, payload, log): Promise { const configs = getConfig().hooks[event]; if (!configs || configs.length === 0) return { decision: 'pass' }; // Pre-filter by match pattern for tool events const toolName = 'tool_name' in payload ? (payload as PreToolUsePayload).tool_name : undefined; let effective: HookResponse = { decision: 'pass' }; for (const cfg of configs) { // Skip if match doesn't apply if (toolName && cfg.match && cfg.match !== '*' && cfg.match !== toolName) continue; const result = await runSingleHook(cfg, payload, log); // Merge decisions: block > warn > pass if (result.decision === 'block') { effective = { ...result, decision: 'block' }; break; // block is terminal } if (result.decision === 'warn' && effective.decision !== 'block') { effective = { ...result, decision: 'warn' }; } // Merge additional_context and updated_input if (result.additional_context) { effective.additional_context = effective.additional_context ? effective.additional_context + '\n' + result.additional_context : result.additional_context; } if (result.updated_input && !effective.updated_input) { effective.updated_input = result.updated_input; } } return effective; }, }; } async function runSingleHook( cfg: HookConfig, payload: HookPayload, log?: FastifyBaseLogger, ): Promise { const timeoutMs = (cfg.timeout ?? 30) * 1000; return new Promise((resolve) => { const child = spawn('sh', ['-c', cfg.command], { stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs, env: { ...process.env }, }); const stdout: Buffer[] = []; const stderr: Buffer[] = []; child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; child.kill('SIGTERM'); log?.warn({ event: payload.event, command: cfg.command }, 'hooks: timeout'); resolve({ decision: 'warn', reason: 'hook timed out' }); } }, timeoutMs); child.on('error', (err) => { if (!settled) { settled = true; clearTimeout(timer); log?.warn({ err, event: payload.event }, 'hooks: spawn error'); resolve({ decision: 'warn', reason: `hook failed: ${err.message}` }); } }); child.on('close', (code) => { if (settled) return; settled = true; clearTimeout(timer); const out = Buffer.concat(stdout).toString('utf8').trim(); const errOut = Buffer.concat(stderr).toString('utf8').trim(); if (code !== 0 && !out) { log?.warn({ event: payload.event, code, stderr: errOut.slice(0, 200) }, 'hooks: non-zero exit'); resolve({ decision: 'warn', reason: `hook exited ${code}` }); return; } // Parse stdout as JSON response if (out) { try { const parsed = JSON.parse(out) as HookResponse; resolve(parsed); return; } catch { // Not JSON — treat as pass with stdout as context if (out.length > 0) { resolve({ decision: 'pass', additional_context: out }); return; } } } resolve({ decision: 'pass' }); }); // Write payload to stdin const json = JSON.stringify(payload); child.stdin.write(json); child.stdin.end(); }); }