fix(hooks): align Claude Code hooks with opencode-cc-plugin reference

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)
This commit is contained in:
YeonGyu-Kim
2025-12-09 19:00:01 +09:00
parent 2ec351d0d8
commit 5a793bb526
2 changed files with 187 additions and 90 deletions

View File

@@ -20,13 +20,16 @@ import {
type StopContext, type StopContext,
} from "./stop" } from "./stop"
import { cacheToolInput, getToolInput } from "./tool-input-cache" import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { getTranscriptPath } from "./transcript" import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import { log } from "../../shared" import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector" import { injectHookMessage } from "../../features/hook-message-injector"
export function createClaudeCodeHooksHook(ctx: PluginInput) { const sessionFirstMessageProcessed = new Set<string>()
const sessionFirstMessageProcessed = new Set<string>() const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
return { return {
"chat.message": async ( "chat.message": async (
input: { input: {
@@ -40,18 +43,28 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) {
parts: Array<{ type: string; text?: string; [key: string]: unknown }> parts: Array<{ type: string; text?: string; [key: string]: unknown }>
} }
): Promise<void> => { ): Promise<void> => {
try { 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 claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig() const extendedConfig = await loadPluginExtendedConfig()
const textParts = output.parts.filter((p) => p.type === "text" && p.text) const textParts = output.parts.filter((p) => p.type === "text" && p.text)
const prompt = textParts.map((p) => p.text ?? "").join("\n") const prompt = textParts.map((p) => p.text ?? "").join("\n")
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID) recordUserMessage(input.sessionID, prompt)
sessionFirstMessageProcessed.add(input.sessionID)
if (isFirstMessage) { const messageParts: MessagePart[] = textParts.map((p) => ({
log("[Claude Hooks] Skipping UserPromptSubmit on first message for title generation") 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 return
} }
@@ -63,11 +76,15 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) {
parentSessionId = sessionInfo.data?.parentID parentSessionId = sessionInfo.data?.parentID
} catch {} } catch {}
const messageParts: MessagePart[] = textParts.map((p) => ({ const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
type: p.type as "text", sessionFirstMessageProcessed.add(input.sessionID)
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 = { const userPromptCtx: UserPromptSubmitContext = {
sessionId: input.sessionID, sessionId: input.sessionID,
parentSessionId, parentSessionId,
@@ -86,6 +103,12 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) {
throw new Error(result.reason ?? "Hook blocked the prompt") 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) { if (result.messages.length > 0) {
const hookContent = result.messages.join("\n\n") const hookContent = result.messages.join("\n\n")
const message = output.message as { const message = output.message as {
@@ -102,16 +125,10 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) {
tools: message.tools, tools: message.tools,
}) })
log( log(success ? "Hook message injected via file system" : "File injection failed", {
success sessionID: input.sessionID,
? "[Claude Hooks] Hook message injected via file system" })
: "[Claude Hooks] 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 }, input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> } output: { args: Record<string, unknown> }
): Promise<void> => { ): Promise<void> => {
try {
const claudeConfig = await loadClaudeHooksConfig() const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig() const extendedConfig = await loadPluginExtendedConfig()
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
if (!isHookDisabled(config, "PreToolUse")) {
const preCtx: PreToolUseContext = { const preCtx: PreToolUseContext = {
sessionId: input.sessionID, sessionId: input.sessionID,
toolName: input.tool, toolName: input.tool,
toolInput: output.args, toolInput: output.args as Record<string, unknown>,
cwd: ctx.directory, cwd: ctx.directory,
transcriptPath: getTranscriptPath(input.sessionID),
toolUseId: input.callID, toolUseId: input.callID,
} }
cacheToolInput(input.sessionID, input.tool, input.callID, output.args)
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig) const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
if (result.decision === "deny") { if (result.decision === "deny") {
throw new Error(result.reason || "Tool execution denied by PreToolUse hook") ctx.client.tui
} .showToast({
body: {
if (result.decision === "ask") { title: "PreToolUse Hook Executed",
log(`[Claude Hooks] PreToolUse hook returned "ask" decision, but OpenCode doesn't support interactive prompts. Allowing by default.`) 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) { if (result.modifiedInput) {
output.args = result.modifiedInput Object.assign(output.args as Record<string, unknown>, 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 }, input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown } output: { title: string; output: string; metadata: unknown }
): Promise<void> => { ): Promise<void> => {
try {
const claudeConfig = await loadClaudeHooksConfig() const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig() 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<string, unknown>) || {})
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = { const postClient: PostToolUseClient = {
session: { session: {
messages: (opts) => ctx.client.session.messages(opts), messages: (opts) => ctx.client.session.messages(opts),
@@ -174,65 +197,139 @@ export function createClaudeCodeHooksHook(ctx: PluginInput) {
toolName: input.tool, toolName: input.tool,
toolInput: cachedInput, toolInput: cachedInput,
toolOutput: { toolOutput: {
title: output.title, title: input.tool,
output: output.output, output: output.output,
metadata: output.metadata, metadata: output.metadata as Record<string, unknown>,
}, },
cwd: ctx.directory, cwd: ctx.directory,
transcriptPath: getTranscriptPath(input.sessionID), transcriptPath: getTranscriptPath(input.sessionID),
toolUseId: input.callID, toolUseId: input.callID,
client: postClient, client: postClient,
permissionMode: "bypassPermissions",
} }
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig) const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
if (result.message) { if (result.block) {
output.output += `\n\n${result.message}` ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Warning",
message: result.reason ?? "Hook returned warning",
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
} }
if (result.block) { if (result.warnings && result.warnings.length > 0) {
throw new Error(result.reason || "Tool execution blocked by PostToolUse hook") 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 } }) => { event: async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input const { event } = input
if (event.type === "session.idle") { if (event.type === "session.error") {
try { const props = event.properties as Record<string, unknown> | undefined
const claudeConfig = await loadClaudeHooksConfig() const sessionID = props?.sessionID as string | undefined
const extendedConfig = await loadPluginExtendedConfig() 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<string, unknown> | 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<string, unknown> | undefined const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined const sessionID = props?.sessionID as string | undefined
if (!sessionID) return 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 = { const stopCtx: StopContext = {
sessionId: sessionID, sessionId: sessionID,
parentSessionId,
cwd: ctx.directory, cwd: ctx.directory,
transcriptPath: getTranscriptPath(sessionID),
} }
const result = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
if (result.injectPrompt) { const errorStateAfter = sessionErrorState.get(sessionID)
await ctx.client.session.prompt({ 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 }, path: { id: sessionID },
body: { body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
parts: [{ type: "text", text: result.injectPrompt }],
},
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}).catch((err) => {
log(`[Claude Hooks] Failed to inject prompt from Stop hook:`, err)
}) })
.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)
} }
}, },
} }

View File

@@ -69,6 +69,8 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
} }
const OhMyOpenCodePlugin: Plugin = async (ctx) => { const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx); const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx); const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx); const sessionRecovery = createSessionRecoveryHook(ctx);
@@ -77,12 +79,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx); const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx); const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
const thinkMode = createThinkModeHook(); const thinkMode = createThinkModeHook();
const claudeCodeHooks = createClaudeCodeHooksHook(ctx); const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
updateTerminalTitle({ sessionId: "main" }); updateTerminalTitle({ sessionId: "main" });
const pluginConfig = loadPluginConfig(ctx.directory);
return { return {
tool: builtinTools, tool: builtinTools,
@@ -99,10 +99,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const projectAgents = loadProjectAgents(); const projectAgents = loadProjectAgents();
config.agent = { config.agent = {
...config.agent,
...builtinAgents, ...builtinAgents,
...userAgents, ...userAgents,
...projectAgents, ...projectAgents,
...config.agent,
}; };
config.tools = { config.tools = {
...config.tools, ...config.tools,