From 4470b09f576b66c0ef63a75490b2fb14d8d1099f Mon Sep 17 00:00:00 2001 From: Lukasz Gandecki Date: Tue, 30 Dec 2025 08:32:10 +0100 Subject: [PATCH 1/2] feat(hooks): add SessionStart hook support for Claude Code plugins - Add SessionStart to ClaudeHookEvent union and ClaudeHooksConfig - Add SessionStartInput/SessionStartOutput interfaces in types.ts - Create session-start.ts executor that wraps output in tags - Wire SessionStart execution to session.created event in index.ts - Add loadPluginHooksConfigs() to load hooks from ~/.claude/plugins/ - Fix replace() -> replaceAll() for ${CLAUDE_PLUGIN_ROOT} variable resolution This enables Claude Code plugins like claude-mem to inject context at session start, matching the behavior in Claude Code. --- .../claude-code-plugin-loader/loader.ts | 2 +- src/hooks/claude-code-hooks/config-loader.ts | 2 + src/hooks/claude-code-hooks/config.ts | 125 +++++++++++++++++- src/hooks/claude-code-hooks/index.ts | 46 +++++++ src/hooks/claude-code-hooks/session-start.ts | 110 +++++++++++++++ src/hooks/claude-code-hooks/types.ts | 18 +++ 6 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/hooks/claude-code-hooks/session-start.ts diff --git a/src/features/claude-code-plugin-loader/loader.ts b/src/features/claude-code-plugin-loader/loader.ts index 72eacc351..9f1c124c9 100644 --- a/src/features/claude-code-plugin-loader/loader.ts +++ b/src/features/claude-code-plugin-loader/loader.ts @@ -40,7 +40,7 @@ function getInstalledPluginsPath(): string { } function resolvePluginPath(path: string, pluginRoot: string): string { - return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) + return path.replaceAll(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) } function resolvePluginPaths(obj: T, pluginRoot: string): T { diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index 8792578b6..6000135b1 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -5,6 +5,7 @@ import type { ClaudeHookEvent } from "./types" import { log } from "../../shared/logger" export interface DisabledHooksConfig { + SessionStart?: string[] Stop?: string[] PreToolUse?: string[] PostToolUse?: string[] @@ -44,6 +45,7 @@ function mergeDisabledHooks( if (!base) return override return { + SessionStart: override.SessionStart ?? base.SessionStart, Stop: override.Stop ?? base.Stop, PreToolUse: override.PreToolUse ?? base.PreToolUse, PostToolUse: override.PostToolUse ?? base.PostToolUse, diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index b155c4810..c6d899804 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -1,8 +1,11 @@ import { join } from "path" -import { existsSync } from "fs" +import { existsSync, readFileSync, readdirSync } from "fs" +import { homedir } from "os" import { getClaudeConfigDir } from "../../shared" import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types" +const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}" + interface RawHookMatcher { matcher?: string pattern?: string @@ -10,6 +13,7 @@ interface RawHookMatcher { } interface RawClaudeHooksConfig { + SessionStart?: RawHookMatcher[] PreToolUse?: RawHookMatcher[] PostToolUse?: RawHookMatcher[] UserPromptSubmit?: RawHookMatcher[] @@ -27,6 +31,7 @@ function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher { function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig { const result: ClaudeHooksConfig = {} const eventTypes: (keyof RawClaudeHooksConfig)[] = [ + "SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", @@ -64,6 +69,7 @@ function mergeHooksConfig( ): ClaudeHooksConfig { const result: ClaudeHooksConfig = { ...base } const eventTypes: (keyof ClaudeHooksConfig)[] = [ + "SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", @@ -99,5 +105,122 @@ export async function loadClaudeHooksConfig( } } + const pluginHooks = await loadPluginHooksConfigs() + mergedConfig = mergeHooksConfig(mergedConfig, pluginHooks) + return Object.keys(mergedConfig).length > 0 ? mergedConfig : null } + +function resolvePluginPath(path: string, pluginRoot: string): string { + return path.replaceAll(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot) +} + +function resolvePluginPaths(obj: T, pluginRoot: string): T { + if (obj === null || obj === undefined) return obj + if (typeof obj === "string") { + return resolvePluginPath(obj, pluginRoot) as T + } + if (Array.isArray(obj)) { + return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T + } + if (typeof obj === "object") { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = resolvePluginPaths(value, pluginRoot) + } + return result as T + } + return obj +} + +interface PluginInstallation { + installPath: string +} + +interface InstalledPluginsDatabase { + version: number + plugins: Record +} + +interface ClaudeSettings { + enabledPlugins?: Record +} + +interface PluginHooksJson { + hooks?: RawClaudeHooksConfig +} + +function getPluginsBaseDir(): string { + if (process.env.CLAUDE_PLUGINS_HOME) { + return process.env.CLAUDE_PLUGINS_HOME + } + return join(homedir(), ".claude", "plugins") +} + +function isPluginEnabled( + pluginKey: string, + enabledPlugins: Record | undefined +): boolean { + if (enabledPlugins && pluginKey in enabledPlugins) { + return enabledPlugins[pluginKey] + } + return true +} + +async function loadPluginHooksConfigs(): Promise { + let mergedConfig: ClaudeHooksConfig = {} + + const dbPath = join(getPluginsBaseDir(), "installed_plugins.json") + if (!existsSync(dbPath)) { + return mergedConfig + } + + let db: InstalledPluginsDatabase + try { + const content = readFileSync(dbPath, "utf-8") + db = JSON.parse(content) as InstalledPluginsDatabase + } catch { + return mergedConfig + } + + let enabledPlugins: Record | undefined + const settingsPath = join(homedir(), ".claude", "settings.json") + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, "utf-8") + const settings = JSON.parse(content) as ClaudeSettings + enabledPlugins = settings.enabledPlugins + } catch { + } + } + + for (const [pluginKey, installations] of Object.entries(db.plugins)) { + if (!isPluginEnabled(pluginKey, enabledPlugins)) { + continue + } + + const installation = Array.isArray(installations) ? installations[0] : installations + if (!installation?.installPath) continue + + const { installPath } = installation + if (!existsSync(installPath)) continue + + const hooksPath = join(installPath, "hooks", "hooks.json") + if (!existsSync(hooksPath)) continue + + try { + const content = readFileSync(hooksPath, "utf-8") + let config = JSON.parse(content) as PluginHooksJson + config = resolvePluginPaths(config, installPath) + + if (config.hooks) { + const normalizedHooks = normalizeHooksConfig(config.hooks) + mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks) + } + } catch { + continue + } + } + + return mergedConfig +} diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index 954fd73b5..0d6af42c4 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -23,6 +23,10 @@ import { executePreCompactHooks, type PreCompactContext, } from "./pre-compact" +import { + executeSessionStartHooks, + type SessionStartContext, +} from "./session-start" import { cacheToolInput, getToolInput } from "./tool-input-cache" import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript" import type { PluginConfig } from "./types" @@ -289,6 +293,48 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig event: async (input: { event: { type: string; properties?: unknown } }) => { const { event } = input + if (event.type === "session.created") { + const props = event.properties as { info?: { id?: string; directory?: string } } | undefined + const sessionID = props?.info?.id + const directory = props?.info?.directory ?? ctx.directory + + if (!sessionID) return + + if (isHookDisabled(config, "SessionStart")) { + return + } + + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const sessionStartCtx: SessionStartContext = { + sessionId: sessionID, + cwd: directory, + } + + const result = await executeSessionStartHooks(sessionStartCtx, claudeConfig, extendedConfig) + + if (result.context.length > 0) { + const hookContent = result.context.join("\n\n") + log("SessionStart hooks injecting context", { + sessionID, + contextCount: result.context.length, + hookName: result.hookName, + elapsedMs: result.elapsedMs, + }) + + const success = injectHookMessage(sessionID, hookContent, { + path: { cwd: directory, root: "/" }, + }) + + log(success ? "SessionStart hook message injected" : "SessionStart injection failed", { + sessionID, + }) + } + + return + } + if (event.type === "session.error") { const props = event.properties as Record | undefined const sessionID = props?.sessionID as string | undefined diff --git a/src/hooks/claude-code-hooks/session-start.ts b/src/hooks/claude-code-hooks/session-start.ts new file mode 100644 index 000000000..fb936fa6e --- /dev/null +++ b/src/hooks/claude-code-hooks/session-start.ts @@ -0,0 +1,110 @@ +import type { + SessionStartInput, + SessionStartOutput, + ClaudeHooksConfig, +} from "./types" +import { findMatchingHooks, executeHookCommand, log } from "../../shared" +import { DEFAULT_CONFIG } from "./plugin-config" +import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" +import { getTranscriptPath } from "./transcript" + +const SESSION_START_TAG_OPEN = "" +const SESSION_START_TAG_CLOSE = "" + +export interface SessionStartContext { + sessionId: string + cwd: string +} + +export interface SessionStartResult { + context: string[] + elapsedMs?: number + hookName?: string +} + +export async function executeSessionStartHooks( + ctx: SessionStartContext, + config: ClaudeHooksConfig | null, + extendedConfig?: PluginExtendedConfig | null +): Promise { + const context: string[] = [] + + if (!config) { + return { context } + } + + const matchers = findMatchingHooks(config, "SessionStart") + if (matchers.length === 0) { + return { context } + } + + const stdinData: SessionStartInput = { + session_id: ctx.sessionId, + transcript_path: getTranscriptPath(ctx.sessionId), + cwd: ctx.cwd, + hook_event_name: "SessionStart", + hook_source: "opencode-plugin", + } + + const startTime = Date.now() + let firstHookName: string | undefined + + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + if (hook.type !== "command") continue + + if (isHookCommandDisabled("SessionStart", hook.command, extendedConfig ?? null)) { + log("SessionStart 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.stdout) { + try { + const output = JSON.parse(result.stdout) as SessionStartOutput + + if (output.hookSpecificOutput?.additionalContext) { + const content = output.hookSpecificOutput.additionalContext + if (content.includes(SESSION_START_TAG_OPEN)) { + context.push(content) + } else { + context.push(`${SESSION_START_TAG_OPEN}\n${content}\n${SESSION_START_TAG_CLOSE}`) + } + } + } catch { + const rawOutput = result.stdout.trim() + if (rawOutput) { + if (rawOutput.includes(SESSION_START_TAG_OPEN)) { + context.push(rawOutput) + } else { + context.push(`${SESSION_START_TAG_OPEN}\n${rawOutput}\n${SESSION_START_TAG_CLOSE}`) + } + } + } + } + + if (result.exitCode !== 0) { + log("SessionStart hook returned non-zero exit code", { + command: hook.command, + exitCode: result.exitCode, + stderr: result.stderr, + }) + } + } + } + + return { + context, + 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 33533e3b5..840f25113 100644 --- a/src/hooks/claude-code-hooks/types.ts +++ b/src/hooks/claude-code-hooks/types.ts @@ -4,6 +4,7 @@ */ export type ClaudeHookEvent = + | "SessionStart" | "PreToolUse" | "PostToolUse" | "UserPromptSubmit" @@ -21,6 +22,7 @@ export interface HookCommand { } export interface ClaudeHooksConfig { + SessionStart?: HookMatcher[] PreToolUse?: HookMatcher[] PostToolUse?: HookMatcher[] UserPromptSubmit?: HookMatcher[] @@ -185,6 +187,22 @@ export interface PreCompactOutput extends HookCommonOutput { } } +export interface SessionStartInput { + session_id: string + transcript_path?: string + cwd: string + hook_event_name: "SessionStart" + hook_source?: HookSource +} + +export interface SessionStartOutput extends HookCommonOutput { + hookSpecificOutput?: { + hookEventName: "SessionStart" + /** Additional context to inject at session start */ + additionalContext?: string + } +} + export type ClaudeCodeContent = | { type: "text"; text: string } | { type: "tool_use"; id: string; name: string; input: Record } From bb1dcaa5c9c807f3fb1b2ea11b3297f670c15e35 Mon Sep 17 00:00:00 2001 From: Lukasz Gandecki Date: Tue, 30 Dec 2025 10:10:04 +0100 Subject: [PATCH 2/2] feat(hooks): add SessionEnd, session state tracking, and transcript improvements - Add SessionEnd hook support for session cleanup - Add session state file tracking (~/.claude-mem/opencode-sessions.json) - Implement 60s delayed Stop hook matching Claude Code's idle_prompt behavior - Fix transcript format to match Claude Code (message.content wrapper) - Add message.part.updated handler to record assistant text responses - Clean up session tracking on session.deleted --- src/hooks/claude-code-hooks/config-loader.ts | 2 + src/hooks/claude-code-hooks/config.ts | 3 + src/hooks/claude-code-hooks/index.ts | 137 ++++++++++--- src/hooks/claude-code-hooks/session-end.ts | 79 ++++++++ src/hooks/claude-code-hooks/session-state.ts | 201 +++++++++++++++++++ src/hooks/claude-code-hooks/transcript.ts | 29 ++- src/hooks/claude-code-hooks/types.ts | 17 +- 7 files changed, 436 insertions(+), 32 deletions(-) create mode 100644 src/hooks/claude-code-hooks/session-end.ts create mode 100644 src/hooks/claude-code-hooks/session-state.ts diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index 6000135b1..72aea6375 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -11,6 +11,7 @@ export interface DisabledHooksConfig { PostToolUse?: string[] UserPromptSubmit?: string[] PreCompact?: string[] + SessionEnd?: string[] } export interface PluginExtendedConfig { @@ -51,6 +52,7 @@ function mergeDisabledHooks( PostToolUse: override.PostToolUse ?? base.PostToolUse, UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit, PreCompact: override.PreCompact ?? base.PreCompact, + SessionEnd: override.SessionEnd ?? base.SessionEnd, } } diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index c6d899804..a6211b50f 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -19,6 +19,7 @@ interface RawClaudeHooksConfig { UserPromptSubmit?: RawHookMatcher[] Stop?: RawHookMatcher[] PreCompact?: RawHookMatcher[] + SessionEnd?: RawHookMatcher[] } function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher { @@ -37,6 +38,7 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig { "UserPromptSubmit", "Stop", "PreCompact", + "SessionEnd", ] for (const eventType of eventTypes) { @@ -75,6 +77,7 @@ function mergeHooksConfig( "UserPromptSubmit", "Stop", "PreCompact", + "SessionEnd", ] 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 0d6af42c4..eed6baf2d 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -27,15 +27,30 @@ import { executeSessionStartHooks, type SessionStartContext, } from "./session-start" +import { + executeSessionEndHooks, + type SessionEndContext, +} from "./session-end" import { cacheToolInput, getToolInput } from "./tool-input-cache" -import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript" +import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage, recordAssistantMessage } from "./transcript" import type { PluginConfig } from "./types" import { log, isHookDisabled } from "../../shared" import { injectHookMessage } from "../../features/hook-message-injector" +import { + updateSessionActivity, + markSessionIdle, + markSummaryGenerated, + markSessionCompleted, + scheduleStopHook, + cancelPendingStopHook, + markActivitySinceIdle, +} from "./session-state" const sessionFirstMessageProcessed = new Set() const sessionErrorState = new Map() const sessionInterruptState = new Map() +// Track recorded assistant text parts to avoid duplicates (keyed by sessionID:partID) +const recordedAssistantParts = new Set() export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) { return { @@ -93,6 +108,9 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig const prompt = textParts.map((p) => p.text ?? "").join("\n") recordUserMessage(input.sessionID, prompt) + updateSessionActivity(input.sessionID, "chat.message", { cwd: ctx.directory }) + markActivitySinceIdle(input.sessionID) + cancelPendingStopHook(input.sessionID) const messageParts: MessagePart[] = textParts.map((p) => ({ type: p.type as "text", @@ -229,6 +247,10 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig const toolOutput = hasMetadata ? metadata : { output: output.output } recordToolResult(input.sessionID, input.tool, cachedInput, toolOutput) + updateSessionActivity(input.sessionID, "tool.execute.after", { cwd: ctx.directory }) + markActivitySinceIdle(input.sessionID) + cancelPendingStopHook(input.sessionID) + if (!isHookDisabled(config, "PostToolUse")) { const postClient: PostToolUseClient = { session: { @@ -300,6 +322,8 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig if (!sessionID) return + updateSessionActivity(sessionID, "session.created", { cwd: directory }) + if (isHookDisabled(config, "SessionStart")) { return } @@ -349,46 +373,113 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig 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) + const sessionInfo = props?.info as { id?: string; directory?: string } | undefined + const sessionID = sessionInfo?.id + const directory = sessionInfo?.directory ?? ctx.directory + + if (sessionID) { + if (!isHookDisabled(config, "SessionEnd")) { + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + + const sessionEndCtx: SessionEndContext = { + sessionId: sessionID, + cwd: directory, + } + + const result = await executeSessionEndHooks(sessionEndCtx, claudeConfig, extendedConfig) + + if (result.hookName) { + log("SessionEnd hook executed", { + sessionID, + hookName: result.hookName, + elapsedMs: result.elapsedMs, + }) + } + } + + cancelPendingStopHook(sessionID) + markSessionCompleted(sessionID) + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) + sessionFirstMessageProcessed.delete(sessionID) + for (const key of recordedAssistantParts) { + if (key.startsWith(`${sessionID}:`)) { + recordedAssistantParts.delete(key) + } + } } return } + if (event.type === "message.part.updated") { + const props = event.properties as Record | undefined + const info = props?.info as { sessionID?: string; role?: string } | undefined + const part = props?.part as { + id?: string + type?: string + text?: string + time?: { end?: number } + } | undefined + + const sessionID = info?.sessionID + const role = info?.role + const partId = part?.id + + if (!sessionID || role !== "assistant") return + if (part?.type !== "text" || !part.text) return + if (!part.time?.end) return + + const key = `${sessionID}:${partId ?? part.text.slice(0, 50)}` + if (recordedAssistantParts.has(key)) return + + recordedAssistantParts.add(key) + recordAssistantMessage(sessionID, part.text) + updateSessionActivity(sessionID, "message.part.updated", { cwd: ctx.directory }) + + 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() + markSessionIdle(sessionID) - const errorStateBefore = sessionErrorState.get(sessionID) - const endedWithErrorBefore = errorStateBefore?.hasError === true - const interruptStateBefore = sessionInterruptState.get(sessionID) - const interruptedBefore = interruptStateBefore?.interrupted === true + if (isHookDisabled(config, "Stop")) { + return + } - let parentSessionId: string | undefined - try { - const sessionInfo = await ctx.client.session.get({ - path: { id: sessionID }, - }) - parentSessionId = sessionInfo.data?.parentID - } catch {} + scheduleStopHook(sessionID, async () => { + 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, + transcriptPath: getTranscriptPath(sessionID), } const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig) + markSummaryGenerated(sessionID) + const errorStateAfter = sessionErrorState.get(sessionID) const endedWithErrorAfter = errorStateAfter?.hasError === true const interruptStateAfter = sessionInterruptState.get(sessionID) @@ -412,10 +503,10 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig } else if (stopResult.block) { log("Stop hook returned block", { sessionID, reason: stopResult.reason }) } - } - sessionErrorState.delete(sessionID) - sessionInterruptState.delete(sessionID) + sessionErrorState.delete(sessionID) + sessionInterruptState.delete(sessionID) + }) } }, } diff --git a/src/hooks/claude-code-hooks/session-end.ts b/src/hooks/claude-code-hooks/session-end.ts new file mode 100644 index 000000000..3c8d7196f --- /dev/null +++ b/src/hooks/claude-code-hooks/session-end.ts @@ -0,0 +1,79 @@ +import type { + SessionEndInput, + SessionEndOutput, + ClaudeHooksConfig, +} from "./types" +import { findMatchingHooks, executeHookCommand, log } from "../../shared" +import { DEFAULT_CONFIG } from "./plugin-config" +import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" +import { getTranscriptPath } from "./transcript" + +export interface SessionEndContext { + sessionId: string + cwd: string +} + +export interface SessionEndResult { + elapsedMs?: number + hookName?: string +} + +export async function executeSessionEndHooks( + ctx: SessionEndContext, + config: ClaudeHooksConfig | null, + extendedConfig?: PluginExtendedConfig | null +): Promise { + if (!config) { + return {} + } + + const matchers = findMatchingHooks(config, "SessionEnd") + if (matchers.length === 0) { + return {} + } + + const stdinData: SessionEndInput = { + session_id: ctx.sessionId, + transcript_path: getTranscriptPath(ctx.sessionId), + cwd: ctx.cwd, + hook_event_name: "SessionEnd", + hook_source: "opencode-plugin", + } + + const startTime = Date.now() + let firstHookName: string | undefined + + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + if (hook.type !== "command") continue + + if (isHookCommandDisabled("SessionEnd", hook.command, extendedConfig ?? null)) { + log("SessionEnd 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 !== 0) { + log("SessionEnd hook returned non-zero exit code", { + command: hook.command, + exitCode: result.exitCode, + stderr: result.stderr, + }) + } + } + } + + return { + elapsedMs: Date.now() - startTime, + hookName: firstHookName, + } +} diff --git a/src/hooks/claude-code-hooks/session-state.ts b/src/hooks/claude-code-hooks/session-state.ts new file mode 100644 index 000000000..e85e4384e --- /dev/null +++ b/src/hooks/claude-code-hooks/session-state.ts @@ -0,0 +1,201 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { join, dirname } from "path" +import { homedir } from "os" +import { log } from "../../shared" + +const STATE_FILE_PATH = join(homedir(), ".claude-mem", "opencode-sessions.json") +const IDLE_CONFIRMATION_DELAY_MS = 60_000 + +const pendingStopHooks = new Map>() +const sessionActivitySinceIdle = new Set() + +export type SessionStatus = "active" | "idle" | "completed" + +export interface SessionState { + sessionId: string + project: string + cwd: string + lastActivity: number + lastActivityType: string + status: SessionStatus + summaryGeneratedAt: number | null + createdAt: number +} + +export interface SessionStateFile { + sessions: Record + version: number +} + +function ensureStateDir(): void { + const dir = dirname(STATE_FILE_PATH) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } +} + +function readStateFile(): SessionStateFile { + try { + if (existsSync(STATE_FILE_PATH)) { + const content = readFileSync(STATE_FILE_PATH, "utf-8") + return JSON.parse(content) as SessionStateFile + } + } catch (e) { + log("Failed to read session state file", { error: String(e) }) + } + return { sessions: {}, version: 1 } +} + +function writeStateFile(state: SessionStateFile): void { + try { + ensureStateDir() + writeFileSync(STATE_FILE_PATH, JSON.stringify(state, null, 2)) + } catch (e) { + log("Failed to write session state file", { error: String(e) }) + } +} + +export function getSessionState(sessionId: string): SessionState | null { + const state = readStateFile() + return state.sessions[sessionId] || null +} + +export function updateSessionActivity( + sessionId: string, + activityType: string, + options: { project?: string; cwd?: string } = {} +): void { + const state = readStateFile() + const now = Date.now() + + const existing = state.sessions[sessionId] + + state.sessions[sessionId] = { + sessionId, + project: options.project || existing?.project || "", + cwd: options.cwd || existing?.cwd || "", + lastActivity: now, + lastActivityType: activityType, + status: "active", + summaryGeneratedAt: existing?.summaryGeneratedAt || null, + createdAt: existing?.createdAt || now, + } + + writeStateFile(state) +} + +export function markSessionIdle(sessionId: string): void { + const state = readStateFile() + const existing = state.sessions[sessionId] + + if (existing) { + existing.status = "idle" + existing.lastActivity = Date.now() + existing.lastActivityType = "session.idle" + writeStateFile(state) + } +} + +export function markSummaryGenerated(sessionId: string): void { + const state = readStateFile() + const existing = state.sessions[sessionId] + + if (existing) { + existing.summaryGeneratedAt = Date.now() + writeStateFile(state) + } +} + +export function markSessionCompleted(sessionId: string): void { + const state = readStateFile() + const existing = state.sessions[sessionId] + + if (existing) { + existing.status = "completed" + existing.lastActivity = Date.now() + writeStateFile(state) + } +} + +export function scheduleStopHook( + sessionId: string, + onConfirmedIdle: () => void +): void { + if (pendingStopHooks.has(sessionId)) { + return + } + + sessionActivitySinceIdle.delete(sessionId) + + const timer = setTimeout(() => { + pendingStopHooks.delete(sessionId) + + if (sessionActivitySinceIdle.has(sessionId)) { + sessionActivitySinceIdle.delete(sessionId) + log("Stop hook cancelled - activity detected during idle confirmation", { sessionId }) + return + } + + log("Stop hook executing after confirmed idle", { sessionId, delayMs: IDLE_CONFIRMATION_DELAY_MS }) + onConfirmedIdle() + }, IDLE_CONFIRMATION_DELAY_MS) + + pendingStopHooks.set(sessionId, timer) + log("Stop hook scheduled", { sessionId, delayMs: IDLE_CONFIRMATION_DELAY_MS }) +} + +export function cancelPendingStopHook(sessionId: string): void { + const timer = pendingStopHooks.get(sessionId) + if (timer) { + clearTimeout(timer) + pendingStopHooks.delete(sessionId) + log("Pending stop hook cancelled", { sessionId }) + } +} + +export function markActivitySinceIdle(sessionId: string): void { + if (pendingStopHooks.has(sessionId)) { + sessionActivitySinceIdle.add(sessionId) + } +} + +export function hasPendingStopHook(sessionId: string): boolean { + return pendingStopHooks.has(sessionId) +} + +export function getOrphanedSessions(staleThresholdMs: number = 5 * 60 * 1000): SessionState[] { + const state = readStateFile() + const now = Date.now() + const orphaned: SessionState[] = [] + + for (const session of Object.values(state.sessions)) { + const isStale = (now - session.lastActivity) > staleThresholdMs + const notCompleted = session.status !== "completed" + const noSummary = !session.summaryGeneratedAt + + if (isStale && notCompleted && noSummary) { + orphaned.push(session) + } + } + + return orphaned +} + +export function cleanupOldSessions(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): number { + const state = readStateFile() + const now = Date.now() + let cleaned = 0 + + for (const [sessionId, session] of Object.entries(state.sessions)) { + if ((now - session.lastActivity) > maxAgeMs) { + delete state.sessions[sessionId] + cleaned++ + } + } + + if (cleaned > 0) { + writeStateFile(state) + } + + return cleaned +} diff --git a/src/hooks/claude-code-hooks/transcript.ts b/src/hooks/claude-code-hooks/transcript.ts index 0cccd4ec5..3dd7b60b6 100644 --- a/src/hooks/claude-code-hooks/transcript.ts +++ b/src/hooks/claude-code-hooks/transcript.ts @@ -60,22 +60,35 @@ export function recordUserMessage( sessionId: string, content: string ): void { - appendTranscriptEntry(sessionId, { + // Claude Code compatible format: { type: "user", message: { role: "user", content: "..." } } + const entry = { type: "user", - timestamp: new Date().toISOString(), - content, - }) + message: { + role: "user", + content, + }, + } + ensureTranscriptDir() + const path = getTranscriptPath(sessionId) + const line = JSON.stringify(entry) + "\n" + appendFileSync(path, line) } export function recordAssistantMessage( sessionId: string, content: string ): void { - appendTranscriptEntry(sessionId, { + const entry = { type: "assistant", - timestamp: new Date().toISOString(), - content, - }) + message: { + role: "assistant", + content: [{ type: "text", text: content }], + }, + } + ensureTranscriptDir() + const path = getTranscriptPath(sessionId) + const line = JSON.stringify(entry) + "\n" + appendFileSync(path, line) } // ============================================================================ diff --git a/src/hooks/claude-code-hooks/types.ts b/src/hooks/claude-code-hooks/types.ts index 840f25113..7b74ff942 100644 --- a/src/hooks/claude-code-hooks/types.ts +++ b/src/hooks/claude-code-hooks/types.ts @@ -10,6 +10,7 @@ export type ClaudeHookEvent = | "UserPromptSubmit" | "Stop" | "PreCompact" + | "SessionEnd" export interface HookMatcher { matcher: string @@ -28,6 +29,7 @@ export interface ClaudeHooksConfig { UserPromptSubmit?: HookMatcher[] Stop?: HookMatcher[] PreCompact?: HookMatcher[] + SessionEnd?: HookMatcher[] } export interface PreToolUseInput { @@ -198,11 +200,24 @@ export interface SessionStartInput { export interface SessionStartOutput extends HookCommonOutput { hookSpecificOutput?: { hookEventName: "SessionStart" - /** Additional context to inject at session start */ additionalContext?: string } } +export interface SessionEndInput { + session_id: string + transcript_path?: string + cwd: string + hook_event_name: "SessionEnd" + hook_source?: HookSource +} + +export interface SessionEndOutput extends HookCommonOutput { + hookSpecificOutput?: { + hookEventName: "SessionEnd" + } +} + export type ClaudeCodeContent = | { type: "text"; text: string } | { type: "tool_use"; id: string; name: string; input: Record }