| import { spawn } from "node:child_process"; |
| import { randomBytes } from "node:crypto"; |
| import { |
| existsSync, |
| mkdirSync, |
| readdirSync, |
| readFileSync, |
| statSync, |
| unlinkSync, |
| writeFileSync, |
| } from "node:fs"; |
| import net from "node:net"; |
| import { homedir } from "node:os"; |
| import path from "node:path"; |
| import process from "node:process"; |
| import { setTimeout as delay } from "node:timers/promises"; |
| import { fileURLToPath, pathToFileURL } from "node:url"; |
|
|
| import { |
| getProcessTreeSpawnOptions, |
| isProcessRunning, |
| signalProcessTree, |
| } from "./dev-process-utils.mjs"; |
| |
| |
| |
| import { buildRuntimeServicesInfo } from "./runtime-services-info.mjs"; |
| import { fileLog, stripAnsi } from "./logger.mjs"; |
|
|
| |
| const __dev_safe_dirname = path.dirname(fileURLToPath(import.meta.url)); |
| const SHARED_DEFAULTS = JSON.parse( |
| readFileSync( |
| path.join(__dev_safe_dirname, "..", "config", "defaults.json"), |
| "utf-8", |
| ), |
| ); |
|
|
| const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; |
| |
| |
| |
| |
| export const VSCODE_BASE_PATH = SHARED_DEFAULTS.paths.vscodeBasePath; |
| const DEFAULT_VITE_PORT = 3001; |
| const DEFAULT_WAIT_TIMEOUT_MS = 30_000; |
| const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer; |
| const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk"; |
| const LOCAL_AGENT_SERVER_SUBDIRS = [ |
| "openhands-agent-server", |
| "openhands-sdk", |
| "openhands-tools", |
| "openhands-workspace", |
| ]; |
| const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer; |
| |
| |
| |
| const AGENT_CLIENT_PROTOCOL_CONSTRAINT = |
| SHARED_DEFAULTS.constraints?.agentClientProtocol; |
| const DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_API_KEY = |
| SHARED_DEFAULTS.telemetry.posthogApiKey; |
| const DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_HOST = |
| SHARED_DEFAULTS.telemetry.posthogHost; |
| const AGENT_SERVER_POSTHOG_CONSTRAINT = "posthog>=6,<7"; |
| const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"]; |
|
|
| |
| |
| |
| |
| export function generateRandomApiKey() { |
| return randomBytes(32).toString("hex"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export const DEFAULT_API_KEY_PATH = path.join( |
| homedir(), |
| ".openhands", |
| "agent-canvas", |
| "api-key.txt", |
| ); |
|
|
| |
| export const DEFAULT_SESSION_API_KEY_PATH = DEFAULT_API_KEY_PATH; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const DEFAULT_SECRET_KEY_PATH = path.join( |
| homedir(), |
| ".openhands", |
| "agent-canvas", |
| "secret-key.txt", |
| ); |
|
|
| |
| |
| const persistedApiKeyCache = new Map(); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function getOrCreatePersistedApiKeyFile( |
| filePath = DEFAULT_API_KEY_PATH, |
| ) { |
| return getOrCreatePersistedApiKey(filePath, "session"); |
| } |
|
|
| |
| export function getOrCreatePersistedSessionApiKey( |
| filePath = DEFAULT_API_KEY_PATH, |
| ) { |
| return getOrCreatePersistedApiKeyFile(filePath); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function getOrCreatePersistedApiKey(filePath, label = "API") { |
| const cached = persistedApiKeyCache.get(filePath); |
| if (cached) return cached; |
|
|
| |
| try { |
| const existing = readFileSync(filePath, "utf8").trim(); |
| if (existing) { |
| persistedApiKeyCache.set(filePath, existing); |
| return existing; |
| } |
| |
| } catch (error) { |
| if (!isEnoentError(error)) { |
| console.warn( |
| `Could not read persisted ${label} API key from ${filePath}: ${error.message}. Regenerating.`, |
| ); |
| } |
| } |
|
|
| |
| const newKey = generateRandomApiKey(); |
| try { |
| mkdirSync(path.dirname(filePath), { recursive: true }); |
| writeFileSync(filePath, `${newKey}\n`, { mode: 0o600 }); |
| } catch (error) { |
| console.warn( |
| `Could not persist ${label} API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`, |
| ); |
| } |
| persistedApiKeyCache.set(filePath, newKey); |
| return newKey; |
| } |
|
|
| |
| |
| |
| |
| export function resetPersistedSessionApiKeyCache() { |
| persistedApiKeyCache.clear(); |
| } |
|
|
| function isEnoentError(error) { |
| return Boolean( |
| (error && |
| typeof error === "object" && |
| "code" in error && |
| error.code === "ENOENT") || |
| /ENOENT/.test(String(error)), |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function findFreePort(preferredPort, host = "127.0.0.1") { |
| |
| if (preferredPort > 0) { |
| const preferredAvailable = await tryPort(preferredPort, host); |
| if (preferredAvailable) { |
| return preferredPort; |
| } |
| } |
|
|
| |
| return new Promise((resolve, reject) => { |
| const server = net.createServer(); |
| server.once("error", reject); |
| server.listen(0, host, () => { |
| const { port } = server.address(); |
| server.close(() => resolve(port)); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function tryPort(port, host = "127.0.0.1") { |
| return new Promise((resolve) => { |
| const server = net.createServer(); |
| server.once("error", () => resolve(false)); |
| server.listen(port, host, () => { |
| server.close(() => resolve(true)); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function assertPortsFree(portConfigs, host = "127.0.0.1") { |
| const results = await Promise.all( |
| portConfigs.map(async ({ name, port }) => ({ |
| name, |
| port, |
| free: await tryPort(port, host), |
| })), |
| ); |
| const busy = results.filter(({ free }) => !free); |
| if (busy.length === 0) return; |
|
|
| const lines = busy |
| .map(({ name, port }) => ` β’ ${name}: port ${port}`) |
| .join("\n"); |
| throw new Error( |
| `Cannot start: the following ports are already in use:\n\n${lines}\n\n` + |
| `Another agent-canvas instance may already be running.\n` + |
| `Stop it first, or override the port via environment variables (e.g. PORT=<other>).`, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function findFreePorts(portConfigs, host = "127.0.0.1") { |
| const result = {}; |
| const usedPorts = new Set(); |
|
|
| for (const { name, preferred } of portConfigs) { |
| |
| |
| if (preferred > 0 && !usedPorts.has(preferred)) { |
| const available = await tryPort(preferred, host); |
| if (available) { |
| result[name] = preferred; |
| usedPorts.add(preferred); |
| continue; |
| } |
| } |
|
|
| |
| let port; |
| let attempts = 0; |
| const maxAttempts = 100; |
| do { |
| port = await findFreePort(0, host); |
| if (++attempts > maxAttempts) { |
| throw new Error( |
| `Could not allocate unique port for "${name}" after ${maxAttempts} attempts`, |
| ); |
| } |
| } while (usedPorts.has(port)); |
|
|
| result[name] = port; |
| usedPorts.add(port); |
| } |
|
|
| return result; |
| } |
|
|
| export function formatMissingUvxGuidance(cwd = process.cwd()) { |
| const readmePath = path.join(cwd, "README.md"); |
|
|
| return [ |
| "Failed to start uvx. Make sure uv is installed and on your PATH.", |
| "", |
| "To fix this:", |
| "1. Install uv:", |
| " curl -LsSf https://astral.sh/uv/install.sh | sh", |
| "2. Make sure the uv bin dir is on your PATH:", |
| ' export PATH="$HOME/.local/bin:$PATH"', |
| " command -v uvx", |
| "", |
| "Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/", |
| `See the local Quickstart for details: ${readmePath}`, |
| "", |
| "Other options:", |
| "- npm run dev:frontend # use an already running backend", |
| "- npm run dev:mock # run the frontend with mock APIs", |
| ].join("\n"); |
| } |
|
|
| function npmBinCandidates(binName, platform = process.platform) { |
| const candidates = [binName]; |
| if (platform === "win32") { |
| candidates.push(`${binName}.cmd`, `${binName}.ps1`); |
| } |
| return candidates; |
| } |
|
|
| export function getMissingFrontendDependencyBins( |
| cwd = process.cwd(), |
| platform = process.platform, |
| ) { |
| const binDir = path.join(cwd, "node_modules", ".bin"); |
| return FRONTEND_REQUIRED_BINS.filter( |
| (binName) => |
| !npmBinCandidates(binName, platform).some((candidate) => |
| existsSync(path.join(binDir, candidate)), |
| ), |
| ); |
| } |
|
|
| export function formatMissingFrontendDependenciesGuidance( |
| missingBins, |
| cwd = process.cwd(), |
| ) { |
| const missingList = missingBins.join(", "); |
| return [ |
| "Frontend dependencies are not installed or are incomplete.", |
| "", |
| `Missing npm binaries: ${missingList}`, |
| "", |
| "Run this from the repository root:", |
| " npm ci", |
| "", |
| `Repository root: ${cwd}`, |
| ].join("\n"); |
| } |
|
|
| export function validateFrontendDependencies( |
| cwd = process.cwd(), |
| platform = process.platform, |
| ) { |
| const missingBins = getMissingFrontendDependencyBins(cwd, platform); |
| if (missingBins.length > 0) { |
| throw new Error( |
| formatMissingFrontendDependenciesGuidance(missingBins, cwd), |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export const AGENT_SERVER_IMPORT_MODULES = "canvas_ui_tool"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildAgentServerCommand(env = process.env) { |
| const localPath = env.OH_AGENT_SERVER_LOCAL_PATH; |
| const gitRef = env.OH_AGENT_SERVER_GIT_REF; |
| const version = env.OH_AGENT_SERVER_VERSION; |
|
|
| const uvxArgs = []; |
| let source = ""; |
|
|
| if (localPath) { |
| if (!path.isAbsolute(localPath)) { |
| throw new Error( |
| `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`, |
| ); |
| } |
| uvxArgs.push( |
| "--reinstall", |
| "--from", |
| path.join(localPath, "openhands-agent-server"), |
| "--with-editable", |
| path.join(localPath, "openhands-sdk"), |
| "--with-editable", |
| path.join(localPath, "openhands-tools"), |
| "--with-editable", |
| path.join(localPath, "openhands-workspace"), |
| "--with", |
| AGENT_SERVER_POSTHOG_CONSTRAINT, |
| "agent-server", |
| ); |
| source = `local (${localPath})`; |
| } else if (gitRef) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${gitRef}`; |
| uvxArgs.push( |
| "--reinstall", |
| "--from", |
| `${baseGitUrl}#subdirectory=openhands-agent-server`, |
| "--with", |
| `${baseGitUrl}#subdirectory=openhands-sdk`, |
| "--with", |
| `${baseGitUrl}#subdirectory=openhands-tools`, |
| "--with", |
| `${baseGitUrl}#subdirectory=openhands-workspace`, |
| "--with", |
| AGENT_SERVER_POSTHOG_CONSTRAINT, |
| "agent-server", |
| ); |
| source = `git (${gitRef})`; |
| } else if (version) { |
| |
| |
| |
| uvxArgs.push( |
| "--from", |
| `${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`, |
| "--with", |
| `openhands-sdk==${version}`, |
| "--with", |
| `openhands-tools==${version}`, |
| "--with", |
| `openhands-workspace==${version}`, |
| ); |
| if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) { |
| uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT); |
| } |
| uvxArgs.push("--with", AGENT_SERVER_POSTHOG_CONSTRAINT); |
| uvxArgs.push("agent-server"); |
| source = `PyPI (${version})`; |
| } else { |
| |
| |
| uvxArgs.push( |
| "--from", |
| `${DEFAULT_AGENT_SERVER_PACKAGE}==${DEFAULT_AGENT_SERVER_VERSION}`, |
| "--with", |
| `openhands-sdk==${DEFAULT_AGENT_SERVER_VERSION}`, |
| "--with", |
| `openhands-tools==${DEFAULT_AGENT_SERVER_VERSION}`, |
| "--with", |
| `openhands-workspace==${DEFAULT_AGENT_SERVER_VERSION}`, |
| ); |
| if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) { |
| uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT); |
| } |
| uvxArgs.push("--with", AGENT_SERVER_POSTHOG_CONSTRAINT); |
| uvxArgs.push("agent-server"); |
| source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`; |
| } |
|
|
| |
| |
| uvxArgs.push("--import-modules", AGENT_SERVER_IMPORT_MODULES); |
|
|
| return { |
| command: "uvx", |
| args: uvxArgs, |
| source, |
| }; |
| } |
|
|
| function parsePort(value, fallback) { |
| if (value == null || value === "") { |
| return fallback; |
| } |
|
|
| const parsed = Number.parseInt(value, 10); |
| if (!Number.isInteger(parsed) || parsed <= 0) { |
| throw new Error(`Invalid port: ${value}`); |
| } |
|
|
| return parsed; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) { |
| const backendPort = parsePort( |
| env.OH_CANVAS_SAFE_BACKEND_PORT, |
| DEFAULT_BACKEND_PORT, |
| ); |
| const vscodePort = parsePort(env.OH_CANVAS_SAFE_VSCODE_PORT, backendPort + 1); |
|
|
| return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function buildSafeDevConfigAsync( |
| cwd = process.cwd(), |
| env = process.env, |
| ) { |
| |
| const preferredBackendPort = parsePort( |
| env.OH_CANVAS_SAFE_BACKEND_PORT, |
| DEFAULT_BACKEND_PORT, |
| ); |
| const preferredVscodePort = parsePort( |
| env.OH_CANVAS_SAFE_VSCODE_PORT, |
| preferredBackendPort + 1, |
| ); |
|
|
| |
| await assertPortsFree([ |
| { name: "agent-server", port: preferredBackendPort }, |
| { name: "vscode", port: preferredVscodePort }, |
| ]); |
|
|
| return buildConfigFromPorts( |
| { backendPort: preferredBackendPort, vscodePort: preferredVscodePort }, |
| cwd, |
| env, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| function buildConfigFromPorts(ports, cwd, env) { |
| const { backendPort, vscodePort } = ports; |
| const stateDir = path.resolve( |
| cwd, |
| env.OH_CANVAS_SAFE_STATE_DIR || |
| path.join(homedir(), ".openhands", "agent-canvas"), |
| ); |
| const conversationsPath = path.join(stateDir, "dev_conversations"); |
| const workspacesPath = path.join(stateDir, "workspaces"); |
| |
| |
| |
| |
| const secretKeyPath = env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH; |
| const secretKey = |
| env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret"); |
| |
| |
| |
| |
| |
| |
| |
| |
| const persistedKeyPath = env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH; |
| const sessionApiKey = |
| env.LOCAL_BACKEND_API_KEY || |
| getOrCreatePersistedApiKeyFile(persistedKeyPath); |
|
|
| |
| |
| |
| const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url)); |
|
|
| return { |
| cwd, |
| backendPort, |
| vscodePort, |
| vscodeBasePath: VSCODE_BASE_PATH, |
| stateDir, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| tmuxTmpDir: env.TMUX_TMPDIR || path.join(stateDir, "tmux"), |
| conversationsPath, |
| workspacesPath, |
| bashEventsDir: path.join(stateDir, "bash_events"), |
| backendBaseUrl: `http://127.0.0.1:${backendPort}`, |
| backendHost: `127.0.0.1:${backendPort}`, |
| workingDir: env.VITE_WORKING_DIR || workspacesPath, |
| secretKey, |
| sessionApiKey, |
| canvasToolsDir, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildAgentServerTelemetryEnv(env = process.env) { |
| const telemetryDisabled = |
| env.VITE_DO_NOT_TRACK === "1" || env.DO_NOT_TRACK === "1"; |
| const result = {}; |
|
|
| for (const key of [ |
| "OH_TELEMETRY_EXPORTER", |
| "OH_TELEMETRY_POSTHOG_API_KEY", |
| "OH_TELEMETRY_POSTHOG_HOST", |
| "OH_TELEMETRY_HTTP_ENDPOINT", |
| "OH_TELEMETRY_HTTP_TOKEN", |
| "OH_TELEMETRY_CONSENT", |
| "OH_TELEMETRY_CONSENT_MODE", |
| "OH_TELEMETRY_SALT", |
| ]) { |
| if (env[key]) result[key] = env[key]; |
| } |
|
|
| if (telemetryDisabled) { |
| result.DO_NOT_TRACK = "1"; |
| } |
|
|
| const apiKey = |
| env.OH_TELEMETRY_POSTHOG_API_KEY || |
| env.VITE_POSTHOG_API_KEY || |
| (telemetryDisabled ? "" : DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_API_KEY); |
| const exporter = env.OH_TELEMETRY_EXPORTER || (apiKey ? "posthog" : ""); |
|
|
| if (exporter) { |
| result.OH_TELEMETRY_EXPORTER = exporter; |
| } |
|
|
| if (exporter === "posthog" && apiKey) { |
| result.OH_TELEMETRY_POSTHOG_API_KEY = apiKey; |
| result.OH_TELEMETRY_POSTHOG_HOST = |
| env.OH_TELEMETRY_POSTHOG_HOST || |
| env.VITE_POSTHOG_HOST || |
| DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_HOST; |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function buildAgentServerEnv(config, options = {}) { |
| const { vscodeBasePath = null, env = process.env } = options; |
| return { |
| ...buildAgentServerTelemetryEnv(env), |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| PYTHONUTF8: "1", |
| TMUX_TMPDIR: config.tmuxTmpDir, |
| |
| OH_PERSISTENCE_DIR: path.dirname(config.stateDir), |
| OH_CONVERSATIONS_PATH: config.conversationsPath, |
| OH_BASH_EVENTS_DIR: config.bashEventsDir, |
| OH_VSCODE_PORT: String(config.vscodePort), |
| |
| |
| |
| |
| |
| |
| ...(vscodeBasePath ? { OH_VSCODE_BASE_PATH: vscodeBasePath } : {}), |
| OH_SECRET_KEY: config.secretKey, |
| |
| OH_SESSION_API_KEYS_0: config.sessionApiKey, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| AGENT_SERVER_URL: config.backendBaseUrl, |
| |
| |
| OH_EXTRA_PYTHON_PATH: config.canvasToolsDir, |
| }; |
| } |
|
|
| |
| |
| |
| export { buildRuntimeServicesInfo }; |
|
|
| export function buildNpmScriptCommand( |
| scriptName, |
| platform = process.platform, |
| env = process.env, |
| nodeExecPath = process.execPath, |
| ) { |
| |
| |
| |
| |
| |
| |
| |
| if (platform === "win32") { |
| return { |
| command: env.ComSpec || "cmd.exe", |
| args: ["/d", "/s", "/c", "npm", "run", scriptName], |
| }; |
| } |
|
|
| if (env.npm_execpath) { |
| return { |
| command: env.npm_node_execpath || nodeExecPath, |
| args: [env.npm_execpath, "run", scriptName], |
| }; |
| } |
|
|
| return { |
| command: "npm", |
| args: ["run", scriptName], |
| }; |
| } |
|
|
| export function validateLocalAgentServerPath(localPath) { |
| if (!path.isAbsolute(localPath)) { |
| throw new Error( |
| `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`, |
| ); |
| } |
| if (!existsSync(localPath)) { |
| throw new Error(`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`); |
| } |
| for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) { |
| const subdirPath = path.join(localPath, subdir); |
| if (!existsSync(subdirPath)) { |
| throw new Error( |
| `OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`, |
| ); |
| } |
| } |
| } |
|
|
| async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { |
| const startedAt = Date.now(); |
|
|
| while (Date.now() - startedAt < timeoutMs) { |
| try { |
| const response = await fetch(url); |
| if (response.ok) { |
| return; |
| } |
| } catch { |
| |
| } |
|
|
| await delay(500); |
| } |
|
|
| throw new Error(`Timed out waiting for agent-server at ${url}`); |
| } |
|
|
| function spawnProcess(command, args, options = {}) { |
| const child = spawn( |
| command, |
| args, |
| getProcessTreeSpawnOptions({ |
| stdio: "inherit", |
| ...options, |
| }), |
| ); |
|
|
| child.once("error", (error) => { |
| if (isEnoentError(error) && command === "uvx") { |
| const msg = formatMissingUvxGuidance(options?.cwd); |
| console.error(msg); |
| fileLog("error", stripAnsi(msg)); |
| } else if (isEnoentError(error)) { |
| const msg = `Failed to start ${command}. Make sure it is installed and on your PATH.`; |
| console.error(msg); |
| fileLog("error", msg); |
| } else { |
| console.error(`Failed to start ${command}:`, error); |
| fileLog("error", `Failed to start ${command}: ${error.message}`); |
| } |
| }); |
|
|
| return child; |
| } |
|
|
| async function main() { |
| console.log("Starting isolated agent-server + frontend dev stack..."); |
| fileLog("info", "Starting isolated agent-server + frontend dev stack..."); |
| validateFrontendDependencies(); |
| console.log("Frontend dependencies found."); |
| fileLog("info", "Frontend dependencies found."); |
| console.log("Allocating ports..."); |
| fileLog("info", "Allocating ports..."); |
|
|
| |
| const config = await buildSafeDevConfigAsync(); |
|
|
| if (process.env.OH_AGENT_SERVER_LOCAL_PATH) { |
| validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); |
| } |
|
|
| for (const dir of [ |
| config.stateDir, |
| config.tmuxTmpDir, |
| config.conversationsPath, |
| config.workspacesPath, |
| config.bashEventsDir, |
| ]) { |
| mkdirSync(dir, { recursive: true }); |
| } |
|
|
| const agentServerCmd = buildAgentServerCommand(); |
|
|
| const secretKeySource = process.env.OH_SECRET_KEY |
| ? "custom (from OH_SECRET_KEY)" |
| : `persisted (${process.env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH})`; |
|
|
| const sessionKeySource = process.env.LOCAL_BACKEND_API_KEY |
| ? "custom (from LOCAL_BACKEND_API_KEY)" |
| : `persisted (${ |
| process.env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH |
| })`; |
|
|
| console.log(`- agent-server: ${agentServerCmd.source}`); |
| console.log(`- backend: ${config.backendBaseUrl}`); |
| console.log(`- vscode port: ${config.vscodePort}`); |
| console.log(`- working dir: ${config.workingDir}`); |
| console.log(`- isolated state dir: ${config.stateDir}`); |
| console.log(`- secret key: ${secretKeySource}`); |
| console.log(`- session API key: ${sessionKeySource}`); |
| console.log(""); |
| fileLog( |
| "info", |
| [ |
| "Agent-server stack config:", |
| ` agent-server: ${agentServerCmd.source}`, |
| ` backend: ${config.backendBaseUrl}`, |
| ` working dir: ${config.workingDir}`, |
| ` state dir: ${config.stateDir}`, |
| ].join("\n"), |
| ); |
|
|
| const backend = spawnProcess( |
| agentServerCmd.command, |
| [ |
| ...agentServerCmd.args, |
| "--host", |
| "127.0.0.1", |
| "--port", |
| String(config.backendPort), |
| ], |
| { |
| cwd: config.cwd, |
| env: { |
| ...process.env, |
| |
| |
| |
| ...buildAgentServerEnv(config, { |
| vscodeBasePath: config.vscodeBasePath, |
| }), |
| }, |
| }, |
| ); |
|
|
| let shuttingDown = false; |
| let frontend = null; |
|
|
| const shutdown = (signal = "SIGTERM") => { |
| if (shuttingDown) { |
| return; |
| } |
|
|
| shuttingDown = true; |
| if (frontend) { |
| signalProcessTree(frontend, signal); |
| } |
| signalProcessTree(backend, signal); |
|
|
| setTimeout(() => { |
| if (frontend && isProcessRunning(frontend)) { |
| signalProcessTree(frontend, "SIGKILL"); |
| } |
| if (isProcessRunning(backend)) { |
| signalProcessTree(backend, "SIGKILL"); |
| } |
| process.exit(process.exitCode ?? 0); |
| }, 3000); |
| }; |
|
|
| process.on("SIGINT", () => shutdown("SIGINT")); |
| process.on("SIGTERM", () => shutdown("SIGTERM")); |
| |
| |
| |
| |
| |
| process.on("SIGHUP", () => shutdown("SIGTERM")); |
|
|
| const backendErrored = new Promise((_, reject) => { |
| backend.once("error", (error) => reject(error)); |
| }); |
| const backendExited = new Promise((_, reject) => { |
| backend.once("exit", (code, signal) => { |
| if (!shuttingDown) { |
| reject( |
| new Error( |
| `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`, |
| ), |
| ); |
| } |
| }); |
| }); |
|
|
| try { |
| await Promise.race([ |
| waitForServer(`${config.backendBaseUrl}/server_info`), |
| backendErrored, |
| backendExited, |
| ]); |
| } catch (error) { |
| shutdown(); |
| throw error; |
| } |
|
|
| const frontendCommand = buildNpmScriptCommand("dev:frontend"); |
| frontend = spawnProcess(frontendCommand.command, frontendCommand.args, { |
| cwd: config.cwd, |
| env: { |
| ...process.env, |
| VITE_BACKEND_HOST: config.backendHost, |
| VITE_BACKEND_BASE_URL: config.backendBaseUrl, |
| VITE_WORKING_DIR: config.workingDir, |
| |
| VITE_SESSION_API_KEY: config.sessionApiKey, |
| |
| |
| |
| |
| VITE_VSCODE_BASE_PATH: config.vscodeBasePath, |
| VITE_VSCODE_TARGET: `http://127.0.0.1:${config.vscodePort}`, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| }, |
| }); |
|
|
| frontend.once("exit", (code) => { |
| shutdown(); |
| process.exitCode = code ?? 0; |
| }); |
|
|
| backend.once("exit", (code) => { |
| if (!shuttingDown) { |
| const msg = `agent-server exited unexpectedly with code ${code ?? 0}`; |
| console.error(msg); |
| fileLog("error", msg); |
| shutdown(); |
| process.exitCode = code ?? 1; |
| } |
| }); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| export function isPortBusy(port, host = "127.0.0.1", timeoutMs = 500) { |
| return new Promise((resolve) => { |
| const socket = new net.Socket(); |
| let settled = false; |
| const finish = (busy) => { |
| if (settled) return; |
| settled = true; |
| socket.destroy(); |
| resolve(busy); |
| }; |
| socket.setTimeout(timeoutMs); |
| socket.once("connect", () => finish(true)); |
| socket.once("timeout", () => finish(false)); |
| socket.once("error", () => finish(false)); |
| socket.connect(port, host); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function releaseStaleConversationLeases(conversationsDir) { |
| if (!existsSync(conversationsDir)) return 0; |
|
|
| let removed = 0; |
| for (const name of readdirSync(conversationsDir)) { |
| const convDir = path.join(conversationsDir, name); |
| let isDir = false; |
| try { |
| isDir = statSync(convDir).isDirectory(); |
| } catch { |
| continue; |
| } |
| if (!isDir) continue; |
|
|
| const leasePath = path.join(convDir, "owner_lease.json"); |
| if (!existsSync(leasePath)) continue; |
| try { |
| unlinkSync(leasePath); |
| removed += 1; |
| } catch { |
| |
| |
| } |
| } |
| return removed; |
| } |
|
|
| if ( |
| process.argv[1] && |
| import.meta.url === pathToFileURL(process.argv[1]).href |
| ) { |
| main().catch((error) => { |
| const msg = error instanceof Error ? error.message : String(error); |
| console.error(msg); |
| fileLog("error", `Fatal error: ${msg}`); |
| if (error instanceof Error && error.stack) { |
| fileLog("error", error.stack); |
| } |
| process.exit(1); |
| }); |
| } |
|
|