Files
oh-my-opencode/src/plugin/event.ts
T
YeonGyu-Kim 1717050f73 feat(event): normalize session.status to session.idle
Add session-status-normalizer to handle session.status events and
convert idle status to synthetic session.idle events. Includes
deduplication logic to prevent duplicate idle events within 500ms.
2026-02-10 11:41:45 +09:00

160 lines
5.8 KiB
TypeScript

import type { OhMyOpenCodeConfig } from "../config"
import type { PluginContext } from "./types"
import {
clearSessionAgent,
getMainSessionID,
setMainSession,
updateSessionAgent,
} from "../features/claude-code-session-state"
import { resetMessageCursor } from "../shared"
import { lspManager } from "../tools"
import type { CreatedHooks } from "../create-hooks"
import type { Managers } from "../create-managers"
import { normalizeSessionStatusToIdle } from "./session-status-normalizer"
type FirstMessageVariantGate = {
markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void
clear: (sessionID: string) => void
}
export function createEventHandler(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
firstMessageVariantGate: FirstMessageVariantGate
managers: Managers
hooks: CreatedHooks
}): (input: { event: { type: string; properties?: Record<string, unknown> } }) => Promise<void> {
const { ctx, firstMessageVariantGate, managers, hooks } = args
const dispatchToHooks = async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
await Promise.resolve(hooks.autoUpdateChecker?.event?.(input))
await Promise.resolve(hooks.claudeCodeHooks?.event?.(input))
await Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))
await Promise.resolve(hooks.sessionNotification?.(input))
await Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))
await Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))
await Promise.resolve(hooks.contextWindowMonitor?.event?.(input))
await Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))
await Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))
await Promise.resolve(hooks.rulesInjector?.event?.(input))
await Promise.resolve(hooks.thinkMode?.event?.(input))
await Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))
await Promise.resolve(hooks.agentUsageReminder?.event?.(input))
await Promise.resolve(hooks.categorySkillReminder?.event?.(input))
await Promise.resolve(hooks.interactiveBashSession?.event?.(input))
await Promise.resolve(hooks.ralphLoop?.event?.(input))
await Promise.resolve(hooks.stopContinuationGuard?.event?.(input))
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))
await Promise.resolve(hooks.atlasHook?.handler?.(input))
}
const recentSyntheticIdles = new Map<string, number>()
const DEDUP_WINDOW_MS = 500
return async (input): Promise<void> => {
if (input.event.type === "session.idle") {
const sessionID = (input.event.properties as Record<string, unknown> | undefined)?.sessionID as string | undefined
if (sessionID) {
const emittedAt = recentSyntheticIdles.get(sessionID)
if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) {
recentSyntheticIdles.delete(sessionID)
return
}
}
}
await dispatchToHooks(input)
const syntheticIdle = normalizeSessionStatusToIdle(input)
if (syntheticIdle) {
const sessionID = (syntheticIdle.event.properties as Record<string, unknown>)?.sessionID as string
recentSyntheticIdles.set(sessionID, Date.now())
await dispatchToHooks(syntheticIdle)
}
const { event } = input
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") {
const sessionInfo = props?.info as
| { id?: string; title?: string; parentID?: string }
| undefined
if (!sessionInfo?.parentID) {
setMainSession(sessionInfo?.id)
}
firstMessageVariantGate.markSessionCreated(sessionInfo)
await managers.tmuxSessionManager.onSessionCreated(
event as {
type: string
properties?: {
info?: { id?: string; parentID?: string; title?: string }
}
},
)
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id === getMainSessionID()) {
setMainSession(undefined)
}
if (sessionInfo?.id) {
clearSessionAgent(sessionInfo.id)
resetMessageCursor(sessionInfo.id)
firstMessageVariantGate.clear(sessionInfo.id)
await managers.skillMcpManager.disconnectSession(sessionInfo.id)
await lspManager.cleanupTempDirectoryClients()
await managers.tmuxSessionManager.onSessionDeleted({
sessionID: sessionInfo.id,
})
}
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const agent = info?.agent as string | undefined
const role = info?.role as string | undefined
if (sessionID && agent && role === "user") {
updateSessionAgent(sessionID, agent)
}
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error
if (hooks.sessionRecovery?.isRecoverableError(error)) {
const messageInfo = {
id: props?.messageID as string | undefined,
role: "assistant" as const,
sessionID,
error,
}
const recovered = await hooks.sessionRecovery.handleSessionRecovery(messageInfo)
if (
recovered &&
sessionID &&
sessionID === getMainSessionID() &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
})
.catch(() => {})
}
}
}
}
}