refactor: clean up OpenCode manager by removing unused code and optimizing server initialization
This commit is contained in:
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- VSCode: switch opencode api management to SDK
|
||||||
|
|
||||||
|
|
||||||
## [1.4.5] - 2026-01-08
|
## [1.4.5] - 2026-01-08
|
||||||
|
|
||||||
- Chat: added support for model variants (thinking effort).
|
- Chat: added support for model variants (thinking effort).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { buildAugmentedPath, type OpenCodeManager } from './opencode';
|
import { type OpenCodeManager } from './opencode';
|
||||||
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig';
|
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig';
|
||||||
import { removeProviderAuth } from './opencodeAuth';
|
import { removeProviderAuth } from './opencodeAuth';
|
||||||
import * as gitService from './gitService';
|
import * as gitService from './gitService';
|
||||||
@@ -604,7 +604,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
|
|
||||||
const augmentedEnv = {
|
const augmentedEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PATH: buildAugmentedPath(),
|
PATH: process.env.PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
const results: Array<{
|
const results: Array<{
|
||||||
|
|||||||
@@ -385,15 +385,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
`Platform: ${process.platform} ${process.arch}`,
|
`Platform: ${process.platform} ${process.arch}`,
|
||||||
`Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`,
|
`Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`,
|
||||||
`Status: ${openCodeManager?.getStatus() ?? 'unknown'}`,
|
`Status: ${openCodeManager?.getStatus() ?? 'unknown'}`,
|
||||||
`CLI available: ${openCodeManager?.isCliAvailable() ?? false}`,
|
|
||||||
`Working directory: ${openCodeManager?.getWorkingDirectory() ?? ''}`,
|
`Working directory: ${openCodeManager?.getWorkingDirectory() ?? ''}`,
|
||||||
`API URL (configured): ${configuredApiUrl || '(none)'}`,
|
`API URL (configured): ${configuredApiUrl || '(none)'}`,
|
||||||
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
|
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
|
||||||
|
debug
|
||||||
|
? `OpenCode server URL: ${debug.serverUrl ?? '(none)'}`
|
||||||
|
: `OpenCode server URL: (unknown)`,
|
||||||
debug
|
debug
|
||||||
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
|
? `OpenCode mode: ${debug.mode} (starts=${debug.startCount}, restarts=${debug.restartCount})`
|
||||||
: `OpenCode mode: (unknown)`,
|
: `OpenCode mode: (unknown)`,
|
||||||
debug
|
debug
|
||||||
? `OpenCode CLI path: ${debug.cliPath || '(not found)'}`
|
? `OpenCode CLI path: ${debug.cliPath || '(not found - SDK manages process)'}`
|
||||||
: `OpenCode CLI path: (unknown)`,
|
: `OpenCode CLI path: (unknown)`,
|
||||||
debug
|
debug
|
||||||
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
? `OpenCode detected port: ${debug.detectedPort ?? '(none)'}`
|
||||||
|
|||||||
+61
-551
@@ -1,34 +1,8 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { spawn, ChildProcess, spawnSync } from 'child_process';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as net from 'net';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { createOpencodeServer } from '@opencode-ai/sdk/server';
|
||||||
|
|
||||||
// Optimized timeouts for faster startup
|
|
||||||
const READY_CHECK_TIMEOUT_MS = 30000;
|
const READY_CHECK_TIMEOUT_MS = 30000;
|
||||||
const READY_CHECK_INTERVAL_MS = 250; // Avoid hammering the server during startup
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 5000;
|
|
||||||
const SHUTDOWN_TIMEOUT_MS = 3000;
|
|
||||||
const DEFAULT_OPENCODE_PORT = 4096;
|
|
||||||
|
|
||||||
// Regex to detect port from CLI output (matches desktop pattern)
|
|
||||||
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,
|
|
||||||
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'),
|
|
||||||
path.join(os.homedir(), '.opencode/bin/opencode'),
|
|
||||||
].filter(Boolean) as string[];
|
|
||||||
|
|
||||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
@@ -49,6 +23,7 @@ export type OpenCodeDebugInfo = {
|
|||||||
lastStartAt: number | null;
|
lastStartAt: number | null;
|
||||||
lastConnectedAt: number | null;
|
lastConnectedAt: number | null;
|
||||||
lastExitCode: number | null;
|
lastExitCode: number | null;
|
||||||
|
serverUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface OpenCodeManager {
|
export interface OpenCodeManager {
|
||||||
@@ -59,294 +34,37 @@ export interface OpenCodeManager {
|
|||||||
getStatus(): ConnectionStatus;
|
getStatus(): ConnectionStatus;
|
||||||
getApiUrl(): string | null;
|
getApiUrl(): string | null;
|
||||||
getWorkingDirectory(): string;
|
getWorkingDirectory(): string;
|
||||||
isCliAvailable(): boolean;
|
|
||||||
getDebugInfo(): OpenCodeDebugInfo;
|
getDebugInfo(): OpenCodeDebugInfo;
|
||||||
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExecutable(filePath: string): boolean {
|
function resolvePortFromUrl(url: string): number | null {
|
||||||
try {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
if (!stats.isFile()) return false;
|
|
||||||
if (process.platform === 'win32') return true;
|
|
||||||
fs.accessSync(filePath, fs.constants.X_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBinaryFromPath(binaryName: string, searchPath: string): string | null {
|
|
||||||
if (!binaryName) return null;
|
|
||||||
if (path.isAbsolute(binaryName)) {
|
|
||||||
return isExecutable(binaryName) ? binaryName : null;
|
|
||||||
}
|
|
||||||
const directories = searchPath.split(path.delimiter).filter(Boolean);
|
|
||||||
for (const directory of directories) {
|
|
||||||
try {
|
|
||||||
const candidate = path.join(directory, binaryName);
|
|
||||||
if (isExecutable(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLoginShellPath(): string | null {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shell = process.env.SHELL || '/bin/zsh';
|
|
||||||
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"'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(shell, args, {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCliPath(): string | null {
|
|
||||||
// First check explicit candidates
|
|
||||||
for (const candidate of BIN_CANDIDATES) {
|
|
||||||
if (!candidate) continue;
|
|
||||||
if (isExecutable(candidate)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32' && !candidate.toLowerCase().endsWith('.exe')) {
|
|
||||||
const withExe = `${candidate}.exe`;
|
|
||||||
if (isExecutable(withExe)) {
|
|
||||||
return withExe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then search in augmented PATH
|
|
||||||
const augmentedPath = buildAugmentedPath();
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
try {
|
|
||||||
const result = spawnSync('where', ['opencode'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, PATH: augmentedPath },
|
|
||||||
});
|
|
||||||
if (result.status === 0 && typeof result.stdout === 'string') {
|
|
||||||
const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
||||||
for (const line of lines) {
|
|
||||||
if (isExecutable(line)) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromPath = resolveBinaryFromPath('opencode.exe', augmentedPath);
|
|
||||||
if (fromPath) {
|
|
||||||
return fromPath;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const fromPath = resolveBinaryFromPath('opencode', augmentedPath);
|
|
||||||
if (fromPath) {
|
|
||||||
return fromPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try login shell detection
|
|
||||||
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 {
|
|
||||||
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, {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkHealth(apiUrl: string, quick = false): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutMs = quick ? 1500 : 3000;
|
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
const normalized = apiUrl.replace(/\/+$/, '');
|
|
||||||
const candidates: string[] = [`${normalized}/config`];
|
|
||||||
|
|
||||||
for (const target of candidates) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(target, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
|
||||||
const isJson = contentType.includes('json') && !contentType.includes('text/html');
|
|
||||||
if (response.ok && isJson) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appendDirectoryQuery = (url: string, directory: string | null | undefined): string => {
|
|
||||||
const dir = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : null;
|
|
||||||
if (!dir) return url;
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
parsed.searchParams.set('directory', dir);
|
return parsed.port ? parseInt(parsed.port, 10) : null;
|
||||||
return parsed.toString();
|
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
async function checkReady(apiUrl: string, directory: string | null | undefined, quick = false): Promise<boolean> {
|
function inferApiPrefixFromUrl(url: string): string {
|
||||||
const normalized = apiUrl.replace(/\/+$/, '');
|
try {
|
||||||
const targets: Array<{ path: string; timeoutMs: number }> = [
|
const parsed = new URL(url);
|
||||||
{ path: '/config', timeoutMs: quick ? 1500 : 4000 },
|
const pathname = parsed.pathname;
|
||||||
{ path: '/config/providers', timeoutMs: quick ? 2000 : 6000 },
|
if (pathname === '/' || pathname === '') {
|
||||||
{ path: '/agent', timeoutMs: quick ? 2500 : 10000 },
|
return '';
|
||||||
{ path: '/session/status', timeoutMs: quick ? 2000 : 6000 },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const target of targets) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), target.timeoutMs);
|
|
||||||
try {
|
|
||||||
const url = appendDirectoryQuery(`${normalized}${target.path}`, directory);
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
|
||||||
const isJson = contentType.includes('json') && !contentType.includes('text/html');
|
|
||||||
if (!isJson) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
|
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isTcpPortAvailable(port: number): Promise<boolean> {
|
|
||||||
if (!Number.isFinite(port) || port <= 0) return false;
|
|
||||||
|
|
||||||
return await new Promise<boolean>((resolve) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
|
|
||||||
server.once('error', () => resolve(false));
|
|
||||||
server.listen({ host: '127.0.0.1', port }, () => {
|
|
||||||
server.close(() => resolve(true));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getEphemeralPort(): Promise<number> {
|
|
||||||
return await new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.unref();
|
|
||||||
server.once('error', (err) => reject(err));
|
|
||||||
server.listen({ host: '127.0.0.1', port: 0 }, () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === 'string') {
|
|
||||||
server.close(() => reject(new Error('Failed to allocate ephemeral port')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const port = address.port;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
||||||
let childProcess: ChildProcess | null = null;
|
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
|
||||||
|
void _context;
|
||||||
|
let server: { url: string; close: () => void } | null = null;
|
||||||
let status: ConnectionStatus = 'disconnected';
|
let status: ConnectionStatus = 'disconnected';
|
||||||
let healthCheckInterval: NodeJS.Timeout | null = null;
|
|
||||||
let lastError: string | undefined;
|
let lastError: string | undefined;
|
||||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||||
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||||
@@ -356,19 +74,14 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
let lastConnectedAt: number | null = null;
|
let lastConnectedAt: number | null = null;
|
||||||
let lastExitCode: number | null = null;
|
let lastExitCode: number | null = null;
|
||||||
|
|
||||||
// Port detection state (like desktop)
|
|
||||||
let detectedPort: number | null = null;
|
let detectedPort: number | null = null;
|
||||||
|
|
||||||
// OpenCode API prefix detection (some versions serve under /api)
|
|
||||||
let apiPrefix: string = '';
|
let apiPrefix: string = '';
|
||||||
let apiPrefixDetected = false;
|
let apiPrefixDetected = false;
|
||||||
|
|
||||||
// Check if user configured a specific API URL
|
|
||||||
const config = vscode.workspace.getConfiguration('openchamber');
|
const config = vscode.workspace.getConfiguration('openchamber');
|
||||||
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
||||||
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
|
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
|
||||||
|
|
||||||
// Parse configured URL to extract port if specified
|
|
||||||
let configuredPort: number | null = null;
|
let configuredPort: number | null = null;
|
||||||
if (useConfiguredUrl) {
|
if (useConfiguredUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -377,85 +90,11 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
configuredPort = parseInt(parsed.port, 10);
|
configuredPort = parseInt(parsed.port, 10);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid URL, will use dynamic port
|
// Invalid URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliPath = resolveCliPath();
|
const setStatus = (newStatus: ConnectionStatus, error?: string) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildApiBaseUrlFromPort = (port: number, prefixOverride?: string): string => {
|
|
||||||
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : apiPrefixDetected ? apiPrefix : '');
|
|
||||||
return `http://localhost:${port}${prefix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const probeOpenCodeAtPort = async (port: number, quick = false): Promise<string | null> => {
|
|
||||||
if (!Number.isFinite(port) || port <= 0) return null;
|
|
||||||
const origin = `http://localhost:${port}`;
|
|
||||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
|
||||||
const base = `${origin}${candidate}`;
|
|
||||||
if (await checkReady(base, workingDirectory, quick)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitForOpenCodeReadyAtPort = async (port: number, timeoutMs: number): Promise<boolean> => {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const prefix = await probeOpenCodeAtPort(port, true);
|
|
||||||
if (prefix !== null) {
|
|
||||||
setDetectedApiPrefix(prefix);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, READY_CHECK_INTERVAL_MS));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function setStatus(newStatus: ConnectionStatus, error?: string) {
|
|
||||||
if (status !== newStatus || lastError !== error) {
|
if (status !== newStatus || lastError !== error) {
|
||||||
status = newStatus;
|
status = newStatus;
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -464,80 +103,20 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
}
|
}
|
||||||
listeners.forEach(cb => cb(status, error));
|
listeners.forEach(cb => cb(status, error));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function setDetectedPort(port: number) {
|
const getApiUrl = (): string | null => {
|
||||||
detectedPort = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
if (detectedPort !== null && detectedPort !== port) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDetectedPort(port);
|
|
||||||
const inferred = inferPrefixFromLogPath(match[2] || '');
|
|
||||||
if (inferred !== null) {
|
|
||||||
setDetectedApiPrefix(inferred);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback pattern
|
|
||||||
const fallbackMatch = FALLBACK_PORT_REGEX.exec(text);
|
|
||||||
if (fallbackMatch) {
|
|
||||||
const port = parseInt(fallbackMatch[1], 10);
|
|
||||||
if (Number.isFinite(port) && port > 0) {
|
|
||||||
if (detectedPort !== null && detectedPort !== port) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDetectedPort(port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiUrl(): string | null {
|
|
||||||
if (useConfiguredUrl && configuredApiUrl) {
|
if (useConfiguredUrl && configuredApiUrl) {
|
||||||
return configuredApiUrl.replace(/\/+$/, '');
|
return configuredApiUrl.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
if (detectedPort !== null) {
|
if (server?.url) {
|
||||||
return buildApiBaseUrlFromPort(detectedPort);
|
return server.url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
if (detectedPort) {
|
||||||
|
return `http://127.0.0.1:${detectedPort}${apiPrefix}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
function startHealthCheck() {
|
|
||||||
stopHealthCheck();
|
|
||||||
healthCheckInterval = setInterval(async () => {
|
|
||||||
const apiUrl = getApiUrl();
|
|
||||||
if (!apiUrl) {
|
|
||||||
if (status === 'connected') {
|
|
||||||
setStatus('disconnected');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const healthy = await checkHealth(apiUrl);
|
|
||||||
if (healthy && status !== 'connected') {
|
|
||||||
setStatus('connected');
|
|
||||||
} else if (!healthy && status === 'connected') {
|
|
||||||
setStatus('disconnected');
|
|
||||||
}
|
|
||||||
}, HEALTH_CHECK_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHealthCheck() {
|
|
||||||
if (healthCheckInterval) {
|
|
||||||
clearInterval(healthCheckInterval);
|
|
||||||
healthCheckInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start(workdir?: string): Promise<void> {
|
async function start(workdir?: string): Promise<void> {
|
||||||
startCount += 1;
|
startCount += 1;
|
||||||
@@ -547,128 +126,64 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
workingDirectory = workdir.trim();
|
workingDirectory = workdir.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user configured an external API URL, do NOT start a local CLI instance.
|
|
||||||
if (useConfiguredUrl && configuredApiUrl) {
|
if (useConfiguredUrl && configuredApiUrl) {
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
const healthy = await checkReady(configuredApiUrl, workingDirectory, false);
|
|
||||||
if (healthy) {
|
|
||||||
setStatus('connected');
|
|
||||||
startHealthCheck();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('error', `OpenCode API at ${configuredApiUrl} is not responding.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing running instance (only if port is known)
|
|
||||||
const currentUrl = getApiUrl();
|
|
||||||
if (currentUrl && await checkReady(currentUrl, workingDirectory, false)) {
|
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
startHealthCheck();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cliAvailable) {
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
|
|
||||||
// Reset port detection for fresh start
|
|
||||||
detectedPort = null;
|
detectedPort = null;
|
||||||
apiPrefix = '';
|
apiPrefix = '';
|
||||||
apiPrefixDetected = false;
|
apiPrefixDetected = false;
|
||||||
lastExitCode = null;
|
lastExitCode = null;
|
||||||
|
|
||||||
const spawnCwd = workingDirectory || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const portToUse = await (async () => {
|
// Let the SDK/OS choose a random available port (port: 0)
|
||||||
if (await isTcpPortAvailable(DEFAULT_OPENCODE_PORT)) {
|
server = await createOpencodeServer({
|
||||||
return DEFAULT_OPENCODE_PORT;
|
hostname: '127.0.0.1',
|
||||||
}
|
port: 0,
|
||||||
return await getEphemeralPort();
|
timeout: READY_CHECK_TIMEOUT_MS,
|
||||||
})();
|
signal: undefined,
|
||||||
|
|
||||||
const augmentedEnv = {
|
|
||||||
...process.env,
|
|
||||||
PATH: buildAugmentedPath(),
|
|
||||||
};
|
|
||||||
|
|
||||||
childProcess = spawn(cliPath!, ['serve', '--port', portToUse.toString()], {
|
|
||||||
cwd: spawnCwd,
|
|
||||||
env: augmentedEnv,
|
|
||||||
detached: false,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// We picked the port explicitly, so we don't need to wait for log-based detection.
|
if (server && server.url) {
|
||||||
setDetectedPort(portToUse);
|
detectedPort = resolvePortFromUrl(server.url);
|
||||||
|
apiPrefix = inferApiPrefixFromUrl(server.url);
|
||||||
childProcess.stdout?.on('data', (data) => {
|
apiPrefixDetected = apiPrefix.length > 0;
|
||||||
const text = data.toString();
|
|
||||||
detectPortFromOutput(text);
|
|
||||||
detectApiPrefixFromOutput(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.stderr?.on('data', (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
detectPortFromOutput(text);
|
|
||||||
detectApiPrefixFromOutput(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.on('error', (err) => {
|
|
||||||
setStatus('error', `Failed to start OpenCode: ${err.message}`);
|
|
||||||
childProcess = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
childProcess.on('exit', (code) => {
|
|
||||||
if (status !== 'disconnected') {
|
|
||||||
setStatus('disconnected', code !== 0 ? `OpenCode exited with code ${code}` : undefined);
|
|
||||||
}
|
|
||||||
childProcess = null;
|
|
||||||
detectedPort = null;
|
|
||||||
lastExitCode = typeof code === 'number' ? code : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ready = await waitForOpenCodeReadyAtPort(portToUse, READY_CHECK_TIMEOUT_MS);
|
|
||||||
if (ready) {
|
|
||||||
setStatus('connected');
|
setStatus('connected');
|
||||||
startHealthCheck();
|
|
||||||
} else {
|
} else {
|
||||||
setStatus('error', 'OpenCode API did not become ready in time');
|
throw new Error('Server started but URL is missing');
|
||||||
await stop();
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
setStatus('error', `Failed to start OpenCode: ${message}`);
|
|
||||||
|
// Check for ENOENT or generic spawn failure which implies CLI missing
|
||||||
|
if (message.includes('ENOENT') || message.includes('spawn opencode')) {
|
||||||
|
setStatus('error', 'OpenCode CLI not found. Install it or ensure it\'s in PATH.');
|
||||||
|
vscode.window.showErrorMessage(
|
||||||
|
'OpenCode CLI not found. Please install it or ensure it\'s in PATH.',
|
||||||
|
'More Info'
|
||||||
|
).then(selection => {
|
||||||
|
if (selection === 'More Info') {
|
||||||
|
vscode.env.openExternal(vscode.Uri.parse('https://github.com/opencode-ai/opencode'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus('error', `Failed to start OpenCode: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop(): Promise<void> {
|
async function stop(): Promise<void> {
|
||||||
stopHealthCheck();
|
if (server) {
|
||||||
|
|
||||||
if (childProcess) {
|
|
||||||
try {
|
try {
|
||||||
childProcess.kill('SIGTERM');
|
server.close();
|
||||||
// Wait for graceful shutdown
|
|
||||||
await new Promise(r => setTimeout(r, SHUTDOWN_TIMEOUT_MS));
|
|
||||||
if (childProcess && !childProcess.killed && childProcess.exitCode === null) {
|
|
||||||
childProcess.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// Ignore close errors
|
||||||
}
|
}
|
||||||
childProcess = null;
|
server = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
detectedPort = null;
|
detectedPort = null;
|
||||||
@@ -678,7 +193,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
async function restart(): Promise<void> {
|
async function restart(): Promise<void> {
|
||||||
restartCount += 1;
|
restartCount += 1;
|
||||||
await stop();
|
await stop();
|
||||||
// Brief delay to let OS release resources
|
|
||||||
await new Promise(r => setTimeout(r, 250));
|
await new Promise(r => setTimeout(r, 250));
|
||||||
await start();
|
await start();
|
||||||
}
|
}
|
||||||
@@ -689,11 +203,8 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
return { success: true, restarted: false, path: target };
|
return { success: true, restarted: false, path: target };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track requested directory for UI + path resolution.
|
|
||||||
// OpenCode requests should use the `directory` parameter instead of relying on process cwd.
|
|
||||||
workingDirectory = target;
|
workingDirectory = target;
|
||||||
|
|
||||||
// When pointing at an external API URL, avoid restarting a local CLI process.
|
|
||||||
if (useConfiguredUrl && configuredApiUrl) {
|
if (useConfiguredUrl && configuredApiUrl) {
|
||||||
return { success: true, restarted: false, path: target };
|
return { success: true, restarted: false, path: target };
|
||||||
}
|
}
|
||||||
@@ -709,14 +220,13 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
getStatus: () => status,
|
getStatus: () => status,
|
||||||
getApiUrl,
|
getApiUrl,
|
||||||
getWorkingDirectory: () => workingDirectory,
|
getWorkingDirectory: () => workingDirectory,
|
||||||
isCliAvailable: () => cliAvailable,
|
|
||||||
getDebugInfo: () => ({
|
getDebugInfo: () => ({
|
||||||
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
||||||
status,
|
status,
|
||||||
lastError,
|
lastError,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
cliAvailable,
|
cliAvailable: status !== 'error' || (lastError ? !lastError.includes('CLI not found') : true), // Infer availability from status
|
||||||
cliPath,
|
cliPath: null,
|
||||||
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
||||||
configuredPort,
|
configuredPort,
|
||||||
detectedPort,
|
detectedPort,
|
||||||
@@ -727,10 +237,10 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
|||||||
lastStartAt,
|
lastStartAt,
|
||||||
lastConnectedAt,
|
lastConnectedAt,
|
||||||
lastExitCode,
|
lastExitCode,
|
||||||
|
serverUrl: getApiUrl(),
|
||||||
}),
|
}),
|
||||||
onStatusChange(callback) {
|
onStatusChange(callback) {
|
||||||
listeners.add(callback);
|
listeners.add(callback);
|
||||||
// Immediately call with current status
|
|
||||||
callback(status, lastError);
|
callback(status, lastError);
|
||||||
return new vscode.Disposable(() => listeners.delete(callback));
|
return new vscode.Disposable(() => listeners.delete(callback));
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user