feat: initial commit for omp-hooks plugin
This commit is contained in:
commit
a0d4230537
6 files changed
+624
No files matched your search
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* @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,
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user