import { createVSCodeAPIs } from './api'; import { createRemovalTombstones } from './inlineCommentRemovals'; import { resolveCommentTarget } from './inlineCommentTarget'; import { onCommand, onThemeChange, postBridgeNotification, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge'; import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf'; import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import { opencodeClient } from '@openchamber/ui/lib/opencode/client'; import { sanitizeHeadersForBrowser } from '@openchamber/ui/lib/runtime-fetch'; import { buildVSCodeThemeFromPalette, readVSCodeThemePalette, type VSCodeThemeKind, type VSCodeThemePayload, } from '@openchamber/ui/lib/theme/vscode/adapter'; import { getBootstrapMessages, readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n'; import type { VSCodeActiveEditorFile } from '@/sync/input-store'; import { usePermissionStore } from '@openchamber/ui/stores/permissionStore'; import { processVSCodePermissionAutoAccept } from '@openchamber/ui/sync/vscode-permission-auto-accept'; import type { PermissionRequest } from '@opencode-ai/sdk/v2/client'; import { focusChatInput } from '@openchamber/ui/components/chat/composer/editor/dom'; type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected'; type PanelType = 'chat' | 'agentManager'; declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string; declare global { interface Window { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; __VSCODE_CONFIG__?: { apiUrl?: string; workspaceFolder: string; workspaceFolders?: Array<{ name: string; path: string }>; theme: string; connectionStatus: string; cliAvailable?: boolean; extensionVersion?: string; platform?: string; arch?: string; panelType?: PanelType; viewMode?: 'sidebar' | 'editor'; initialSessionId?: string | null; }; __OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme']; __OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record; dark?: Record } | null; __OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean }; __OPENCHAMBER_HOME__?: string; __OPENCHAMBER_PANEL_TYPE__?: PanelType; __OPENCHAMBER_VSCODE_WINDOW_FOCUSED__?: boolean; } } console.log('[OpenChamber] VS Code webview starting...'); console.log('[OpenChamber] VS Code webview build:', __OPENCHAMBER_WEBVIEW_BUILD_TIME__); console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__); try { if (window.localStorage.getItem('openchamber_stream_debug') === '1') { console.log('[OpenChamber] Debug: openchamber_stream_debug=1'); } } catch { // ignore } window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs(); const bootstrapLocale = readStoredLocaleForBootstrap(); const bootstrapMessages = getBootstrapMessages(bootstrapLocale); const bootstrapConnectionStatus = () => { const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting'; const cliAvailable = window.__VSCODE_CONFIG__?.cliAvailable ?? true; window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus, cliAvailable }; }; bootstrapConnectionStatus(); // Expose panel type globally for the VS Code app root to conditionally render. window.__OPENCHAMBER_PANEL_TYPE__ = (window.__VSCODE_CONFIG__?.panelType as PanelType) || 'chat'; const handleConnectionMessage = (event: MessageEvent) => { const msg = event.data; if (msg?.type === 'connectionStatus') { const payload: ConnectionStatus = msg.status; const error: string | undefined = msg.error; const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true; window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable }; window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } })); } }; window.addEventListener('message', handleConnectionMessage); window.addEventListener('openchamber:connection-status', () => { maybeHideLoadingOverlay(); }); const fadeOutLoadingScreen = () => { const loadingEl = document.getElementById('initial-loading'); if (!loadingEl) return; loadingEl.classList.add('fade-out'); setTimeout(() => { try { loadingEl.remove(); } catch { // ignore } }, 300); }; const setLoadingStatusText = (text: string, variant: 'normal' | 'error' = 'normal') => { const statusEl = document.getElementById('loading-status'); if (!statusEl) return; statusEl.textContent = text; if (variant === 'error') { statusEl.classList.add('error-text'); } else { statusEl.classList.remove('error-text'); } }; const waitForUiMount = (timeoutMs = 8000): Promise => { if (typeof document === 'undefined') return Promise.resolve(false); const root = document.getElementById('root'); if (!root) return Promise.resolve(false); const hasContent = () => root.childNodes.length > 0; if (hasContent()) return Promise.resolve(true); return new Promise((resolve) => { const observer = new MutationObserver(() => { if (hasContent()) { observer.disconnect(); clearTimeout(timeout); resolve(true); } }); observer.observe(root, { childList: true, subtree: true }); const timeout = setTimeout(() => { observer.disconnect(); resolve(false); }, timeoutMs); }); }; let uiMounted = false; const maybeHideLoadingOverlay = () => { const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting'; if (!uiMounted) { return; } if (connectionStatus === 'connected') { // The UI hydrates pickers and the sidebar from cache and refreshes // providers/agents in the background, so once it's mounted and OpenCode is // connected there's real interactive content underneath the splash. Don't // keep the overlay up waiting on the live provider/agent fetches — on a cold // start those are the slowest tail, and gating on them makes the splash // linger long after the app is usable. Per-widget loaders convey any // remaining background work. fadeOutLoadingScreen(); return; } if (connectionStatus === 'error') { const error = window.__OPENCHAMBER_CONNECTION__?.error; setLoadingStatusText(error || bootstrapMessages.connectionError, 'error'); fadeOutLoadingScreen(); return; } if (connectionStatus === 'disconnected') { setLoadingStatusText(bootstrapMessages.disconnected, 'error'); fadeOutLoadingScreen(); return; } // Connecting — no jargon; the animated logo conveys progress. setLoadingStatusText(''); }; const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => { if (typeof document === 'undefined' || !theme) return; const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light'; const root = document.documentElement; root.classList.remove('light', 'dark'); root.classList.add(variant); const background = theme.colors?.surface?.background; if (background) { document.body.style.backgroundColor = background; let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null; if (!meta) { meta = document.createElement('meta'); meta.setAttribute('name', 'theme-color'); document.head.appendChild(meta); } meta.setAttribute('content', background); } }; const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => { const palette = readVSCodeThemePalette(preferredKind); if (!palette) { return; } const theme = buildVSCodeThemeFromPalette(palette); window.__OPENCHAMBER_VSCODE_THEME__ = theme; applyInitialTheme(theme); window.dispatchEvent(new CustomEvent('openchamber:vscode-theme', { detail: { theme, palette }, })); }; emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined); const scheduleThemeRecompute = (kind?: VSCodeThemeKind) => { // VS Code updates webview CSS variables asynchronously around theme changes. // Re-read on the next frames so we don't snapshot the old palette. requestAnimationFrame(() => { emitVSCodeTheme(kind); requestAnimationFrame(() => emitVSCodeTheme(kind)); }); }; onThemeChange((payload) => { const kind = (typeof payload === 'string' ? payload : typeof payload === 'object' && payload ? payload.kind : undefined) as VSCodeThemeKind | undefined; if (typeof payload === 'object' && payload?.shikiThemes !== undefined) { window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__ = payload.shikiThemes; window.dispatchEvent( new CustomEvent('openchamber:vscode-shiki-themes', { detail: { shikiThemes: payload.shikiThemes }, }), ); } scheduleThemeRecompute(kind); }); const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder; if (workspaceFolder) { const normalizeWorkspacePath = (value: string) => { const normalized = value .replace(/\\/g, '/') .replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`) .replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`); if (normalized === '/') { return '/'; } return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; }; const normalizedWorkspaceFolder = normalizeWorkspacePath(workspaceFolder); window.__OPENCHAMBER_HOME__ = normalizedWorkspaceFolder; try { window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder); window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder); // VS Code defaults: show dotfiles, hide gitignored if (window.localStorage.getItem('directoryTreeShowHidden') === null) { window.localStorage.setItem('directoryTreeShowHidden', 'true'); } if (window.localStorage.getItem('filesViewShowGitignored') === null) { window.localStorage.setItem('filesViewShowGitignored', 'false'); } } catch (error) { console.warn('Failed to persist workspace folder', error); } } const normalizeUrl = (input: string | URL) => { try { return typeof input === 'string' ? new URL(input, window.location.href) : new URL(input.toString(), window.location.href); } catch { return null; } }; const headersToRecord = (headers: HeadersInit | undefined): Record => { if (!headers) return {}; const normalized = new Headers(sanitizeHeadersForBrowser(headers) ?? headers); const result: Record = {}; normalized.forEach((value, key) => { result[key] = value; }); return result; }; const getRequestHeaders = (input?: RequestInfo | URL, init?: RequestInit): Record => { const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {}; const headersFromInit = headersToRecord(init?.headers); return { ...headersFromRequest, ...headersFromInit }; }; const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: RequestInit): string | undefined => { const queryDirectory = url.searchParams.get('directory') || undefined; if (queryDirectory) return queryDirectory; const headers = getRequestHeaders(input, init); const directoryEncoding = Object.entries(headers).find(([key]) => key.toLowerCase() === 'x-opencode-directory-encoding')?.[1]; for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === 'x-opencode-directory') { // headersToRecord marks encoded directory hints so direct/raw percent // sequences from other callers are not decoded accidentally. if (directoryEncoding !== 'uri') return value; try { return decodeURIComponent(value); } catch { return value; } } } return undefined; }; const decodeBase64 = (value: string): ArrayBuffer => { const binary = atob(value); const buffer = new ArrayBuffer(binary.length); const bytes = new Uint8Array(buffer); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return buffer; }; const jsonResponse = (body: unknown, status = 200): Response => { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); }; const unsupportedWebRouteResponse = (feature: string): Response => { return jsonResponse({ error: `${feature} is not supported in VS Code` }, 501); }; const pluginConfigErrorStatus = (message: string): number => { const lower = message.toLowerCase(); if (lower.includes('already exists')) return 409; if (lower.includes('not found')) return 404; if (lower.includes('required') || lower.includes('invalid') || lower.includes('must ')) return 400; return 500; }; const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304; const buildProxiedResponse = ( proxied: { status: number; headers: Record; bodyBase64?: string; bodyText?: string } ): Response => { if (isNullBodyStatus(proxied.status)) { return new Response(null, { status: proxied.status, headers: proxied.headers }); } if (typeof proxied.bodyText === 'string') { return new Response(proxied.bodyText, { status: proxied.status, headers: proxied.headers }); } const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new ArrayBuffer(0); return new Response(body, { status: proxied.status, headers: proxied.headers }); }; const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event'; const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname); const isApiPath = (pathname: string) => pathname === '/api' || pathname.startsWith('/api/'); const isLocalRuntimePath = (pathname: string) => isApiPath(pathname) || pathname === '/auth/session'; const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: RequestInit | undefined, method: string) => { const pathname = url.pathname; const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname; if (normalizedPathname === '/api/system/info' && method === 'GET') { const config = window.__VSCODE_CONFIG__; return jsonResponse({ openchamberVersion: config?.extensionVersion || 'VS Code Extension', runtime: 'vscode', platform: config?.platform || '', arch: config?.arch || '', }); } if (normalizedPathname === '/api/preview/targets') { return unsupportedWebRouteResponse('Preview proxy'); } if (normalizedPathname.startsWith('/api/openchamber/tunnel/')) { return unsupportedWebRouteResponse('Remote tunnel settings'); } // Archiving a batch of sessions server-side needs an OpenChamber server // process; the extension host has none. Answering explicitly keeps the // shared UI on its per-session archive path instead of leaving the request // to the generic proxy. if (normalizedPathname === '/api/openchamber/sessions/archive') { return unsupportedWebRouteResponse('Server-side session archiving'); } if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) { return unsupportedWebRouteResponse('Scheduled tasks'); } // Project setup (worktree setup commands, project actions, draft starters) // lives in the user's OpenChamber config dir; the extension host owns the // file the way the OpenChamber server does elsewhere. const projectSetupMatch = normalizedPathname.match(/^\/api\/projects\/([^/]+)\/config(\/shared)?$/); if (projectSetupMatch && (method === 'GET' || method === 'PUT') && !(method === 'GET' && projectSetupMatch[2])) { const projectId = decodeURIComponent(projectSetupMatch[1]); const payload = method === 'GET' ? { projectId } : { projectId, patch: await extractJsonBody(input, init, method) }; const bridgeType = method === 'GET' ? 'api:project-setup:get' : projectSetupMatch[2] ? 'api:project-setup:update-shared' : 'api:project-setup:update'; try { const data = await sendBridgeMessage(bridgeType, payload); return jsonResponse(data, 200); } catch (error) { const message = error instanceof Error ? error.message : 'Project config request failed'; return jsonResponse({ error: message }, /must be|is required|unsupported characters/.test(message) ? 400 : 500); } } if (normalizedPathname === '/api/fs/git-dirs') { return unsupportedWebRouteResponse('Nested git repository discovery'); } if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') { const activity = await sendBridgeMessage>('api:session-activity:get') .catch(() => ({})); return new Response( JSON.stringify({ statusSessions: {}, attentionSessions: {}, activitySessions: activity || {}, serverTime: Date.now(), }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') { return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (normalizedPathname === '/api/permission-auto-accept' && method === 'GET') { const snapshot = await sendBridgeMessage('api:permission-auto-accept:get'); return new Response(JSON.stringify(snapshot), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } const permissionPolicyMatch = normalizedPathname.match(/^\/api\/permission-auto-accept\/sessions\/([^/]+)$/); if (permissionPolicyMatch && method === 'PUT') { const bodyText = await extractBodyText(url, init, method); const body = bodyText ? JSON.parse(bodyText) as { enabled?: unknown } : {}; const snapshot = await sendBridgeMessage('api:permission-auto-accept:set', { sessionId: decodeURIComponent(permissionPolicyMatch[1]), enabled: body.enabled, }); return new Response(JSON.stringify(snapshot), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') { const sessionId = normalizedPathname.split('/')[3] || ''; return new Response( JSON.stringify({ success: true, sessionId, messageSent: true, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } if (normalizedPathname === '/api/session-activity' && method === 'GET') { const activity = await sendBridgeMessage>('api:session-activity:get') .catch(() => ({})); return new Response(JSON.stringify(activity || {}), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (normalizedPathname === '/api/sessions/status' && method === 'GET') { return new Response( JSON.stringify({ sessions: {}, serverTime: Date.now(), }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } if (normalizedPathname === '/api/sessions/attention' && method === 'GET') { return new Response( JSON.stringify({ sessions: {}, serverTime: Date.now(), }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } if (/^\/api\/sessions\/[^/]+\/status$/.test(normalizedPathname) && method === 'GET') { const sessionId = normalizedPathname.split('/')[3] || ''; return new Response( JSON.stringify({ error: 'Session not found or no state available', sessionId, }), { status: 404, headers: { 'Content-Type': 'application/json' }, }, ); } if (/^\/api\/sessions\/[^/]+\/attention$/.test(normalizedPathname) && method === 'GET') { const sessionId = normalizedPathname.split('/')[3] || ''; return new Response( JSON.stringify({ error: 'Session not found or no attention state available', sessionId, }), { status: 404, headers: { 'Content-Type': 'application/json' }, }, ); } if (normalizedPathname === '/api/tts/status' && method === 'GET') { return new Response(JSON.stringify({ available: false }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (normalizedPathname === '/api/tts/say/status' && method === 'GET') { return new Response(JSON.stringify({ available: false, voices: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak') && method === 'POST') { return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), { status: 501, headers: { 'Content-Type': 'application/json' }, }); } // Dictation runs on the OpenChamber web server (WebSocket + worker); the VS // Code bridge has no server process, so report it deterministically // unavailable. The mic button hides itself when capture is unsupported. if (normalizedPathname === '/api/dictation/status' && method === 'GET') { return new Response(JSON.stringify({ provider: 'local', available: false, reasonCode: 'unsupported_runtime', models: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (normalizedPathname.startsWith('/api/dictation/') ) { return new Response(JSON.stringify({ error: 'Dictation is not available in VS Code runtime' }), { status: 501, headers: { 'Content-Type': 'application/json' }, }); } // Health endpoints: reflect actual connection status if (pathname === '/health' || pathname === '/api/health') { const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status; const isReady = connectionStatus === 'connected'; const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true; return new Response(JSON.stringify({ status: isReady ? 'ok' : 'connecting', isOpenCodeReady: isReady, cliAvailable, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (pathname.startsWith('/api/fs/list')) { const targetPath = url.searchParams.get('path') || ''; const respectGitignore = url.searchParams.get('respectGitignore') === 'true'; const data = await sendBridgeMessage('api:fs:list', { path: targetPath, respectGitignore }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/fs/mkdir')) { const body = await extractJsonBody(input, init, method); const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/fs/home')) { const data = await sendBridgeMessage('api:fs/home'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/vscode/pick-files')) { const data = await sendBridgeMessage('api:files/pick'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') { const body = await extractJsonBody(input, init, method); const uris = Array.isArray((body as { uris?: unknown[] }).uris) ? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string') : []; const data = await sendBridgeMessage('api:files/drop', { uris }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') { const body = await extractJsonBody(input, init, method); const fileName = typeof (body as { fileName?: unknown }).fileName === 'string' ? (body as { fileName: string }).fileName : undefined; const dataUrl = typeof (body as { dataUrl?: unknown }).dataUrl === 'string' ? (body as { dataUrl: string }).dataUrl : undefined; const data = await sendBridgeMessage('api:files/save-image', { fileName, dataUrl }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/vscode/save-markdown') && method === 'POST') { const body = await extractJsonBody(input, init, method); const fileName = typeof (body as { fileName?: unknown }).fileName === 'string' ? (body as { fileName: string }).fileName : undefined; const content = typeof (body as { content?: unknown }).content === 'string' ? (body as { content: string }).content : undefined; const data = await sendBridgeMessage('api:files/save-markdown', { fileName, content }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/config/agents/')) { const encodedName = pathname.slice('/api/config/agents/'.length); const name = decodeURIComponent(encodedName); const verb = method; const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/config/commands/')) { const encodedName = pathname.slice('/api/config/commands/'.length); const name = decodeURIComponent(encodedName); const verb = method; const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/config/mcp') { const verb = method; const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/config/mcp/')) { const encodedName = pathname.slice('/api/config/mcp/'.length); const name = decodeURIComponent(encodedName); const verb = method; const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/config/snippets') { const verb = method; const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/snippets', { method: verb, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/config/snippets/expand') { const verb = method === 'GET' && !hasInitBody(init) && !(input instanceof Request) ? 'POST' : method; const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/snippets', { method: verb, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/config/snippets/')) { const encodedName = pathname.slice('/api/config/snippets/'.length); const name = decodeURIComponent(encodedName); const verb = method; const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); try { const data = await sendBridgeMessage('api:config/snippets', { method: verb, name, body, directory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Skills file operations: /api/config/skills/:name/files/:filePath const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/); if (skillsFilesMatch) { const name = decodeURIComponent(skillsFilesMatch[1]); const filePath = decodeURIComponent(skillsFilesMatch[2]); const verb = method; const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills/files', { method: verb, name, filePath, content: body.content }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } const skillsCatalogStatusFromPayload = (payload: unknown): number => { if (!payload || typeof payload !== 'object') return 200; const data = payload as { ok?: boolean; error?: { kind?: string } }; if (data.ok === false) { const kind = data.error?.kind; if (kind === 'conflicts') return 409; if (kind === 'authRequired') return 401; return 400; } return 200; }; // Skills catalog: /api/config/skills/catalog if (pathname === '/api/config/skills/catalog') { const refresh = url.searchParams.get('refresh') === 'true'; try { const data = await sendBridgeMessage('api:config/skills:catalog', { refresh }); return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Skills scan: /api/config/skills/scan if (pathname === '/api/config/skills/scan') { const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills:scan', body); return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Skills install: /api/config/skills/install if (pathname === '/api/config/skills/install') { const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills:install', body); return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Skills CRUD: /api/config/skills/:name or /api/config/skills if (pathname === '/api/config/skills') { try { const data = await sendBridgeMessage('api:config/skills', { method: 'GET' }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/config/skills/')) { const encodedName = pathname.slice('/api/config/skills/'.length); const name = decodeURIComponent(encodedName); const verb = method; const body = await extractJsonBody(input, init, method); try { const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/config/settings')) { if (method === 'GET') { const settings = await sendBridgeMessage('api:config/settings:get'); return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } }); } const body = await extractJsonBody(input, init, method); const updated = await sendBridgeMessage('api:config/settings:save', body); return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (normalizedPathname === '/api/behavior/agents-md') { if (method === 'GET') { const data = await sendBridgeMessage('api:behavior/agents-md:get'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (method === 'PUT') { const body = await extractJsonBody(input, init, method); const data = await sendBridgeMessage('api:behavior/agents-md:save', body); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/magic-prompts') { if (method === 'GET') { const data = await sendBridgeMessage('api:magic-prompts:get'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (method === 'DELETE') { const data = await sendBridgeMessage('api:magic-prompts:reset-all'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/magic-prompts/')) { const id = decodeURIComponent(pathname.slice('/api/magic-prompts/'.length)); if (method === 'PUT') { const body = await extractJsonBody(input, init, method); const data = await sendBridgeMessage('api:magic-prompts:save', { id, text: body?.text }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (method === 'DELETE') { const data = await sendBridgeMessage('api:magic-prompts:reset', { id }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/config/opencode-resolution' && method === 'GET') { try { const data = await sendBridgeMessage('api:config/opencode-resolution:get'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/config/reload')) { await sendBridgeMessage('api:config/reload'); return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/api/config/plugins' && method === 'GET') { try { const directory = getRequestDirectoryHint(url, input, init); const data = await sendBridgeMessage('api:config/plugins', { method, target: 'list', directory }); return jsonResponse(data); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); } } if (pathname === '/api/config/plugins/registry' && method === 'GET') { try { const rawSpecs = url.searchParams.get('specs') || ''; const specs = rawSpecs ? rawSpecs.split(',').map((spec) => spec.trim()).filter(Boolean) : []; const directory = getRequestDirectoryHint(url, input, init); const data = await sendBridgeMessage('api:config/plugins', { method, target: 'registry', specs, refresh: url.searchParams.get('refresh') === 'true', directory, }); return jsonResponse(data); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); } } if (pathname === '/api/config/plugins/entry' && method === 'POST') { try { const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); const data = await sendBridgeMessage('api:config/plugins', { method, target: 'entry', body, directory }); return jsonResponse(data); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); } } const pluginEntryMatch = pathname.match(/^\/api\/config\/plugins\/entry\/([^/]+)$/); if (pluginEntryMatch) { try { const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); const data = await sendBridgeMessage('api:config/plugins', { method, target: 'entry', pluginId: decodeURIComponent(pluginEntryMatch[1]), body, directory, }); return jsonResponse(data); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); } } if (pathname === '/api/config/plugins/file' && method === 'POST') { try { const body = await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); const data = await sendBridgeMessage('api:config/plugins', { method, target: 'file', body, directory }); return jsonResponse(data); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); } } const pluginFileMatch = pathname.match(/^\/api\/config\/plugins\/file\/([^/]+)$/); if (pluginFileMatch) { try { const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method); const directory = getRequestDirectoryHint(url, input, init); const data = await sendBridgeMessage('api:config/plugins', { method, target: 'file', pluginId: decodeURIComponent(pluginFileMatch[1]), body, directory, }); return jsonResponse(data); } catch (error) { const message = error instanceof Error ? error.message : String(error); return jsonResponse({ error: message }, pluginConfigErrorStatus(message)); } } if (pathname.startsWith('/api/openchamber/models-metadata')) { try { const data = await sendBridgeMessage('api:models/metadata'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error); return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/opencode/version' && method === 'GET') { try { const data = await sendBridgeMessage('api:opencode/version'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ version: null, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/api/opencode/health' && method === 'GET') { const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status; return new Response(JSON.stringify({ healthy: connectionStatus === 'connected' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (pathname === '/api/opencode/upgrade-status' && method === 'GET') { const data = await sendBridgeMessage('api:opencode/upgrade-status'); return jsonResponse(data); } if (pathname === '/api/opencode/upgrade' && method === 'POST') { const body = await extractJsonBody(input, init, method); const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body); return jsonResponse(result.body, result.status); } if (pathname === '/api/zen/models' && method === 'GET') { try { const data = await sendBridgeMessage('api:zen:models'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message, models: [] }), { status: 502, headers: { 'Content-Type': 'application/json' } }); } } if (pathname.startsWith('/api/openchamber/update-check')) { try { const currentVersion = url.searchParams.get('currentVersion') || undefined; const instanceMode = url.searchParams.get('instanceMode') || 'local'; const deviceClass = url.searchParams.get('deviceClass') || 'desktop'; const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined; const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined; const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase(); const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no'); const data = await sendBridgeMessage('api:openchamber:update-check', { currentVersion, instanceMode, deviceClass, platform, arch, reportUsage, }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ available: false, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } }); } } if (pathname === '/auth/session') { // VS Code host is trusted; mirror web server shape to keep UI logic happy const body = { authenticated: true, requireSetup: false, authenticatedAt: Date.now(), }; return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname.startsWith('/api/opencode/directory')) { const body = await extractJsonBody(input, init, method); const result = await sendBridgeMessage('api:opencode/directory', { path: body.path }); return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/api/quota/providers') { try { const data = await sendBridgeMessage('api:quota:providers'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(ollama-cloud|cursor)(?:\/(validate|import))?$/); if (quotaCredentialMatch) { try { const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined; const bridgeMethod = quotaCredentialMatch[2]?.toUpperCase() || method; const data = await sendBridgeMessage('api:quota:credentials', { providerId: quotaCredentialMatch[1], method: bridgeMethod, credential: body }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } } const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/); if (quotaMatch && method === 'GET') { const providerId = decodeURIComponent(quotaMatch[1]); try { const data = await sendBridgeMessage('api:quota:get', { providerId }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Handle provider auth deletion: DELETE /api/provider/:providerId/auth const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/); if (providerAuthMatch && method === 'DELETE') { const providerId = decodeURIComponent(providerAuthMatch[1]); const scope = url.searchParams.get('scope') || 'auth'; const queryDirectory = url.searchParams.get('directory') || undefined; try { const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope, directory: queryDirectory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Handle provider source lookup: GET /api/provider/:providerId/source const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/); if (providerSourceMatch && method === 'GET') { const providerId = decodeURIComponent(providerSourceMatch[1]); const queryDirectory = url.searchParams.get('directory') || undefined; try { const data = await sendBridgeMessage('api:provider/source:get', { providerId, directory: queryDirectory }); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // Handle custom provider upsert: PUT /api/provider if (pathname === '/api/provider' && method === 'PUT') { try { const body = await extractJsonBody(input, init, method); const queryDirectory = url.searchParams.get('directory') || undefined; const data = await sendBridgeMessage('api:provider:upsert', { ...(body && typeof body === 'object' ? body : {}), directory: queryDirectory ?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined), }); if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) { const message = (data as { error?: string }).error || 'Failed to save provider config'; return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify((data as { data?: unknown })?.data ?? data), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } return null; }; const originalFetch = window.fetch.bind(window); let sseStreamCounter = 0; window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url); const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); const pathname = targetUrl?.pathname || ''; const normalizedPathname = pathname.replace(/\/{2,}/g, '/'); if (targetUrl && normalizedPathname === '/health') { const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status; const isReady = connectionStatus === 'connected'; const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true; return new Response(JSON.stringify({ status: isReady ? 'ok' : 'connecting', isOpenCodeReady: isReady, cliAvailable, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (targetUrl && isLocalRuntimePath(normalizedPathname)) { const localResponse = await handleLocalApiRequest(input, targetUrl, init, method); if (localResponse) { maybeHideLoadingOverlay(); return localResponse; } if (!isApiPath(normalizedPathname)) { return originalFetch(input as RequestInfo, init); } const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`; const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {}; const headersFromInit = headersToRecord(init?.headers); const headers = { ...headersFromRequest, ...headersFromInit }; if (isSseApiPath(targetUrl.pathname)) { // Install the listener before the extension opens the upstream stream. A // reconnect can replay an event immediately, before the start response // has crossed the VS Code bridge. const streamId = `sse_webview_${Date.now()}_${++sseStreamCounter}`; const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; const encoder = new TextEncoder(); let unsubscribe: (() => void) | null = null; const stream = new ReadableStream({ start(controller) { const onMessage = (event: MessageEvent) => { const msg = event.data as { type?: string; streamId?: string; chunk?: string; error?: string }; if (!msg || msg.streamId !== streamId) return; if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') { vscodeStreamPerfCount('vscode.webview.sse_chunk'); vscodeStreamPerfObserve('vscode.webview.sse_chunk_bytes', msg.chunk.length); controller.enqueue(encoder.encode(msg.chunk)); return; } if (msg.type === 'api:sse:end') { vscodeStreamPerfCount('vscode.webview.sse_end'); unsubscribe?.(); unsubscribe = null; if (typeof msg.error === 'string' && msg.error.length > 0) { controller.error(new Error(msg.error)); } else { controller.close(); } void stopSseProxy({ streamId }).catch(() => {}); } }; window.addEventListener('message', onMessage); unsubscribe = () => window.removeEventListener('message', onMessage); if (signal) { const onAbort = () => { unsubscribe?.(); unsubscribe = null; try { controller.error(new DOMException('Aborted', 'AbortError')); } catch { controller.close(); } void stopSseProxy({ streamId }).catch(() => {}); }; if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); } }, cancel() { unsubscribe?.(); unsubscribe = null; void stopSseProxy({ streamId }).catch(() => {}); }, }); let start; try { start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers, streamId })); } catch (error) { await stream.cancel(); throw error; } if (!start.streamId) { void stream.cancel(); return new Response(null, { status: start.status || 503, headers: start.headers || {} }); } return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } }); } if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) { const bodyText = await extractBodyText(input, init, method); const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText, signal }); const response = buildProxiedResponse(proxied); maybeHideLoadingOverlay(); return response; } const bodyBase64 = await extractBodyBase64(input, init, method); const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined; const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64, signal }); const response = buildProxiedResponse(proxied); maybeHideLoadingOverlay(); return response; } if (targetUrl && targetUrl.hostname.includes('models.dev')) { try { const data = await sendBridgeMessage('api:models/metadata'); return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error); return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }); } } return originalFetch(input as RequestInfo, init); }; onCommand('focusChatInput', () => { focusChatInput(); }); onCommand('addContextSelection', (payload) => { const { filePath, filename, text } = payload as { filePath?: unknown; filename?: unknown; text?: unknown }; if (typeof filePath !== 'string' || typeof filename !== 'string' || typeof text !== 'string') { return; } const trimmedPath = filePath.trim(); const trimmedFilename = filename.trim(); if (!trimmedPath || !trimmedFilename || !text.trim()) { return; } import('@/sync/input-store').then(({ useInputStore }) => { const file = new File([new Blob([text], { type: 'text/plain' })], trimmedFilename, { type: 'text/plain' }); void useInputStore.getState().addVSCodeSelectionAttachment(trimmedPath, file).finally(() => { focusChatInput(); }); }); }); // Comments dropped from their editor thread before the draft reached this // store. See the module for why the window exists. const removedComments = createRemovalTombstones(); onCommand('addLineComment', (payload) => { // SAFETY: the payload crossed the extension boundary as JSON; every field is // read as unknown here and trusted only after the checks below. const record = payload as { draftId?: unknown; filePath?: unknown; relativePath?: unknown; source?: unknown; side?: unknown; startLine?: unknown; endLine?: unknown; code?: unknown; language?: unknown; comment?: unknown; targetSessionId?: unknown; }; // The editor thread mints the id so it can track its own draft without a // round trip. Absent when the comment came from anywhere else. const draftId = typeof record.draftId === 'string' && record.draftId ? record.draftId : undefined; // A session panel is told which session the comment is for, so it can wait // until it actually shows that session. The sidebar files wherever it is. const targetSessionId = typeof record.targetSessionId === 'string' && record.targetSessionId ? record.targetSessionId : undefined; const relativePath = typeof record.relativePath === 'string' ? record.relativePath : ''; const source = record.source === 'diff' ? 'diff' : 'file'; const side = record.side === 'original' || record.side === 'modified' ? record.side : undefined; const startLine = typeof record.startLine === 'number' ? record.startLine : 1; const endLine = typeof record.endLine === 'number' ? record.endLine : startLine; const code = typeof record.code === 'string' ? record.code : ''; const language = typeof record.language === 'string' ? record.language : 'text'; const comment = typeof record.comment === 'string' ? record.comment.trim() : ''; if (!relativePath) { console.warn('[openchamber] inline comment arrived without a path; dropping', record); return; } void Promise.all([ import('@/sync/session-ui-store'), import('@/stores/useDirectoryStore'), import('@/stores/useInlineCommentDraftStore'), ]).then(async ([{ useSessionUIStore }, { useDirectoryStore }, { useInlineCommentDraftStore }]) => { // Inline drafts are owned by runtime + directory + session. Both halves are // read together, from one store snapshot: read apart, a session that // finished loading between them would pair its key with the previous // session's directory, and the draft would land under a key ChatInput never // reads. Directory precedence matches the composer's own. const resolveTarget = () => { const sessionState = useSessionUIStore.getState(); const currentSessionId = sessionState.currentSessionId ?? null; const draft = sessionState.newSessionDraft; return resolveCommentTarget({ currentSessionId, sessionDirectory: currentSessionId ? sessionState.getDirectoryForSession(currentSessionId) ?? null : null, draftOpen: Boolean(draft?.open), draftDirectory: draft?.open ? draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null : null, currentDirectory: useDirectoryStore.getState().currentDirectory ?? null, }, targetSessionId); }; // A comment can arrive before the chat surface shows its session: a panel // opened for the comment knows its directory long before the session list // has loaded and the session is selected. Filing before that put the draft // under a key this composer never reads. Wait for the surface to land on // the session (or an open draft) instead, within the extension's own // confirmation deadline. let target = resolveTarget(); for (let attempt = 0; !target && attempt < 80; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 250)); target = resolveTarget(); } if (!target) { console.warn('[openchamber] chat surface never showed the session; dropping inline comment', { relativePath, startLine, targetSessionId }); return; } // Checked after the wait, which is the window the removal can land in. if (removedComments.consume(draftId)) { return; } useInlineCommentDraftStore.getState().addDraft(target, { id: draftId, source, fileLabel: relativePath, startLine, endLine, side, code, language, text: comment, }); }); }); // The editor's comment threads mirror the composer's drafts, so every change to // the draft store is reported as a whole snapshot. Sending the full list rather // than add/remove events means a dropped notification cannot leave a thread // anchored to a comment that is no longer attached; sending the message empties // the list, which clears the threads through the same path. void import('@/stores/useInlineCommentDraftStore').then(({ useInlineCommentDraftStore }) => { let lastSignature = ''; const publish = (drafts: Record>) => { const flat = Object.values(drafts) .flat() .map((draft) => ({ id: draft.id, text: draft.text })); const signature = JSON.stringify(flat); if (signature === lastSignature) return; lastSignature = signature; postBridgeNotification('inlineComments:sync', { drafts: flat }); }; publish(useInlineCommentDraftStore.getState().drafts); useInlineCommentDraftStore.subscribe((state) => publish(state.drafts)); }); onCommand('removeLineComment', (payload) => { if (typeof payload !== 'object' || payload === null || !('draftId' in payload)) return; const { draftId } = payload; if (typeof draftId !== 'string' || !draftId) { return; } // Recorded even when the draft is already here: the store removal below is // the normal path, and this only matters when the draft has not landed yet. removedComments.remember(draftId); void Promise.all([ import('@/stores/useInlineCommentDraftStore'), import('@/lib/runtime-switch'), ]).then(([{ useInlineCommentDraftStore }, { getRuntimeKey }]) => { const state = useInlineCommentDraftStore.getState(); const runtimeKey = getRuntimeKey(); // The thread knows its draft id but not which target holds it. Search for // the owning key, and only within the current runtime: `removeDraft` // recomputes the key from the live runtime, so a target rebuilt from // another runtime's key would delete from the wrong place. for (const [key, drafts] of Object.entries(state.drafts)) { if (!drafts.some((draft) => draft.id === draftId)) continue; let parsed: unknown; try { parsed = JSON.parse(key); } catch { continue; } if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every((segment) => typeof segment === 'string')) continue; const [keyRuntime, directory, sessionKey] = parsed; if (keyRuntime !== runtimeKey) continue; state.removeDraft({ directory, sessionKey }, draftId); return; } }); }); onCommand('addFileMentions', (payload) => { const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths) ? (payload as { paths: unknown[] }).paths : []; const paths = rawPaths .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); if (paths.length === 0) { return; } const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' '); import('@/sync/input-store').then(({ useInputStore }) => { useInputStore.getState().setPendingInputText(mentionText, 'append-inline'); }); }); onCommand('addFileAttachments', (payload) => { const rawFiles = Array.isArray((payload as { files?: unknown[] })?.files) ? (payload as { files: unknown[] }).files : []; const files = rawFiles .map((entry) => { const record = entry as { filePath?: unknown; fileName?: unknown; fileSize?: unknown }; const filePath = typeof record.filePath === 'string' ? record.filePath.trim() : ''; const fileName = typeof record.fileName === 'string' ? record.fileName.trim() : ''; const fileSize = typeof record.fileSize === 'number' && Number.isFinite(record.fileSize) ? record.fileSize : null; return filePath && fileName ? { filePath, fileName, fileSize } : null; }) .filter((entry): entry is { filePath: string; fileName: string; fileSize: number | null } => entry !== null); if (files.length === 0) { return; } import('@/sync/input-store').then(({ useInputStore }) => { const inputStore = useInputStore.getState(); for (const file of files) { inputStore.addVSCodeFileAttachment(file.filePath, file.fileName, file.fileSize); } }); }); // Listen for createSessionWithPrompt command from extension (Explain, Improve Code) onCommand('createSessionWithPrompt', (payload) => { const { prompt } = payload as { prompt: string }; Promise.all([ import('@/sync/session-ui-store'), import('@/stores/useConfigStore'), import('@/sync/input-store'), ]).then(([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => { const sessionStore = useSessionUIStore.getState(); const configStore = useConfigStore.getState(); // Get current provider/model/agent configuration const { currentProviderId, currentModelId, currentAgentName } = configStore; if (currentProviderId && currentModelId) { if (!sessionStore.currentSessionId) { sessionStore.openNewSessionDraft(); } // Send the message - this will create the session from the draft and send sessionStore.sendMessage( prompt, currentProviderId, currentModelId, currentAgentName ?? undefined, undefined, // attachments undefined, // agentMentionName undefined // additionalParts ).catch((error: unknown) => { console.error('[OpenChamber] Failed to send prompt:', error); }); } else { // If no provider/model configured, just set the text and let user send manually useInputStore.getState().setPendingInputText(prompt); } }); }); const normalizeWorkspaceFoldersPayload = (value: unknown): Array<{ name: string; path: string }> => { if (!Array.isArray(value)) { return []; } return value .map((entry) => { const candidate = entry as { name?: unknown; path?: unknown }; const name = typeof candidate.name === 'string' ? candidate.name.trim() : ''; const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''; return path ? { name, path } : null; }) .filter((entry): entry is { name: string; path: string } => entry !== null); }; const syncVSCodeWorkspaceProjects = async ( workspaceFolders: Array<{ name: string; path: string }>, activePath?: string, ) => { if (window.__VSCODE_CONFIG__) { window.__VSCODE_CONFIG__.workspaceFolders = workspaceFolders; } const { useProjectsStore } = await import('@/stores/useProjectsStore'); return useProjectsStore.getState().syncVSCodeWorkspaceFolders(workspaceFolders, activePath); }; onCommand('workspaceFoldersChanged', (payload) => { const record = payload as { workspaceFolders?: unknown } | undefined; const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders); void syncVSCodeWorkspaceProjects(workspaceFolders); }); // Listen for newSession command from extension title bar button onCommand('newSession', (payload) => { const record = payload as { directory?: unknown; workspaceFolders?: unknown } | undefined; const directory = record?.directory; const directoryOverride = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : undefined; const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders); Promise.all([ import('@/sync/session-ui-store'), syncVSCodeWorkspaceProjects(workspaceFolders, directoryOverride), ]).then(([{ useSessionUIStore }, selectedProject]) => { useSessionUIStore.getState().openNewSessionDraft( directoryOverride ? { directoryOverride, selectedProjectId: selectedProject?.id ?? undefined } : undefined ); }); // Also dispatch event to navigate to chat view in VSCodeLayout window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } })); }); // Listen for showSettings command from extension title bar button onCommand('showSettings', () => { // Dispatch event to navigate to settings view in VSCodeLayout window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } })); }); // Run the same full OpenCode reload flow the app uses after an update: shows the // reload overlay, restarts the managed OpenCode (via the bridge's /api/config/reload), // and refreshes config/data. Triggered by the "Restart API Connection" command. onCommand('reloadOpenCode', () => { void import('@openchamber/ui/stores/useAgentsStore').then(({ reloadOpenCodeConfiguration }) => { void reloadOpenCodeConfiguration().catch(() => undefined); }); }); const getNotificationClaimKey = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown } | undefined): string => { const tag = typeof payload?.tag === 'string' ? payload.tag.trim() : ''; if (tag) return tag; return [payload?.sessionId, payload?.title, payload?.body] .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .map((value) => value.trim()) .join('|'); }; const claimOpenChamberNotification = async (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown } | undefined): Promise => { const key = getNotificationClaimKey(payload); if (!key) return true; try { const result = await sendBridgeMessage<{ claimed?: boolean }>('api:notifications:claim', { key }); return result?.claimed === true; } catch { return true; } }; const showOpenChamberNotification = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown; requireHidden?: unknown } | undefined) => { if (typeof Notification === 'undefined') { return false; } const show = async () => { const isVSCodeWindowFocused = window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ ?? document.hasFocus(); if (payload?.requireHidden === true && isVSCodeWindowFocused) { return false; } if (Notification.permission !== 'granted') { return false; } const title = typeof payload?.title === 'string' && payload.title.trim().length > 0 ? payload.title.trim() : 'OpenChamber'; const body = typeof payload?.body === 'string' ? payload.body : ''; const sessionId = typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0 ? payload.sessionId.trim() : ''; if (!await claimOpenChamberNotification({ ...payload, title, body, sessionId })) { return false; } const notification = new Notification(title, { body }); notification.onclick = () => { if (sessionId) { import('@/sync/session-ui-store').then(({ useSessionUIStore }) => { useSessionUIStore.getState().setCurrentSession(sessionId); }); } window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } })); }; return true; }; if (Notification.permission === 'default') { void Notification.requestPermission().then((permission) => { if (permission === 'granted') { void show(); } }); return true; } void show(); return true; }; onCommand('showNotification', (payload) => { showOpenChamberNotification(payload as { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined); }); onCommand('windowFocusChanged', (payload) => { if (typeof payload === 'object' && payload && typeof (payload as { focused?: unknown }).focused === 'boolean') { window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ = (payload as { focused: boolean }).focused; } }); const readyNotificationCooldowns = new Map(); const errorNotificationCooldowns = new Map(); const READY_NOTIFICATION_COOLDOWN_MS = 5000; const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250; let notificationSettingsSyncPromise: Promise | null = null; const getPayloadString = (value: unknown): string => typeof value === 'string' ? value.trim() : ''; const normalizeNotificationPlainText = (text: string): string => text .replace(/```[\s\S]*?```/g, ' ') .replace(/`([^`]*)`/g, '$1') .replace(/^[\t ]*[-*+]\s+/gm, '') .replace(/^#{1,6}\s+/gm, '') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/__(.*?)__/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/_(.*?)_/g, '$1') .replace(/\[(.*?)\]\((.*?)\)/g, '$1') .replace(/\s*\n\s*/g, ' ') .replace(/\s+/g, ' ') .trim(); const truncateNotificationText = (text: string, maxLength: number): string => ( text.length <= maxLength ? text : `${text.slice(0, maxLength)}...` ); const resolvePositiveNotificationNumber = (value: unknown, fallback: number): number => ( typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback ); const ensureNotificationSettingsSynced = async () => { if (!notificationSettingsSyncPromise) { notificationSettingsSyncPromise = import('@/lib/persistence') .then(({ syncDesktopSettings }) => syncDesktopSettings()) .catch((error) => { notificationSettingsSyncPromise = null; console.warn('[OpenChamber] Failed to sync notification settings:', error); }); } await notificationSettingsSyncPromise; }; const prepareNotificationLastMessage = ( message: string, settings: { maxLastMessageLength: number }, ): string => { const maxLength = resolvePositiveNotificationNumber(settings.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH); return truncateNotificationText(normalizeNotificationPlainText(message), maxLength); }; const resolveTemplate = (template: string, variables: Record): string => ( template.replace(/\{(\w+)\}/g, (_match, key: string) => variables[key] ?? '') ); const shouldApplyTemplateMessage = (template: string, resolved: string, variables: Record) => { if (!resolved) return false; if (template.includes('{last_message}')) { return variables.last_message.trim().length > 0; } return true; }; const formatNotificationLabel = (raw: string, fallback: string): string => { if (!raw) return fallback; return raw.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); }; const extractNotificationTextFromParts = (parts: unknown): string => { if (!Array.isArray(parts)) return ''; return parts .map((part) => { if (!part || typeof part !== 'object') return ''; const entry = part as { type?: unknown; text?: unknown; content?: unknown }; if (entry.type === 'text') { return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : ''; } return ''; }) .filter(Boolean) .join('\n') .trim(); }; const extractNotificationLastMessage = (payload: Record): string => { const properties = (payload.properties ?? payload) as Record; const info = properties.info as Record | undefined; if (!info) return ''; return extractNotificationTextFromParts(info.parts ?? properties.parts) || extractNotificationTextFromParts(info.content); }; const fetchLastAssistantMessageText = async (sessionId: string, messageId?: string): Promise => { if (!sessionId) return ''; try { const messages = await opencodeClient.getSessionMessages(sessionId, 5); if (!Array.isArray(messages)) return ''; let target = messageId ? messages.find((message) => { const info = message && typeof message === 'object' ? (message as { info?: { id?: unknown; role?: unknown } }).info : undefined; return info?.id === messageId && info?.role === 'assistant'; }) : null; if (!target) { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; const info = message && typeof message === 'object' ? (message as { info?: { role?: unknown; finish?: unknown } }).info : undefined; if (info?.role === 'assistant' && info?.finish === 'stop') { target = message; break; } } } if (!target || typeof target !== 'object') return ''; const message = target as { parts?: unknown; content?: unknown; info?: { parts?: unknown; content?: unknown } }; return extractNotificationTextFromParts(message.parts ?? message.info?.parts) || extractNotificationTextFromParts(message.content ?? message.info?.content); } catch { return ''; } }; const getNotificationTemplate = ( settings: { notificationTemplates?: Record }, key: 'completion' | 'subtask' | 'error' | 'question', fallback: { title: string; message: string }, ) => { const candidate = settings.notificationTemplates?.[key]; return { title: typeof candidate?.title === 'string' ? candidate.title : fallback.title, message: typeof candidate?.message === 'string' ? candidate.message : fallback.message, }; }; const buildNotificationVariables = (payload: Record, sessionId: string, lastMessage: string): Record => { const properties = (payload.properties ?? payload) as Record; const info = properties.info as Record | undefined; const pathInfo = info?.path as { root?: unknown; cwd?: unknown } | undefined; const worktree = getPayloadString(pathInfo?.root ?? pathInfo?.cwd); const modelId = getPayloadString(info?.modelID ?? info?.modelId ?? (info?.model as { modelID?: unknown } | undefined)?.modelID); return { project_name: worktree.split(/[\\/]/).filter(Boolean).pop() || '', worktree, branch: '', session_name: getPayloadString(properties.sessionTitle ?? (properties.session as { title?: unknown } | undefined)?.title ?? info?.sessionTitle), agent_name: formatNotificationLabel(getPayloadString(info?.agent ?? info?.mode), 'Agent'), model_name: formatNotificationLabel(modelId, 'Assistant'), last_message: lastMessage, session_id: sessionId, }; }; const getNotificationSessionId = (payload: Record): string => { const properties = (payload.properties ?? payload) as Record; const info = properties.info as Record | undefined; return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session); }; const getNotificationDirectory = (payload: Record): string | null => { const properties = (payload.properties ?? payload) as Record; const info = properties.info as Record | undefined; return getPayloadString(properties.directory ?? info?.directory) || null; }; window.addEventListener('openchamber:vscode-notification-event', (event) => { const detail = (event as CustomEvent<{ directory?: string; payload?: unknown }>).detail; const payload = detail?.payload; if (!payload || typeof payload !== 'object') { return; } const record = payload as Record; const type = getPayloadString(record.type); const properties = (record.properties ?? record) as Record; const info = properties.info as Record | undefined; const sessionId = getNotificationSessionId(record); if (!sessionId) { return; } Promise.all([ import('@/stores/useUIStore'), ]).then(async ([{ useUIStore }]) => { await ensureNotificationSettingsSynced(); const settings = useUIStore.getState(); if (!settings.nativeNotificationsEnabled) { return; } const requireHidden = settings.notificationMode !== 'always'; const messageId = getPayloadString(info?.id); const error = properties.error; const errorMessage = getPayloadString( typeof error === 'object' && error ? (error as { message?: unknown }).message : error, ); const rawLastMessage = extractNotificationLastMessage(record) || errorMessage || await fetchLastAssistantMessageText(sessionId, messageId); const lastMessage = prepareNotificationLastMessage( rawLastMessage, settings, ); const variables = buildNotificationVariables(record, sessionId, lastMessage); const isAssistantMessage = type === 'message.updated' && getPayloadString(info?.role) === 'assistant'; const finish = isAssistantMessage ? getPayloadString(info?.finish) : ''; const isCompletion = type === 'session.idle' || finish === 'stop'; const isError = type === 'session.error' || finish === 'error'; if (isCompletion) { const session = await opencodeClient.getSession(sessionId, getNotificationDirectory(record)).catch(() => undefined); if (!session) return; const isSubtask = Boolean(session?.parentID); if (isSubtask ? !settings.notifyOnSubtasks : !settings.notifyOnCompletion) return; const now = Date.now(); const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0; if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return; readyNotificationCooldowns.set(sessionId, now); const template = getNotificationTemplate(settings, isSubtask ? 'subtask' : 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' }); const title = resolveTemplate(template.title, variables) || 'Agent is ready'; const body = resolveTemplate(template.message, variables); showOpenChamberNotification({ title, body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`, sessionId, requireHidden, }); return; } if (isError) { if (!settings.notifyOnError) return; const now = Date.now(); const lastAt = errorNotificationCooldowns.get(sessionId) ?? 0; if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return; errorNotificationCooldowns.set(sessionId, now); const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' }); const title = resolveTemplate(template.title, variables) || 'Tool error'; const body = resolveTemplate(template.message, variables); showOpenChamberNotification({ title, body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred', sessionId, requireHidden, }); return; } if (type === 'question.asked') { if (!settings.notifyOnQuestion) return; const questions = Array.isArray(properties.questions) ? properties.questions : []; const firstQuestion = questions[0] as Record | undefined; const header = getPayloadString(firstQuestion?.header); const questionText = getPayloadString(firstQuestion?.question); const questionVariables = { ...variables, last_message: questionText || header }; const template = getNotificationTemplate(settings, 'question', { title: 'Input needed', message: '{last_message}' }); const title = resolveTemplate(template.title, questionVariables) || (/plan\s*mode/i.test(header) ? 'Switch to plan mode' : /build\s*agent/i.test(header) ? 'Switch to build mode' : header || 'Input needed'); const body = resolveTemplate(template.message, questionVariables); showOpenChamberNotification({ title, body: shouldApplyTemplateMessage(template.message, body, questionVariables) ? body : questionText || 'Agent is waiting for your response', sessionId, requireHidden, }); return; } if (type === 'permission.asked') { if (!settings.notifyOnQuestion) return; const requestId = getPayloadString(properties.id); if (requestId) { const accepted = await processVSCodePermissionAutoAccept( properties as unknown as PermissionRequest, detail?.directory, ); if (accepted) return; } const permission = getPayloadString(properties.permission); const sessionTitle = getPayloadString(properties.sessionTitle); const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval'; const permissionVariables = { ...variables, last_message: fallbackMessage }; const template = getNotificationTemplate(settings, 'question', { title: 'Permission required', message: '{last_message}' }); const title = resolveTemplate(template.title, permissionVariables) || 'Permission required'; const body = resolveTemplate(template.message, permissionVariables); showOpenChamberNotification({ title, body: shouldApplyTemplateMessage(template.message, body, permissionVariables) ? body : fallbackMessage, sessionId, requireHidden, }); } }); }); // Listen for settings sync command from extension (broadcast to all VS Code webviews) onCommand('settingsSynced', () => { import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => { void syncDesktopSettings({ adoptTheme: false }); }); }); onCommand('permissionAutoAcceptSynced', (payload) => { if (!payload || typeof payload !== 'object') return; const snapshot = payload as { sessions?: unknown; revision?: unknown }; const sessions = snapshot.sessions; if (!sessions || typeof sessions !== 'object') return; usePermissionStore.getState().applySnapshot({ sessions: sessions as Record, revision: typeof snapshot.revision === 'number' ? snapshot.revision : undefined, }); }); // Listen for active editor file changes from the extension onCommand('activeEditorFile', (payload) => { import('@/sync/input-store').then(({ useInputStore }) => { useInputStore.getState().setActiveEditorFile((payload as VSCodeActiveEditorFile | null) ?? null); }); }); import('@openchamber/ui/apps/renderVSCodeApp') .then(async ({ renderVSCodeApp }) => { renderVSCodeApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createVSCodeAPIs()); await waitForUiMount(); uiMounted = true; maybeHideLoadingOverlay(); }) .catch((error) => { console.error('[OpenChamber] Failed to bootstrap UI:', error); // If the UI bundle fails to load, remove the overlay so the user at least sees errors in the root. uiMounted = true; fadeOutLoadingScreen(); });