Fix package runtime and subagent reliability
This commit is contained in:
@@ -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
456
src/pi/package-ops.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
56
tests/package-ops.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user