2025-12-13 16:34:17 +02:00
import { createVSCodeAPIs } from './api' ;
2026-02-11 19:28:22 +02:00
import { onCommand , onThemeChange , proxyApiRequest , proxySessionMessageRequest , sendBridgeMessage , startSseProxy , stopSseProxy } from './api/bridge' ;
2026-03-31 18:47:00 +03:00
import { vscodeStreamPerfCount , vscodeStreamPerfMeasure , vscodeStreamPerfObserve } from './api/streamPerf' ;
2026-06-02 00:43:05 +03:00
import { extractBodyBase64 , extractBodyText , extractJsonBody , hasInitBody } from './requestBodyTransport' ;
2026-01-06 21:31:04 +02:00
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types' ;
2026-06-02 00:43:05 +03:00
import { opencodeClient } from '@openchamber/ui/lib/opencode/client' ;
2026-06-24 00:49:44 +08:00
import { sanitizeHeadersForBrowser } from '@openchamber/ui/lib/runtime-fetch' ;
2025-12-13 16:34:17 +02:00
import {
buildVSCodeThemeFromPalette ,
readVSCodeThemePalette ,
type VSCodeThemeKind ,
type VSCodeThemePayload ,
2026-01-06 21:31:04 +02:00
} from '@openchamber/ui/lib/theme/vscode/adapter' ;
2026-06-10 19:19:30 +02:00
import { getBootstrapMessages , readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n' ;
2026-05-05 00:24:03 +02:00
import type { VSCodeActiveEditorFile } from '@/sync/input-store' ;
2026-07-17 12:59:41 +03:00
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' ;
2026-08-13 23:50:54 +03:00
import { focusChatInput } from '@openchamber/ui/components/chat/composer/editor/dom' ;
2025-12-13 16:34:17 +02:00
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected' ;
2026-01-03 22:11:16 +01:00
type PanelType = 'chat' | 'agentManager' ;
2025-12-13 16:34:17 +02:00
2026-01-06 21:31:04 +02:00
declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__ : string ;
2025-12-13 16:34:17 +02:00
declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__? : RuntimeAPIs ;
__VSCODE_CONFIG__ ?: {
2025-12-24 22:50:46 +02:00
apiUrl? : string ;
2025-12-13 16:34:17 +02:00
workspaceFolder : string ;
2026-06-10 23:58:40 +03:00
workspaceFolders? : Array < { name : string ; path : string } > ;
2025-12-13 16:34:17 +02:00
theme : string ;
connectionStatus : string ;
2025-12-24 03:31:07 +02:00
cliAvailable? : boolean ;
2026-05-01 12:23:58 +03:00
extensionVersion? : string ;
platform? : string ;
arch? : string ;
2026-01-03 22:11:16 +01:00
panelType? : PanelType ;
2026-01-14 17:13:47 +02:00
viewMode ?: 'sidebar' | 'editor' ;
initialSessionId? : string | null ;
2025-12-13 16:34:17 +02:00
};
__OPENCHAMBER_VSCODE_THEME__? : VSCodeThemePayload [ 'theme' ];
2025-12-15 02:29:39 +02:00
__OPENCHAMBER_VSCODE_SHIKI_THEMES__ ?: { light? : Record < string , unknown >; dark? : Record < string , unknown > } | null ;
2025-12-24 03:31:07 +02:00
__OPENCHAMBER_CONNECTION__ ?: { status : ConnectionStatus ; error? : string ; cliAvailable? : boolean };
2025-12-20 02:44:46 +02:00
__OPENCHAMBER_HOME__? : string ;
2026-01-03 22:11:16 +01:00
__OPENCHAMBER_PANEL_TYPE__? : PanelType ;
2026-05-19 02:06:32 +03:00
__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__? : boolean ;
2025-12-13 16:34:17 +02:00
}
}
console . log ( '[OpenChamber] VS Code webview starting...' );
2026-01-06 21:31:04 +02:00
console . log ( '[OpenChamber] VS Code webview build:' , __OPENCHAMBER_WEBVIEW_BUILD_TIME__ );
2025-12-13 16:34:17 +02:00
console . log ( '[OpenChamber] Config:' , window . __VSCODE_CONFIG__ );
2026-01-06 21:31:04 +02:00
try {
if ( window . localStorage . getItem ( 'openchamber_stream_debug' ) === '1' ) {
console . log ( '[OpenChamber] Debug: openchamber_stream_debug=1' );
}
} catch {
// ignore
}
2025-12-13 16:34:17 +02:00
window . __OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs ();
2026-06-10 19:19:30 +02:00
const bootstrapLocale = readStoredLocaleForBootstrap ();
const bootstrapMessages = getBootstrapMessages ( bootstrapLocale );
2025-12-13 16:34:17 +02:00
const bootstrapConnectionStatus = () => {
const initialStatus = ( window . __VSCODE_CONFIG__ ? . connectionStatus as ConnectionStatus | undefined ) || 'connecting' ;
2025-12-24 03:31:07 +02:00
const cliAvailable = window . __VSCODE_CONFIG__ ? . cliAvailable ?? true ;
window . __OPENCHAMBER_CONNECTION__ = { status : initialStatus , cliAvailable };
2025-12-13 16:34:17 +02:00
};
bootstrapConnectionStatus ();
2026-05-07 01:31:17 +03:00
// Expose panel type globally for the VS Code app root to conditionally render.
2026-01-03 22:11:16 +01:00
window . __OPENCHAMBER_PANEL_TYPE__ = ( window . __VSCODE_CONFIG__ ? . panelType as PanelType ) || 'chat' ;
2025-12-13 16:34:17 +02:00
const handleConnectionMessage = ( event : MessageEvent ) => {
const msg = event . data ;
if ( msg ? . type === 'connectionStatus' ) {
const payload : ConnectionStatus = msg . status ;
const error : string | undefined = msg . error ;
2025-12-24 03:31:07 +02:00
const prevCliAvailable = window . __OPENCHAMBER_CONNECTION__ ? . cliAvailable ?? true ;
window . __OPENCHAMBER_CONNECTION__ = { status : payload , error , cliAvailable : prevCliAvailable };
2025-12-13 16:34:17 +02:00
window . dispatchEvent ( new CustomEvent ( 'openchamber:connection-status' , { detail : { status : payload , error } }));
}
};
window . addEventListener ( 'message' , handleConnectionMessage );
2025-12-24 22:50:46 +02:00
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' ) {
2026-06-15 13:02:26 +03:00
// 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 ();
2025-12-24 22:50:46 +02:00
return ;
}
if ( connectionStatus === 'error' ) {
const error = window . __OPENCHAMBER_CONNECTION__ ? . error ;
2026-06-10 19:19:30 +02:00
setLoadingStatusText ( error || bootstrapMessages . connectionError , 'error' );
2025-12-24 22:50:46 +02:00
fadeOutLoadingScreen ();
return ;
}
if ( connectionStatus === 'disconnected' ) {
2026-06-10 19:19:30 +02:00
setLoadingStatusText ( bootstrapMessages . disconnected , 'error' );
2025-12-24 22:50:46 +02:00
fadeOutLoadingScreen ();
return ;
}
2026-05-30 01:03:08 +03:00
// Connecting — no jargon; the animated logo conveys progress.
setLoadingStatusText ( '' );
2025-12-24 22:50:46 +02:00
};
2025-12-13 16:34:17 +02:00
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 );
2025-12-15 02:29:39 +02:00
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 ));
});
};
2025-12-13 16:34:17 +02:00
onThemeChange (( payload ) => {
const kind = ( typeof payload === 'string'
? payload
: typeof payload === 'object' && payload
? payload.kind
: undefined ) as VSCodeThemeKind | undefined ;
2025-12-15 02:29:39 +02:00
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 );
2025-12-13 16:34:17 +02:00
});
const workspaceFolder = window . __VSCODE_CONFIG__ ? . workspaceFolder ;
if ( workspaceFolder ) {
2026-01-06 21:31:04 +02:00
const normalizeWorkspacePath = ( value : string ) => {
2026-03-20 18:58:13 +02:00
const normalized = value
. replace ( /\\/g , '/' )
. replace ( /^([a-z]):\// , ( _ , letter : string ) => ` ${ letter . toUpperCase () } :/` )
. replace ( /^\/([a-z]):\// , ( _ , letter : string ) => `/ ${ letter . toUpperCase () } :/` );
2026-01-06 21:31:04 +02:00
if ( normalized === '/' ) {
return '/' ;
}
return normalized . length > 1 ? normalized . replace ( /\/+$/ , '' ) : normalized ;
};
const normalizedWorkspaceFolder = normalizeWorkspacePath ( workspaceFolder );
window . __OPENCHAMBER_HOME__ = normalizedWorkspaceFolder ;
2025-12-13 16:34:17 +02:00
try {
2026-01-06 21:31:04 +02:00
window . localStorage . setItem ( 'lastDirectory' , normalizedWorkspaceFolder );
window . localStorage . setItem ( 'homeDirectory' , normalizedWorkspaceFolder );
2026-01-20 20:20:17 +07:00
// 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' );
}
2025-12-13 16:34:17 +02:00
} catch ( error ) {
console . warn ( 'Failed to persist workspace folder' , error );
}
}
const normalizeUrl = ( input : string | URL ) => {
try {
2026-04-18 13:46:51 +03:00
return typeof input === 'string' ? new URL ( input , window . location . href ) : new URL ( input . toString (), window . location . href );
2025-12-13 16:34:17 +02:00
} catch {
return null ;
}
};
2025-12-24 22:50:46 +02:00
const headersToRecord = ( headers : HeadersInit | undefined ) : Record < string , string > => {
if ( ! headers ) return {};
2026-06-24 00:49:44 +08:00
const normalized = new Headers ( sanitizeHeadersForBrowser ( headers ) ?? headers );
2025-12-24 22:50:46 +02:00
const result : Record < string , string > = {};
normalized . forEach (( value , key ) => {
result [ key ] = value ;
});
return result ;
};
2026-06-02 00:43:05 +03:00
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 );
2026-06-24 00:49:44 +08:00
const directoryEncoding = Object . entries ( headers ). find (([ key ]) => key . toLowerCase () === 'x-opencode-directory-encoding' ) ? .[ 1 ];
2026-06-02 00:43:05 +03:00
for ( const [ key , value ] of Object . entries ( headers )) {
2026-06-24 00:49:44 +08:00
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 ; }
}
2026-06-02 00:43:05 +03:00
}
return undefined ;
};
2026-06-10 17:05:10 +03:00
const decodeBase64 = ( value : string ) : ArrayBuffer => {
2025-12-24 22:50:46 +02:00
const binary = atob ( value );
2026-06-10 17:05:10 +03:00
const buffer = new ArrayBuffer ( binary . length );
const bytes = new Uint8Array ( buffer );
2025-12-24 22:50:46 +02:00
for ( let i = 0 ; i < binary . length ; i += 1 ) {
bytes [ i ] = binary . charCodeAt ( i );
}
2026-06-10 17:05:10 +03:00
return buffer ;
2025-12-24 22:50:46 +02:00
};
2026-06-02 00:43:05 +03:00
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 ;
};
2026-02-17 18:01:57 +02:00
const isNullBodyStatus = ( status : number ) : boolean => status === 204 || status === 205 || status === 304 ;
const buildProxiedResponse = (
2026-05-21 15:45:15 +03:00
proxied : { status : number ; headers : Record < string , string >; bodyBase64? : string ; bodyText? : string }
2026-02-17 18:01:57 +02:00
) : Response => {
if ( isNullBodyStatus ( proxied . status )) {
return new Response ( null , { status : proxied.status , headers : proxied.headers });
}
2026-05-21 15:45:15 +03:00
if ( typeof proxied . bodyText === 'string' ) {
return new Response ( proxied . bodyText , { status : proxied.status , headers : proxied.headers });
}
2026-06-10 17:05:10 +03:00
const body = proxied . bodyBase64 ? decodeBase64 ( proxied . bodyBase64 ) : new ArrayBuffer ( 0 );
2026-02-17 18:01:57 +02:00
return new Response ( body , { status : proxied.status , headers : proxied.headers });
};
2026-06-02 00:43:05 +03:00
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' ;
2025-12-13 16:34:17 +02:00
2026-06-02 00:43:05 +03:00
const handleLocalApiRequest = async ( input : RequestInfo | URL , url : URL , init : RequestInit | undefined , method : string ) => {
const pathname = url . pathname ;
const normalizedPathname = pathname !== '/' ? pathname . replace ( /\/+$/ , '' ) : pathname ;
2026-02-11 19:28:22 +02:00
2026-06-02 00:43:05 +03:00
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 || '' ,
});
2026-02-11 19:28:22 +02:00
}
2026-06-02 00:43:05 +03:00
if ( normalizedPathname === '/api/preview/targets' ) {
return unsupportedWebRouteResponse ( 'Preview proxy' );
2026-02-11 19:28:22 +02:00
}
2026-06-02 00:43:05 +03:00
if ( normalizedPathname . startsWith ( '/api/openchamber/tunnel/' )) {
return unsupportedWebRouteResponse ( 'Remote tunnel settings' );
2026-02-11 19:28:22 +02:00
}
2026-06-02 00:43:05 +03:00
if ( /^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/ . test ( normalizedPathname )) {
return unsupportedWebRouteResponse ( 'Scheduled tasks' );
2026-02-11 19:28:22 +02:00
}
2026-02-17 18:01:57 +02:00
if ( normalizedPathname === '/api/sessions/snapshot' && method === 'GET' ) {
2026-03-31 18:47:00 +03:00
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 }), {
2026-02-17 18:01:57 +02:00
status : 200 ,
headers : { 'Content-Type' : 'application/json' },
});
}
2026-07-17 12:59:41 +03:00
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' ) {
2026-05-19 02:06:32 +03:00
const bodyText = await extractBodyText ( url , init , method );
2026-07-17 12:59:41 +03:00
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 ,
2026-05-19 02:06:32 +03:00
headers : { 'Content-Type' : 'application/json' },
});
}
2026-03-31 18:47:00 +03:00
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 || {}), {
2026-02-17 18:01:57 +02:00
status : 200 ,
headers : { 'Content-Type' : 'application/json' },
});
}
2026-03-31 18:47:00 +03:00
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' },
},
);
}
2026-02-17 18:01:57 +02:00
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' },
});
}
2026-05-19 02:06:32 +03:00
if (( pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' ) && method === 'POST' ) {
2026-02-17 18:01:57 +02:00
return new Response ( JSON . stringify ({ error : 'TTS endpoints are not available in VS Code runtime' }), {
status : 501 ,
headers : { 'Content-Type' : 'application/json' },
});
}
2025-12-13 16:34:17 +02:00
2026-07-04 02:48:07 +03:00
// 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' },
});
}
2025-12-24 03:31:07 +02:00
// Health endpoints: reflect actual connection status
2025-12-13 16:34:17 +02:00
if ( pathname === '/health' || pathname === '/api/health' ) {
2025-12-24 03:31:07 +02:00
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 ,
}), {
2025-12-13 16:34:17 +02:00
status : 200 ,
headers : { 'Content-Type' : 'application/json' },
});
}
if ( pathname . startsWith ( '/api/fs/list' )) {
const targetPath = url . searchParams . get ( 'path' ) || '' ;
2026-01-20 20:20:17 +07:00
const respectGitignore = url . searchParams . get ( 'respectGitignore' ) === 'true' ;
const data = await sendBridgeMessage ( 'api:fs:list' , { path : targetPath , respectGitignore });
2025-12-13 16:34:17 +02:00
return new Response ( JSON . stringify ( data ), { status : 200 , headers : { 'Content-Type' : 'application/json' } });
}
if ( pathname . startsWith ( '/api/fs/mkdir' )) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2025-12-13 16:34:17 +02:00
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' } });
}
2026-02-28 02:21:12 +08:00
if ( pathname . startsWith ( '/api/vscode/drop-files' ) && method === 'POST' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2026-02-28 02:21:12 +08:00
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' } });
}
2026-03-04 01:41:01 +02:00
if ( pathname . startsWith ( '/api/vscode/save-image' ) && method === 'POST' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2026-03-04 01:41:01 +02:00
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' } });
}
2026-04-17 18:46:13 +03:00
if ( pathname . startsWith ( '/api/vscode/save-markdown' ) && method === 'POST' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2026-04-17 18:46:13 +03:00
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' } });
}
2025-12-24 22:50:46 +02:00
if ( pathname . startsWith ( '/api/config/agents/' )) {
const encodedName = pathname . slice ( '/api/config/agents/' . length );
const name = decodeURIComponent ( encodedName );
2026-06-02 00:43:05 +03:00
const verb = method ;
const body = await extractJsonBody ( input , init , method );
const directory = getRequestDirectoryHint ( url , input , init );
2025-12-24 22:50:46 +02:00
try {
2026-01-06 21:31:04 +02:00
const data = await sendBridgeMessage ( 'api:config/agents' , { method : verb , name , body , directory });
2025-12-24 22:50:46 +02:00
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 );
2026-06-02 00:43:05 +03:00
const verb = method ;
const body = await extractJsonBody ( input , init , method );
const directory = getRequestDirectoryHint ( url , input , init );
2025-12-24 22:50:46 +02:00
try {
2026-01-06 21:31:04 +02:00
const data = await sendBridgeMessage ( 'api:config/commands' , { method : verb , name , body , directory });
2025-12-24 22:50:46 +02:00
return new Response ( JSON . stringify ( data ), { status : 200 , headers : { 'Content-Type' : 'application/json' } });
} catch ( error ) {
const message = error instanceof Error ? error.message : String ( error );
2025-12-30 15:11:07 +02:00
return new Response ( JSON . stringify ({ error : message }), { status : 500 , headers : { 'Content-Type' : 'application/json' } });
}
}
2026-02-23 04:22:33 +07:00
if ( pathname === '/api/config/mcp' ) {
2026-06-02 00:43:05 +03:00
const verb = method ;
const body = await extractJsonBody ( input , init , method );
const directory = getRequestDirectoryHint ( url , input , init );
2026-02-23 04:22:33 +07:00
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 );
2026-06-02 00:43:05 +03:00
const verb = method ;
const body = await extractJsonBody ( input , init , method );
const directory = getRequestDirectoryHint ( url , input , init );
2026-02-23 04:22:33 +07:00
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' } });
}
}
2026-06-02 00:43:05 +03:00
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' } });
}
}
2025-12-30 15:11:07 +02:00
// 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 ]);
2026-06-02 00:43:05 +03:00
const verb = method ;
const body = await extractJsonBody ( input , init , method );
2025-12-30 15:11:07 +02:00
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' } });
}
}
2025-12-30 21:03:16 +02:00
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' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2025-12-30 21:03:16 +02:00
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' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2025-12-30 21:03:16 +02:00
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' } });
}
}
2025-12-30 15:11:07 +02:00
// 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 );
2026-06-02 00:43:05 +03:00
const verb = method ;
const body = await extractJsonBody ( input , init , method );
2025-12-30 15:11:07 +02:00
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 );
2025-12-24 22:50:46 +02:00
return new Response ( JSON . stringify ({ error : message }), { status : 500 , headers : { 'Content-Type' : 'application/json' } });
}
}
2025-12-13 16:34:17 +02:00
if ( pathname . startsWith ( '/api/config/settings' )) {
2026-06-02 00:43:05 +03:00
if ( method === 'GET' ) {
2025-12-13 16:34:17 +02:00
const settings = await sendBridgeMessage ( 'api:config/settings:get' );
return new Response ( JSON . stringify ( settings ), { status : 200 , headers : { 'Content-Type' : 'application/json' } });
}
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2025-12-13 16:34:17 +02:00
const updated = await sendBridgeMessage ( 'api:config/settings:save' , body );
return new Response ( JSON . stringify ( updated ), { status : 200 , headers : { 'Content-Type' : 'application/json' } });
}
2026-04-30 14:39:55 -07:00
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' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2026-04-30 14:39:55 -07:00
const data = await sendBridgeMessage ( 'api:behavior/agents-md:save' , body );
return new Response ( JSON . stringify ( data ), { status : 200 , headers : { 'Content-Type' : 'application/json' } });
}
}
2026-04-07 22:51:14 +03:00
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' ) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2026-04-07 22:51:14 +03:00
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' } });
}
}
2026-03-31 18:47:00 +03:00
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' } });
}
}
2025-12-13 16:34:17 +02:00
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' } });
}
2026-06-02 00:43:05 +03:00
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 ));
}
}
2025-12-13 16:34:17 +02:00
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' } });
}
}
2026-06-05 15:09:24 +03:00
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' },
});
}
2026-07-29 19:59:41 +03:00
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 );
}
2026-03-31 18:47:00 +03:00
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' } });
}
}
2026-03-15 23:14:37 +02:00
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' ;
2026-05-01 12:23:58 +03:00
const platform = url . searchParams . get ( 'platform' ) || window . __VSCODE_CONFIG__ ? . platform || undefined ;
const arch = url . searchParams . get ( 'arch' ) || window . __VSCODE_CONFIG__ ? . arch || undefined ;
2026-03-15 23:14:37 +02:00
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 ,
2026-03-22 23:02:53 +02:00
platform ,
arch ,
2026-03-15 23:14:37 +02:00
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' } });
}
}
2025-12-13 16:34:17 +02:00
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' )) {
2026-06-02 00:43:05 +03:00
const body = await extractJsonBody ( input , init , method );
2025-12-13 16:34:17 +02:00
const result = await sendBridgeMessage ( 'api:opencode/directory' , { path : body.path });
return new Response ( JSON . stringify ( result ), { status : 200 , headers : { 'Content-Type' : 'application/json' } });
}
2026-02-01 19:37:18 +02:00
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' } });
}
}
2026-08-12 01:49:22 +03:00
const quotaCredentialMatch = pathname . match ( /^\/api\/quota\/credentials\/(ollama-cloud|cursor)(?:\/(validate|import))?$/ );
2026-07-12 16:21:38 +03:00
if ( quotaCredentialMatch ) {
2026-07-12 14:48:50 +03:00
try {
const body = method === 'PUT' ? await extractJsonBody ( input , init , method ) : undefined ;
2026-07-12 16:21:38 +03:00
const bridgeMethod = quotaCredentialMatch [ 2 ] ? . toUpperCase () || method ;
const data = await sendBridgeMessage ( 'api:quota:credentials' , { providerId : quotaCredentialMatch [ 1 ], method : bridgeMethod , credential : body });
2026-07-12 14:48:50 +03:00
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' } });
}
}
2026-02-01 19:37:18 +02:00
const quotaMatch = pathname . match ( /^\/api\/quota\/([^/]+)$/ );
2026-06-02 00:43:05 +03:00
if ( quotaMatch && method === 'GET' ) {
2026-02-01 19:37:18 +02:00
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' } });
}
}
2025-12-27 02:22:59 +02:00
// Handle provider auth deletion: DELETE /api/provider/:providerId/auth
const providerAuthMatch = pathname . match ( /^\/api\/provider\/([^/]+)\/auth$/ );
2026-06-02 00:43:05 +03:00
if ( providerAuthMatch && method === 'DELETE' ) {
2025-12-27 02:22:59 +02:00
const providerId = decodeURIComponent ( providerAuthMatch [ 1 ]);
2026-01-22 13:45:30 +02:00
const scope = url . searchParams . get ( 'scope' ) || 'auth' ;
2026-03-31 18:47:00 +03:00
const queryDirectory = url . searchParams . get ( 'directory' ) || undefined ;
2025-12-27 02:22:59 +02:00
try {
2026-03-31 18:47:00 +03:00
const data = await sendBridgeMessage ( 'api:provider/auth:delete' , { providerId , scope , directory : queryDirectory });
2026-01-22 13:45:30 +02:00
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$/ );
2026-06-02 00:43:05 +03:00
if ( providerSourceMatch && method === 'GET' ) {
2026-01-22 13:45:30 +02:00
const providerId = decodeURIComponent ( providerSourceMatch [ 1 ]);
2026-03-31 18:47:00 +03:00
const queryDirectory = url . searchParams . get ( 'directory' ) || undefined ;
2026-01-22 13:45:30 +02:00
try {
2026-03-31 18:47:00 +03:00
const data = await sendBridgeMessage ( 'api:provider/source:get' , { providerId , directory : queryDirectory });
2025-12-27 02:22:59 +02:00
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' } });
2026-08-02 09:12:28 +00:00
}
}
// 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' } });
2025-12-27 02:22:59 +02:00
}
}
2025-12-13 16:34:17 +02:00
return null ;
};
const originalFetch = window . fetch . bind ( window );
2026-07-30 00:28:12 +03:00
let sseStreamCounter = 0 ;
2025-12-13 16:34:17 +02:00
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 || '' ;
2026-06-02 00:43:05 +03:00
const normalizedPathname = pathname . replace ( /\/{2,}/g , '/' );
2025-12-13 16:34:17 +02:00
if ( targetUrl && normalizedPathname === '/health' ) {
2025-12-24 03:31:07 +02:00
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 ,
}), {
2025-12-13 16:34:17 +02:00
status : 200 ,
headers : { 'Content-Type' : 'application/json' },
});
}
2026-06-02 00:43:05 +03:00
if ( targetUrl && isLocalRuntimePath ( normalizedPathname )) {
const localResponse = await handleLocalApiRequest ( input , targetUrl , init , method );
2025-12-13 16:34:17 +02:00
if ( localResponse ) {
2025-12-24 22:50:46 +02:00
maybeHideLoadingOverlay ();
2025-12-13 16:34:17 +02:00
return localResponse ;
}
2026-06-02 00:43:05 +03:00
if ( ! isApiPath ( normalizedPathname )) {
return originalFetch ( input as RequestInfo , init );
}
2025-12-24 22:50:46 +02:00
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 )) {
2026-07-30 00:28:12 +03:00
// 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 } ` ;
2025-12-24 22:50:46 +02:00
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' ) {
2026-03-31 18:47:00 +03:00
vscodeStreamPerfCount ( 'vscode.webview.sse_chunk' );
vscodeStreamPerfObserve ( 'vscode.webview.sse_chunk_bytes' , msg . chunk . length );
2025-12-24 22:50:46 +02:00
controller . enqueue ( encoder . encode ( msg . chunk ));
return ;
}
if ( msg . type === 'api:sse:end' ) {
2026-03-31 18:47:00 +03:00
vscodeStreamPerfCount ( 'vscode.webview.sse_end' );
2025-12-24 22:50:46 +02:00
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 (() => {});
},
});
2026-07-30 00:28:12 +03:00
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 || {} });
}
2025-12-24 22:50:46 +02:00
return new Response ( stream , { status : start.status || 200 , headers : start.headers || { 'content-type' : 'text/event-stream' } });
2025-12-13 16:34:17 +02:00
}
2026-02-11 19:28:22 +02:00
if ( method === 'POST' && isSessionMessageApiPath ( targetUrl . pathname )) {
const bodyText = await extractBodyText ( input , init , method );
2026-06-02 00:43:05 +03:00
const signal = ( input instanceof Request ? input.signal : init?.signal ) as AbortSignal | undefined ;
const proxied = await proxySessionMessageRequest ({ path : suffixPath , headers , bodyText , signal });
2026-02-17 18:01:57 +02:00
const response = buildProxiedResponse ( proxied );
2026-02-11 19:28:22 +02:00
maybeHideLoadingOverlay ();
return response ;
}
2025-12-24 22:50:46 +02:00
const bodyBase64 = await extractBodyBase64 ( input , init , method );
2026-06-02 00:43:05 +03:00
const signal = ( input instanceof Request ? input.signal : init?.signal ) as AbortSignal | undefined ;
const proxied = await proxyApiRequest ({ method , path : suffixPath , headers , bodyBase64 , signal });
2026-02-17 18:01:57 +02:00
const response = buildProxiedResponse ( proxied );
2025-12-24 22:50:46 +02:00
maybeHideLoadingOverlay ();
return response ;
2025-12-13 16:34:17 +02:00
}
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 );
};
2025-12-24 22:50:46 +02:00
2026-08-13 23:50:54 +03:00
onCommand ( 'focusChatInput' , () => {
focusChatInput ();
});
2026-05-19 15:53:54 +03:00
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 ;
}
2026-03-31 18:47:00 +03:00
import ( '@/sync/input-store' ). then (({ useInputStore }) => {
2026-05-19 15:53:54 +03:00
const file = new File ([ new Blob ([ text ], { type : 'text/plain' })], trimmedFilename , { type : 'text/plain' });
2026-08-13 23:50:54 +03:00
void useInputStore . getState (). addVSCodeSelectionAttachment ( trimmedPath , file ). finally (() => {
focusChatInput ();
});
2025-12-30 16:32:27 +01:00
});
});
2026-03-23 23:51:55 +02:00
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 ( ' ' );
2026-03-31 18:47:00 +03:00
import ( '@/sync/input-store' ). then (({ useInputStore }) => {
useInputStore . getState (). setPendingInputText ( mentionText , 'append-inline' );
2026-03-23 23:51:55 +02:00
});
});
2026-05-19 15:53:54 +03:00
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 );
}
});
});
2025-12-30 16:32:27 +01:00
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
onCommand ( 'createSessionWithPrompt' , ( payload ) => {
const { prompt } = payload as { prompt : string };
2026-03-31 18:47:00 +03:00
2025-12-30 16:32:27 +01:00
Promise . all ([
2026-03-31 18:47:00 +03:00
import ( '@/sync/session-ui-store' ),
2026-01-06 21:31:04 +02:00
import ( '@/stores/useConfigStore' ),
2026-03-31 18:47:00 +03:00
import ( '@/sync/input-store' ),
]). then (([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => {
const sessionStore = useSessionUIStore . getState ();
2025-12-30 16:32:27 +01:00
const configStore = useConfigStore . getState ();
2026-03-31 18:47:00 +03:00
2025-12-30 16:32:27 +01:00
// Get current provider/model/agent configuration
const { currentProviderId , currentModelId , currentAgentName } = configStore ;
2026-03-31 18:47:00 +03:00
2025-12-30 16:32:27 +01:00
if ( currentProviderId && currentModelId ) {
2026-05-19 15:53:54 +03:00
if ( ! sessionStore . currentSessionId ) {
sessionStore . openNewSessionDraft ();
}
2025-12-30 16:32:27 +01:00
// 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
2026-03-31 18:47:00 +03:00
useInputStore . getState (). setPendingInputText ( prompt );
2025-12-30 16:32:27 +01:00
}
});
});
2026-06-10 23:58:40 +03:00
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 );
});
2025-12-31 16:16:03 +01:00
// Listen for newSession command from extension title bar button
2026-06-10 23:58:40 +03:00
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
);
2025-12-31 16:16:03 +01:00
});
2026-06-10 23:58:40 +03:00
2025-12-31 16:16:03 +01:00
// 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' } }));
});
2026-05-30 01:03:08 +03:00
// 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 }) => {
2026-06-08 13:43:45 +03:00
void reloadOpenCodeConfiguration (). catch (() => undefined );
2026-05-30 01:03:08 +03:00
});
});
2026-05-19 16:41:16 +03:00
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 ) => {
2026-05-19 02:06:32 +03:00
if ( typeof Notification === 'undefined' ) {
return false ;
}
2026-05-19 16:41:16 +03:00
const show = async () => {
2026-05-19 02:06:32 +03:00
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 ()
: '' ;
2026-05-19 16:41:16 +03:00
if ( ! await claimOpenChamberNotification ({ ... payload , title , body , sessionId })) {
return false ;
}
2026-05-19 02:06:32 +03:00
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' ) {
2026-05-19 16:41:16 +03:00
void show ();
2026-05-19 02:06:32 +03:00
}
});
return true ;
}
2026-05-19 16:41:16 +03:00
void show ();
return true ;
2026-05-19 02:06:32 +03:00
};
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 >();
2026-07-12 14:47:57 +03:00
const errorNotificationCooldowns = new Map < string , number >();
2026-05-19 02:06:32 +03:00
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 ) => {
2026-07-12 14:47:57 +03:00
notificationSettingsSyncPromise = null ;
2026-05-19 02:06:32 +03:00
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 };
2026-06-16 15:21:27 +03:00
if ( entry . type === 'text' ) {
2026-05-19 02:06:32 +03:00
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 {
2026-06-02 00:43:05 +03:00
const messages = await opencodeClient . getSessionMessages ( sessionId , 5 );
2026-05-19 02:06:32 +03:00
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 }> },
2026-07-12 14:47:57 +03:00
key : 'completion' | 'subtask' | 'error' | 'question' ,
2026-05-19 02:06:32 +03:00
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 );
};
2026-07-12 14:47:57 +03:00
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 ;
};
2026-05-19 02:06:32 +03:00
window . addEventListener ( 'openchamber:vscode-notification-event' , ( event ) => {
2026-07-17 12:59:41 +03:00
const detail = ( event as CustomEvent < { directory? : string ; payload? : unknown } > ). detail ;
2026-05-19 02:06:32 +03:00
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' ),
2026-07-17 12:59:41 +03:00
]). then ( async ([{ useUIStore }]) => {
2026-05-19 02:06:32 +03:00
await ensureNotificationSettingsSynced ();
2026-07-12 14:47:57 +03:00
const settings = useUIStore . getState ();
2026-05-19 02:06:32 +03:00
if ( ! settings . nativeNotificationsEnabled ) {
return ;
}
const requireHidden = settings . notificationMode !== 'always' ;
const messageId = getPayloadString ( info ? . id );
2026-07-12 14:47:57 +03:00
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 );
2026-05-19 02:06:32 +03:00
const lastMessage = prepareNotificationLastMessage (
rawLastMessage ,
settings ,
);
const variables = buildNotificationVariables ( record , sessionId , lastMessage );
2026-07-12 14:47:57 +03:00
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 ;
}
2026-05-19 02:06:32 +03:00
2026-07-12 14:47:57 +03:00
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 ;
2026-05-19 02:06:32 +03:00
}
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 ;
2026-07-17 12:59:41 +03:00
const requestId = getPayloadString ( properties . id );
if ( requestId ) {
const accepted = await processVSCodePermissionAutoAccept (
properties as unknown as PermissionRequest ,
detail ? . directory ,
);
if ( accepted ) return ;
}
2026-05-19 02:06:32 +03:00
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 ,
});
}
});
});
2026-03-12 23:45:45 +02:00
// Listen for settings sync command from extension (broadcast to all VS Code webviews)
onCommand ( 'settingsSynced' , () => {
import ( '@openchamber/ui/lib/persistence' ). then (({ syncDesktopSettings }) => {
void syncDesktopSettings ();
});
});
2026-07-17 12:59:41 +03:00
onCommand ( 'permissionAutoAcceptSynced' , ( payload ) => {
if ( ! payload || typeof payload !== 'object' ) return ;
2026-07-21 20:52:20 +03:00
const snapshot = payload as { sessions? : unknown ; revision? : unknown };
const sessions = snapshot . sessions ;
2026-07-17 12:59:41 +03:00
if ( ! sessions || typeof sessions !== 'object' ) return ;
2026-07-21 20:52:20 +03:00
usePermissionStore . getState (). applySnapshot ({
sessions : sessions as Record < string , boolean >,
revision : typeof snapshot . revision === 'number' ? snapshot.revision : undefined ,
});
2026-07-17 12:59:41 +03:00
});
2026-05-05 00:24:03 +02:00
// 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 );
});
});
2026-05-07 01:31:17 +03:00
import ( '@openchamber/ui/apps/renderVSCodeApp' )
. then ( async ({ renderVSCodeApp }) => {
renderVSCodeApp ( window . __OPENCHAMBER_RUNTIME_APIS__ ?? createVSCodeAPIs ());
2025-12-24 22:50:46 +02:00
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 ();
});