feat(vscode): refactor OpenCode configuration management and API proxying

This commit is contained in:
Bohdan Triapitsyn
2025-12-24 22:50:46 +02:00
parent 2d45abdc32
commit 359cfd45b1
10 changed files with 1457 additions and 148 deletions
+2 -1
View File
@@ -34,12 +34,13 @@ OpenChamber inside VS Code: embeds the OpenChamber chat UI in the activity bar a
|---------|-------------|
| `OpenChamber: Focus on Chat View` | Focus chat panel |
| `OpenChamber: Restart API Connection` | Restart OpenCode API process |
| `OpenChamber: Show OpenCode Status` | Provide debug info useful for development or bug report |
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL |
| `openchamber.apiUrl` | `http://localhost:47339` | OpenCode API server URL. Not required by default. Spawns its own process when not set. |
## Requirements
+4
View File
@@ -56,6 +56,10 @@
{
"command": "openchamber.restartApi",
"title": "OpenChamber: Restart API Connection"
},
{
"command": "openchamber.showOpenCodeStatus",
"title": "OpenChamber: Show OpenCode Status"
}
],
"configuration": {
+171 -17
View File
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { handleBridgeMessage, type BridgeRequest } from './bridge';
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
import { getThemeKindName } from './theme';
import type { OpenCodeManager, ConnectionStatus } from './opencode';
import { getWebviewShikiThemes } from './shikiThemes';
@@ -12,7 +12,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
// Cache latest status/URL for when webview is resolved after connection is ready
private _cachedStatus: ConnectionStatus = 'connecting';
private _cachedError?: string;
private _cachedApiUrl?: string;
private _sseCounter = 0;
private _sseStreams = new Map<string, AbortController>();
constructor(
private readonly _context: vscode.ExtensionContext,
@@ -44,6 +45,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
await this._openCodeManager?.restart();
return;
}
if (message.type === 'api:sse:start') {
const response = await this._startSseProxy(message);
webviewView.webview.postMessage(response);
return;
}
if (message.type === 'api:sse:stop') {
const response = await this._stopSseProxy(message);
webviewView.webview.postMessage(response);
return;
}
const response = await handleBridgeMessage(message, {
manager: this._openCodeManager,
context: this._context,
@@ -68,7 +82,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
// Cache the latest state
this._cachedStatus = status;
this._cachedError = error;
this._cachedApiUrl = this._openCodeManager?.getApiUrl() || undefined;
// Send to webview if it exists
this._sendCachedState();
@@ -84,14 +97,159 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
status: this._cachedStatus,
error: this._cachedError,
});
// Send API URL update if we have one
if (this._cachedApiUrl) {
this._view.webview.postMessage({
type: 'apiUrlUpdate',
url: this._cachedApiUrl,
});
}
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
return {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...(extra || {}),
};
}
private _collectHeaders(headers: Headers): Record<string, string> {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const apiBaseUrl = this._openCodeManager?.getApiUrl();
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
if (!apiBaseUrl) {
return {
id,
type,
success: true,
data: { status: 503, headers: { 'content-type': 'application/json' }, streamId: null },
};
}
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
const controller = new AbortController();
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
let response: Response;
try {
response = await fetch(targetUrl, {
method: 'GET',
headers: this._buildSseHeaders(headers || {}),
signal: controller.signal,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
id,
type,
success: true,
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
};
}
const responseHeaders = this._collectHeaders(response.headers);
const responseBody = response.body;
if (!response.ok || !responseBody) {
return {
id,
type,
success: true,
data: {
status: response.status,
headers: responseHeaders,
streamId: null,
error: `SSE failed: ${response.status}`,
},
};
}
this._sseStreams.set(streamId, controller);
(async () => {
try {
const reader = responseBody.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (controller.signal.aborted) break;
if (value && value.length > 0) {
const chunk = decoder.decode(value, { stream: true });
if (!chunk) continue;
// Reduce webview message pressure by forwarding complete SSE blocks.
// The SDK SSE parser is block-based (\n\n delimited) and can consume
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
sseBuffer += chunk;
const blocks = sseBuffer.split('\n\n');
sseBuffer = blocks.pop() ?? '';
if (blocks.length > 0) {
const joined = blocks.map((block) => `${block}\n\n`).join('');
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
}
}
}
const tail = decoder.decode();
if (tail) {
sseBuffer += tail;
}
if (sseBuffer) {
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
}
} finally {
try {
reader.releaseLock();
} catch {
// ignore
}
}
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
} catch (error) {
if (!controller.signal.aborted) {
const message = error instanceof Error ? error.message : String(error);
this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
}
} finally {
this._sseStreams.delete(streamId);
}
})();
return {
id,
type,
success: true,
data: {
status: response.status,
headers: responseHeaders,
streamId,
},
};
}
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
const { id, type, payload } = message;
const { streamId } = (payload || {}) as { streamId?: string };
if (typeof streamId === 'string' && streamId.length > 0) {
const controller = this._sseStreams.get(streamId);
if (controller) {
controller.abort();
this._sseStreams.delete(streamId);
}
}
return { id, type, success: true, data: { stopped: true } };
}
private _getHtmlForWebview(webview: vscode.Webview) {
@@ -102,7 +260,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
// Use cached values which are updated by onStatusChange callback
const initialStatus = this._cachedStatus;
const apiUrl = this._cachedApiUrl || '';
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
// Use VS Code CSS variables for proper theme integration
@@ -211,7 +368,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
window.__VSCODE_CONFIG__ = {
apiUrl: "${apiUrl}",
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
theme: "${themeKind}",
connectionStatus: "${initialStatus}",
@@ -224,21 +380,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
var msg = event.data;
if (msg && msg.type === 'connectionStatus') {
var statusEl = document.getElementById('loading-status');
var loadingEl = document.getElementById('initial-loading');
if (statusEl) {
if (msg.status === 'connecting') {
statusEl.textContent = 'Starting OpenCode API…';
statusEl.classList.remove('error-text');
} else if (msg.status === 'connected') {
statusEl.textContent = 'Connected!';
// Fade out loading screen once connected and UI is ready
setTimeout(function() {
if (loadingEl) loadingEl.classList.add('fade-out');
}, 300);
statusEl.classList.remove('error-text');
} else if (msg.status === 'error') {
statusEl.textContent = msg.error || 'Connection error';
statusEl.classList.add('error-text');
} else {
statusEl.textContent = 'Reconnecting…';
statusEl.classList.remove('error-text');
}
}
}
+228
View File
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import type { OpenCodeManager } from './opencode';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand } from './opencodeConfig';
export interface BridgeRequest {
id: string;
@@ -17,6 +18,19 @@ export interface BridgeResponse {
error?: string;
}
type ApiProxyRequestPayload = {
method?: string;
path?: string;
headers?: Record<string, string>;
bodyBase64?: string;
};
type ApiProxyResponsePayload = {
status: number;
headers: Record<string, string>;
bodyBase64: string;
};
interface FileEntry {
name: string;
path: string;
@@ -34,6 +48,7 @@ export interface BridgeContext {
}
const SETTINGS_KEY = 'openchamber.settings';
const CLIENT_RELOAD_DELAY_MS = 800;
const readSettings = (ctx?: BridgeContext) => {
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
@@ -230,11 +245,86 @@ const fetchModelsMetadata = async () => {
}
};
const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
const collectHeaders = (headers: Headers): Record<string, string> => {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
};
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
const { id, type, payload } = message;
try {
switch (type) {
case 'api:proxy': {
const apiUrl = ctx?.manager?.getApiUrl();
if (!apiUrl) {
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
const data: ApiProxyResponsePayload = {
status: 503,
headers: { 'content-type': 'application/json' },
bodyBase64: base64EncodeUtf8(body),
};
return { id, type, success: true, data };
}
const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
const normalizedPath =
typeof requestPath === 'string' && requestPath.trim().length > 0
? requestPath.trim().startsWith('/')
? requestPath.trim()
: `/${requestPath.trim()}`
: '/';
const base = `${apiUrl.replace(/\/+$/, '')}/`;
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
const requestHeaders: Record<string, string> = { ...(headers || {}) };
// Ensure SSE requests are negotiated correctly.
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
if (!requestHeaders.Accept) {
requestHeaders.Accept = 'text/event-stream';
}
requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache';
requestHeaders.Connection = requestHeaders.Connection || 'keep-alive';
}
try {
const response = await fetch(targetUrl, {
method: normalizedMethod,
headers: requestHeaders,
body:
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
? Buffer.from(bodyBase64, 'base64')
: undefined,
});
const arrayBuffer = await response.arrayBuffer();
const data: ApiProxyResponsePayload = {
status: response.status,
headers: collectHeaders(response.headers),
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
};
return { id, type, success: true, data };
} catch (error) {
const body = JSON.stringify({
error: error instanceof Error ? error.message : 'Failed to reach OpenCode API',
});
const data: ApiProxyResponsePayload = {
status: 502,
headers: { 'content-type': 'application/json' },
bodyBase64: base64EncodeUtf8(body),
};
return { id, type, success: true, data };
}
}
case 'files:list': {
const { path: dirPath } = payload as { path: string };
const uri = vscode.Uri.file(dirPath);
@@ -382,6 +472,144 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: true, data: { restarted: true } };
}
case 'api:config/agents': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
const agentName = typeof name === 'string' ? name.trim() : '';
if (!agentName) {
return { id, type, success: false, error: 'Agent name is required' };
}
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const sources = getAgentSources(agentName);
return {
id,
type,
success: true,
data: { name: agentName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists },
};
}
if (normalizedMethod === 'POST') {
createAgent(agentName, (body || {}) as Record<string, unknown>);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Agent ${agentName} created successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
},
};
}
if (normalizedMethod === 'PATCH') {
updateAgent(agentName, (body || {}) as Record<string, unknown>);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Agent ${agentName} updated successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteAgent(agentName);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/commands': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
const commandName = typeof name === 'string' ? name.trim() : '';
if (!commandName) {
return { id, type, success: false, error: 'Command name is required' };
}
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const sources = getCommandSources(commandName);
return {
id,
type,
success: true,
data: { name: commandName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists },
};
}
if (normalizedMethod === 'POST') {
createCommand(commandName, (body || {}) as Record<string, unknown>);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Command ${commandName} created successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
},
};
}
if (normalizedMethod === 'PATCH') {
updateCommand(commandName, (body || {}) as Record<string, unknown>);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Command ${commandName} updated successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteCommand(commandName);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Command ${commandName} deleted successfully. Reloading interface…`,
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:opencode/directory': {
const target = (payload as { path?: string })?.path;
if (!target) {
+172 -3
View File
@@ -4,8 +4,28 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode';
let chatViewProvider: ChatViewProvider | undefined;
let openCodeManager: OpenCodeManager | undefined;
let outputChannel: vscode.OutputChannel | undefined;
const SETTINGS_KEY = 'openchamber.settings';
const formatIso = (value: number | null | undefined) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)';
try {
return new Date(value).toISOString();
} catch {
return String(value);
}
};
const formatDurationMs = (value: number | null | undefined) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)';
const seconds = Math.round(value / 100) / 10;
return `${seconds}s`;
};
export async function activate(context: vscode.ExtensionContext) {
outputChannel = vscode.window.createOutputChannel('OpenChamber');
// Create OpenCode manager first
openCodeManager = createOpenCodeManager(context);
@@ -32,6 +52,153 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.showOpenCodeStatus', async () => {
const config = vscode.workspace.getConfiguration('openchamber');
const configuredApiUrl = (config.get<string>('apiUrl') || '').trim();
const extensionVersion = String(context.extension?.packageJSON?.version || '');
const workspaceFolders = (vscode.workspace.workspaceFolders || []).map((folder) => folder.uri.fsPath);
const debug = openCodeManager?.getDebugInfo();
const resolvedApiUrl = openCodeManager?.getApiUrl();
const workingDirectory = openCodeManager?.getWorkingDirectory() ?? '';
const safeFetch = async (input: string, timeoutMs = 2500) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const resp = await fetch(input, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
const elapsedMs = Date.now() - startedAt;
const contentType = resp.headers.get('content-type') || '';
let summary = '';
if (contentType.includes('application/json')) {
const json = await resp.json().catch(() => null);
if (Array.isArray(json)) {
summary = `json[array] len=${json.length}`;
} else if (json && typeof json === 'object') {
const keys = Object.keys(json).slice(0, 8);
summary = `json[object] keys=${keys.join(',')}${Object.keys(json).length > keys.length ? ',…' : ''}`;
} else {
summary = `json[${typeof json}]`;
}
} else {
summary = contentType ? `content-type=${contentType}` : 'no content-type';
}
return { ok: resp.ok, status: resp.status, elapsedMs, summary };
} catch (error) {
const elapsedMs = Date.now() - startedAt;
const isAbort =
controller.signal.aborted ||
(error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted')));
const message = isAbort
? `timeout after ${timeoutMs}ms`
: error instanceof Error
? error.message
: String(error);
return { ok: false, status: 0, elapsedMs, summary: `error=${message}` };
} finally {
clearTimeout(timeout);
}
};
const buildProbeUrl = (pathname: string, includeDirectory = true) => {
if (!resolvedApiUrl) return null;
const base = `${resolvedApiUrl.replace(/\/+$/, '')}/`;
const url = new URL(pathname.replace(/^\/+/, ''), base);
if (includeDirectory && workingDirectory) {
url.searchParams.set('directory', workingDirectory);
}
return url.toString();
};
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
{ label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true },
// Can be slower on large configs; keep the probe from producing false negatives.
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 8000 },
{ label: 'commands', path: '/command', includeDirectory: true },
{ label: 'project', path: '/project/current', includeDirectory: true },
{ label: 'path', path: '/path', includeDirectory: true },
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
];
const probes = resolvedApiUrl
? await Promise.all(
probeTargets.map(async (entry) => {
const url = buildProbeUrl(entry.path, entry.includeDirectory !== false);
if (!url) {
return { label: entry.label, url: '(none)', result: null as null };
}
const result = await safeFetch(url, typeof entry.timeoutMs === 'number' ? entry.timeoutMs : undefined);
return { label: entry.label, url, result };
})
)
: [];
const storedSettings = context.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
const settingsKeys = Object.keys(storedSettings).filter((key) => key !== 'lastDirectory');
const lines = [
`Time: ${new Date().toISOString()}`,
`OpenChamber version: ${extensionVersion || '(unknown)'}`,
`VS Code version: ${vscode.version}`,
`Platform: ${process.platform} ${process.arch}`,
`Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`,
`Status: ${openCodeManager?.getStatus() ?? 'unknown'}`,
`CLI available: ${openCodeManager?.isCliAvailable() ?? false}`,
`Working directory: ${openCodeManager?.getWorkingDirectory() ?? ''}`,
`API URL (configured): ${configuredApiUrl || '(none)'}`,
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
debug
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
: `OpenCode mode: (unknown)`,
debug
? `OpenCode CLI path: ${debug.cliPath || '(not found)'}`
: `OpenCode CLI path: (unknown)`,
debug
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
: `OpenCode detected port: (unknown)`,
debug
? `OpenCode API prefix: ${debug.apiPrefixDetected ? (debug.apiPrefix || '(root)') : '(unknown)'}`
: `OpenCode API prefix: (unknown)`,
debug
? `Last start: ${formatIso(debug.lastStartAt)}`
: `Last start: (unknown)`,
debug
? `Last connected: ${formatIso(debug.lastConnectedAt)}`
: `Last connected: (unknown)`,
debug && debug.lastConnectedAt ? `Connected for: ${formatDurationMs(Date.now() - debug.lastConnectedAt)}` : `Connected for: (n/a)`,
debug && debug.lastExitCode !== null ? `Last exit code: ${debug.lastExitCode}` : `Last exit code: (none)`,
debug?.lastError ? `Last error: ${debug.lastError}` : `Last error: (none)`,
`Settings keys (stored): ${settingsKeys.length ? settingsKeys.join(', ') : '(none)'}`,
probes.length ? '' : '',
...(probes.length
? [
'OpenCode API probes:',
...probes.map((probe) => {
if (!probe.result) return `- ${probe.label}: (no url)`;
const { ok, status, elapsedMs, summary } = probe.result;
const suffix = ok ? '' : ` url=${probe.url}`;
return `- ${probe.label}: ${ok ? 'ok' : 'fail'} status=${status} time=${elapsedMs}ms ${summary}${suffix}`;
}),
]
: []),
'',
];
outputChannel?.appendLine(lines.join('\n'));
outputChannel?.show(true);
})
);
context.subscriptions.push(
vscode.window.onDidChangeActiveColorTheme((theme) => {
chatViewProvider?.updateTheme(theme.kind);
@@ -60,13 +227,15 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
// Start OpenCode API and wait for it to be ready
// The webview will show loading state during this time
await openCodeManager.start();
// Start OpenCode API without blocking activation.
// Blocking here delays webview resolution and causes a blank panel until startup completes.
void openCodeManager.start();
}
export async function deactivate() {
await openCodeManager?.stop();
openCodeManager = undefined;
chatViewProvider = undefined;
outputChannel?.dispose();
outputChannel = undefined;
}
+196 -29
View File
@@ -12,9 +12,11 @@ const HEALTH_CHECK_INTERVAL_MS = 5000;
const SHUTDOWN_TIMEOUT_MS = 3000;
// Regex to detect port from CLI output (matches desktop pattern)
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(?:\/[^\s"']*)?/gi;
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
const FALLBACK_PORT_REGEX = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i;
const API_PREFIX_CANDIDATES = ['', '/api'] as const;
const BIN_CANDIDATES = [
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
@@ -29,6 +31,25 @@ const BIN_CANDIDATES = [
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
export type OpenCodeDebugInfo = {
mode: 'managed' | 'external';
status: ConnectionStatus;
lastError?: string;
workingDirectory: string;
cliAvailable: boolean;
cliPath: string | null;
configuredApiUrl: string | null;
configuredPort: number | null;
detectedPort: number | null;
apiPrefix: string;
apiPrefixDetected: boolean;
startCount: number;
restartCount: number;
lastStartAt: number | null;
lastConnectedAt: number | null;
lastExitCode: number | null;
};
export interface OpenCodeManager {
start(workdir?: string): Promise<void>;
stop(): Promise<void>;
@@ -38,6 +59,7 @@ export interface OpenCodeManager {
getApiUrl(): string | null;
getWorkingDirectory(): string;
isCliAvailable(): boolean;
getDebugInfo(): OpenCodeDebugInfo;
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
}
@@ -147,36 +169,30 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
const timeoutMs = quick ? 1500 : 3000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
// For quick checks during startup, just check /health
if (quick) {
try {
const response = await fetch(`${apiUrl}/health`, { signal: controller.signal });
clearTimeout(timeout);
return response.ok;
} catch {
clearTimeout(timeout);
return false;
}
const normalized = apiUrl.replace(/\/+$/, '');
const candidates: string[] = [`${normalized}/config`];
// Some deployments expose a /health endpoint (not guaranteed for OpenCode).
if (!quick) {
const healthUrl = normalized.endsWith('/api') ? `${normalized.slice(0, -4)}/health` : `${normalized}/health`;
candidates.push(healthUrl);
}
// Full health check: verify multiple endpoints
const candidates = [`${apiUrl}/health`, `${apiUrl}/config`];
let successCount = 0;
for (const target of candidates) {
try {
const response = await fetch(target, { signal: controller.signal });
const response = await fetch(target, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
if (response.ok) {
successCount++;
if (successCount >= 2) {
clearTimeout(timeout);
return true;
}
clearTimeout(timeout);
return true;
}
} catch {
// try next
}
}
clearTimeout(timeout);
} catch {
// ignore
@@ -192,10 +208,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
let lastError: string | undefined;
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
let startCount = 0;
let restartCount = 0;
let lastStartAt: number | null = null;
let lastConnectedAt: number | null = null;
let lastExitCode: number | null = null;
// Port detection state (like desktop)
let detectedPort: number | null = null;
let portWaiters: Array<(port: number) => void> = [];
// OpenCode API prefix detection (some versions serve under /api)
let apiPrefix: string = '';
let apiPrefixDetected = false;
// Check if user configured a specific API URL
const config = vscode.workspace.getConfiguration('openchamber');
@@ -218,10 +243,110 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
const cliPath = resolveCliPath();
const cliAvailable = cliPath !== null;
const normalizeApiPrefix = (prefix: string): string => {
const trimmed = (prefix || '').trim();
if (!trimmed || trimmed === '/') return '';
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
};
const inferPrefixFromLogPath = (candidatePath: string | null | undefined): string | null => {
if (!candidatePath) return null;
const normalized = normalizeApiPrefix(candidatePath);
if (normalized === '/api' || normalized.startsWith('/api/')) {
return '/api';
}
return null;
};
const setDetectedApiPrefix = (prefix: string) => {
const normalized = normalizeApiPrefix(prefix);
if (!apiPrefixDetected || apiPrefix !== normalized) {
apiPrefix = normalized;
apiPrefixDetected = true;
console.log(`[OpenCode] Detected API prefix: ${apiPrefix || '(root)'}`);
}
};
const buildApiBaseUrlFromPort = (port: number, prefixOverride?: string): string => {
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : apiPrefixDetected ? apiPrefix : '');
return `http://localhost:${port}${prefix}`;
};
const detectApiPrefixFromOutput = (text: string) => {
if (!text) return;
URL_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = URL_REGEX.exec(text)) !== null) {
const port = parseInt(match[1], 10);
if (!Number.isFinite(port) || port <= 0) continue;
if (detectedPort !== null && port !== detectedPort) continue;
const inferred = inferPrefixFromLogPath(match[2] || '');
if (inferred !== null) {
setDetectedApiPrefix(inferred);
return;
}
}
};
const extractPrefixFromOpenApiDoc = (content: string): string | null => {
const match = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
if (!match?.[1]) return null;
try {
const parsed = new URL(match[1], 'http://localhost');
return normalizeApiPrefix(parsed.pathname || '');
} catch {
return normalizeApiPrefix(match[1]);
}
};
const detectApiPrefix = async (port: number): Promise<string> => {
if (apiPrefixDetected) return apiPrefix;
const origin = `http://localhost:${port}`;
// Try /doc for explicit base hints first (best signal when available).
for (const candidate of API_PREFIX_CANDIDATES) {
const prefix = normalizeApiPrefix(candidate);
try {
const response = await fetch(`${origin}${prefix}/doc`, { method: 'GET', headers: { Accept: '*/*' } });
if (!response.ok) continue;
const text = await response.text();
const extracted = extractPrefixFromOpenApiDoc(text);
if (extracted !== null) {
setDetectedApiPrefix(extracted);
return apiPrefix;
}
} catch {
// ignore
}
}
// Fallback: probe a stable endpoint under root vs /api.
for (const candidate of API_PREFIX_CANDIDATES) {
try {
const base = buildApiBaseUrlFromPort(port, candidate);
const response = await fetch(`${base}/config`, { method: 'GET', headers: { Accept: 'application/json' } });
if (!response.ok) continue;
await response.json().catch(() => null);
setDetectedApiPrefix(candidate);
return apiPrefix;
} catch {
// ignore
}
}
return apiPrefix;
};
function setStatus(newStatus: ConnectionStatus, error?: string) {
if (status !== newStatus || lastError !== error) {
status = newStatus;
lastError = error;
if (newStatus === 'connected') {
lastConnectedAt = Date.now();
}
listeners.forEach(cb => cb(status, error));
}
}
@@ -252,6 +377,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
const port = parseInt(match[1], 10);
if (Number.isFinite(port) && port > 0) {
setDetectedPort(port);
const inferred = inferPrefixFromLogPath(match[2] || '');
if (inferred !== null) {
setDetectedApiPrefix(inferred);
}
return;
}
}
@@ -305,7 +434,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
return configuredApiUrl.replace(/\/+$/, '');
}
if (detectedPort !== null) {
return `http://localhost:${detectedPort}`;
return buildApiBaseUrlFromPort(detectedPort);
}
return null;
}
@@ -338,11 +467,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
}
async function start(workdir?: string): Promise<void> {
startCount += 1;
lastStartAt = Date.now();
if (typeof workdir === 'string' && workdir.trim().length > 0) {
workingDirectory = workdir.trim();
}
// If user configured an external API URL, just check if it's healthy
// If user configured an external API URL, do NOT start a local CLI instance.
if (useConfiguredUrl && configuredApiUrl) {
setStatus('connecting');
const healthy = await checkHealth(configuredApiUrl);
@@ -351,12 +483,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
startHealthCheck();
return;
}
// If configured URL isn't responding and no CLI, show error
if (!cliAvailable) {
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding and CLI is not available.`);
return;
}
// Fall through to start CLI with configured port if possible
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding.`);
return;
}
// Check for existing running instance (only if port is known)
@@ -384,6 +512,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
// Reset port detection for fresh start
detectedPort = null;
apiPrefix = '';
apiPrefixDetected = false;
lastExitCode = null;
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
@@ -407,12 +538,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
const text = data.toString();
console.log('[OpenCode]', text.trim());
detectPortFromOutput(text);
detectApiPrefixFromOutput(text);
});
childProcess.stderr?.on('data', (data) => {
const text = data.toString();
console.error('[OpenCode]', text.trim());
detectPortFromOutput(text);
detectApiPrefixFromOutput(text);
});
childProcess.on('error', (err) => {
@@ -426,6 +559,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
}
childProcess = null;
detectedPort = null;
lastExitCode = typeof code === 'number' ? code : null;
});
// Wait for port detection (port comes from stdout/stderr)
@@ -438,6 +572,11 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
}
// Now wait for API to be ready
const detected = detectedPort;
if (detected !== null && !apiPrefixDetected) {
await detectApiPrefix(detected);
}
const apiUrl = getApiUrl();
if (!apiUrl) {
setStatus('error', 'Failed to determine OpenCode API URL');
@@ -480,6 +619,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
}
async function restart(): Promise<void> {
restartCount += 1;
await stop();
// Brief delay to let OS release resources
await new Promise(r => setTimeout(r, 250));
@@ -488,7 +628,16 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
if (target === workingDirectory) {
return { success: true, restarted: false, path: target };
}
workingDirectory = target;
// When pointing at an external API URL, avoid restarting a local CLI process.
if (useConfiguredUrl && configuredApiUrl) {
return { success: true, restarted: false, path: target };
}
await restart();
return { success: true, restarted: true, path: target };
}
@@ -502,6 +651,24 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
getApiUrl,
getWorkingDirectory: () => workingDirectory,
isCliAvailable: () => cliAvailable,
getDebugInfo: () => ({
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
status,
lastError,
workingDirectory,
cliAvailable,
cliPath,
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
configuredPort,
detectedPort,
apiPrefix,
apiPrefixDetected,
startCount,
restartCount,
lastStartAt,
lastConnectedAt,
lastExitCode,
}),
onStatusChange(callback) {
listeners.add(callback);
// Immediately call with current status
+338
View File
@@ -0,0 +1,338 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import yaml from 'yaml';
import stripJsonComments from 'strip-json-comments';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agent');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'command');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
export type ConfigSources = {
md: { exists: boolean; path: string | null; fields: string[] };
json: { exists: boolean; path: string; fields: string[] };
};
const ensureDirs = () => {
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
if (!fs.existsSync(AGENT_DIR)) fs.mkdirSync(AGENT_DIR, { recursive: true });
if (!fs.existsSync(COMMAND_DIR)) fs.mkdirSync(COMMAND_DIR, { recursive: true });
};
const isPromptFileReference = (value: unknown): value is string => {
return typeof value === 'string' && PROMPT_FILE_PATTERN.test(value.trim());
};
const resolvePromptFilePath = (reference: string): string | null => {
const match = reference.trim().match(PROMPT_FILE_PATTERN);
if (!match?.[1]) return null;
let target = match[1].trim();
if (!target) return null;
if (target.startsWith('./')) {
target = path.join(OPENCODE_CONFIG_DIR, target.slice(2));
} else if (!path.isAbsolute(target)) {
target = path.join(OPENCODE_CONFIG_DIR, target);
}
return target;
};
const writePromptFile = (filePath: string, content: string) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
};
const readConfig = (): Record<string, unknown> => {
if (!fs.existsSync(CONFIG_FILE)) return {};
const content = fs.readFileSync(CONFIG_FILE, 'utf8');
const normalized = stripJsonComments(content).trim();
if (!normalized) return {};
return JSON.parse(normalized) as Record<string, unknown>;
};
const writeConfig = (config: Record<string, unknown>) => {
if (fs.existsSync(CONFIG_FILE)) {
const backupFile = `${CONFIG_FILE}.openchamber.backup`;
try {
fs.copyFileSync(CONFIG_FILE, backupFile);
} catch {
// ignore backup failures
}
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
};
const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>; body: string } => {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content.trim() };
return { frontmatter: (yaml.parse(match[1]) || {}) as Record<string, unknown>, body: (match[2] || '').trim() };
};
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
const yamlStr = yaml.stringify(frontmatter ?? {});
const content = `---\n${yamlStr}---\n\n${body ?? ''}`.trimEnd();
fs.writeFileSync(filePath, content, 'utf8');
};
export const getAgentSources = (agentName: string): ConfigSources => {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
const config = readConfig();
const agentSection = (config.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
const sources: ConfigSources = {
md: { exists: mdExists, path: mdExists ? mdPath : null, fields: [] },
json: { exists: Boolean(agentSection), path: CONFIG_FILE, fields: [] },
};
if (mdExists) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) sources.md.fields.push('prompt');
}
if (agentSection) {
sources.json.fields = Object.keys(agentSection);
}
return sources;
};
export const createAgent = (agentName: string, config: Record<string, unknown>) => {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(mdPath)) throw new Error(`Agent ${agentName} already exists as .md file`);
const existingConfig = readConfig();
const agentMap = existingConfig.agent as Record<string, unknown> | undefined;
if (agentMap?.[agentName]) throw new Error(`Agent ${agentName} already exists in opencode.json`);
const { prompt, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown };
writeMdFile(mdPath, frontmatter, typeof prompt === 'string' ? prompt : '');
};
export const updateAgent = (agentName: string, updates: Record<string, unknown>) => {
ensureDirs();
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
const mdExists = fs.existsSync(mdPath);
const mdData = mdExists ? parseMdFile(mdPath) : null;
const config = readConfig();
const agentMap = (config.agent as Record<string, unknown> | undefined) ?? {};
const jsonSection = agentMap[agentName] as Record<string, unknown> | undefined;
let mdModified = false;
let jsonModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'prompt') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
if (mdExists && mdData) {
mdData.body = normalizedValue;
mdModified = true;
continue;
}
if (isPromptFileReference(jsonSection?.prompt)) {
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
if (!promptFilePath) throw new Error(`Invalid prompt file reference for agent ${agentName}`);
writePromptFile(promptFilePath, normalizedValue);
continue;
}
if (!config.agent) config.agent = {};
const target = (config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined;
(config.agent as Record<string, unknown>)[agentName] = { ...(target || {}), prompt: normalizedValue };
jsonModified = true;
continue;
}
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
if (hasMdField && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
continue;
}
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
jsonModified = true;
if (hasJsonField) {
continue;
}
}
if (mdModified && mdData) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
}
};
export const deleteAgent = (agentName: string) => {
const mdPath = path.join(AGENT_DIR, `${agentName}.md`);
let deleted = false;
if (fs.existsSync(mdPath)) {
fs.unlinkSync(mdPath);
deleted = true;
}
const config = readConfig();
const agentMap = (config.agent as Record<string, unknown> | undefined) ?? {};
if (agentMap[agentName] !== undefined) {
delete agentMap[agentName];
config.agent = agentMap;
writeConfig(config);
deleted = true;
}
if (!deleted) {
config.agent = agentMap;
agentMap[agentName] = { disable: true };
writeConfig(config);
}
};
export const getCommandSources = (commandName: string): ConfigSources => {
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
const mdExists = fs.existsSync(mdPath);
const config = readConfig();
const commandSection = (config.command as Record<string, unknown> | undefined)?.[commandName] as Record<string, unknown> | undefined;
const sources: ConfigSources = {
md: { exists: mdExists, path: mdExists ? mdPath : null, fields: [] },
json: { exists: Boolean(commandSection), path: CONFIG_FILE, fields: [] },
};
if (mdExists) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) sources.md.fields.push('template');
}
if (commandSection) {
sources.json.fields = Object.keys(commandSection);
}
return sources;
};
export const createCommand = (commandName: string, config: Record<string, unknown>) => {
ensureDirs();
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
if (fs.existsSync(mdPath)) throw new Error(`Command ${commandName} already exists as .md file`);
const existingConfig = readConfig();
const commandMap = existingConfig.command as Record<string, unknown> | undefined;
if (commandMap?.[commandName]) throw new Error(`Command ${commandName} already exists in opencode.json`);
const { template, ...frontmatter } = config as Record<string, unknown> & { template?: unknown };
writeMdFile(mdPath, frontmatter, typeof template === 'string' ? template : '');
};
export const updateCommand = (commandName: string, updates: Record<string, unknown>) => {
ensureDirs();
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
const mdExists = fs.existsSync(mdPath);
const mdData = mdExists ? parseMdFile(mdPath) : null;
const config = readConfig();
const commandMap = (config.command as Record<string, unknown> | undefined) ?? {};
const jsonSection = commandMap[commandName] as Record<string, unknown> | undefined;
let mdModified = false;
let jsonModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'template') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
if (mdExists && mdData) {
mdData.body = normalizedValue;
mdModified = true;
continue;
}
if (isPromptFileReference(jsonSection?.template)) {
const templateFilePath = resolvePromptFilePath(jsonSection.template);
if (!templateFilePath) throw new Error(`Invalid template file reference for command ${commandName}`);
writePromptFile(templateFilePath, normalizedValue);
continue;
}
if (!config.command) config.command = {};
const target = (config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined;
(config.command as Record<string, unknown>)[commandName] = { ...(target || {}), template: normalizedValue };
jsonModified = true;
continue;
}
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
if (hasMdField && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
continue;
}
if (!config.command) config.command = {};
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
(config.command as Record<string, unknown>)[commandName] = { ...current, [field]: value };
jsonModified = true;
if (hasJsonField) {
continue;
}
}
if (mdModified && mdData) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config);
}
};
export const deleteCommand = (commandName: string) => {
const mdPath = path.join(COMMAND_DIR, `${commandName}.md`);
let deleted = false;
if (fs.existsSync(mdPath)) {
fs.unlinkSync(mdPath);
deleted = true;
}
const config = readConfig();
const commandMap = (config.command as Record<string, unknown> | undefined) ?? {};
if (commandMap[commandName] !== undefined) {
delete commandMap[commandName];
config.command = commandMap;
writeConfig(config);
deleted = true;
}
if (!deleted) {
throw new Error(`Command "${commandName}" not found`);
}
};
+52 -6
View File
@@ -59,6 +59,14 @@ window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
});
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
return sendBridgeMessageWithOptions<T>(type, payload);
}
export function sendBridgeMessageWithOptions<T = unknown>(
type: string,
payload?: unknown,
options?: { timeoutMs?: number }
): Promise<T> {
return new Promise((resolve, reject) => {
const id = `req_${++requestIdCounter}_${Date.now()}`;
const request: BridgeRequest = { id, type, payload };
@@ -68,17 +76,55 @@ export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown):
reject,
});
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request ${type} timed out`));
}
}, 30000);
const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000;
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request ${type} timed out`));
}
}, timeoutMs);
}
getVSCodeAPI().postMessage(request);
});
}
export type ProxiedApiResponse = {
status: number;
headers: Record<string, string>;
bodyBase64: string;
};
export async function proxyApiRequest(options: {
method: string;
path: string;
headers?: Record<string, string>;
bodyBase64?: string;
}): Promise<ProxiedApiResponse> {
// Do not impose a bridge-level timeout. Let the original fetch's AbortSignal
// (or OpenCode server response timing) control the lifecycle.
return sendBridgeMessageWithOptions<ProxiedApiResponse>('api:proxy', options, { timeoutMs: 0 });
}
export type ProxiedSseStartResponse = {
status: number;
headers: Record<string, string>;
streamId: string | null;
error?: string;
};
export async function startSseProxy(options: {
path: string;
headers?: Record<string, string>;
}): Promise<ProxiedSseStartResponse> {
return sendBridgeMessage<ProxiedSseStartResponse>('api:sse:start', options);
}
export async function stopSseProxy(options: { streamId: string }): Promise<{ stopped: boolean }> {
return sendBridgeMessage<{ stopped: boolean }>('api:sse:stop', options);
}
type CommandHandler = (payload: unknown) => void;
const commandHandlers = new Map<string, CommandHandler>();
+293 -91
View File
@@ -1,5 +1,5 @@
import { createVSCodeAPIs } from './api';
import { onThemeChange, sendBridgeMessage } from './api/bridge';
import { onThemeChange, proxyApiRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import type { RuntimeAPIs } from '../../ui/src/lib/api/types';
import {
buildVSCodeThemeFromPalette,
@@ -14,7 +14,7 @@ declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
__VSCODE_CONFIG__?: {
apiUrl: string;
apiUrl?: string;
workspaceFolder: string;
theme: string;
connectionStatus: string;
@@ -48,19 +48,124 @@ const handleConnectionMessage = (event: MessageEvent) => {
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 } }));
// Hide loading screen when connected
if (payload === 'connected') {
const loadingEl = document.getElementById('initial-loading');
if (loadingEl) {
loadingEl.classList.add('fade-out');
setTimeout(() => loadingEl.remove(), 300);
}
}
}
};
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;
let bootstrapProvidersReady = false;
let bootstrapAgentsReady = false;
let bootstrapFailed = false;
const recordBootstrapFetch = (pathname: string, ok: boolean) => {
if (!pathname.startsWith('/api/')) return;
if (pathname.startsWith('/api/config/providers')) {
if (ok) bootstrapProvidersReady = true;
else bootstrapFailed = true;
return;
}
if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) {
if (ok) bootstrapAgentsReady = true;
else bootstrapFailed = true;
}
};
const maybeHideLoadingOverlay = () => {
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting';
if (!uiMounted) {
return;
}
if (connectionStatus === 'connected') {
if (bootstrapFailed) {
setLoadingStatusText('OpenCode connected, but initial data load failed.', 'error');
fadeOutLoadingScreen();
return;
}
if (bootstrapProvidersReady && bootstrapAgentsReady) {
fadeOutLoadingScreen();
return;
}
const providersText = bootstrapProvidersReady ? '✓ Providers' : '… Providers';
const agentsText = bootstrapAgentsReady ? '✓ Agents' : '… Agents';
setLoadingStatusText(`Loading data (${providersText}, ${agentsText})…`);
return;
}
if (connectionStatus === 'error') {
const error = window.__OPENCHAMBER_CONNECTION__?.error;
setLoadingStatusText(error || 'Connection error', 'error');
fadeOutLoadingScreen();
return;
}
if (connectionStatus === 'disconnected') {
setLoadingStatusText('Disconnected', 'error');
fadeOutLoadingScreen();
return;
}
setLoadingStatusText('Starting OpenCode API…');
};
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
if (typeof document === 'undefined' || !theme) return;
@@ -133,9 +238,6 @@ if (workspaceFolder) {
} catch (error) {
console.warn('Failed to persist workspace folder', error);
}
sendBridgeMessage('api:opencode/directory', { path: workspaceFolder }).catch((error) => {
console.warn('Failed to set OpenCode working directory from VS Code workspace', error);
});
}
const normalizeUrl = (input: string | URL) => {
@@ -146,42 +248,67 @@ const normalizeUrl = (input: string | URL) => {
}
};
// API URL may be empty during initial load while port is being detected
// The extension will broadcast the URL once it's known
let apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || '';
// Promise that resolves when API URL is available
let apiUrlResolver: ((url: string) => void) | null = null;
const apiUrlPromise = apiBaseUrl
? Promise.resolve(apiBaseUrl)
: new Promise<string>((resolve) => { apiUrlResolver = resolve; });
// Listen for API URL updates from extension
window.addEventListener('message', (event: MessageEvent) => {
const msg = event.data;
if (msg?.type === 'apiUrlUpdate' && typeof msg.url === 'string') {
const newUrl = msg.url.replace(/\/+$/, '');
apiBaseUrl = newUrl;
console.log('[OpenChamber] API URL updated:', apiBaseUrl);
// Resolve the promise if waiting
if (apiUrlResolver) {
apiUrlResolver(newUrl);
apiUrlResolver = null;
}
}
});
// Helper to wait for API URL with timeout
const waitForApiUrl = async (timeoutMs = 15000): Promise<string> => {
if (apiBaseUrl) return apiBaseUrl;
const timeout = new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error('Timeout waiting for API URL')), timeoutMs)
);
return Promise.race([apiUrlPromise, timeout]);
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
if (!headers) return {};
const normalized = headers instanceof Headers ? headers : new Headers(headers);
const result: Record<string, string> = {};
normalized.forEach((value, key) => {
result[key] = value;
});
return result;
};
const decodeBase64 = (value: string): Uint8Array => {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
const encodeBase64 = (bytes: Uint8Array): string => {
const CHUNK = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
};
const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string | undefined> => {
if (method === 'GET' || method === 'HEAD') return undefined;
if (input instanceof Request) {
const cloned = input.clone();
const buffer = await cloned.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
const body = init?.body;
if (!body) return undefined;
if (typeof body === 'string') {
return encodeBase64(new TextEncoder().encode(body));
}
if (body instanceof URLSearchParams) {
return encodeBase64(new TextEncoder().encode(body.toString()));
}
if (body instanceof Blob) {
const buffer = await body.arrayBuffer();
const bytes = new Uint8Array(buffer);
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
}
console.warn('[OpenChamber] Unsupported request body type for proxy request:', body);
return undefined;
};
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
const pathname = url.pathname;
@@ -200,33 +327,6 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
});
}
if (pathname.startsWith('/api/openchamber/models-metadata')) {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : undefined;
try {
const response = await fetch('https://models.dev/api.json', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`models.dev responded with ${response.status}`);
}
const data = await response.json();
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.warn('[OpenChamber] Failed to fetch models metadata, returning empty set:', error);
return new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} finally {
if (timeout) clearTimeout(timeout);
}
}
if (pathname.startsWith('/api/fs/list')) {
const targetPath = url.searchParams.get('path') || '';
const data = await sendBridgeMessage('api:fs:list', { path: targetPath });
@@ -259,6 +359,34 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
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 = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/agents', { 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/commands/')) {
const encodedName = pathname.slice('/api/config/commands/'.length);
const name = decodeURIComponent(encodedName);
const verb = ((init?.method || 'GET') as string).toUpperCase();
const body = init?.body ? JSON.parse(init.body as string) : {};
try {
const data = await sendBridgeMessage('api:config/commands', { 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 ((init?.method || 'GET').toUpperCase() === 'GET') {
const settings = await sendBridgeMessage('api:config/settings:get');
@@ -327,27 +455,89 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
const localResponse = await handleLocalApiRequest(targetUrl, init);
if (localResponse) {
recordBootstrapFetch(targetUrl.pathname, localResponse.ok);
maybeHideLoadingOverlay();
return localResponse;
}
// Wait for API URL to be available before making requests
const baseUrl = await waitForApiUrl();
const rewritten = new URL(targetUrl.href);
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
const fetchTarget = `${baseUrl}${rewritten.pathname}${rewritten.search}`;
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
if (input instanceof Request) {
const cloned = input.clone();
const requestInit: RequestInit = {
method: method,
headers: cloned.headers,
body: method === 'GET' || method === 'HEAD' ? undefined : await cloned.blob(),
};
return originalFetch(fetchTarget, requestInit);
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
const headersFromInit = headersToRecord(init?.headers);
const headers = { ...headersFromRequest, ...headersFromInit };
if (isSseApiPath(targetUrl.pathname)) {
const start = await 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') {
controller.enqueue(encoder.encode(msg.chunk));
return;
}
if (msg.type === 'api: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' } });
}
return originalFetch(fetchTarget, init);
const bodyBase64 = await extractBodyBase64(input, init, method);
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
const response = new Response(body, { status: proxied.status, headers: proxied.headers });
recordBootstrapFetch(targetUrl.pathname, response.ok);
maybeHideLoadingOverlay();
return response;
}
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
@@ -362,4 +552,16 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
return originalFetch(input as RequestInfo, init);
};
import('../../ui/src/main');
import('../../ui/src/main')
.then(async () => {
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();
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
import type { Session, Message, Part } from '@opencode-ai/sdk';
const getApiUrl = () => window.__VSCODE_CONFIG__?.apiUrl || 'http://localhost:47339';
const getApiUrl = () => '/api';
const getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt';