Files
oh-my-opencode/src/hooks/claude-code-hooks/handlers/session-event-handler.ts
T
YeonGyu-Kim 4da48555ee fix(plugin): normalize event session ids
Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events.

Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks.
2026-05-12 12:32:26 +09:00

139 lines
4.5 KiB
TypeScript

import type { PluginInput } from "@opencode-ai/plugin"
import type { ContextCollector } from "../../../features/context-injector"
import { clearClaudeHooksConfigCache, loadClaudeHooksConfig } from "../config"
import { clearPluginExtendedConfigCache, loadPluginExtendedConfig } from "../config-loader"
import { executeStopHooks, type StopContext } from "../stop"
import { clearTranscriptCache } from "../transcript"
import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-cache"
import type { PluginConfig } from "../types"
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
import { resolveSessionEventID } from "../../../shared/event-session-id"
import {
clearAllSessionHookState,
clearSessionHookState,
sessionErrorState,
sessionInterruptState,
} from "../session-hook-state"
export function createSessionEventHandler(
ctx: PluginInput,
config: PluginConfig,
contextCollector?: ContextCollector,
) {
const parentSessionIdCache = new Map<string, string | undefined>()
return async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input
if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
sessionErrorState.set(sessionID, {
hasError: true,
errorMessage: String(props?.error ?? "Unknown error"),
})
}
return
}
if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
parentSessionIdCache.delete(sessionID)
clearTranscriptCache(sessionID)
clearToolInputCache(sessionID)
contextCollector?.clear(sessionID)
clearSessionHookState(sessionID)
}
return
}
if (event.type !== "session.idle") {
return
}
const props = event.properties as Record<string, unknown> | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
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 = parentSessionIdCache.get(sessionID)
if (parentSessionId === undefined && !parentSessionIdCache.has(sessionID)) {
try {
const sessionInfo = await ctx.client.session.get({
path: { id: sessionID },
})
parentSessionId = sessionInfo.data?.parentID
parentSessionIdCache.set(sessionID, parentSessionId)
} catch {
parentSessionId = undefined
}
}
if (!isHookDisabled(config, "Stop")) {
const stopCtx: StopContext = {
sessionId: sessionID,
parentSessionId,
cwd: ctx.directory,
}
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
const errorStateAfter = sessionErrorState.get(sessionID)
const endedWithErrorAfter = errorStateAfter?.hasError === true
const interruptStateAfter = sessionInterruptState.get(sessionID)
const interruptedAfter = interruptStateAfter?.interrupted === true
const shouldBypass =
endedWithErrorBefore ||
endedWithErrorAfter ||
interruptedBefore ||
interruptedAfter
if (shouldBypass && stopResult.block) {
log("Stop hook block ignored", {
sessionID,
block: stopResult.block,
interrupted: interruptedBefore || interruptedAfter,
endedWithError: endedWithErrorBefore || endedWithErrorAfter,
})
} else if (stopResult.block && stopResult.injectPrompt) {
log("Stop hook returned block with inject_prompt", { sessionID })
ctx.client.session
.prompt({
path: { id: sessionID },
body: {
parts: [createInternalAgentTextPart(stopResult.injectPrompt)],
},
query: { directory: ctx.directory },
})
.catch((err: unknown) =>
log("Failed to inject prompt from Stop hook", { error: String(err) }),
)
} else if (stopResult.block) {
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
}
}
clearSessionHookState(sessionID)
}
}
export function disposeSessionEventHandler(contextCollector?: ContextCollector): void {
clearTranscriptCache()
clearClaudeHooksConfigCache()
clearPluginExtendedConfigCache()
stopToolInputCacheCleanup()
contextCollector?.clearAll()
clearAllSessionHookState()
}