diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index 653a67ef52..ee59ebbd0a 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -10,6 +10,8 @@ export interface DisabledHooksConfig { PostToolUse?: string[] UserPromptSubmit?: string[] PreCompact?: string[] + SessionStart?: string[] + SessionEnd?: string[] } export interface PluginExtendedConfig { @@ -49,6 +51,8 @@ function mergeDisabledHooks( PostToolUse: override.PostToolUse ?? base.PostToolUse, UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit, PreCompact: override.PreCompact ?? base.PreCompact, + SessionStart: override.SessionStart ?? base.SessionStart, + SessionEnd: override.SessionEnd ?? base.SessionEnd, } } diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index a2daf00391..778b48416f 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -15,6 +15,8 @@ interface RawClaudeHooksConfig { UserPromptSubmit?: RawHookMatcher[] Stop?: RawHookMatcher[] PreCompact?: RawHookMatcher[] + SessionStart?: RawHookMatcher[] + SessionEnd?: RawHookMatcher[] } function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher { @@ -32,6 +34,8 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig { "UserPromptSubmit", "Stop", "PreCompact", + "SessionStart", + "SessionEnd", ] for (const eventType of eventTypes) { @@ -71,6 +75,8 @@ function mergeHooksConfig( "UserPromptSubmit", "Stop", "PreCompact", + "SessionStart", + "SessionEnd", ] for (const eventType of eventTypes) { if (override[eventType]) { diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts index 4c845004c5..4b10bcd65e 100644 --- a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { loadClaudeHooksConfig } from "../config" import { loadPluginExtendedConfig } from "../config-loader" import { executeStopHooks, type StopContext } from "../stop" +import { executeSessionStartHooks, executeSessionEndHooks } from "../session-start" import type { PluginConfig } from "../types" import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared" import { @@ -26,12 +27,56 @@ export function createSessionEventHandler(ctx: PluginInput, config: PluginConfig return } + if (event.type === "session.created") { + const props = event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string; parentID?: string } | undefined + if (sessionInfo?.parentID) return + + if (!isHookDisabled(config, "SessionStart")) { + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + const result = await executeSessionStartHooks( + { sessionId: sessionInfo?.id ?? "", cwd: ctx.directory }, + claudeConfig, + extendedConfig + ) + for (const message of result.messages) { + if (message.trim()) { + ctx.client.session + .prompt({ + path: { id: sessionInfo?.id ?? "" }, + body: { + parts: [createInternalAgentTextPart(message)], + }, + query: { directory: ctx.directory }, + }) + .catch((err: unknown) => + log("Failed to inject SessionStart hook output", { error: String(err) }), + ) + } + } + } + return + } + if (event.type === "session.deleted") { const props = event.properties as Record | undefined - const sessionInfo = props?.info as { id?: string } | undefined + const sessionInfo = props?.info as { id?: string; parentID?: string } | undefined if (sessionInfo?.id) { clearSessionHookState(sessionInfo.id) } + + if (!sessionInfo?.parentID && !isHookDisabled(config, "SessionEnd")) { + const claudeConfig = await loadClaudeHooksConfig() + const extendedConfig = await loadPluginExtendedConfig() + executeSessionEndHooks( + { sessionId: sessionInfo?.id ?? "", cwd: ctx.directory }, + claudeConfig, + extendedConfig + ).catch((err: unknown) => + log("SessionEnd hook error", { error: String(err) }), + ) + } return } 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 0000000000..aa6113754f --- /dev/null +++ b/src/hooks/claude-code-hooks/session-start.ts @@ -0,0 +1,114 @@ +import type { + SessionStartInput, + SessionEndInput, + ClaudeHooksConfig, +} from "./types" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" +import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" + +export interface SessionStartContext { + sessionId: string + parentSessionId?: string + cwd: string +} + +export interface SessionStartResult { + messages: string[] +} + +export interface SessionEndResult { + block: false +} + +export async function executeSessionStartHooks( + ctx: SessionStartContext, + config: ClaudeHooksConfig | null, + extendedConfig?: PluginExtendedConfig | null +): Promise { + if (ctx.parentSessionId) { + return { messages: [] } + } + + if (!config) { + return { messages: [] } + } + + const matchers = findMatchingHooks(config, "SessionStart") + if (matchers.length === 0) { + return { messages: [] } + } + + const stdinData: SessionStartInput = { + session_id: ctx.sessionId, + cwd: ctx.cwd, + hook_event_name: "SessionStart", + hook_source: "opencode-plugin", + } + + const messages: string[] = [] + + for (const matcher of matchers) { + if (!matcher.hooks || matcher.hooks.length === 0) continue + for (const hook of matcher.hooks) { + if (hook.type !== "command" && hook.type !== "http") continue + + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("SessionStart", hookName, extendedConfig ?? null)) { + log("SessionStart hook command skipped (disabled by config)", { command: hookName }) + continue + } + + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) + + if (result.stdout && result.stdout.trim()) { + messages.push(result.stdout.trim()) + } + } + } + + return { messages } +} + +export async function executeSessionEndHooks( + ctx: SessionStartContext, + config: ClaudeHooksConfig | null, + extendedConfig?: PluginExtendedConfig | null +): Promise { + if (ctx.parentSessionId) { + return { block: false } + } + + if (!config) { + return { block: false } + } + + const matchers = findMatchingHooks(config, "SessionEnd") + if (matchers.length === 0) { + return { block: false } + } + + const stdinData: SessionEndInput = { + session_id: ctx.sessionId, + cwd: ctx.cwd, + hook_event_name: "SessionEnd", + hook_source: "opencode-plugin", + } + + for (const matcher of matchers) { + if (!matcher.hooks || matcher.hooks.length === 0) continue + for (const hook of matcher.hooks) { + if (hook.type !== "command" && hook.type !== "http") continue + + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("SessionEnd", hookName, extendedConfig ?? null)) { + log("SessionEnd hook command skipped (disabled by config)", { command: hookName }) + continue + } + + await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) + } + } + + return { block: false } +} diff --git a/src/hooks/claude-code-hooks/types.ts b/src/hooks/claude-code-hooks/types.ts index 28924de105..544c21b19d 100644 --- a/src/hooks/claude-code-hooks/types.ts +++ b/src/hooks/claude-code-hooks/types.ts @@ -9,6 +9,8 @@ export type ClaudeHookEvent = | "UserPromptSubmit" | "Stop" | "PreCompact" + | "SessionStart" + | "SessionEnd" export interface HookMatcher { matcher: string @@ -36,6 +38,8 @@ export interface ClaudeHooksConfig { UserPromptSubmit?: HookMatcher[] Stop?: HookMatcher[] PreCompact?: HookMatcher[] + SessionStart?: HookMatcher[] + SessionEnd?: HookMatcher[] } export interface PreToolUseInput { @@ -101,6 +105,20 @@ export interface PreCompactInput { hook_source?: HookSource } +export interface SessionStartInput { + session_id: string + cwd: string + hook_event_name: "SessionStart" + hook_source?: HookSource +} + +export interface SessionEndInput { + session_id: string + cwd: string + hook_event_name: "SessionEnd" + hook_source?: HookSource +} + export type PermissionDecision = "allow" | "deny" | "ask" /**