Files
openchamber/packages/vscode/src/opencode.ts
T

638 lines
19 KiB
TypeScript
Raw Normal View History

2025-12-13 16:34:17 +02:00
import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import { execSync } from 'child_process';
import { spawnSync } from 'child_process';
import { createOpencodeServer } from '@opencode-ai/sdk/v2/server';
2025-12-13 16:34:17 +02:00
2026-01-06 21:31:04 +02:00
const READY_CHECK_TIMEOUT_MS = 30000;
2025-12-13 16:34:17 +02:00
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;
serverUrl: string | null;
2026-01-16 14:43:53 +02:00
lastReadyElapsedMs: number | null;
lastReadyAttempts: number | null;
lastStartAttempts: number | null;
version: string | null;
};
2025-12-13 16:34:17 +02:00
export interface OpenCodeManager {
start(workdir?: string): Promise<void>;
stop(): Promise<void>;
restart(): Promise<void>;
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
getStatus(): ConnectionStatus;
getApiUrl(): string | null;
2025-12-13 16:34:17 +02:00
getWorkingDirectory(): string;
isCliAvailable(): boolean;
getDebugInfo(): OpenCodeDebugInfo;
2025-12-13 16:34:17 +02:00
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
}
function resolvePortFromUrl(url: string): number | null {
2025-12-13 16:34:17 +02:00
try {
const parsed = new URL(url);
return parsed.port ? parseInt(parsed.port, 10) : null;
2025-12-13 16:34:17 +02:00
} catch {
return null;
}
2025-12-13 16:34:17 +02:00
}
function isExecutable(filePath: string): boolean {
if (!filePath) return false;
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
// Windows executability is extension-based.
if (process.platform === 'win32') {
const ext = path.extname(filePath).toLowerCase();
if (!ext) return true;
return ['.exe', '.cmd', '.bat', '.com'].includes(ext);
}
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function appendToPath(dir: string) {
const trimmed = (dir || '').trim();
if (!trimmed) return;
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
if (parts.includes(trimmed)) return;
process.env.PATH = [trimmed, ...parts].join(path.delimiter);
}
function resolveOpencodeCliPath(): string | null {
const configured = (() => {
try {
const config = vscode.workspace.getConfiguration('openchamber');
const raw = config.get<string>('opencodeBinary') || '';
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const stat = fs.statSync(trimmed);
if (stat.isDirectory()) {
return path.join(trimmed, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
}
} catch {
// ignore
}
return trimmed;
} catch {
return null;
}
})();
if (configured && isExecutable(configured)) {
return configured;
}
const sharedFromOpenChamber = (() => {
try {
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
const raw = fs.readFileSync(settingsPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const candidate = (parsed as Record<string, unknown>).opencodeBinary;
if (typeof candidate !== 'string') {
return null;
}
const trimmed = candidate.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
})();
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber)) {
return sharedFromOpenChamber;
}
const explicit = [
process.env.OPENCODE_BINARY,
process.env.OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_PATH,
process.env.OPENCHAMBER_OPENCODE_BIN,
]
.map((v) => (typeof v === 'string' ? v.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutable(candidate)) {
return candidate;
}
}
const home = os.homedir();
const unixFallbacks = [
path.join(home, '.opencode', 'bin', 'opencode'),
path.join(home, '.bun', 'bin', 'opencode'),
path.join(home, '.local', 'bin', 'opencode'),
path.join(home, 'bin', 'opencode'),
];
const winFallbacks = (() => {
const userProfile = process.env.USERPROFILE || home;
const appData = process.env.APPDATA || '';
const localAppData = process.env.LOCALAPPDATA || '';
const programData = process.env.ProgramData || 'C:\\ProgramData';
return [
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
path.join(appData, 'npm', 'opencode.cmd'),
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
// Bun global install
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
// Some installers use LocalAppData
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
].filter(Boolean);
})();
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
for (const candidate of fallbacks) {
if (isExecutable(candidate)) {
return candidate;
}
}
if (process.platform === 'win32') {
try {
const result = spawnSync('where', ['opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status === 0) {
const lines = (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const found = lines.find((line) => isExecutable(line));
if (found) return found;
}
} catch {
// ignore
}
return null;
}
// Non-Windows: try a login shell PATH lookup.
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean) as string[];
for (const shell of shells) {
if (!isExecutable(shell)) continue;
try {
const result = spawnSync(shell, ['-lic', 'command -v opencode'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status === 0) {
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
if (found && isExecutable(found)) {
return found;
}
}
} catch {
// ignore
}
}
return null;
}
2026-01-16 14:43:53 +02:00
type ReadyResult =
| { ok: true; baseUrl: string; elapsedMs: number; attempts: number; version: string | null }
| { ok: false; elapsedMs: number; attempts: number; version: null };
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, '');
}
function getCandidateBaseUrls(serverUrl: string): string[] {
const normalized = normalizeBaseUrl(serverUrl);
try {
const parsed = new URL(normalized);
const origin = parsed.origin;
const candidates: string[] = [];
const add = (url: string) => {
const v = normalizeBaseUrl(url);
if (!candidates.includes(v)) candidates.push(v);
};
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
// Prefer plain origin. Only keep SDK url when already root.
add(origin);
if (normalizedPath === '' || normalizedPath === '/') {
add(normalized);
}
return candidates;
} catch {
return [normalized];
}
}
2026-01-17 22:44:09 +02:00
async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<ReadyResult> {
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
const start = Date.now();
const candidates = getCandidateBaseUrls(serverUrl);
2026-01-16 14:43:53 +02:00
let attempts = 0;
while (Date.now() - start < timeoutMs) {
for (const baseUrl of candidates) {
2026-01-16 14:43:53 +02:00
attempts += 1;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
// OpenCode readiness check.
const url = new URL(`${baseUrl}/global/health`);
const res = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
let body: { healthy?: boolean, version?: string } | null = null;
try {
body = (await res.json()) as { healthy?: boolean, version?: string };
} catch {
body = null;
}
clearTimeout(timeout);
outputChannel?.appendLine(
`Health check to ${url.toString()} returned ${res.status} with body: ${JSON.stringify(body)}`
);
if (res.ok && body?.healthy === true) {
return { ok: true, baseUrl, elapsedMs: Date.now() - start, attempts, version: body?.version ?? null };
2026-01-16 14:43:53 +02:00
}
} catch {
// ignore
}
}
await new Promise(r => setTimeout(r, 100));
}
return { ok: false, elapsedMs: Date.now() - start, attempts, version: null };
}
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
void _context;
let server: { url: string; close: () => void } | null = null;
let managedApiUrlOverride: string | null = null;
2025-12-13 16:34:17 +02:00
let status: ConnectionStatus = 'disconnected';
let lastError: string | undefined;
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
2026-01-16 14:43:53 +02:00
const workspaceDirectory = (): string =>
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
let workingDirectory: string = workspaceDirectory();
let startCount = 0;
let restartCount = 0;
let lastStartAt: number | null = null;
let lastConnectedAt: number | null = null;
let lastExitCode: number | null = null;
2026-01-16 14:43:53 +02:00
let lastReadyElapsedMs: number | null = null;
let lastReadyAttempts: number | null = null;
let lastStartAttempts: number | null = null;
let version: string | null = null;
let detectedPort: number | null = null;
let cliMissing = false;
let cliPath: string | null = null;
let pendingOperation: Promise<void> | null = null;
2025-12-13 16:34:17 +02:00
const config = vscode.workspace.getConfiguration('openchamber');
const configuredApiUrl = config.get<string>('apiUrl') || '';
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
let configuredPort: number | null = null;
if (useConfiguredUrl) {
2025-12-13 16:34:17 +02:00
try {
const parsed = new URL(configuredApiUrl);
if (parsed.port) {
configuredPort = parseInt(parsed.port, 10);
}
2025-12-13 16:34:17 +02:00
} catch {
// Invalid URL
2025-12-13 16:34:17 +02:00
}
}
2025-12-13 16:34:17 +02:00
const setStatus = (newStatus: ConnectionStatus, error?: string) => {
2025-12-13 16:34:17 +02:00
if (status !== newStatus || lastError !== error) {
status = newStatus;
lastError = error;
if (newStatus === 'connected') {
lastConnectedAt = Date.now();
}
2025-12-13 16:34:17 +02:00
listeners.forEach(cb => cb(status, error));
}
};
const getApiUrl = (): string | null => {
if (useConfiguredUrl && configuredApiUrl) {
return configuredApiUrl.replace(/\/+$/, '');
}
if (managedApiUrlOverride) {
return managedApiUrlOverride.replace(/\/+$/, '');
}
if (server?.url) {
return server.url.replace(/\/+$/, '');
}
if (detectedPort) {
2026-01-16 14:43:53 +02:00
return `http://127.0.0.1:${detectedPort}`;
2025-12-13 16:34:17 +02:00
}
return null;
};
2025-12-13 16:34:17 +02:00
async function startInternal(workdir?: string): Promise<void> {
startCount += 1;
2026-01-16 14:43:53 +02:00
setStatus('connecting');
lastStartAt = Date.now();
2026-01-16 14:43:53 +02:00
lastStartAttempts = startCount;
2025-12-13 16:34:17 +02:00
if (typeof workdir === 'string' && workdir.trim().length > 0) {
workingDirectory = workdir.trim();
2026-01-16 14:43:53 +02:00
} else {
workingDirectory = workspaceDirectory();
2025-12-13 16:34:17 +02:00
}
if (useConfiguredUrl && configuredApiUrl) {
setStatus('connecting');
2025-12-13 16:34:17 +02:00
setStatus('connected');
return;
}
// If server already running, don't spawn another
if (server) {
if (status !== 'connected') {
setStatus('connected');
}
return;
}
setStatus('connecting');
cliMissing = false;
cliPath = null;
detectedPort = null;
lastExitCode = null;
managedApiUrlOverride = null;
2025-12-13 16:34:17 +02:00
try {
// Best-effort: locate CLI even when VS Code PATH is stale.
const resolvedCli = resolveOpencodeCliPath();
if (resolvedCli) {
cliPath = resolvedCli;
appendToPath(path.dirname(resolvedCli));
process.env.OPENCODE_BINARY = resolvedCli;
}
// SDK spawns `opencode serve` in current process cwd.
// Some OpenCode endpoints behave differently based on server process cwd,
// so ensure we start it from the workspace directory.
const originalCwd = process.cwd();
try {
process.chdir(workingDirectory);
server = await createOpencodeServer({
hostname: '127.0.0.1',
port: 0,
timeout: READY_CHECK_TIMEOUT_MS,
signal: undefined,
});
} finally {
try {
process.chdir(originalCwd);
} catch {
// ignore
}
}
if (server && server.url) {
// Validate readiness for the current workspace context.
const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS);
2026-01-16 14:43:53 +02:00
lastReadyElapsedMs = ready.elapsedMs;
lastReadyAttempts = ready.attempts;
if (ready.ok) {
managedApiUrlOverride = ready.baseUrl;
detectedPort = resolvePortFromUrl(ready.baseUrl);
version = ready.version;
setStatus('connected');
} else {
try {
server.close();
} catch {
// ignore
}
server = null;
throw new Error('Server started but health check failed');
}
2025-12-13 16:34:17 +02:00
} else {
throw new Error('Server started but URL is missing');
2025-12-13 16:34:17 +02:00
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Check for ENOENT or generic spawn failure which implies CLI missing
if (message.includes('ENOENT') || message.includes('spawn opencode')) {
cliMissing = true;
if (!cliPath) {
cliPath = resolveOpencodeCliPath();
}
setStatus('error', 'OpenCode CLI not found. Install it and ensure it\'s in PATH.');
vscode.window.showErrorMessage(
'OpenCode CLI not found. Please install it and ensure it\'s in PATH.',
'More Info'
).then(selection => {
if (selection === 'More Info') {
2026-01-31 21:33:52 +01:00
vscode.env.openExternal(vscode.Uri.parse('https://github.com/anomalyco/opencode'));
}
});
} else {
setStatus('error', `Failed to start OpenCode: ${message}`);
}
2025-12-13 16:34:17 +02:00
}
}
async function stopInternal(): Promise<void> {
const portToKill = detectedPort;
if (server) {
2025-12-13 16:34:17 +02:00
try {
server.close();
2025-12-13 16:34:17 +02:00
} catch {
// Ignore close errors
2025-12-13 16:34:17 +02:00
}
server = null;
2025-12-13 16:34:17 +02:00
}
// SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary.
// Kill any process listening on our port to clean up orphaned children.
if (portToKill) {
try {
const lsofOutput = execSync(`lsof -ti:${portToKill} 2>/dev/null || true`, {
encoding: 'utf8',
timeout: 5000
});
const myPid = process.pid;
for (const pidStr of lsofOutput.split(/\s+/)) {
const pid = parseInt(pidStr.trim(), 10);
if (pid && pid !== myPid) {
try {
execSync(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'ignore', timeout: 2000 });
} catch {
// Ignore
}
}
}
} catch {
// Ignore - process may already be dead
}
}
managedApiUrlOverride = null;
detectedPort = null;
version = null;
2025-12-13 16:34:17 +02:00
setStatus('disconnected');
}
async function restartInternal(): Promise<void> {
restartCount += 1;
await stopInternal();
await new Promise(r => setTimeout(r, 250));
await startInternal();
}
async function start(workdir?: string): Promise<void> {
if (pendingOperation) {
await pendingOperation;
if (server) {
return;
}
}
2026-01-16 14:43:53 +02:00
lastStartAttempts = 1;
pendingOperation = startInternal(workdir);
try {
await pendingOperation;
} finally {
pendingOperation = null;
}
}
async function stop(): Promise<void> {
if (pendingOperation) {
await pendingOperation;
}
// Check if already stopped
if (!server) {
return;
}
pendingOperation = stopInternal();
try {
await pendingOperation;
} finally {
pendingOperation = null;
}
}
async function restart(): Promise<void> {
if (pendingOperation) {
await pendingOperation;
}
2026-01-16 14:43:53 +02:00
lastStartAttempts = 1;
pendingOperation = restartInternal();
try {
await pendingOperation;
} finally {
pendingOperation = null;
}
2025-12-13 16:34:17 +02:00
}
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
2026-01-17 22:44:09 +02:00
void newPath;
2026-01-16 14:43:53 +02:00
const workspacePath = workspaceDirectory();
const nextDirectory = workspacePath;
if (workingDirectory === nextDirectory) {
return { success: true, restarted: false, path: nextDirectory };
}
2026-01-06 21:31:04 +02:00
2026-01-16 14:43:53 +02:00
workingDirectory = nextDirectory;
if (useConfiguredUrl && configuredApiUrl) {
2026-01-16 14:43:53 +02:00
return { success: true, restarted: false, path: nextDirectory };
}
2026-01-16 14:43:53 +02:00
return { success: true, restarted: false, path: nextDirectory };
2025-12-13 16:34:17 +02:00
}
return {
start,
stop,
restart,
setWorkingDirectory,
getStatus: () => status,
getApiUrl,
2025-12-13 16:34:17 +02:00
getWorkingDirectory: () => workingDirectory,
isCliAvailable: () => !cliMissing,
getDebugInfo: () => ({
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
status,
lastError,
workingDirectory,
cliAvailable: !cliMissing,
cliPath,
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
configuredPort,
detectedPort,
2026-01-16 14:43:53 +02:00
apiPrefix: '',
apiPrefixDetected: true,
startCount,
restartCount,
lastStartAt,
lastConnectedAt,
lastExitCode,
serverUrl: getApiUrl(),
2026-01-16 14:43:53 +02:00
lastReadyElapsedMs,
lastReadyAttempts,
lastStartAttempts,
version,
}),
2025-12-13 16:34:17 +02:00
onStatusChange(callback) {
listeners.add(callback);
callback(status, lastError);
return new vscode.Disposable(() => listeners.delete(callback));
},
};
}