* perf(vscode): gate API readiness and coalesce duplicate startup reads Bring the VS Code bridge runtime to parity with the web startup optimizations (PR #1650), which were web/desktop-only. waitForApiUrl now hands out the OpenCode API URL only once the manager reports 'connected', instead of as soon as getApiUrl() exposes server.url. The URL is available the moment the process is spawned — before waitForReady confirms it can serve and during a workspace-switch restart (stale port) — so URL-presence alone let the bridge forward to a not-yet-ready OpenCode and surface 502s. Gating on connected status mirrors the web proxy's isOpenCodeReady hold. Also fail fast on 'error' status so a missing CLI doesn't burn the full 30s timeout. Coalesce concurrent identical GET reads (config/path/agents/agent/ project/command) at the bridge proxy so the single OpenCode process serves them once. On cold start the webview's sync bootstrap and config store fire these reads in parallel with no shared dedup; this is the extension-host analog of the runtimeFetch coalescer. Shared reads carry no AbortController so one caller's abort can't strand the others, and the entry clears as soon as it settles (never serves stale). * perf(vscode): fade the startup splash once mounted + connected, not on live fetch The webview's initial-loading overlay held until a successful live /api/config/providers AND /api/agent fetch completed. After the cache hydration work those live reads are the slowest cold-start tail — the UI underneath already paints pickers and the sidebar from cache and refreshes in the background — so gating the splash on them kept it spinning long after the app was usable. Fade the overlay as soon as the UI is mounted and OpenCode is connected. Per-widget loaders convey any remaining background refresh, matching how web/desktop (which have no such splash) already behave. Removes the now -obsolete bootstrapProvidersReady/AgentsReady/Failed tracking and recordBootstrapFetch. Connection error/disconnected splash messages are unchanged. * fix(vscode): include captured OpenCode output in spawn-timeout error When the managed OpenCode server fails to emit its 'listening' line within the start timeout, the error discarded everything the process printed to stdout/stderr — so the status report showed a bare 'Timeout waiting for server to start' with no clue whether the process hung, crashed silently, or printed a config/auth error. The exit path already includes the output; the timeout path now does too (or notes that nothing was printed). * feat(vscode): workspace-grouped session list with working folders, pinning, and archived toggle Replace the flat multi-workspace session list with the grouped project view, using each open VS Code workspace folder as a header (no per-worktree subgroups). This restores native folder and pin support, which the flat list silently dropped, and fixes the clipped left padding on session rows. - Group sessions strictly by open workspace; funnel all non-archived sessions into the workspace's group so they no longer fall into the archived bucket. - Keep the project/group/folder + buttons but make them open a draft in the correct workspace and navigate to chat; hide the project actions (...) menu, which isn't relevant in VS Code. - Force the minimal single-line row layout (the second metadata row is redundant under workspace headers) and drop the per-row tooltip. - Add a show/hide archived toggle next to the archive-all control, since the VS Code header has no display-mode menu. - Size the hover action reveal so the timestamp clears the row buttons.
1128 lines
34 KiB
TypeScript
1128 lines
34 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import * as net from 'net';
|
|
import { execSync } from 'child_process';
|
|
import { spawnSync } from 'child_process';
|
|
import { spawn } from 'child_process';
|
|
import { randomBytes } from 'crypto';
|
|
import { normalizeWindowsDriveLetter } from './pathUtils';
|
|
import { resolveWorkingDirectoryChange } from './workingDirectoryChange';
|
|
|
|
const t = vscode.l10n.t;
|
|
|
|
const READY_CHECK_TIMEOUT_MS = 30000;
|
|
const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
.split(';')
|
|
.map((ext) => ext.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
|
|
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;
|
|
lastReadyElapsedMs: number | null;
|
|
lastReadyAttempts: number | null;
|
|
lastStartAttempts: number | null;
|
|
version: string | null;
|
|
secureConnection: boolean;
|
|
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
|
};
|
|
|
|
export type SetWorkingDirectoryResult =
|
|
| { success: true; path: string }
|
|
| { success: false; error: string };
|
|
|
|
export interface OpenCodeManager {
|
|
start(workdir?: string): Promise<void>;
|
|
stop(): Promise<void>;
|
|
restart(): Promise<void>;
|
|
setWorkingDirectory(path: string): Promise<SetWorkingDirectoryResult>;
|
|
getStatus(): ConnectionStatus;
|
|
getApiUrl(): string | null;
|
|
getOpenCodeAuthHeaders(): Record<string, string>;
|
|
getWorkingDirectory(): string;
|
|
isCliAvailable(): boolean;
|
|
getDebugInfo(): OpenCodeDebugInfo;
|
|
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
|
}
|
|
|
|
function generateSecureOpenCodePassword(): string {
|
|
return randomBytes(32)
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/g, '');
|
|
}
|
|
|
|
function buildOpenCodeAuthHeader(password: string): string {
|
|
return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`;
|
|
}
|
|
|
|
function isValidOpenCodePassword(password: string): boolean {
|
|
return typeof password === 'string' && password.trim().length > 0;
|
|
}
|
|
|
|
function readOpenChamberSettings(): Record<string, unknown> {
|
|
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
|
try {
|
|
const raw = fs.readFileSync(settingsPath, 'utf8');
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
return {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function resolvePortFromUrl(url: string): number | null {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.port ? parseInt(parsed.port, 10) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 shouldUseWindowsShell(binary: string): boolean {
|
|
if (process.platform !== 'win32') return false;
|
|
const trimmed = (binary || '').trim();
|
|
if (!trimmed) return true;
|
|
const ext = path.extname(trimmed).toLowerCase();
|
|
if (ext === '.cmd' || ext === '.bat') return true;
|
|
// Bare command names often resolve to .cmd shims via PATHEXT.
|
|
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
|
|
}
|
|
|
|
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 findExecutableInPath(binaryName: string): string | null {
|
|
const trimmed = (binaryName || '').trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const current = process.env.PATH || '';
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
|
|
const extensions = process.platform === 'win32' ? WINDOWS_EXECUTABLE_EXTENSIONS : [''];
|
|
for (const segment of current.split(path.delimiter)) {
|
|
const dir = segment.trim();
|
|
if (!dir) {
|
|
continue;
|
|
}
|
|
|
|
for (const ext of extensions) {
|
|
const candidate = path.join(dir, process.platform === 'win32' ? `${trimmed}${ext}` : trimmed);
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
let cachedDetectedOpencodeCliPath: string | undefined;
|
|
|
|
function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
|
|
if (typeof raw !== 'string') {
|
|
return null;
|
|
}
|
|
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 {
|
|
// Keep the explicit path so strict startup validation can report it.
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function isMacOpenCodeAppBundlePath(candidate: string): boolean {
|
|
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
|
|
}
|
|
|
|
function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error {
|
|
const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.';
|
|
if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) {
|
|
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`);
|
|
}
|
|
|
|
try {
|
|
const rawStat = fs.statSync(raw);
|
|
if (rawStat.isDirectory()) {
|
|
return new Error(`Configured OpenCode binary directory does not contain an executable ${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}: ${raw}. ${messageSuffix}`);
|
|
}
|
|
} catch {
|
|
// The normalized path check below produces the missing-path error.
|
|
}
|
|
|
|
try {
|
|
const stat = fs.statSync(normalized);
|
|
if (!stat.isFile()) {
|
|
return new Error(`Configured OpenCode binary is not a file: ${normalized}. ${messageSuffix}`);
|
|
}
|
|
return new Error(`Configured OpenCode binary is not executable: ${normalized}. ${messageSuffix}`);
|
|
} catch {
|
|
return new Error(`Configured OpenCode binary not found: ${normalized}. ${messageSuffix}`);
|
|
}
|
|
}
|
|
|
|
function validateConfiguredOpencodeBinaryForManagedStart(): string | null {
|
|
const candidates: string[] = [];
|
|
try {
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const raw = config.get<string>('opencodeBinary') || '';
|
|
if (raw.trim()) {
|
|
candidates.push(raw.trim());
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
const settings = readOpenChamberSettings();
|
|
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
|
|
if (raw) {
|
|
candidates.push(raw);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const raw = candidates[0];
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = normalizeConfiguredOpencodeBinary(raw);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
throw createConfiguredOpencodeBinaryError(raw, normalized);
|
|
}
|
|
|
|
function resolveOpencodeCliPath(): string | null {
|
|
const configured = (() => {
|
|
try {
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
return normalizeConfiguredOpencodeBinary(config.get<string>('opencodeBinary') || '');
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) {
|
|
return configured;
|
|
}
|
|
|
|
const sharedFromOpenChamber = (() => {
|
|
try {
|
|
const settings = readOpenChamberSettings();
|
|
const candidate = settings.opencodeBinary;
|
|
if (typeof candidate !== 'string') {
|
|
return null;
|
|
}
|
|
return normalizeConfiguredOpencodeBinary(candidate);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(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;
|
|
}
|
|
}
|
|
|
|
if (cachedDetectedOpencodeCliPath) {
|
|
if (isExecutable(cachedDetectedOpencodeCliPath)) {
|
|
return cachedDetectedOpencodeCliPath;
|
|
}
|
|
cachedDetectedOpencodeCliPath = undefined;
|
|
}
|
|
|
|
const home = os.homedir();
|
|
const unixFallbacks = [
|
|
path.join(home, '.opencode', 'bin', 'opencode'),
|
|
path.join(home, '.bun', 'bin', 'opencode'),
|
|
path.join(home, '.local', 'bin', 'opencode'),
|
|
'/usr/local/bin/opencode',
|
|
'/opt/homebrew/bin/opencode',
|
|
path.join(home, 'bin', 'opencode'),
|
|
];
|
|
|
|
const winFallbacks = (() => {
|
|
const userProfile = process.env.USERPROFILE || home;
|
|
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
|
|
const localAppData = process.env.LOCALAPPDATA || '';
|
|
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
|
const npmDir = path.join(appData, 'npm');
|
|
|
|
return [
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
|
|
path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
path.join(npmDir, 'opencode.exe'),
|
|
path.join(npmDir, 'opencode.cmd'),
|
|
path.join(npmDir, 'opencode.bat'),
|
|
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);
|
|
})();
|
|
|
|
if (process.platform !== 'win32') {
|
|
const fromPath = findExecutableInPath('opencode');
|
|
if (fromPath) {
|
|
cachedDetectedOpencodeCliPath = fromPath;
|
|
return fromPath;
|
|
}
|
|
}
|
|
|
|
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
|
for (const candidate of fallbacks) {
|
|
if (isExecutable(candidate)) {
|
|
cachedDetectedOpencodeCliPath = candidate;
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
const fromPath = findExecutableInPath('opencode');
|
|
if (fromPath) {
|
|
cachedDetectedOpencodeCliPath = fromPath;
|
|
return fromPath;
|
|
}
|
|
|
|
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) {
|
|
cachedDetectedOpencodeCliPath = found;
|
|
return found;
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|
|
|
|
let cachedLoginShellEnvSnapshot: Record<string, string> | null | undefined;
|
|
|
|
function parseNullSeparatedEnvSnapshot(raw: string): Record<string, string> | null {
|
|
if (typeof raw !== 'string' || raw.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const result: Record<string, string> = {};
|
|
const entries = raw.split('\0');
|
|
for (const entry of entries) {
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
const idx = entry.indexOf('=');
|
|
if (idx <= 0) {
|
|
continue;
|
|
}
|
|
const key = entry.slice(0, idx);
|
|
const value = entry.slice(idx + 1);
|
|
result[key] = value;
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : null;
|
|
}
|
|
|
|
function getWindowsShellEnvSnapshot(): Record<string, string> | null {
|
|
const parseResult = (stdout: string | null | undefined) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : '');
|
|
|
|
const psScript =
|
|
"Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }";
|
|
|
|
const powershellCandidates = [
|
|
'pwsh.exe',
|
|
'powershell.exe',
|
|
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
];
|
|
|
|
for (const shellPath of powershellCandidates) {
|
|
try {
|
|
const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
if (result.status !== 0) {
|
|
continue;
|
|
}
|
|
const parsed = parseResult(result.stdout);
|
|
if (parsed) {
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
try {
|
|
const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
|
|
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getLoginShellEnvSnapshot(): Record<string, string> | null {
|
|
if (cachedLoginShellEnvSnapshot !== undefined) {
|
|
return cachedLoginShellEnvSnapshot;
|
|
}
|
|
|
|
// Avoid interactive POSIX login shells in the extension host.
|
|
if (process.platform !== 'win32') {
|
|
cachedLoginShellEnvSnapshot = null;
|
|
return null;
|
|
}
|
|
|
|
const windowsSnapshot = getWindowsShellEnvSnapshot();
|
|
cachedLoginShellEnvSnapshot = windowsSnapshot;
|
|
return windowsSnapshot;
|
|
}
|
|
|
|
function mergePathValues(preferred: string, fallback: string): string {
|
|
const merged = new Set<string>();
|
|
const addSegments = (value: string) => {
|
|
if (typeof value !== 'string' || !value) {
|
|
return;
|
|
}
|
|
for (const segment of value.split(path.delimiter)) {
|
|
if (segment) {
|
|
merged.add(segment);
|
|
}
|
|
}
|
|
};
|
|
|
|
addSegments(preferred);
|
|
addSegments(fallback);
|
|
return Array.from(merged).join(path.delimiter);
|
|
}
|
|
|
|
function applyLoginShellEnvSnapshot() {
|
|
const snapshot = getLoginShellEnvSnapshot();
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
|
|
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
|
|
for (const [key, value] of Object.entries(snapshot)) {
|
|
if (skipKeys.has(key)) {
|
|
continue;
|
|
}
|
|
const existing = process.env[key];
|
|
if (typeof existing === 'string' && existing.length > 0) {
|
|
continue;
|
|
}
|
|
process.env[key] = value;
|
|
}
|
|
|
|
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
|
|
}
|
|
|
|
async function waitForReady(
|
|
serverUrl: string,
|
|
timeoutMs = 15000,
|
|
authHeaders: Record<string, string> = {}
|
|
): Promise<ReadyResult> {
|
|
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
|
const start = Date.now();
|
|
const candidates = getCandidateBaseUrls(serverUrl);
|
|
let attempts = 0;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
for (const baseUrl of candidates) {
|
|
attempts += 1;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
|
|
// OpenCode readiness check. Use /global/health for OpenCode 1.15.x compatibility.
|
|
const url = new URL(`${baseUrl}/global/health`);
|
|
const res = await fetch(url.toString(), {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json', ...authHeaders },
|
|
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 };
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
return { ok: false, elapsedMs: Date.now() - start, attempts, version: null };
|
|
}
|
|
|
|
async function spawnManagedOpenCodeServer(
|
|
workingDirectory: string,
|
|
port: number,
|
|
timeoutMs: number
|
|
): Promise<{ url: string; close: () => void }> {
|
|
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
|
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
|
|
const child = spawn(binary, args, {
|
|
cwd: workingDirectory,
|
|
env: { ...process.env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
shell: shouldUseWindowsShell(binary),
|
|
});
|
|
|
|
const url = await new Promise<string>((resolve, reject) => {
|
|
let output = '';
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
child.stdout?.off('data', onStdout);
|
|
child.stderr?.off('data', onStderr);
|
|
child.off('exit', onExit);
|
|
child.off('error', onError);
|
|
};
|
|
|
|
const onStdout = (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
const lines = output.split('\n');
|
|
for (const line of lines) {
|
|
if (!line.startsWith('opencode server listening')) continue;
|
|
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
|
if (!match) {
|
|
cleanup();
|
|
reject(new Error(`Failed to parse server url from output: ${line}`));
|
|
return;
|
|
}
|
|
cleanup();
|
|
resolve(match[1]);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const onStderr = (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
};
|
|
|
|
const onExit = (code: number | null) => {
|
|
cleanup();
|
|
const appBundleHint = isMacOpenCodeAppBundlePath(binary)
|
|
? ' The configured binary appears to point at the macOS desktop app bundle; OpenChamber needs the standalone opencode CLI.'
|
|
: '';
|
|
reject(new Error(`OpenCode process exited before serving with code ${code}. Binary used: ${binary}.${appBundleHint} Output: ${output}`));
|
|
};
|
|
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
// Surface whatever OpenCode printed while we waited — otherwise a hung or
|
|
// misconfigured start is indistinguishable from a slow one in the status
|
|
// report, leaving no thread to pull on.
|
|
const trimmedOutput = output.trim();
|
|
const outputHint = trimmedOutput ? ` Output: ${trimmedOutput}` : ' Output: (none — process printed nothing)';
|
|
reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms.${outputHint}`));
|
|
}, timeoutMs);
|
|
|
|
child.stdout?.on('data', onStdout);
|
|
child.stderr?.on('data', onStderr);
|
|
child.on('exit', onExit);
|
|
child.on('error', onError);
|
|
});
|
|
|
|
return {
|
|
url,
|
|
close: () => {
|
|
try {
|
|
child.kill('SIGTERM');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function allocateManagedOpenCodePort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
|
|
server.once('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
server.once('listening', () => {
|
|
const address = server.address();
|
|
const port = address && typeof address === 'object' ? address.port : 0;
|
|
server.close(() => {
|
|
if (port > 0) {
|
|
resolve(port);
|
|
return;
|
|
}
|
|
reject(new Error('Failed to allocate OpenCode port'));
|
|
});
|
|
});
|
|
|
|
server.listen(0, '127.0.0.1');
|
|
});
|
|
}
|
|
|
|
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
|
|
let server: { url: string; close: () => void } | null = null;
|
|
let managedApiUrlOverride: string | null = null;
|
|
let managedPassword: string | null = null;
|
|
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
|
|
const userProvidedEnvPassword = (() => {
|
|
const normalized = (process.env.OPENCODE_SERVER_PASSWORD || '').trim();
|
|
return isValidOpenCodePassword(normalized) ? normalized : null;
|
|
})();
|
|
let status: ConnectionStatus = 'disconnected';
|
|
let lastError: string | undefined;
|
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
|
const workspaceDirectory = (): string =>
|
|
normalizeWindowsDriveLetter(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir());
|
|
const serverWorkingDirectory = (): string => normalizeWindowsDriveLetter(context.globalStorageUri.fsPath);
|
|
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;
|
|
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;
|
|
|
|
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) {
|
|
try {
|
|
const parsed = new URL(configuredApiUrl);
|
|
if (parsed.port) {
|
|
configuredPort = parseInt(parsed.port, 10);
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
}
|
|
|
|
const 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));
|
|
}
|
|
};
|
|
|
|
const getApiUrl = (): string | null => {
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
return configuredApiUrl.replace(/\/+$/, '');
|
|
}
|
|
if (managedApiUrlOverride) {
|
|
return managedApiUrlOverride.replace(/\/+$/, '');
|
|
}
|
|
if (server?.url) {
|
|
return server.url.replace(/\/+$/, '');
|
|
}
|
|
if (detectedPort) {
|
|
return `http://127.0.0.1:${detectedPort}`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getOpenCodeAuthHeaders = (): Record<string, string> => {
|
|
const password = (managedPassword || userProvidedEnvPassword || process.env.OPENCODE_SERVER_PASSWORD || '').trim();
|
|
if (!password) {
|
|
return {};
|
|
}
|
|
return { Authorization: buildOpenCodeAuthHeader(password) };
|
|
};
|
|
|
|
const setManagedPasswordState = (
|
|
password: string,
|
|
source: 'user-env' | 'generated' | 'rotated'
|
|
): string => {
|
|
const normalized = password.trim();
|
|
managedPassword = normalized;
|
|
managedPasswordSource = source;
|
|
process.env.OPENCODE_SERVER_PASSWORD = normalized;
|
|
return normalized;
|
|
};
|
|
|
|
const ensureManagedOpenCodeServerPassword = async ({ rotateManaged = false }: { rotateManaged?: boolean } = {}): Promise<string> => {
|
|
if (userProvidedEnvPassword) {
|
|
return setManagedPasswordState(userProvidedEnvPassword, 'user-env');
|
|
}
|
|
|
|
if (rotateManaged) {
|
|
return setManagedPasswordState(generateSecureOpenCodePassword(), 'rotated');
|
|
}
|
|
|
|
if (managedPassword && isValidOpenCodePassword(managedPassword)) {
|
|
return setManagedPasswordState(
|
|
managedPassword,
|
|
managedPasswordSource || 'generated'
|
|
);
|
|
}
|
|
|
|
return setManagedPasswordState(generateSecureOpenCodePassword(), 'generated');
|
|
};
|
|
|
|
async function startInternal(
|
|
workdir?: string,
|
|
options: { rotateManaged?: boolean } = {}
|
|
): Promise<void> {
|
|
startCount += 1;
|
|
setStatus('connecting');
|
|
lastStartAt = Date.now();
|
|
lastStartAttempts = startCount;
|
|
|
|
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
|
workingDirectory = normalizeWindowsDriveLetter(workdir.trim());
|
|
} else {
|
|
workingDirectory = workspaceDirectory();
|
|
}
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
setStatus('connecting');
|
|
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;
|
|
|
|
try {
|
|
applyLoginShellEnvSnapshot();
|
|
|
|
const configuredCli = validateConfiguredOpencodeBinaryForManagedStart();
|
|
if (configuredCli) {
|
|
cliPath = configuredCli;
|
|
appendToPath(path.dirname(configuredCli));
|
|
process.env.OPENCODE_BINARY = configuredCli;
|
|
}
|
|
|
|
// Best-effort: locate CLI even when VS Code PATH is stale.
|
|
const resolvedCli = configuredCli || resolveOpencodeCliPath();
|
|
if (resolvedCli) {
|
|
cliPath = resolvedCli;
|
|
appendToPath(path.dirname(resolvedCli));
|
|
process.env.OPENCODE_BINARY = resolvedCli;
|
|
}
|
|
|
|
const password = await ensureManagedOpenCodeServerPassword({
|
|
rotateManaged: options.rotateManaged === true,
|
|
});
|
|
process.env.OPENCODE_SERVER_PASSWORD = password;
|
|
|
|
// Match the web runtime: keep the server process in a neutral cwd and pass
|
|
// the selected workspace through explicit `directory` API parameters.
|
|
const serverCwd = serverWorkingDirectory();
|
|
const originalCwd = process.cwd();
|
|
try {
|
|
fs.mkdirSync(serverCwd, { recursive: true });
|
|
process.chdir(serverCwd);
|
|
const port = await allocateManagedOpenCodePort();
|
|
server = await spawnManagedOpenCodeServer(serverCwd, port, READY_CHECK_TIMEOUT_MS);
|
|
} 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, getOpenCodeAuthHeaders());
|
|
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');
|
|
}
|
|
} else {
|
|
throw new Error('Server started but URL is missing');
|
|
}
|
|
} 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();
|
|
}
|
|
const moreInfoLabel = t('More Info');
|
|
setStatus('error', t('OpenCode CLI not found. Install it and ensure it\'s in PATH.'));
|
|
vscode.window.showErrorMessage(
|
|
t('OpenCode CLI not found. Please install it and ensure it\'s in PATH.'),
|
|
moreInfoLabel
|
|
).then(selection => {
|
|
if (selection === moreInfoLabel) {
|
|
vscode.env.openExternal(vscode.Uri.parse('https://github.com/anomalyco/opencode'));
|
|
}
|
|
});
|
|
} else {
|
|
setStatus('error', t('Failed to start OpenCode: {0}', message));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stopInternal(): Promise<void> {
|
|
const portToKill = detectedPort;
|
|
|
|
if (server) {
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// Ignore close errors
|
|
}
|
|
server = null;
|
|
}
|
|
|
|
// 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;
|
|
setStatus('disconnected');
|
|
}
|
|
|
|
async function restartInternal(): Promise<void> {
|
|
restartCount += 1;
|
|
const restartDirectory = workingDirectory;
|
|
await stopInternal();
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await startInternal(restartDirectory, { rotateManaged: true });
|
|
}
|
|
|
|
async function start(workdir?: string): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
if (server) {
|
|
return;
|
|
}
|
|
}
|
|
lastStartAttempts = 1;
|
|
pendingOperation = startInternal(workdir, { rotateManaged: true });
|
|
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;
|
|
}
|
|
lastStartAttempts = 1;
|
|
pendingOperation = restartInternal();
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function setWorkingDirectory(newPath: string): Promise<SetWorkingDirectoryResult> {
|
|
const trimmed = newPath.trim();
|
|
if (!trimmed) {
|
|
return { success: false, error: 'path not found' };
|
|
}
|
|
|
|
let stat;
|
|
try {
|
|
stat = await fs.promises.stat(trimmed);
|
|
} catch {
|
|
return { success: false, error: 'path not found' };
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
return { success: false, error: 'path not found' };
|
|
}
|
|
|
|
const change = resolveWorkingDirectoryChange(workingDirectory, trimmed);
|
|
if (!change.changed) {
|
|
return { success: true, path: change.path };
|
|
}
|
|
|
|
workingDirectory = change.path;
|
|
return { success: true, path: change.path };
|
|
}
|
|
|
|
return {
|
|
start,
|
|
stop,
|
|
restart,
|
|
setWorkingDirectory,
|
|
getStatus: () => status,
|
|
getApiUrl,
|
|
getOpenCodeAuthHeaders,
|
|
getWorkingDirectory: () => workingDirectory,
|
|
isCliAvailable: () => !cliMissing || Boolean(cliPath || resolveOpencodeCliPath()),
|
|
getDebugInfo: () => {
|
|
const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization);
|
|
const detectedCliPath = cliPath || resolveOpencodeCliPath();
|
|
return {
|
|
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
|
status,
|
|
lastError,
|
|
workingDirectory,
|
|
cliAvailable: !cliMissing || Boolean(detectedCliPath),
|
|
cliPath: detectedCliPath,
|
|
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
|
configuredPort,
|
|
detectedPort,
|
|
apiPrefix: '',
|
|
apiPrefixDetected: true,
|
|
startCount,
|
|
restartCount,
|
|
lastStartAt,
|
|
lastConnectedAt,
|
|
lastExitCode,
|
|
serverUrl: getApiUrl(),
|
|
lastReadyElapsedMs,
|
|
lastReadyAttempts,
|
|
lastStartAttempts,
|
|
version,
|
|
secureConnection,
|
|
authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null),
|
|
};
|
|
},
|
|
onStatusChange(callback) {
|
|
listeners.add(callback);
|
|
callback(status, lastError);
|
|
return new vscode.Disposable(() => listeners.delete(callback));
|
|
},
|
|
};
|
|
}
|