From 01c2808606b4fab03c3216a9e9a5a26d6288fbf7 Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Wed, 15 Apr 2026 13:51:06 -0700 Subject: [PATCH] Fix package runtime and subagent reliability --- scripts/lib/pi-subagents-patch.mjs | 60 ++++ src/pi/package-ops.ts | 456 +++++++++++++++++++++++++++++ src/pi/package-presets.ts | 25 ++ src/pi/settings.ts | 26 +- src/system/executables.ts | 15 +- tests/package-ops.test.ts | 56 ++++ tests/pi-settings.test.ts | 47 ++- tests/pi-subagents-patch.test.ts | 38 +++ 8 files changed, 718 insertions(+), 5 deletions(-) create mode 100644 src/pi/package-ops.ts create mode 100644 tests/package-ops.test.ts diff --git a/scripts/lib/pi-subagents-patch.mjs b/scripts/lib/pi-subagents-patch.mjs index f0b1631..9156e2b 100644 --- a/scripts/lib/pi-subagents-patch.mjs +++ b/scripts/lib/pi-subagents-patch.mjs @@ -83,6 +83,66 @@ export function patchPiSubagentsSource(relativePath, source) { 'const userDir = path.join(os.homedir(), ".pi", "agent", "agents");', 'const userDir = path.join(resolvePiAgentDir(), "agents");', ); + patched = replaceAll( + patched, + [ + 'export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {', + '\tconst userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");', + '\tconst userDirNew = path.join(os.homedir(), ".agents");', + ].join("\n"), + [ + 'export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {', + '\tconst userDir = path.join(resolvePiAgentDir(), "agents");', + ].join("\n"), + ); + patched = replaceAll( + patched, + [ + '\tconst userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user");', + '\tconst userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user");', + '\tconst userAgents = [...userAgentsOld, ...userAgentsNew];', + ].join("\n"), + '\tconst userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");', + ); + patched = replaceAll( + patched, + [ + 'const userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");', + 'const userDirNew = path.join(os.homedir(), ".agents");', + ].join("\n"), + 'const userDir = path.join(resolvePiAgentDir(), "agents");', + ); + patched = replaceAll( + patched, + [ + '\tconst user = [', + '\t\t...loadAgentsFromDir(userDirOld, "user"),', + '\t\t...loadAgentsFromDir(userDirNew, "user"),', + '\t];', + ].join("\n"), + '\tconst user = loadAgentsFromDir(userDir, "user");', + ); + patched = replaceAll( + patched, + [ + '\tconst chains = [', + '\t\t...loadChainsFromDir(userDirOld, "user"),', + '\t\t...loadChainsFromDir(userDirNew, "user"),', + '\t\t...(projectDir ? loadChainsFromDir(projectDir, "project") : []),', + '\t];', + ].join("\n"), + [ + '\tconst chains = [', + '\t\t...loadChainsFromDir(userDir, "user"),', + '\t\t...(projectDir ? loadChainsFromDir(projectDir, "project") : []),', + '\t];', + ].join("\n"), + ); + patched = replaceAll( + patched, + '\tconst userDir = fs.existsSync(userDirNew) ? userDirNew : userDirOld;', + '\tconst userDir = path.join(resolvePiAgentDir(), "agents");', + ); break; case "artifacts.ts": patched = replaceAll( diff --git a/src/pi/package-ops.ts b/src/pi/package-ops.ts new file mode 100644 index 0000000..cf868b7 --- /dev/null +++ b/src/pi/package-ops.ts @@ -0,0 +1,456 @@ +import { spawn } from "node:child_process"; +import { cpSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +import { DefaultPackageManager, SettingsManager } from "@mariozechner/pi-coding-agent"; + +import { NATIVE_PACKAGE_SOURCES, supportsNativePackageSources } from "./package-presets.js"; +import { applyFeynmanPackageManagerEnv, getFeynmanNpmPrefixPath } from "./runtime.js"; +import { getPathWithCurrentNode, resolveExecutable } from "../system/executables.js"; + +type PackageScope = "user" | "project"; + +type ConfiguredPackage = { + source: string; + scope: PackageScope; + filtered: boolean; + installedPath?: string; +}; + +type NpmSource = { + name: string; + source: string; + spec: string; + pinned: boolean; +}; + +export type MissingConfiguredPackageSummary = { + missing: ConfiguredPackage[]; + bundled: ConfiguredPackage[]; +}; + +export type InstallPackageSourcesResult = { + installed: string[]; + skipped: string[]; +}; + +export type UpdateConfiguredPackagesResult = { + updated: string[]; + skipped: string[]; +}; + +const FILTERED_INSTALL_OUTPUT_PATTERNS = [ + /npm warn deprecated node-domexception@1\.0\.0/i, + /npm notice/i, + /^(added|removed|changed) \d+ packages?( in .+)?$/i, + /^(\d+ )?packages are looking for funding$/i, + /^run `npm fund` for details$/i, +]; +const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function createPackageContext(workingDir: string, agentDir: string) { + applyFeynmanPackageManagerEnv(agentDir); + process.env.PATH = getPathWithCurrentNode(process.env.PATH); + const settingsManager = SettingsManager.create(workingDir, agentDir); + const packageManager = new DefaultPackageManager({ + cwd: workingDir, + agentDir, + settingsManager, + }); + + return { + settingsManager, + packageManager, + }; +} + +function shouldSkipNativeSource(source: string, version = process.versions.node): boolean { + return !supportsNativePackageSources(version) && NATIVE_PACKAGE_SOURCES.includes(source as (typeof NATIVE_PACKAGE_SOURCES)[number]); +} + +function filterUnsupportedSources(sources: string[], version = process.versions.node): { supported: string[]; skipped: string[] } { + const supported: string[] = []; + const skipped: string[] = []; + + for (const source of sources) { + if (shouldSkipNativeSource(source, version)) { + skipped.push(source); + continue; + } + supported.push(source); + } + + return { supported, skipped }; +} + +function relayFilteredOutput(chunk: Buffer | string, writer: NodeJS.WriteStream): void { + const text = chunk.toString(); + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + if (FILTERED_INSTALL_OUTPUT_PATTERNS.some((pattern) => pattern.test(line.trim()))) { + continue; + } + writer.write(`${line}\n`); + } +} + +function parseNpmSource(source: string): NpmSource | undefined { + if (!source.startsWith("npm:")) { + return undefined; + } + + const spec = source.slice("npm:".length).trim(); + const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); + const name = match?.[1] ?? spec; + const version = match?.[2]; + + return { + name, + source, + spec, + pinned: Boolean(version), + }; +} + +function dedupeNpmSources(sources: string[], updateToLatest: boolean): string[] { + const specs = new Map(); + + for (const source of sources) { + const parsed = parseNpmSource(source); + if (!parsed) continue; + + specs.set(parsed.name, updateToLatest && !parsed.pinned ? `${parsed.name}@latest` : parsed.spec); + } + + return [...specs.values()]; +} + +function ensureProjectInstallRoot(workingDir: string): string { + const installRoot = resolve(workingDir, ".feynman", "npm"); + mkdirSync(installRoot, { recursive: true }); + + const ignorePath = join(installRoot, ".gitignore"); + if (!existsSync(ignorePath)) { + writeFileSync(ignorePath, "*\n!.gitignore\n", "utf8"); + } + + const packageJsonPath = join(installRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + writeFileSync(packageJsonPath, JSON.stringify({ name: "feynman-packages", private: true }, null, 2) + "\n", "utf8"); + } + + return installRoot; +} + +function resolveAdjacentNpmExecutable(): string | undefined { + const executableName = process.platform === "win32" ? "npm.cmd" : "npm"; + const candidate = resolve(dirname(process.execPath), executableName); + return existsSync(candidate) ? candidate : undefined; +} + +function resolvePackageManagerCommand(settingsManager: SettingsManager): { command: string; args: string[] } | undefined { + const configured = settingsManager.getNpmCommand(); + if (!configured || configured.length === 0) { + const adjacentNpm = resolveAdjacentNpmExecutable() ?? resolveExecutable("npm"); + return adjacentNpm ? { command: adjacentNpm, args: [] } : undefined; + } + + const [command = "npm", ...args] = configured; + if (!command) { + return undefined; + } + + const executable = resolveExecutable(command); + if (!executable) { + return undefined; + } + + return { command: executable, args }; +} + +async function runPackageManagerInstall( + settingsManager: SettingsManager, + workingDir: string, + agentDir: string, + scope: PackageScope, + specs: string[], +): Promise { + if (specs.length === 0) { + return; + } + + const packageManagerCommand = resolvePackageManagerCommand(settingsManager); + if (!packageManagerCommand) { + throw new Error("No supported package manager found. Install npm, pnpm, or bun, or configure `npmCommand`."); + } + + const args = [ + ...packageManagerCommand.args, + "install", + "--no-audit", + "--no-fund", + "--legacy-peer-deps", + "--loglevel", + "error", + ]; + + if (scope === "user") { + args.push("-g", "--prefix", getFeynmanNpmPrefixPath(agentDir)); + } else { + args.push("--prefix", ensureProjectInstallRoot(workingDir)); + } + + args.push(...specs); + + await new Promise((resolvePromise, reject) => { + const child = spawn(packageManagerCommand.command, args, { + cwd: scope === "user" ? agentDir : workingDir, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + PATH: getPathWithCurrentNode(process.env.PATH), + }, + }); + + child.stdout?.on("data", (chunk) => relayFilteredOutput(chunk, process.stdout)); + child.stderr?.on("data", (chunk) => relayFilteredOutput(chunk, process.stderr)); + + child.on("error", reject); + child.on("exit", (code) => { + if ((code ?? 1) !== 0) { + const installingGenerativeUi = specs.some((spec) => spec.startsWith("pi-generative-ui")); + if (installingGenerativeUi && process.platform === "darwin") { + reject( + new Error( + "Installing pi-generative-ui failed. Its native glimpseui dependency did not compile against the current macOS/Xcode toolchain. Try the npm-installed Feynman path with your local Node toolchain or skip this optional preset for now.", + ), + ); + return; + } + reject(new Error(`${packageManagerCommand.command} install failed with code ${code ?? 1}`)); + return; + } + + resolvePromise(); + }); + }); +} + +function groupConfiguredNpmSources(packages: ConfiguredPackage[]): Record { + return { + user: packages.filter((entry) => entry.scope === "user").map((entry) => entry.source), + project: packages.filter((entry) => entry.scope === "project").map((entry) => entry.source), + }; +} + +function isBundledWorkspacePackagePath(installedPath: string | undefined, appRoot: string): boolean { + if (!installedPath) { + return false; + } + + const bundledRoot = resolve(appRoot, ".feynman", "npm", "node_modules"); + return installedPath.startsWith(bundledRoot); +} + +export function getMissingConfiguredPackages( + workingDir: string, + agentDir: string, + appRoot: string, +): MissingConfiguredPackageSummary { + const { packageManager } = createPackageContext(workingDir, agentDir); + const configured = packageManager.listConfiguredPackages(); + + return configured.reduce( + (summary, entry) => { + if (entry.installedPath) { + if (isBundledWorkspacePackagePath(entry.installedPath, appRoot)) { + summary.bundled.push(entry); + } + return summary; + } + + summary.missing.push(entry); + return summary; + }, + { missing: [], bundled: [] }, + ); +} + +export async function installPackageSources( + workingDir: string, + agentDir: string, + sources: string[], + options?: { local?: boolean; persist?: boolean }, +): Promise { + const { settingsManager, packageManager } = createPackageContext(workingDir, agentDir); + const scope: PackageScope = options?.local ? "project" : "user"; + const installed: string[] = []; + + const bundledSeeded = scope === "user" ? seedBundledWorkspacePackages(agentDir, APP_ROOT, sources) : []; + installed.push(...bundledSeeded); + const remainingSources = sources.filter((source) => !bundledSeeded.includes(source)); + const grouped = groupConfiguredNpmSources( + remainingSources.map((source) => ({ + source, + scope, + filtered: false, + })), + ); + const { supported: supportedUserSources, skipped } = filterUnsupportedSources(grouped.user); + const { supported: supportedProjectSources, skipped: skippedProject } = filterUnsupportedSources(grouped.project); + skipped.push(...skippedProject); + + const supportedNpmSources = scope === "user" ? supportedUserSources : supportedProjectSources; + if (supportedNpmSources.length > 0) { + await runPackageManagerInstall(settingsManager, workingDir, agentDir, scope, dedupeNpmSources(supportedNpmSources, false)); + installed.push(...supportedNpmSources); + } + + for (const source of sources) { + if (parseNpmSource(source)) { + continue; + } + + await packageManager.install(source, { local: options?.local }); + installed.push(source); + } + + if (options?.persist) { + for (const source of installed) { + if (packageManager.addSourceToSettings(source, { local: options?.local })) { + continue; + } + skipped.push(source); + } + await settingsManager.flush(); + } + + return { installed, skipped }; +} + +export async function updateConfiguredPackages( + workingDir: string, + agentDir: string, + source?: string, +): Promise { + const { settingsManager, packageManager } = createPackageContext(workingDir, agentDir); + + if (source) { + await packageManager.update(source); + return { updated: [source], skipped: [] }; + } + + const availableUpdates = await packageManager.checkForAvailableUpdates(); + if (availableUpdates.length === 0) { + return { updated: [], skipped: [] }; + } + + const npmUpdatesByScope: Record = { user: [], project: [] }; + const gitUpdates: string[] = []; + const skipped: string[] = []; + + for (const entry of availableUpdates) { + if (entry.type === "npm") { + if (shouldSkipNativeSource(entry.source)) { + skipped.push(entry.source); + continue; + } + npmUpdatesByScope[entry.scope].push(entry.source); + continue; + } + + gitUpdates.push(entry.source); + } + + for (const scope of ["user", "project"] as const) { + const sources = npmUpdatesByScope[scope]; + if (sources.length === 0) continue; + + await runPackageManagerInstall(settingsManager, workingDir, agentDir, scope, dedupeNpmSources(sources, true)); + } + + for (const gitSource of gitUpdates) { + await packageManager.update(gitSource); + } + + return { + updated: availableUpdates + .map((entry) => entry.source) + .filter((source) => !skipped.includes(source)), + skipped, + }; +} + +function ensureParentDir(path: string): void { + mkdirSync(dirname(path), { recursive: true }); +} + +function pathsMatchSymlinkTarget(linkPath: string, targetPath: string): boolean { + try { + if (!lstatSync(linkPath).isSymbolicLink()) { + return false; + } + return resolve(dirname(linkPath), readlinkSync(linkPath)) === targetPath; + } catch { + return false; + } +} + +function linkDirectory(linkPath: string, targetPath: string): void { + if (pathsMatchSymlinkTarget(linkPath, targetPath)) { + return; + } + + try { + if (existsSync(linkPath) && lstatSync(linkPath).isSymbolicLink()) { + rmSync(linkPath, { force: true }); + } + } catch {} + + if (existsSync(linkPath)) { + return; + } + + ensureParentDir(linkPath); + try { + symlinkSync(targetPath, linkPath, process.platform === "win32" ? "junction" : "dir"); + } catch { + // Fallback for filesystems that do not allow symlinks. + if (!existsSync(linkPath)) { + cpSync(targetPath, linkPath, { recursive: true }); + } + } +} + +export function seedBundledWorkspacePackages( + agentDir: string, + appRoot: string, + sources: string[], +): string[] { + const bundledNodeModulesRoot = resolve(appRoot, ".feynman", "npm", "node_modules"); + if (!existsSync(bundledNodeModulesRoot)) { + return []; + } + + const globalNodeModulesRoot = resolve(getFeynmanNpmPrefixPath(agentDir), "lib", "node_modules"); + const seeded: string[] = []; + + for (const source of sources) { + if (shouldSkipNativeSource(source)) continue; + + const parsed = parseNpmSource(source); + if (!parsed) continue; + + const bundledPackagePath = resolve(bundledNodeModulesRoot, parsed.name); + if (!existsSync(bundledPackagePath)) continue; + + const targetPath = resolve(globalNodeModulesRoot, parsed.name); + if (!existsSync(targetPath)) { + linkDirectory(targetPath, bundledPackagePath); + seeded.push(source); + } + } + + return seeded; +} diff --git a/src/pi/package-presets.ts b/src/pi/package-presets.ts index e3d821a..b1ebe78 100644 --- a/src/pi/package-presets.ts +++ b/src/pi/package-presets.ts @@ -17,6 +17,13 @@ export const CORE_PACKAGE_SOURCES = [ "npm:@tmustier/pi-ralph-wiggum", ] as const; +export const NATIVE_PACKAGE_SOURCES = [ + "npm:@kaiserlich-dev/pi-session-search", + "npm:@samfp/pi-memory", +] as const; + +export const MAX_NATIVE_PACKAGE_NODE_MAJOR = 24; + export const OPTIONAL_PACKAGE_PRESETS = { "generative-ui": { description: "Interactive Glimpse UI widgets.", @@ -50,6 +57,24 @@ export function shouldPruneLegacyDefaultPackages(packages: PackageSource[] | und return arraysMatchAsSets(packages as string[], LEGACY_DEFAULT_PACKAGE_SOURCES); } +function parseNodeMajor(version: string): number { + const [major = "0"] = version.replace(/^v/, "").split("."); + return Number.parseInt(major, 10) || 0; +} + +export function supportsNativePackageSources(version = process.versions.node): boolean { + return parseNodeMajor(version) <= MAX_NATIVE_PACKAGE_NODE_MAJOR; +} + +export function filterPackageSourcesForCurrentNode(sources: readonly T[], version = process.versions.node): T[] { + if (supportsNativePackageSources(version)) { + return [...sources]; + } + + const blocked = new Set(NATIVE_PACKAGE_SOURCES); + return sources.filter((source) => !blocked.has(source)); +} + export function getOptionalPackagePresetSources(name: string): string[] | undefined { const normalized = name.trim().toLowerCase(); if (normalized === "ui") { diff --git a/src/pi/settings.ts b/src/pi/settings.ts index d3af68c..d338d94 100644 --- a/src/pi/settings.ts +++ b/src/pi/settings.ts @@ -3,7 +3,7 @@ import { dirname } from "node:path"; import { ModelRegistry, type PackageSource } from "@mariozechner/pi-coding-agent"; -import { CORE_PACKAGE_SOURCES, shouldPruneLegacyDefaultPackages } from "./package-presets.js"; +import { CORE_PACKAGE_SOURCES, filterPackageSourcesForCurrentNode, shouldPruneLegacyDefaultPackages } from "./package-presets.js"; import { createModelRegistry } from "../model/registry.js"; export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -67,6 +67,23 @@ function choosePreferredModel( return availableModels[0]; } +function filterConfiguredPackagesForCurrentNode(packages: PackageSource[] | undefined): PackageSource[] { + if (!Array.isArray(packages)) { + return []; + } + + const filteredStringSources = new Set(filterPackageSourcesForCurrentNode( + packages + .map((entry) => (typeof entry === "string" ? entry : entry.source)) + .filter((entry): entry is string => typeof entry === "string"), + )); + + return packages.filter((entry) => { + const source = typeof entry === "string" ? entry : entry.source; + return filteredStringSources.has(source); + }); +} + export function readJson(path: string): Record { if (!existsSync(path)) { return {}; @@ -110,10 +127,13 @@ export function normalizeFeynmanSettings( settings.theme = "feynman"; settings.quietStartup = true; settings.collapseChangelog = true; + const supportedCorePackages = filterPackageSourcesForCurrentNode(CORE_PACKAGE_SOURCES); if (!Array.isArray(settings.packages) || settings.packages.length === 0) { - settings.packages = [...CORE_PACKAGE_SOURCES]; + settings.packages = supportedCorePackages; } else if (shouldPruneLegacyDefaultPackages(settings.packages as PackageSource[])) { - settings.packages = [...CORE_PACKAGE_SOURCES]; + settings.packages = supportedCorePackages; + } else { + settings.packages = filterConfiguredPackagesForCurrentNode(settings.packages as PackageSource[]); } const modelRegistry = createModelRegistry(authPath); diff --git a/src/system/executables.ts b/src/system/executables.ts index b0c3f7f..a5f10cd 100644 --- a/src/system/executables.ts +++ b/src/system/executables.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { dirname, delimiter } from "node:path"; const isWindows = process.platform === "win32"; const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; @@ -40,14 +41,20 @@ export function resolveExecutable(name: string, fallbackPaths: string[] = []): s } const isWindows = process.platform === "win32"; + const env = { + ...process.env, + PATH: process.env.PATH ?? "", + }; const result = isWindows ? spawnSync("cmd", ["/c", `where ${name}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + env, }) - : spawnSync("sh", ["-lc", `command -v ${name}`], { + : spawnSync("sh", ["-c", `command -v ${name}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + env, }); if (result.status === 0) { @@ -59,3 +66,9 @@ export function resolveExecutable(name: string, fallbackPaths: string[] = []): s return undefined; } + +export function getPathWithCurrentNode(pathValue = process.env.PATH ?? ""): string { + const nodeDir = dirname(process.execPath); + const parts = pathValue.split(delimiter).filter(Boolean); + return parts.includes(nodeDir) ? pathValue : `${nodeDir}${delimiter}${pathValue}`; +} diff --git a/tests/package-ops.test.ts b/tests/package-ops.test.ts new file mode 100644 index 0000000..2e94350 --- /dev/null +++ b/tests/package-ops.test.ts @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { seedBundledWorkspacePackages } from "../src/pi/package-ops.js"; + +function createBundledWorkspace(appRoot: string, packageNames: string[]): void { + for (const packageName of packageNames) { + const packageDir = resolve(appRoot, ".feynman", "npm", "node_modules", packageName); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: packageName, version: "1.0.0" }, null, 2) + "\n", + "utf8", + ); + } +} + +test("seedBundledWorkspacePackages links bundled packages into the Feynman npm prefix", () => { + const appRoot = mkdtempSync(join(tmpdir(), "feynman-bundle-")); + const homeRoot = mkdtempSync(join(tmpdir(), "feynman-home-")); + const agentDir = resolve(homeRoot, "agent"); + mkdirSync(agentDir, { recursive: true }); + + createBundledWorkspace(appRoot, ["pi-subagents", "@samfp/pi-memory"]); + + const seeded = seedBundledWorkspacePackages(agentDir, appRoot, [ + "npm:pi-subagents", + "npm:@samfp/pi-memory", + ]); + + assert.deepEqual(seeded.sort(), ["npm:@samfp/pi-memory", "npm:pi-subagents"]); + const globalRoot = resolve(homeRoot, "npm-global", "lib", "node_modules"); + assert.equal(existsSync(resolve(globalRoot, "pi-subagents", "package.json")), true); + assert.equal(existsSync(resolve(globalRoot, "@samfp", "pi-memory", "package.json")), true); +}); + +test("seedBundledWorkspacePackages preserves existing installed packages", () => { + const appRoot = mkdtempSync(join(tmpdir(), "feynman-bundle-")); + const homeRoot = mkdtempSync(join(tmpdir(), "feynman-home-")); + const agentDir = resolve(homeRoot, "agent"); + const existingPackageDir = resolve(homeRoot, "npm-global", "lib", "node_modules", "pi-subagents"); + + mkdirSync(agentDir, { recursive: true }); + createBundledWorkspace(appRoot, ["pi-subagents"]); + mkdirSync(existingPackageDir, { recursive: true }); + writeFileSync(resolve(existingPackageDir, "package.json"), '{"name":"pi-subagents","version":"user"}\n', "utf8"); + + const seeded = seedBundledWorkspacePackages(agentDir, appRoot, ["npm:pi-subagents"]); + + assert.deepEqual(seeded, []); + assert.equal(readFileSync(resolve(existingPackageDir, "package.json"), "utf8"), '{"name":"pi-subagents","version":"user"}\n'); + assert.equal(lstatSync(existingPackageDir).isSymbolicLink(), false); +}); diff --git a/tests/pi-settings.test.ts b/tests/pi-settings.test.ts index f33de48..6075a25 100644 --- a/tests/pi-settings.test.ts +++ b/tests/pi-settings.test.ts @@ -4,7 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { CORE_PACKAGE_SOURCES, getOptionalPackagePresetSources, shouldPruneLegacyDefaultPackages } from "../src/pi/package-presets.js"; +import { + CORE_PACKAGE_SOURCES, + getOptionalPackagePresetSources, + NATIVE_PACKAGE_SOURCES, + shouldPruneLegacyDefaultPackages, + supportsNativePackageSources, +} from "../src/pi/package-presets.js"; import { normalizeFeynmanSettings, normalizeThinkingLevel } from "../src/pi/settings.js"; test("normalizeThinkingLevel accepts the latest Pi thinking levels", () => { @@ -71,3 +77,42 @@ test("optional package presets map friendly aliases", () => { assert.deepEqual(getOptionalPackagePresetSources("search"), undefined); assert.equal(shouldPruneLegacyDefaultPackages(["npm:custom"]), false); }); + +test("supportsNativePackageSources disables sqlite-backed packages on Node 25+", () => { + assert.equal(supportsNativePackageSources("24.8.0"), true); + assert.equal(supportsNativePackageSources("25.0.0"), false); +}); + +test("normalizeFeynmanSettings prunes native core packages on unsupported Node majors", () => { + const root = mkdtempSync(join(tmpdir(), "feynman-settings-")); + const settingsPath = join(root, "settings.json"); + const bundledSettingsPath = join(root, "bundled-settings.json"); + const authPath = join(root, "auth.json"); + + writeFileSync( + settingsPath, + JSON.stringify( + { + packages: [...CORE_PACKAGE_SOURCES], + }, + null, + 2, + ) + "\n", + "utf8", + ); + writeFileSync(bundledSettingsPath, "{}\n", "utf8"); + writeFileSync(authPath, "{}\n", "utf8"); + + const originalVersion = process.versions.node; + Object.defineProperty(process.versions, "node", { value: "25.0.0", configurable: true }); + try { + normalizeFeynmanSettings(settingsPath, bundledSettingsPath, "medium", authPath); + } finally { + Object.defineProperty(process.versions, "node", { value: originalVersion, configurable: true }); + } + + const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as { packages?: string[] }; + for (const source of NATIVE_PACKAGE_SOURCES) { + assert.equal(settings.packages?.includes(source), false); + } +}); diff --git a/tests/pi-subagents-patch.test.ts b/tests/pi-subagents-patch.test.ts index e03f482..2afb917 100644 --- a/tests/pi-subagents-patch.test.ts +++ b/tests/pi-subagents-patch.test.ts @@ -102,3 +102,41 @@ test("patchPiSubagentsSource is idempotent", () => { assert.equal(twice, once); }); + +test("patchPiSubagentsSource rewrites modern agents.ts discovery paths", () => { + const input = [ + 'import * as fs from "node:fs";', + 'import * as os from "node:os";', + 'import * as path from "node:path";', + 'export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {', + '\tconst userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");', + '\tconst userDirNew = path.join(os.homedir(), ".agents");', + '\tconst userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user");', + '\tconst userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user");', + '\tconst userAgents = [...userAgentsOld, ...userAgentsNew];', + '}', + 'export function discoverAgentsAll(cwd: string) {', + '\tconst userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");', + '\tconst userDirNew = path.join(os.homedir(), ".agents");', + '\tconst user = [', + '\t\t...loadAgentsFromDir(userDirOld, "user"),', + '\t\t...loadAgentsFromDir(userDirNew, "user"),', + '\t];', + '\tconst chains = [', + '\t\t...loadChainsFromDir(userDirOld, "user"),', + '\t\t...loadChainsFromDir(userDirNew, "user"),', + '\t\t...(projectDir ? loadChainsFromDir(projectDir, "project") : []),', + '\t];', + '\tconst userDir = fs.existsSync(userDirNew) ? userDirNew : userDirOld;', + '}', + ].join("\n"); + + const patched = patchPiSubagentsSource("agents.ts", input); + + assert.match(patched, /function resolvePiAgentDir\(\): string \{/); + assert.match(patched, /const userDir = path\.join\(resolvePiAgentDir\(\), "agents"\);/); + assert.match(patched, /const userAgents = scope === "project" \? \[\] : loadAgentsFromDir\(userDir, "user"\);/); + assert.ok(!patched.includes('loadAgentsFromDir(userDirOld, "user")')); + assert.ok(!patched.includes('loadChainsFromDir(userDirNew, "user")')); + assert.ok(!patched.includes('fs.existsSync(userDirNew) ? userDirNew : userDirOld')); +});