From d3a3f0c3a64a2f88a063bffca6132883764e1b43 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Feb 2026 16:22:17 +0900 Subject: [PATCH] refactor(claude-code-hooks): extract handlers and session state Split hook into per-concern modules: - handlers/ directory for individual hook handlers - session-hook-state.ts: session-level hook state management --- .../claude-code-hooks-hook.ts | 419 +----------------- .../handlers/chat-message-handler.ts | 140 ++++++ .../handlers/pre-compact-handler.ts | 41 ++ .../handlers/session-event-handler.ts | 111 +++++ .../handlers/tool-execute-after-handler.ts | 105 +++++ .../handlers/tool-execute-before-handler.ts | 92 ++++ .../claude-code-hooks/session-hook-state.ts | 11 + 7 files changed, 510 insertions(+), 409 deletions(-) create mode 100644 src/hooks/claude-code-hooks/handlers/chat-message-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/session-event-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts create mode 100644 src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts create mode 100644 src/hooks/claude-code-hooks/session-hook-state.ts diff --git a/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts index de8bc9c47..b4c2a3124 100644 --- a/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts +++ b/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts @@ -1,37 +1,11 @@ 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() +import { createChatMessageHandler } from "./handlers/chat-message-handler" +import { createPreCompactHandler } from "./handlers/pre-compact-handler" +import { createSessionEventHandler } from "./handlers/session-event-handler" +import { createToolExecuteAfterHandler } from "./handlers/tool-execute-after-handler" +import { createToolExecuteBeforeHandler } from "./handlers/tool-execute-before-handler" export function createClaudeCodeHooksHook( ctx: PluginInput, @@ -39,383 +13,10 @@ export function createClaudeCodeHooksHook( 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" as const, - 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) - } - }, + "experimental.session.compacting": createPreCompactHandler(ctx, config), + "chat.message": createChatMessageHandler(ctx, config, contextCollector), + "tool.execute.before": createToolExecuteBeforeHandler(ctx, config), + "tool.execute.after": createToolExecuteAfterHandler(ctx, config), + event: createSessionEventHandler(ctx, config), } } diff --git a/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts b/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts new file mode 100644 index 000000000..927e6b96d --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts @@ -0,0 +1,140 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { + executeUserPromptSubmitHooks, + type MessagePart, + type UserPromptSubmitContext, +} from "../user-prompt-submit" +import type { PluginConfig } from "../types" +import type { ContextCollector } from "../../../features/context-injector" +import { isHookDisabled, log } from "../../../shared" +import { appendTranscriptEntry } from "../transcript" +import { sessionFirstMessageProcessed, sessionInterruptState } from "../session-hook-state" + +export function createChatMessageHandler( + ctx: PluginInput, + config: PluginConfig, + contextCollector?: ContextCollector, +) { + return 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: "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 { + parentSessionId = undefined + } + + const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) + sessionFirstMessageProcessed.add(input.sessionID) + + if (isHookDisabled(config, "UserPromptSubmit")) { + return + } + + 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) { + return + } + + 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) { + return + } + + 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, + }) + } +} diff --git a/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts b/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts new file mode 100644 index 000000000..832d053dd --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts @@ -0,0 +1,41 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { executePreCompactHooks, type PreCompactContext } from "../pre-compact" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" + +export function createPreCompactHandler(ctx: PluginInput, config: PluginConfig) { + return 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) + } + } +} diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts new file mode 100644 index 000000000..6c6d3a584 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -0,0 +1,111 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { executeStopHooks, type StopContext } from "../stop" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" +import { + clearSessionHookState, + sessionErrorState, + sessionInterruptState, +} from "../session-hook-state" + +export function createSessionEventHandler(ctx: PluginInput, config: PluginConfig) { + return 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) { + clearSessionHookState(sessionInfo.id) + } + return + } + + if (event.type !== "session.idle") { + return + } + + 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 { + parentSessionId = undefined + } + + 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) { + log("Stop hook block ignored", { + sessionID, + block: stopResult.block, + interrupted: interruptedBefore || interruptedAfter, + endedWithError: endedWithErrorBefore || endedWithErrorAfter, + }) + } 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", { error: String(err) }), + ) + } else if (stopResult.block) { + log("Stop hook returned block", { sessionID, reason: stopResult.reason }) + } + } + + clearSessionHookState(sessionID) + } +} diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts new file mode 100644 index 000000000..0dc934ea2 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts @@ -0,0 +1,105 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { loadClaudeHooksConfig } from "../config" +import { loadPluginExtendedConfig } from "../config-loader" +import { + executePostToolUseHooks, + type PostToolUseClient, + type PostToolUseContext, +} from "../post-tool-use" +import { getToolInput } from "../tool-input-cache" +import { appendTranscriptEntry, getTranscriptPath } from "../transcript" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" + +export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) { + return async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } | undefined, + ): Promise => { + if (!output) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + + 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")) { + return + } + + 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(() => {}) + } + } +} diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts new file mode 100644 index 000000000..164c52682 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts @@ -0,0 +1,92 @@ +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 { appendTranscriptEntry } from "../transcript" +import { cacheToolInput } from "../tool-input-cache" +import type { PluginConfig } from "../types" +import { isHookDisabled, log } from "../../../shared" + +export function createToolExecuteBeforeHandler(ctx: PluginInput, config: PluginConfig) { + return 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 { + 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, + }) + + cacheToolInput(input.sessionID, input.tool, input.callID, output.args) + + if (isHookDisabled(config, "PreToolUse")) { + return + } + + const preCtx: PreToolUseContext = { + sessionId: input.sessionID, + toolName: input.tool, + toolInput: output.args, + 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" as const, + duration: 4000, + }, + }) + .catch(() => {}) + throw new Error(result.reason ?? "Hook blocked the operation") + } + + if (result.modifiedInput) { + Object.assign(output.args, result.modifiedInput) + } + } +} diff --git a/src/hooks/claude-code-hooks/session-hook-state.ts b/src/hooks/claude-code-hooks/session-hook-state.ts new file mode 100644 index 000000000..50a2887cb --- /dev/null +++ b/src/hooks/claude-code-hooks/session-hook-state.ts @@ -0,0 +1,11 @@ +export const sessionFirstMessageProcessed = new Set() + +export const sessionErrorState = new Map() + +export const sessionInterruptState = new Map() + +export function clearSessionHookState(sessionID: string): void { + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) + sessionFirstMessageProcessed.delete(sessionID) +}