diff --git a/packages/pi-plugin/src/dreamer/pi-session-api.test.ts b/packages/pi-plugin/src/dreamer/pi-session-api.test.ts index c5efe6d08..2b43c8112 100644 --- a/packages/pi-plugin/src/dreamer/pi-session-api.test.ts +++ b/packages/pi-plugin/src/dreamer/pi-session-api.test.ts @@ -1,12 +1,93 @@ /// import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { clearCachedModule, + defaultLoaders, loadDefaultPiSessionApi, type ModuleLoader, + resolvePiCodingAgentModule, } from "./pi-session-api"; +const PI_SPEC = "@earendil-works/pi-coding-agent"; + +/** A fixture module whose listAll returns a unique marker, so tests can tell + * WHICH copy of pi-coding-agent was resolved through the public API. */ +function fixtureModule(marker: string): string { + return ( + `export const __piShimFakeMarker = ${JSON.stringify(marker)};\n` + + `export const SessionManager = { listAll: async () => [${JSON.stringify(marker)}] };\n` + ); +} + +/** Write a fake pi-coding-agent package rooted at pkgRoot. */ +function writeFixturePackage( + pkgRoot: string, + opts: { + version?: string; + entry?: string; + /** Full manifest override — use for shapes the default can't express. */ + manifest?: Record; + files: Record; + }, +): void { + mkdirSync(pkgRoot, { recursive: true }); + writeFileSync( + join(pkgRoot, "package.json"), + JSON.stringify( + opts.manifest ?? { + name: PI_SPEC, + version: opts.version ?? "9.9.9", + exports: { ".": { import: opts.entry ?? "./index.js" } }, + }, + ), + ); + for (const [rel, content] of Object.entries(opts.files)) { + const p = join(pkgRoot, rel); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, content); + } +} + +/** Loader that stands in for "the next strategy" when the first must fail. */ +function fallbackLoader(marker = "fallback-used"): ModuleLoader { + return { + name: "Fallback", + load: async () => ({ + SessionManager: { listAll: async () => [marker] }, + }), + }; +} + +/** Run fn with process.argv[1] temporarily replaced (undefined = no script arg). */ +async function withArgv1( + argv1: string | undefined, + fn: () => Promise, +): Promise { + const origArgv = process.argv; + Object.defineProperty(process, "argv", { + value: argv1 === undefined ? [process.execPath] : [process.execPath, argv1], + configurable: true, + }); + try { + return await fn(); + } finally { + Object.defineProperty(process, "argv", { + value: origArgv, + configurable: true, + }); + } +} + /** * These tests exercise the DEFAULT resolution path against the actually * installed pi-coding-agent package. The Pi session-listing API drifted once @@ -30,9 +111,6 @@ describe("loadDefaultPiSessionApi", () => { it("parses JSONL session entries through the resolved loader", async () => { const api = await loadDefaultPiSessionApi(); - const { mkdtempSync, writeFileSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); const dir = mkdtempSync(join(tmpdir(), "pi-session-api-test-")); const file = join(dir, "session.jsonl"); @@ -52,6 +130,318 @@ describe("loadDefaultPiSessionApi", () => { expect(entries.length).toBeGreaterThan(0); }, 30000); + describe("default loader order", () => { + it('first default loader is "Resolve from running Pi binary entry" (prefer the running Pi version)', () => { + const names = defaultLoaders.map((l) => l.name); + expect(names[0]).toBe("Resolve from running Pi binary entry"); + expect(names).toContain("Bare import"); + }); + + it("resolves through a bin-shim symlink when argv[1] is the shim path", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-symlink-test-")); + const pkgRoot = join( + dir, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + writeFixturePackage(pkgRoot, { + files: { "index.js": fixtureModule("fake-9.9.9") }, + }); + // bin shim: bin/pi -> /index.js (a symlink to the real entry) + const binDir = join(dir, "bin"); + mkdirSync(binDir, { recursive: true }); + const shim = join(binDir, "pi"); + symlinkSync(join(pkgRoot, "index.js"), shim); + + const origCwd = process.cwd(); + try { + process.chdir(dir); + await withArgv1(shim, async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(typeof api.listSessions).toBe("function"); + expect(typeof api.loadEntriesFromFile).toBe("function"); + // End-to-end through the PUBLIC resolve path: the fixture's + // listAll marker must come back. If the symlink walk broke and + // the bare-import fallback rescued resolution, the REAL + // installed package's sessions would come back instead and + // this assertion fails — the fallback cannot green this test. + expect(await api.listSessions()).toEqual(["fake-9.9.9"]); + // Belt and braces: the fake copy also exports a module marker + // the real installed pi-coding-agent does not. + const mod = (await resolvePiCodingAgentModule(defaultLoaders)) as { + __piShimFakeMarker?: string; + }; + expect(mod.__piShimFakeMarker).toBe("fake-9.9.9"); + }); + expect(realpathSync(shim)).toBe( + realpathSync(join(pkgRoot, "index.js")), + ); + } finally { + process.chdir(origCwd); + } + }, 30000); + }); + + describe("running-Pi resolver layouts", () => { + it("prefers the running Pi over a stale extension-tree copy", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-running-vs-stale-")); + const pkgRoot = join( + dir, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + writeFixturePackage(pkgRoot, { + files: { "index.js": fixtureModule("running-pi-9.9.9") }, + }); + + // The bare import resolves a STALE extension-tree copy. mock.module + // makes that copy observable: if the running-Pi resolver breaks and + // the bare import rescues resolution, the stale marker comes back + // and this test fails instead of greening on the fallback. + const originalModule = await import(PI_SPEC); + mock.module(PI_SPEC, () => ({ + SessionManager: { listAll: async () => ["stale-extension-tree-copy"] }, + })); + try { + await withArgv1(join(pkgRoot, "index.js"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["running-pi-9.9.9"]); + }); + } finally { + mock.module(PI_SPEC, () => originalModule); + clearCachedModule(); + } + }, 30000); + + it("skips Pi's copied dist/package.json and resolves the parent package root", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-dist-metadata-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + entry: "./dist/index.js", + files: { + "dist/index.js": fixtureModule("parent-root-9.9.9"), + "dist/cli.js": "// binary entry\n", + // build:binary copies this metadata into dist/. Stopping the + // walk here would resolve ./dist/index.js against dist/ and + // construct dist/dist/index.js. + "dist/package.json": JSON.stringify({ + name: PI_SPEC, + version: "9.9.9", + exports: { ".": { import: "./dist/index.js" } }, + }), + }, + }); + await withArgv1(join(pkgRoot, "dist", "cli.js"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["parent-root-9.9.9"]); + }); + }, 30000); + + it("rejects a manifest entry that escapes the package root", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-traversal-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + entry: "../../outside.js", + files: { "cli.js": "// entry\n" }, + }); + await withArgv1(join(pkgRoot, "cli.js"), async () => { + clearCachedModule(); + const fallback: ModuleLoader = { + name: "Fallback", + load: async () => ({ + SessionManager: { listAll: async () => ["fallback-used"] }, + }), + }; + // The escaping entry must be rejected, falling through to the + // next loader rather than importing a path outside the package. + const api = await loadDefaultPiSessionApi([ + defaultLoaders[0], + fallback, + ]); + expect(await api.listSessions()).toEqual(["fallback-used"]); + }); + }, 30000); + + it("running from a TypeScript source checkout loads the source entry, not stale dist output", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-source-mode-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + entry: "./dist/index.js", + files: { + "src/cli.ts": "// source entry (tsx/jiti)\n", + "src/index.ts": fixtureModule("source-checkout"), + // Stale build output left behind by an old build — must NOT win. + "dist/index.js": fixtureModule("stale-dist-output"), + }, + }); + await withArgv1(join(pkgRoot, "src", "cli.ts"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["source-checkout"]); + }); + }, 30000); + + it("source mode without a source counterpart falls through instead of loading stale dist", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-source-missing-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + entry: "./dist/index.js", + files: { + "src/cli.ts": "// source entry (tsx/jiti)\n", + "dist/index.js": fixtureModule("stale-dist-output"), + }, + }); + await withArgv1(join(pkgRoot, "src", "cli.ts"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi([ + defaultLoaders[0], + fallbackLoader(), + ]); + expect(await api.listSessions()).toEqual(["fallback-used"]); + }); + }, 30000); + + describe("manifest entry validation", () => { + // The "./" requirement applies to every manually joined manifest + // target, not just exports: a non-prefixed "dist/index.js" in ANY + // field must be rejected, falling through to the next loader. + const nonPrefixedCases: Array<{ + label: string; + manifest: Record; + }> = [ + { + label: "exports string entry", + manifest: { + name: PI_SPEC, + exports: { ".": "dist/index.js" }, + }, + }, + { + label: "exports import condition", + manifest: { + name: PI_SPEC, + exports: { ".": { import: "dist/index.js" } }, + }, + }, + { + label: "module field", + manifest: { name: PI_SPEC, module: "dist/index.js" }, + }, + { + label: "main field", + manifest: { name: PI_SPEC, main: "dist/index.js" }, + }, + ]; + for (const { label, manifest } of nonPrefixedCases) { + it(`rejects a non-prefixed entry in ${label}`, async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-nonprefixed-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + manifest, + files: { + "cli.js": "// entry\n", + "dist/index.js": fixtureModule("must-not-load"), + }, + }); + await withArgv1(join(pkgRoot, "cli.js"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi([ + defaultLoaders[0], + fallbackLoader(), + ]); + expect(await api.listSessions()).toEqual(["fallback-used"]); + }); + }, 30000); + } + + it("resolves the default condition when no import condition exists", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-default-cond-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + manifest: { + name: PI_SPEC, + exports: { + ".": { + types: "./index.d.ts", + default: "./index.js", + }, + }, + }, + files: { "index.js": fixtureModule("default-condition") }, + }); + await withArgv1(join(pkgRoot, "index.js"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["default-condition"]); + }); + }, 30000); + + it("resolves array exports by falling back to the first resolvable target", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-array-exports-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + manifest: { + name: PI_SPEC, + // First element is a require-only conditional (no import / + // node / default) — unresolvable for us; the array fallback + // must advance to the plain string target. + exports: { + ".": [{ require: "./index.cjs" }, "./index.js"], + }, + }, + files: { "index.js": fixtureModule("array-fallback") }, + }); + await withArgv1(join(pkgRoot, "index.js"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["array-fallback"]); + }); + }, 30000); + }); + + describe("script entry whitelist", () => { + it("accepts an extensionless bin-style entry script", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-extensionless-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + files: { + // bin-style entry with no file extension (e.g. dist/cli) + cli: "// extensionless entry\n", + "index.js": fixtureModule("extensionless-entry"), + }, + }); + await withArgv1(join(pkgRoot, "cli"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["extensionless-entry"]); + }); + }, 30000); + + it("running from a .tsx source checkout loads the source entry, not stale dist", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-tsx-source-")); + const pkgRoot = join(dir, "pi-coding-agent"); + writeFixturePackage(pkgRoot, { + entry: "./dist/index.jsx", + files: { + "src/cli.tsx": "// source entry (tsx/jiti)\n", + "src/index.tsx": fixtureModule("tsx-source-checkout"), + "dist/index.jsx": fixtureModule("stale-dist-output"), + }, + }); + await withArgv1(join(pkgRoot, "src", "cli.tsx"), async () => { + clearCachedModule(); + const api = await loadDefaultPiSessionApi(); + expect(await api.listSessions()).toEqual(["tsx-source-checkout"]); + }); + }, 30000); + }); + }); + describe("resolution ladder mechanics", () => { it("ladder order: first loader succeeds -> second never called", async () => { const firstLoaderCalled = mock(() => @@ -124,6 +514,28 @@ describe("loadDefaultPiSessionApi", () => { ); }); + it("missing argv[1] names the walked entry (execPath) in the error, not undefined", async () => { + // Packaged-binary run: argv has no script path, so the loader falls + // back to process.execPath; the upward walk cannot find + // pi-coding-agent from there, and the error must name the path + // actually walked rather than interpolating `undefined`. + await withArgv1(undefined, async () => { + const loaders: ModuleLoader[] = [defaultLoaders[0]]; + let error: Error | null = null; + try { + await loadDefaultPiSessionApi(loaders); + } catch (e: unknown) { + error = e as Error; + } + expect(error).not.toBeNull(); + expect(error?.message).toContain( + "Could not locate @earendil-works/pi-coding-agent package.json from", + ); + expect(error?.message).toContain(process.execPath); + expect(error?.message).not.toContain("from undefined"); + }); + }); + it("memoization: two calls -> loaders invoked once", async () => { const loaderCalled = mock(() => Promise.resolve({ SessionManager: { listAll: () => [] } }), diff --git a/packages/pi-plugin/src/dreamer/pi-session-api.ts b/packages/pi-plugin/src/dreamer/pi-session-api.ts index 0351e28c0..ba6c9fc3f 100644 --- a/packages/pi-plugin/src/dreamer/pi-session-api.ts +++ b/packages/pi-plugin/src/dreamer/pi-session-api.ts @@ -1,5 +1,13 @@ -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { + basename, + dirname, + extname, + join, + relative, + resolve, + sep, +} from "node:path"; import { pathToFileURL } from "node:url"; /** @@ -21,6 +29,191 @@ export interface PiSessionApi { const PI_CODING_AGENT_MODULE = "@earendil-works/pi-coding-agent"; +// Script-like entries: explicit JS/TS extensions, or no extension at all +// (bin-style entry scripts such as an extensionless `dist/cli`). Anything +// with another extension (.json, .png, ...) is a CLI argument, not an entry. +const SCRIPT_ENTRY_PATTERN = /\.(mjs|cjs|mts|cts|js|ts|tsx|jsx)$/i; +const TS_ENTRY_PATTERN = /\.(mts|cts|ts|tsx)$/i; + +function isScriptEntry(filePath: string): boolean { + return SCRIPT_ENTRY_PATTERN.test(filePath) || extname(filePath) === ""; +} + +interface FoundPackage { + dir: string; + pkg: Record; +} + +function readManifest(pkgJsonPath: string): Record | null { + try { + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8")); + return pkg && typeof pkg === "object" + ? (pkg as Record) + : null; + } catch { + return null; + } +} + +/** + * In a `bun build --compile` binary the pi-coding-agent code is embedded in the + * executable ($bunfs) and process.execPath IS that executable: there is no + * on-disk package to walk to, and process.argv[1] is a user-controlled CLI + * argument (e.g. `pi --print`), not an entry script. Detect this layout + * explicitly so the walker falls through to the next loader instead of walking + * up from an arbitrary user-supplied path. + */ +function isCompiledBunBinary(): boolean { + if (!process.versions.bun) { + return false; + } + const exe = basename(process.execPath).toLowerCase(); + return exe !== "bun" && exe !== "bun.exe"; +} + +/** + * Determine the on-disk file the running Pi was started from, or null when + * there is none. The Pi binary is spawned with `process.argv[1]` pointing at + * the host `cli.js` (see subagent-runner resolvePiInvocation); when Pi is + * launched through a bin shim, argv[1] is a SYMLINK to the real cli.js + * (node/bun preserve the invocation path), so resolve the real path first. + * Only script-like files are accepted: anything else (absent argv[1], a CLI + * argument, a Jiti VIRTUAL module with no physical path) means there is no + * usable entry and the caller falls back to process.execPath. + */ +function resolveRunningEntry(): string | null { + const argv1 = process.argv[1]; + if (argv1 && existsSync(argv1) && statSync(argv1).isFile()) { + const real = realpathSync(argv1); + if (isScriptEntry(real)) { + return real; + } + } + return null; +} + +/** + * Walk up from `startDir` to the pi-coding-agent package root. + * + * Pi's `build:binary` copies Bun's metadata into `dist/package.json`, and that + * manifest ALSO carries the pi-coding-agent name. Stopping there would resolve + * an entry like `./dist/index.js` against `dist/`, producing + * `dist/dist/index.js`. Mirror Pi's own `findNodePackageDir` (dist/config.js): + * when a matching manifest sits in a `dist/` directory whose parent also owns + * a matching manifest, the parent is the package root. + */ +function findPackageRoot(startDir: string): FoundPackage | null { + let dir = startDir; + while (dir !== dirname(dir)) { + const pkg = readManifest(join(dir, "package.json")); + if (pkg?.name === PI_CODING_AGENT_MODULE) { + if (basename(dir) === "dist") { + const parentDir = dirname(dir); + const parentPkg = readManifest(join(parentDir, "package.json")); + if (parentPkg?.name === PI_CODING_AGENT_MODULE) { + return { dir: parentDir, pkg: parentPkg }; + } + } + return { dir, pkg }; + } + dir = dirname(dir); + } + return null; +} + +/** + * Resolve a conditional/array exports target to a concrete path, mirroring + * Node's condition resolution for ESM imports: "import" first, then "node", + * then "default" ("types" is TypeScript-only and never a runtime target). + * Arrays are fallback lists — the first resolvable entry wins. Shapes we + * cannot interpret return undefined; the caller then falls through to + * module/main and ultimately to the bare-import loader (the designed + * safety valve). + */ +function resolveExportsTarget(target: unknown): string | undefined { + if (typeof target === "string") { + return target; + } + if (Array.isArray(target)) { + for (const item of target) { + const resolved = resolveExportsTarget(item); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; + } + if (target && typeof target === "object") { + const conditions = target as Record; + for (const condition of ["import", "node", "default"]) { + if (condition in conditions) { + const resolved = resolveExportsTarget(conditions[condition]); + if (resolved !== undefined) { + return resolved; + } + } + } + } + return undefined; +} + +/** + * Resolve the package's ESM entry from its manifest, mirroring Node's own + * export-target validation: the target must be relative ("./"-prefixed) + * and must stay inside the package root. The "./" requirement applies to + * EVERY manually joined target, not just exports: module/main entries are + * joined by hand here, so a non-prefixed "dist/index.js" must not skip the + * relative-path requirement. We import the file directly, bypassing the + * loader's built-in validation, so a broken or malicious manifest must not + * be able to point us at an arbitrary path. + */ +function resolveManifestEntry(found: FoundPackage): string { + const pkg = found.pkg as { + exports?: { "."?: unknown }; + module?: unknown; + main?: unknown; + }; + const entryRel = + resolveExportsTarget(pkg.exports?.["."]) ?? + (typeof pkg.module === "string" ? pkg.module : undefined) ?? + (typeof pkg.main === "string" ? pkg.main : undefined); + if (!entryRel) { + throw new Error(`No ESM entry found in ${found.dir}/package.json`); + } + if (!entryRel.startsWith("./")) { + throw new Error( + `Invalid entry "${entryRel}" in ${found.dir}/package.json: targets must start with "./"`, + ); + } + const root = resolve(found.dir); + const resolved = resolve(root, entryRel); + if (resolved !== root && !resolved.startsWith(root + sep)) { + throw new Error( + `Invalid entry "${entryRel}" in ${found.dir}/package.json: target escapes the package root`, + ); + } + return resolved; +} + +/** + * Map a build-output entry (dist/index.js) back to its source counterpart + * (src/index.ts). Returns null when the entry does not follow the dist→src + * layout. + */ +function toSourceEntry(entryPath: string, pkgDir: string): string | null { + const rel = relative(pkgDir, entryPath); + const srcRel = rel + .replace(/^dist[/\\]/, "src/") + .replace(/\.mjs$/, ".mts") + .replace(/\.cjs$/, ".cts") + .replace(/\.jsx$/, ".tsx") + .replace(/\.js$/, ".ts"); + if (srcRel === rel) { + return null; + } + return join(pkgDir, srcRel); +} + export interface ModuleLoader { name: string; load: () => Promise; @@ -28,20 +221,62 @@ export interface ModuleLoader { export const defaultLoaders: ModuleLoader[] = [ { - name: "Bare import", - load: async () => await import(/* @vite-ignore */ PI_CODING_AGENT_MODULE), - }, - { + // Resolve from the running Pi binary FIRST so the dreamer loads the SAME + // pi-coding-agent version that owns the live session format. A stale or + // mismatched copy in an extension tree (e.g. a host peer that was + // auto-installed once and never updated) can drift from the live session + // API and break retrospective / refresh-primers. name: "Resolve from running Pi binary entry", load: async () => { - if (!process.argv[1]) { - throw new Error("process.argv[1] is undefined"); + if (isCompiledBunBinary()) { + throw new Error( + "Running inside a compiled Bun binary: pi-coding-agent is embedded in the executable and argv[1] is a user CLI argument, so there is no on-disk package entry to resolve", + ); + } + // argv[1] may be absent, a non-script CLI argument, or a Jiti VIRTUAL + // module with no on-disk path — in those cases fall back to the + // interpreter binary (process.execPath) and walk from there. + const entry = resolveRunningEntry() ?? process.execPath; + if (!entry) { + throw new Error( + "Neither process.argv[1] nor process.execPath is available", + ); + } + // pi-coding-agent ships ESM-only exports (no "require" condition), so + // createRequire(...).resolve("") fails with + // ERR_PACKAGE_PATH_NOT_EXPORTED. Walk up from the resolved entry to find + // the package by name and import its ESM entry directly. + const found = findPackageRoot(dirname(entry)); + if (!found) { + // `entry` may be the execPath fallback (when argv[1] is a CLI arg, + // a virtual module, or absent), so the message names the path + // actually walked instead of interpolating `undefined`. + throw new Error( + `Could not locate ${PI_CODING_AGENT_MODULE} package.json from ${entry}`, + ); + } + const entryPath = resolveManifestEntry(found); + // Source checkouts (tsx/jiti running src/cli.ts): the manifest entry + // points at build output under dist/, which can be STALE relative to + // the running sources. When the running entry is TypeScript, load the + // matching source file; if none exists, refuse to silently select + // stale build output and fall through to the next loader. + if (TS_ENTRY_PATTERN.test(entry)) { + const srcEntry = toSourceEntry(entryPath, found.dir); + if (srcEntry && existsSync(srcEntry)) { + return await import(pathToFileURL(srcEntry).href); + } + throw new Error( + `Pi is running from TypeScript source (${entry}) but no source counterpart of ${entryPath} exists; refusing to load possibly stale build output`, + ); } - const require = createRequire(process.argv[1]); - const resolved = require.resolve(PI_CODING_AGENT_MODULE); - return await import(pathToFileURL(resolved).href); + return await import(pathToFileURL(entryPath).href); }, }, + { + name: "Bare import", + load: async () => await import(/* @vite-ignore */ PI_CODING_AGENT_MODULE), + }, ]; let cachedModulePromise: Promise | null = null;