import { createHash, randomUUID } from "node:crypto"; import { constants, existsSync, readdirSync, readFileSync } from "node:fs"; import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { stderr, stdin } from "node:process"; import { createInterface } from "node:readline/promises"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { getPackageDir } from "../../config.js"; import { isProcessAlive, spawnHidden } from "../../utils/child-process.js"; import { tryAcquireDirLock } from "../../utils/dir-lock.js"; import type { PythonSkillRuntimeInfo } from "../skills.js"; const BOOTSTRAP_SCHEMA = 9; const PYTHON_VERSION = "3.11"; const RUNTIME_REQUIREMENT = "prime-agent-runtime"; // Serializes the kernel's user namespace so it can be revived across session // resume. Internal-only; intentionally not surfaced to the model as an import. const STATE_SNAPSHOT_REQUIREMENT = "dill"; const DEFAULT_RLM_EXTRA_PACKAGES = [ { uvArg: "requests", importName: "requests", promptLabel: "requests" }, { uvArg: "httpx", importName: "httpx", promptLabel: "httpx" }, { uvArg: "pyyaml", importName: "yaml", promptLabel: "yaml (PyYAML)" }, { uvArg: "tomli", importName: "tomli", promptLabel: "tomli" }, { uvArg: "python-dotenv", importName: "dotenv", promptLabel: "dotenv (python-dotenv)" }, { uvArg: "pandas", importName: "pandas", promptLabel: "pandas" }, { uvArg: "numpy", importName: "numpy", promptLabel: "numpy" }, { uvArg: "scipy", importName: "scipy", promptLabel: "scipy" }, { uvArg: "beautifulsoup4", importName: "bs4", promptLabel: "bs4 (Beautiful Soup)" }, { uvArg: "lxml", importName: "lxml", promptLabel: "lxml" }, { uvArg: "pydantic", importName: "pydantic", promptLabel: "pydantic" }, { uvArg: "tyro", importName: "tyro", promptLabel: "tyro" }, ]; export const DEFAULT_RLM_EXTRA_UV_ARGS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.uvArg); export const DEFAULT_RLM_EXTRA_IMPORT_NAMES = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.importName); export const DEFAULT_RLM_EXTRA_IMPORT_LABELS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.promptLabel); const WINDOWS_PATHEXT_DEFAULT = [".COM", ".EXE", ".BAT", ".CMD"]; const WINDOWS_SUPPORTED_EXECUTABLE_EXTENSIONS = new Set( WINDOWS_PATHEXT_DEFAULT.map((extension) => extension.toLowerCase()), ); export interface BatchShimInvocation { args: string[]; env: NodeJS.ProcessEnv; } /** Build a cmd.exe invocation without embedding user-controlled values in its command string. */ export function buildBatchShimInvocation( command: string, args: readonly string[], baseEnv: NodeJS.ProcessEnv, token = randomUUID().replaceAll("-", ""), ): BatchShimInvocation { if (!/^[A-Za-z0-9_]+$/.test(token)) { throw new Error("Windows batch shim token contains unsupported characters"); } const values = [command, ...args]; if (values.some((value) => /["\0\r\n]/.test(value))) { throw new Error("Windows batch shim paths and arguments cannot contain quotes, NUL, or line breaks"); } const env = { ...baseEnv }; const variables = values.map((value, index) => { const name = `PRIME_AGENT_BATCH_${token}_${index}`; env[name] = value; return `"%${name}%"`; }); return { args: ["/d", "/v:off", "/s", "/c", `"${variables.join(" ")}"`], env, }; } const UV_INSTALL_COMMAND = "curl -LsSf https://astral.sh/uv/install.sh | sh"; /** MCP discovery surface the kernel runtime must expose for /plugins work. */ const REQUIRED_MCP_DISCOVERY_METHODS = [ "list_plugins", "search_plugins", "list_connections", "search_tools", "describe_tool", ]; const REQUIRED_HARNESS_METHODS = [ "create_memory", "update_memory", "delete_memory", "create_skill", "update_skill", "delete_skill", "create_subagent", "update_subagent", "delete_subagent", "create_prompt_note", "update_prompt_note", "delete_prompt_note", "record_refinement", ]; export const RUNTIME_READY_CHECK = `import inspect; import rlm; import agent; from agent import sessions; from rlm import McpIntegration; import rlm.mcp as mcp; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert callable(mcp.list_tools); assert callable(mcp.call_tool); assert all(callable(getattr(mcp, _m, None)) for _m in ${JSON.stringify(REQUIRED_MCP_DISCOVERY_METHODS)}), "rlm.mcp is missing MCP discovery methods (list_plugins, search_plugins, list_connections, search_tools, describe_tool); the kernel venv needs a current prime-agent-runtime"; assert callable(rlm.spawn); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm.spawn); assert inspect.signature(rlm.spawn).parameters['name'].default is inspect.Parameter.empty; assert not hasattr(rlm, 'run'); assert not hasattr(rlm.rlm, 'run'); assert callable(rlm.host_request); assert callable(sessions.list); assert callable(sessions.get); assert callable(rlm.models); assert callable(rlm.rlm.models); assert not hasattr(rlm, 'roles'); assert not hasattr(rlm.rlm, 'roles'); assert not hasattr(rlm, 'find_models'); assert not hasattr(rlm.rlm, 'find_models'); assert 'model' in inspect.signature(rlm.spawn).parameters; assert inspect.signature(rlm.spawn).parameters['model'].default is inspect.Parameter.empty; assert 'role' not in inspect.signature(rlm.spawn).parameters; assert 'thinking' in inspect.signature(rlm.spawn).parameters; assert callable(rlm.create_session); assert callable(rlm.rlm.create_session); assert callable(rlm.progress_note); assert callable(rlm.rlm.progress_note); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'scope' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert 'global_' in inspect.signature(rlm.harness.create_memory).parameters; assert 'global_' in inspect.signature(rlm.get_harness_state).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background'); from rlm.bash import BashHandle, BashResult; assert callable(rlm.bash); assert all(callable(getattr(BashHandle, _m, None)) for _m in ('tail', 'text', 'poll', 'kill')); assert callable(getattr(BashResult, 'text', None)); assert {'exit_code', 'duration', 'stdout', 'stderr'} <= set(BashResult.__dataclass_fields__); import rlm.repl as _repl; assert callable(_repl.main); assert callable(_repl.emit); assert callable(_repl.host_request); assert callable(_repl.is_active); assert _repl.PROTOCOL_VERSION == 3; assert callable(rlm.emit); assert not hasattr(rlm, 'HOST_COMM_TARGET'); assert not hasattr(mcp, 'install_shutdown_hook')`; const BOOTSTRAP_VERSION_FILE = ".bootstrap-version"; export const KERNEL_VENV_LOCK_SUFFIX = ".bootstrap.lock"; /** A lock this old without a readable, live owner pid is a crashed build, not a running one. */ export const KERNEL_VENV_LOCK_STALE_MS = 30_000; const BOOTSTRAP_LOCK_RETRY_MS = 100; // A builder holds the lock for a whole cold install (network, wheels, editable // skills), so a waiter bounds its wait instead of failing on the first slow build. const DEFAULT_BOOTSTRAP_LOCK_WAIT_MS = 10 * 60_000; // Staged and replaced trees live beside the live venv: same filesystem, so the // promotion below is a rename, and the live path never holds a partial tree. const VENV_STAGING_SUFFIX = ".staging"; const VENV_REPLACED_SUFFIX = ".replaced"; const VENV_SIBLING_SWEEP_AGE_MS = 60 * 60_000; let inFlightEnsureKernelPython: { key: string; promise: Promise } | null = null; export type KernelPythonSkill = PythonSkillRuntimeInfo; export type KernelBootstrapProgressHandler = (message: string) => void; export interface EnsureKernelPythonOptions { pythonSkills?: readonly KernelPythonSkill[]; onProgress?: KernelBootstrapProgressHandler; } interface BootstrapPythonSkill { importName: string; packagePath: string; pyprojectPath: string; pyprojectHash: string; } interface BootstrapVersion { schema: number; runtime?: string; snapshot?: string; extraUvArgs?: string[]; pythonSkills?: BootstrapPythonSkill[]; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } async function exists(filePath: string): Promise { try { await access(filePath); return true; } catch { return false; } } async function isExecutable(filePath: string): Promise { try { await access(filePath, constants.X_OK); return true; } catch { return false; } } function expandHome(filePath: string): string { if (filePath === "~") return os.homedir(); if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2)); return filePath; } function fileContentHash(filePath: string): string { try { return `sha256:${createHash("sha256").update(readFileSync(filePath)).digest("hex")}`; } catch { return "unreadable"; } } function normalizePythonSkills(pythonSkills: readonly KernelPythonSkill[] | undefined): BootstrapPythonSkill[] { const byKey = new Map(); const addSkill = (skill: Pick): void => { const packagePath = path.resolve(skill.packagePath); const pyprojectPath = path.resolve(skill.pyprojectPath); const key = `${skill.importName}\0${packagePath}`; if (byKey.has(key)) { return; } const bootstrapSkill: BootstrapPythonSkill = { importName: skill.importName, packagePath, pyprojectPath, pyprojectHash: fileContentHash(pyprojectPath), }; byKey.set(key, bootstrapSkill); for (const dependencyName of readPythonSkillDependencyNames(bootstrapSkill)) { const siblingDependency = resolveSiblingPythonSkillDependency(bootstrapSkill, dependencyName); if (siblingDependency) { addSkill(siblingDependency); } } }; for (const skill of pythonSkills ?? []) { addSkill(skill); } return [...byKey.values()].sort((a, b) => { const packageCompare = a.packagePath.localeCompare(b.packagePath); if (packageCompare !== 0) return packageCompare; return a.importName.localeCompare(b.importName); }); } function readTomlProjectSection(pyprojectPath: string): string | undefined { try { const text = readFileSync(pyprojectPath, "utf-8"); const match = text.match(/^\s*\[project\]\s*$/m); if (!match || match.index === undefined) { return undefined; } const sectionStart = match.index + match[0].length; const rest = text.slice(sectionStart); const nextSection = rest.search(/^\s*\[/m); return nextSection >= 0 ? rest.slice(0, nextSection) : rest; } catch { return undefined; } } function readPythonSkillProjectName(skill: BootstrapPythonSkill): string { const projectSection = readTomlProjectSection(skill.pyprojectPath); const name = projectSection?.match(/^\s*name\s*=\s*["']([^"']+)["']/m)?.[1]; return name?.trim() || skill.importName.replaceAll("_", "-"); } function parseDependencyPackageName(dependency: string): string | undefined { const withoutMarker = dependency.split(";")[0]?.trim() ?? ""; if (!withoutMarker) { return undefined; } const match = withoutMarker.match(/^([A-Za-z0-9_.-]+)/); return match?.[1]?.replaceAll("_", "-").toLowerCase(); } function findTomlArrayEnd(text: string, startIndex: number): number { let inQuote: '"' | "'" | undefined; let escaped = false; for (let index = startIndex; index < text.length; index++) { const char = text[index]; if (inQuote) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (char === inQuote) { inQuote = undefined; } continue; } if (char === '"' || char === "'") { inQuote = char; continue; } if (char === "]") { return index; } } return -1; } function readPythonSkillDependencyNames(skill: BootstrapPythonSkill): Set { const projectSection = readTomlProjectSection(skill.pyprojectPath); if (!projectSection) { return new Set(); } const dependenciesStart = projectSection.search(/^\s*dependencies\s*=\s*\[/m); if (dependenciesStart < 0) { return new Set(); } const arrayStart = projectSection.indexOf("[", dependenciesStart); if (arrayStart < 0) { return new Set(); } const arrayEnd = findTomlArrayEnd(projectSection, arrayStart + 1); if (arrayEnd < 0) { return new Set(); } const dependenciesArray = projectSection.slice(arrayStart, arrayEnd + 1); const dependencies = new Set(); const dependencyPattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; for (const match of dependenciesArray.matchAll(dependencyPattern)) { const dependency = (match[1] ?? match[2] ?? "").replaceAll('\\"', '"').replaceAll("\\'", "'"); const name = parseDependencyPackageName(dependency); if (name) { dependencies.add(name); } } return dependencies; } function resolveSiblingPythonSkillDependency( skill: BootstrapPythonSkill, dependencyName: string, ): BootstrapPythonSkill | undefined { const siblingsDir = path.dirname(skill.packagePath); for (const entry of readdirSync(siblingsDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } const packagePath = path.join(siblingsDir, entry.name); const pyprojectPath = path.join(packagePath, "pyproject.toml"); if (!existsSync(pyprojectPath)) { continue; } const dependency: BootstrapPythonSkill = { importName: entry.name.replaceAll("-", "_"), packagePath, pyprojectPath, pyprojectHash: fileContentHash(pyprojectPath), }; if (readPythonSkillProjectName(dependency).replaceAll("_", "-").toLowerCase() === dependencyName) { return dependency; } } return undefined; } function sortPythonSkillsForInstall(pythonSkills: readonly BootstrapPythonSkill[]): BootstrapPythonSkill[] { const byProjectName = new Map(); const originalIndex = new Map(); for (const [index, skill] of pythonSkills.entries()) { originalIndex.set(skill, index); byProjectName.set(readPythonSkillProjectName(skill).replaceAll("_", "-").toLowerCase(), skill); } const dependenciesBySkill = new Map(); for (const skill of pythonSkills) { dependenciesBySkill.set( skill, [...readPythonSkillDependencyNames(skill)] .map( (dependencyName) => byProjectName.get(dependencyName) ?? resolveSiblingPythonSkillDependency(skill, dependencyName), ) .filter((dependency): dependency is BootstrapPythonSkill => Boolean(dependency)), ); } const pending = new Set(pythonSkills); const sorted: BootstrapPythonSkill[] = []; while (pending.size > 0) { let progressed = false; for (const skill of [...pending].sort((a, b) => (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0))) { const dependencies = dependenciesBySkill.get(skill) ?? []; if (dependencies.some((dependency) => pending.has(dependency))) { continue; } sorted.push(skill); pending.delete(skill); progressed = true; } if (!progressed) { // Cyclic local skill dependencies cannot be topologically ordered; keep a // deterministic order and let uv surface the packaging error if needed. sorted.push(...[...pending].sort((a, b) => a.packagePath.localeCompare(b.packagePath))); break; } } return sorted; } function formatPythonSkillInstallArgs(skill: BootstrapPythonSkill): string[] { return ["--editable", skill.packagePath]; } function ensureKernelPythonKey(pythonSkills: readonly BootstrapPythonSkill[]): string { return [ process.env.PRIME_AGENT_KERNEL_PYTHON ?? "", process.env.PRIME_AGENT_KERNEL_VENV ?? "", process.env.HOME ?? "", process.env.XDG_DATA_HOME ?? "", JSON.stringify(pythonSkills), ].join("\0"); } export function getKernelVenvDir(): string { const override = process.env.PRIME_AGENT_KERNEL_VENV; if (override) return path.resolve(expandHome(override)); return path.join(os.homedir(), ".prime", "agent", "kernel-venv"); } function getXdgKernelVenvDir(): string { const dataHome = process.env.XDG_DATA_HOME ? path.resolve(expandHome(process.env.XDG_DATA_HOME)) : path.join(os.homedir(), ".local", "share"); return path.join(dataHome, "prime", "agent", "kernel-venv"); } async function resolveWritableKernelVenvDir(): Promise { const primary = getKernelVenvDir(); try { await mkdir(path.dirname(primary), { recursive: true }); return primary; } catch (primaryError) { if (process.env.PRIME_AGENT_KERNEL_VENV) { throw new Error(`couldn't create kernel venv parent directory for ${primary}: ${errorMessage(primaryError)}`); } const fallback = getXdgKernelVenvDir(); try { await mkdir(path.dirname(fallback), { recursive: true }); return fallback; } catch (fallbackError) { throw new Error( `couldn't create kernel venv directory at ${primary} or ${fallback}; set PRIME_AGENT_KERNEL_PYTHON to a python with a current prime-agent-runtime installed. ${errorMessage(fallbackError)}`, ); } } } function isBatchShim(command: string): boolean { return process.platform === "win32" && /\.(cmd|bat)$/i.test(command); } function run(command: string, args: string[], options: { stdio?: "ignore" | "inherit" } = {}): Promise { return new Promise((resolve, reject) => { // CPython must read UTF-8 .pth files even under a Windows legacy code page. const env = { ...process.env, ...(process.platform === "win32" ? { PYTHONUTF8: "1" } : {}) }; const batch = isBatchShim(command) ? buildBatchShimInvocation(command, args, env) : undefined; const child = spawnHidden(batch ? (process.env.ComSpec ?? "cmd.exe") : command, batch?.args ?? args, { env: batch?.env ?? env, stdio: options.stdio ?? "ignore", ...(batch ? { windowsVerbatimArguments: true } : {}), }); child.on("error", reject); child.on("exit", (code, signal) => { if (code === 0) { resolve(); return; } const reason = signal ? `signal ${signal}` : `exit code ${code}`; reject(new Error(`${command} ${args.join(" ")} failed with ${reason}`)); }); }); } async function pythonImports(python: string, moduleName: string): Promise { try { await run(python, ["-c", `import ${moduleName}`], { stdio: "ignore" }); return true; } catch { return false; } } async function hasPrimeAgentRuntime(python: string): Promise { try { await run(python, ["-c", RUNTIME_READY_CHECK], { stdio: "ignore" }); return true; } catch { return false; } } async function missingRlmExtraImportLabels(python: string): Promise { const missing: string[] = []; for (const pkg of DEFAULT_RLM_EXTRA_PACKAGES) { if (!(await pythonImports(python, pkg.importName))) { missing.push(pkg.promptLabel); } } return missing; } async function missingPythonSkillImportLabels( python: string, pythonSkills: readonly KernelPythonSkill[], ): Promise { const missing: string[] = []; for (const skill of pythonSkills) { if (!(await pythonImports(python, skill.importName))) { missing.push(`${skill.name} (${skill.importName})`); } } return missing; } function reportProgress(options: EnsureKernelPythonOptions, message: string): void { if (options.onProgress) { options.onProgress(message); return; } process.stderr.write(`${message}\n`); } function bootstrapLockDir(venv: string): string { return path.join(path.dirname(venv), `${path.basename(venv)}${KERNEL_VENV_LOCK_SUFFIX}`); } async function lockMissingPidIsStale(lockDir: string): Promise { try { const lockStat = await stat(lockDir); return Date.now() - lockStat.mtimeMs > KERNEL_VENV_LOCK_STALE_MS; } catch { return false; } } export function formatBootstrapLockOwner(pid: number, bootToken: string = `${Date.now()}-${randomUUID()}`): string { return `${pid}:${bootToken}\n`; } export function parseBootstrapLockOwner(raw: string): { pid: number; bootToken?: string } | undefined { const trimmed = raw.trim(); if (!trimmed) { return undefined; } const parts = trimmed.split(/[\s:]+/); const pid = Number.parseInt(parts[0], 10); if (!Number.isInteger(pid) || pid <= 0) { return undefined; } return { pid, bootToken: parts[1] }; } // Shared with the kernel-side bootstrap, which uses the same name to bound its own wait. function bootstrapLockWaitMs(): number { const seconds = Number.parseFloat(process.env.PRIME_AGENT_KERNEL_BOOTSTRAP_WAIT_SECONDS ?? ""); return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : DEFAULT_BOOTSTRAP_LOCK_WAIT_MS; } async function acquireBootstrapLock(venv: string, options: EnsureKernelPythonOptions): Promise<() => Promise> { const lockDir = bootstrapLockDir(venv); const waitMs = bootstrapLockWaitMs(); await mkdir(path.dirname(lockDir), { recursive: true }); const ownerContent = formatBootstrapLockOwner(process.pid); let deadline: number | undefined; for (;;) { const attempt = await tryAcquireDirLock( lockDir, async (ownerPid) => ownerPid === undefined ? !(await lockMissingPidIsStale(lockDir)) : isProcessAlive(ownerPid), ownerContent, ); if (attempt === "acquired") { return () => rm(lockDir, { recursive: true, force: true }); } if (attempt === "held") { if (deadline === undefined) { // Whoever holds the lock is rebuilding the venv; importing it now would // read a tree mid-install, so wait for the build instead. deadline = Date.now() + waitMs; reportProgress( options, `› another prime-agent process is building the kernel venv at ${venv}; waiting for it to finish…`, ); } else if (Date.now() >= deadline) { throw new Error( `another prime-agent process has held the kernel venv build lock at ${lockDir} for over ${Math.round(waitMs / 1000)}s. ` + "If that process is gone, remove that file and retry; otherwise wait for it to finish. " + "Set PRIME_AGENT_KERNEL_PYTHON to a Python with a current prime-agent-runtime to skip the shared venv.", ); } await sleep(BOOTSTRAP_LOCK_RETRY_MS); } } } function venvSiblingPath(venv: string, suffix: string): string { return path.join(path.dirname(venv), `${path.basename(venv)}${suffix}-${process.pid}-${randomUUID().slice(0, 8)}`); } /** The directory a build stages into before it is promoted onto the live path. */ function kernelVenvStagingDir(venv: string): string { return venvSiblingPath(venv, VENV_STAGING_SUFFIX); } /** Collect staged or replaced trees a crashed build left behind. */ async function sweepStaleVenvSiblings(venv: string): Promise { const directory = path.dirname(venv); const prefixes = [`${path.basename(venv)}${VENV_STAGING_SUFFIX}-`, `${path.basename(venv)}${VENV_REPLACED_SUFFIX}-`]; let entries: string[]; try { entries = await readdir(directory); } catch { return; } for (const name of entries) { if (!prefixes.some((prefix) => name.startsWith(prefix))) continue; const sibling = path.join(directory, name); try { const siblingStat = await stat(sibling); if (Date.now() - siblingStat.mtimeMs < VENV_SIBLING_SWEEP_AGE_MS) continue; await rm(sibling, { recursive: true, force: true }); } catch { // Litter collection only: the next build retries. } } } /** * Move a completed staged tree onto the live venv path. rename(2) cannot replace a * non-empty directory, so the swap is two renames: the live path holds the previous * complete tree, nothing, or the new complete tree - never a partially installed one. */ async function verifyStagedKernelVenv(python: string, staging: string, venv: string): Promise { if (!(await hasPrimeAgentRuntime(python))) { throw new Error( `the staged kernel venv at ${staging} does not satisfy the prime-agent-runtime check; ` + `keeping the previous kernel venv at ${venv}. Re-run once the runtime install is fixed.`, ); } } async function promoteKernelVenv(venv: string, staging: string): Promise { const replaced = existsSync(venv) ? venvSiblingPath(venv, VENV_REPLACED_SUFFIX) : undefined; if (replaced) { await rename(venv, replaced); } try { await rename(staging, venv); } catch (error) { if (replaced) { await rename(replaced, venv).catch(() => undefined); } throw error; } if (replaced) { await rm(replaced, { recursive: true, force: true }).catch(() => undefined); } } /** Try a bare command followed by supported PATHEXT extensions in the configured order. */ export function windowsExecutableCandidates(name: string, pathext: string | undefined): string[] { const extensions = (pathext ?? "") .split(";") .map((ext) => ext.trim().toLowerCase()) .filter((ext) => WINDOWS_SUPPORTED_EXECUTABLE_EXTENSIONS.has(ext)); const lowerName = name.toLowerCase(); if (WINDOWS_PATHEXT_DEFAULT.some((ext) => lowerName.endsWith(ext.toLowerCase()))) { return [name]; } const seen = new Set([name.toLowerCase()]); const candidates = [name]; for (const ext of extensions.length > 0 ? extensions : WINDOWS_PATHEXT_DEFAULT) { const candidate = `${name}${ext}`; if (seen.has(candidate.toLowerCase())) continue; seen.add(candidate.toLowerCase()); candidates.push(candidate); } return candidates; } async function findExecutable(name: string): Promise { const pathValue = process.env.PATH; if (!pathValue) return null; const candidates = process.platform === "win32" ? windowsExecutableCandidates(name, process.env.PATHEXT) : [name]; for (const dir of pathValue.split(path.delimiter)) { if (!dir) continue; for (const candidate of candidates) { const fullPath = path.join(dir, candidate); if (await isExecutable(fullPath)) return fullPath; } } return null; } async function ensureUv(options: EnsureKernelPythonOptions): Promise { const fromPath = await findExecutable("uv"); if (fromPath) return fromPath; const localUv = path.join(os.homedir(), ".local", "bin", process.platform === "win32" ? "uv.exe" : "uv"); if (await isExecutable(localUv)) return localUv; const shouldInstallUv = process.env.PRIME_AGENT_INSTALL_UV === "1" || (!options.onProgress && (await confirmUvInstall())); if (!shouldInstallUv) { throw new Error( `uv is required to set up the Python kernel. Install uv yourself: ${UV_INSTALL_COMMAND}, ` + "or set PRIME_AGENT_INSTALL_UV=1 to let prime-agent run that installer.", ); } reportProgress(options, "› installing uv (one-time)…"); try { await run("sh", ["-c", UV_INSTALL_COMMAND], { stdio: options.onProgress ? "ignore" : "inherit" }); } catch (error) { throw new Error( `couldn't install uv from astral.sh; install it yourself: ${UV_INSTALL_COMMAND}, then re-run prime-agent. ${errorMessage(error)}`, ); } if (await isExecutable(localUv)) return localUv; const installedFromPath = await findExecutable("uv"); if (installedFromPath) return installedFromPath; throw new Error("uv install completed but binary not found at ~/.local/bin/uv"); } async function confirmUvInstall(): Promise { if (process.env.PRIME_AGENT_INSTALL_UV === "0") return false; if (!stdin.isTTY || !stderr.isTTY) return false; const rl = createInterface({ input: stdin, output: stderr }); try { const answer = (await rl.question("Prime Agent needs uv to set up Python. Install uv from astral.sh now? [Y/n] ")) .trim() .toLowerCase(); return answer !== "n" && answer !== "no"; } finally { rl.close(); } } async function readBootstrapVersion(venv: string): Promise { try { const raw = await readFile(path.join(venv, BOOTSTRAP_VERSION_FILE), "utf8"); const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed) || typeof parsed.schema !== "number") return null; const extraUvArgs = Array.isArray(parsed.extraUvArgs) && parsed.extraUvArgs.every((v: unknown): v is string => typeof v === "string") ? (parsed.extraUvArgs as string[]) : undefined; let pythonSkills: BootstrapPythonSkill[] | undefined; if (Array.isArray(parsed.pythonSkills)) { if ( !parsed.pythonSkills.every((v: unknown): v is BootstrapPythonSkill => { if (!isRecord(v)) return false; return ( typeof v.importName === "string" && typeof v.packagePath === "string" && typeof v.pyprojectPath === "string" && typeof v.pyprojectHash === "string" ); }) ) { return null; } pythonSkills = parsed.pythonSkills as BootstrapPythonSkill[]; } return { schema: parsed.schema, runtime: typeof parsed.runtime === "string" ? parsed.runtime : undefined, snapshot: typeof parsed.snapshot === "string" ? parsed.snapshot : undefined, extraUvArgs, pythonSkills, }; } catch { return null; } } function extraUvArgsMatch(a: string[] | undefined, b: string[] | undefined): boolean { if (a === b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; return a.every((v, i) => v === b[i]); } function pythonSkillsMatch(a: BootstrapPythonSkill[] | undefined, b: readonly BootstrapPythonSkill[]): boolean { const left = a ?? []; if (left.length !== b.length) return false; return left.every((skill, index) => { const expected = b[index]; return ( skill.importName === expected.importName && skill.packagePath === expected.packagePath && skill.pyprojectPath === expected.pyprojectPath && skill.pyprojectHash === expected.pyprojectHash ); }); } function bootstrapVersionCurrent( version: BootstrapVersion | null, runtimeIdentity: string, pythonSkills: readonly BootstrapPythonSkill[], ): boolean { return ( version !== null && bootstrapBaseVersionCurrent(version, runtimeIdentity) && pythonSkillsMatch(version.pythonSkills, pythonSkills) ); } function bootstrapBaseVersionCurrent(version: BootstrapVersion | null, runtimeIdentity: string): boolean { return ( version?.schema === BOOTSTRAP_SCHEMA && version.runtime === runtimeIdentity && version.snapshot === STATE_SNAPSHOT_REQUIREMENT && extraUvArgsMatch(version.extraUvArgs, DEFAULT_RLM_EXTRA_UV_ARGS) ); } async function writeBootstrapVersion( venv: string, runtimeIdentity: string, pythonSkills: readonly BootstrapPythonSkill[], ): Promise { const version: BootstrapVersion = { schema: BOOTSTRAP_SCHEMA, runtime: runtimeIdentity, snapshot: STATE_SNAPSHOT_REQUIREMENT, extraUvArgs: DEFAULT_RLM_EXTRA_UV_ARGS, pythonSkills: [...pythonSkills], }; await writeFile(path.join(venv, BOOTSTRAP_VERSION_FILE), `${JSON.stringify(version)}\n`, "utf8"); } function runtimeCandidateDirs(): string[] { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); // Compiled executables use a flat sidecar layout; Node packages keep sources in dist/. // Resolve both from the physical package directory, outside Bun's virtual filesystem. return [ path.join(getPackageDir(), "prime-agent-runtime"), path.join(getPackageDir(), "dist", "prime-agent-runtime"), path.resolve(moduleDir, "..", "..", "prime-agent-runtime"), path.resolve(moduleDir, "..", "..", "..", "..", "..", "prime-agent-runtime"), ]; } export async function resolveRuntimeSourceDir(): Promise { for (const candidate of runtimeCandidateDirs()) { if (await exists(path.join(candidate, "pyproject.toml"))) { return candidate; } } return null; } // Identity of the runtime to be installed. For a local source checkout this is a // content hash of every Python source file plus pyproject.toml, so any runtime code // or dependency change invalidates an existing venv automatically. Falls back to // the bare package name when the runtime resolves to a registry install (no local source). export async function resolveRuntimeIdentity(): Promise { const sourceDir = await resolveRuntimeSourceDir(); if (!sourceDir) return RUNTIME_REQUIREMENT; return hashRuntimeSource(sourceDir); } // Throws if the local source can't be read. A failure here must surface rather than // fall back to RUNTIME_REQUIREMENT: that constant is the registry-install identity, and // recording it for a local checkout would permanently mask later source changes. async function hashRuntimeSource(sourceDir: string): Promise { const srcDir = path.join(sourceDir, "src"); const files: string[] = [path.join(sourceDir, "pyproject.toml")]; async function collect(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { await collect(full); } else if (entry.isFile() && entry.name.endsWith(".py")) { files.push(full); } } } await collect(srcDir); files.sort(); const hash = createHash("sha256"); for (const file of files) { hash.update(path.relative(sourceDir, file)); hash.update("\0"); hash.update(await readFile(file)); hash.update("\0"); } return `sha256:${hash.digest("hex")}`; } export function kernelVenvPython(venv: string, platform: NodeJS.Platform = process.platform): string { return platform === "win32" ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python"); } async function bootstrapVenv( venv: string, pythonSkills: readonly BootstrapPythonSkill[], options: EnsureKernelPythonOptions, ): Promise { await mkdir(path.dirname(venv), { recursive: true }); await sweepStaleVenvSiblings(venv); const uv = await ensureUv(options); const sourceDir = await resolveRuntimeSourceDir(); const runtimeRequirement = sourceDir ?? RUNTIME_REQUIREMENT; const runtimeIdentity = await resolveRuntimeIdentity(); // Build the whole tree beside the live path - runtime, extras, and every editable // Python skill - and rename it in only when the manifest is written. A kernel that // imports while this runs sees the previous complete venv, never a half-built one. const staging = kernelVenvStagingDir(venv); const python = kernelVenvPython(staging); try { await run(uv, ["python", "install", PYTHON_VERSION]); // Relocatable keeps bin/ shebangs relative to the venv, so they still resolve // after the staged tree is renamed onto the live path. await run(uv, ["venv", staging, "--python", PYTHON_VERSION, "--seed", "--relocatable"]); await run(uv, [ "pip", "install", "--python", python, runtimeRequirement, STATE_SNAPSHOT_REQUIREMENT, ...DEFAULT_RLM_EXTRA_UV_ARGS, ]); await syncPythonSkills(uv, staging, python, runtimeIdentity, pythonSkills, options); await verifyStagedKernelVenv(python, staging, venv); } catch (error) { await rm(staging, { recursive: true, force: true }).catch(() => undefined); throw error; } await promoteKernelVenv(venv, staging); } async function syncPythonSkills( uv: string, venv: string, python: string, runtimeIdentity: string, pythonSkills: readonly BootstrapPythonSkill[], options: EnsureKernelPythonOptions, ): Promise { const version = await readBootstrapVersion(venv); const installedPythonSkills: BootstrapPythonSkill[] = []; const currentPythonSkills = new Map( (version?.pythonSkills ?? []).map((skill) => [`${skill.importName}\0${skill.packagePath}`, skill]), ); const pythonSkillsByProjectName = new Map( pythonSkills.map((skill) => [readPythonSkillProjectName(skill).replaceAll("_", "-").toLowerCase(), skill]), ); const dependenciesBySkill = new Map( pythonSkills.map((skill) => [ skill, [...readPythonSkillDependencyNames(skill)] .map( (dependencyName) => pythonSkillsByProjectName.get(dependencyName) ?? resolveSiblingPythonSkillDependency(skill, dependencyName), ) .filter((dependency): dependency is BootstrapPythonSkill => Boolean(dependency)), ]), ); for (const skill of sortPythonSkillsForInstall(pythonSkills)) { const existingSkill = currentPythonSkills.get(`${skill.importName}\0${skill.packagePath}`); if (existingSkill?.pyprojectPath === skill.pyprojectPath && existingSkill.pyprojectHash === skill.pyprojectHash) { installedPythonSkills.push(skill); continue; } const localDependencies = dependenciesBySkill.get(skill) ?? []; const localDependencyArgs = localDependencies .filter((dependency) => { const installedDependency = currentPythonSkills.get(`${dependency.importName}\0${dependency.packagePath}`); const installedThisSync = installedPythonSkills.some( (installed) => installed.importName === dependency.importName && installed.packagePath === dependency.packagePath && installed.pyprojectPath === dependency.pyprojectPath && installed.pyprojectHash === dependency.pyprojectHash, ); return !( installedThisSync || (installedDependency?.pyprojectPath === dependency.pyprojectPath && installedDependency.pyprojectHash === dependency.pyprojectHash) ); }) .flatMap(formatPythonSkillInstallArgs); try { await run(uv, [ "pip", "install", "--python", python, ...formatPythonSkillInstallArgs(skill), ...localDependencyArgs, ]); installedPythonSkills.push( skill, ...localDependencies.filter((dependency) => !installedPythonSkills.includes(dependency)), ); } catch (error) { reportProgress( options, `Warning: Python skill ${skill.importName} failed to install and will be unavailable: ${errorMessage(error)}`, ); } } await writeBootstrapVersion(venv, runtimeIdentity, installedPythonSkills); } async function kernelBaseReady(python: string, venv: string, runtimeIdentity: string): Promise { return ( (await hasPrimeAgentRuntime(python)) && bootstrapBaseVersionCurrent(await readBootstrapVersion(venv), runtimeIdentity) ); } async function kernelReady( python: string, venv: string, runtimeIdentity: string, pythonSkills: readonly BootstrapPythonSkill[], ): Promise { return ( (await hasPrimeAgentRuntime(python)) && bootstrapVersionCurrent(await readBootstrapVersion(venv), runtimeIdentity, pythonSkills) ); } function formatBootstrapFailure(error: unknown): Error { return new Error( `Failed to set up the Python kernel runtime. ${errorMessage(error)}\n` + "First-time setup needs internet to install uv, Python, prime-agent-runtime, and default Python packages; once set up, prime-agent runs offline. " + "An interrupted runtime upgrade needs network once more, so re-run this while online. " + "Set PRIME_AGENT_KERNEL_PYTHON to a Python with a current prime-agent-runtime and default Python packages installed to skip auto-bootstrap.", ); } async function ensureKernelPythonUncached( options: EnsureKernelPythonOptions, pythonSkills: readonly BootstrapPythonSkill[], ): Promise { const override = process.env.PRIME_AGENT_KERNEL_PYTHON; if (override) { const python = path.resolve(expandHome(override)); if (isBatchShim(python)) { throw new Error( `PRIME_AGENT_KERNEL_PYTHON must point directly to a Python executable, not a Windows batch shim: ${python}`, ); } const missing: string[] = []; if (!(await hasPrimeAgentRuntime(python))) { missing.push( "a current prime-agent-runtime with callable rlm.spawn, rlm.create_session, rlm.host_request, rlm.progress_note, and explicit harness CRUD methods", ); } if (missing.length === 0) { const missingExtraImports = await missingRlmExtraImportLabels(python); if (missingExtraImports.length > 0) { missing.push(`default Python packages (${missingExtraImports.join(", ")})`); } } if (missing.length === 0 && pythonSkills.length > 0) { const missingPythonSkills = await missingPythonSkillImportLabels(python, options.pythonSkills ?? []); if (missingPythonSkills.length > 0) { reportProgress( options, `Warning: Python skills unavailable in PRIME_AGENT_KERNEL_PYTHON and will be disabled: ${missingPythonSkills.join(", ")}`, ); } } if (missing.length === 0) return python; throw new Error(`PRIME_AGENT_KERNEL_PYTHON points to a Python missing ${missing.join(" and ")}: ${python}`); } const venv = await resolveWritableKernelVenvDir(); const python = kernelVenvPython(venv); const runtimeIdentity = await resolveRuntimeIdentity(); if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; const releaseLock = await acquireBootstrapLock(venv, options); try { if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; if (await kernelBaseReady(python, venv, runtimeIdentity)) { await syncPythonSkills(await ensureUv(options), venv, python, runtimeIdentity, pythonSkills, options); return python; } reportProgress(options, "› setting up python kernel (one-time, ~30s)…"); if (existsSync(venv)) { reportProgress(options, "rebuilding kernel venv"); } // The build stages beside the live venv and replaces it at the end, so the old // tree stays importable (and recoverable) until the new one is complete. await bootstrapVenv(venv, pythonSkills, options); } catch (error) { throw formatBootstrapFailure(error); } finally { await releaseLock().catch(() => undefined); } reportProgress(options, "✓ ready"); return python; } export function ensureKernelPython(options: EnsureKernelPythonOptions = {}): Promise { const pythonSkills = normalizePythonSkills(options.pythonSkills); const key = ensureKernelPythonKey(pythonSkills); if (inFlightEnsureKernelPython?.key === key) return inFlightEnsureKernelPython.promise; const promise = ensureKernelPythonUncached(options, pythonSkills).finally(() => { if (inFlightEnsureKernelPython?.promise === promise) inFlightEnsureKernelPython = null; }); inFlightEnsureKernelPython = { key, promise }; return promise; }