/** * session-sm.ts — Two-SM architecture for Hermes sessions * * CHANNEL SM (shared, per session key — all users see same state) * FRESH → session exists, 0 messages * READY → has messages, idle, user can type * AGENT_RUNNING → agent processing a user message * HANDOVER_PENDING → handover write in progress * HANDOVER_DONE → handover written, waiting for action * RESETTING → deliberate new session in progress * NO_SESSION → no session file found * * CONNECTION SM (per WebSocket — only this user sees it) * CONNECTING → WS open, waiting for auth * LOADING_HISTORY → receiving history replay * SYNCED → connected to channel, seeing live updates * SWITCHING → leaving one channel, joining another */ // ── Channel SM ────────────────────────────────────────────── export type ChannelState = | 'FRESH' | 'READY' | 'AGENT_RUNNING' | 'HANDOVER_PENDING' | 'HANDOVER_DONE' | 'RESETTING' | 'NO_SESSION'; const CHANNEL_TRANSITIONS: Record = { FRESH: ['AGENT_RUNNING', 'RESETTING', 'NO_SESSION'], READY: ['AGENT_RUNNING', 'HANDOVER_PENDING', 'RESETTING', 'NO_SESSION'], AGENT_RUNNING: ['READY', 'RESETTING'], HANDOVER_PENDING: ['HANDOVER_DONE', 'READY'], HANDOVER_DONE: ['RESETTING', 'READY'], RESETTING: ['AGENT_RUNNING', 'FRESH', 'NO_SESSION'], NO_SESSION: ['RESETTING', 'FRESH', 'READY'], }; const CHANNEL_TIMEOUTS_MS: Partial> = { AGENT_RUNNING: 300_000, HANDOVER_PENDING: 120_000, RESETTING: 30_000, }; // ── Connection SM ─────────────────────────────────────────── export type ConnectionState = | 'CONNECTING' | 'LOADING_HISTORY' | 'SYNCED' | 'SWITCHING'; const CONNECTION_TRANSITIONS: Record = { CONNECTING: ['LOADING_HISTORY', 'SYNCED'], LOADING_HISTORY: ['SYNCED'], SYNCED: ['SWITCHING'], SWITCHING: ['LOADING_HISTORY', 'SYNCED'], }; const CONNECTION_TIMEOUTS_MS: Partial> = { SWITCHING: 10_000, }; // ── Generic SM factory ────────────────────────────────────── export interface StateMachine { transition(next: S, payload?: Record): boolean; get(): S; destroy(): void; } function createSM( id: string, label: string, initial: S, transitions: Record, timeouts: Partial>, onStateChange: (event: Record) => void, timeoutTarget?: S, ): StateMachine { let state: S = initial; let timeoutHandle: ReturnType | null = null; function clearPendingTimeout() { if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; } } function transition(next: S, payload: Record = {}): boolean { const allowed = transitions[state]; if (!allowed?.includes(next)) { console.warn(`[${label}:${id}] invalid transition: ${state} -> ${next} (ignored)`); return false; } clearPendingTimeout(); const prev = state; state = next; console.log(`[${label}:${id}] ${prev} -> ${state}${payload.reason ? ' (' + payload.reason + ')' : ''}`); const ms = timeouts[state]; if (ms && timeoutTarget) { timeoutHandle = setTimeout(() => { console.warn(`[${label}:${id}] timeout in ${state}, forcing -> ${timeoutTarget}`); transition(timeoutTarget, { reason: 'timeout' }); }, ms); } onStateChange({ type: `${label}_state`, state, prev, ...payload }); return true; } return { transition, get: () => state, destroy: clearPendingTimeout, }; } // ── Factory functions ─────────────────────────────────────── export function createChannelSM( sessionKey: string, onStateChange: (event: Record) => void, initial: ChannelState = 'NO_SESSION', ): StateMachine { return createSM(sessionKey, 'channel', initial, CHANNEL_TRANSITIONS, CHANNEL_TIMEOUTS_MS, onStateChange, 'READY'); } export function createConnectionSM( clientId: string, onStateChange: (event: Record) => void, ): StateMachine { return createSM(clientId, 'connection', 'CONNECTING', CONNECTION_TRANSITIONS, CONNECTION_TIMEOUTS_MS, onStateChange, 'SYNCED'); } // ── Backward compat (temporary, remove after migration) ───── export type SmState = ChannelState | ConnectionState; export type SessionSM = StateMachine;