const SESSION_COOLDOWN_DURATION_MS = 2000; const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; const SESSION_ACTIVITY_MAX_AGE_MS = 24 * 60 * 60 * 1000; const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; const extractSessionStatusUpdate = (payload) => { if (!payload || payload.type !== 'session.status') { return null; } const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; const status = properties.status && typeof properties.status === 'object' ? properties.status : {}; const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; // Canonical OpenCode schema uses properties.status.type. Keep legacy info.type fallback for compatibility. const type = typeof status.type === 'string' ? status.type.trim() : (typeof info.type === 'string' ? info.type.trim() : ''); if (!sessionId || !type) { return null; } return { sessionId, type, eventId: typeof payload.id === 'string' ? payload.id : '', attempt: typeof status.attempt === 'number' ? status.attempt : (typeof info.attempt === 'number' ? info.attempt : undefined), message: typeof status.message === 'string' ? status.message : (typeof info.message === 'string' ? info.message : undefined), next: typeof status.next === 'number' ? status.next : (typeof info.next === 'number' ? info.next : undefined), }; }; export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => { const sessionActivityPhases = new Map(); const sessionActivityCooldowns = new Map(); const sessionStates = new Map(); const sessionAttentionStates = new Map(); let activeSessionCount = 0; const getOrCreateAttentionState = (sessionId) => { if (!sessionId || typeof sessionId !== 'string') return null; let state = sessionAttentionStates.get(sessionId); if (!state) { state = { needsAttention: false, lastUserMessageAt: null, lastStatusChangeAt: Date.now(), viewedByClients: new Set(), status: 'idle', }; sessionAttentionStates.set(sessionId, state); } return state; }; const setSessionActivityPhase = (sessionId, phase) => { if (!sessionId || typeof sessionId !== 'string') return false; const current = sessionActivityPhases.get(sessionId); if (current?.phase === phase) return false; if (phase === 'cooldown' && current?.phase !== 'busy') { return false; } const existingTimer = sessionActivityCooldowns.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); sessionActivityCooldowns.delete(sessionId); } const wasActive = current?.phase === 'busy'; const isActive = phase === 'busy'; if (wasActive !== isActive) { activeSessionCount = Math.max(0, activeSessionCount + (isActive ? 1 : -1)); } sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); if (phase === 'cooldown') { const timer = setTimeout(() => { const now = sessionActivityPhases.get(sessionId); if (now?.phase === 'cooldown') { setSessionActivityPhase(sessionId, 'idle'); return; } sessionActivityCooldowns.delete(sessionId); }, SESSION_COOLDOWN_DURATION_MS); sessionActivityCooldowns.set(sessionId, timer); } if (typeof broadcastEvent === 'function') { broadcastEvent({ type: 'openchamber:session-activity', properties: { sessionId, phase, }, }); } return true; }; const updateSessionAttentionStatus = (sessionId, status) => { const state = getOrCreateAttentionState(sessionId); if (!state) return; const prevStatus = state.status; state.status = status; state.lastStatusChangeAt = Date.now(); if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') { if (state.lastUserMessageAt && state.viewedByClients.size === 0) { state.needsAttention = true; } } }; const updateSessionState = (sessionId, status, eventId, metadata = {}) => { if (!sessionId || typeof sessionId !== 'string') return; const now = Date.now(); const existing = sessionStates.get(sessionId); const existingAttentionState = sessionAttentionStates.get(sessionId); const isRestartInterruption = metadata.reason === 'opencode-restart'; if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status && !isRestartInterruption) { return; } sessionStates.set(sessionId, { status, lastUpdateAt: now, lastEventId: eventId || `server-${now}`, metadata: { ...existing?.metadata, ...metadata }, }); updateSessionAttentionStatus(sessionId, status); const attentionState = sessionAttentionStates.get(sessionId); const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention; const clients = getNotificationClients(); if (!existing || existing.status !== status || attentionChanged || isRestartInterruption) { const state = sessionStates.get(sessionId); const syntheticPayload = { type: 'openchamber:session-status', properties: { sessionID: sessionId, status: state.status, timestamp: state.lastUpdateAt, metadata: state.metadata, needsAttention: attentionState?.needsAttention ?? false, }, }; if (typeof broadcastEvent === 'function') { broadcastEvent(syntheticPayload); } else if (clients.size > 0) { for (const res of clients) { try { writeSseEvent(res, syntheticPayload); } catch { } } } } const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle'; if (phase !== 'idle' || sessionActivityPhases.get(sessionId)?.phase !== 'cooldown') { setSessionActivityPhase(sessionId, phase); } }; const getSessionStateSnapshot = () => { const result = {}; const now = Date.now(); for (const [sessionId, data] of sessionStates) { if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue; result[sessionId] = { status: data.status, lastUpdateAt: data.lastUpdateAt, metadata: data.metadata, }; } return result; }; const getSessionState = (sessionId) => { if (!sessionId) return null; return sessionStates.get(sessionId) || null; }; const markSessionViewed = (sessionId, clientId) => { const state = getOrCreateAttentionState(sessionId); if (!state) return; const wasNeedsAttention = state.needsAttention; state.viewedByClients.add(clientId); if (wasNeedsAttention) { state.needsAttention = false; const syntheticPayload = { type: 'openchamber:session-status', properties: { sessionID: sessionId, status: state.status, timestamp: Date.now(), metadata: {}, needsAttention: false, }, }; if (typeof broadcastEvent === 'function') { broadcastEvent(syntheticPayload); } else { const clients = getNotificationClients(); for (const res of clients) { try { writeSseEvent(res, syntheticPayload); } catch { } } } } }; const markSessionUnviewed = (sessionId, clientId) => { const state = sessionAttentionStates.get(sessionId); if (!state) return; state.viewedByClients.delete(clientId); }; const markUserMessageSent = (sessionId) => { const state = getOrCreateAttentionState(sessionId); if (!state) return; state.lastUserMessageAt = Date.now(); }; const getSessionAttentionSnapshot = () => { const result = {}; const now = Date.now(); for (const [sessionId, state] of sessionAttentionStates) { if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue; result[sessionId] = { needsAttention: state.needsAttention, lastUserMessageAt: state.lastUserMessageAt, lastStatusChangeAt: state.lastStatusChangeAt, status: state.status, isViewed: state.viewedByClients.size > 0, }; } return result; }; const getSessionAttentionState = (sessionId) => { if (!sessionId) return null; const state = sessionAttentionStates.get(sessionId); if (!state) return null; return { needsAttention: state.needsAttention, lastUserMessageAt: state.lastUserMessageAt, lastStatusChangeAt: state.lastStatusChangeAt, status: state.status, isViewed: state.viewedByClients.size > 0, }; }; const getSessionActivitySnapshot = () => { const result = {}; for (const [sessionId, data] of sessionActivityPhases) { result[sessionId] = { type: data.phase }; } return result; }; const getActiveSessionCount = () => activeSessionCount; const resetAllSessionActivityToIdle = () => { for (const timer of sessionActivityCooldowns.values()) { clearTimeout(timer); } sessionActivityCooldowns.clear(); activeSessionCount = 0; const now = Date.now(); for (const [sessionId] of sessionActivityPhases) { sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now }); } }; const interruptBusySessionsAfterRestart = () => { const interruptedSessionIds = new Set(); for (const [sessionId, state] of sessionStates) { if (state.status === 'busy' || state.status === 'retry') { interruptedSessionIds.add(sessionId); } } for (const [sessionId, activity] of sessionActivityPhases) { if (activity.phase === 'busy') { interruptedSessionIds.add(sessionId); } } const eventId = `opencode-restart-${Date.now()}`; for (const sessionId of interruptedSessionIds) { updateSessionState(sessionId, 'idle', eventId, { message: 'Interrupted by OpenCode restart', reason: 'opencode-restart', }); broadcastEvent?.({ type: 'session.error', properties: { sessionID: sessionId, error: { name: 'MessageAbortedError', message: 'The running turn was interrupted when OpenCode restarted.', }, }, }); } resetAllSessionActivityToIdle(); return { sessionIds: [...interruptedSessionIds] }; }; const cleanupOldSessionStates = () => { const now = Date.now(); for (const [sessionId, data] of sessionStates) { if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) { sessionStates.delete(sessionId); } } for (const [sessionId, state] of sessionAttentionStates) { if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) { sessionAttentionStates.delete(sessionId); } } for (const [sessionId, data] of sessionActivityPhases) { if (now - data.updatedAt <= SESSION_ACTIVITY_MAX_AGE_MS) continue; const timer = sessionActivityCooldowns.get(sessionId); if (timer) clearTimeout(timer); sessionActivityCooldowns.delete(sessionId); sessionActivityPhases.delete(sessionId); if (data.phase === 'busy') activeSessionCount = Math.max(0, activeSessionCount - 1); } }; const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS); const processOpenCodeSsePayload = (payload) => { const update = extractSessionStatusUpdate(payload); if (!update) return; if (update.type === 'busy' || update.type === 'retry') { setSessionActivityPhase(update.sessionId, 'busy'); } else if (update.type === 'idle') { setSessionActivityPhase(update.sessionId, 'cooldown'); } updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, { attempt: update.attempt, message: update.message, next: update.next, }); }; const dispose = () => { clearInterval(cleanupInterval); for (const timer of sessionActivityCooldowns.values()) { clearTimeout(timer); } sessionActivityCooldowns.clear(); sessionActivityPhases.clear(); sessionStates.clear(); sessionAttentionStates.clear(); activeSessionCount = 0; }; return { processOpenCodeSsePayload, getSessionActivitySnapshot, getActiveSessionCount, getSessionStateSnapshot, getSessionAttentionSnapshot, getSessionState, getSessionAttentionState, markSessionViewed, markSessionUnviewed, markUserMessageSent, resetAllSessionActivityToIdle, interruptBusySessionsAfterRestart, dispose, }; };