fix(pi-claude-cli): unblock parameterless MCP tool calls in triage

Triage with claude-sonnet-4-6 via pi-claude-cli kept looping on
fn_review_spec calls that were rejected by pi's validator with
"root: must be object". Parameterless MCP tools (schema
{type:"object", properties:{}}) emit zero input_json_delta events,
so partialJson stayed "" and the catch fell through to
finalArgs = "" — a string, which TypeBox's Type.Object({}) rightly
refuses. Default empty partialJson to {} so the call lands.

Also:
- Add a 2-step reminder loop in triage before swapping to the
  fallback planning model — primary models that wrote PROMPT.md
  but forgot fn_review_spec recover from a nudge, no need to pay
  the cold-start tax of a new triage on a different model.
- Inject @runfusion/fusion's own pi extension into dashboard/
  daemon/serve sessions and propagate the path to createFnAgent
  via setHostExtensionPaths so fn_* tools register globally
  without requiring `pi install npm:@runfusion/fusion`.
- Drop the "historical" qualifier from replayed tool labels —
  Claude was reading "TOOL RESULT (historical Read):" as
  "previous session, ignore" and looping on verification.
- Remove subprocess-lifecycle stderr debug logs that landed for
  hang diagnosis — root cause is fixed, the noise can go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 00:00:49 -07:00
parent 1d1d1edfb3
commit 371f2d8e01
13 changed files with 269 additions and 89 deletions

View File

@@ -25,7 +25,7 @@ import {
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
import { ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { ProjectEngineManager, PeerExchangeService, setHostExtensionPaths } from "@fusion/engine";
import {
AuthStorage,
DefaultPackageManager,
@@ -49,6 +49,7 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
@@ -416,8 +417,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
// 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.
// Inject the cli's own extension (@runfusion/fusion) so fn_* tools
// register globally without requiring `pi install npm:@runfusion/fusion`.
const selfExtension = resolveSelfExtension();
const selfExtensionPaths = selfExtension.status === "ok" ? [selfExtension.path] : [];
if (selfExtension.status !== "ok") {
console.warn(`[extensions] self: ${selfExtension.reason}`);
}
setHostExtensionPaths(selfExtensionPaths);
const reconciledExtensionPaths = reconcileClaudeCliPaths(
[...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
claudeCliPaths[0] ?? null,
);

View File

@@ -26,7 +26,7 @@ import {
stopAllDevServers,
type RuntimeLogger,
} from "@fusion/dashboard";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService, setHostExtensionPaths } from "@fusion/engine";
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import {
getMergeStrategy,
@@ -46,6 +46,7 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
@@ -1131,9 +1132,24 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
})();
// Always inject the cli's own extension (`@runfusion/fusion`) so its
// `fn_*` tools register globally even when the user hasn't run
// `pi install npm:@runfusion/fusion`. Without this, agent chat with
// pi-claude-cli has no fn_* tools at all.
const selfExtension = resolveSelfExtension();
const selfExtensionPaths = selfExtension.status === "ok" ? [selfExtension.path] : [];
if (selfExtension.status !== "ok") {
logSink.warn(`[extensions] self: ${selfExtension.reason}`, "extensions");
}
// Propagate self-extension path to engine so createFnAgent sessions
// (chat, refine, mission, etc.) also load fn_* tools, not just the
// dashboard's extension runtime.
setHostExtensionPaths(selfExtensionPaths);
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
const extensionsResult = await discoverAndLoadExtensions(
[
...selfExtensionPaths,
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,

View File

@@ -0,0 +1,88 @@
/**
* Resolver for the CLI's own pi extension (`@runfusion/fusion`).
*
* `packages/cli/src/extension.ts` registers all `fn_*` tools (`fn_task_create`,
* `fn_mission_create`, etc.). For these to appear in pi sessions launched by
* the dashboard/daemon (e.g. agent chat using pi-claude-cli), the extension
* must be loaded by pi's `discoverAndLoadExtensions`.
*
* Pi normally only loads extensions that are registered in
* `~/.pi/agent/settings.json` packages or symlinked into an extensions
* directory. To avoid requiring users to `pi install npm:@runfusion/fusion`
* before fn_* tools work, we resolve the bundled extension path at runtime
* and inject it into the load list — same pattern as
* `claude-cli-extension.ts` does for `@fusion/pi-claude-cli`.
*
* In dev (`pnpm dev dashboard`), this resolves to `packages/cli/src/extension.ts`.
* In a published install, it resolves to `<install>/dist/extension.js`.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export type SelfExtensionResolution =
| { status: "ok"; path: string; packageVersion: string }
| { status: "missing"; reason: string };
/**
* Resolve the absolute path to the cli's own pi extension entry file.
*
* Walk up from this module to the @runfusion/fusion package.json and read
* `pi.extensions[0]`. Prefer `src/extension.ts` over `dist/extension.js` when
* both exist so dev iterations don't require a rebuild.
*/
export function resolveSelfExtension(): SelfExtensionResolution {
const here = dirname(fileURLToPath(import.meta.url));
let pkgDir: string | undefined;
let cur = here;
for (let i = 0; i < 5; i++) {
if (existsSync(resolve(cur, "package.json"))) {
try {
const parsed = JSON.parse(readFileSync(resolve(cur, "package.json"), "utf-8")) as { name?: string };
if (parsed.name === "@runfusion/fusion") {
pkgDir = cur;
break;
}
} catch {
// ignore and keep walking
}
}
const parent = resolve(cur, "..");
if (parent === cur) break;
cur = parent;
}
if (!pkgDir) {
return { status: "missing", reason: "Could not locate @runfusion/fusion package.json from CLI module" };
}
let pkgJson: { pi?: { extensions?: unknown }; version?: string };
try {
pkgJson = JSON.parse(readFileSync(resolve(pkgDir, "package.json"), "utf-8")) as typeof pkgJson;
} catch (err) {
return { status: "missing", reason: `Failed to read @runfusion/fusion package.json: ${err instanceof Error ? err.message : String(err)}` };
}
// Prefer src/extension.ts in dev (always fresh); fall back to pi.extensions[0]
// (dist/extension.js in production).
const srcEntry = resolve(pkgDir, "src", "extension.ts");
if (existsSync(srcEntry)) {
return { status: "ok", path: srcEntry, packageVersion: pkgJson.version ?? "unknown" };
}
const extensions = pkgJson.pi?.extensions;
if (!Array.isArray(extensions) || extensions.length === 0) {
return { status: "missing", reason: "@runfusion/fusion package.json has no pi.extensions array" };
}
const rawEntry = extensions[0];
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
return { status: "missing", reason: "@runfusion/fusion pi.extensions[0] is not a valid path string" };
}
const entryPath = resolve(pkgDir, rawEntry);
if (!existsSync(entryPath)) {
return { status: "missing", reason: `@runfusion/fusion extension file not found at ${entryPath}` };
}
return { status: "ok", path: entryPath, packageVersion: pkgJson.version ?? "unknown" };
}

View File

@@ -25,7 +25,7 @@ import {
} from "@fusion/core";
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
import { ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { ProjectEngineManager, PeerExchangeService, setHostExtensionPaths } from "@fusion/engine";
import {
AuthStorage,
DefaultPackageManager,
@@ -52,6 +52,7 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -468,8 +469,18 @@ export async function runServe(
}
})();
// Inject the cli's own extension so fn_* tools register globally without
// requiring `pi install npm:@runfusion/fusion`.
const selfExtension = resolveSelfExtension();
const selfExtensionPaths = selfExtension.status === "ok" ? [selfExtension.path] : [];
if (selfExtension.status !== "ok") {
console.warn(`[extensions] self: ${selfExtension.reason}`);
}
setHostExtensionPaths(selfExtensionPaths);
const extensionsResult = await discoverAndLoadExtensions(
[
...selfExtensionPaths,
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,