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 { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript" import type { PluginConfig } from "./types" import { log, isHookDisabled } from "../../shared" import { injectHookMessage } from "../../features/hook-message-injector" import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector" const sessionFirstMessageProcessed = new Set() const sessionErrorState = new Map() const sessionInterruptState = new Map() export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) { 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") recordUserMessage(input.sessionID, 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 } const keywordMessages: string[] = [] if (!config.keywordDetectorDisabled) { const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent) keywordMessages.push(...detectedKeywords.map((k) => k.message)) if (keywordMessages.length > 0) { log("[claude-code-hooks] Detected keywords", { sessionID: input.sessionID, keywordCount: keywordMessages.length, types: detectedKeywords.map((k) => k.type), }) } } const allMessages = [...keywordMessages, ...result.messages] if (allMessages.length > 0) { const hookContent = allMessages.join("\n\n") log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage }) if (isFirstMessage) { const idx = output.parts.findIndex((p) => p.type === "text" && p.text) if (idx >= 0) { output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}` log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID }) } } else { const message = output.message as { agent?: string model?: { modelID?: string; providerID?: string } path?: { cwd?: string; root?: string } tools?: Record } const success = injectHookMessage(input.sessionID, hookContent, { agent: message.agent, model: message.model, path: message.path ?? { cwd: ctx.directory, root: "/" }, tools: message.tools, }) log(success ? "Hook message injected via file system" : "File injection failed", { sessionID: input.sessionID, }) } } } }, "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string }, output: { args: Record } ): Promise => { const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() recordToolUse(input.sessionID, input.tool, 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: `✗ ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${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 => { 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, background_task, 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 } recordToolResult(input.sessionID, input.tool, cachedInput, 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) } }, } }