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
This commit is contained in:
@@ -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<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
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<void> => {
|
||||
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<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
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<string, unknown> }
|
||||
): Promise<void> => {
|
||||
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<string, unknown>,
|
||||
})
|
||||
|
||||
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
|
||||
|
||||
if (!isHookDisabled(config, "PreToolUse")) {
|
||||
const preCtx: PreToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: output.args as Record<string, unknown>,
|
||||
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<string, unknown>, result.modifiedInput)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
// 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<string, unknown> | 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<string, unknown>,
|
||||
},
|
||||
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<string, unknown> | 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<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 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),
|
||||
}
|
||||
}
|
||||
|
||||
140
src/hooks/claude-code-hooks/handlers/chat-message-handler.ts
Normal file
140
src/hooks/claude-code-hooks/handlers/chat-message-handler.ts
Normal file
@@ -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<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
},
|
||||
): Promise<void> => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
41
src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts
Normal file
41
src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts
Normal file
@@ -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<void> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/hooks/claude-code-hooks/handlers/session-event-handler.ts
Normal file
111
src/hooks/claude-code-hooks/handlers/session-event-handler.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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)
|
||||
}
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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<string, unknown> | 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<string, unknown>,
|
||||
},
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> },
|
||||
): Promise<void> => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/hooks/claude-code-hooks/session-hook-state.ts
Normal file
11
src/hooks/claude-code-hooks/session-hook-state.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const sessionFirstMessageProcessed = new Set<string>()
|
||||
|
||||
export const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
|
||||
export const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function clearSessionHookState(sessionID: string): void {
|
||||
sessionErrorState.delete(sessionID)
|
||||
sessionInterruptState.delete(sessionID)
|
||||
sessionFirstMessageProcessed.delete(sessionID)
|
||||
}
|
||||
Reference in New Issue
Block a user