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:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
88
packages/cli/src/commands/self-extension.ts
Normal file
88
packages/cli/src/commands/self-extension.ts
Normal 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" };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user