diff --git a/package-lock.json b/package-lock.json index 7042de0da..f9b9ee73d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5511,7 +5511,7 @@ "yaml": "2.9.0" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "devDependencies": { "@types/cross-spawn": "6.0.6", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7ceb283d..d53007714 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- Changed the Node.js CLI and RPC entrypoints to load a bundled runtime, reducing startup filesystem reads while keeping the public library and legacy module paths on the modular runtime for normal dependency identity. - Changed session sharing to render clickable terminal links and Radius shares to display only the artifact's canonical URL. ### Fixed diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 1e2f28d28..b95f0bb82 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -530,7 +530,7 @@ "@mariozechner/clipboard": "0.3.9" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index e58a04fa7..acd50e996 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -35,7 +35,7 @@ "@mariozechner/clipboard": "0.3.9" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "engines": { "node": ">=22.19.0" diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 58d9dd64d..0073ac0f8 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -7,7 +7,7 @@ "configDir": ".pi" }, "bin": { - "pi": "dist/cli.js" + "pi": "dist/bundle/cli.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -17,7 +17,7 @@ "import": "./dist/index.js" }, "./rpc-entry": { - "import": "./dist/rpc-entry.js" + "import": "./dist/bundle/rpc-entry.js" }, "./client": { "types": "./dist/client/index.d.ts", @@ -34,8 +34,9 @@ ], "scripts": { "clean": "shx rm -rf dist", - "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "build": "npm run build:unbundled && node ../../scripts/build-coding-agent-bundle.mjs", + "build:unbundled": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./src/bun/cli.ts ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index dddda3d4e..21e7cd2a3 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -362,9 +362,26 @@ export function getUpdateInstruction(packageName: string): string { /** * Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md). * - For Bun binary: returns the directory containing the executable - * - For Node.js (dist/): returns __dirname (the dist/ directory) - * - For tsx (src/): returns parent directory (the package root) + * - For Node.js and tsx: returns the package root containing package.json + * - Ignores Bun binary metadata copied into dist/ when the package root is available */ +export function findNodePackageDir(startDir: string): string { + let dir = startDir; + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + const parent = dirname(dir); + // build:binary places Bun's metadata inside dist/. Node still needs the + // package root so its dist-relative asset paths do not become dist/dist/. + if (basename(dir) === "dist" && existsSync(join(parent, "package.json"))) { + return parent; + } + return dir; + } + dir = dirname(dir); + } + return startDir; +} + export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; @@ -376,16 +393,7 @@ export function getPackageDir(): string { // Bun binary: process.execPath points to the compiled executable return dirname(process.execPath); } - // Node.js: walk up from __dirname until we find package.json - let dir = __dirname; - while (dir !== dirname(dir)) { - if (existsSync(join(dir, "package.json"))) { - return dir; - } - dir = dirname(dir); - } - // Fallback (shouldn't happen) - return __dirname; + return findNodePackageDir(__dirname); } /** diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 2f00807f2..d560e67c4 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -78,6 +78,8 @@ const require = createRequire(import.meta.url); const isNodeSeaBinary = ("sea" in process.features && process.features.sea === true) || process.getBuiltinModule("node:sea")?.isSea() === true; +declare const PI_BUNDLED_NODE: boolean; +const isBundledNode = typeof PI_BUNDLED_NODE !== "undefined" && PI_BUNDLED_NODE; const isTypeScriptSourceRuntime = !isBunBinary && path.extname(fileURLToPath(import.meta.url)) === ".ts"; /** @@ -451,9 +453,10 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension const jiti = createJiti(import.meta.url, { moduleCache: false, - // Compiled binaries use modules embedded in the executable. Source TypeScript - // reuses host modules and root tsconfig paths. Built Node uses dist aliases. - ...(isBunBinary || isNodeSeaBinary + // Compiled binaries and the bundled Node distribution use embedded modules. + // Source TypeScript reuses host modules and root tsconfig paths. Unbundled + // Node builds use dist aliases. + ...(isBunBinary || isNodeSeaBinary || isBundledNode ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true } diff --git a/packages/coding-agent/test/config.test.ts b/packages/coding-agent/test/config.test.ts index 49448cf11..300f9e06a 100644 --- a/packages/coding-agent/test/config.test.ts +++ b/packages/coding-agent/test/config.test.ts @@ -4,6 +4,7 @@ import { delimiter, join } from "path"; import { afterEach, describe, expect, test } from "vitest"; import { detectInstallMethod, + findNodePackageDir, getSelfUpdateCommand, getSelfUpdateUnavailableInstruction, getUpdateInstruction, @@ -145,6 +146,19 @@ function createFakeBunScript(bunBin: string): string { return `#!/bin/sh\nif [ "$1" = "pm" ] && [ "$2" = "bin" ] && [ "$3" = "-g" ]; then\n\tprintf '%s\\n' '${escapedBunBin}'\n\texit 0\nfi\nexit 1\n`; } +describe("findNodePackageDir", () => { + test("skips binary metadata copied into dist", () => { + tempDir = mkdtempSync(join(tmpdir(), "pi-package-dir-")); + const distDir = join(tempDir, "dist"); + const bundleDir = join(distDir, "bundle"); + mkdirSync(bundleDir, { recursive: true }); + writeFileSync(join(tempDir, "package.json"), "{}"); + writeFileSync(join(distDir, "package.json"), "{}"); + + expect(findNodePackageDir(bundleDir)).toBe(tempDir); + }); +}); + describe("detectInstallMethod", () => { test("detects pnpm from Windows .pnpm install paths", () => { setExecPath( diff --git a/packages/coding-agent/test/package-distribution.test.ts b/packages/coding-agent/test/package-distribution.test.ts new file mode 100644 index 000000000..2fe5380ec --- /dev/null +++ b/packages/coding-agent/test/package-distribution.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +interface CodingAgentPackageJson { + bin: { pi: string }; + main: string; + exports: { + ".": { import: string; types: string }; + "./client": { import: string; types: string }; + "./rpc-entry": { import: string }; + }; +} + +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as CodingAgentPackageJson; + +describe("package distribution entrypoints", () => { + test("uses the bundle for executables and modular output for libraries", () => { + expect(packageJson.bin.pi).toBe("dist/bundle/cli.js"); + expect(packageJson.main).toBe("./dist/index.js"); + expect(packageJson.exports["."].import).toBe("./dist/index.js"); + expect(packageJson.exports["./client"].import).toBe("./dist/client/index.js"); + expect(packageJson.exports["./rpc-entry"].import).toBe("./dist/bundle/rpc-entry.js"); + }); +}); diff --git a/packages/tui/src/native-modifiers.ts b/packages/tui/src/native-modifiers.ts index 549ce47a9..88684cc10 100644 --- a/packages/tui/src/native-modifiers.ts +++ b/packages/tui/src/native-modifiers.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { getNativeModuleCandidates } from "./native-module-path.ts"; const cjsRequire = createRequire(import.meta.url); @@ -33,14 +33,7 @@ function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { return undefined; } - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ - path.join(moduleDir, "..", nativePath), - path.join(moduleDir, nativePath), - path.join(path.dirname(process.execPath), nativePath), - ]; - - for (const modulePath of candidates) { + for (const modulePath of getNativeModuleCandidates(nativePath)) { try { const helper = cjsRequire(modulePath) as unknown; if (isNativeModifiersHelper(helper)) { diff --git a/packages/tui/src/native-module-path.ts b/packages/tui/src/native-module-path.ts new file mode 100644 index 000000000..b75117feb --- /dev/null +++ b/packages/tui/src/native-module-path.ts @@ -0,0 +1,31 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const moduleRequire = createRequire(import.meta.url); +const TUI_PACKAGE_NAME = "@earendil-works/pi-tui"; + +export interface NativeModuleCandidateOptions { + moduleUrl?: string; + execPath?: string; + resolvePackage?: (specifier: string) => string; +} + +export function getNativeModuleCandidates(nativePath: string, options: NativeModuleCandidateOptions = {}): string[] { + const moduleDir = dirname(fileURLToPath(options.moduleUrl ?? import.meta.url)); + const candidates: string[] = []; + + try { + const packageEntry = (options.resolvePackage ?? moduleRequire.resolve)(TUI_PACKAGE_NAME); + candidates.push(join(dirname(packageEntry), "..", nativePath)); + } catch { + // Standalone binaries do not have an installed TUI package. + } + + candidates.push( + join(moduleDir, "..", nativePath), + join(moduleDir, nativePath), + join(dirname(options.execPath ?? process.execPath), nativePath), + ); + return Array.from(new Set(candidates)); +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 08d8cbbcf..f858bf9f2 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,9 +1,9 @@ import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; import { setKittyProtocolActive } from "./keys.ts"; import { isNativeModifierPressed } from "./native-modifiers.ts"; +import { getNativeModuleCandidates } from "./native-module-path.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const cjsRequire = createRequire(import.meta.url); @@ -370,16 +370,10 @@ export class ProcessTerminal implements Terminal { if (arch !== "x64" && arch !== "arm64") return; // Dynamic require so non-Windows and bundled/browser paths never load the - // native helper. In the npm package native/ is next to dist/; in compiled - // binary archives native/ is copied next to the executable. - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + // native helper. Installed packages resolve it from pi-tui; standalone + // binaries resolve the copy next to the executable. const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); - const candidates = [ - path.join(moduleDir, "..", nativePath), - path.join(moduleDir, nativePath), - path.join(path.dirname(process.execPath), nativePath), - ]; - for (const modulePath of candidates) { + for (const modulePath of getNativeModuleCandidates(nativePath)) { try { const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; helper.enableVirtualTerminalInput?.(); diff --git a/packages/tui/test/native-module-path.test.ts b/packages/tui/test/native-module-path.test.ts new file mode 100644 index 000000000..02245ef12 --- /dev/null +++ b/packages/tui/test/native-module-path.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert"; +import { dirname, join, resolve } from "node:path"; +import { describe, it } from "node:test"; +import { pathToFileURL } from "node:url"; +import { getNativeModuleCandidates } from "../src/native-module-path.ts"; + +describe("getNativeModuleCandidates", () => { + it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => { + const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui"); + const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js"); + const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node"); + + const candidates = getNativeModuleCandidates(nativePath, { + moduleUrl: pathToFileURL(bundledModule).href, + execPath: resolve("virtual", "node", "node.exe"), + resolvePackage: (specifier) => { + assert.equal(specifier, "@earendil-works/pi-tui"); + return join(packageRoot, "dist", "index.js"); + }, + }); + + assert.equal(candidates[0], join(packageRoot, nativePath)); + assert.ok(candidates.includes(join(dirname(bundledModule), "..", nativePath))); + }); + + it("keeps standalone binary fallbacks when the TUI package is unavailable", () => { + const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js"); + const execPath = resolve("virtual", "pi", "pi.exe"); + const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node"); + + const candidates = getNativeModuleCandidates(nativePath, { + moduleUrl: pathToFileURL(bundledModule).href, + execPath, + resolvePackage: () => { + throw new Error("not installed"); + }, + }); + + assert.deepEqual(candidates, [ + join(dirname(bundledModule), "..", nativePath), + join(dirname(bundledModule), nativePath), + join(dirname(execPath), nativePath), + ]); + }); +}); diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs new file mode 100644 index 000000000..5c4fc62c6 --- /dev/null +++ b/scripts/build-coding-agent-bundle.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { isBuiltin } from "node:module"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const codingAgentDir = join(repoRoot, "packages", "coding-agent"); +const aiDistDir = join(repoRoot, "packages", "ai", "dist"); +const codingAgentDistDir = join(codingAgentDir, "dist"); +const bundleDir = join(codingAgentDistDir, "bundle"); +const banner = { + js: 'import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);', +}; +const allowedExternalPackages = new Set([ + "@silvia-odwyer/photon-node", + // Optional native accelerators. Their callers fall back to JavaScript when absent. + "bufferutil", + "utf-8-validate", + // Optional debug output coloring. + "supports-color", +]); + +function commonBuildOptions() { + return { + absWorkingDir: repoRoot, + banner, + bundle: true, + define: { PI_BUNDLED_NODE: "true" }, + external: ["@silvia-odwyer/photon-node"], + format: "esm", + legalComments: "none", + logLevel: "warning", + metafile: true, + minifySyntax: true, + minifyWhitespace: true, + platform: "node", + sourcemap: false, + target: "node22.19", + // Do not apply the monorepo's source-oriented path aliases while bundling + // compiled output. Release builds must resolve the same package entries as + // an installed npm package. + tsconfigRaw: { compilerOptions: {} }, + }; +} + +function validateExternalImports(metafiles) { + const unexpected = new Set(); + for (const metafile of metafiles) { + for (const input of Object.values(metafile.inputs)) { + for (const imported of input.imports) { + if (!imported.external || isBuiltin(imported.path) || allowedExternalPackages.has(imported.path)) { + continue; + } + unexpected.add(imported.path); + } + } + } + if (unexpected.size > 0) { + throw new Error(`Bundle left unexpected external imports: ${Array.from(unexpected).sort().join(", ")}`); + } +} + +function findContainingOutput(metafile, inputSuffix) { + const normalizedSuffix = inputSuffix.replaceAll("\\", "/"); + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + if (Object.keys(output.inputs).some((inputPath) => inputPath.replaceAll("\\", "/").endsWith(normalizedSuffix))) { + return resolve(repoRoot, outputPath); + } + } + throw new Error(`Could not locate bundled output containing ${inputSuffix}`); +} + +function outputBytes(metafiles) { + return metafiles.reduce( + (total, metafile) => total + Object.values(metafile.outputs).reduce((subtotal, output) => subtotal + output.bytes, 0), + 0, + ); +} + +for (const entry of [ + join(codingAgentDistDir, "cli.js"), + join(codingAgentDistDir, "index.js"), + join(codingAgentDistDir, "rpc-entry.js"), + join(codingAgentDistDir, "client", "index.js"), + join(codingAgentDistDir, "utils", "image-resize-worker.js"), + join(aiDistDir, "api", "bedrock-converse-stream.js"), + join(aiDistDir, "auth", "oauth", "anthropic.js"), +]) { + if (!existsSync(entry)) { + throw new Error(`Bundle input is missing: ${relative(repoRoot, entry)}. Build the workspace packages first.`); + } +} + +rmSync(bundleDir, { force: true, recursive: true }); +mkdirSync(bundleDir, { recursive: true }); + +const mainResult = await build({ + ...commonBuildOptions(), + entryNames: "[name]", + entryPoints: { + cli: join(codingAgentDistDir, "cli.js"), + client: join(codingAgentDistDir, "client", "index.js"), + index: join(codingAgentDistDir, "index.js"), + "rpc-entry": join(codingAgentDistDir, "rpc-entry.js"), + }, + outdir: bundleDir, + chunkNames: "chunks/[name]-[hash]", + splitting: true, +}); + +const bedrockLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/api/bedrock-converse-stream.lazy.js"); +const oauthLoaderOutput = findContainingOutput(mainResult.metafile, "packages/ai/dist/auth/oauth/load.js"); +const imageResizeOutput = findContainingOutput(mainResult.metafile, "packages/coding-agent/dist/utils/image-resize.js"); +if (dirname(bedrockLoaderOutput) !== dirname(oauthLoaderOutput)) { + throw new Error("Bedrock and OAuth lazy loaders were emitted into different directories"); +} + +// These implementations are reached through variable-specifier imports or a +// worker URL, so the main bundle cannot follow them. Emit one self-contained +// file per implementation beside the code that resolves it. +const lazyResult = await build({ + ...commonBuildOptions(), + entryNames: "[name]", + entryPoints: { + anthropic: join(aiDistDir, "auth", "oauth", "anthropic.js"), + "bedrock-converse-stream": join(aiDistDir, "api", "bedrock-converse-stream.js"), + "github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"), + "image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"), + "kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"), + "openai-codex": join(aiDistDir, "auth", "oauth", "openai-codex.js"), + openrouter: join(aiDistDir, "auth", "oauth", "openrouter.js"), + radius: join(aiDistDir, "auth", "oauth", "radius.js"), + xai: join(aiDistDir, "auth", "oauth", "xai.js"), + }, + outdir: dirname(bedrockLoaderOutput), + splitting: false, +}); + +const imageResizeWorkerOutput = resolve(dirname(bedrockLoaderOutput), "image-resize-worker.js"); +if (dirname(imageResizeOutput) !== dirname(imageResizeWorkerOutput)) { + throw new Error("Image resize implementation and worker were emitted into different directories"); +} + +validateExternalImports([mainResult.metafile, lazyResult.metafile]); +chmodSync(join(bundleDir, "cli.js"), 0o755); +chmodSync(join(bundleDir, "rpc-entry.js"), 0o755); + +const files = new Set([...Object.keys(mainResult.metafile.outputs), ...Object.keys(lazyResult.metafile.outputs)]).size; +const mib = outputBytes([mainResult.metafile, lazyResult.metafile]) / (1024 * 1024); +console.log(`Built ${relative(repoRoot, bundleDir)} (${files} files, ${mib.toFixed(1)} MiB)`); diff --git a/scripts/profile-coding-agent-node.mjs b/scripts/profile-coding-agent-node.mjs index 79d8d6a6b..3d2fb5b78 100644 --- a/scripts/profile-coding-agent-node.mjs +++ b/scripts/profile-coding-agent-node.mjs @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; @@ -9,6 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const packageDir = join(repoRoot, "packages", "coding-agent"); const distCliPath = join(packageDir, "dist", "cli.js"); +const bundledDistCliPath = join(packageDir, "dist", "bundle", "cli.js"); const srcCliPath = join(packageDir, "src", "cli.ts"); const defaultNodeProfileDir = join(repoRoot, "profiles-node"); const defaultBunProfileDir = join(repoRoot, "profiles-bun"); @@ -35,8 +36,9 @@ Options: --runtime node, bun, or auto (default: auto) --agent-dir Use a specific PI_CODING_AGENT_DIR for the benchmark run --isolated-agent-dir Use a fresh temporary agent dir instead of the normal one + --bundle Build and profile the bundled Node entrypoint instead of dist/cli.js --no-offline Do not force PI_OFFLINE=1 / PI_SKIP_VERSION_CHECK=1 - --skip-build Reuse the current dist/cli.js without rebuilding first (Node only) + --skip-build Reuse the selected build output without rebuilding first (Node only) --cpu-profile Write CPU profiles for benchmark runs --help Show this help @@ -73,6 +75,7 @@ function parseMode(value) { function parseArgs(argv) { const options = { mode: "tui", + bundle: false, runs: 1, warmup: 0, profileDir: undefined, @@ -103,6 +106,11 @@ function parseArgs(argv) { continue; } + if (arg === "--bundle") { + options.bundle = true; + continue; + } + if (arg === "--skip-build") { options.build = false; continue; @@ -221,7 +229,7 @@ function parseStartupTimings(stderr) { let inBlock = false; for (const line of lines) { - if (line.includes("--- Startup Timings ---")) { + if (/^--- Startup Timings(?:: [^-]+)? ---$/.test(line.trim())) { inBlock = true; continue; } @@ -278,59 +286,72 @@ async function waitForExit(child, errorPrefix) { }); } -async function runBuild() { - process.stdout.write("Building packages/tui, packages/telemetry, packages/ai, packages/agent, and packages/coding-agent...\n"); +async function runBuild(bundle) { + process.stdout.write( + `Building dependencies and the ${bundle ? "bundled" : "unbundled"} coding-agent Node entrypoint...\n`, + ); const startedAt = performance.now(); - const child = spawn( - "npm", - [ - "run", - "build", - "--workspace", - "packages/tui", - "--workspace", - "packages/telemetry", - "--workspace", - "packages/ai", - "--workspace", - "packages/agent", - "--workspace", - "packages/coding-agent", - ], + const commands = [ { + label: "Dependency build", + args: [ + "run", + "build", + "--workspace", + "packages/tui", + "--workspace", + "packages/telemetry", + "--workspace", + "packages/ai", + "--workspace", + "packages/agent", + "--workspace", + "packages/protocol", + "--workspace", + "packages/client", + ], + }, + { + label: "Coding-agent build", + args: ["run", bundle ? "build" : "build:unbundled", "--workspace", "packages/coding-agent"], + }, + ]; + + for (const command of commands) { + const child = spawn("npm", command.args, { cwd: repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", - }, - ); + }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); - const exitCode = await waitForExit(child, "Build"); - if (exitCode !== 0) { - if (stdout.trim()) { - process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`); + const exitCode = await waitForExit(child, command.label); + if (exitCode !== 0) { + if (stdout.trim()) { + process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`); + } + if (stderr.trim()) { + process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`); + } + throw new Error(`${command.label} failed with exit code ${exitCode}`); } - if (stderr.trim()) { - process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`); - } - throw new Error(`Build failed with exit code ${exitCode}`); } process.stdout.write(`Build completed in ${formatMs(performance.now() - startedAt)}\n`); } -function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { +function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile, nodeEntryPath) { const benchmarkArgs = ["--no-session"]; if (mode === "rpc") { benchmarkArgs.push("--mode", "rpc"); @@ -352,7 +373,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { if (cpuProfile) { args.push("--cpu-prof", `--cpu-prof-dir=${profileDir}`, `--cpu-prof-name=${profileName}`); } - args.push(distCliPath, ...benchmarkArgs); + args.push(nodeEntryPath, ...benchmarkArgs); return { executable: process.execPath, args, @@ -360,7 +381,7 @@ function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) { } function createBenchmarkEnv(options, isolatedAgentDir) { - const env = { ...process.env }; + const env = { ...process.env, PI_TIMING: "1" }; if (options.agentDir) { env[agentDirEnvName] = options.agentDir; } else if (isolatedAgentDir) { @@ -386,11 +407,12 @@ async function runTuiBenchmarkRun({ runtime, runIndex, measuredIndex, options, p mkdirSync(isolatedAgentDir, { recursive: true }); } - const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile); + const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath; + const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile, nodeEntryPath); const child = spawn(command.executable, command.args, { cwd: packageDir, env: createBenchmarkEnv(options, isolatedAgentDir), - stdio: ["inherit", "ignore", "pipe"], + stdio: ["inherit", "inherit", "pipe"], shell: process.platform === "win32" && runtime === "bun", }); @@ -445,7 +467,8 @@ async function runRpcBenchmarkRun({ runtime, runIndex, measuredIndex, options, p mkdirSync(isolatedAgentDir, { recursive: true }); } - const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile); + const nodeEntryPath = options.bundle ? bundledDistCliPath : distCliPath; + const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile, nodeEntryPath); const child = spawn(command.executable, command.args, { cwd: packageDir, env: createBenchmarkEnv(options, isolatedAgentDir), @@ -547,11 +570,14 @@ async function main() { } const runtime = resolveRuntime(options.runtime); + if (options.bundle && runtime !== "node") { + throw new Error("--bundle only supports the Node runtime"); + } options.label = resolveLabel(options.mode, options.label); const profileDir = resolveProfileDir(runtime, options.profileDir); if (runtime === "node" && options.build) { - await runBuild(); + await runBuild(options.bundle); } if (runtime === "bun") { process.stdout.write( @@ -559,7 +585,16 @@ async function main() { ); } - const entryPath = runtime === "bun" ? srcCliPath : distCliPath; + const entryPath = runtime === "bun" ? srcCliPath : options.bundle ? bundledDistCliPath : distCliPath; + if ( + runtime === "node" && + !options.bundle && + !options.build && + existsSync(distCliPath) && + readFileSync(distCliPath, "utf8").includes('import "./bundle/cli.js";') + ) { + throw new Error("dist/cli.js is a bundled facade; rerun without --skip-build for an unbundled profile"); + } if (!existsSync(entryPath)) { throw new Error(`CLI entrypoint not found: ${entryPath}`); } @@ -597,7 +632,7 @@ async function main() { const maxElapsedRun = measuredRuns.reduce((slowest, run) => (run.elapsedMs > slowest.elapsedMs ? run : slowest)); if (measuredRuns.length === 1) { process.stdout.write("\nResult\n"); - process.stdout.write(` runtime: ${runtime}\n`); + process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`); process.stdout.write(` mode: ${options.mode}\n`); process.stdout.write(` elapsed: ${formatMs(measuredRuns[0].elapsedMs)}\n`); for (const [label, summary] of timingSummaries.entries()) { @@ -615,7 +650,7 @@ async function main() { } process.stdout.write("\nSummary\n"); - process.stdout.write(` runtime: ${runtime}\n`); + process.stdout.write(` runtime: ${runtime}${options.bundle ? " (bundle)" : ""}\n`); process.stdout.write(` mode: ${options.mode}\n`); process.stdout.write(` elapsed min: ${formatMs(elapsedSummary.min)}\n`); process.stdout.write(` elapsed median: ${formatMs(elapsedSummary.median)}\n`);