fix(extensions): always prefer vendored @fusion/pi-claude-cli over external installs

When users have an external pi-claude-cli (e.g. a global `npm install -g
pi-claude-cli`, or `npm:pi-claude-cli` in ~/.pi/agent/settings.json packages),
pi's extension discovery loaded the upstream copy and shadowed our fork. The
upstream has a once-and-lock MCP-config bug that throws "Extension runtime not
initialized" during early streamSimple calls and never recovers.

Adds reconcileClaudeCliPaths in @fusion/core, used by both the daemon's
extension assembly and the engine's per-session registerExtensionProviders, to
drop any path with a `pi-claude-cli` segment that isn't our vendored fork and
prepend the vendored path. Engine resolves the fork via require.resolve and
gracefully no-ops when it isn't reachable (e.g. embedded standalone usage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 15:25:08 -07:00
parent d4c4137d83
commit 825fecbb35
7 changed files with 159 additions and 8 deletions

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { reconcileClaudeCliPaths } from "../pi-extensions.js";
const VENDORED = "/repo/packages/pi-claude-cli/index.ts";
const GLOBAL_NPM = "/opt/homebrew/lib/node_modules/pi-claude-cli/index.ts";
const PI_AGENT = "/Users/u/.pi/agent/extensions/pi-claude-cli/index.ts";
const UNRELATED = "/Users/u/.pi/agent/extensions/quota.ts";
describe("reconcileClaudeCliPaths", () => {
it("returns input unchanged when no vendored path is supplied", () => {
const input = [GLOBAL_NPM, UNRELATED];
expect(reconcileClaudeCliPaths(input, null)).toEqual(input);
});
it("drops a globally-installed pi-claude-cli and prepends the vendored path", () => {
const result = reconcileClaudeCliPaths([GLOBAL_NPM, UNRELATED], VENDORED);
expect(result).toEqual([VENDORED, UNRELATED]);
});
it("drops pi-claude-cli installed under .pi/agent/extensions/", () => {
const result = reconcileClaudeCliPaths([PI_AGENT, UNRELATED], VENDORED);
expect(result).toEqual([VENDORED, UNRELATED]);
});
it("keeps the vendored path exactly once even if it appears in input", () => {
const result = reconcileClaudeCliPaths(
[VENDORED, GLOBAL_NPM, UNRELATED],
VENDORED,
);
expect(result).toEqual([VENDORED, UNRELATED]);
});
it("preserves the relative order of unrelated extension paths", () => {
const a = "/ext/a.ts";
const b = "/ext/b.ts";
const c = "/ext/c.ts";
const result = reconcileClaudeCliPaths([a, GLOBAL_NPM, b, c], VENDORED);
expect(result).toEqual([VENDORED, a, b, c]);
});
it("does not mistake substrings of pi-claude-cli for the package", () => {
const looksLike = "/ext/some-pi-claude-cli-helper/index.ts";
const result = reconcileClaudeCliPaths([looksLike], VENDORED);
expect(result).toEqual([VENDORED, looksLike]);
});
it("matches case-insensitively (e.g. on macOS-cased filesystems)", () => {
const upper = "/opt/homebrew/lib/node_modules/PI-CLAUDE-CLI/index.ts";
const result = reconcileClaudeCliPaths([upper], VENDORED);
expect(result).toEqual([VENDORED]);
});
});

View File

@@ -48,7 +48,7 @@ export { ArchiveDatabase } from "./archive-db.js";
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";

View File

@@ -226,6 +226,56 @@ export function updatePiExtensionDisabledIds(cwd: string, disabledIds: string[],
return discoverPiExtensions(cwd, home);
}
/**
* Heuristic: does this extension path look like an external (non-Fusion)
* `pi-claude-cli` install? We match any path with a directory segment named
* exactly `pi-claude-cli`, except for the explicit vendored path that callers
* pass in (which always wins).
*
* Example matches: `/opt/homebrew/lib/node_modules/pi-claude-cli/index.ts`,
* `~/.pi/agent/extensions/pi-claude-cli/index.ts`.
*/
function isExternalClaudeCliPath(p: string, vendoredPath: string | null): boolean {
if (vendoredPath && p === vendoredPath) return false;
// Match a path segment "pi-claude-cli" delimited by either separator.
return /(^|[/\\])pi-claude-cli([/\\]|$)/i.test(p);
}
/**
* Reconcile the assembled pi-extension load list so Fusion's vendored
* `@fusion/pi-claude-cli` always wins over any externally-installed
* `pi-claude-cli` (e.g. a stale `npm install -g pi-claude-cli` left in
* `/opt/homebrew/lib/node_modules`, or `npm:pi-claude-cli` in agent
* settings).
*
* Two motivating scenarios:
* 1. The published upstream package has a once-and-lock MCP-config bug that
* causes "Extension runtime not initialized" during early streamSimple
* calls; our fork fixes it via context.tools-driven regeneration.
* 2. Side-by-side loading of two extensions that register the same
* provider name (`pi-claude-cli`) produces unpredictable winners
* depending on load order.
*
* Behaviour:
* - When `vendoredPath` is null (caller couldn't find the fork — typically
* because Fusion isn't running): return the input unchanged.
* - When `vendoredPath` is set: drop every external pi-claude-cli path and
* ensure the vendored path is loaded first.
*/
export function reconcileClaudeCliPaths(
paths: readonly string[],
vendoredPath: string | null,
): string[] {
if (!vendoredPath) {
return [...paths];
}
const filtered = paths.filter((p) => !isExternalClaudeCliPath(p, vendoredPath));
if (!filtered.includes(vendoredPath)) {
return [vendoredPath, ...filtered];
}
return filtered;
}
export function formatPiExtensionSource(source: PiExtensionSource, extensionPath: string, cwd: string, home?: string): string {
const homeDir = getHomeDir(home);
const projectRoot = resolvePiExtensionProjectRoot(cwd);