Fix package runtime and subagent reliability

This commit is contained in:
Advait Paliwal
2026-04-15 13:51:06 -07:00
parent dd3c07633b
commit 01c2808606
8 changed files with 718 additions and 5 deletions

View File

@@ -83,6 +83,66 @@ export function patchPiSubagentsSource(relativePath, source) {
'const userDir = path.join(os.homedir(), ".pi", "agent", "agents");', 'const userDir = path.join(os.homedir(), ".pi", "agent", "agents");',
'const userDir = path.join(resolvePiAgentDir(), "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; break;
case "artifacts.ts": case "artifacts.ts":
patched = replaceAll( patched = replaceAll(

456
src/pi/package-ops.ts Normal file
View File

@@ -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<string, string>();
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<void> {
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<void>((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<PackageScope, string[]> {
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<MissingConfiguredPackageSummary>(
(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<InstallPackageSourcesResult> {
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<UpdateConfiguredPackagesResult> {
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<PackageScope, string[]> = { 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;
}

View File

@@ -17,6 +17,13 @@ export const CORE_PACKAGE_SOURCES = [
"npm:@tmustier/pi-ralph-wiggum", "npm:@tmustier/pi-ralph-wiggum",
] as const; ] 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 = { export const OPTIONAL_PACKAGE_PRESETS = {
"generative-ui": { "generative-ui": {
description: "Interactive Glimpse UI widgets.", description: "Interactive Glimpse UI widgets.",
@@ -50,6 +57,24 @@ export function shouldPruneLegacyDefaultPackages(packages: PackageSource[] | und
return arraysMatchAsSets(packages as string[], LEGACY_DEFAULT_PACKAGE_SOURCES); 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<T extends string>(sources: readonly T[], version = process.versions.node): T[] {
if (supportsNativePackageSources(version)) {
return [...sources];
}
const blocked = new Set<string>(NATIVE_PACKAGE_SOURCES);
return sources.filter((source) => !blocked.has(source));
}
export function getOptionalPackagePresetSources(name: string): string[] | undefined { export function getOptionalPackagePresetSources(name: string): string[] | undefined {
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
if (normalized === "ui") { if (normalized === "ui") {

View File

@@ -3,7 +3,7 @@ import { dirname } from "node:path";
import { ModelRegistry, type PackageSource } from "@mariozechner/pi-coding-agent"; 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"; import { createModelRegistry } from "../model/registry.js";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -67,6 +67,23 @@ function choosePreferredModel(
return availableModels[0]; 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<string, unknown> { export function readJson(path: string): Record<string, unknown> {
if (!existsSync(path)) { if (!existsSync(path)) {
return {}; return {};
@@ -110,10 +127,13 @@ export function normalizeFeynmanSettings(
settings.theme = "feynman"; settings.theme = "feynman";
settings.quietStartup = true; settings.quietStartup = true;
settings.collapseChangelog = true; settings.collapseChangelog = true;
const supportedCorePackages = filterPackageSourcesForCurrentNode(CORE_PACKAGE_SOURCES);
if (!Array.isArray(settings.packages) || settings.packages.length === 0) { if (!Array.isArray(settings.packages) || settings.packages.length === 0) {
settings.packages = [...CORE_PACKAGE_SOURCES]; settings.packages = supportedCorePackages;
} else if (shouldPruneLegacyDefaultPackages(settings.packages as PackageSource[])) { } 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); const modelRegistry = createModelRegistry(authPath);

View File

@@ -1,5 +1,6 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { dirname, delimiter } from "node:path";
const isWindows = process.platform === "win32"; const isWindows = process.platform === "win32";
const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files"; 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 isWindows = process.platform === "win32";
const env = {
...process.env,
PATH: process.env.PATH ?? "",
};
const result = isWindows const result = isWindows
? spawnSync("cmd", ["/c", `where ${name}`], { ? spawnSync("cmd", ["/c", `where ${name}`], {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
env,
}) })
: spawnSync("sh", ["-lc", `command -v ${name}`], { : spawnSync("sh", ["-c", `command -v ${name}`], {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
env,
}); });
if (result.status === 0) { if (result.status === 0) {
@@ -59,3 +66,9 @@ export function resolveExecutable(name: string, fallbackPaths: string[] = []): s
return undefined; 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}`;
}

56
tests/package-ops.test.ts Normal file
View File

@@ -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);
});

View File

@@ -4,7 +4,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import test from "node:test"; 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"; import { normalizeFeynmanSettings, normalizeThinkingLevel } from "../src/pi/settings.js";
test("normalizeThinkingLevel accepts the latest Pi thinking levels", () => { 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.deepEqual(getOptionalPackagePresetSources("search"), undefined);
assert.equal(shouldPruneLegacyDefaultPackages(["npm:custom"]), false); 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);
}
});

View File

@@ -102,3 +102,41 @@ test("patchPiSubagentsSource is idempotent", () => {
assert.equal(twice, once); 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'));
});