import type { PluginInput } from "@opencode-ai/plugin" import { loadClaudeHooksConfig } from "./config" import { loadPluginExtendedConfig } from "./config-loader" import { executePreToolUseHooks, type PreToolUseContext, } from "./pre-tool-use" import { executePostToolUseHooks, type PostToolUseContext, type PostToolUseClient, } from "./post-tool-use" import { executeUserPromptSubmitHooks, type UserPromptSubmitContext, type MessagePart, } from "./user-prompt-submit" import { executeStopHooks, type StopContext, } from "./stop" import { executePreCompactHooks, type PreCompactContext, } from "./pre-compact" import { cacheToolInput, getToolInput } from "./tool-input-cache" import { appendTranscriptEntry, getTranscriptPath } from "./transcript" import type { PluginConfig } from "./types" import { log, isHookDisabled } from "../../shared" import type { ContextCollector } from "../../features/context-injector" const sessionFirstMessageProcessed = new Set() const sessionErrorState = new Map() const sessionInterruptState = new Map() export function createClaudeCodeHooksHook( ctx: PluginInput, config: PluginConfig = {}, contextCollector?: ContextCollector ) { return { "experimental.session.compacting": async ( input: { sessionID: string }, output: { context: string[] } ): Promise => { if (isHookDisabled(config, "PreCompact")) { return } const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() const preCompactCtx: PreCompactContext = { sessionId: input.sessionID, cwd: ctx.directory, } const result = await executePreCompactHooks(preCompactCtx, claudeConfig, extendedConfig) if (result.context.length > 0) { log("PreCompact hooks injecting context", { sessionID: input.sessionID, contextCount: result.context.length, hookName: result.hookName, elapsedMs: result.elapsedMs, }) output.context.push(...result.context) } }, "chat.message": async ( input: { sessionID: string agent?: string model?: { providerID: string; modelID: string } messageID?: string }, output: { message: Record parts: Array<{ type: string; text?: string; [key: string]: unknown }> } ): Promise => { const interruptState = sessionInterruptState.get(input.sessionID) if (interruptState?.interrupted) { log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID }) return } const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() const textParts = output.parts.filter((p) => p.type === "text" && p.text) const prompt = textParts.map((p) => p.text ?? "").join("\n") appendTranscriptEntry(input.sessionID, { type: "user", timestamp: new Date().toISOString(), content: prompt, }) const messageParts: MessagePart[] = textParts.map((p) => ({ type: p.type as "text", text: p.text, })) const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID) if (interruptStateBeforeHooks?.interrupted) { log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID }) return } let parentSessionId: string | undefined try { const sessionInfo = await ctx.client.session.get({ path: { id: input.sessionID }, }) parentSessionId = sessionInfo.data?.parentID } catch {} const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) sessionFirstMessageProcessed.add(input.sessionID) if (!isHookDisabled(config, "UserPromptSubmit")) { const userPromptCtx: UserPromptSubmitContext = { sessionId: input.sessionID, parentSessionId, prompt, parts: messageParts, cwd: ctx.directory, } const result = await executeUserPromptSubmitHooks( userPromptCtx, claudeConfig, extendedConfig ) if (result.block) { throw new Error(result.reason ?? "Hook blocked the prompt") } const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID) if (interruptStateAfterHooks?.interrupted) { log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID }) return } if (result.messages.length > 0) { const hookContent = result.messages.join("\n\n") log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) if (contextCollector) { log("[DEBUG] Registering hook content to contextCollector", { sessionID: input.sessionID, contentLength: hookContent.length, contentPreview: hookContent.slice(0, 100), }) contextCollector.register(input.sessionID, { id: "hook-context", source: "custom", content: hookContent, priority: "high", }) log("Hook content registered for synthetic message injection", { sessionID: input.sessionID, contentLength: hookContent.length, }) } } } }, "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string }, output: { args: Record } ): Promise => { if (input.tool.trim() === "todowrite" && typeof output.args.todos === "string") { let parsed: unknown try { parsed = JSON.parse(output.args.todos) } catch (e) { throw new Error( `[todowrite ERROR] Failed to parse todos string as JSON. ` + `Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` + `Expected: Valid JSON array. Pass todos as an array, not a string.` ) } if (!Array.isArray(parsed)) { throw new Error( `[todowrite ERROR] Parsed JSON is not an array. ` + `Received type: ${typeof parsed}. ` + `Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].` ) } output.args.todos = parsed log("todowrite: parsed todos string to array", { sessionID: input.sessionID }) } const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() appendTranscriptEntry(input.sessionID, { type: "tool_use", timestamp: new Date().toISOString(), tool_name: input.tool, tool_input: output.args as Record, }) cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) if (!isHookDisabled(config, "PreToolUse")) { const preCtx: PreToolUseContext = { sessionId: input.sessionID, toolName: input.tool, toolInput: output.args as Record, cwd: ctx.directory, toolUseId: input.callID, } const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) if (result.decision === "deny") { ctx.client.tui .showToast({ body: { title: "PreToolUse Hook Executed", message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`, variant: "error", duration: 4000, }, }) .catch(() => {}) throw new Error(result.reason ?? "Hook blocked the operation") } if (result.modifiedInput) { Object.assign(output.args as Record, result.modifiedInput) } } }, "tool.execute.after": async ( input: { tool: string; sessionID: string; callID: string }, output: { title: string; output: string; metadata: unknown } ): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) if (!output) { return } const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} // Use metadata if available and non-empty, otherwise wrap output.output in a structured object // This ensures plugin tools (call_omo_agent, task) that return strings // get their results properly recorded in transcripts instead of empty {} const metadata = output.metadata as Record | undefined const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 const toolOutput = hasMetadata ? metadata : { output: output.output } appendTranscriptEntry(input.sessionID, { type: "tool_result", timestamp: new Date().toISOString(), tool_name: input.tool, tool_input: cachedInput, tool_output: toolOutput, }) if (!isHookDisabled(config, "PostToolUse")) { const postClient: PostToolUseClient = { session: { messages: (opts) => ctx.client.session.messages(opts), }, } const postCtx: PostToolUseContext = { sessionId: input.sessionID, toolName: input.tool, toolInput: cachedInput, toolOutput: { title: input.tool, output: output.output, metadata: output.metadata as Record, }, cwd: ctx.directory, transcriptPath: getTranscriptPath(input.sessionID), toolUseId: input.callID, client: postClient, permissionMode: "bypassPermissions", } const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) if (result.block) { ctx.client.tui .showToast({ body: { title: "PostToolUse Hook Warning", message: result.reason ?? "Hook returned warning", variant: "warning", duration: 4000, }, }) .catch(() => {}) } if (result.warnings && result.warnings.length > 0) { output.output = `${output.output}\n\n${result.warnings.join("\n")}` } if (result.message) { output.output = `${output.output}\n\n${result.message}` } if (result.hookName) { ctx.client.tui .showToast({ body: { title: "PostToolUse Hook Executed", message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`, variant: "success", duration: 2000, }, }) .catch(() => {}) } } }, event: async (input: { event: { type: string; properties?: unknown } }) => { const { event } = input if (event.type === "session.error") { const props = event.properties as Record | undefined const sessionID = props?.sessionID as string | undefined if (sessionID) { sessionErrorState.set(sessionID, { hasError: true, errorMessage: String(props?.error ?? "Unknown error"), }) } return } if (event.type === "session.deleted") { const props = event.properties as Record | undefined const sessionInfo = props?.info as { id?: string } | undefined if (sessionInfo?.id) { sessionErrorState.delete(sessionInfo.id) sessionInterruptState.delete(sessionInfo.id) sessionFirstMessageProcessed.delete(sessionInfo.id) } return } if (event.type === "session.idle") { const props = event.properties as Record | undefined const sessionID = props?.sessionID as string | undefined if (!sessionID) return const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() const errorStateBefore = sessionErrorState.get(sessionID) const endedWithErrorBefore = errorStateBefore?.hasError === true const interruptStateBefore = sessionInterruptState.get(sessionID) const interruptedBefore = interruptStateBefore?.interrupted === true let parentSessionId: string | undefined try { const sessionInfo = await ctx.client.session.get({ path: { id: sessionID }, }) parentSessionId = sessionInfo.data?.parentID } catch {} if (!isHookDisabled(config, "Stop")) { const stopCtx: StopContext = { sessionId: sessionID, parentSessionId, cwd: ctx.directory, } const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) const errorStateAfter = sessionErrorState.get(sessionID) const endedWithErrorAfter = errorStateAfter?.hasError === true const interruptStateAfter = sessionInterruptState.get(sessionID) const interruptedAfter = interruptStateAfter?.interrupted === true const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter if (shouldBypass && stopResult.block) { const interrupted = interruptedBefore || interruptedAfter const endedWithError = endedWithErrorBefore || endedWithErrorAfter log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError }) } else if (stopResult.block && stopResult.injectPrompt) { log("Stop hook returned block with inject_prompt", { sessionID }) ctx.client.session .prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: stopResult.injectPrompt }] }, query: { directory: ctx.directory }, }) .catch((err: unknown) => log("Failed to inject prompt from Stop hook", err)) } else if (stopResult.block) { log("Stop hook returned block", { sessionID, reason: stopResult.reason }) } } sessionErrorState.delete(sessionID) sessionInterruptState.delete(sessionID) } }, } }