2025-12-13 16:34:17 +02:00
import * as vscode from 'vscode' ;
import * as os from 'os' ;
2026-02-05 01:59:49 +02:00
import * as path from 'path' ;
import * as fs from 'fs' ;
2026-02-17 18:01:57 +02:00
import * as net from 'net' ;
2026-01-16 01:22:43 +02:00
import { execSync } from 'child_process' ;
2026-02-05 01:59:49 +02:00
import { spawnSync } from 'child_process' ;
2026-02-17 18:01:57 +02:00
import { spawn } from 'child_process' ;
import { randomBytes } from 'crypto' ;
2026-04-27 23:34:50 +08:00
import { normalizeWindowsDriveLetter } from './pathUtils' ;
2025-12-13 16:34:17 +02:00
2026-01-06 21:31:04 +02:00
const READY_CHECK_TIMEOUT_MS = 30000 ;
2026-06-04 14:13:14 +03:00
const WINDOWS_EXECUTABLE_EXTENSIONS = [ '' , '.exe' , '.cmd' , '.bat' , '.com' ];
2025-12-13 16:34:17 +02:00
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 ;
2026-01-09 12:15:09 +02:00
serverUrl : string | null ;
2026-01-16 14:43:53 +02:00
lastReadyElapsedMs : number | null ;
lastReadyAttempts : number | null ;
lastStartAttempts : number | null ;
2026-02-02 16:33:45 +01:00
version : string | null ;
2026-02-17 18:01:57 +02:00
secureConnection : boolean ;
authSource : 'user-env' | 'generated' | 'rotated' | null ;
2025-12-24 22:50:46 +02:00
};
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 ;
2026-02-17 18:01:57 +02:00
getOpenCodeAuthHeaders () : Record < string , string >;
2025-12-13 16:34:17 +02:00
getWorkingDirectory () : string ;
2026-01-09 18:03:12 +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 ;
}
2026-02-17 18:01:57 +02:00
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 {};
}
}
2026-01-09 12:15:09 +02:00
function resolvePortFromUrl ( url : string ) : number | null {
2025-12-13 16:34:17 +02:00
try {
2026-01-09 12:15:09 +02:00
const parsed = new URL ( url );
return parsed . port ? parseInt ( parsed . port , 10 ) : null ;
2025-12-13 16:34:17 +02:00
} catch {
2025-12-24 03:31:07 +02:00
return null ;
}
2025-12-13 16:34:17 +02:00
}
2026-02-05 01:59:49 +02:00
function isExecutable ( filePath : string ) : boolean {
if ( ! filePath ) return false ;
try {
const stat = fs . statSync ( filePath );
if ( ! stat . isFile ()) return false ;
// Windows executability is extension-based.
if ( process . platform === 'win32' ) {
const ext = path . extname ( filePath ). toLowerCase ();
if ( ! ext ) return true ;
return [ '.exe' , '.cmd' , '.bat' , '.com' ]. includes ( ext );
}
fs . accessSync ( filePath , fs . constants . X_OK );
return true ;
} catch {
return false ;
}
}
2026-02-28 16:00:10 +02:00
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 ( '/' );
}
2026-02-05 01:59:49 +02:00
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 );
}
2026-04-16 21:31:07 +08:00
function findExecutableInPath ( binaryName : string ) : string | null {
const trimmed = ( binaryName || '' ). trim ();
if ( ! trimmed ) {
return null ;
}
const current = process . env . PATH || '' ;
if ( ! current ) {
return null ;
}
2026-06-04 14:13:14 +03:00
const extensions = process . platform === 'win32' ? WINDOWS_EXECUTABLE_EXTENSIONS : [ '' ];
2026-04-16 21:31:07 +08:00
for ( const segment of current . split ( path . delimiter )) {
const dir = segment . trim ();
if ( ! dir ) {
continue ;
}
2026-06-04 14:13:14 +03:00
for ( const ext of extensions ) {
const candidate = path . join ( dir , process . platform === 'win32' ? ` ${ trimmed }${ ext } ` : trimmed );
if ( isExecutable ( candidate )) {
return candidate ;
}
2026-04-16 21:31:07 +08:00
}
}
return null ;
}
let cachedDetectedOpencodeCliPath : string | undefined ;
2026-05-06 02:06:16 +03:00
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 );
}
2026-02-05 01:59:49 +02:00
function resolveOpencodeCliPath () : string | null {
2026-02-06 01:32:26 +02:00
const configured = (() => {
try {
const config = vscode . workspace . getConfiguration ( 'openchamber' );
2026-05-06 02:06:16 +03:00
return normalizeConfiguredOpencodeBinary ( config . get < string >( 'opencodeBinary' ) || '' );
2026-02-06 01:32:26 +02:00
} catch {
return null ;
}
})();
2026-05-06 02:06:16 +03:00
if ( configured && isExecutable ( configured ) && ! isMacOpenCodeAppBundlePath ( configured )) {
2026-02-06 01:32:26 +02:00
return configured ;
}
const sharedFromOpenChamber = (() => {
try {
2026-02-17 18:01:57 +02:00
const settings = readOpenChamberSettings ();
const candidate = settings . opencodeBinary ;
2026-02-06 01:32:26 +02:00
if ( typeof candidate !== 'string' ) {
return null ;
}
2026-05-06 02:06:16 +03:00
return normalizeConfiguredOpencodeBinary ( candidate );
2026-02-06 01:32:26 +02:00
} catch {
return null ;
}
})();
2026-05-06 02:06:16 +03:00
if ( sharedFromOpenChamber && isExecutable ( sharedFromOpenChamber ) && ! isMacOpenCodeAppBundlePath ( sharedFromOpenChamber )) {
2026-02-06 01:32:26 +02:00
return sharedFromOpenChamber ;
}
2026-02-05 01:59:49 +02:00
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 ;
}
}
2026-04-16 21:31:07 +08:00
if ( cachedDetectedOpencodeCliPath ) {
if ( isExecutable ( cachedDetectedOpencodeCliPath )) {
return cachedDetectedOpencodeCliPath ;
}
cachedDetectedOpencodeCliPath = undefined ;
}
2026-02-05 01:59:49 +02:00
const home = os . homedir ();
const unixFallbacks = [
path . join ( home , '.opencode' , 'bin' , 'opencode' ),
2026-02-05 13:57:50 +02:00
path . join ( home , '.bun' , 'bin' , 'opencode' ),
2026-02-05 01:59:49 +02:00
path . join ( home , '.local' , 'bin' , 'opencode' ),
2026-04-16 21:31:07 +08:00
'/usr/local/bin/opencode' ,
'/opt/homebrew/bin/opencode' ,
2026-02-05 01:59:49 +02:00
path . join ( home , 'bin' , 'opencode' ),
];
const winFallbacks = (() => {
const userProfile = process . env . USERPROFILE || home ;
2026-06-04 14:13:14 +03:00
const appData = process . env . APPDATA || path . join ( userProfile , 'AppData' , 'Roaming' );
2026-02-05 01:59:49 +02:00
const localAppData = process . env . LOCALAPPDATA || '' ;
const programData = process . env . ProgramData || 'C:\\ProgramData' ;
2026-06-04 14:13:14 +03:00
const npmDir = path . join ( appData , 'npm' );
2026-02-05 01:59:49 +02:00
return [
path . join ( userProfile , '.opencode' , 'bin' , 'opencode.exe' ),
path . join ( userProfile , '.opencode' , 'bin' , 'opencode.cmd' ),
2026-06-04 14:13:14 +03:00
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' ),
2026-02-05 01:59:49 +02:00
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 );
})();
2026-06-04 14:13:14 +03:00
const fromPath = findExecutableInPath ( 'opencode' );
if ( fromPath ) {
cachedDetectedOpencodeCliPath = fromPath ;
return fromPath ;
2026-04-16 21:31:07 +08:00
}
2026-02-05 01:59:49 +02:00
const fallbacks = process . platform === 'win32' ? winFallbacks : unixFallbacks ;
for ( const candidate of fallbacks ) {
if ( isExecutable ( candidate )) {
2026-04-16 21:31:07 +08:00
cachedDetectedOpencodeCliPath = candidate ;
2026-02-05 01:59:49 +02:00
return candidate ;
}
}
if ( process . platform === 'win32' ) {
try {
const result = spawnSync ( 'where' , [ 'opencode' ], {
encoding : 'utf8' ,
stdio : [ 'ignore' , 'pipe' , 'pipe' ],
});
if ( result . status === 0 ) {
const lines = ( result . stdout || '' )
. split ( /\r?\n/ )
. map (( line ) => line . trim ())
. filter ( Boolean );
const found = lines . find (( line ) => isExecutable ( line ));
2026-04-16 21:31:07 +08:00
if ( found ) {
cachedDetectedOpencodeCliPath = found ;
2026-02-05 01:59:49 +02:00
return found ;
}
}
} catch {
// ignore
}
}
return null ;
}
2026-01-16 14:43:53 +02:00
type ReadyResult =
2026-02-02 16:33:45 +01:00
| { ok : true ; baseUrl : string ; elapsedMs : number ; attempts : number ; version : string | null }
| { ok : false ; elapsedMs : number ; attempts : number ; version : null };
2026-01-14 15:40:41 +02:00
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 );
};
2026-01-16 18:44:11 +02:00
const normalizedPath = parsed . pathname . replace ( /\/+$/ , '' );
// Prefer plain origin. Only keep SDK url when already root.
2026-01-14 15:40:41 +02:00
add ( origin );
2026-01-16 18:44:11 +02:00
if ( normalizedPath === '' || normalizedPath === '/' ) {
add ( normalized );
}
2026-01-14 15:40:41 +02:00
return candidates ;
} catch {
return [ normalized ];
}
}
2026-03-20 18:58:13 +02:00
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 ;
}
2026-04-16 21:31:07 +08:00
// Avoid interactive POSIX login shells in the extension host.
if ( process . platform !== 'win32' ) {
cachedLoginShellEnvSnapshot = null ;
return null ;
2026-03-20 18:58:13 +02:00
}
2026-04-16 21:31:07 +08:00
const windowsSnapshot = getWindowsShellEnvSnapshot ();
cachedLoginShellEnvSnapshot = windowsSnapshot ;
return windowsSnapshot ;
2026-03-20 18:58:13 +02:00
}
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 || '' );
}
2026-02-17 18:01:57 +02:00
async function waitForReady (
serverUrl : string ,
timeoutMs = 15000 ,
authHeaders : Record < string , string > = {}
) : Promise < ReadyResult > {
2026-02-02 16:33:45 +01:00
const outputChannel = vscode . window . createOutputChannel ( 'OpenChamberManager' );
2026-01-09 18:03:12 +02:00
const start = Date . now ();
2026-01-14 15:40:41 +02:00
const candidates = getCandidateBaseUrls ( serverUrl );
2026-01-16 14:43:53 +02:00
let attempts = 0 ;
2026-01-14 15:40:41 +02:00
2026-01-09 18:03:12 +02:00
while ( Date . now () - start < timeoutMs ) {
2026-01-14 15:40:41 +02:00
for ( const baseUrl of candidates ) {
2026-01-16 14:43:53 +02:00
attempts += 1 ;
2026-01-14 15:40:41 +02:00
try {
const controller = new AbortController ();
2026-01-16 18:44:11 +02:00
const timeout = setTimeout (() => controller . abort (), 3000 );
2026-01-14 15:40:41 +02:00
2026-02-05 01:59:49 +02:00
// OpenCode readiness check.
2026-02-02 16:33:45 +01:00
const url = new URL ( ` ${ baseUrl } /global/health` );
2026-01-15 18:56:14 +02:00
const res = await fetch ( url . toString (), {
2026-01-14 15:40:41 +02:00
method : 'GET' ,
2026-02-17 18:01:57 +02:00
headers : { Accept : 'application/json' , ... authHeaders },
2026-01-14 15:40:41 +02:00
signal : controller.signal ,
});
2026-02-02 16:33:45 +01:00
let body : { healthy? : boolean , version? : string } | null = null ;
try {
body = ( await res . json ()) as { healthy? : boolean , version? : string };
} catch {
body = null ;
}
2026-01-14 15:40:41 +02:00
clearTimeout ( timeout );
2026-02-02 16:33:45 +01:00
outputChannel ? . appendLine (
`Health check to ${ url . toString () } returned ${ res . status } with body: ${ JSON . stringify ( body ) } `
);
if ( res . ok && body ? . healthy === true ) {
return { ok : true , baseUrl , elapsedMs : Date.now () - start , attempts , version : body?.version ?? null };
2026-01-16 14:43:53 +02:00
}
2026-01-14 15:40:41 +02:00
} catch {
// ignore
}
2026-01-09 18:03:12 +02:00
}
2026-01-14 15:40:41 +02:00
2026-01-09 18:03:12 +02:00
await new Promise ( r => setTimeout ( r , 100 ));
}
2026-01-14 15:40:41 +02:00
2026-02-02 16:33:45 +01:00
return { ok : false , elapsedMs : Date.now () - start , attempts , version : null };
2026-01-09 18:03:12 +02:00
}
2026-02-17 18:01:57 +02:00
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' ],
2026-03-17 13:18:54 +02:00
windowsHide : true ,
2026-02-28 16:00:10 +02:00
shell : shouldUseWindowsShell ( binary ),
2026-02-17 18:01:57 +02:00
});
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 ();
2026-05-06 02:06:16 +03:00
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 } ` ));
2026-02-17 18:01:57 +02:00
};
const onError = ( error : Error ) => {
cleanup ();
reject ( error );
};
const timer = setTimeout (() => {
cleanup ();
reject ( new Error ( `Timeout waiting for server to start after ${ timeoutMs } ms` ));
}, 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' );
});
}
2025-12-24 03:31:07 +02:00
export function createOpenCodeManager ( _context : vscode.ExtensionContext ) : OpenCodeManager {
2026-01-09 12:15:09 +02:00
void _context ;
let server : { url : string ; close : () => void } | null = null ;
2026-01-14 15:40:41 +02:00
let managedApiUrlOverride : string | null = null ;
2026-02-17 18:01:57 +02:00
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 ;
})();
2025-12-13 16:34:17 +02:00
let status : ConnectionStatus = 'disconnected' ;
let lastError : string | undefined ;
const listeners = new Set < ( status : ConnectionStatus , error? : string ) => void > ();
2026-01-16 14:43:53 +02:00
const workspaceDirectory = () : string =>
2026-03-17 17:29:50 +08:00
normalizeWindowsDriveLetter ( vscode . workspace . workspaceFolders ? .[ 0 ] ? . uri . fsPath || os . homedir ());
2026-01-16 14:43:53 +02:00
let workingDirectory : string = workspaceDirectory ();
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 ;
2026-01-16 14:43:53 +02:00
let lastReadyElapsedMs : number | null = null ;
let lastReadyAttempts : number | null = null ;
let lastStartAttempts : number | null = null ;
2026-02-02 16:33:45 +01:00
let version : string | null = null ;
2025-12-26 02:29:00 +02:00
2025-12-24 03:31:07 +02:00
let detectedPort : number | null = null ;
2026-01-09 18:03:12 +02:00
let cliMissing = false ;
2026-02-05 01:59:49 +02:00
let cliPath : string | null = null ;
2025-12-26 02:29:00 +02:00
2026-01-16 01:22:43 +02:00
let pendingOperation : Promise < void > | null = null ;
2025-12-13 16:34:17 +02:00
const config = vscode . workspace . getConfiguration ( 'openchamber' );
const configuredApiUrl = config . get < string >( 'apiUrl' ) || '' ;
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
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 {
2026-01-09 12:15:09 +02:00
// Invalid URL
2025-12-13 16:34:17 +02:00
}
2025-12-24 03:31:07 +02:00
}
2025-12-13 16:34:17 +02:00
2026-01-09 12:15:09 +02:00
const setStatus = ( newStatus : ConnectionStatus , error? : string ) => {
2025-12-13 16:34:17 +02:00
if ( status !== newStatus || lastError !== error ) {
status = newStatus ;
lastError = error ;
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 ));
}
2026-01-09 12:15:09 +02:00
};
2025-12-24 03:31:07 +02:00
2026-01-09 12:15:09 +02:00
const getApiUrl = () : string | null => {
2025-12-24 03:31:07 +02:00
if ( useConfiguredUrl && configuredApiUrl ) {
return configuredApiUrl . replace ( /\/+$/ , '' );
}
2026-01-14 15:40:41 +02:00
if ( managedApiUrlOverride ) {
return managedApiUrlOverride . replace ( /\/+$/ , '' );
}
2026-01-09 12:15:09 +02:00
if ( server ? . url ) {
return server . url . replace ( /\/+$/ , '' );
2025-12-24 03:31:07 +02:00
}
2026-01-09 12:15:09 +02:00
if ( detectedPort ) {
2026-01-16 14:43:53 +02:00
return `http://127.0.0.1: ${ detectedPort } ` ;
2025-12-13 16:34:17 +02:00
}
2026-01-09 12:15:09 +02:00
return null ;
};
2025-12-13 16:34:17 +02:00
2026-02-17 18:01:57 +02:00
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 > {
2025-12-24 22:50:46 +02:00
startCount += 1 ;
2026-01-16 14:43:53 +02:00
setStatus ( 'connecting' );
2025-12-24 22:50:46 +02:00
lastStartAt = Date . now ();
2026-01-16 14:43:53 +02:00
lastStartAttempts = startCount ;
2025-12-24 22:50:46 +02:00
2025-12-13 16:34:17 +02:00
if ( typeof workdir === 'string' && workdir . trim (). length > 0 ) {
2026-03-17 17:29:50 +08:00
workingDirectory = normalizeWindowsDriveLetter ( workdir . trim ());
2026-01-16 14:43:53 +02:00
} else {
workingDirectory = workspaceDirectory ();
2025-12-13 16:34:17 +02:00
}
2025-12-24 03:31:07 +02:00
if ( useConfiguredUrl && configuredApiUrl ) {
setStatus ( 'connecting' );
2025-12-13 16:34:17 +02:00
setStatus ( 'connected' );
return ;
}
2026-01-16 01:22:43 +02:00
// If server already running, don't spawn another
if ( server ) {
if ( status !== 'connected' ) {
setStatus ( 'connected' );
}
return ;
}
2025-12-24 03:31:07 +02:00
setStatus ( 'connecting' );
2026-01-16 01:22:43 +02:00
cliMissing = false ;
2026-02-05 01:59:49 +02:00
cliPath = null ;
2025-12-24 03:31:07 +02:00
detectedPort = null ;
2025-12-24 22:50:46 +02:00
lastExitCode = null ;
2026-01-14 15:40:41 +02:00
managedApiUrlOverride = null ;
2026-02-05 01:59:49 +02:00
2025-12-13 16:34:17 +02:00
try {
2026-03-20 18:58:13 +02:00
applyLoginShellEnvSnapshot ();
2026-05-06 02:06:16 +03:00
const configuredCli = validateConfiguredOpencodeBinaryForManagedStart ();
if ( configuredCli ) {
cliPath = configuredCli ;
appendToPath ( path . dirname ( configuredCli ));
process . env . OPENCODE_BINARY = configuredCli ;
}
2026-02-05 01:59:49 +02:00
// Best-effort: locate CLI even when VS Code PATH is stale.
2026-05-06 02:06:16 +03:00
const resolvedCli = configuredCli || resolveOpencodeCliPath ();
2026-02-05 01:59:49 +02:00
if ( resolvedCli ) {
cliPath = resolvedCli ;
appendToPath ( path . dirname ( resolvedCli ));
2026-02-06 01:32:26 +02:00
process . env . OPENCODE_BINARY = resolvedCli ;
2026-02-05 01:59:49 +02:00
}
2026-02-17 18:01:57 +02:00
const password = await ensureManagedOpenCodeServerPassword ({
rotateManaged : options.rotateManaged === true ,
});
process . env . OPENCODE_SERVER_PASSWORD = password ;
2026-01-15 18:56:14 +02:00
// SDK spawns `opencode serve` in current process cwd.
// Some OpenCode endpoints behave differently based on server process cwd,
// so ensure we start it from the workspace directory.
const originalCwd = process . cwd ();
try {
process . chdir ( workingDirectory );
2026-02-17 18:01:57 +02:00
const port = await allocateManagedOpenCodePort ();
server = await spawnManagedOpenCodeServer ( workingDirectory , port , READY_CHECK_TIMEOUT_MS );
2026-01-15 18:56:14 +02:00
} finally {
try {
process . chdir ( originalCwd );
} catch {
// ignore
}
}
2026-01-09 12:15:09 +02:00
if ( server && server . url ) {
2026-01-15 18:56:14 +02:00
// Validate readiness for the current workspace context.
2026-02-17 18:01:57 +02:00
const ready = await waitForReady ( server . url , READY_CHECK_TIMEOUT_MS , getOpenCodeAuthHeaders ());
2026-01-16 14:43:53 +02:00
lastReadyElapsedMs = ready . elapsedMs ;
lastReadyAttempts = ready . attempts ;
2026-01-14 15:40:41 +02:00
if ( ready . ok ) {
managedApiUrlOverride = ready . baseUrl ;
detectedPort = resolvePortFromUrl ( ready . baseUrl );
2026-02-02 16:33:45 +01:00
version = ready . version ;
2026-01-09 18:03:12 +02:00
setStatus ( 'connected' );
} else {
try {
server . close ();
} catch {
// ignore
}
server = null ;
throw new Error ( 'Server started but health check failed' );
}
2025-12-13 16:34:17 +02:00
} else {
2026-01-09 12:15:09 +02:00
throw new Error ( 'Server started but URL is missing' );
2025-12-13 16:34:17 +02:00
}
} catch ( err ) {
const message = err instanceof Error ? err.message : String ( err );
2026-02-05 01:59:49 +02:00
2026-01-09 12:15:09 +02:00
// Check for ENOENT or generic spawn failure which implies CLI missing
if ( message . includes ( 'ENOENT' ) || message . includes ( 'spawn opencode' )) {
2026-01-09 18:03:12 +02:00
cliMissing = true ;
2026-02-05 01:59:49 +02:00
if ( ! cliPath ) {
cliPath = resolveOpencodeCliPath ();
}
setStatus ( 'error' , 'OpenCode CLI not found. Install it and ensure it\'s in PATH.' );
2026-01-09 12:15:09 +02:00
vscode . window . showErrorMessage (
2026-02-05 01:59:49 +02:00
'OpenCode CLI not found. Please install it and ensure it\'s in PATH.' ,
2026-01-09 12:15:09 +02:00
'More Info'
). then ( selection => {
if ( selection === 'More Info' ) {
2026-01-31 21:33:52 +01:00
vscode . env . openExternal ( vscode . Uri . parse ( 'https://github.com/anomalyco/opencode' ));
2026-01-09 12:15:09 +02:00
}
});
} else {
setStatus ( 'error' , `Failed to start OpenCode: ${ message } ` );
}
2025-12-13 16:34:17 +02:00
}
}
2026-01-16 01:22:43 +02:00
async function stopInternal () : Promise < void > {
const portToKill = detectedPort ;
2026-02-05 01:59:49 +02:00
2026-01-09 12:15:09 +02:00
if ( server ) {
2025-12-13 16:34:17 +02:00
try {
2026-01-09 12:15:09 +02:00
server . close ();
2025-12-13 16:34:17 +02:00
} catch {
2026-01-09 12:15:09 +02:00
// Ignore close errors
2025-12-13 16:34:17 +02:00
}
2026-01-09 12:15:09 +02:00
server = null ;
2025-12-13 16:34:17 +02:00
}
2026-01-15 18:56:14 +02:00
2026-01-16 01:22:43 +02:00
// Kill any process listening on our port to clean up orphaned children.
if ( portToKill ) {
try {
2026-02-05 01:59:49 +02:00
const lsofOutput = execSync ( `lsof -ti: ${ portToKill } 2>/dev/null || true` , {
2026-01-16 02:38:09 +02:00
encoding : 'utf8' ,
2026-02-05 01:59:49 +02:00
timeout : 5000
2026-01-16 01:22:43 +02:00
});
2026-01-16 02:38:09 +02:00
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
}
}
}
2026-01-16 01:22:43 +02:00
} catch {
// Ignore - process may already be dead
}
}
2026-01-15 18:56:14 +02:00
2026-01-14 15:40:41 +02:00
managedApiUrlOverride = null ;
2025-12-24 03:31:07 +02:00
detectedPort = null ;
2026-02-02 16:33:45 +01:00
version = null ;
2025-12-13 16:34:17 +02:00
setStatus ( 'disconnected' );
}
2026-01-16 01:22:43 +02:00
async function restartInternal () : Promise < void > {
2025-12-24 22:50:46 +02:00
restartCount += 1 ;
2026-01-16 01:22:43 +02:00
await stopInternal ();
2025-12-24 03:31:07 +02:00
await new Promise ( r => setTimeout ( r , 250 ));
2026-02-17 18:01:57 +02:00
await startInternal ( undefined , { rotateManaged : true });
2026-01-16 01:22:43 +02:00
}
async function start ( workdir? : string ) : Promise < void > {
if ( pendingOperation ) {
await pendingOperation ;
if ( server ) {
return ;
}
}
2026-01-16 14:43:53 +02:00
lastStartAttempts = 1 ;
2026-02-17 18:01:57 +02:00
pendingOperation = startInternal ( workdir , { rotateManaged : true });
2026-01-16 01:22:43 +02:00
try {
await pendingOperation ;
} finally {
pendingOperation = null ;
}
}
async function stop () : Promise < void > {
if ( pendingOperation ) {
await pendingOperation ;
}
// Check if already stopped
if ( ! server ) {
return ;
}
pendingOperation = stopInternal ();
try {
await pendingOperation ;
} finally {
pendingOperation = null ;
}
}
async function restart () : Promise < void > {
if ( pendingOperation ) {
await pendingOperation ;
}
2026-01-16 14:43:53 +02:00
lastStartAttempts = 1 ;
2026-01-16 01:22:43 +02:00
pendingOperation = restartInternal ();
try {
await pendingOperation ;
} finally {
pendingOperation = null ;
}
2025-12-13 16:34:17 +02:00
}
2025-12-24 03:31:07 +02:00
async function setWorkingDirectory ( newPath : string ) : Promise < { success : boolean ; restarted : boolean ; path : string } > {
2026-01-17 22:44:09 +02:00
void newPath ;
2026-01-16 14:43:53 +02:00
const workspacePath = workspaceDirectory ();
const nextDirectory = workspacePath ;
if ( workingDirectory === nextDirectory ) {
return { success : true , restarted : false , path : nextDirectory };
2025-12-24 22:50:46 +02:00
}
2026-01-06 21:31:04 +02:00
2026-01-16 14:43:53 +02:00
workingDirectory = nextDirectory ;
2025-12-24 22:50:46 +02:00
if ( useConfiguredUrl && configuredApiUrl ) {
2026-01-16 14:43:53 +02:00
return { success : true , restarted : false , path : nextDirectory };
2025-12-24 22:50:46 +02:00
}
2026-01-16 14:43:53 +02:00
return { success : true , restarted : false , path : nextDirectory };
2025-12-13 16:34:17 +02:00
}
return {
start ,
stop ,
restart ,
setWorkingDirectory ,
getStatus : () => status ,
2025-12-24 03:31:07 +02:00
getApiUrl ,
2026-02-17 18:01:57 +02:00
getOpenCodeAuthHeaders ,
2025-12-13 16:34:17 +02:00
getWorkingDirectory : () => workingDirectory ,
2026-01-09 18:03:12 +02:00
isCliAvailable : () => ! cliMissing ,
2026-02-17 18:01:57 +02:00
getDebugInfo : () => {
const secureConnection = Boolean ( getOpenCodeAuthHeaders (). Authorization );
return {
mode : useConfiguredUrl && configuredApiUrl ? 'external' : 'managed' ,
status ,
lastError ,
workingDirectory ,
cliAvailable : ! cliMissing ,
cliPath ,
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 ),
};
},
2025-12-13 16:34:17 +02:00
onStatusChange ( callback ) {
listeners . add ( callback );
callback ( status , lastError );
return new vscode . Disposable (() => listeners . delete ( callback ));
},
};
}