diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index a9eda36..8792578 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -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, } } diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index c4f1eaf..a1f4d52 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -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]) { diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index 6c8acb6..954fd73 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -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() export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) { 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 diff --git a/src/hooks/claude-code-hooks/pre-compact.ts b/src/hooks/claude-code-hooks/pre-compact.ts new file mode 100644 index 0000000..edaf1fa --- /dev/null +++ b/src/hooks/claude-code-hooks/pre-compact.ts @@ -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 { + 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, + } +} diff --git a/src/hooks/claude-code-hooks/types.ts b/src/hooks/claude-code-hooks/types.ts index a2272c2..33533e3 100644 --- a/src/hooks/claude-code-hooks/types.ts +++ b/src/hooks/claude-code-hooks/types.ts @@ -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 }