diff --git a/package-lock.json b/package-lock.json index de7866173f..494395059b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "typescript": "^5.9.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.8.0" } }, "node_modules/@anthropic-ai/sandbox-runtime": { @@ -5852,7 +5852,7 @@ "vitest": "^4.1.9" }, "engines": { - "node": ">=20.6.0" + "node": ">=22.8.0" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.9" diff --git a/package.json b/package.json index b6d65a0ee5..6e6937ebc2 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "typescript": "^5.9.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.8.0" }, "version": "0.3.1", "dependencies": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7e2d3c7e68..80cb44b09e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Fixed unsupported Node versions crashing before startup by requiring Node 22.8.0 or newer and showing upgrade guidance before loading the CLI ([ENG-4260](https://linear.app/primeintellect/issue/ENG-4260/incorrect-node-version-breaks-first-launch)). - Added `@` file-path autocomplete to new-agent and reply prompts in the Agents View. ## [0.3.1] - 2026-07-15 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 35b6878419..29f547c142 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -105,6 +105,6 @@ "directory": "packages/coding-agent" }, "engines": { - "node": ">=20.6.0" + "node": ">=22.8.0" } } diff --git a/packages/coding-agent/src/cli-main.ts b/packages/coding-agent/src/cli-main.ts new file mode 100644 index 0000000000..752a239d8f --- /dev/null +++ b/packages/coding-agent/src/cli-main.ts @@ -0,0 +1,43 @@ +import { enableCompileCache } from "node:module"; +import { maybeStartInteractiveDaemonEarly } from "./cli/daemon-launch.js"; +import { + closeOwnedSessionWorkerOwnerWatch, + installOwnedSessionWorkerOwnerWatch, + maybeRunOwnedSessionWorkerFrontend, +} from "./cli/owned-session-worker.js"; +import { APP_NAME } from "./config.js"; + +export async function runCli(): Promise { + try { + enableCompileCache?.(); + } catch { + // Read-only cache dir; startup just skips the cache. + } + + process.title = APP_NAME; + process.env.PI_CODING_AGENT = "true"; + process.emitWarning = (() => {}) as typeof process.emitWarning; + + // Boot a cold daemon concurrently with this process's heavy imports. + maybeStartInteractiveDaemonEarly(process.argv.slice(2)); + + installOwnedSessionWorkerOwnerWatch(); + + const handledByOwnedWorker = await maybeRunOwnedSessionWorkerFrontend(process.argv.slice(2)); + if (!handledByOwnedWorker) { + const [{ EnvHttpProxyAgent, setGlobalDispatcher }, { main }] = await Promise.all([ + import("undici"), + import("./main.js"), + ]); + + // undici's 300s body/headers timeouts abort long local-LLM SSE stalls; provider + // SDKs enforce their own deadlines via retry.provider.timeoutMs. + setGlobalDispatcher(new EnvHttpProxyAgent({ bodyTimeout: 0, headersTimeout: 0 })); + + try { + await main(process.argv.slice(2)); + } finally { + closeOwnedSessionWorkerOwnerWatch(); + } + } +} diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 4262a6ec93..07d1b9b3d0 100644 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -1,53 +1,15 @@ #!/usr/bin/env node -/** - * CLI entry point for the refactored coding agent. - * Uses main.ts with AgentSession and new mode modules. - * - * Test with: npx tsx src/cli-new.ts [args...] - */ -import { enableCompileCache } from "node:module"; -import { maybeStartInteractiveDaemonEarly } from "./cli/daemon-launch.js"; -import { - closeOwnedSessionWorkerOwnerWatch, - installOwnedSessionWorkerOwnerWatch, - maybeRunOwnedSessionWorkerFrontend, -} from "./cli/owned-session-worker.js"; -import { APP_NAME } from "./config.js"; - -// Persist V8 compile caches across runs (~10-15% off module-graph load time). -try { - enableCompileCache?.(); -} catch { - // Unsupported Node version or read-only cache dir; startup just skips the cache. -} - -process.title = APP_NAME; -process.env.PI_CODING_AGENT = "true"; -process.emitWarning = (() => {}) as typeof process.emitWarning; - -// Kick off the interactive daemon spawn/probe before importing the heavy main -// module graph (~1.5s), so a cold daemon boots concurrently with this -// process's own imports instead of serially after them. -maybeStartInteractiveDaemonEarly(process.argv.slice(2)); - -installOwnedSessionWorkerOwnerWatch(); - -const handledByOwnedWorker = await maybeRunOwnedSessionWorkerFrontend(process.argv.slice(2)); -if (!handledByOwnedWorker) { - const [{ EnvHttpProxyAgent, setGlobalDispatcher }, { main }] = await Promise.all([ - import("undici"), - import("./main.js"), - ]); - - // bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls - // (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream - // with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own - // AbortController-based deadlines via retry.provider.timeoutMs. - setGlobalDispatcher(new EnvHttpProxyAgent({ bodyTimeout: 0, headersTimeout: 0 })); - - try { - await main(process.argv.slice(2)); - } finally { - closeOwnedSessionWorkerOwnerWatch(); - } +// The Node 22+ module graph fails at link time on older Node, so it must load +// behind the dynamic import, after the dependency-free guard runs. +import { assertNodeVersion } from "./cli/node-version-check.js"; + +const supported = assertNodeVersion({ + version: process.versions.node, + log: console.error, + exit: (code) => process.exit(code), +}); + +if (supported) { + const { runCli } = await import("./cli-main.js"); + await runCli(); } diff --git a/packages/coding-agent/src/cli/node-version-check.ts b/packages/coding-agent/src/cli/node-version-check.ts new file mode 100644 index 0000000000..24eef4eb8d --- /dev/null +++ b/packages/coding-agent/src/cli/node-version-check.ts @@ -0,0 +1,58 @@ +// Dependency-free and Node-20-safe so it can never crash on the versions it rejects. + +const MIN_NODE_VERSION_PARTS = [22, 8, 0] as const; +export const MIN_NODE_VERSION = MIN_NODE_VERSION_PARTS.join("."); + +export interface NodeVersionGuardIO { + version: string; + log: (message: string) => void; + exit: (code: number) => void; +} + +interface ParsedNodeVersion { + parts: readonly [number, number, number]; + prerelease: boolean; +} + +function parseVersion(version: string): ParsedNodeVersion | undefined { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + return undefined; + } + + return { + parts: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4] !== undefined, + }; +} + +function isSupportedNodeVersion(version: ParsedNodeVersion): boolean { + for (let index = 0; index < MIN_NODE_VERSION_PARTS.length; index++) { + const part = version.parts[index]!; + const minimumPart = MIN_NODE_VERSION_PARTS[index]!; + if (part !== minimumPart) { + return part > minimumPart; + } + } + return !version.prerelease; +} + +export function assertNodeVersion(io: NodeVersionGuardIO): boolean { + // Bun ships its own runtime; its node-compat version is unrelated to the user's Node. + if (process.versions.bun) { + return true; + } + + const version = parseVersion(io.version); + if (!version || isSupportedNodeVersion(version)) { + return true; + } + + io.log(`prime-agent requires Node ${MIN_NODE_VERSION} or newer, but the active Node is v${io.version}.`); + io.log(""); + io.log(` 1. Install Node ${MIN_NODE_VERSION}+ (e.g. "nvm install 22 && nvm use 22", or from https://nodejs.org)`); + io.log(" 2. Reinstall prime-agent under that Node so the command resolves to it:"); + io.log(" https://github.com/PrimeIntellect-ai/prime-agent/releases/latest"); + io.exit(1); + return false; +} diff --git a/packages/coding-agent/test/node-version-check.test.ts b/packages/coding-agent/test/node-version-check.test.ts new file mode 100644 index 0000000000..903b677395 --- /dev/null +++ b/packages/coding-agent/test/node-version-check.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { assertNodeVersion, MIN_NODE_VERSION } from "../src/cli/node-version-check.js"; + +function run(version: string) { + const logs: string[] = []; + let exitCode: number | null = null; + const ok = assertNodeVersion({ + version, + log: (m) => logs.push(m), + exit: (code) => { + exitCode = code; + }, + }); + return { ok, logs, exitCode }; +} + +describe("assertNodeVersion", () => { + test("passes on the minimum supported version", () => { + const { ok, logs, exitCode } = run(MIN_NODE_VERSION); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + expect(logs).toHaveLength(0); + }); + + test("passes on a newer minor", () => { + const { ok, exitCode } = run("22.9.0"); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + }); + + test("passes on a newer patch", () => { + const { ok, exitCode } = run("22.8.1"); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + }); + + test("passes on a newer major", () => { + const { ok, exitCode } = run("25.9.0"); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + }); + + test("rejects a Node 22 release below the minimum", () => { + const { ok, logs, exitCode } = run("22.7.0"); + expect(ok).toBe(false); + expect(exitCode).toBe(1); + expect(logs.join("\n")).toContain(`Node ${MIN_NODE_VERSION}`); + }); + + test("rejects an outdated major with guidance and exit 1", () => { + const { ok, logs, exitCode } = run("20.18.1"); + expect(ok).toBe(false); + expect(exitCode).toBe(1); + const text = logs.join("\n"); + expect(text).toContain(`Node ${MIN_NODE_VERSION}`); + expect(text).toContain("20.18.1"); + expect(text).toContain("github.com/PrimeIntellect-ai/prime-agent/releases/latest"); + }); + + test("accepts the v prefix used by process.version", () => { + const { ok, exitCode } = run(`v${MIN_NODE_VERSION}`); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + }); + + test("accepts build metadata at the minimum version", () => { + const { ok, exitCode } = run(`${MIN_NODE_VERSION}+build.1`); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + }); + + test("rejects a prerelease of the minimum version", () => { + const { ok, exitCode } = run(`${MIN_NODE_VERSION}-rc.1`); + expect(ok).toBe(false); + expect(exitCode).toBe(1); + }); + + test("lets an unparseable version through rather than blocking", () => { + const { ok, exitCode } = run("not-a-version"); + expect(ok).toBe(true); + expect(exitCode).toBeNull(); + }); +});