Add type: "http" hook support matching Claude Code's HTTP hook specification. HTTP hooks send POST requests with JSON body, support env var interpolation in headers via allowedEnvVars, and configurable timeout. New files: - execute-http-hook.ts: HTTP hook execution with env var interpolation - dispatch-hook.ts: Unified dispatcher for command and HTTP hooks - execute-http-hook.test.ts: 14 tests covering all HTTP hook scenarios Modified files: - types.ts: Added HookHttp interface, HookAction union type - config.ts: Updated to accept HookAction in raw hook matchers - pre-tool-use/post-tool-use/stop/user-prompt-submit/pre-compact: Updated all 5 executors to dispatch HTTP hooks via dispatchHook() - plugin-loader/types.ts: Added "http" to HookEntry type union
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
import type {
|
|
StopInput,
|
|
StopOutput,
|
|
ClaudeHooksConfig,
|
|
} from "./types"
|
|
import { findMatchingHooks, log } from "../../shared"
|
|
import { dispatchHook } from "./dispatch-hook"
|
|
import { getTodoPath } from "./todo"
|
|
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
|
|
|
// Module-level state to track stop_hook_active per session
|
|
const stopHookActiveState = new Map<string, boolean>()
|
|
|
|
export function setStopHookActive(sessionId: string, active: boolean): void {
|
|
stopHookActiveState.set(sessionId, active)
|
|
}
|
|
|
|
export function getStopHookActive(sessionId: string): boolean {
|
|
return stopHookActiveState.get(sessionId) ?? false
|
|
}
|
|
|
|
export interface StopContext {
|
|
sessionId: string
|
|
parentSessionId?: string
|
|
cwd: string
|
|
transcriptPath?: string
|
|
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
|
stopHookActive?: boolean
|
|
}
|
|
|
|
export interface StopResult {
|
|
block: boolean
|
|
reason?: string
|
|
stopHookActive?: boolean
|
|
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
|
injectPrompt?: string
|
|
}
|
|
|
|
export async function executeStopHooks(
|
|
ctx: StopContext,
|
|
config: ClaudeHooksConfig | null,
|
|
extendedConfig?: PluginExtendedConfig | null
|
|
): Promise<StopResult> {
|
|
if (ctx.parentSessionId) {
|
|
return { block: false }
|
|
}
|
|
|
|
if (!config) {
|
|
return { block: false }
|
|
}
|
|
|
|
const matchers = findMatchingHooks(config, "Stop")
|
|
if (matchers.length === 0) {
|
|
return { block: false }
|
|
}
|
|
|
|
const stdinData: StopInput = {
|
|
session_id: ctx.sessionId,
|
|
transcript_path: ctx.transcriptPath,
|
|
cwd: ctx.cwd,
|
|
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
|
hook_event_name: "Stop",
|
|
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
|
|
todo_path: getTodoPath(ctx.sessionId),
|
|
hook_source: "opencode-plugin",
|
|
}
|
|
|
|
for (const matcher of matchers) {
|
|
if (!matcher.hooks || matcher.hooks.length === 0) continue
|
|
for (const hook of matcher.hooks) {
|
|
if (hook.type !== "command" && hook.type !== "http") continue
|
|
|
|
if (hook.type === "command" && isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
|
|
log("Stop hook command skipped (disabled by config)", { command: hook.command })
|
|
continue
|
|
}
|
|
|
|
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
|
|
|
|
// Check exit code first - exit code 2 means block
|
|
if (result.exitCode === 2) {
|
|
const reason = result.stderr || result.stdout || "Blocked by stop hook"
|
|
return {
|
|
block: true,
|
|
reason,
|
|
injectPrompt: reason,
|
|
}
|
|
}
|
|
|
|
if (result.stdout) {
|
|
try {
|
|
const output = JSON.parse(result.stdout || "{}") as StopOutput
|
|
if (output.stop_hook_active !== undefined) {
|
|
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
|
|
}
|
|
const isBlock = output.decision === "block"
|
|
// Only return early if the hook explicitly blocks - non-blocking hooks
|
|
// should not prevent subsequent hooks from executing (matches Claude Code behavior)
|
|
if (isBlock) {
|
|
const injectPrompt = output.inject_prompt ?? (output.reason || undefined)
|
|
return {
|
|
block: true,
|
|
reason: output.reason,
|
|
stopHookActive: output.stop_hook_active,
|
|
permissionMode: output.permission_mode,
|
|
injectPrompt,
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore JSON parse errors - hook may return non-JSON output
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { block: false }
|
|
}
|