Files
openchamber/packages/vscode/webview/main.tsx
T
𝖎𝖚𝖑𝖎𝖎𝖆andBohdan Triapitsyn c88dd16d2a fix: prevent bundled OpenCode self-upgrades (#2525)
* fix: prevent bundled OpenCode self-upgrades

* feat(vscode): support OpenCode upgrades

* fix: refresh OpenCode update status on runtime switch

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-07-29 19:59:41 +03:00

1851 lines
77 KiB
TypeScript

import { createVSCodeAPIs } from './api';
import { onCommand, onThemeChange, 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';
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<string, unknown>; dark?: Record<string, unknown> } | 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<boolean> => {
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<VSCodeThemePayload>('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<string, string> => {
if (!headers) return {};
const normalized = new Headers(sanitizeHeadersForBrowser(headers) ?? headers);
const result: Record<string, string> = {};
normalized.forEach((value, key) => {
result[key] = value;
});
return result;
};
const getRequestHeaders = (input?: RequestInfo | URL, init?: RequestInit): Record<string, string> => {
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<string, string>; 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');
}
if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) {
return unsupportedWebRouteResponse('Scheduled tasks');
}
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('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<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('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\/(opencode-go|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' } });
}
}
return null;
};
const originalFetch = window.fetch.bind(window);
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)) {
const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers }));
if (!start.streamId) {
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
}
const streamId = start.streamId;
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<Uint8Array>({
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(() => {});
},
});
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('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);
});
});
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<boolean> => {
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<string, number>();
const errorNotificationCooldowns = new Map<string, number>();
const READY_NOTIFICATION_COOLDOWN_MS = 5000;
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
let notificationSettingsSyncPromise: Promise<void> | 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, string>): string => (
template.replace(/\{(\w+)\}/g, (_match, key: string) => variables[key] ?? '')
);
const shouldApplyTemplateMessage = (template: string, resolved: string, variables: Record<string, string>) => {
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, unknown>): string => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
if (!info) return '';
return extractNotificationTextFromParts(info.parts ?? properties.parts) || extractNotificationTextFromParts(info.content);
};
const fetchLastAssistantMessageText = async (sessionId: string, messageId?: string): Promise<string> => {
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<string, { title?: string; message?: string }> },
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<string, unknown>, sessionId: string, lastMessage: string): Record<string, string> => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | 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, unknown>): string => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | undefined;
return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session);
};
const getNotificationDirectory = (payload: Record<string, unknown>): string | null => {
const properties = (payload.properties ?? payload) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | 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<string, unknown>;
const type = getPayloadString(record.type);
const properties = (record.properties ?? record) as Record<string, unknown>;
const info = properties.info as Record<string, unknown> | 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<string, unknown> | 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();
});
});
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<string, boolean>,
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();
});