diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 0cf78e7b3..eadd585af 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -21,6 +21,7 @@ import { GlobalSettingsStore, resolveGlobalDir, getEnabledPiExtensionPaths, + reconcileClaudeCliPaths, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard"; @@ -411,12 +412,17 @@ export async function runDaemon(opts: DaemonOptions = {}) { } })(); + // Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any + // external `pi-claude-cli` install. Drops shadowing externals (e.g. a + // global `npm install -g pi-claude-cli`) so the upstream's once-and-lock + // MCP-config bug can't poison sessions. + const reconciledExtensionPaths = reconcileClaudeCliPaths( + [...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths], + claudeCliPaths[0] ?? null, + ); + const extensionsResult = await discoverAndLoadExtensions( - [ - ...getEnabledPiExtensionPaths(cwd), - ...packageExtensionPaths, - ...claudeCliPaths, - ], + reconciledExtensionPaths, cwd, join(cwd, ".fusion", "disabled-auto-extension-discovery"), ); diff --git a/packages/core/src/__tests__/reconcile-claude-cli-paths.test.ts b/packages/core/src/__tests__/reconcile-claude-cli-paths.test.ts new file mode 100644 index 000000000..9a34b09b7 --- /dev/null +++ b/packages/core/src/__tests__/reconcile-claude-cli-paths.test.ts @@ -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]); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 06f0557f9..bac121d29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/pi-extensions.ts b/packages/core/src/pi-extensions.ts index b5d8f24ff..8497ee222 100644 --- a/packages/core/src/pi-extensions.ts +++ b/packages/core/src/pi-extensions.ts @@ -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); diff --git a/packages/engine/package.json b/packages/engine/package.json index 296ebdec5..c920076b2 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@fusion/core": "workspace:*", + "@fusion/pi-claude-cli": "workspace:*", "@mariozechner/pi-ai": "^0.70.0", "@mariozechner/pi-coding-agent": "^0.70.0", "typebox": "^1.0.0", diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index b9976ced8..ef675e853 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"; import { exec } from "node:child_process"; import { promisify } from "node:util"; +import { createRequire } from "node:module"; import { basename, dirname, join, relative, isAbsolute, resolve } from "node:path"; const execAsync = promisify(exec); @@ -26,7 +27,7 @@ import { type AgentSession, type ToolDefinition, } from "@mariozechner/pi-coding-agent"; -import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, resolvePiExtensionProjectRoot } from "@fusion/core"; +import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, reconcileClaudeCliPaths, resolvePiExtensionProjectRoot } from "@fusion/core"; import { resolveSessionSkills, createSkillsOverrideFromSelection, @@ -674,6 +675,33 @@ function getPackageManagerAgentDir(): string { return existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir; } +/** + * Resolve the absolute path to Fusion's vendored `@fusion/pi-claude-cli` + * extension entry. Used by `registerExtensionProviders` to ensure the fork + * always wins over any externally-installed `pi-claude-cli`. + * + * Returns null when the vendored package isn't available (e.g. someone + * embedded `@fusion/engine` standalone without bundling the fork) — callers + * should treat that as "no override needed, leave external paths alone". + */ +function resolveVendoredClaudeCliEntry(): string | null { + try { + const require_ = createRequire(import.meta.url); + const pkgJsonPath = require_.resolve("@fusion/pi-claude-cli/package.json"); + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as { + pi?: { extensions?: unknown }; + }; + const extensions = pkgJson.pi?.extensions; + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const entry = extensions[0]; + if (typeof entry !== "string" || entry.length === 0) return null; + const path = resolve(dirname(pkgJsonPath), entry); + return existsSync(path) ? path : null; + } catch { + return null; + } +} + async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegistry): Promise { try { const agentDir = getPackageManagerAgentDir(); @@ -687,8 +715,19 @@ async function registerExtensionProviders(cwd: string, modelRegistry: ModelRegis .filter((resource) => resource.enabled) .map((resource) => resource.path); - const extensionsResult = await discoverAndLoadExtensions( + // Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any external + // `pi-claude-cli` install (e.g. a global `npm install -g pi-claude-cli`, + // or `npm:pi-claude-cli` in agent settings). Upstream has known timing + // and once-and-lock MCP-config bugs that we fix in the fork; loading both + // also produces unpredictable provider-registration winners. + const vendoredClaudeCli = resolveVendoredClaudeCliEntry(); + const reconciledPaths = reconcileClaudeCliPaths( [...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths], + vendoredClaudeCli, + ); + + const extensionsResult = await discoverAndLoadExtensions( + reconciledPaths, cwd, join(resolvePiExtensionProjectRoot(cwd), ".fusion", "disabled-auto-extension-discovery"), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4a37cde2..6c3739519 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: '@fusion/core': specifier: workspace:* version: link:../core + '@fusion/pi-claude-cli': + specifier: workspace:* + version: link:../pi-claude-cli '@mariozechner/pi-ai': specifier: ^0.70.0 version: 0.70.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)