Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/coding-agent/.changes/eng-6046-native-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- Added migration from global npm installations to compiled releases during the next launch after an update, preserving settings and a Node fallback when migration cannot run.
- Fixed migration to preserve a newer compiled installation activated by a competing update.
- Fixed automatic migration delaying daemon startup, reusing incompatible compiled releases, and replacing a concurrent npm command.
- Fixed automatic migration blocking informational and automated launches, hiding installer progress, suppressing retries after cancellation, and silently deferring invalid compiled releases.
- Fixed migration from scoped global npm packages to compiled installations.
- Fixed background and informational launches starting migration downloads after the public command had already switched to a compiled installation.
- Fixed unsupported hosts attempting compiled migration downloads instead of quietly continuing with Node.js.
16 changes: 15 additions & 1 deletion packages/coding-agent/docs/standalone-binaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,18 @@ Activation replaces the current launcher before refreshing the previous launcher

The installer still shows download and verification progress and can prepare Python. Compilation removes JavaScript dependency installation; Python and external tools still need preparation. Set `PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL=0` to defer Python setup.

Existing npm installations and the in-app updater are handled by the next layers of the rollout. Homebrew packaging remains separate work.
## Migration from npm

Releases containing native archives also include a bridge at the existing npm CLI entrypoint. An old version can install that release through its existing updater; its next foreground interactive launch downloads and verifies the compiled application. Installer progress is written to the terminal without changing application stdout. Informational commands, piped and machine-readable runs, internal workers, daemon launches, and restart coordinators do not start a migration download, so automation and liveness checks remain immediate. Internal entrypoints can still reuse an already compatible compiled release after command handoff. The bridge only migrates conventional global npm installs whose command still points to that package. It then transfers that owned command link to the managed native launcher, so subsequent launches do not require Node. Existing Node files and shared runtimes are retained.

Homebrew, source checkouts, other package-manager layouts, read-only prefixes, and unsupported platforms keep the Node route. `PRIME_AGENT_INSTALL_METHOD=node` disables migration. Offline launches defer downloads. Installation failures keep the Node application usable and suppress automatic retries for 24 hours; set `PRIME_AGENT_MIGRATE_RETRY=1` for an explicit earlier retry. Cancelling migration does not start that retry window. Migration also works when npm lifecycle scripts were disabled.

Already-released updaters pass the package URL directly to npm. npm 12's default remote-package policy can reject that download with `EALLOWREMOTE` before this bridge runs; the installed Node application remains usable. For a trusted release source, retry with `NPM_CONFIG_ALLOW_REMOTE=all prime-agent update`, then launch `prime-agent` normally to migrate. This setting applies only to that command and its children, without changing global npm configuration. Fresh compiled installs and compiled updates do not use npm; unsupported hosts remaining on Node can encounter this policy on later updates too.

An interactive update from an older Node release can restore its session on a Node daemon worker before the foreground launcher finishes migration. The public command then runs Bun, while that resident worker keeps running until the daemon is restarted. Resuming the saved conversation after shutdown starts it on Bun; migration does not forcibly replace a healthy worker solely to change runtimes.

Migration reuses an equal or newer managed release. It also checks the captured active release after acquiring the installer lock: if another install wins the race, migration defers to the Node application instead of overwriting that install. The next launch can adopt the newer managed release.

Before reusing a compiled release, the bridge checks the installer's OS/architecture compatibility result and probes the executable's version. Unsupported hosts quietly retain Node. A broken probe or a mismatch with installed release metadata reports a rate-limited diagnostic with reinstall and opt-out guidance. Command handoff captures the npm link and creates the native link exclusively, so a concurrent npm command wins instead of being overwritten. The public path can be briefly absent during this one-time transfer. If abrupt termination prevents restoration, the captured command remains under the adjacent `.prime-agent-link-*` directory for recovery; the versioned application and user data remain intact.

Compiled self-update and rollback are handled by the final layer of the rollout. Homebrew packaging remains separate work.
304 changes: 304 additions & 0 deletions packages/coding-agent/src/cli/npm-native-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import {
existsSync,
linkSync,
lstatSync,
mkdtempSync,
readFileSync,
readlinkSync,
realpathSync,
renameSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { readNativeInstallation } from "../utils/native-installation.js";
import { comparePackageVersions } from "../utils/version-check.js";

const entrypoint = fileURLToPath(import.meta.url);
const packageDir = resolve(dirname(entrypoint), "../..");
const args = process.argv.slice(2);
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
const retryDelayMs = 86400000;
const informationalFlags = new Set(["--help", "-h", "--version", "-v", "--list-models", "--export"]);

function isForegroundMigration(): boolean {
if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) return false;
if (args[0] === "help" || args.some((arg) => informationalFlags.has(arg))) return false;
if (args.includes("--print") || args.includes("-p") || args.includes("--json")) return false;
if (args.includes("--mode")) return false;
if (
args.includes("--internal-update-restart-coordinator") ||
process.env.PRIME_AGENT_INTERNAL_DAEMON_WORKER ||
process.env.PRIME_AGENT_INTERNAL_DAEMON_CATALOG ||
process.env.PRIME_AGENT_INTERNAL_OWNED_WORKER ||
process.env.PRIME_AGENT_INTERACTIVE_SELF_UPDATE ||
process.env.PI_STARTUP_BENCHMARK
)
return false;
return true;
}

function wasRecordedRecently(path: string): boolean {
try {
return Date.now() - Number(readFileSync(path, "utf8")) < retryDelayMs;
} catch {
return false;
}
}

function recordNow(path: string): void {
try {
writeFileSync(path, String(Date.now()));
} catch {
// Read-only package prefixes can still use the bundled Node application.
}
}

function reportProbeFailure(message: string): void {
const diagnosticFile = join(packageDir, "dist/.native-migration-diagnostic");
if (process.env.PRIME_AGENT_MIGRATE_RETRY !== "1" && wasRecordedRecently(diagnosticFile)) return;
recordNow(diagnosticFile);
console.error(
`prime-agent: ${message} Reinstall prime-agent, or set PRIME_AGENT_INSTALL_METHOD=node to keep using Node without migration.`,
);
}

interface InstallerResult {
status: number | null;
signal: NodeJS.Signals | null;
parentSignal?: (typeof signals)[number];
error?: Error;
}

function runInstaller(command: string, version: string, environment: NodeJS.ProcessEnv): Promise<InstallerResult> {
return new Promise((resolveResult) => {
const child = spawn("sh", [command, version], {
env: environment,
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
child.stdout.pipe(process.stderr, { end: false });
child.stderr.pipe(process.stderr, { end: false });
let parentSignal: (typeof signals)[number] | undefined;
let forceTimer: NodeJS.Timeout | undefined;
let settled = false;
const terminate = (signal: NodeJS.Signals) => {
try {
process.kill(-child.pid!, signal);
} catch {
child.kill(signal);
}
};
const handlers = signals.map((signal) => {
const handler = () => {
parentSignal ??= signal;
terminate(signal);
forceTimer ??= setTimeout(() => terminate("SIGKILL"), 1000);
forceTimer.unref();
};
process.on(signal, handler);
return handler;
});
const timeout = setTimeout(() => {
terminate("SIGTERM");
forceTimer = setTimeout(() => terminate("SIGKILL"), 1000);
forceTimer.unref();
}, 450000);
const finish = (result: InstallerResult) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (forceTimer) clearTimeout(forceTimer);
for (const [index, signal] of signals.entries()) process.removeListener(signal, handlers[index]);
resolveResult({ ...result, parentSignal });
};
child.once("error", (error) => finish({ status: null, signal: null, error }));
child.once("close", (status, signal) => finish({ status, signal }));
});
}

async function migrationTarget(): Promise<string | undefined> {
if (process.platform !== "darwin" && process.platform !== "linux") return undefined;
if (process.env.PRIME_AGENT_INSTALL_METHOD === "node") return undefined;
// Only the public release package in a conventional global npm prefix owns this command.
const packageParent = dirname(packageDir);
const modules = basename(packageParent).startsWith("@") ? dirname(packageParent) : packageParent;
if (
basename(modules) !== "node_modules" ||
basename(dirname(modules)) !== "lib" ||
/[/\\]Cellar[/\\]/.test(packageDir)
)
return undefined;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const metadata = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) as {
version: string;
bin: Record<string, string>;
};
const commandName = Object.keys(metadata.bin)[0];
if (!commandName || basename(commandName) !== commandName) return undefined;
const publicCommand = join(dirname(dirname(modules)), "bin", commandName);
const commandIdentity = lstatSync(publicCommand);
const commandTarget = commandIdentity.isSymbolicLink() ? readlinkSync(publicCommand) : undefined;
const entryIdentity = statSync(entrypoint);
const root =
process.env.PRIME_AGENT_INSTALL_DIR ||
join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "prime-agent");
let native = readNativeInstallation(root);
const ownsPackageLink = realpathSync(publicCommand) === realpathSync(entrypoint);
if (!ownsPackageLink && (!native || realpathSync(publicCommand) !== native.executable)) return undefined;
if (ownsPackageLink && !isForegroundMigration()) return undefined;
const needsInstall = !native || (comparePackageVersions(native.version, metadata.version) ?? -1) < 0;
if (needsInstall && (!isForegroundMigration() || process.env.PI_OFFLINE || args.includes("--offline")))
return undefined;
const platform = spawnSync("sh", [join(packageDir, "dist/install.sh"), "--native-platform"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 3000,
});
if (platform.status !== 0) {
// Exit 1 is the installer's expected signal for an unsupported host.
if (platform.status !== 1 || platform.error || platform.signal)
reportProbeFailure("the native platform check failed unexpectedly.");
return undefined;
}
if (needsInstall) {
const retryFile = join(packageDir, "dist/.native-migration-attempt");
if (process.env.PRIME_AGENT_MIGRATE_RETRY !== "1" && wasRecordedRecently(retryFile)) return undefined;
// Confirm the npm package is writable before downloading; read-only prefixes stay on Node.
writeFileSync(retryFile, String(Date.now()));
const release = JSON.parse(readFileSync(join(packageDir, "dist/native-release.json"), "utf8")) as {
baseUrl: string;
};
console.error(`prime-agent: migrating ${metadata.version} from npm to the compiled application...`);
const result = await runInstaller(join(packageDir, "dist/install.sh"), metadata.version, {
...process.env,
PRIME_AGENT_INSTALL_METHOD: "binary",
PRIME_AGENT_INSTALL_DIR: root,
PRIME_AGENT_EXPECTED_CURRENT: native ? relative(join(native.root, "bin"), native.executable) : "",
PRIME_AGENT_DOWNLOAD_BASE_URL: release.baseUrl,
PRIME_AGENT_INSTALL_LINK: "0",
PRIME_AGENT_INSTALLER_NONINTERACTIVE: "1",
PRIME_AGENT_INSTALLER_PLAIN: "1",
PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL: "0",
Comment thread
cursor[bot] marked this conversation as resolved.
});
if (result.parentSignal) {
rmSync(retryFile, { force: true });
process.kill(process.pid, result.parentSignal);
return undefined;
Comment thread
kevinjosethomas marked this conversation as resolved.
}
if (result.status !== 0) {
recordNow(retryFile);
const detail = result.error ? ` (${result.error.message})` : "";
console.error(
`prime-agent: compiled migration deferred${detail}; continuing with the installed Node application. Retry with PRIME_AGENT_MIGRATE_RETRY=1.`,
);
return undefined;
}
rmSync(retryFile, { force: true });
native = readNativeInstallation(root);
if (!native || native.version !== metadata.version) {
recordNow(retryFile);
reportProbeFailure("the compiled installer completed without activating the requested version.");
return undefined;
}
}
if (!native) return undefined;
if (platform.stdout.trim() !== native.platform) {
reportProbeFailure(
`the native platform check returned ${JSON.stringify(platform.stdout.trim())}, but the installed release requires ${JSON.stringify(native.platform)}.`,
);
return undefined;
}
const probe = spawnSync(native.executable, ["--version"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 3000,
});
if (probe.status !== 0) {
reportProbeFailure("the compiled executable failed its version check.");
return undefined;
}
if (probe.stdout.trim() !== native.version) {
reportProbeFailure(
`the compiled executable reports ${JSON.stringify(probe.stdout.trim())}, but its release metadata requires ${JSON.stringify(native.version)}.`,
);
return undefined;
Comment thread
kevinjosethomas marked this conversation as resolved.
}
if (!ownsPackageLink) return native.launcher;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// Capture the link, then create exclusively so a concurrent npm install cannot be overwritten.
const staging = mkdtempSync(join(dirname(publicCommand), ".prime-agent-link-"));
const captured = join(staging, "command");
let needsRestore = false;
let capturedOwned = false;
try {
renameSync(publicCommand, captured);
needsRestore = true;
const capturedIdentity = lstatSync(captured);
capturedOwned =
capturedIdentity.dev === commandIdentity.dev &&
capturedIdentity.ino === commandIdentity.ino &&
capturedIdentity.mtimeMs === commandIdentity.mtimeMs &&
commandTarget !== undefined &&
capturedIdentity.isSymbolicLink() &&
readlinkSync(captured) === commandTarget;
const currentEntry = statSync(entrypoint);
if (
!capturedOwned ||
currentEntry.dev !== entryIdentity.dev ||
currentEntry.ino !== entryIdentity.ino ||
currentEntry.mtimeMs !== entryIdentity.mtimeMs ||
currentEntry.size !== entryIdentity.size
)
return undefined;
symlinkSync(native.launcher, publicCommand);
needsRestore = false;
return native.launcher;
} finally {
if (needsRestore) {
try {
if (lstatSync(captured).isSymbolicLink()) symlinkSync(readlinkSync(captured), publicCommand);
else linkSync(captured, publicCommand);
needsRestore = false;
} catch (error) {
if (capturedOwned && error instanceof Error && "code" in error && error.code === "EEXIST")
needsRestore = false;
}
}
if (needsRestore)
console.error(`prime-agent: command handoff deferred; the displaced command is preserved at ${captured}`);
else rmSync(staging, { recursive: true, force: true });
}
}

let target: string | undefined;
try {
target = await migrationTarget();
} catch {
// Read-only prefixes and package-manager wrappers keep the bundled Node route.
}
const fallback = join(dirname(entrypoint), "cli-node.js");
if (!target && !existsSync(fallback)) throw new Error("Missing Node fallback entrypoint");
const child = spawn(target ?? process.execPath, target ? args : [...process.execArgv, fallback, ...args], {
stdio: "inherit",
});
const handlers = signals.map((signal) => {
const handler = () => {
child.kill(signal);
};
process.on(signal, handler);
return handler;
});
child.on("error", (error) => {
console.error(error.message);
process.exitCode = 1;
});
child.on("exit", (code, signal) => {
for (const [index, name] of signals.entries()) process.removeListener(name, handlers[index]);
if (signal) process.kill(process.pid, signal);
else process.exitCode = code ?? 1;
});
62 changes: 62 additions & 0 deletions packages/coding-agent/src/utils/native-installation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";

export interface NativeInstallation {
root: string;
launcher: string;
executable: string;
releaseDir: string;
version: string;
platform: string;
sha256: string;
baseUrl: string;
}

export function readNativeInstallation(root: string, link = "prime-agent"): NativeInstallation | undefined {
Comment thread
cursor[bot] marked this conversation as resolved.
try {
root = realpathSync(root);
for (const part of [".managed", "bin", "releases"]) {
if (lstatSync(join(root, part)).isSymbolicLink()) return undefined;
}
if (readFileSync(join(root, ".managed"), "utf8").trim() !== "prime-agent-native-v1") return undefined;
const launcher = join(root, "bin", link);
const target = readlinkSync(launcher);
const match =
/^\.\.\/releases\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)-(darwin|linux)-(arm64|x64)-([a-f0-9]{64})(?:\.[A-Za-z0-9]{6})?\/prime-agent$/.exec(
target,
);
if (!match) return undefined;
const executable = resolve(dirname(launcher), target);
if (realpathSync(executable) !== executable) return undefined;
const releaseDir = dirname(executable);
if (readFileSync(join(releaseDir, ".archive-sha256"), "utf8").trim() !== match[4]) return undefined;
const metadata = JSON.parse(readFileSync(join(releaseDir, "package.json"), "utf8")) as { version?: unknown };
if (metadata.version !== match[1]) return undefined;
const baseUrl = readFileSync(join(releaseDir, ".install-source"), "utf8").trim();
if (!["https:", "http:"].includes(new URL(baseUrl).protocol)) return undefined;
return {
root,
launcher,
executable,
releaseDir,
version: match[1],
platform: `${match[2]}-${match[3]}`,
sha256: match[4],
baseUrl,
};
} catch {
return undefined;
}
}

export function getNativeInstallation(executable = process.execPath): NativeInstallation | undefined {
try {
const actual = realpathSync(executable);
const root = resolve(dirname(actual), "../..");
const installation = readNativeInstallation(root);
// A running process may belong to the previous release after activation.
return installation && dirname(dirname(actual)) === join(root, "releases") ? installation : undefined;
} catch {
return undefined;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading