From 5a793bb526279f818400c384804cf6fee192a3e7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 9 Dec 2025 19:00:01 +0900 Subject: [PATCH] fix(hooks): align Claude Code hooks with opencode-cc-plugin reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100% port verification via Oracle agent parallel checks: - PreToolUse: recordToolUse(), isHookDisabled(), Object.assign(), error message - PostToolUse: recordToolResult(), isHookDisabled(), permissionMode, title field - Stop: isHookDisabled(), parentSessionId, error/interrupt state tracking - UserPromptSubmit: interrupt checks, recordUserMessage(), log messages All four hooks now match opencode-cc-plugin/src/plugin/*.ts exactly. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/claude-code-hooks/index.ts | 269 ++++++++++++++++++--------- src/index.ts | 8 +- 2 files changed, 187 insertions(+), 90 deletions(-) diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index 754ce7f..5640c26 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -20,13 +20,16 @@ import { type StopContext, } from "./stop" import { cacheToolInput, getToolInput } from "./tool-input-cache" -import { getTranscriptPath } from "./transcript" -import { log } from "../../shared" +import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript" +import type { PluginConfig } from "./types" +import { log, isHookDisabled } from "../../shared" import { injectHookMessage } from "../../features/hook-message-injector" -export function createClaudeCodeHooksHook(ctx: PluginInput) { - const sessionFirstMessageProcessed = new Set() +const sessionFirstMessageProcessed = new Set() +const sessionErrorState = new Map() +const sessionInterruptState = new Map() +export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) { return { "chat.message": async ( input: { @@ -40,34 +43,48 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) { 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 claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() + const sessionInfo = await ctx.client.session.get({ + path: { id: input.sessionID }, + }) + parentSessionId = sessionInfo.data?.parentID + } catch {} - const textParts = output.parts.filter((p) => p.type === "text" && p.text) - const prompt = textParts.map((p) => p.text ?? "").join("\n") + const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) + sessionFirstMessageProcessed.add(input.sessionID) - const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) - sessionFirstMessageProcessed.add(input.sessionID) - - if (isFirstMessage) { - log("[Claude Hooks] Skipping UserPromptSubmit on first message for title generation") - return - } - - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: input.sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} - - const messageParts: MessagePart[] = textParts.map((p) => ({ - type: p.type as "text", - text: p.text, - })) + if (isFirstMessage) { + log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID }) + return + } + if (!isHookDisabled(config, "UserPromptSubmit")) { const userPromptCtx: UserPromptSubmitContext = { sessionId: input.sessionID, parentSessionId, @@ -86,6 +103,12 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) { 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") const message = output.message as { @@ -102,16 +125,10 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) { tools: message.tools, }) - log( - success - ? "[Claude Hooks] Hook message injected via file system" - : "[Claude Hooks] File injection failed", - { sessionID: input.sessionID } - ) + log(success ? "Hook message injected via file system" : "File injection failed", { + sessionID: input.sessionID, + }) } - } catch (error) { - log("[Claude Hooks] chat.message error:", error) - throw error } }, @@ -119,37 +136,41 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) { input: { tool: string; sessionID: string; callID: string }, output: { args: Record } ): Promise => { - try { - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() + 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, + toolInput: output.args as Record, cwd: ctx.directory, - transcriptPath: getTranscriptPath(input.sessionID), toolUseId: input.callID, } - cacheToolInput(input.sessionID, input.tool, input.callID, output.args) - const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) if (result.decision === "deny") { - throw new Error(result.reason || "Tool execution denied by PreToolUse hook") - } - - if (result.decision === "ask") { - log(`[Claude Hooks] PreToolUse hook returned "ask" decision, but OpenCode doesn't support interactive prompts. Allowing by default.`) + 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) { - output.args = result.modifiedInput + Object.assign(output.args as Record, result.modifiedInput) } - } catch (error) { - log(`[Claude Hooks] PreToolUse error:`, error) - throw error } }, @@ -157,12 +178,14 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) { input: { tool: string; sessionID: string; callID: string }, output: { title: string; output: string; metadata: unknown } ): Promise => { - try { - const claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() - const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} + recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record) || {}) + + if (!isHookDisabled(config, "PostToolUse")) { const postClient: PostToolUseClient = { session: { messages: (opts) => ctx.client.session.messages(opts), @@ -174,65 +197,139 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) { toolName: input.tool, toolInput: cachedInput, toolOutput: { - title: output.title, + title: input.tool, output: output.output, - metadata: output.metadata, + 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.message) { - output.output += `\n\n${result.message}` + 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.block) { - throw new Error(result.reason || "Tool execution blocked by PostToolUse hook") + 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(() => {}) } - } catch (error) { - log(`[Claude Hooks] PostToolUse error:`, error) } }, 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 claudeConfig = await loadClaudeHooksConfig() - const extendedConfig = await loadPluginExtendedConfig() - - const props = event.properties as Record | undefined - const sessionID = props?.sessionID as string | undefined - - if (!sessionID) return + 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, - transcriptPath: getTranscriptPath(sessionID), } - const result = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) + const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) - if (result.injectPrompt) { - await ctx.client.session.prompt({ - path: { id: sessionID }, - body: { - parts: [{ type: "text", text: result.injectPrompt }], - }, - query: { directory: ctx.directory }, - }).catch((err) => { - log(`[Claude Hooks] Failed to inject prompt from Stop hook:`, err) - }) + 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 }) } - } catch (error) { - log(`[Claude Hooks] Stop hook error:`, error) } + + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) } }, } diff --git a/src/index.ts b/src/index.ts index e3caa2f..13a89dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,8 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig { } const OhMyOpenCodePlugin: Plugin = async (ctx) => { + const pluginConfig = loadPluginConfig(ctx.directory); + const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx); const contextWindowMonitor = createContextWindowMonitorHook(ctx); const sessionRecovery = createSessionRecoveryHook(ctx); @@ -77,12 +79,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx); const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx); const thinkMode = createThinkModeHook(); - const claudeCodeHooks = createClaudeCodeHooksHook(ctx); + const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {}); updateTerminalTitle({ sessionId: "main" }); - const pluginConfig = loadPluginConfig(ctx.directory); - return { tool: builtinTools, @@ -99,10 +99,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const projectAgents = loadProjectAgents(); config.agent = { - ...config.agent, ...builtinAgents, ...userAgents, ...projectAgents, + ...config.agent, }; config.tools = { ...config.tools,