/** * @local/omp-hooks — Project-level & session-level lifecycle hooks for DSH. * Full compatibility with Claude Code .claude/settings.json, .claude/hooks.json, * and project-level hooks. * * Interception seams: * - SessionStart -> agent/created (injects initial prompt context) * - UserPromptSubmit -> agent/pre-step (can block prompt or attach context) * - PreToolUse -> tools/pre-execute (can deny or ask approval before execution) * - PostToolUse -> tools/post-execute (can block result or attach context) * - Stop -> agent/turn-stopping (can steer agent to force continuation) */ import fs from 'node:fs' import path from 'node:path' import { spawn } from 'node:child_process' import { createUserMessage } from './lib/message.js' export const name = 'omp-hooks' export const inject = ['tools'] const CLAUDE_EVENTS = [ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop', ] const CONTEXT_SOURCE = { kind: 'omp-hooks' } function asObject(val) { return typeof val === 'object' && val !== null && !Array.isArray(val) ? val : undefined } export function substituteCommand(command, vars = {}) { let out = command if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot) if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir) return out } export function parseClaudeConfig(raw, vars = {}) { const config = {} const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined if (!hooksMap) return config for (const event of CLAUDE_EVENTS) { const rawGroups = hooksMap[event] if (!Array.isArray(rawGroups)) continue const groups = [] for (const rawGroup of rawGroups) { const group = asObject(rawGroup) if (!group || !Array.isArray(group.hooks)) continue const commands = [] for (const rawHook of group.hooks) { const hook = asObject(rawHook) if (!hook) continue const type = typeof hook.type === 'string' ? hook.type : 'command' if (type !== 'command') continue if (typeof hook.command !== 'string') continue commands.push({ command: substituteCommand(hook.command, vars), timeoutSec: typeof hook.timeout === 'number' ? hook.timeout : 600, }) } if (commands.length === 0) continue const matcher = (event === 'UserPromptSubmit' || event === 'Stop') ? undefined : (typeof group.matcher === 'string' ? group.matcher : undefined) groups.push({ matcher, hooks: commands, }) } if (groups.length > 0) config[event] = groups } return config } /** * Discovers hook configuration in order of precedence: * 1. .claude/settings.local.json * 2. .claude/settings.json * 3. .claude/hooks.json * 4. hooks.json */ export function findProjectHookConfig(projectDir) { if (!projectDir || typeof projectDir !== 'string') return null const candidates = [ path.join(projectDir, '.claude', 'settings.local.json'), path.join(projectDir, '.claude', 'settings.json'), path.join(projectDir, '.claude', 'hooks.json'), path.join(projectDir, 'hooks.json'), ] for (const candidate of candidates) { try { if (fs.existsSync(candidate)) { const text = fs.readFileSync(candidate, 'utf8') const raw = JSON.parse(text) return parseClaudeConfig(raw, { projectDir }) } } catch {} } return null } export function matchesPattern(pattern, subject) { if (!pattern || pattern === '*' || pattern === '') return true if (pattern === subject) return true try { const rx = new RegExp(`^(${pattern})$`) return rx.test(subject) } catch { return false } } /** * Execute a hook command with JSON payload on stdin */ export async function executeHookCommand(cmdStr, payload, { cwd, timeoutSec = 600, signal }) { return new Promise((resolve) => { let resolved = false const done = (res) => { if (resolved) return resolved = true resolve(res) } const timer = setTimeout(() => { done({ exitCode: 124, stdout: '', stderr: 'Hook timed out' }) try { proc.kill('SIGTERM') } catch {} }, timeoutSec * 1000) const env = { ...process.env, CLAUDE_PROJECT_DIR: cwd || process.cwd(), } const proc = spawn('bash', ['-c', cmdStr], { cwd: cwd || process.cwd(), env, stdio: ['pipe', 'pipe', 'pipe'], }) if (signal) { signal.addEventListener('abort', () => { clearTimeout(timer) try { proc.kill('SIGKILL') } catch {} done({ exitCode: 130, stdout: '', stderr: 'Operation cancelled' }) }, { once: true }) } let stdout = '' let stderr = '' proc.stdout.on('data', (d) => { stdout += d.toString('utf8') }) proc.stderr.on('data', (d) => { stderr += d.toString('utf8') }) proc.on('error', (err) => { clearTimeout(timer) done({ exitCode: 1, stdout: '', stderr: String(err?.message ?? err) }) }) proc.on('close', (code) => { clearTimeout(timer) done({ exitCode: code ?? 0, stdout, stderr }) }) try { proc.stdin.write(JSON.stringify(payload) + '\n') proc.stdin.end() } catch {} }) } export function parseHookStdout(stdout, exitCode, stderr) { let decision = 'allow' // allow | deny | ask let reason = undefined let additionalContext = [] // Check exit code conventions: // Exit 2 = Deny / Block // Exit 0 = Allow if (exitCode === 2) { decision = 'deny' reason = stderr.trim() || 'Blocked by lifecycle hook (exit code 2)' } // Parse stdout JSON if available const trimmed = stdout.trim() if (trimmed) { try { const data = JSON.parse(trimmed) if (data && typeof data === 'object') { if (data.decision === 'deny' || data.blocked === true) { decision = 'deny' reason = data.reason || reason || 'Blocked by lifecycle hook' } else if (data.decision === 'ask') { decision = 'ask' reason = data.reason } if (typeof data.reason === 'string' && decision === 'deny') { reason = data.reason } if (typeof data.context === 'string') { additionalContext.push(data.context) } else if (Array.isArray(data.context)) { additionalContext.push(...data.context.filter((c) => typeof c === 'string')) } if (typeof data.additionalContext === 'string') { additionalContext.push(data.additionalContext) } else if (Array.isArray(data.additionalContext)) { additionalContext.push(...data.additionalContext.filter((c) => typeof c === 'string')) } } } catch { // stdout was non-JSON text; if exitCode is 0 and it's non-empty, could be context if (exitCode === 0 && trimmed.length > 0 && !trimmed.startsWith('{')) { additionalContext.push(trimmed) } } } return { decision, reason, additionalContext } } export function apply(ctx, config = {}) { const cfg = { enabled: true, ...config, } if (!cfg.enabled) return // Cache configs per cwd with 5s expiry const configCache = new Map() function getConfigForDir(dir) { const cwd = dir || process.cwd() const now = Date.now() const hit = configCache.get(cwd) if (hit && now - hit.time < 5000) return hit.config const conf = findProjectHookConfig(cwd) configCache.set(cwd, { time: now, config: conf }) return conf } function cwdOf(entity) { return entity?.session?.header?.cwd || entity?.cwd || process.cwd() } async function runHooks(point, matchQuery, payload, cwd, signal) { const conf = getConfigForDir(cwd) if (!conf) return { decision: 'allow', additionalContext: [] } const groups = conf[point] ?? [] let finalDecision = 'allow' let finalReason = undefined const allContext = [] for (const group of groups) { if (!matchesPattern(group.matcher, matchQuery)) continue for (const hook of group.hooks) { const res = await executeHookCommand(hook.command, payload, { cwd, timeoutSec: hook.timeoutSec, signal, }) const parsed = parseHookStdout(res.stdout, res.exitCode, res.stderr) if (parsed.decision === 'deny') { finalDecision = 'deny' finalReason = parsed.reason || res.stderr.trim() || 'Blocked by hook' } else if (parsed.decision === 'ask' && finalDecision !== 'deny') { finalDecision = 'ask' finalReason = parsed.reason } if (parsed.additionalContext.length > 0) { allContext.push(...parsed.additionalContext) } } } return { decision: finalDecision, reason: finalReason, additionalContext: allContext } } // 1. SessionStart ctx.on('agent/created', async ({ agent, source, signal }) => { const cwd = cwdOf(agent) const payload = { session_id: agent?.id, source: source || 'user', cwd, } const { additionalContext } = await runHooks('SessionStart', source || '', payload, cwd, signal) if (additionalContext.length > 0 && agent?.inject) { agent.inject(createUserMessage({ content: additionalContext.map((text) => ({ type: 'text', text })), source: CONTEXT_SOURCE, })) } }) // 2. UserPromptSubmit ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => { if (!messages || messages.length === 0) return next() const cwd = cwdOf(agent) const promptText = messages.map((m) => { if (typeof m.content === 'string') return m.content if (Array.isArray(m.content)) { return m.content.map((c) => c.text || '').join('\n') } return '' }).join('\n') const payload = { session_id: agent?.id, prompt: promptText, } const { decision, reason, additionalContext } = await runHooks('UserPromptSubmit', '', payload, cwd, signal) if (decision === 'deny') { return { kind: 'deny', reason: reason || 'Prompt rejected by UserPromptSubmit hook' } } const downstream = await next() if (additionalContext.length > 0 && downstream?.kind === 'enter') { const extraMsg = createUserMessage({ content: additionalContext.map((text) => ({ type: 'text', text })), source: CONTEXT_SOURCE, }) return { ...downstream, messages: [...(downstream.messages || []), extraMsg], } } return downstream }) // 3. PreToolUse ctx.on('tools/pre-execute', async (exec, next) => { const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd() const payload = { session_id: exec.agent?.id, tool_name: exec.name, tool_input: exec.arguments ?? {}, } const { decision, reason } = await runHooks('PreToolUse', exec.name, payload, cwd, exec.signal) if (decision === 'deny') { return { kind: 'deny', reason: reason || `Blocked by PreToolUse hook for ${exec.name}`, } } if (decision === 'ask') { return { kind: 'ask', reason: reason || `Approval requested by PreToolUse hook for ${exec.name}`, } } return next() }) // 4. PostToolUse ctx.on('tools/post-execute', async (exec, result, next) => { const cwd = exec.agent ? cwdOf(exec.agent) : process.cwd() const payload = { session_id: exec.agent?.id, tool_name: exec.name, tool_input: exec.arguments ?? {}, tool_result: result?.content ?? result ?? null, is_error: !!result?.isError, } const { decision, reason, additionalContext } = await runHooks('PostToolUse', exec.name, payload, cwd, exec.signal) if (decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: reason || `Blocked by PostToolUse hook for ${exec.name}` }], ...(additionalContext.length > 0 ? { additionalContexts: [createUserMessage({ content: additionalContext.map((t) => ({ type: 'text', text: t })), source: CONTEXT_SOURCE, })], } : {}), } } const downstream = await next() if (additionalContext.length > 0) { const extra = createUserMessage({ content: additionalContext.map((t) => ({ type: 'text', text: t })), source: CONTEXT_SOURCE, }) return { ...downstream, additionalContexts: [extra, ...(downstream?.additionalContexts || [])], } } return downstream }) // 5. Stop ctx.on('agent/turn-stopping', async ({ agent, turn, signal }) => { const cwd = cwdOf(agent) const payload = { session_id: agent?.id, turn, } const { decision, reason } = await runHooks('Stop', '', payload, cwd, signal) if (decision === 'deny') { const text = reason || 'Continue: blocked by Stop hook' agent?.steer?.(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE, })) } }) }