2025-12-13 16:34:17 +02:00
|
|
|
import * as vscode from 'vscode';
|
|
|
|
|
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
import * as path from 'path';
|
|
|
|
|
import * as os from 'os';
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Optimized timeouts for faster startup
|
|
|
|
|
const PORT_DETECTION_TIMEOUT_MS = 10000;
|
|
|
|
|
const READY_CHECK_TIMEOUT_MS = 12000;
|
|
|
|
|
const READY_CHECK_INTERVAL_MS = 100; // Fast polling during startup
|
|
|
|
|
const HEALTH_CHECK_INTERVAL_MS = 5000;
|
|
|
|
|
const SHUTDOWN_TIMEOUT_MS = 3000;
|
|
|
|
|
|
|
|
|
|
// Regex to detect port from CLI output (matches desktop pattern)
|
2025-12-24 22:50:46 +02:00
|
|
|
const URL_REGEX = /https?:\/\/[^:\s]+:(\d+)(\/[^\s"']*)?/gi;
|
2025-12-24 03:31:07 +02:00
|
|
|
const FALLBACK_PORT_REGEX = /(?:^|\s)(?:127\.0\.0\.1|localhost):(\d+)/i;
|
2025-12-13 16:34:17 +02:00
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
const API_PREFIX_CANDIDATES = ['', '/api'] as const;
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const BIN_CANDIDATES = [
|
|
|
|
|
process.env.OPENCHAMBER_OPENCODE_PATH,
|
|
|
|
|
process.env.OPENCHAMBER_OPENCODE_BIN,
|
|
|
|
|
process.env.OPENCODE_PATH,
|
|
|
|
|
process.env.OPENCODE_BINARY,
|
|
|
|
|
'/opt/homebrew/bin/opencode',
|
|
|
|
|
'/usr/local/bin/opencode',
|
|
|
|
|
'/usr/bin/opencode',
|
|
|
|
|
path.join(os.homedir(), '.local/bin/opencode'),
|
2025-12-24 03:31:07 +02:00
|
|
|
path.join(os.homedir(), '.opencode/bin/opencode'),
|
2025-12-13 16:34:17 +02:00
|
|
|
].filter(Boolean) as string[];
|
|
|
|
|
|
|
|
|
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
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;
|
2025-12-24 03:31:07 +02:00
|
|
|
getApiUrl(): string | null;
|
2025-12-13 16:34:17 +02:00
|
|
|
getWorkingDirectory(): string;
|
2025-12-24 03:31:07 +02:00
|
|
|
isCliAvailable(): boolean;
|
2025-12-24 22:50:46 +02:00
|
|
|
getDebugInfo(): OpenCodeDebugInfo;
|
2025-12-13 16:34:17 +02:00
|
|
|
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isExecutable(filePath: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
|
|
|
return fs.statSync(filePath).isFile();
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
function getLoginShellPath(): string | null {
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shell = process.env.SHELL || '/bin/zsh';
|
2025-12-26 01:44:33 +02:00
|
|
|
const shellName = path.basename(shell);
|
|
|
|
|
|
|
|
|
|
// Nushell requires different flag syntax and PATH access
|
|
|
|
|
const isNushell = shellName === 'nu' || shellName === 'nushell';
|
|
|
|
|
const args = isNushell
|
|
|
|
|
? ['-l', '-i', '-c', '$env.PATH | str join (char esep)']
|
|
|
|
|
: ['-lic', 'echo -n "$PATH"'];
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
try {
|
2025-12-26 01:44:33 +02:00
|
|
|
const result = spawnSync(shell, args, {
|
2025-12-24 03:31:07 +02:00
|
|
|
encoding: 'utf8',
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
if (result.status === 0 && typeof result.stdout === 'string') {
|
|
|
|
|
const value = result.stdout.trim();
|
|
|
|
|
if (value) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAugmentedPath(): string {
|
|
|
|
|
const augmented = new Set<string>();
|
|
|
|
|
|
|
|
|
|
const loginPath = getLoginShellPath();
|
|
|
|
|
if (loginPath) {
|
|
|
|
|
for (const segment of loginPath.split(path.delimiter)) {
|
|
|
|
|
if (segment) {
|
|
|
|
|
augmented.add(segment);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
|
|
|
for (const segment of current) {
|
|
|
|
|
augmented.add(segment);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(augmented).join(path.delimiter);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
function resolveCliPath(): string | null {
|
2025-12-24 03:31:07 +02:00
|
|
|
// First check explicit candidates
|
2025-12-13 16:34:17 +02:00
|
|
|
for (const candidate of BIN_CANDIDATES) {
|
|
|
|
|
if (candidate && isExecutable(candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Then search in augmented PATH
|
|
|
|
|
const augmentedPath = buildAugmentedPath();
|
|
|
|
|
for (const segment of augmentedPath.split(path.delimiter)) {
|
2025-12-13 16:34:17 +02:00
|
|
|
const candidate = path.join(segment, 'opencode');
|
|
|
|
|
if (isExecutable(candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Fallback: try login shell detection
|
2025-12-13 16:34:17 +02:00
|
|
|
if (process.platform !== 'win32') {
|
|
|
|
|
const shellCandidates = [
|
|
|
|
|
process.env.SHELL,
|
|
|
|
|
'/bin/bash',
|
|
|
|
|
'/bin/zsh',
|
|
|
|
|
'/bin/sh',
|
|
|
|
|
].filter(Boolean) as string[];
|
|
|
|
|
|
|
|
|
|
for (const shellPath of shellCandidates) {
|
|
|
|
|
if (!isExecutable(shellPath)) continue;
|
|
|
|
|
try {
|
2025-12-26 01:44:33 +02:00
|
|
|
const shellName = path.basename(shellPath);
|
|
|
|
|
const isNushell = shellName === 'nu' || shellName === 'nushell';
|
|
|
|
|
const args = isNushell
|
|
|
|
|
? ['-l', '-i', '-c', 'which opencode']
|
|
|
|
|
: ['-lic', 'command -v opencode'];
|
|
|
|
|
|
|
|
|
|
const result = spawnSync(shellPath, args, {
|
2025-12-13 16:34:17 +02:00
|
|
|
encoding: 'utf8',
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
if (result.status === 0) {
|
|
|
|
|
const candidate = result.stdout.trim().split(/\s+/).pop();
|
|
|
|
|
if (candidate && isExecutable(candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
2025-12-13 16:34:17 +02:00
|
|
|
try {
|
|
|
|
|
const controller = new AbortController();
|
2025-12-24 03:31:07 +02:00
|
|
|
const timeoutMs = quick ? 1500 : 3000;
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
const normalized = apiUrl.replace(/\/+$/, '');
|
|
|
|
|
const candidates: string[] = [`${normalized}/config`];
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
for (const target of candidates) {
|
|
|
|
|
try {
|
2025-12-24 22:50:46 +02:00
|
|
|
const response = await fetch(target, {
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
});
|
2025-12-13 16:34:17 +02:00
|
|
|
if (response.ok) {
|
2025-12-24 22:50:46 +02:00
|
|
|
clearTimeout(timeout);
|
|
|
|
|
return true;
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
} catch {
|
2025-12-24 03:31:07 +02:00
|
|
|
// try next
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
clearTimeout(timeout);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
|
|
|
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
2025-12-13 16:34:17 +02:00
|
|
|
let childProcess: ChildProcess | null = null;
|
|
|
|
|
let status: ConnectionStatus = 'disconnected';
|
|
|
|
|
let healthCheckInterval: NodeJS.Timeout | null = null;
|
|
|
|
|
let lastError: string | undefined;
|
|
|
|
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
|
|
|
|
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
2025-12-24 22:50:46 +02:00
|
|
|
let startCount = 0;
|
|
|
|
|
let restartCount = 0;
|
|
|
|
|
let lastStartAt: number | null = null;
|
|
|
|
|
let lastConnectedAt: number | null = null;
|
|
|
|
|
let lastExitCode: number | null = null;
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Port detection state (like desktop)
|
|
|
|
|
let detectedPort: number | null = null;
|
|
|
|
|
let portWaiters: Array<(port: number) => void> = [];
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
// OpenCode API prefix detection (some versions serve under /api)
|
|
|
|
|
let apiPrefix: string = '';
|
|
|
|
|
let apiPrefixDetected = false;
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Check if user configured a specific API URL
|
2025-12-13 16:34:17 +02:00
|
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
|
|
|
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
2025-12-24 03:31:07 +02:00
|
|
|
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Parse configured URL to extract port if specified
|
|
|
|
|
let configuredPort: number | null = null;
|
|
|
|
|
if (useConfiguredUrl) {
|
2025-12-13 16:34:17 +02:00
|
|
|
try {
|
2025-12-24 03:31:07 +02:00
|
|
|
const parsed = new URL(configuredApiUrl);
|
|
|
|
|
if (parsed.port) {
|
|
|
|
|
configuredPort = parseInt(parsed.port, 10);
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
} catch {
|
2025-12-24 03:31:07 +02:00
|
|
|
// Invalid URL, will use dynamic port
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
2025-12-24 03:31:07 +02:00
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
const cliPath = resolveCliPath();
|
|
|
|
|
const cliAvailable = cliPath !== null;
|
2025-12-13 16:34:17 +02:00
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
|
|
|
|
if (status !== newStatus || lastError !== error) {
|
|
|
|
|
status = newStatus;
|
|
|
|
|
lastError = error;
|
2025-12-24 22:50:46 +02:00
|
|
|
if (newStatus === 'connected') {
|
|
|
|
|
lastConnectedAt = Date.now();
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
listeners.forEach(cb => cb(status, error));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
function setDetectedPort(port: number) {
|
|
|
|
|
if (detectedPort !== port) {
|
|
|
|
|
detectedPort = port;
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Notify all waiters
|
|
|
|
|
const waiters = portWaiters;
|
|
|
|
|
portWaiters = [];
|
|
|
|
|
for (const notify of waiters) {
|
|
|
|
|
try {
|
|
|
|
|
notify(port);
|
2025-12-25 03:33:24 +02:00
|
|
|
} catch {
|
|
|
|
|
// Ignore waiter errors
|
2025-12-24 03:31:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectPortFromOutput(text: string) {
|
|
|
|
|
// Match URL pattern first (like desktop)
|
|
|
|
|
URL_REGEX.lastIndex = 0;
|
|
|
|
|
let match;
|
|
|
|
|
while ((match = URL_REGEX.exec(text)) !== null) {
|
|
|
|
|
const port = parseInt(match[1], 10);
|
|
|
|
|
if (Number.isFinite(port) && port > 0) {
|
|
|
|
|
setDetectedPort(port);
|
2025-12-24 22:50:46 +02:00
|
|
|
const inferred = inferPrefixFromLogPath(match[2] || '');
|
|
|
|
|
if (inferred !== null) {
|
|
|
|
|
setDetectedApiPrefix(inferred);
|
|
|
|
|
}
|
2025-12-24 03:31:07 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback pattern
|
|
|
|
|
const fallbackMatch = FALLBACK_PORT_REGEX.exec(text);
|
|
|
|
|
if (fallbackMatch) {
|
|
|
|
|
const port = parseInt(fallbackMatch[1], 10);
|
|
|
|
|
if (Number.isFinite(port) && port > 0) {
|
|
|
|
|
setDetectedPort(port);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForPort(timeoutMs: number): Promise<number> {
|
|
|
|
|
if (detectedPort !== null) {
|
|
|
|
|
return detectedPort;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const onPortDetected = (port: number) => {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
resolve(port);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
portWaiters = portWaiters.filter(cb => cb !== onPortDetected);
|
|
|
|
|
reject(new Error('Timed out waiting for OpenCode port detection'));
|
|
|
|
|
}, timeoutMs);
|
|
|
|
|
|
|
|
|
|
portWaiters.push(onPortDetected);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForReady(apiUrl: string, timeoutMs: number): Promise<boolean> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
// Use quick health check during startup for faster response
|
|
|
|
|
if (await checkHealth(apiUrl, true)) {
|
2025-12-13 16:34:17 +02:00
|
|
|
return true;
|
|
|
|
|
}
|
2025-12-24 03:31:07 +02:00
|
|
|
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
function getApiUrl(): string | null {
|
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
|
|
|
return configuredApiUrl.replace(/\/+$/, '');
|
|
|
|
|
}
|
|
|
|
|
if (detectedPort !== null) {
|
2025-12-24 22:50:46 +02:00
|
|
|
return buildApiBaseUrlFromPort(detectedPort);
|
2025-12-24 03:31:07 +02:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
function startHealthCheck() {
|
|
|
|
|
stopHealthCheck();
|
|
|
|
|
healthCheckInterval = setInterval(async () => {
|
2025-12-24 03:31:07 +02:00
|
|
|
const apiUrl = getApiUrl();
|
|
|
|
|
if (!apiUrl) {
|
|
|
|
|
if (status === 'connected') {
|
|
|
|
|
setStatus('disconnected');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const healthy = await checkHealth(apiUrl);
|
|
|
|
|
if (healthy && status !== 'connected') {
|
|
|
|
|
setStatus('connected');
|
|
|
|
|
} else if (!healthy && status === 'connected') {
|
|
|
|
|
setStatus('disconnected');
|
|
|
|
|
}
|
2025-12-24 03:31:07 +02:00
|
|
|
}, HEALTH_CHECK_INTERVAL_MS);
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopHealthCheck() {
|
|
|
|
|
if (healthCheckInterval) {
|
|
|
|
|
clearInterval(healthCheckInterval);
|
|
|
|
|
healthCheckInterval = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
async function start(workdir?: string): Promise<void> {
|
2025-12-24 22:50:46 +02:00
|
|
|
startCount += 1;
|
|
|
|
|
lastStartAt = Date.now();
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
|
|
|
|
workingDirectory = workdir.trim();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 22:50:46 +02:00
|
|
|
// If user configured an external API URL, do NOT start a local CLI instance.
|
2025-12-24 03:31:07 +02:00
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
|
|
|
setStatus('connecting');
|
|
|
|
|
const healthy = await checkHealth(configuredApiUrl);
|
|
|
|
|
if (healthy) {
|
|
|
|
|
setStatus('connected');
|
|
|
|
|
startHealthCheck();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-24 22:50:46 +02:00
|
|
|
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding.`);
|
|
|
|
|
return;
|
2025-12-24 03:31:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for existing running instance (only if port is known)
|
|
|
|
|
const currentUrl = getApiUrl();
|
|
|
|
|
if (currentUrl && await checkHealth(currentUrl)) {
|
2025-12-13 16:34:17 +02:00
|
|
|
setStatus('connected');
|
|
|
|
|
startHealthCheck();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
if (!cliAvailable) {
|
2025-12-13 16:34:17 +02:00
|
|
|
setStatus('error', 'OpenCode CLI not found. Install it or set OPENCODE_BINARY env var.');
|
|
|
|
|
vscode.window.showErrorMessage(
|
|
|
|
|
'OpenCode CLI not found. Please install it or set the OPENCODE_BINARY environment variable.',
|
|
|
|
|
'More Info'
|
|
|
|
|
).then(selection => {
|
|
|
|
|
if (selection === 'More Info') {
|
|
|
|
|
vscode.env.openExternal(vscode.Uri.parse('https://github.com/opencode-ai/opencode'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
setStatus('connecting');
|
|
|
|
|
|
|
|
|
|
// Reset port detection for fresh start
|
|
|
|
|
detectedPort = null;
|
2025-12-24 22:50:46 +02:00
|
|
|
apiPrefix = '';
|
|
|
|
|
apiPrefixDetected = false;
|
|
|
|
|
lastExitCode = null;
|
2025-12-24 03:31:07 +02:00
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
2025-12-26 02:29:00 +02:00
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Use port 0 for dynamic assignment unless user configured a specific port
|
|
|
|
|
const portArg = configuredPort !== null ? configuredPort.toString() : '0';
|
2025-12-13 16:34:17 +02:00
|
|
|
|
|
|
|
|
try {
|
2025-12-24 03:31:07 +02:00
|
|
|
const augmentedEnv = {
|
|
|
|
|
...process.env,
|
|
|
|
|
PATH: buildAugmentedPath(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
childProcess = spawn(cliPath!, ['serve', '--port', portArg], {
|
2025-12-13 16:34:17 +02:00
|
|
|
cwd: spawnCwd,
|
2025-12-24 03:31:07 +02:00
|
|
|
env: augmentedEnv,
|
2025-12-13 16:34:17 +02:00
|
|
|
detached: false,
|
|
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
childProcess.stdout?.on('data', (data) => {
|
2025-12-24 03:31:07 +02:00
|
|
|
const text = data.toString();
|
|
|
|
|
detectPortFromOutput(text);
|
2025-12-24 22:50:46 +02:00
|
|
|
detectApiPrefixFromOutput(text);
|
2025-12-13 16:34:17 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
childProcess.stderr?.on('data', (data) => {
|
2025-12-24 03:31:07 +02:00
|
|
|
const text = data.toString();
|
|
|
|
|
detectPortFromOutput(text);
|
2025-12-24 22:50:46 +02:00
|
|
|
detectApiPrefixFromOutput(text);
|
2025-12-13 16:34:17 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
childProcess.on('error', (err) => {
|
|
|
|
|
setStatus('error', `Failed to start OpenCode: ${err.message}`);
|
|
|
|
|
childProcess = null;
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
childProcess.on('exit', (code) => {
|
2025-12-13 16:34:17 +02:00
|
|
|
if (status !== 'disconnected') {
|
2025-12-24 03:31:07 +02:00
|
|
|
setStatus('disconnected', code !== 0 ? `OpenCode exited with code ${code}` : undefined);
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
childProcess = null;
|
2025-12-24 03:31:07 +02:00
|
|
|
detectedPort = null;
|
2025-12-24 22:50:46 +02:00
|
|
|
lastExitCode = typeof code === 'number' ? code : null;
|
2025-12-13 16:34:17 +02:00
|
|
|
});
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
// Wait for port detection (port comes from stdout/stderr)
|
|
|
|
|
try {
|
|
|
|
|
await waitForPort(PORT_DETECTION_TIMEOUT_MS);
|
|
|
|
|
} catch {
|
|
|
|
|
setStatus('error', 'OpenCode did not report port in time');
|
|
|
|
|
await stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now wait for API to be ready
|
2025-12-24 22:50:46 +02:00
|
|
|
const detected = detectedPort;
|
|
|
|
|
if (detected !== null && !apiPrefixDetected) {
|
|
|
|
|
await detectApiPrefix(detected);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
const apiUrl = getApiUrl();
|
|
|
|
|
if (!apiUrl) {
|
|
|
|
|
setStatus('error', 'Failed to determine OpenCode API URL');
|
|
|
|
|
await stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ready = await waitForReady(apiUrl, READY_CHECK_TIMEOUT_MS);
|
|
|
|
|
if (ready) {
|
2025-12-13 16:34:17 +02:00
|
|
|
setStatus('connected');
|
|
|
|
|
startHealthCheck();
|
|
|
|
|
} else {
|
2025-12-24 03:31:07 +02:00
|
|
|
setStatus('error', 'OpenCode API did not become ready in time');
|
2025-12-13 16:34:17 +02:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
setStatus('error', `Failed to start OpenCode: ${message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
async function stop(): Promise<void> {
|
2025-12-13 16:34:17 +02:00
|
|
|
stopHealthCheck();
|
|
|
|
|
|
|
|
|
|
if (childProcess) {
|
|
|
|
|
try {
|
|
|
|
|
childProcess.kill('SIGTERM');
|
2025-12-24 03:31:07 +02:00
|
|
|
// Wait for graceful shutdown
|
|
|
|
|
await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT_MS));
|
2025-12-13 16:34:17 +02:00
|
|
|
if (childProcess && !childProcess.killed && childProcess.exitCode === null) {
|
|
|
|
|
childProcess.kill('SIGKILL');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
childProcess = null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
detectedPort = null;
|
2025-12-13 16:34:17 +02:00
|
|
|
setStatus('disconnected');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
async function restart(): Promise<void> {
|
2025-12-24 22:50:46 +02:00
|
|
|
restartCount += 1;
|
2025-12-13 16:34:17 +02:00
|
|
|
await stop();
|
2025-12-24 03:31:07 +02:00
|
|
|
// Brief delay to let OS release resources
|
|
|
|
|
await new Promise(r => setTimeout(r, 250));
|
2025-12-13 16:34:17 +02:00
|
|
|
await start();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-24 03:31:07 +02:00
|
|
|
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
|
|
|
|
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
|
2025-12-24 22:50:46 +02:00
|
|
|
if (target === workingDirectory) {
|
|
|
|
|
return { success: true, restarted: false, path: target };
|
|
|
|
|
}
|
2025-12-13 16:34:17 +02:00
|
|
|
workingDirectory = target;
|
2025-12-24 22:50:46 +02:00
|
|
|
|
|
|
|
|
// When pointing at an external API URL, avoid restarting a local CLI process.
|
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
|
|
|
return { success: true, restarted: false, path: target };
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-13 16:34:17 +02:00
|
|
|
await restart();
|
|
|
|
|
return { success: true, restarted: true, path: target };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
start,
|
|
|
|
|
stop,
|
|
|
|
|
restart,
|
|
|
|
|
setWorkingDirectory,
|
|
|
|
|
getStatus: () => status,
|
2025-12-24 03:31:07 +02:00
|
|
|
getApiUrl,
|
2025-12-13 16:34:17 +02:00
|
|
|
getWorkingDirectory: () => workingDirectory,
|
2025-12-24 03:31:07 +02:00
|
|
|
isCliAvailable: () => cliAvailable,
|
2025-12-24 22:50:46 +02:00
|
|
|
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,
|
|
|
|
|
}),
|
2025-12-13 16:34:17 +02:00
|
|
|
onStatusChange(callback) {
|
|
|
|
|
listeners.add(callback);
|
|
|
|
|
// Immediately call with current status
|
|
|
|
|
callback(status, lastError);
|
|
|
|
|
return new vscode.Disposable(() => listeners.delete(callback));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|