Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/hooks/claude-code-hooks/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface DisabledHooksConfig {
PostToolUse?: string[]
UserPromptSubmit?: string[]
PreCompact?: string[]
SessionStart?: string[]
SessionEnd?: string[]
}

export interface PluginExtendedConfig {
Expand Down Expand Up @@ -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,
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/hooks/claude-code-hooks/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface RawClaudeHooksConfig {
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
PreCompact?: RawHookMatcher[]
SessionStart?: RawHookMatcher[]
SessionEnd?: RawHookMatcher[]
}

function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
Expand All @@ -32,6 +34,8 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
"UserPromptSubmit",
"Stop",
"PreCompact",
"SessionStart",
"SessionEnd",
]

for (const eventType of eventTypes) {
Expand Down Expand Up @@ -71,6 +75,8 @@ function mergeHooksConfig(
"UserPromptSubmit",
"Stop",
"PreCompact",
"SessionStart",
"SessionEnd",
]
for (const eventType of eventTypes) {
if (override[eventType]) {
Expand Down
47 changes: 46 additions & 1 deletion src/hooks/claude-code-hooks/handlers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,12 +27,56 @@ export function createSessionEventHandler(ctx: PluginInput, config: PluginConfig
return
}

if (event.type === "session.created") {
const props = event.properties as Record<string, unknown> | 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<string, unknown> | 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
}

Expand Down
114 changes: 114 additions & 0 deletions src/hooks/claude-code-hooks/session-start.ts
Original file line number Diff line number Diff line change
@@ -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<SessionStartResult> {
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<SessionEndResult> {
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 }
}
18 changes: 18 additions & 0 deletions src/hooks/claude-code-hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type ClaudeHookEvent =
| "UserPromptSubmit"
| "Stop"
| "PreCompact"
| "SessionStart"
| "SessionEnd"

export interface HookMatcher {
matcher: string
Expand Down Expand Up @@ -36,6 +38,8 @@ export interface ClaudeHooksConfig {
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
PreCompact?: HookMatcher[]
SessionStart?: HookMatcher[]
SessionEnd?: HookMatcher[]
}

export interface PreToolUseInput {
Expand Down Expand Up @@ -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"

/**
Expand Down