Normalize pi-claude-cli requirement comments to the FNXC convention.\n\n- Convert ACP bridge and CLI validation requirement notes to FNXC-prefixed JSDoc comments.\n- Update inline ACP bridge comments to include dated FNXC context without changing behavior.\n\nFiles changed:\n packages/pi-claude-cli/index.ts | 28 ++++++++++++----------------\n 1 file changed, 12 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-7112 Fusion-Task-Lineage: 970a9ebc-2c8f-4481-9e2b-c1c3fcf5cc24 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
284 lines
10 KiB
TypeScript
284 lines
10 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 "@earendil-works/pi-ai";
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import { streamViaCli } from "./src/provider.js";
|
|
import { streamViaAcp } from "./src/acp-driver.js";
|
|
import {
|
|
validateCliPresenceAsync,
|
|
validateCliAuthAsync,
|
|
killAllProcesses,
|
|
} from "./src/process-manager.js";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
getCustomToolDefs,
|
|
toolsFromContext,
|
|
writeMcpConfig,
|
|
buildAcpMcpServers,
|
|
type McpToolDef,
|
|
type UserMcpServerSpec,
|
|
} from "./src/mcp-config.js";
|
|
|
|
/**
|
|
* FNXC:pi-claude-cli 2026-06-27-06:39:
|
|
* Route A drives Claude through the ACP bridge only when `FUSION_CLAUDE_ACP=1`
|
|
* AND a bridge binary path is injected via `FUSION_CLAUDE_ACP_BRIDGE`.
|
|
* OFF by default: the live `claude -p` path stays untouched until soak.
|
|
*/
|
|
function resolveAcpBridgePath(): string | undefined {
|
|
if (process.env.FUSION_CLAUDE_ACP !== "1") return undefined;
|
|
const p = process.env.FUSION_CLAUDE_ACP_BRIDGE;
|
|
return typeof p === "string" && p.length > 0 ? p : undefined;
|
|
}
|
|
|
|
/** Resolve custom tool defs the same way ensureMcpConfig does (context → registry). */
|
|
function resolveToolDefs(
|
|
pi: ExtensionAPI,
|
|
contextTools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }>,
|
|
): McpToolDef[] {
|
|
let toolDefs = toolsFromContext(contextTools);
|
|
if (toolDefs.length === 0 && Array.isArray(pi.getAllTools())) toolDefs = getCustomToolDefs(pi);
|
|
return toolDefs;
|
|
}
|
|
|
|
// Kill all active Claude subprocesses on process exit to prevent orphans
|
|
process.on("exit", killAllProcesses);
|
|
|
|
const PROVIDER_ID = "pi-claude-cli";
|
|
|
|
/**
|
|
* FNXC:pi-claude-cli 2026-06-27-06:39:
|
|
* The factory runs per `createFnAgent` call, which the dashboard does for each
|
|
* chat message. Synchronous `execSync` presence + auth probes froze the Node
|
|
* event loop while `claude` cold-started, so memoize the probes as an async
|
|
* Promise: the factory returns immediately, the result logs once, and later
|
|
* calls reuse it.
|
|
*/
|
|
let cliValidationPromise: Promise<void> | undefined;
|
|
|
|
function runCliValidationOnce(): Promise<void> {
|
|
if (cliValidationPromise) return cliValidationPromise;
|
|
cliValidationPromise = (async () => {
|
|
const presence = await validateCliPresenceAsync();
|
|
if (!presence.ok) {
|
|
console.warn(`[pi-claude-cli] ${presence.error.message}`);
|
|
return;
|
|
}
|
|
await validateCliAuthAsync();
|
|
})();
|
|
return cliValidationPromise;
|
|
}
|
|
|
|
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
|
|
const DEBUG_MCP = process.env.PI_CLAUDE_CLI_DEBUG === "1";
|
|
|
|
function debugMcp(message: string): void {
|
|
if (!DEBUG_MCP) return;
|
|
console.error(`[pi-claude-cli] ${message}`);
|
|
}
|
|
|
|
function getUserMcpServers(options: unknown): UserMcpServerSpec[] {
|
|
const servers = (options as { mcpServers?: unknown } | undefined)?.mcpServers;
|
|
return Array.isArray(servers) ? servers.filter((server): server is UserMcpServerSpec => Boolean(server && typeof server === "object" && "name" in server)) : [];
|
|
}
|
|
|
|
/**
|
|
* Resolve the MCP config path for the current request, regenerating it when
|
|
* the set of custom tools changes.
|
|
*
|
|
* Source of truth (in order of preference):
|
|
* 1. `context.tools` — the per-session tool list pi-ai actually hands to
|
|
* `streamSimple`. This is what the session is asking the model to see, so
|
|
* it includes session-scoped registrations (e.g. `fn_review_spec` and
|
|
* `fn_review_step` injected by the engine's triage/executor sessions).
|
|
* 2. `pi.getAllTools()` — fallback for older callers that don't supply
|
|
* `context.tools`.
|
|
*
|
|
* Why not a single once-and-lock cache:
|
|
* - The engine spawns triage/executor sessions with session-scoped tools.
|
|
* A locked-on-first-call cache silently drops them and the Claude CLI
|
|
* subprocess refuses with "unknown tool fn_review_spec".
|
|
* - Hashing the tool defs lets us reuse 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,
|
|
contextTools?: ReadonlyArray<{
|
|
name: string;
|
|
description: string;
|
|
parameters: Record<string, unknown>;
|
|
}>,
|
|
userMcpServers: UserMcpServerSpec[] = [],
|
|
): string | undefined {
|
|
try {
|
|
let toolDefs: McpToolDef[] = toolsFromContext(contextTools);
|
|
if (contextTools && contextTools.length > 0) {
|
|
debugMcp(
|
|
`MCP config from context.tools: ${contextTools.map((tool) => tool.name).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
// Fallback to the pi runtime registry if the context didn't carry tools.
|
|
// (Older agent-loop versions don't populate Context.tools for streamSimple.)
|
|
if (toolDefs.length === 0) {
|
|
const allTools = pi.getAllTools();
|
|
if (!Array.isArray(allTools)) {
|
|
return cachedMcpConfig?.configPath;
|
|
}
|
|
toolDefs = getCustomToolDefs(pi);
|
|
}
|
|
|
|
if (toolDefs.length === 0 && userMcpServers.length === 0) {
|
|
cachedMcpConfig = undefined;
|
|
return undefined;
|
|
}
|
|
|
|
const hash = createHash("sha1")
|
|
.update(JSON.stringify({ toolDefs, userMcpServerNames: userMcpServers.map((server) => server.name) }))
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
|
|
if (cachedMcpConfig?.hash === hash) {
|
|
debugMcp(`MCP config cache hit (hash=${hash})`);
|
|
return cachedMcpConfig.configPath;
|
|
}
|
|
|
|
const configPath = writeMcpConfig(toolDefs, hash, userMcpServers);
|
|
cachedMcpConfig = { hash, configPath };
|
|
const toolNames = toolDefs.map((t) => t.name).join(", ");
|
|
debugMcp(
|
|
`MCP config refreshed with ${toolDefs.length} custom tool(s) [${toolNames}] (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: kick off async, memoized presence + auth probes
|
|
// without blocking the factory. Failures surface via warnings; the actual
|
|
// `claude` subprocess in streamViaCli still reports hard errors on send.
|
|
void runCliValidationOnce();
|
|
|
|
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 @earendil-works/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,
|
|
},
|
|
{
|
|
id: "claude-sonnet-4-6",
|
|
name: "Claude Sonnet 4.6",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
contextWindow: 200_000,
|
|
maxTokens: 16_384,
|
|
},
|
|
{
|
|
id: "claude-sonnet-4-5",
|
|
name: "Claude Sonnet 4.5",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
contextWindow: 200_000,
|
|
maxTokens: 8_192,
|
|
},
|
|
{
|
|
id: "claude-haiku-4-5",
|
|
name: "Claude Haiku 4.5",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
|
|
contextWindow: 200_000,
|
|
maxTokens: 8_192,
|
|
},
|
|
];
|
|
|
|
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 contextTools = (context as { tools?: ReadonlyArray<{
|
|
name: string;
|
|
description: string;
|
|
parameters: Record<string, unknown>;
|
|
}> }).tools;
|
|
|
|
// FNXC:pi-claude-cli 2026-06-27-06:39: Route A drives Claude through the ACP bridge only when the kill-switch is on AND a bridge path is injected. OFF by default → `-p` path below.
|
|
const bridgePath = resolveAcpBridgePath();
|
|
if (bridgePath) {
|
|
const toolDefs = resolveToolDefs(pi, contextTools);
|
|
const userMcpServers = getUserMcpServers(options);
|
|
const hash = createHash("sha1").update(JSON.stringify({ toolDefs, userMcpServerNames: userMcpServers.map((server) => server.name) })).digest("hex").slice(0, 12);
|
|
return streamViaAcp(model, context, {
|
|
...options,
|
|
bridgePath,
|
|
mcpServers: buildAcpMcpServers(toolDefs, hash, userMcpServers),
|
|
// FNXC:pi-claude-cli 2026-06-27-06:39: Forward only HOME/PATH so the bridged `claude` authenticates from the login/keychain session (R17); never inherited process.env or API keys.
|
|
bridgeEnv: { HOME: process.env.HOME, PATH: process.env.PATH },
|
|
});
|
|
}
|
|
|
|
const configPath = ensureMcpConfig(pi, contextTools, getUserMcpServers(options));
|
|
return streamViaCli(model, context, {
|
|
...options,
|
|
mcpConfigPath: configPath,
|
|
});
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error(`[pi-claude-cli] Failed to register provider:`, err);
|
|
}
|
|
}
|