Files
fusion/packages/pi-claude-cli/index.ts
gsxdsm bcc642eaa6 fix: regenerate pi-claude-cli MCP config per session and auto-sync skill tools
The MCP config was generated lazily once and locked, so engine session-scoped
tools (fn_review_spec, fn_review_step) never reached the Claude CLI subprocess
and triage/executor sessions failed with "unknown tool" errors. Now the config
is hashed per call and rewritten when the tool set changes.

Also adds scripts/sync-fusion-skill-tools.mjs to regenerate the SKILL.md
tool-categories block from extension.ts at build time, with a --check mode
wired into skill-sync tests so drift fails CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:10:56 -07:00

147 lines
4.7 KiB
TypeScript

/**
* Pi extension entry point for pi-claude-cli.
*
* Registers a custom provider that routes LLM calls through the Claude Code CLI
* subprocess using stream-json NDJSON protocol.
*/
import { getModels } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { streamViaCli } from "./src/provider.js";
import {
validateCliPresence,
validateCliAuth,
killAllProcesses,
} from "./src/process-manager.js";
import { createHash } from "node:crypto";
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
// Kill all active Claude subprocesses on process exit to prevent orphans
process.on("exit", killAllProcesses);
const PROVIDER_ID = "pi-claude-cli";
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
/**
* Resolve the MCP config path for the current request, regenerating it when
* the set of custom tools changes.
*
* Why per-call instead of once-and-lock:
* - The engine registers session-scoped custom tools (e.g. `fn_review_spec`,
* `fn_review_step`) when it spawns triage/executor sessions. These appear
* in `pi.getAllTools()` only while that session is active.
* - A locked-on-first-call cache would freeze in the global tool set and
* silently drop session tools, so the Claude CLI subprocess would refuse
* to call them ("unknown tool fn_review_spec").
* - Hashing the tool defs lets us reuse the same temp files when the tool
* set is unchanged across calls, and produce fresh files (with the hash
* in the filename to avoid races) when it changes.
*
* Uses warn-don't-block: failure logs a warning but does not prevent the
* provider from functioning (built-ins still work).
*/
function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
try {
const allTools = pi.getAllTools();
// Registry not ready yet — fall back to whatever we last computed (if any)
if (!Array.isArray(allTools)) {
return cachedMcpConfig?.configPath;
}
const toolDefs = getCustomToolDefs(pi);
if (toolDefs.length === 0) {
cachedMcpConfig = undefined;
return undefined;
}
const hash = createHash("sha1")
.update(JSON.stringify(toolDefs))
.digest("hex")
.slice(0, 12);
if (cachedMcpConfig?.hash === hash) {
return cachedMcpConfig.configPath;
}
const configPath = writeMcpConfig(toolDefs, hash);
cachedMcpConfig = { hash, configPath };
console.error(
`[pi-claude-cli] MCP config refreshed with ${toolDefs.length} custom tool(s) (hash=${hash})`,
);
return configPath;
} catch (err) {
console.warn(
"[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
err,
);
return cachedMcpConfig?.configPath;
}
}
export default function (pi: ExtensionAPI) {
try {
// Startup validation
validateCliPresence(); // throws if CLI not on PATH
validateCliAuth(); // warns if not authenticated
const catalogModels = getModels("anthropic").map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: model.input,
cost: model.cost,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}));
// Newer models released after the pinned @mariozechner/pi-ai catalog
// was generated. Dedupe by id so this list is harmless once the upstream
// catalog catches up.
// https://platform.claude.com/docs/en/about-claude/models/overview
const extraModels: typeof catalogModels = [
{
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
},
];
const seen = new Set(catalogModels.map((m) => m.id));
const models = [
...catalogModels,
...extraModels.filter((m) => !seen.has(m.id)),
];
// Ensure all registered tools are active so pi can execute them.
// Some tools (find, grep, ls) are registered but not activated by default.
pi.on("session_start", async () => {
const allTools = pi.getAllTools();
if (Array.isArray(allTools)) {
pi.setActiveTools(allTools.map((t: { name: string }) => t.name));
}
});
pi.registerProvider(PROVIDER_ID, {
baseUrl: "pi-claude-cli",
apiKey: "unused",
api: "pi-claude-cli",
models,
streamSimple: (model, context, options) => {
const configPath = ensureMcpConfig(pi);
return streamViaCli(model, context, {
...options,
mcpConfigPath: configPath,
});
},
});
} catch (err) {
console.error(`[pi-claude-cli] Failed to register provider:`, err);
}
}