Electron updater now uses Electron release metadata only Removed legacy Tauri package and migration workflow Replaced Tauri shim usage with the desktop bridge
295 lines
9.1 KiB
TypeScript
295 lines
9.1 KiB
TypeScript
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<string, { phase: ActivityPhase; updatedAt: number }>();
|
|
const sessionActivityCooldowns = new Map<string, NodeJS.Timeout>();
|
|
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<string, unknown> | 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<string, unknown>;
|
|
}
|
|
|
|
return eventData as Record<string, unknown>;
|
|
};
|
|
|
|
const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Promise<void> => {
|
|
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<string, { type?: string }>;
|
|
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<string, { type: ActivityPhase }> => {
|
|
const snapshot: Record<string, { type: ActivityPhase }> = {};
|
|
for (const [sessionId, data] of sessionActivityPhases.entries()) {
|
|
snapshot[sessionId] = { type: data.phase };
|
|
}
|
|
return snapshot;
|
|
};
|
|
|
|
const deriveSessionActivity = (payload: Record<string, unknown>): SessionActivity | null => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const type = payload.type as string;
|
|
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
|
|
|
if (type === 'session.status') {
|
|
const status = properties?.status as Record<string, unknown> | undefined;
|
|
const info = properties?.info as Record<string, unknown> | 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<string, unknown> | 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<number | null> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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;
|
|
};
|