import type { OhMyOpenCodeConfig } from "../config"; import type { PluginInput } from "@opencode-ai/plugin"; import type { PluginContext } from "./types"; import { clearSessionAgent, getMainSessionID, getSessionAgent, resolveRegisteredAgentName, setMainSession, subagentSessions, syncSubagentSessions, updateSessionAgent, } from "../features/claude-code-session-state"; import { clearPendingModelFallback, clearSessionFallbackChain, setSessionFallbackChain, setPendingModelFallback, type ModelFallbackHook, } from "../hooks/model-fallback/hook"; import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models"; import { clearBackgroundOutputConsumptionsForParentSession, clearBackgroundOutputConsumptionsForTaskSession, restoreBackgroundOutputConsumption, } from "../shared/background-output-consumption"; import { createInternalAgentContinuationTextPart, resetMessageCursor } from "../shared"; import { getAgentConfigKey } from "../shared/agent-display-names"; import { readConnectedProvidersCache } from "../shared/connected-providers-cache"; import { invalidateContextWindowUsageCache } from "../shared/dynamic-truncator"; import { log } from "../shared/logger"; import { shouldRetryError } from "../shared/model-error-classifier"; import { buildFallbackChainFromModels } from "../shared/fallback-chain-from-models"; import { extractRetryAttempt, normalizeRetryStatusMessage } from "../shared/retry-status-utils"; import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"; import { clearSessionPromptParams } from "../shared/session-prompt-params-state"; import { deleteSessionTools } from "../shared/session-tools-store"; import { lspManager } from "../tools"; import { dispatchOpenClawEvent } from "../openclaw/runtime-dispatch"; import { createTeamIdleWakeHint } from "../hooks/team-session-events/team-idle-wake-hint"; import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler"; import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler"; import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler"; import { promptAfterSessionIdle, promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../hooks/shared/prompt-async-gate"; import type { CreatedHooks } from "../create-hooks"; import type { Managers } from "../create-managers"; import { pruneRecentSyntheticIdles } from "./recent-synthetic-idles"; import { normalizeSessionStatusToIdle } from "./session-status-normalizer"; import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"; type FirstMessageVariantGate = { markSessionCreated: (sessionInfo: { id?: string; title?: string; parentID?: string } | undefined) => void; clear: (sessionID: string) => void; }; type FallbackContinuationDedupeKeys = { modelKey?: string; providerModelKey?: string; }; type FallbackContinuationDedupeState = { modelKeys: Set; providerModelKeys: Set; providerlessModelKeys: Set; }; type FallbackContinuationContext = { agentName?: string; providerID?: string; dedupeProviderID?: string; modelID?: string; }; 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; } export function extractErrorMessage(error: unknown): string { if (!error) return ""; if (typeof error === "string") return error; if (isRecord(error)) { const candidates: unknown[] = [ error.data, isRecord(error.data) ? error.data.error : undefined, error.error, error.cause, error, ]; for (const candidate of candidates) { if (isRecord(candidate) && typeof candidate.message === "string" && candidate.message.length > 0) { return candidate.message; } } } if (error instanceof Error) return error.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 {}; } function applyUserConfiguredFallbackChain( modelFallback: Pick | null | undefined, sessionID: string, agentName: string, currentProviderID: string, pluginConfig: OhMyOpenCodeConfig, ): void { const agentKey = getAgentConfigKey(agentName); const rawFallbackModels = getRawFallbackModels(sessionID, agentKey, pluginConfig); if (!rawFallbackModels || rawFallbackModels.length === 0) return; const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID); if (fallbackChain && fallbackChain.length > 0) { if (modelFallback) { setSessionFallbackChain(modelFallback, sessionID, fallbackChain); } } } function isCompactionAgent(agent: string): boolean { return agent.toLowerCase() === "compaction"; } type EventInput = Parameters["event"]>>[0]; export function createEventHandler(args: { ctx: PluginContext; pluginConfig: OhMyOpenCodeConfig; firstMessageVariantGate: FirstMessageVariantGate; managers: Managers; hooks: CreatedHooks; }): (input: EventInput) => Promise { const { ctx, pluginConfig, firstMessageVariantGate, managers, hooks } = args; const tmuxIntegrationEnabled = pluginConfig.tmux?.enabled ?? false; const pluginContext = ctx as PluginContext & { directory: string; client: { session: { abort: (input: { path: { id: string } }) => Promise; promptAsync?: (input: { path: { id: string }; body: { parts: Array<{ type: "text"; text: string; synthetic?: boolean; metadata?: Record; }>; agent?: string; model?: { providerID: string; modelID: string }; variant?: string; }; query: { directory: string }; }) => Promise; prompt: (input: { path: { id: string }; body: { parts: Array<{ type: "text"; text: string; synthetic?: boolean; metadata?: Record; }>; agent?: string; model?: { providerID: string; modelID: string }; variant?: string; }; query: { directory: string }; }) => Promise; summarize: { (input: { path: { id: string }; body: { providerID: string; modelID: string; auto?: boolean }; query: { directory: string }; }): Promise; (input: { path: { id: string }; body: { auto: boolean }; 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; const modelFallback = hooks.modelFallback; // Avoid triggering multiple abort+continue cycles for the same failing assistant message. const lastHandledModelErrorMessageID = new Map(); const lastHandledRetryStatusKey = new Map(); const lastKnownModelBySession = new Map(); const modelFallbackContinuationsInFlight = new Set(); const lastDispatchedModelFallbackContinuationKeys = new Map(); const resolveFallbackProviderID = (sessionID: string, providerHint?: string): string => { const normalizedProviderHint = providerHint?.trim(); if (normalizedProviderHint) { return normalizedProviderHint; } const sessionModel = getSessionModel(sessionID); if (sessionModel?.providerID) { return sessionModel.providerID; } const lastKnownModel = lastKnownModelBySession.get(sessionID); if (lastKnownModel?.providerID) { return lastKnownModel.providerID; } const connectedProvider = readConnectedProvidersCache()?.[0]; if (connectedProvider) { return connectedProvider; } return "opencode"; }; const getEventSessionID = (input: EventInput): string | undefined => { const properties = input.event.properties; if (input.event.type.startsWith("session.")) { return resolveSessionEventID(properties); } if (input.event.type.startsWith("message.") || input.event.type.startsWith("tool.")) { return resolveMessageEventSessionID(properties); } const record: Record | undefined = isRecord(properties) ? properties : undefined; const sessionID = record?.sessionID; return typeof sessionID === "string" && sessionID.length > 0 ? sessionID : undefined; }; const runEventHookSafely = async ( hookName: string, handler: ((input: EventInput) => unknown | Promise) | null | undefined, input: EventInput, ): Promise => { if (!handler) return; try { await Promise.resolve(handler(input)); } catch (error) { log("[event] hook execution failed", { hook: hookName, eventType: input.event.type, sessionID: getEventSessionID(input), error, }); } }; const dispatchToHooks = async (input: EventInput): Promise => { await runEventHookSafely("autoUpdateChecker", hooks.autoUpdateChecker?.event, input); await runEventHookSafely("legacyPluginToast", hooks.legacyPluginToast?.event, input); await runEventHookSafely("claudeCodeHooks", hooks.claudeCodeHooks?.event, input); await runEventHookSafely("backgroundNotificationHook", hooks.backgroundNotificationHook?.event, input); await runEventHookSafely("sessionNotification", hooks.sessionNotification, input); await runEventHookSafely("todoContinuationEnforcer", hooks.todoContinuationEnforcer?.handler, input); await runEventHookSafely("unstableAgentBabysitter", hooks.unstableAgentBabysitter?.event, input); await runEventHookSafely("contextWindowMonitor", hooks.contextWindowMonitor?.event, input); await runEventHookSafely("preemptiveCompaction", hooks.preemptiveCompaction?.event, input); await runEventHookSafely("directoryAgentsInjector", hooks.directoryAgentsInjector?.event, input); await runEventHookSafely("directoryReadmeInjector", hooks.directoryReadmeInjector?.event, input); await runEventHookSafely("rulesInjector", hooks.rulesInjector?.event, input); await runEventHookSafely("thinkMode", hooks.thinkMode?.event, input); await runEventHookSafely( "anthropicContextWindowLimitRecovery", hooks.anthropicContextWindowLimitRecovery?.event, input, ); await runEventHookSafely("runtimeFallback", hooks.runtimeFallback?.event, input); await runEventHookSafely("agentUsageReminder", hooks.agentUsageReminder?.event, input); await runEventHookSafely("categorySkillReminder", hooks.categorySkillReminder?.event, input); await runEventHookSafely("interactiveBashSession", hooks.interactiveBashSession?.event, input as EventInput); await runEventHookSafely("ralphLoop", hooks.ralphLoop?.event, input); await runEventHookSafely("stopContinuationGuard", hooks.stopContinuationGuard?.event, input); await runEventHookSafely("compactionContextInjector", hooks.compactionContextInjector?.event, input); await runEventHookSafely("compactionTodoPreserver", hooks.compactionTodoPreserver?.event, input); await runEventHookSafely("writeExistingFileGuard", hooks.writeExistingFileGuard?.event, input); await runEventHookSafely("atlasHook", hooks.atlasHook?.handler, input); await runEventHookSafely("autoSlashCommand", hooks.autoSlashCommand?.event, input); }; const recentSyntheticIdles = new Map(); const recentRealIdles = new Map(); const recentAnyIdles = new Map(); const DEDUP_WINDOW_MS = 500; const teamModeConfig = pluginConfig.team_mode?.enabled ? pluginConfig.team_mode : undefined; const teamLeadOrphanHandler = teamModeConfig ? createTeamLeadOrphanHandler(teamModeConfig, managers.tmuxSessionManager, managers.backgroundManager) : undefined; const teamMemberErrorHandler = teamModeConfig ? createTeamMemberErrorHandler(teamModeConfig) : undefined; const teamMemberStatusHandler = teamModeConfig ? createTeamMemberStatusHandler(teamModeConfig) : undefined; const teamIdleWakeHint = teamModeConfig && pluginContext.client.session?.promptAsync ? createTeamIdleWakeHint({ directory: pluginContext.directory, client: { session: { promptAsync: pluginContext.client.session.promptAsync, status: pluginContext.client.session.status, }, }, }, teamModeConfig) : undefined; const TMUX_ACTIVITY_EVENT_TYPES = new Set([ "message.updated", "message.part.updated", "message.part.delta", "message.part.removed", "message.removed", ]); 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); }; const shouldDispatchIdleEvent = (sessionID: string, now: number): boolean => { const lastDispatchedAt = recentAnyIdles.get(sessionID); if (lastDispatchedAt !== undefined && now - lastDispatchedAt < DEDUP_WINDOW_MS) { return false; } recentAnyIdles.set(sessionID, now); return true; }; const getFallbackContinuationKeys = (fallbackContext?: FallbackContinuationContext): FallbackContinuationDedupeKeys => { const agentKey = fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName).trim().toLowerCase() : ""; const providerID = fallbackContext?.dedupeProviderID?.trim().toLowerCase() ?? ""; const modelID = fallbackContext?.modelID?.trim().toLowerCase() ?? ""; if (!agentKey || !modelID) { return {}; } return { modelKey: `${agentKey}:${modelID}`, ...(providerID ? { providerModelKey: `${agentKey}:${providerID}:${modelID}` } : {}), }; }; const getFallbackContinuationDedupeState = (sessionID: string): FallbackContinuationDedupeState => { const existingState = lastDispatchedModelFallbackContinuationKeys.get(sessionID); if (existingState) { return existingState; } const state = { modelKeys: new Set(), providerModelKeys: new Set(), providerlessModelKeys: new Set(), }; lastDispatchedModelFallbackContinuationKeys.set(sessionID, state); return state; }; const wasFallbackContinuationAlreadyDispatched = ( state: FallbackContinuationDedupeState | undefined, keys: FallbackContinuationDedupeKeys, ): boolean => { if (!state || !keys.modelKey) { return false; } if (!keys.providerModelKey) { return state.modelKeys.has(keys.modelKey); } return state.providerModelKeys.has(keys.providerModelKey) || state.providerlessModelKeys.has(keys.modelKey); }; const shouldSkipFallbackContinuation = ( sessionID: string, source: string, fallbackContext?: FallbackContinuationContext, ): boolean => { const fallbackKeys = getFallbackContinuationKeys(fallbackContext); if (modelFallbackContinuationsInFlight.has(sessionID)) { log("[event] model-fallback continuation skipped because one is already in flight", { sessionID, source }); return true; } const lastDispatchedKeys = lastDispatchedModelFallbackContinuationKeys.get(sessionID); if (wasFallbackContinuationAlreadyDispatched(lastDispatchedKeys, fallbackKeys)) { log("[event] model-fallback continuation skipped because matching fallback was already dispatched", { sessionID, source, }); return true; } return false; }; const autoContinueAfterFallback = async ( sessionID: string, source: string, fallbackContext?: FallbackContinuationContext, ): Promise => { const fallbackKeys = getFallbackContinuationKeys(fallbackContext); if (shouldSkipFallbackContinuation(sessionID, source, fallbackContext)) { return; } modelFallbackContinuationsInFlight.add(sessionID); let dispatched = false; try { await pluginContext.client.session.abort({ path: { id: sessionID } }).catch((error) => { log("[event] model-fallback abort failed", { sessionID, source, error }); }); releasePromptAsyncReservation(sessionID, `model-fallback-abort:${source}`, { reservedBy: [`model-fallback:${source}`, `model-fallback:${source}:sync`], reservedByPrefix: "model-fallback:", }); const launchAgent = fallbackContext?.agentName ? resolveRegisteredAgentName(fallbackContext.agentName) : undefined; const launchModel = fallbackContext?.providerID && fallbackContext?.modelID ? { providerID: fallbackContext.providerID, modelID: fallbackContext.modelID } : undefined; const agentConfigKey = fallbackContext?.agentName ? getAgentConfigKey(fallbackContext.agentName) : undefined; const agentSettings = agentConfigKey ? pluginConfig.agents?.[agentConfigKey as keyof NonNullable] : undefined; const launchVariant = (agentSettings as { variant?: string } | undefined)?.variant; const promptBody = { path: { id: sessionID }, body: { ...(launchAgent ? { agent: launchAgent } : {}), ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), parts: [createInternalAgentContinuationTextPart("continue")], }, query: { directory: pluginContext.directory }, }; if (typeof pluginContext.client.session.promptAsync === "function") { const promptResult = await promptAsyncAfterSessionIdle({ client: pluginContext.client, sessionID, source: `model-fallback:${source}`, input: promptBody, }); if (promptResult.status === "dispatched") { dispatched = true; } else if (promptResult.status === "failed") { const error = promptResult.error; log("[event] model-fallback promptAsync failed", { sessionID, source, error }); } else { log("[event] model-fallback promptAsync skipped by gate", { sessionID, source, status: promptResult.status }); } return; } const promptResult = await promptAfterSessionIdle({ client: pluginContext.client, sessionID, source: `model-fallback:${source}:sync`, input: promptBody, }); if (promptResult.status === "dispatched") { dispatched = true; } else if (promptResult.status === "failed") { log("[event] model-fallback prompt failed", { sessionID, source, error: promptResult.error }); } else { log("[event] model-fallback prompt skipped by gate", { sessionID, source, status: promptResult.status }); } } finally { if (dispatched && fallbackKeys.modelKey) { const dispatchedKeys = getFallbackContinuationDedupeState(sessionID); dispatchedKeys.modelKeys.add(fallbackKeys.modelKey); if (fallbackKeys.providerModelKey) { dispatchedKeys.providerModelKeys.add(fallbackKeys.providerModelKey); } else { dispatchedKeys.providerlessModelKeys.add(fallbackKeys.modelKey); } } modelFallbackContinuationsInFlight.delete(sessionID); } }; return async (input): Promise => { pruneRecentSyntheticIdles({ recentSyntheticIdles, recentRealIdles, recentAnyIdles, now: Date.now(), dedupWindowMs: DEDUP_WINDOW_MS, }); if (input.event.type === "session.idle") { const sessionID = getEventSessionID(input); if (sessionID) { const now = Date.now(); const emittedAt = recentSyntheticIdles.get(sessionID); if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) { recentSyntheticIdles.delete(sessionID); // Let real idle events through even when a synthetic idle fired moments earlier. // OpenCode diagnostics expect a concrete session.idle event signal. const lastAnyIdleAt = recentAnyIdles.get(sessionID); if (lastAnyIdleAt === emittedAt) { recentAnyIdles.delete(sessionID); } } recentRealIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) { return; } } } await dispatchToHooks(input); const syntheticIdle = normalizeSessionStatusToIdle(input); if (syntheticIdle) { const sessionID = (syntheticIdle.event.properties as Record)?.sessionID as string; const now = Date.now(); const emittedAt = recentRealIdles.get(sessionID); if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) { recentRealIdles.delete(sessionID); return; } recentSyntheticIdles.set(sessionID, now); if (!shouldDispatchIdleEvent(sessionID, now)) { return; } await dispatchToHooks(syntheticIdle as EventInput); if (pluginConfig.openclaw) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: "session.idle", context: { sessionId: sessionID, projectPath: pluginContext.directory, tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } } const { event } = input; const props = event.properties as Record | undefined; if (tmuxIntegrationEnabled && TMUX_ACTIVITY_EVENT_TYPES.has(event.type)) { managers.tmuxSessionManager.onEvent?.(event as { type: string; properties?: Record }); } if (event.type === "session.created") { const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined; const sessionID = resolveSessionEventID(props); const isSubagentSession = !!sessionInfo?.parentID || !!sessionID && subagentSessions.has(sessionID); if (!isSubagentSession) { setMainSession(sessionID); } firstMessageVariantGate.markSessionCreated(sessionInfo); // Subagent sessions are registered by the specialized background/delegate callbacks. if (tmuxIntegrationEnabled && !isSubagentSession) { await managers.tmuxSessionManager.onSessionCreated( event as { type: string; properties?: { info?: { id?: string; parentID?: string; title?: string }; }; }, ); } // Skip subagent sessions — they are dispatched by specialized callbacks // in create-managers.ts (async) and tool-registry.ts (sync) if (pluginConfig.openclaw && sessionID && !isSubagentSession) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { sessionId: sessionID, projectPath: pluginContext.directory, tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } } if (event.type === "session.deleted") { const sessionID = resolveSessionEventID(props); if (sessionID === getMainSessionID()) { setMainSession(undefined); } if (sessionID) { const wasSyncSubagentSession = syncSubagentSessions.has(sessionID); clearSessionAgent(sessionID); lastHandledModelErrorMessageID.delete(sessionID); lastHandledRetryStatusKey.delete(sessionID); lastKnownModelBySession.delete(sessionID); modelFallbackContinuationsInFlight.delete(sessionID); lastDispatchedModelFallbackContinuationKeys.delete(sessionID); if (modelFallback) { clearPendingModelFallback(modelFallback, sessionID); clearSessionFallbackChain(modelFallback, sessionID); } resetMessageCursor(sessionID); clearBackgroundOutputConsumptionsForParentSession(sessionID); clearBackgroundOutputConsumptionsForTaskSession(sessionID); firstMessageVariantGate.clear(sessionID); clearSessionModel(sessionID); clearSessionPromptParams(sessionID); syncSubagentSessions.delete(sessionID); if (pluginConfig.openclaw) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { sessionId: sessionID, projectPath: pluginContext.directory, tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } if (wasSyncSubagentSession) { subagentSessions.delete(sessionID); } deleteSessionTools(sessionID); await managers.skillMcpManager.disconnectSession(sessionID); await lspManager.cleanupTempDirectoryClients(); if (tmuxIntegrationEnabled) { await managers.tmuxSessionManager.onSessionDeleted({ sessionID, }); } } await runEventHookSafely("teamLeadOrphanHandler", teamLeadOrphanHandler, input); await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input); } if (event.type === "message.removed") { const messageID = props?.messageID as string | undefined; const sessionID = resolveMessageEventSessionID(props); restoreBackgroundOutputConsumption(sessionID, messageID); } if (event.type === "session.idle" && pluginConfig.openclaw) { const sessionID = resolveSessionEventID(props); if (sessionID) { await dispatchOpenClawEvent({ config: pluginConfig.openclaw, rawEvent: event.type, context: { sessionId: sessionID, projectPath: pluginContext.directory, tmuxPaneId: managers.tmuxSessionManager.getTrackedPaneId?.(sessionID) ?? process.env.TMUX_PANE, }, }); } } if (event.type === "session.idle") { managers.tmuxSessionManager?.onEvent?.(event); await runEventHookSafely("teamIdleWakeHint", teamIdleWakeHint, input); await runEventHookSafely("teamMemberStatusHandler", teamMemberStatusHandler, input); } if (event.type === "message.updated") { const info = props?.info as Record | undefined; const sessionID = resolveMessageEventSessionID(props); const agent = info?.agent as string | undefined; const role = info?.role as string | undefined; if (sessionID && info?.finish === true) { invalidateContextWindowUsageCache(pluginContext as PluginInput, sessionID); } if (sessionID && role === "user") { const isCompactionMessage = agent ? isCompactionAgent(agent) : false; if (agent && !isCompactionMessage) { updateSessionAgent(sessionID, agent); } const providerID = info?.providerID as string | undefined; const modelID = info?.modelID as string | undefined; if (providerID && modelID && !isCompactionMessage) { 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 providerHint = info?.providerID as string | undefined; const currentProvider = resolveFallbackProviderID(sessionID, providerHint); const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7"; const currentModel = normalizeFallbackModelID(rawModel); const fallbackContext = { agentName, providerID: currentProvider, dedupeProviderID: providerHint, modelID: currentModel, }; const shouldAutoContinue = shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID); if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "message.updated", fallbackContext)) { applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); const setFallback = modelFallback ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) : false; if (setFallback && shouldAutoContinue) { lastHandledModelErrorMessageID.set(sessionID, assistantMessageID); await autoContinueAfterFallback(sessionID, "message.updated", fallbackContext); } } } } } } catch (err) { log("[event] model-fallback error in message.updated:", { sessionID, error: err }); } } } if (event.type === "session.status") { const sessionID = resolveSessionEventID(props); const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined; // Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery // (non-retry idle) so future failures with the same key can trigger fallback again. if (sessionID && status?.type === "idle") { lastHandledRetryStatusKey.delete(sessionID); lastDispatchedModelFallbackContinuationKeys.delete(sessionID); } if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) { try { const retryMessage = typeof status.message === "string" ? status.message : ""; const parsedForKey = extractProviderModelFromErrorMessage(retryMessage); const retryAttempt = extractRetryAttempt(status.attempt, retryMessage); // Deduplicate countdown updates for the same retry attempt/model. // Messages like "retrying in 7m 56s" change every second but should only trigger once. const retryKey = `${retryAttempt}:${parsedForKey.providerID ?? ""}/${parsedForKey.modelID ?? ""}:${normalizeRetryStatusMessage(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 = resolveFallbackProviderID(sessionID, parsed.providerID); let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7"; currentModel = normalizeFallbackModelID(currentModel); const fallbackContext = { agentName, providerID: currentProvider, dedupeProviderID: parsed.providerID, modelID: currentModel, }; const shouldAutoContinue = shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID); if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "session.status", fallbackContext)) { applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); const setFallback = modelFallback ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) : false; if (setFallback && shouldAutoContinue) { await autoContinueAfterFallback(sessionID, "session.status", fallbackContext); } } } } } catch (err) { log("[event] model-fallback error in session.status:", { sessionID, error: err }); } } } if (event.type === "session.error") { try { const sessionID = resolveSessionEventID(props); 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) ) { // Trigger compaction before sending "continue" to avoid double-sending continuation await pluginContext.client.session .summarize({ path: { id: sessionID }, body: { auto: true }, query: { directory: pluginContext.directory }, }) .catch((err: unknown) => { log("[event] compaction before recovery continue failed:", { sessionID, error: err }); }); const promptResult = await promptAfterSessionIdle({ client: pluginContext.client, sessionID, source: "session-recovery:post-compaction-continue", input: { path: { id: sessionID }, body: { parts: [createInternalAgentContinuationTextPart("continue")] }, query: { directory: pluginContext.directory }, }, }); if (promptResult.status === "failed") { log("[event] recovery continue prompt failed", { sessionID, error: promptResult.error }); } else if (promptResult.status !== "dispatched") { log("[event] recovery continue prompt skipped by gate", { sessionID, status: promptResult.status }); } } } // 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 providerHint = (props?.providerID as string | undefined) || parsed.providerID; const currentProvider = resolveFallbackProviderID(sessionID, providerHint); let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7"; currentModel = normalizeFallbackModelID(currentModel); const fallbackContext = { agentName, providerID: currentProvider, dedupeProviderID: providerHint, modelID: currentModel, }; const shouldAutoContinue = shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID); if (!shouldAutoContinue || !shouldSkipFallbackContinuation(sessionID, "session.error", fallbackContext)) { applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig); const setFallback = modelFallback ? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel) : false; if (setFallback && shouldAutoContinue) { await autoContinueAfterFallback(sessionID, "session.error", fallbackContext); } } } } } catch (err) { const sessionID = resolveSessionEventID(props); log("[event] model-fallback error in session.error:", { sessionID, error: err }); } await runEventHookSafely("teamMemberErrorHandler", teamMemberErrorHandler, input); } }; }