import { createOpencodeClient } from '@opencode-ai/sdk/v2'; import type { OpenCodeManager } from './opencode'; // Session activity tracking (mirrors web server and desktop behavior) type ActivityPhase = 'idle' | 'busy' | 'cooldown'; interface SessionActivity { sessionId: string; phase: ActivityPhase; } const sessionActivityPhases = new Map(); const sessionActivityCooldowns = new Map(); const SESSION_COOLDOWN_DURATION_MS = 2000; let globalEventWatcherAbortController: AbortController | null = null; let chatViewProvider: { postMessage: (message: unknown) => void } | null = null; let globalEventWatcherRetryTimer: NodeJS.Timeout | null = null; let globalEventWatcherStartToken = 0; const clearGlobalEventWatcherRetry = (): void => { if (!globalEventWatcherRetryTimer) { return; } clearTimeout(globalEventWatcherRetryTimer); globalEventWatcherRetryTimer = null; }; const unwrapGlobalEventPayload = (eventData: unknown): Record | null => { if (!eventData || typeof eventData !== 'object') { return null; } const record = eventData as { payload?: unknown }; if (record.payload && typeof record.payload === 'object') { return record.payload as Record; } return eventData as Record; }; const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Promise => { const baseUrl = manager.getApiUrl(); if (!baseUrl) { return; } const url = new URL('/session/status', baseUrl); const response = await fetch(url.toString(), { headers: manager.getOpenCodeAuthHeaders(), }); if (!response.ok) { throw new Error(`session status fetch failed (${response.status})`); } const statuses = await response.json() as Record; const knownSessionIds = new Set(Object.keys(statuses || {})); for (const [sessionId, data] of Object.entries(statuses || {})) { const type = typeof data?.type === 'string' ? data.type : 'idle'; const phase: ActivityPhase = type === 'busy' || type === 'retry' ? 'busy' : 'idle'; setSessionActivityPhase(sessionId, phase); } // Drop stale in-memory activity entries not present in authoritative status. for (const sessionId of Array.from(sessionActivityPhases.keys())) { if (!knownSessionIds.has(sessionId)) { setSessionActivityPhase(sessionId, 'idle'); } } }; const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void => { if (!sessionId) return; const existingTimer = sessionActivityCooldowns.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); sessionActivityCooldowns.delete(sessionId); } const current = sessionActivityPhases.get(sessionId); if (current?.phase === phase) return; sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() }); chatViewProvider?.postMessage({ type: 'openchamber:session-activity', properties: { sessionId, phase, }, }); if (phase === 'cooldown') { const timer = setTimeout(() => { const now = sessionActivityPhases.get(sessionId); if (now?.phase === 'cooldown') { sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() }); chatViewProvider?.postMessage({ type: 'openchamber:session-activity', properties: { sessionId, phase: 'idle', }, }); } sessionActivityCooldowns.delete(sessionId); }, SESSION_COOLDOWN_DURATION_MS); sessionActivityCooldowns.set(sessionId, timer); } }; export const getSessionActivitySnapshot = (): Record => { const snapshot: Record = {}; for (const [sessionId, data] of sessionActivityPhases.entries()) { snapshot[sessionId] = { type: data.phase }; } return snapshot; }; const deriveSessionActivity = (payload: Record): SessionActivity | null => { if (!payload || typeof payload !== 'object') { return null; } const type = payload.type as string; const properties = (payload.properties ?? payload) as Record; if (type === 'session.status') { const status = properties?.status as Record | undefined; const info = properties?.info as Record | undefined; const sessionId = (properties?.sessionID ?? properties?.sessionId) as string; const statusType = (status?.type ?? info?.type) as string; if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') { const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle'; return { sessionId, phase }; } } if (type === 'message.updated' || type === 'message.part.updated' || type === 'message.part.delta') { const info = properties?.info as Record | undefined; const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string; const role = info?.role as string; const finish = info?.finish as string; if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') { return { sessionId, phase: 'cooldown' }; } } if (type === 'session.idle') { const sessionId = (properties?.sessionID ?? properties?.sessionId) as string; if (typeof sessionId === 'string' && sessionId.length > 0) { return { sessionId, phase: 'idle' }; } } return null; }; const waitForOpenCodePort = async (manager: OpenCodeManager, timeoutMs = 30000): Promise => { const start = Date.now(); while (Date.now() - start < timeoutMs) { const apiUrl = manager.getApiUrl(); if (apiUrl) { try { const url = new URL(apiUrl); if (url.port) { return parseInt(url.port, 10); } } catch { // ignore } } await new Promise(r => setTimeout(r, 500)); } return null; }; export const startGlobalEventWatcher = async ( manager: OpenCodeManager, provider: { postMessage: (message: unknown) => void } ): Promise => { if (globalEventWatcherAbortController) { return; } const startToken = ++globalEventWatcherStartToken; clearGlobalEventWatcherRetry(); chatViewProvider = provider; const port = await waitForOpenCodePort(manager); if (startToken !== globalEventWatcherStartToken) { return; } if (!port) { console.warn('[VSCode:Activity] OpenCode port unavailable; will retry'); globalEventWatcherRetryTimer = setTimeout(() => { globalEventWatcherRetryTimer = null; if (startToken === globalEventWatcherStartToken) { void startGlobalEventWatcher(manager, provider); } }, 2000); return; } globalEventWatcherAbortController = new AbortController(); const signal = globalEventWatcherAbortController.signal; let attempt = 0; const run = async (): Promise => { while (!signal.aborted) { attempt += 1; try { const baseUrl = manager.getApiUrl(); if (!baseUrl) { throw new Error('OpenCode API URL not available'); } const client = createOpencodeClient({ baseUrl, headers: manager.getOpenCodeAuthHeaders(), }); try { await reconcileSessionActivityFromStatus(manager); } catch (error) { console.warn( '[VSCode:Activity] session status reconcile failed', error instanceof Error ? error.message : error, ); } const result = await client.global.event({ signal, sseMaxRetryAttempts: 0, }); console.log('[VSCode:Activity] connected'); for await (const event of result.stream) { const payload = unwrapGlobalEventPayload((event as { payload?: unknown }).payload ?? event); if (payload) { const activity = deriveSessionActivity(payload); if (activity) { setSessionActivityPhase(activity.sessionId, activity.phase); } } if (signal.aborted) { break; } } } catch (error) { if (signal.aborted) { return; } console.warn('[VSCode:Activity] disconnected', error instanceof Error ? error.message : error); } const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000); await new Promise(r => setTimeout(r, backoffMs)); } }; void run(); }; export const stopGlobalEventWatcher = (): void => { globalEventWatcherStartToken += 1; clearGlobalEventWatcherRetry(); if (globalEventWatcherAbortController) { try { globalEventWatcherAbortController.abort(); } catch { // ignore } } globalEventWatcherAbortController = null; chatViewProvider = null; for (const timer of sessionActivityCooldowns.values()) { clearTimeout(timer); } sessionActivityCooldowns.clear(); sessionActivityPhases.clear(); }; export const setChatViewProvider = (provider: { postMessage: (message: unknown) => void } | null): void => { chatViewProvider = provider; };