feat(claude-code-hooks): add PreCompact hook support for experimental.session.compacting event (#139)

This commit is contained in:
YeonGyu-Kim
2025-12-25 14:29:27 +09:00
committed by GitHub
parent ac3c21fe90
commit fccaaf7676
5 changed files with 166 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ export interface DisabledHooksConfig {
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
PreCompact?: string[]
}
export interface PluginExtendedConfig {
@@ -47,6 +48,7 @@ function mergeDisabledHooks(
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
PreCompact: override.PreCompact ?? base.PreCompact,
}
}

View File

@@ -14,6 +14,7 @@ interface RawClaudeHooksConfig {
PostToolUse?: RawHookMatcher[]
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
PreCompact?: RawHookMatcher[]
}
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
@@ -30,6 +31,7 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
"PostToolUse",
"UserPromptSubmit",
"Stop",
"PreCompact",
]
for (const eventType of eventTypes) {
@@ -66,6 +68,7 @@ function mergeHooksConfig(
"PostToolUse",
"UserPromptSubmit",
"Stop",
"PreCompact",
]
for (const eventType of eventTypes) {
if (override[eventType]) {

View File

@@ -19,6 +19,10 @@ import {
executeStopHooks,
type StopContext,
} from "./stop"
import {
executePreCompactHooks,
type PreCompactContext,
} from "./pre-compact"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import type { PluginConfig } from "./types"
@@ -31,6 +35,35 @@ const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
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

View File

@@ -0,0 +1,109 @@
import type {
PreCompactInput,
PreCompactOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreCompactContext {
sessionId: string
cwd: string
}
export interface PreCompactResult {
context: string[]
elapsedMs?: number
hookName?: string
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
export async function executePreCompactHooks(
ctx: PreCompactContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PreCompactResult> {
if (!config) {
return { context: [] }
}
const matchers = findMatchingHooks(config, "PreCompact", "*")
if (matchers.length === 0) {
return { context: [] }
}
const stdinData: PreCompactInput = {
session_id: ctx.sessionId,
cwd: ctx.cwd,
hook_event_name: "PreCompact",
hook_source: "opencode-plugin",
}
const startTime = Date.now()
let firstHookName: string | undefined
const collectedContext: string[] = []
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) {
log("PreCompact hook command skipped (disabled by config)", { command: hook.command })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.exitCode === 2) {
log("PreCompact hook blocked", { hookName, stderr: result.stderr })
continue
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreCompactOutput
if (output.hookSpecificOutput?.additionalContext) {
collectedContext.push(...output.hookSpecificOutput.additionalContext)
} else if (output.context) {
collectedContext.push(...output.context)
}
if (output.continue === false) {
return {
context: collectedContext,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
if (result.stdout.trim()) {
collectedContext.push(result.stdout.trim())
}
}
}
}
}
return {
context: collectedContext,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
}
}

View File

@@ -8,6 +8,7 @@ export type ClaudeHookEvent =
| "PostToolUse"
| "UserPromptSubmit"
| "Stop"
| "PreCompact"
export interface HookMatcher {
matcher: string
@@ -24,6 +25,7 @@ export interface ClaudeHooksConfig {
PostToolUse?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
PreCompact?: HookMatcher[]
}
export interface PreToolUseInput {
@@ -82,6 +84,13 @@ export interface StopInput {
hook_source?: HookSource
}
export interface PreCompactInput {
session_id: string
cwd: string
hook_event_name: "PreCompact"
hook_source?: HookSource
}
export type PermissionDecision = "allow" | "deny" | "ask"
/**
@@ -166,6 +175,16 @@ export interface StopOutput {
inject_prompt?: string
}
export interface PreCompactOutput extends HookCommonOutput {
/** Additional context to inject into compaction prompt */
context?: string[]
hookSpecificOutput?: {
hookEventName: "PreCompact"
/** Additional context strings to inject */
additionalContext?: string[]
}
}
export type ClaudeCodeContent =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }