import { spawn, execSync, ChildProcess } from "child_process" import { app, BrowserWindow } from "electron" import { existsSync, statSync } from "fs" import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell" export interface ProcessInfo { pid: number port: number binaryPath: string } interface ProcessMeta { pid: number port: number folder: string startTime: number childProcess: ChildProcess logs: string[] instanceId: string } class ProcessManager { private processes = new Map() private mainWindow: BrowserWindow | null = null setMainWindow(window: BrowserWindow) { this.mainWindow = window } private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" { const upperMessage = message.toUpperCase() if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error" if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn" if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug" if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info" return "info" } private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) { if (this.mainWindow && message.trim()) { const parsedLevel = this.parseLogLevel(message) this.mainWindow.webContents.send("instance:log", { id: instanceId, entry: { timestamp: Date.now(), level: parsedLevel, message: message.trim(), }, }) } } async spawn( folder: string, instanceId: string, binaryPath?: string, environmentVariables?: Record, ): Promise { this.validateFolder(folder) const useUserShell = supportsUserShell() const logAttempt = (message: string) => { console.info(`[ProcessManager] ${message}`) this.sendLog(instanceId, "debug", message) } const env = useUserShell ? getUserShellEnv() : { ...process.env } if (environmentVariables) { Object.assign(env, environmentVariables) this.sendLog( instanceId, "info", `Using ${Object.keys(environmentVariables).length} custom environment variables:`, ) // Log each environment variable for (const [key, value] of Object.entries(environmentVariables)) { this.sendLog(instanceId, "info", ` ${key}=${value}`) } } let targetBinary: string if (!binaryPath || binaryPath === "opencode") { targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt) } else { targetBinary = this.validateCustomBinary(binaryPath, logAttempt) } const spawnCommand = useUserShell ? this.buildShellServeCommand(targetBinary) : { command: targetBinary, args: this.buildServeArgs() } const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim() this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`) this.sendLog( instanceId, "info", `Starting OpenCode server for ${folder} using ${targetBinary}...`, ) return new Promise((resolve, reject) => { const child = spawn(spawnCommand.command, spawnCommand.args, { cwd: folder, stdio: ["ignore", "pipe", "pipe"], env, shell: false, }) const timeout = setTimeout(() => { child.kill("SIGKILL") this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)") reject(new Error("Server startup timeout (10s exceeded)")) }, 10000) let stdoutBuffer = "" let stderrBuffer = "" let portFound = false child.stdout?.on("data", (data: Buffer) => { const text = data.toString() stdoutBuffer += text const lines = stdoutBuffer.split("\n") stdoutBuffer = lines.pop() || "" for (const line of lines) { if (!line.trim()) continue this.sendLog(instanceId, "info", line) const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/) if (portMatch && !portFound) { portFound = true const port = parseInt(portMatch[1], 10) clearTimeout(timeout) const meta: ProcessMeta = { pid: child.pid!, port, folder, startTime: Date.now(), childProcess: child, logs: [line], instanceId, } this.processes.set(child.pid!, meta) resolve({ pid: child.pid!, port, binaryPath: targetBinary }) } const meta = this.processes.get(child.pid!) if (meta) { meta.logs.push(line) } } }) child.stderr?.on("data", (data: Buffer) => { const text = data.toString() stderrBuffer += text const lines = stderrBuffer.split("\n") stderrBuffer = lines.pop() || "" for (const line of lines) { if (!line.trim()) continue this.sendLog(instanceId, "error", line) const meta = this.processes.get(child.pid!) if (meta) { meta.logs.push(line) } } }) child.on("error", (error) => { clearTimeout(timeout) if (error.message.includes("ENOENT")) { reject(new Error("opencode binary not found in PATH")) } else { reject(error) } }) child.on("exit", (code, signal) => { clearTimeout(timeout) this.processes.delete(child.pid!) if (!portFound) { const errorMsg = stderrBuffer || `Process exited with code ${code}` reject(new Error(errorMsg)) } }) }) } async kill(pid: number): Promise { const meta = this.processes.get(pid) if (!meta) { // Treat unknown processes as already stopped so tabs close cleanly return } return new Promise((resolve, reject) => { const child = meta.childProcess const killTimeout = setTimeout(() => { child.kill("SIGKILL") }, 2000) child.on("exit", () => { clearTimeout(killTimeout) this.processes.delete(pid) resolve() }) child.kill("SIGTERM") }) } getStatus(pid: number): "running" | "stopped" | "unknown" { if (!this.processes.has(pid)) { return "unknown" } try { process.kill(pid, 0) return "running" } catch { return "stopped" } } getAllProcesses(): Map { return new Map(this.processes) } async cleanup(): Promise { const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {})) await Promise.all(killPromises) } private validateFolder(folder: string): void { if (!existsSync(folder)) { throw new Error(`Folder does not exist: ${folder}`) } const stats = statSync(folder) if (!stats.isDirectory()) { throw new Error(`Path is not a directory: ${folder}`) } } private validateOpenCodeBinary(logAttempt?: (message: string) => void): string { const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`)) if (process.platform === "win32") { log("Checking PATH via 'where opencode'") return this.resolveBinaryViaLocator("where opencode", log) } const shellCheck = buildUserShellCommand("command -v opencode") const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ") log(`Checking PATH via shell: ${shellPreview}`) try { const resolved = runUserShellCommandSync("command -v opencode") const path = this.pickFirstPath(resolved) if (path) { log(`Shell located opencode at ${path}`) return path } throw new Error("Empty result from shell lookup") } catch (shellError) { const message = shellError instanceof Error ? shellError.message : String(shellError) log(`Shell lookup failed: ${message}`) try { log("Fallback to 'which opencode'") return this.resolveBinaryViaLocator("which opencode", log) } catch (locatorError) { const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError) log(`Locator fallback failed: ${locatorMessage}`) throw new Error( "opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli", ) } } } private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string { log?.(`Validating custom binary at ${binaryPath}`) if (!existsSync(binaryPath)) { throw new Error(`OpenCode binary not found: ${binaryPath}`) } const stats = statSync(binaryPath) if (!stats.isFile()) { throw new Error(`Path is not a file: ${binaryPath}`) } // Check if executable (on Unix systems) if (process.platform !== "win32") { try { execSync(`test -x "${binaryPath}"`, { stdio: "pipe" }) } catch { throw new Error(`Binary is not executable: ${binaryPath}`) } } return binaryPath } private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string { log?.(`Running locator command: ${command}`) const output = execSync(command, { stdio: "pipe", encoding: "utf-8" }) log?.(`Locator output: ${output.trim() || ""}`) const path = this.pickFirstPath(output) if (!path) { throw new Error("opencode binary not found in PATH") } return path } private pickFirstPath(output: string): string | null { const line = output .split("\n") .map((entry) => entry.trim()) .find((entry) => entry.length > 0) return line ?? null } private buildServeArgs(): string[] { return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] } private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } { const args = this.buildServeArgs() .map((arg) => JSON.stringify(arg)) .join(" ") return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`) } } export const processManager = new ProcessManager() app.on("before-quit", async (event) => { event.preventDefault() await processManager.cleanup() app.exit(0) })