feat(vscode): refactor OpenCode configuration management and API proxying
This commit is contained in:
@@ -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: Focus on Chat View` | Focus chat panel |
|
||||||
| `OpenChamber: Restart API Connection` | Restart OpenCode API process |
|
| `OpenChamber: Restart API Connection` | Restart OpenCode API process |
|
||||||
|
| `OpenChamber: Show OpenCode Status` | Provide debug info useful for development or bug report |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Setting | Default | Description |
|
| 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
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@
|
|||||||
{
|
{
|
||||||
"command": "openchamber.restartApi",
|
"command": "openchamber.restartApi",
|
||||||
"title": "OpenChamber: Restart API Connection"
|
"title": "OpenChamber: Restart API Connection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openchamber.showOpenCodeStatus",
|
||||||
|
"title": "OpenChamber: Show OpenCode Status"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configuration": {
|
"configuration": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { handleBridgeMessage, type BridgeRequest } from './bridge';
|
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
||||||
import { getThemeKindName } from './theme';
|
import { getThemeKindName } from './theme';
|
||||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||||
import { getWebviewShikiThemes } from './shikiThemes';
|
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
|
// Cache latest status/URL for when webview is resolved after connection is ready
|
||||||
private _cachedStatus: ConnectionStatus = 'connecting';
|
private _cachedStatus: ConnectionStatus = 'connecting';
|
||||||
private _cachedError?: string;
|
private _cachedError?: string;
|
||||||
private _cachedApiUrl?: string;
|
private _sseCounter = 0;
|
||||||
|
private _sseStreams = new Map<string, AbortController>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _context: vscode.ExtensionContext,
|
private readonly _context: vscode.ExtensionContext,
|
||||||
@@ -44,6 +45,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
await this._openCodeManager?.restart();
|
await this._openCodeManager?.restart();
|
||||||
return;
|
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, {
|
const response = await handleBridgeMessage(message, {
|
||||||
manager: this._openCodeManager,
|
manager: this._openCodeManager,
|
||||||
context: this._context,
|
context: this._context,
|
||||||
@@ -68,7 +82,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
// Cache the latest state
|
// Cache the latest state
|
||||||
this._cachedStatus = status;
|
this._cachedStatus = status;
|
||||||
this._cachedError = error;
|
this._cachedError = error;
|
||||||
this._cachedApiUrl = this._openCodeManager?.getApiUrl() || undefined;
|
|
||||||
|
|
||||||
// Send to webview if it exists
|
// Send to webview if it exists
|
||||||
this._sendCachedState();
|
this._sendCachedState();
|
||||||
@@ -84,14 +97,159 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
status: this._cachedStatus,
|
status: this._cachedStatus,
|
||||||
error: this._cachedError,
|
error: this._cachedError,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Send API URL update if we have one
|
|
||||||
if (this._cachedApiUrl) {
|
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
this._view.webview.postMessage({
|
return {
|
||||||
type: 'apiUrlUpdate',
|
Accept: 'text/event-stream',
|
||||||
url: this._cachedApiUrl,
|
'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) {
|
private _getHtmlForWebview(webview: vscode.Webview) {
|
||||||
@@ -102,7 +260,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||||
// Use cached values which are updated by onStatusChange callback
|
// Use cached values which are updated by onStatusChange callback
|
||||||
const initialStatus = this._cachedStatus;
|
const initialStatus = this._cachedStatus;
|
||||||
const apiUrl = this._cachedApiUrl || '';
|
|
||||||
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||||
|
|
||||||
// Use VS Code CSS variables for proper theme integration
|
// 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.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
|
||||||
|
|
||||||
window.__VSCODE_CONFIG__ = {
|
window.__VSCODE_CONFIG__ = {
|
||||||
apiUrl: "${apiUrl}",
|
|
||||||
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
||||||
theme: "${themeKind}",
|
theme: "${themeKind}",
|
||||||
connectionStatus: "${initialStatus}",
|
connectionStatus: "${initialStatus}",
|
||||||
@@ -224,21 +380,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
var msg = event.data;
|
var msg = event.data;
|
||||||
if (msg && msg.type === 'connectionStatus') {
|
if (msg && msg.type === 'connectionStatus') {
|
||||||
var statusEl = document.getElementById('loading-status');
|
var statusEl = document.getElementById('loading-status');
|
||||||
var loadingEl = document.getElementById('initial-loading');
|
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
if (msg.status === 'connecting') {
|
if (msg.status === 'connecting') {
|
||||||
statusEl.textContent = 'Starting OpenCode API…';
|
statusEl.textContent = 'Starting OpenCode API…';
|
||||||
|
statusEl.classList.remove('error-text');
|
||||||
} else if (msg.status === 'connected') {
|
} else if (msg.status === 'connected') {
|
||||||
statusEl.textContent = 'Connected!';
|
statusEl.textContent = 'Connected!';
|
||||||
// Fade out loading screen once connected and UI is ready
|
statusEl.classList.remove('error-text');
|
||||||
setTimeout(function() {
|
|
||||||
if (loadingEl) loadingEl.classList.add('fade-out');
|
|
||||||
}, 300);
|
|
||||||
} else if (msg.status === 'error') {
|
} else if (msg.status === 'error') {
|
||||||
statusEl.textContent = msg.error || 'Connection error';
|
statusEl.textContent = msg.error || 'Connection error';
|
||||||
statusEl.classList.add('error-text');
|
statusEl.classList.add('error-text');
|
||||||
} else {
|
} else {
|
||||||
statusEl.textContent = 'Reconnecting…';
|
statusEl.textContent = 'Reconnecting…';
|
||||||
|
statusEl.classList.remove('error-text');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { OpenCodeManager } from './opencode';
|
import type { OpenCodeManager } from './opencode';
|
||||||
|
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand } from './opencodeConfig';
|
||||||
|
|
||||||
export interface BridgeRequest {
|
export interface BridgeRequest {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +18,19 @@ export interface BridgeResponse {
|
|||||||
error?: string;
|
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 {
|
interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -34,6 +48,7 @@ export interface BridgeContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_KEY = 'openchamber.settings';
|
const SETTINGS_KEY = 'openchamber.settings';
|
||||||
|
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||||
|
|
||||||
const readSettings = (ctx?: BridgeContext) => {
|
const readSettings = (ctx?: BridgeContext) => {
|
||||||
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
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> {
|
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||||
const { id, type, payload } = message;
|
const { id, type, payload } = message;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
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': {
|
case 'files:list': {
|
||||||
const { path: dirPath } = payload as { path: string };
|
const { path: dirPath } = payload as { path: string };
|
||||||
const uri = vscode.Uri.file(dirPath);
|
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 } };
|
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': {
|
case 'api:opencode/directory': {
|
||||||
const target = (payload as { path?: string })?.path;
|
const target = (payload as { path?: string })?.path;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
@@ -4,8 +4,28 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
|||||||
|
|
||||||
let chatViewProvider: ChatViewProvider | undefined;
|
let chatViewProvider: ChatViewProvider | undefined;
|
||||||
let openCodeManager: OpenCodeManager | 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) {
|
export async function activate(context: vscode.ExtensionContext) {
|
||||||
|
outputChannel = vscode.window.createOutputChannel('OpenChamber');
|
||||||
|
|
||||||
// Create OpenCode manager first
|
// Create OpenCode manager first
|
||||||
openCodeManager = createOpenCodeManager(context);
|
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(
|
context.subscriptions.push(
|
||||||
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
||||||
chatViewProvider?.updateTheme(theme.kind);
|
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
|
// Start OpenCode API without blocking activation.
|
||||||
// The webview will show loading state during this time
|
// Blocking here delays webview resolution and causes a blank panel until startup completes.
|
||||||
await openCodeManager.start();
|
void openCodeManager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
await openCodeManager?.stop();
|
await openCodeManager?.stop();
|
||||||
openCodeManager = undefined;
|
openCodeManager = undefined;
|
||||||
chatViewProvider = undefined;
|
chatViewProvider = undefined;
|
||||||
|
outputChannel?.dispose();
|
||||||
|
outputChannel = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-29
@@ -12,9 +12,11 @@ const HEALTH_CHECK_INTERVAL_MS = 5000;
|
|||||||
const SHUTDOWN_TIMEOUT_MS = 3000;
|
const SHUTDOWN_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
// Regex to detect port from CLI output (matches desktop pattern)
|
// 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 FALLBACK_PORT_REGEX = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i;
|
||||||
|
|
||||||
|
const API_PREFIX_CANDIDATES = ['', '/api'] as const;
|
||||||
|
|
||||||
const BIN_CANDIDATES = [
|
const BIN_CANDIDATES = [
|
||||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||||
@@ -29,6 +31,25 @@ const BIN_CANDIDATES = [
|
|||||||
|
|
||||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
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 {
|
export interface OpenCodeManager {
|
||||||
start(workdir?: string): Promise<void>;
|
start(workdir?: string): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
@@ -38,6 +59,7 @@ export interface OpenCodeManager {
|
|||||||
getApiUrl(): string | null;
|
getApiUrl(): string | null;
|
||||||
getWorkingDirectory(): string;
|
getWorkingDirectory(): string;
|
||||||
isCliAvailable(): boolean;
|
isCliAvailable(): boolean;
|
||||||
|
getDebugInfo(): OpenCodeDebugInfo;
|
||||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
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 timeoutMs = quick ? 1500 : 3000;
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
// For quick checks during startup, just check /health
|
const normalized = apiUrl.replace(/\/+$/, '');
|
||||||
if (quick) {
|
const candidates: string[] = [`${normalized}/config`];
|
||||||
try {
|
|
||||||
const response = await fetch(`${apiUrl}/health`, { signal: controller.signal });
|
// Some deployments expose a /health endpoint (not guaranteed for OpenCode).
|
||||||
clearTimeout(timeout);
|
if (!quick) {
|
||||||
return response.ok;
|
const healthUrl = normalized.endsWith('/api') ? `${normalized.slice(0, -4)}/health` : `${normalized}/health`;
|
||||||
} catch {
|
candidates.push(healthUrl);
|
||||||
clearTimeout(timeout);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full health check: verify multiple endpoints
|
|
||||||
const candidates = [`${apiUrl}/health`, `${apiUrl}/config`];
|
|
||||||
let successCount = 0;
|
|
||||||
|
|
||||||
for (const target of candidates) {
|
for (const target of candidates) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(target, { signal: controller.signal });
|
const response = await fetch(target, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
successCount++;
|
clearTimeout(timeout);
|
||||||
if (successCount >= 2) {
|
return true;
|
||||||
clearTimeout(timeout);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// try next
|
// try next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -192,10 +208,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
let lastError: string | undefined;
|
let lastError: string | undefined;
|
||||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||||
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
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)
|
// Port detection state (like desktop)
|
||||||
let detectedPort: number | null = null;
|
let detectedPort: number | null = null;
|
||||||
let portWaiters: Array<(port: number) => void> = [];
|
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
|
// Check if user configured a specific API URL
|
||||||
const config = vscode.workspace.getConfiguration('openchamber');
|
const config = vscode.workspace.getConfiguration('openchamber');
|
||||||
@@ -218,10 +243,110 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
const cliPath = resolveCliPath();
|
const cliPath = resolveCliPath();
|
||||||
const cliAvailable = cliPath !== null;
|
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) {
|
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
||||||
if (status !== newStatus || lastError !== error) {
|
if (status !== newStatus || lastError !== error) {
|
||||||
status = newStatus;
|
status = newStatus;
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
if (newStatus === 'connected') {
|
||||||
|
lastConnectedAt = Date.now();
|
||||||
|
}
|
||||||
listeners.forEach(cb => cb(status, error));
|
listeners.forEach(cb => cb(status, error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +377,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
const port = parseInt(match[1], 10);
|
const port = parseInt(match[1], 10);
|
||||||
if (Number.isFinite(port) && port > 0) {
|
if (Number.isFinite(port) && port > 0) {
|
||||||
setDetectedPort(port);
|
setDetectedPort(port);
|
||||||
|
const inferred = inferPrefixFromLogPath(match[2] || '');
|
||||||
|
if (inferred !== null) {
|
||||||
|
setDetectedApiPrefix(inferred);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +434,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
return configuredApiUrl.replace(/\/+$/, '');
|
return configuredApiUrl.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
if (detectedPort !== null) {
|
if (detectedPort !== null) {
|
||||||
return `http://localhost:${detectedPort}`;
|
return buildApiBaseUrlFromPort(detectedPort);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -338,11 +467,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function start(workdir?: string): Promise<void> {
|
async function start(workdir?: string): Promise<void> {
|
||||||
|
startCount += 1;
|
||||||
|
lastStartAt = Date.now();
|
||||||
|
|
||||||
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
||||||
workingDirectory = workdir.trim();
|
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) {
|
if (useConfiguredUrl && configuredApiUrl) {
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
const healthy = await checkHealth(configuredApiUrl);
|
const healthy = await checkHealth(configuredApiUrl);
|
||||||
@@ -351,12 +483,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
startHealthCheck();
|
startHealthCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If configured URL isn't responding and no CLI, show error
|
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding.`);
|
||||||
if (!cliAvailable) {
|
return;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing running instance (only if port is known)
|
// 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
|
// Reset port detection for fresh start
|
||||||
detectedPort = null;
|
detectedPort = null;
|
||||||
|
apiPrefix = '';
|
||||||
|
apiPrefixDetected = false;
|
||||||
|
lastExitCode = null;
|
||||||
|
|
||||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
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();
|
const text = data.toString();
|
||||||
console.log('[OpenCode]', text.trim());
|
console.log('[OpenCode]', text.trim());
|
||||||
detectPortFromOutput(text);
|
detectPortFromOutput(text);
|
||||||
|
detectApiPrefixFromOutput(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
childProcess.stderr?.on('data', (data) => {
|
childProcess.stderr?.on('data', (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
console.error('[OpenCode]', text.trim());
|
console.error('[OpenCode]', text.trim());
|
||||||
detectPortFromOutput(text);
|
detectPortFromOutput(text);
|
||||||
|
detectApiPrefixFromOutput(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
childProcess.on('error', (err) => {
|
childProcess.on('error', (err) => {
|
||||||
@@ -426,6 +559,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
childProcess = null;
|
childProcess = null;
|
||||||
detectedPort = null;
|
detectedPort = null;
|
||||||
|
lastExitCode = typeof code === 'number' ? code : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for port detection (port comes from stdout/stderr)
|
// 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
|
// Now wait for API to be ready
|
||||||
|
const detected = detectedPort;
|
||||||
|
if (detected !== null && !apiPrefixDetected) {
|
||||||
|
await detectApiPrefix(detected);
|
||||||
|
}
|
||||||
|
|
||||||
const apiUrl = getApiUrl();
|
const apiUrl = getApiUrl();
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
setStatus('error', 'Failed to determine OpenCode API URL');
|
setStatus('error', 'Failed to determine OpenCode API URL');
|
||||||
@@ -480,6 +619,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function restart(): Promise<void> {
|
async function restart(): Promise<void> {
|
||||||
|
restartCount += 1;
|
||||||
await stop();
|
await stop();
|
||||||
// Brief delay to let OS release resources
|
// Brief delay to let OS release resources
|
||||||
await new Promise(r => setTimeout(r, 250));
|
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 }> {
|
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
||||||
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
|
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
|
||||||
|
if (target === workingDirectory) {
|
||||||
|
return { success: true, restarted: false, path: target };
|
||||||
|
}
|
||||||
workingDirectory = 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();
|
await restart();
|
||||||
return { success: true, restarted: true, path: target };
|
return { success: true, restarted: true, path: target };
|
||||||
}
|
}
|
||||||
@@ -502,6 +651,24 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
getApiUrl,
|
getApiUrl,
|
||||||
getWorkingDirectory: () => workingDirectory,
|
getWorkingDirectory: () => workingDirectory,
|
||||||
isCliAvailable: () => cliAvailable,
|
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) {
|
onStatusChange(callback) {
|
||||||
listeners.add(callback);
|
listeners.add(callback);
|
||||||
// Immediately call with current status
|
// Immediately call with current status
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -59,6 +59,14 @@ window.addEventListener('message', (event: MessageEvent<BridgeResponse>) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown): Promise<T> {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = `req_${++requestIdCounter}_${Date.now()}`;
|
const id = `req_${++requestIdCounter}_${Date.now()}`;
|
||||||
const request: BridgeRequest = { id, type, payload };
|
const request: BridgeRequest = { id, type, payload };
|
||||||
@@ -68,17 +76,55 @@ export function sendBridgeMessage<T = unknown>(type: string, payload?: unknown):
|
|||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
const timeoutMs = typeof options?.timeoutMs === 'number' ? options.timeoutMs : 30000;
|
||||||
if (pendingRequests.has(id)) {
|
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||||
pendingRequests.delete(id);
|
setTimeout(() => {
|
||||||
reject(new Error(`Request ${type} timed out`));
|
if (pendingRequests.has(id)) {
|
||||||
}
|
pendingRequests.delete(id);
|
||||||
}, 30000);
|
reject(new Error(`Request ${type} timed out`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
getVSCodeAPI().postMessage(request);
|
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;
|
type CommandHandler = (payload: unknown) => void;
|
||||||
const commandHandlers = new Map<string, CommandHandler>();
|
const commandHandlers = new Map<string, CommandHandler>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createVSCodeAPIs } from './api';
|
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 type { RuntimeAPIs } from '../../ui/src/lib/api/types';
|
||||||
import {
|
import {
|
||||||
buildVSCodeThemeFromPalette,
|
buildVSCodeThemeFromPalette,
|
||||||
@@ -14,7 +14,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||||
__VSCODE_CONFIG__?: {
|
__VSCODE_CONFIG__?: {
|
||||||
apiUrl: string;
|
apiUrl?: string;
|
||||||
workspaceFolder: string;
|
workspaceFolder: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
@@ -48,19 +48,124 @@ const handleConnectionMessage = (event: MessageEvent) => {
|
|||||||
const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
||||||
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable };
|
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable };
|
||||||
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
|
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('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 } } }) => {
|
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
|
||||||
if (typeof document === 'undefined' || !theme) return;
|
if (typeof document === 'undefined' || !theme) return;
|
||||||
@@ -133,9 +238,6 @@ if (workspaceFolder) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist workspace folder', 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) => {
|
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
|
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
|
||||||
// The extension will broadcast the URL once it's known
|
if (!headers) return {};
|
||||||
let apiBaseUrl = window.__VSCODE_CONFIG__?.apiUrl?.replace(/\/+$/, '') || '';
|
const normalized = headers instanceof Headers ? headers : new Headers(headers);
|
||||||
|
const result: Record<string, string> = {};
|
||||||
// Promise that resolves when API URL is available
|
normalized.forEach((value, key) => {
|
||||||
let apiUrlResolver: ((url: string) => void) | null = null;
|
result[key] = value;
|
||||||
const apiUrlPromise = apiBaseUrl
|
});
|
||||||
? Promise.resolve(apiBaseUrl)
|
return result;
|
||||||
: 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 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 handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
||||||
const pathname = url.pathname;
|
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')) {
|
if (pathname.startsWith('/api/fs/list')) {
|
||||||
const targetPath = url.searchParams.get('path') || '';
|
const targetPath = url.searchParams.get('path') || '';
|
||||||
const data = await sendBridgeMessage('api:fs:list', { path: targetPath });
|
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' } });
|
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 (pathname.startsWith('/api/config/settings')) {
|
||||||
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
||||||
const settings = await sendBridgeMessage('api:config/settings: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/')) {
|
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
|
||||||
const localResponse = await handleLocalApiRequest(targetUrl, init);
|
const localResponse = await handleLocalApiRequest(targetUrl, init);
|
||||||
if (localResponse) {
|
if (localResponse) {
|
||||||
|
recordBootstrapFetch(targetUrl.pathname, localResponse.ok);
|
||||||
|
maybeHideLoadingOverlay();
|
||||||
return localResponse;
|
return localResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for API URL to be available before making requests
|
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
|
||||||
const baseUrl = await waitForApiUrl();
|
|
||||||
|
|
||||||
const rewritten = new URL(targetUrl.href);
|
|
||||||
rewritten.pathname = targetUrl.pathname.replace(/^\/api/, '');
|
|
||||||
const fetchTarget = `${baseUrl}${rewritten.pathname}${rewritten.search}`;
|
|
||||||
|
|
||||||
if (input instanceof Request) {
|
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
|
||||||
const cloned = input.clone();
|
const headersFromInit = headersToRecord(init?.headers);
|
||||||
const requestInit: RequestInit = {
|
const headers = { ...headersFromRequest, ...headersFromInit };
|
||||||
method: method,
|
|
||||||
headers: cloned.headers,
|
if (isSseApiPath(targetUrl.pathname)) {
|
||||||
body: method === 'GET' || method === 'HEAD' ? undefined : await cloned.blob(),
|
const start = await startSseProxy({ path: suffixPath, headers });
|
||||||
};
|
if (!start.streamId) {
|
||||||
return originalFetch(fetchTarget, requestInit);
|
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')) {
|
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);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
|
import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk';
|
||||||
import type { Session, Message, Part } 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 getWorkspaceFolder = () => window.__VSCODE_CONFIG__?.workspaceFolder || '';
|
||||||
|
|
||||||
const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt';
|
const AUTO_DELETE_STORAGE_KEY = 'oc.vscode.autoDeleteLastRunAt';
|
||||||
|
|||||||
Reference in New Issue
Block a user