feat(vscode): refactor OpenCode configuration management and API proxying
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { handleBridgeMessage, type BridgeRequest } from './bridge';
|
||||
import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge';
|
||||
import { getThemeKindName } from './theme';
|
||||
import type { OpenCodeManager, ConnectionStatus } from './opencode';
|
||||
import { getWebviewShikiThemes } from './shikiThemes';
|
||||
@@ -12,7 +12,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
// Cache latest status/URL for when webview is resolved after connection is ready
|
||||
private _cachedStatus: ConnectionStatus = 'connecting';
|
||||
private _cachedError?: string;
|
||||
private _cachedApiUrl?: string;
|
||||
private _sseCounter = 0;
|
||||
private _sseStreams = new Map<string, AbortController>();
|
||||
|
||||
constructor(
|
||||
private readonly _context: vscode.ExtensionContext,
|
||||
@@ -44,6 +45,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
await this._openCodeManager?.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'api:sse:start') {
|
||||
const response = await this._startSseProxy(message);
|
||||
webviewView.webview.postMessage(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'api:sse:stop') {
|
||||
const response = await this._stopSseProxy(message);
|
||||
webviewView.webview.postMessage(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await handleBridgeMessage(message, {
|
||||
manager: this._openCodeManager,
|
||||
context: this._context,
|
||||
@@ -68,7 +82,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
// Cache the latest state
|
||||
this._cachedStatus = status;
|
||||
this._cachedError = error;
|
||||
this._cachedApiUrl = this._openCodeManager?.getApiUrl() || undefined;
|
||||
|
||||
// Send to webview if it exists
|
||||
this._sendCachedState();
|
||||
@@ -84,14 +97,159 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
status: this._cachedStatus,
|
||||
error: this._cachedError,
|
||||
});
|
||||
|
||||
// Send API URL update if we have one
|
||||
if (this._cachedApiUrl) {
|
||||
this._view.webview.postMessage({
|
||||
type: 'apiUrlUpdate',
|
||||
url: this._cachedApiUrl,
|
||||
});
|
||||
}
|
||||
|
||||
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
private _collectHeaders(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async _startSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
const apiBaseUrl = this._openCodeManager?.getApiUrl();
|
||||
|
||||
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { status: 503, headers: { 'content-type': 'application/json' }, streamId: null },
|
||||
};
|
||||
}
|
||||
|
||||
const streamId = `sse_${++this._sseCounter}_${Date.now()}`;
|
||||
const controller = new AbortController();
|
||||
|
||||
const base = `${apiBaseUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: this._buildSseHeaders(headers || {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { status: 502, headers: { 'content-type': 'application/json' }, streamId: null, error: message },
|
||||
};
|
||||
}
|
||||
|
||||
const responseHeaders = this._collectHeaders(response.headers);
|
||||
const responseBody = response.body;
|
||||
if (!response.ok || !responseBody) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId: null,
|
||||
error: `SSE failed: ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
this._sseStreams.set(streamId, controller);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const reader = responseBody.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (controller.signal.aborted) break;
|
||||
if (value && value.length > 0) {
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
if (!chunk) continue;
|
||||
|
||||
// Reduce webview message pressure by forwarding complete SSE blocks.
|
||||
// The SDK SSE parser is block-based (\n\n delimited) and can consume
|
||||
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
|
||||
sseBuffer += chunk;
|
||||
const blocks = sseBuffer.split('\n\n');
|
||||
sseBuffer = blocks.pop() ?? '';
|
||||
if (blocks.length > 0) {
|
||||
const joined = blocks.map((block) => `${block}\n\n`).join('');
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
sseBuffer += tail;
|
||||
}
|
||||
if (sseBuffer) {
|
||||
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this._view?.webview.postMessage({ type: 'api:sse:end', streamId, error: message });
|
||||
}
|
||||
} finally {
|
||||
this._sseStreams.delete(streamId);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
streamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _stopSseProxy(message: BridgeRequest): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
const { streamId } = (payload || {}) as { streamId?: string };
|
||||
if (typeof streamId === 'string' && streamId.length > 0) {
|
||||
const controller = this._sseStreams.get(streamId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this._sseStreams.delete(streamId);
|
||||
}
|
||||
}
|
||||
return { id, type, success: true, data: { stopped: true } };
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(webview: vscode.Webview) {
|
||||
@@ -102,7 +260,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);
|
||||
// Use cached values which are updated by onStatusChange callback
|
||||
const initialStatus = this._cachedStatus;
|
||||
const apiUrl = this._cachedApiUrl || '';
|
||||
const cliAvailable = this._openCodeManager?.isCliAvailable() ?? false;
|
||||
|
||||
// Use VS Code CSS variables for proper theme integration
|
||||
@@ -211,7 +368,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
window.process = window.process || { env: { NODE_ENV: 'production' }, platform: '', version: '', browser: true };
|
||||
|
||||
window.__VSCODE_CONFIG__ = {
|
||||
apiUrl: "${apiUrl}",
|
||||
workspaceFolder: "${workspaceFolder.replace(/\\/g, '\\\\')}",
|
||||
theme: "${themeKind}",
|
||||
connectionStatus: "${initialStatus}",
|
||||
@@ -224,21 +380,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
var msg = event.data;
|
||||
if (msg && msg.type === 'connectionStatus') {
|
||||
var statusEl = document.getElementById('loading-status');
|
||||
var loadingEl = document.getElementById('initial-loading');
|
||||
if (statusEl) {
|
||||
if (msg.status === 'connecting') {
|
||||
statusEl.textContent = 'Starting OpenCode API…';
|
||||
statusEl.classList.remove('error-text');
|
||||
} else if (msg.status === 'connected') {
|
||||
statusEl.textContent = 'Connected!';
|
||||
// Fade out loading screen once connected and UI is ready
|
||||
setTimeout(function() {
|
||||
if (loadingEl) loadingEl.classList.add('fade-out');
|
||||
}, 300);
|
||||
statusEl.classList.remove('error-text');
|
||||
} else if (msg.status === 'error') {
|
||||
statusEl.textContent = msg.error || 'Connection error';
|
||||
statusEl.classList.add('error-text');
|
||||
} else {
|
||||
statusEl.textContent = 'Reconnecting…';
|
||||
statusEl.classList.remove('error-text');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { OpenCodeManager } from './opencode';
|
||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand } from './opencodeConfig';
|
||||
|
||||
export interface BridgeRequest {
|
||||
id: string;
|
||||
@@ -17,6 +18,19 @@ export interface BridgeResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type ApiProxyRequestPayload = {
|
||||
method?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
bodyBase64?: string;
|
||||
};
|
||||
|
||||
type ApiProxyResponsePayload = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
};
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -34,6 +48,7 @@ export interface BridgeContext {
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'openchamber.settings';
|
||||
const CLIENT_RELOAD_DELAY_MS = 800;
|
||||
|
||||
const readSettings = (ctx?: BridgeContext) => {
|
||||
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
||||
@@ -230,11 +245,86 @@ const fetchModelsMetadata = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const base64EncodeUtf8 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
|
||||
|
||||
const collectHeaders = (headers: Headers): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeContext): Promise<BridgeResponse> {
|
||||
const { id, type, payload } = message;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'api:proxy': {
|
||||
const apiUrl = ctx?.manager?.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
const body = JSON.stringify({ error: 'OpenCode API unavailable' });
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: 503,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
|
||||
const { method, path: requestPath, headers, bodyBase64 } = (payload || {}) as ApiProxyRequestPayload;
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
const normalizedPath =
|
||||
typeof requestPath === 'string' && requestPath.trim().length > 0
|
||||
? requestPath.trim().startsWith('/')
|
||||
? requestPath.trim()
|
||||
: `/${requestPath.trim()}`
|
||||
: '/';
|
||||
|
||||
const base = `${apiUrl.replace(/\/+$/, '')}/`;
|
||||
const targetUrl = new URL(normalizedPath.replace(/^\/+/, ''), base).toString();
|
||||
const requestHeaders: Record<string, string> = { ...(headers || {}) };
|
||||
|
||||
// Ensure SSE requests are negotiated correctly.
|
||||
if (normalizedPath === '/event' || normalizedPath === '/global/event') {
|
||||
if (!requestHeaders.Accept) {
|
||||
requestHeaders.Accept = 'text/event-stream';
|
||||
}
|
||||
requestHeaders['Cache-Control'] = requestHeaders['Cache-Control'] || 'no-cache';
|
||||
requestHeaders.Connection = requestHeaders.Connection || 'keep-alive';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
method: normalizedMethod,
|
||||
headers: requestHeaders,
|
||||
body:
|
||||
typeof bodyBase64 === 'string' && bodyBase64.length > 0 && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
|
||||
? Buffer.from(bodyBase64, 'base64')
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: response.status,
|
||||
headers: collectHeaders(response.headers),
|
||||
bodyBase64: Buffer.from(arrayBuffer).toString('base64'),
|
||||
};
|
||||
|
||||
return { id, type, success: true, data };
|
||||
} catch (error) {
|
||||
const body = JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Failed to reach OpenCode API',
|
||||
});
|
||||
const data: ApiProxyResponsePayload = {
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
bodyBase64: base64EncodeUtf8(body),
|
||||
};
|
||||
return { id, type, success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
case 'files:list': {
|
||||
const { path: dirPath } = payload as { path: string };
|
||||
const uri = vscode.Uri.file(dirPath);
|
||||
@@ -382,6 +472,144 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: true, data: { restarted: true } };
|
||||
}
|
||||
|
||||
case 'api:config/agents': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const agentName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!agentName) {
|
||||
return { id, type, success: false, error: 'Agent name is required' };
|
||||
}
|
||||
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getAgentSources(agentName);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: agentName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
createAgent(agentName, (body || {}) as Record<string, unknown>);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateAgent(agentName, (body || {}) as Record<string, unknown>);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteAgent(agentName);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:config/commands': {
|
||||
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
|
||||
const commandName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!commandName) {
|
||||
return { id, type, success: false, error: 'Command name is required' };
|
||||
}
|
||||
|
||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||
if (normalizedMethod === 'GET') {
|
||||
const sources = getCommandSources(commandName);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: { name: commandName, sources, isBuiltIn: !sources.md.exists && !sources.json.exists },
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'POST') {
|
||||
createCommand(commandName, (body || {}) as Record<string, unknown>);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateCommand(commandName, (body || {}) as Record<string, unknown>);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteCommand(commandName);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
|
||||
}
|
||||
|
||||
case 'api:opencode/directory': {
|
||||
const target = (payload as { path?: string })?.path;
|
||||
if (!target) {
|
||||
|
||||
@@ -4,8 +4,28 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
||||
|
||||
let chatViewProvider: ChatViewProvider | undefined;
|
||||
let openCodeManager: OpenCodeManager | undefined;
|
||||
let outputChannel: vscode.OutputChannel | undefined;
|
||||
|
||||
const SETTINGS_KEY = 'openchamber.settings';
|
||||
|
||||
const formatIso = (value: number | null | undefined) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)';
|
||||
try {
|
||||
return new Date(value).toISOString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDurationMs = (value: number | null | undefined) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '(none)';
|
||||
const seconds = Math.round(value / 100) / 10;
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
outputChannel = vscode.window.createOutputChannel('OpenChamber');
|
||||
|
||||
// Create OpenCode manager first
|
||||
openCodeManager = createOpenCodeManager(context);
|
||||
|
||||
@@ -32,6 +52,153 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('openchamber.showOpenCodeStatus', async () => {
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
const configuredApiUrl = (config.get<string>('apiUrl') || '').trim();
|
||||
|
||||
const extensionVersion = String(context.extension?.packageJSON?.version || '');
|
||||
const workspaceFolders = (vscode.workspace.workspaceFolders || []).map((folder) => folder.uri.fsPath);
|
||||
|
||||
const debug = openCodeManager?.getDebugInfo();
|
||||
const resolvedApiUrl = openCodeManager?.getApiUrl();
|
||||
const workingDirectory = openCodeManager?.getWorkingDirectory() ?? '';
|
||||
|
||||
const safeFetch = async (input: string, timeoutMs = 2500) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const resp = await fetch(input, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
|
||||
let summary = '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = await resp.json().catch(() => null);
|
||||
if (Array.isArray(json)) {
|
||||
summary = `json[array] len=${json.length}`;
|
||||
} else if (json && typeof json === 'object') {
|
||||
const keys = Object.keys(json).slice(0, 8);
|
||||
summary = `json[object] keys=${keys.join(',')}${Object.keys(json).length > keys.length ? ',…' : ''}`;
|
||||
} else {
|
||||
summary = `json[${typeof json}]`;
|
||||
}
|
||||
} else {
|
||||
summary = contentType ? `content-type=${contentType}` : 'no content-type';
|
||||
}
|
||||
|
||||
return { ok: resp.ok, status: resp.status, elapsedMs, summary };
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const isAbort =
|
||||
controller.signal.aborted ||
|
||||
(error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted')));
|
||||
const message = isAbort
|
||||
? `timeout after ${timeoutMs}ms`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
return { ok: false, status: 0, elapsedMs, summary: `error=${message}` };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const buildProbeUrl = (pathname: string, includeDirectory = true) => {
|
||||
if (!resolvedApiUrl) return null;
|
||||
const base = `${resolvedApiUrl.replace(/\/+$/, '')}/`;
|
||||
const url = new URL(pathname.replace(/^\/+/, ''), base);
|
||||
if (includeDirectory && workingDirectory) {
|
||||
url.searchParams.set('directory', workingDirectory);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
|
||||
{ label: 'config', path: '/config', includeDirectory: true },
|
||||
{ label: 'providers', path: '/config/providers', includeDirectory: true },
|
||||
// Can be slower on large configs; keep the probe from producing false negatives.
|
||||
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 8000 },
|
||||
{ label: 'commands', path: '/command', includeDirectory: true },
|
||||
{ label: 'project', path: '/project/current', includeDirectory: true },
|
||||
{ label: 'path', path: '/path', includeDirectory: true },
|
||||
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
|
||||
];
|
||||
|
||||
const probes = resolvedApiUrl
|
||||
? await Promise.all(
|
||||
probeTargets.map(async (entry) => {
|
||||
const url = buildProbeUrl(entry.path, entry.includeDirectory !== false);
|
||||
if (!url) {
|
||||
return { label: entry.label, url: '(none)', result: null as null };
|
||||
}
|
||||
const result = await safeFetch(url, typeof entry.timeoutMs === 'number' ? entry.timeoutMs : undefined);
|
||||
return { label: entry.label, url, result };
|
||||
})
|
||||
)
|
||||
: [];
|
||||
|
||||
const storedSettings = context.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
||||
const settingsKeys = Object.keys(storedSettings).filter((key) => key !== 'lastDirectory');
|
||||
|
||||
const lines = [
|
||||
`Time: ${new Date().toISOString()}`,
|
||||
`OpenChamber version: ${extensionVersion || '(unknown)'}`,
|
||||
`VS Code version: ${vscode.version}`,
|
||||
`Platform: ${process.platform} ${process.arch}`,
|
||||
`Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`,
|
||||
`Status: ${openCodeManager?.getStatus() ?? 'unknown'}`,
|
||||
`CLI available: ${openCodeManager?.isCliAvailable() ?? false}`,
|
||||
`Working directory: ${openCodeManager?.getWorkingDirectory() ?? ''}`,
|
||||
`API URL (configured): ${configuredApiUrl || '(none)'}`,
|
||||
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
|
||||
debug
|
||||
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
|
||||
: `OpenCode mode: (unknown)`,
|
||||
debug
|
||||
? `OpenCode CLI path: ${debug.cliPath || '(not found)'}`
|
||||
: `OpenCode CLI path: (unknown)`,
|
||||
debug
|
||||
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
||||
: `OpenCode detected port: (unknown)`,
|
||||
debug
|
||||
? `OpenCode API prefix: ${debug.apiPrefixDetected ? (debug.apiPrefix || '(root)') : '(unknown)'}`
|
||||
: `OpenCode API prefix: (unknown)`,
|
||||
debug
|
||||
? `Last start: ${formatIso(debug.lastStartAt)}`
|
||||
: `Last start: (unknown)`,
|
||||
debug
|
||||
? `Last connected: ${formatIso(debug.lastConnectedAt)}`
|
||||
: `Last connected: (unknown)`,
|
||||
debug && debug.lastConnectedAt ? `Connected for: ${formatDurationMs(Date.now() - debug.lastConnectedAt)}` : `Connected for: (n/a)`,
|
||||
debug && debug.lastExitCode !== null ? `Last exit code: ${debug.lastExitCode}` : `Last exit code: (none)`,
|
||||
debug?.lastError ? `Last error: ${debug.lastError}` : `Last error: (none)`,
|
||||
`Settings keys (stored): ${settingsKeys.length ? settingsKeys.join(', ') : '(none)'}`,
|
||||
probes.length ? '' : '',
|
||||
...(probes.length
|
||||
? [
|
||||
'OpenCode API probes:',
|
||||
...probes.map((probe) => {
|
||||
if (!probe.result) return `- ${probe.label}: (no url)`;
|
||||
const { ok, status, elapsedMs, summary } = probe.result;
|
||||
const suffix = ok ? '' : ` url=${probe.url}`;
|
||||
return `- ${probe.label}: ${ok ? 'ok' : 'fail'} status=${status} time=${elapsedMs}ms ${summary}${suffix}`;
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
];
|
||||
|
||||
outputChannel?.appendLine(lines.join('\n'));
|
||||
outputChannel?.show(true);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
||||
chatViewProvider?.updateTheme(theme.kind);
|
||||
@@ -60,13 +227,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
// Start OpenCode API and wait for it to be ready
|
||||
// The webview will show loading state during this time
|
||||
await openCodeManager.start();
|
||||
// Start OpenCode API without blocking activation.
|
||||
// Blocking here delays webview resolution and causes a blank panel until startup completes.
|
||||
void openCodeManager.start();
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await openCodeManager?.stop();
|
||||
openCodeManager = undefined;
|
||||
chatViewProvider = undefined;
|
||||
outputChannel?.dispose();
|
||||
outputChannel = undefined;
|
||||
}
|
||||
|
||||
+196
-29
@@ -12,9 +12,11 @@ const HEALTH_CHECK_INTERVAL_MS = 5000;
|
||||
const SHUTDOWN_TIMEOUT_MS = 3000;
|
||||
|
||||
// Regex to detect port from CLI output (matches desktop pattern)
|
||||
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(?:\/[^\s"']*)?/gi;
|
||||
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
|
||||
const FALLBACK_PORT_REGEX = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i;
|
||||
|
||||
const API_PREFIX_CANDIDATES = ['', '/api'] as const;
|
||||
|
||||
const BIN_CANDIDATES = [
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
@@ -29,6 +31,25 @@ const BIN_CANDIDATES = [
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type OpenCodeDebugInfo = {
|
||||
mode: 'managed' | 'external';
|
||||
status: ConnectionStatus;
|
||||
lastError?: string;
|
||||
workingDirectory: string;
|
||||
cliAvailable: boolean;
|
||||
cliPath: string | null;
|
||||
configuredApiUrl: string | null;
|
||||
configuredPort: number | null;
|
||||
detectedPort: number | null;
|
||||
apiPrefix: string;
|
||||
apiPrefixDetected: boolean;
|
||||
startCount: number;
|
||||
restartCount: number;
|
||||
lastStartAt: number | null;
|
||||
lastConnectedAt: number | null;
|
||||
lastExitCode: number | null;
|
||||
};
|
||||
|
||||
export interface OpenCodeManager {
|
||||
start(workdir?: string): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -38,6 +59,7 @@ export interface OpenCodeManager {
|
||||
getApiUrl(): string | null;
|
||||
getWorkingDirectory(): string;
|
||||
isCliAvailable(): boolean;
|
||||
getDebugInfo(): OpenCodeDebugInfo;
|
||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
||||
}
|
||||
|
||||
@@ -147,36 +169,30 @@ async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
||||
const timeoutMs = quick ? 1500 : 3000;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// For quick checks during startup, just check /health
|
||||
if (quick) {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/health`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
return response.ok;
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
return false;
|
||||
}
|
||||
const normalized = apiUrl.replace(/\/+$/, '');
|
||||
const candidates: string[] = [`${normalized}/config`];
|
||||
|
||||
// Some deployments expose a /health endpoint (not guaranteed for OpenCode).
|
||||
if (!quick) {
|
||||
const healthUrl = normalized.endsWith('/api') ? `${normalized.slice(0, -4)}/health` : `${normalized}/health`;
|
||||
candidates.push(healthUrl);
|
||||
}
|
||||
|
||||
// Full health check: verify multiple endpoints
|
||||
const candidates = [`${apiUrl}/health`, `${apiUrl}/config`];
|
||||
let successCount = 0;
|
||||
|
||||
for (const target of candidates) {
|
||||
try {
|
||||
const response = await fetch(target, { signal: controller.signal });
|
||||
const response = await fetch(target, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (response.ok) {
|
||||
successCount++;
|
||||
if (successCount >= 2) {
|
||||
clearTimeout(timeout);
|
||||
return true;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -192,10 +208,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
let lastError: string | undefined;
|
||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
let startCount = 0;
|
||||
let restartCount = 0;
|
||||
let lastStartAt: number | null = null;
|
||||
let lastConnectedAt: number | null = null;
|
||||
let lastExitCode: number | null = null;
|
||||
|
||||
// Port detection state (like desktop)
|
||||
let detectedPort: number | null = null;
|
||||
let portWaiters: Array<(port: number) => void> = [];
|
||||
|
||||
// OpenCode API prefix detection (some versions serve under /api)
|
||||
let apiPrefix: string = '';
|
||||
let apiPrefixDetected = false;
|
||||
|
||||
// Check if user configured a specific API URL
|
||||
const config = vscode.workspace.getConfiguration('openchamber');
|
||||
@@ -218,10 +243,110 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
const cliPath = resolveCliPath();
|
||||
const cliAvailable = cliPath !== null;
|
||||
|
||||
const normalizeApiPrefix = (prefix: string): string => {
|
||||
const trimmed = (prefix || '').trim();
|
||||
if (!trimmed || trimmed === '/') return '';
|
||||
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
|
||||
};
|
||||
|
||||
const inferPrefixFromLogPath = (candidatePath: string | null | undefined): string | null => {
|
||||
if (!candidatePath) return null;
|
||||
const normalized = normalizeApiPrefix(candidatePath);
|
||||
if (normalized === '/api' || normalized.startsWith('/api/')) {
|
||||
return '/api';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setDetectedApiPrefix = (prefix: string) => {
|
||||
const normalized = normalizeApiPrefix(prefix);
|
||||
if (!apiPrefixDetected || apiPrefix !== normalized) {
|
||||
apiPrefix = normalized;
|
||||
apiPrefixDetected = true;
|
||||
console.log(`[OpenCode] Detected API prefix: ${apiPrefix || '(root)'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const buildApiBaseUrlFromPort = (port: number, prefixOverride?: string): string => {
|
||||
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : apiPrefixDetected ? apiPrefix : '');
|
||||
return `http://localhost:${port}${prefix}`;
|
||||
};
|
||||
|
||||
const detectApiPrefixFromOutput = (text: string) => {
|
||||
if (!text) return;
|
||||
URL_REGEX.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||
const port = parseInt(match[1], 10);
|
||||
if (!Number.isFinite(port) || port <= 0) continue;
|
||||
if (detectedPort !== null && port !== detectedPort) continue;
|
||||
|
||||
const inferred = inferPrefixFromLogPath(match[2] || '');
|
||||
if (inferred !== null) {
|
||||
setDetectedApiPrefix(inferred);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const extractPrefixFromOpenApiDoc = (content: string): string | null => {
|
||||
const match = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!match?.[1]) return null;
|
||||
try {
|
||||
const parsed = new URL(match[1], 'http://localhost');
|
||||
return normalizeApiPrefix(parsed.pathname || '');
|
||||
} catch {
|
||||
return normalizeApiPrefix(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const detectApiPrefix = async (port: number): Promise<string> => {
|
||||
if (apiPrefixDetected) return apiPrefix;
|
||||
|
||||
const origin = `http://localhost:${port}`;
|
||||
|
||||
// Try /doc for explicit base hints first (best signal when available).
|
||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||
const prefix = normalizeApiPrefix(candidate);
|
||||
try {
|
||||
const response = await fetch(`${origin}${prefix}/doc`, { method: 'GET', headers: { Accept: '*/*' } });
|
||||
if (!response.ok) continue;
|
||||
const text = await response.text();
|
||||
const extracted = extractPrefixFromOpenApiDoc(text);
|
||||
if (extracted !== null) {
|
||||
setDetectedApiPrefix(extracted);
|
||||
return apiPrefix;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: probe a stable endpoint under root vs /api.
|
||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||
try {
|
||||
const base = buildApiBaseUrlFromPort(port, candidate);
|
||||
const response = await fetch(`${base}/config`, { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) continue;
|
||||
await response.json().catch(() => null);
|
||||
setDetectedApiPrefix(candidate);
|
||||
return apiPrefix;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return apiPrefix;
|
||||
};
|
||||
|
||||
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
||||
if (status !== newStatus || lastError !== error) {
|
||||
status = newStatus;
|
||||
lastError = error;
|
||||
if (newStatus === 'connected') {
|
||||
lastConnectedAt = Date.now();
|
||||
}
|
||||
listeners.forEach(cb => cb(status, error));
|
||||
}
|
||||
}
|
||||
@@ -252,6 +377,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
const port = parseInt(match[1], 10);
|
||||
if (Number.isFinite(port) && port > 0) {
|
||||
setDetectedPort(port);
|
||||
const inferred = inferPrefixFromLogPath(match[2] || '');
|
||||
if (inferred !== null) {
|
||||
setDetectedApiPrefix(inferred);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -305,7 +434,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
return configuredApiUrl.replace(/\/+$/, '');
|
||||
}
|
||||
if (detectedPort !== null) {
|
||||
return `http://localhost:${detectedPort}`;
|
||||
return buildApiBaseUrlFromPort(detectedPort);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -338,11 +467,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
|
||||
async function start(workdir?: string): Promise<void> {
|
||||
startCount += 1;
|
||||
lastStartAt = Date.now();
|
||||
|
||||
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
||||
workingDirectory = workdir.trim();
|
||||
}
|
||||
|
||||
// If user configured an external API URL, just check if it's healthy
|
||||
// If user configured an external API URL, do NOT start a local CLI instance.
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
setStatus('connecting');
|
||||
const healthy = await checkHealth(configuredApiUrl);
|
||||
@@ -351,12 +483,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
startHealthCheck();
|
||||
return;
|
||||
}
|
||||
// If configured URL isn't responding and no CLI, show error
|
||||
if (!cliAvailable) {
|
||||
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding and CLI is not available.`);
|
||||
return;
|
||||
}
|
||||
// Fall through to start CLI with configured port if possible
|
||||
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for existing running instance (only if port is known)
|
||||
@@ -384,6 +512,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
// Reset port detection for fresh start
|
||||
detectedPort = null;
|
||||
apiPrefix = '';
|
||||
apiPrefixDetected = false;
|
||||
lastExitCode = null;
|
||||
|
||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
|
||||
@@ -407,12 +538,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
const text = data.toString();
|
||||
console.log('[OpenCode]', text.trim());
|
||||
detectPortFromOutput(text);
|
||||
detectApiPrefixFromOutput(text);
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.error('[OpenCode]', text.trim());
|
||||
detectPortFromOutput(text);
|
||||
detectApiPrefixFromOutput(text);
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
@@ -426,6 +559,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
childProcess = null;
|
||||
detectedPort = null;
|
||||
lastExitCode = typeof code === 'number' ? code : null;
|
||||
});
|
||||
|
||||
// Wait for port detection (port comes from stdout/stderr)
|
||||
@@ -438,6 +572,11 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
|
||||
// Now wait for API to be ready
|
||||
const detected = detectedPort;
|
||||
if (detected !== null && !apiPrefixDetected) {
|
||||
await detectApiPrefix(detected);
|
||||
}
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) {
|
||||
setStatus('error', 'Failed to determine OpenCode API URL');
|
||||
@@ -480,6 +619,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
|
||||
async function restart(): Promise<void> {
|
||||
restartCount += 1;
|
||||
await stop();
|
||||
// Brief delay to let OS release resources
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
@@ -488,7 +628,16 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
|
||||
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
||||
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
|
||||
if (target === workingDirectory) {
|
||||
return { success: true, restarted: false, path: target };
|
||||
}
|
||||
workingDirectory = target;
|
||||
|
||||
// When pointing at an external API URL, avoid restarting a local CLI process.
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
return { success: true, restarted: false, path: target };
|
||||
}
|
||||
|
||||
await restart();
|
||||
return { success: true, restarted: true, path: target };
|
||||
}
|
||||
@@ -502,6 +651,24 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
getApiUrl,
|
||||
getWorkingDirectory: () => workingDirectory,
|
||||
isCliAvailable: () => cliAvailable,
|
||||
getDebugInfo: () => ({
|
||||
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
||||
status,
|
||||
lastError,
|
||||
workingDirectory,
|
||||
cliAvailable,
|
||||
cliPath,
|
||||
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
||||
configuredPort,
|
||||
detectedPort,
|
||||
apiPrefix,
|
||||
apiPrefixDetected,
|
||||
startCount,
|
||||
restartCount,
|
||||
lastStartAt,
|
||||
lastConnectedAt,
|
||||
lastExitCode,
|
||||
}),
|
||||
onStatusChange(callback) {
|
||||
listeners.add(callback);
|
||||
// Immediately call with current status
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user