import type { UserSession } from './types'; import { logger } from './logger'; import { clearOpenCodeSession } from './opencode/state'; class SessionManager { private sessions = new Map(); private userSessions = new Map(); getOrCreate( sessionId: string, email: string, cwd: string, model: string, groupSlug?: string | null, context?: string, contextId?: string, ): UserSession { let session = this.sessions.get(sessionId); if (!session) { session = { sessionId, email, cwd, model, piProcess: null, sockets: new Set(), lastActivity: Date.now(), idleTimer: null, streamBuffer: '', isGenerating: false, systemContextSent: false, messages: [], meta: { id: sessionId, title: '', model, cwd, groupSlug: groupSlug || null, context, contextId, createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0, }, }, }; this.sessions.set(sessionId, session); const userSessionIds = this.userSessions.get(email) || []; userSessionIds.push(sessionId); this.userSessions.set(email, userSessionIds); } session.lastActivity = Date.now(); return session; } getSession(sessionId: string): UserSession | null { return this.sessions.get(sessionId) || null; } /** * Record that a live session moved onto a different model. * * `getOrCreate` takes a `model` and, for a session that already exists, silently discards it — which was * correct while the model could not change, and became a lie the moment it could. Everything that routes * or attributes by `session.model` reads this: `handleStop`, `endTurnIfAgentIsGone`, the orphan adopter, * and the cost fold. Leaving it stale meant a stop landing on the wrong harness's kill path. * * Called only AFTER the harness confirms the switch. A refused switch must leave this alone, or the * server's record and the running agent disagree in the direction that is hardest to notice. */ setModel(sessionId: string, model: string): void { const session = this.sessions.get(sessionId); if (!session || session.model === model) return; session.model = model; session.meta.model = model; session.meta.updatedAt = Date.now(); } getUserSessions(email: string): UserSession[] { const sessionIds = this.userSessions.get(email) || []; return sessionIds.map((id) => this.sessions.get(id)).filter((s): s is UserSession => s !== undefined); } /** Drop this process's record of a session. Shared by `deleteSession` and `releaseSession`. */ private forget(session: UserSession): void { if (session.idleTimer) { clearTimeout(session.idleTimer); } this.sessions.delete(session.sessionId); const userSessionIds = this.userSessions.get(session.email); if (userSessionIds) { const filtered = userSessionIds.filter((id) => id !== session.sessionId); if (filtered.length > 0) { this.userSessions.set(session.email, filtered); } else { this.userSessions.delete(session.email); } } } /** * End the session for good: kill the agent upstream, drop the subscription, forget the record. This is * what an explicit "disconnect" means — the user said stop. */ deleteSession(sessionId: string): void { const session = this.sessions.get(sessionId); if (!session) return; // Clean up sidecar subscriptions if (session._sidecarUnsub) { session._sidecarUnsub(); } if (session._claudeKill) { session._claudeKill(); } // The OpenCode sessionKey → `ses_…` map is keyed on our session id and nothing else cleared it, so // it grew for the process lifetime and a REUSED key silently resumed a stale OpenCode session. // Only on deleteSession, never on releaseSession: releasing is "let go, leave it running", and a // returning browser has to be able to find the same `ses_…` again. clearOpenCodeSession(sessionId); this.forget(session); } /** * Let go WITHOUT killing: unsubscribe, forget the record, leave the agent running. * * This is what the idle GC should always have done. Officer's timer was doing two unrelated jobs — * collecting its own in-memory binding, which is its business, and terminating the agent, which is the * sidecar's. The sidecar already refuses to collect a session that is mid-turn or holding background * tasks (`claude-manager.ts` → `pendingTasks`, and an `armIdle` that re-checks rather than firing * once); officer knew none of that and killed anyway. A `run_in_background` job outliving the browser * — a laptop that ran out of battery — died an hour later for no reason. * * Nothing is stranded by forgetting the record. The sidecar keeps committing to `chat_session_events`, * and a returning browser goes through `adoptOrphanedSession`, which rebuilds the record and a fresh * subscription. That is the same path every `pm2 restart officer` already takes. */ releaseSession(sessionId: string): void { const session = this.sessions.get(sessionId); if (!session) return; // Only the listener goes. `_claudeKill` is deliberately NOT called — and must not be left behind // either: the record is being dropped, so the next turn starts from `adoptOrphanedSession`, which // installs its own kill and subscription. if (session._sidecarUnsub) { session._sidecarUnsub(); } this.forget(session); } attachWs(sessionId: string, ws: any): void { const session = this.sessions.get(sessionId); if (session) { session.sockets.add(ws); session.lastActivity = Date.now(); if (session.idleTimer) { clearTimeout(session.idleTimer); session.idleTimer = null; } } } /** * Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the * session's only socket field, so a stale client's close event silenced whichever client had attached * after it. A close is only the end of the conversation when nothing else is still watching. */ detachWs(sessionId: string, ws: any): void { const session = this.sessions.get(sessionId); if (session) { session.sockets.delete(ws); session.lastActivity = Date.now(); } } /** Whether anything is still watching — the idle GC must not start while another client is attached. */ hasSockets(sessionId: string): boolean { return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0; } setIdleTimeout(sessionId: string, timeoutMs: number): void { const session = this.sessions.get(sessionId); if (!session) return; if (session.idleTimer) { clearTimeout(session.idleTimer); } session.idleTimer = setTimeout(() => { logger.info('Session idle timeout reached, releasing binding (agent left running)', { sessionId, timeoutMs, }); this.releaseSession(sessionId); }, timeoutMs); } updateActivity(sessionId: string): void { const session = this.sessions.get(sessionId); if (session) { session.lastActivity = Date.now(); } } getAllSessions(): UserSession[] { return Array.from(this.sessions.values()); } } export const sessionManager = new SessionManager();