Files

424 lines
13 KiB
JavaScript

/**
* @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,
}))
}
})
}