import type { OhMyOpenCodeConfig } from "../config"; import type { PluginContext } from "./types"; import { clearSessionAgent, getMainSessionID, getSessionAgent, setMainSession, subagentSessions, syncSubagentSessions, updateSessionAgent, } from "../features/claude-code-session-state"; import type { CreatedHooks } from "../create-hooks"; import type { Managers } from "../create-managers"; import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"; import { normalizeSessionStatusToIdle } from "./session-status-normalizer"; type FirstMessageVariantGate = { markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void; clear: (sessionID: string) => void; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function normalizeFallbackModelID(modelID: string): string { return modelID .replace(/-thinking$/i, "") .replace(/-max$/i, "") .replace(/-high$/i, ""); } function extractErrorName(error: unknown): string | undefined { if (isRecord(error) && typeof error.name === "string") return error.name; if (error instanceof Error) return error.name; return undefined; } function extractErrorMessage(error: unknown): string { if (!error) return ""; if (typeof error === "string") return error; if (error instanceof Error) return error.message; if (isRecord(error)) { const candidates: unknown[] = [ error, error.data, error.error, isRecord(error.data) ? error.data.error : undefined, error.cause, ]; for (const candidate of candidates) { if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) { return candidate.message; } } } try { return JSON.stringify(error); } catch { return String(error); } } function extractProviderModelFromErrorMessage(message: string): { providerID?: string; modelID?: string } { const lower = message.toLowerCase(); const providerModel = lower.match(/model\s+not\s+found:\s*([a-z0-9_-]+)\s*\/\s*([a-z0-9._-]+)/i); if (providerModel) { return { providerID: providerModel[1], modelID: providerModel[2], }; } const modelOnly = lower.match(/unknown\s+provider\s+for\s+model\s+([a-z0-9._-]+)/i); if (modelOnly) { return { modelID: modelOnly[1], }; } return {}; } type EventInput = Parameters["event"]>>[0]; export function createEventHandler(args: { ctx: PluginContext; pluginConfig: OhMyOpenCodeConfig; firstMessageVariantGate: FirstMessageVariantGate; managers: Managers; hooks: CreatedHooks; }): (input: EventInput) => Promise { const { ctx, firstMessageVariantGate, managers, hooks } = args; const pluginContext = ctx as { directory: string; client: { session: { abort: (input: { path: { id: string } }) => Promise; prompt: (input: { path: { id: string }; body: { parts: Array<{ type: "text"; text: string }> }; query: { directory: string }; }) => Promise; }; }; }; const isRuntimeFallbackEnabled = hooks.runtimeFallback !== null && hooks.runtimeFallback !== undefined && (typeof args.pluginConfig.runtime_fallback === "boolean" ? args.pluginConfig.runtime_fallback : (args.pluginConfig.runtime_fallback?.enabled ?? false)); const isModelFallbackEnabled = hooks.modelFallback !== null && hooks.modelFallback !== undefined; // Avoid triggering multiple abort+continue cycles for the same failing assistant message. const lastHandledModelErrorMessageID = new Map(); const lastHandledRetryStatusKey = new Map(); const lastKnownModelBySession = new Map(); async function runHookSafely(hookName: string, runner: () => Promise): Promise { try { await runner() } catch (error) { log("[event] Hook execution failed", { hookName, error: String(error), }) } } const dispatchToHooks = async (input: EventInput): Promise => { // Keep agent switch early and resilient so queued handoffs are not blocked // by unrelated hook failures in the same idle cycle. await runHookSafely("agent-switch", () => Promise.resolve(hooks.agentSwitchHook?.event?.(input))); await runHookSafely("auto-update-checker", () => Promise.resolve(hooks.autoUpdateChecker?.event?.(input))); await runHookSafely("claude-code-hooks", () => Promise.resolve(hooks.claudeCodeHooks?.event?.(input))); await runHookSafely("background-notification", () => Promise.resolve(hooks.backgroundNotificationHook?.event?.(input))); await runHookSafely("session-notification", () => Promise.resolve(hooks.sessionNotification?.(input))); await runHookSafely("todo-continuation-enforcer", () => Promise.resolve(hooks.todoContinuationEnforcer?.handler?.(input))); await runHookSafely("unstable-agent-babysitter", () => Promise.resolve(hooks.unstableAgentBabysitter?.event?.(input))); await runHookSafely("context-window-monitor", () => Promise.resolve(hooks.contextWindowMonitor?.event?.(input))); await runHookSafely("directory-agents-injector", () => Promise.resolve(hooks.directoryAgentsInjector?.event?.(input))); await runHookSafely("directory-readme-injector", () => Promise.resolve(hooks.directoryReadmeInjector?.event?.(input))); await runHookSafely("rules-injector", () => Promise.resolve(hooks.rulesInjector?.event?.(input))); await runHookSafely("think-mode", () => Promise.resolve(hooks.thinkMode?.event?.(input))); await runHookSafely("anthropic-context-window-limit-recovery", () => Promise.resolve(hooks.anthropicContextWindowLimitRecovery?.event?.(input))); await runHookSafely("runtime-fallback", () => Promise.resolve(hooks.runtimeFallback?.event?.(input))); await runHookSafely("agent-usage-reminder", () => Promise.resolve(hooks.agentUsageReminder?.event?.(input))); await runHookSafely("category-skill-reminder", () => Promise.resolve(hooks.categorySkillReminder?.event?.(input))); await runHookSafely("interactive-bash-session", () => Promise.resolve(hooks.interactiveBashSession?.event?.(input as EventInput))); await runHookSafely("ralph-loop", () => Promise.resolve(hooks.ralphLoop?.event?.(input))); await runHookSafely("stop-continuation-guard", () => Promise.resolve(hooks.stopContinuationGuard?.event?.(input))); await runHookSafely("compaction-todo-preserver", () => Promise.resolve(hooks.compactionTodoPreserver?.event?.(input))); await runHookSafely("write-existing-file-guard", () => Promise.resolve(hooks.writeExistingFileGuard?.event?.(input))); await runHookSafely("atlas", () => Promise.resolve(hooks.atlasHook?.handler?.(input))); }; const recentSyntheticIdles = new Map(); const recentRealIdles = new Map(); const DEDUP_WINDOW_MS = 500; const shouldAutoRetrySession = (sessionID: string): boolean => { if (syncSubagentSessions.has(sessionID)) return true; const mainSessionID = getMainSessionID(); if (mainSessionID) return sessionID === mainSessionID; // Headless runs (or resumed sessions) may not emit session.created, so mainSessionID can be unset. // In that case, treat any non-subagent session as the "main" interactive session. return !subagentSessions.has(sessionID); }; return async (input): Promise => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, now: Date.now(), dedupWindowMs: DEDUP_WINDOW_MS, }); if (input.event.type === "session.idle") { const sessionID = (input.event.properties as Record | undefined)?.sessionID as | string | undefined; if (sessionID) { const emittedAt = recentSyntheticIdles.get(sessionID); if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) { recentSyntheticIdles.delete(sessionID); return; } recentRealIdles.set(sessionID, Date.now()); } } await dispatchToHooks(input); const syntheticIdle = normalizeSessionStatusToIdle(input); if (syntheticIdle) { const sessionID = (syntheticIdle.event.properties as Record)?.sessionID as string; const emittedAt = recentRealIdles.get(sessionID); if (emittedAt && Date.now() - emittedAt < DEDUP_WINDOW_MS) { recentRealIdles.delete(sessionID); return; } recentSyntheticIdles.set(sessionID, Date.now()); await dispatchToHooks(syntheticIdle as EventInput); } const { event } = input; const props = event.properties as Record | 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); lastHandledModelErrorMessageID.delete(sessionInfo.id); lastHandledRetryStatusKey.delete(sessionInfo.id); lastKnownModelBySession.delete(sessionInfo.id); clearPendingModelFallback(sessionInfo.id); clearSessionFallbackChain(sessionInfo.id); resetMessageCursor(sessionInfo.id); firstMessageVariantGate.clear(sessionInfo.id); clearSessionModel(sessionInfo.id); syncSubagentSessions.delete(sessionInfo.id); deleteSessionTools(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 | undefined; const sessionID = info?.sessionID as string | undefined; const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; if (sessionID && role === "user") { if (agent) { updateSessionAgent(sessionID, agent); } const providerID = info?.providerID as string | undefined; const modelID = info?.modelID as string | undefined; if (providerID && modelID) { lastKnownModelBySession.set(sessionID, { providerID, modelID }); setSessionModel(sessionID, { providerID, modelID }); } } // Model fallback: in practice, API/model failures often surface as assistant message errors. // session.error events are not guaranteed for all providers, so we also observe message.updated. if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled && isModelFallbackEnabled) { try { const assistantMessageID = info?.id as string | undefined; const assistantError = info?.error; if (assistantMessageID && assistantError) { const lastHandled = lastHandledModelErrorMessageID.get(sessionID); if (lastHandled === assistantMessageID) { return; } const errorName = extractErrorName(assistantError); const errorMessage = extractErrorMessage(assistantError); const errorInfo = { name: errorName, message: errorMessage }; if (shouldRetryError(errorInfo)) { // Prefer the agent/model/provider from the assistant message payload. let agentName = agent ?? getSessionAgent(sessionID); if (!agentName && sessionID === getMainSessionID()) { if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) { agentName = "sisyphus"; } else if (errorMessage.includes("gpt-5")) { agentName = "hephaestus"; } else { agentName = "sisyphus"; } } if (agentName) { const currentProvider = (info?.providerID as string | undefined) ?? "opencode"; const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-6"; const currentModel = normalizeFallbackModelID(rawModel); const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel); if ( setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID) ) { lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {}); await pluginContext.client.session .prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: "continue" }] }, query: { directory: pluginContext.directory }, }) .catch(() => {}); } } } } } catch (err) { log("[event] model-fallback error in message.updated:", { sessionID, error: err }); } } } if (event.type === "session.status") { const sessionID = props?.sessionID as string | undefined; const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; if (sessionID && status?.type === "retry" && isModelFallbackEnabled) { try { const retryMessage = typeof status.message === "string" ? status.message : ""; const retryKey = `${status.attempt ?? "?"}:${status.next ?? "?"}:${retryMessage}`; if (lastHandledRetryStatusKey.get(sessionID) === retryKey) { return; } lastHandledRetryStatusKey.set(sessionID, retryKey); const errorInfo = { name: undefined as string | undefined, message: retryMessage }; if (shouldRetryError(errorInfo)) { let agentName = getSessionAgent(sessionID); if (!agentName && sessionID === getMainSessionID()) { if (retryMessage.includes("claude-opus") || retryMessage.includes("opus")) { agentName = "sisyphus"; } else if (retryMessage.includes("gpt-5")) { agentName = "hephaestus"; } else { agentName = "sisyphus"; } } if (agentName) { const parsed = extractProviderModelFromErrorMessage(retryMessage); const lastKnown = lastKnownModelBySession.get(sessionID); const currentProvider = parsed.providerID ?? lastKnown?.providerID ?? "opencode"; let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-6"; currentModel = normalizeFallbackModelID(currentModel); const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel); if ( setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID) ) { await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {}); await pluginContext.client.session .prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: "continue" }] }, query: { directory: pluginContext.directory }, }) .catch(() => {}); } } } } catch (err) { log("[event] model-fallback error in session.status:", { sessionID, error: err }); } } } if (event.type === "session.error") { try { const sessionID = props?.sessionID as string | undefined; const error = props?.error; const errorName = extractErrorName(error); const errorMessage = extractErrorMessage(error); const errorInfo = { name: errorName, message: errorMessage }; // First, try session recovery for internal errors (thinking blocks, tool results, etc.) 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 pluginContext.client.session .prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: "continue" }] }, query: { directory: pluginContext.directory }, }) .catch(() => {}); } } // Second, try model fallback for model errors (rate limit, quota, provider issues, etc.) else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled && isModelFallbackEnabled) { let agentName = getSessionAgent(sessionID); if (!agentName && sessionID === getMainSessionID()) { if (errorMessage.includes("claude-opus") || errorMessage.includes("opus")) { agentName = "sisyphus"; } else if (errorMessage.includes("gpt-5")) { agentName = "hephaestus"; } else { agentName = "sisyphus"; } } if (agentName) { const parsed = extractProviderModelFromErrorMessage(errorMessage); const currentProvider = (props?.providerID as string) || parsed.providerID || "opencode"; let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-6"; currentModel = normalizeFallbackModelID(currentModel); const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel); if ( setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID) ) { await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {}); await pluginContext.client.session .prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: "continue" }] }, query: { directory: pluginContext.directory }, }) .catch(() => {}); } } } } catch (err) { const sessionID = props?.sessionID as string | undefined; log("[event] model-fallback error in session.error:", { sessionID, error: err }); } } }; }