feat(acp): U11 — drive pi-claude-cli provider via the ACP bridge (kill-switch OFF)
Adds streamViaAcp: a drop-in alternative to streamViaCli that drives Claude through the claude-code-cli-acp bridge over ACP instead of `claude -p`. Returns the same AssistantMessageEventStream, so streamSimple dispatches to either transport behind a kill-switch (FUSION_CLAUDE_ACP=1 + an injected bridge path), OFF by default — the live `-p` path is byte-for-byte untouched until soak. - Full-history prompt every turn (buildPrompt) — the ACP path has no --resume (R13). - Forwards schema-only MCP servers so Claude emits correct tool calls; breaks early on the first tool_call (cancel turn, surface to pi) so the bridge never executes Fusion's tools — mirrors the `-p` break-early pattern. - Translation reuses the tested createEventBridge by synthesizing Claude stream events from ACP session/updates, sharing pi sequencing + tool-name mapping. - Bridge env forwards only HOME/PATH so `claude` authenticates from the login session (R17); never inherited process.env or API keys. Verified: 3/3 translation unit tests; real-bridge session/update shapes confirmed (agent_message_chunk text + tool_call); 326/326 existing pi-claude-cli tests green; typecheck clean. Remaining for Route A: engine injection of the bridge path (KTD10), U12 picker/ auth/status, U13 workflow verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
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,
|
||||
@@ -18,9 +19,32 @@ import {
|
||||
getCustomToolDefs,
|
||||
toolsFromContext,
|
||||
writeMcpConfig,
|
||||
buildAcpMcpServers,
|
||||
type McpToolDef,
|
||||
} from "./src/mcp-config.js";
|
||||
|
||||
/**
|
||||
* Route A kill-switch (U11). When `FUSION_CLAUDE_ACP=1` AND a bridge binary path
|
||||
* is available (`FUSION_CLAUDE_ACP_BRIDGE`, injected by the engine seam per
|
||||
* KTD10), the provider drives Claude through the ACP bridge instead of
|
||||
* `claude -p`. OFF by default: the live `-p` path is 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);
|
||||
|
||||
@@ -220,14 +244,29 @@ export default function (pi: ExtensionAPI) {
|
||||
api: "pi-claude-cli",
|
||||
models,
|
||||
streamSimple: (model, context, options) => {
|
||||
const configPath = ensureMcpConfig(
|
||||
pi,
|
||||
(context as { tools?: ReadonlyArray<{
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}> }).tools,
|
||||
);
|
||||
const contextTools = (context as { tools?: ReadonlyArray<{
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}> }).tools;
|
||||
|
||||
// Route A (U11): drive Claude through the ACP bridge 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 hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12);
|
||||
return streamViaAcp(model, context, {
|
||||
...options,
|
||||
bridgePath,
|
||||
mcpServers: buildAcpMcpServers(toolDefs, hash),
|
||||
// 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);
|
||||
return streamViaCli(model, context, {
|
||||
...options,
|
||||
mcpConfigPath: configPath,
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"url": "https://github.com/Runfusion/Fusion",
|
||||
"directory": "packages/pi-claude-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.24.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
|
||||
105
packages/pi-claude-cli/src/__tests__/acp-driver.test.ts
Normal file
105
packages/pi-claude-cli/src/__tests__/acp-driver.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
// Synthetic ACP session/update sequence the mocked prompt() will replay.
|
||||
let scriptedUpdates: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(() => {
|
||||
const proc = new EventEmitter() as EventEmitter & Record<string, unknown>;
|
||||
proc.stdin = new PassThrough();
|
||||
proc.stdout = new PassThrough();
|
||||
proc.stderr = new PassThrough();
|
||||
proc.kill = vi.fn();
|
||||
proc.pid = 4242;
|
||||
return proc;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the ACP SDK: ClientSideConnection.prompt() replays scriptedUpdates onto
|
||||
// the client handler, then resolves — so we exercise the real translation logic.
|
||||
vi.mock("@agentclientprotocol/sdk", () => ({
|
||||
PROTOCOL_VERSION: 1,
|
||||
ndJsonStream: vi.fn(() => ({})),
|
||||
ClientSideConnection: vi.fn(function (this: Record<string, unknown>, factory: () => { sessionUpdate: (p: unknown) => Promise<void> }) {
|
||||
const handler = factory();
|
||||
this.initialize = vi.fn(async () => ({ protocolVersion: 1 }));
|
||||
this.newSession = vi.fn(async () => ({ sessionId: "s1" }));
|
||||
this.prompt = vi.fn(async () => {
|
||||
for (const u of scriptedUpdates) await handler.sessionUpdate({ update: u });
|
||||
return { stopReason: "end_turn" };
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
const { MockStream } = vi.hoisted(() => {
|
||||
const MockStream: unknown = vi.fn(function (this: Record<string, unknown>) {
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
this.push = vi.fn((e: Record<string, unknown>) => events.push(e));
|
||||
this.end = vi.fn();
|
||||
this._events = events;
|
||||
});
|
||||
return { MockStream };
|
||||
});
|
||||
|
||||
vi.mock("@earendil-works/pi-ai", () => ({
|
||||
AssistantMessageEventStream: MockStream,
|
||||
calculateCost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { streamViaAcp } from "../acp-driver.js";
|
||||
|
||||
const MODEL = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" } as never;
|
||||
const CTX = { messages: [{ role: "user", content: "hi" }] } as never;
|
||||
const OPTS = { bridgePath: "/fake/claude-code-cli-acp", cwd: "/tmp", mcpServers: [], bridgeEnv: { HOME: "/h", PATH: "/b" } };
|
||||
|
||||
function eventsOf(stream: { _events: Array<Record<string, unknown>> }) {
|
||||
return stream._events;
|
||||
}
|
||||
const flush = () => new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
describe("streamViaAcp — ACP→pi translation (U11)", () => {
|
||||
beforeEach(() => { scriptedUpdates = []; });
|
||||
|
||||
it("translates agent_message_chunk text into pi text events + done(stop)", async () => {
|
||||
scriptedUpdates = [
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello " } },
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" } },
|
||||
];
|
||||
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
|
||||
await flush();
|
||||
const types = eventsOf(stream).map((e) => e.type);
|
||||
expect(types).toContain("start");
|
||||
expect(types).toContain("text_start");
|
||||
expect(types.filter((t) => t === "text_delta").length).toBe(2);
|
||||
const done = eventsOf(stream).find((e) => e.type === "done");
|
||||
expect(done).toBeDefined();
|
||||
expect(done!.reason).toBe("stop");
|
||||
});
|
||||
|
||||
it("breaks early on a tool_call: emits toolcall_start + done(toolUse), no execution", async () => {
|
||||
scriptedUpdates = [
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "let me check" } },
|
||||
{ sessionUpdate: "tool_call", toolCallId: "t1", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
|
||||
// anything after the tool call must be ignored (break-early)
|
||||
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "SHOULD NOT APPEAR" } },
|
||||
];
|
||||
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
|
||||
await flush();
|
||||
const types = eventsOf(stream).map((e) => e.type);
|
||||
expect(types).toContain("toolcall_start");
|
||||
const done = eventsOf(stream).find((e) => e.type === "done");
|
||||
expect(done!.reason).toBe("toolUse");
|
||||
// break-early: the post-tool text delta must not have been translated
|
||||
const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta);
|
||||
expect(deltas.join("")).not.toContain("SHOULD NOT APPEAR");
|
||||
});
|
||||
|
||||
it("ends with done even when the turn produces no content", async () => {
|
||||
scriptedUpdates = [];
|
||||
const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
|
||||
await flush();
|
||||
expect(eventsOf(stream).some((e) => e.type === "done")).toBe(true);
|
||||
});
|
||||
});
|
||||
200
packages/pi-claude-cli/src/acp-driver.ts
Normal file
200
packages/pi-claude-cli/src/acp-driver.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* ACP transport for the pi-claude-cli provider (U11 — Route A).
|
||||
*
|
||||
* `streamViaAcp` is the drop-in alternative to `streamViaCli` that drives Claude
|
||||
* through the `claude-code-cli-acp` bridge over the Agent Client Protocol instead
|
||||
* of `claude -p`. It returns the SAME `AssistantMessageEventStream` shape, so the
|
||||
* provider's `streamSimple` can dispatch to either transport behind a kill-switch.
|
||||
*
|
||||
* Design (see plan U11):
|
||||
* - Full-history prompt EVERY turn (`buildPrompt`) — the ACP path has no Claude
|
||||
* `--resume`, so we never send the latest-turn-only `buildResumePrompt` (R13).
|
||||
* - MCP tool SCHEMAS are forwarded on `session/new` so Claude knows the Fusion
|
||||
* tools and emits correct `tool_use` calls; we DO NOT let the bridge execute
|
||||
* them. On the first tool call we break early (cancel the turn) and surface the
|
||||
* call to pi, which runs the tool itself — mirroring the `-p` break-early
|
||||
* pattern (the schema-only MCP server never reaches `tools/call`).
|
||||
* - Translation reuses the tested `createEventBridge` by synthesizing Claude
|
||||
* stream events from ACP `session/update`s, so pi event sequencing, tool-name
|
||||
* mapping and arg translation are shared with the `-p` path.
|
||||
*
|
||||
* Auth: the bridge spawns the real `claude`, which authenticates from the host
|
||||
* login/keychain session (R17). The bridge binary path is injected by the caller
|
||||
* (engine seam, KTD10) — this module never reaches into the ACP plugin.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
||||
import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
||||
import { buildPrompt, buildSystemPrompt, type PiContext } from "./prompt-builder.js";
|
||||
import { createEventBridge } from "./event-bridge.js";
|
||||
import type { ClaudeApiEvent } from "./types.js";
|
||||
|
||||
/** A stdio MCP server forwarded on `session/new` (schema-only — never executed here). */
|
||||
export interface AcpMcpServerSpec {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: { name: string; value: string }[];
|
||||
}
|
||||
|
||||
/** Options for the ACP transport: pi's stream options plus ACP wiring. */
|
||||
export type StreamViaAcpOptions = SimpleStreamOptions & {
|
||||
cwd?: string;
|
||||
/** Absolute path to the `claude-code-cli-acp` bridge binary (injected by the engine seam). */
|
||||
bridgePath: string;
|
||||
/** MCP servers (tool schemas) forwarded so Claude emits correct tool calls. */
|
||||
mcpServers?: AcpMcpServerSpec[];
|
||||
/** Env allow-list forwarded to the bridge (HOME/PATH …); never inherited process.env. */
|
||||
bridgeEnv?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
const INITIALIZE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function flattenPromptText(prompt: string | { type: string; text?: string }[]): string {
|
||||
if (typeof prompt === "string") return prompt;
|
||||
return prompt
|
||||
.map((b) => (b.type === "text" && typeof b.text === "string" ? b.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a Claude response via the ACP bridge as an `AssistantMessageEventStream`.
|
||||
* Mirrors `streamViaCli`'s contract (start → deltas → done; break-early on tools).
|
||||
*/
|
||||
export function streamViaAcp(
|
||||
model: Model<Api>,
|
||||
context: PiContext,
|
||||
options: StreamViaAcpOptions,
|
||||
): AssistantMessageEventStream {
|
||||
// @ts-expect-error — pi-ai exports AssistantMessageEventStream as a type; the
|
||||
// constructor exists at runtime (same workaround as streamViaCli).
|
||||
const stream = new AssistantMessageEventStream();
|
||||
const bridge = createEventBridge(stream, model);
|
||||
|
||||
(async () => {
|
||||
let child: ChildProcess | undefined;
|
||||
let ended = false;
|
||||
// Claude content-block index synthesis: one open text/thinking block at a time.
|
||||
let blockIndex = -1;
|
||||
let openKind: "text" | "thinking" | null = null;
|
||||
let sawToolCall = false;
|
||||
|
||||
const finish = (reason: "stop" | "tool_use") => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
|
||||
bridge.handleEvent({ type: "message_delta", delta: { stop_reason: reason === "tool_use" ? "tool_use" : "end_turn" } } as ClaudeApiEvent);
|
||||
stream.push({ type: "done", reason: reason === "tool_use" ? "toolUse" : "stop", message: bridge.getOutput() });
|
||||
stream.end();
|
||||
try { child?.kill("SIGKILL"); } catch { /* registry SIGKILL is authoritative */ }
|
||||
};
|
||||
|
||||
const failWith = (msg: string) => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
const output = bridge.getOutput();
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
...output,
|
||||
content: output.content?.length ? output.content : [{ type: "text" as const, text: `Error: ${msg}` }],
|
||||
stopReason: "stop" as const,
|
||||
},
|
||||
});
|
||||
stream.end();
|
||||
try { child?.kill("SIGKILL"); } catch { /* noop */ }
|
||||
};
|
||||
|
||||
// Ensure a text/thinking block is open, closing any block of the other kind first.
|
||||
const openBlock = (kind: "text" | "thinking") => {
|
||||
if (openKind === kind) return;
|
||||
if (openKind !== null) bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
|
||||
blockIndex += 1;
|
||||
openKind = kind;
|
||||
bridge.handleEvent({
|
||||
type: "content_block_start",
|
||||
index: blockIndex,
|
||||
content_block: { type: kind },
|
||||
} as ClaudeApiEvent);
|
||||
};
|
||||
|
||||
const clientHandler = {
|
||||
async sessionUpdate(params: { update?: Record<string, unknown> } & Record<string, unknown>) {
|
||||
if (ended) return;
|
||||
const u = (params.update ?? params) as Record<string, unknown>;
|
||||
const kind = u.sessionUpdate as string;
|
||||
const content = u.content as { type?: string; text?: string } | undefined;
|
||||
|
||||
if (kind === "agent_message_chunk" && content?.type === "text" && content.text) {
|
||||
openBlock("text");
|
||||
bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text: content.text } } as ClaudeApiEvent);
|
||||
} else if (kind === "agent_thought_chunk" && content?.text) {
|
||||
openBlock("thinking");
|
||||
bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: content.text } } as ClaudeApiEvent);
|
||||
} else if (kind === "tool_call") {
|
||||
// Break-early: surface the tool call to pi, do NOT let the bridge execute it.
|
||||
const meta = (u._meta as { claudeCode?: { toolName?: string } } | undefined)?.claudeCode;
|
||||
const claudeName = (meta?.toolName as string) ?? (u.title as string) ?? "";
|
||||
const id = (u.toolCallId as string) ?? `acp_${blockIndex + 1}`;
|
||||
if (openKind !== null) { bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent); openKind = null; }
|
||||
blockIndex += 1;
|
||||
bridge.handleEvent({ type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", name: claudeName, id } } as ClaudeApiEvent);
|
||||
const rawInput = u.rawInput ?? u.input ?? {};
|
||||
bridge.handleEvent({ type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(rawInput) } } as ClaudeApiEvent);
|
||||
bridge.handleEvent({ type: "content_block_stop", index: blockIndex } as ClaudeApiEvent);
|
||||
sawToolCall = true;
|
||||
finish("tool_use");
|
||||
}
|
||||
},
|
||||
async requestPermission() {
|
||||
// We break early before execution, so this should not fire. Reject to be safe.
|
||||
return { outcome: { outcome: "cancelled" as const } };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const env = options.bridgeEnv ?? { HOME: process.env.HOME, PATH: process.env.PATH };
|
||||
child = spawn(options.bridgePath, [], { stdio: ["pipe", "pipe", "pipe"], cwd: options.cwd ?? process.cwd(), env });
|
||||
child.on("error", (e) => failWith(`ACP bridge spawn failed: ${e.message}`));
|
||||
if (options.signal) options.signal.addEventListener("abort", () => { try { child?.kill("SIGKILL"); } catch { /* noop */ } failWith("aborted"); }, { once: true });
|
||||
|
||||
const acpStream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
);
|
||||
const conn = new ClientSideConnection(() => clientHandler, acpStream);
|
||||
|
||||
const init = await Promise.race([
|
||||
conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } }),
|
||||
new Promise<never>((_, rej) => setTimeout(() => rej(new Error("ACP initialize timeout")), INITIALIZE_TIMEOUT_MS)),
|
||||
]);
|
||||
if (init.protocolVersion !== PROTOCOL_VERSION) { failWith(`incompatible ACP protocol ${init.protocolVersion}`); return; }
|
||||
|
||||
const opened = await conn.newSession({ cwd: options.cwd ?? process.cwd(), mcpServers: options.mcpServers ?? [] });
|
||||
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const promptText = flattenPromptText(buildPrompt(context));
|
||||
const systemPrompt = buildSystemPrompt(context, cwd);
|
||||
const blocks = [
|
||||
...(systemPrompt ? [{ type: "text" as const, text: `${systemPrompt}\n\n` }] : []),
|
||||
{ type: "text" as const, text: promptText },
|
||||
];
|
||||
|
||||
await conn.prompt({ sessionId: opened.sessionId, prompt: blocks });
|
||||
// Resolved without a tool call → normal end of turn.
|
||||
if (!sawToolCall) finish("stop");
|
||||
} catch (err) {
|
||||
failWith(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
@@ -142,3 +142,32 @@ export function writeMcpConfig(
|
||||
|
||||
return configFilePath;
|
||||
}
|
||||
|
||||
/** A stdio MCP server spec for ACP `session/new.mcpServers` (U11 — Route A). */
|
||||
export interface AcpMcpServerSpec {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: { name: string; value: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ACP `mcpServers` spec for the same schema-only `custom-tools` server
|
||||
* `writeMcpConfig` produces for `--mcp-config` — but as the inline ACP shape
|
||||
* (`session/new.mcpServers`) instead of a config-file path (U11). Writes the
|
||||
* tool-schema file and points the server at the shared `mcp-schema-server.cjs`.
|
||||
* Returns `[]` when there are no custom tools (Route B read-only posture).
|
||||
*/
|
||||
export function buildAcpMcpServers(
|
||||
toolDefs: McpToolDef[],
|
||||
cacheKey?: string,
|
||||
): AcpMcpServerSpec[] {
|
||||
if (toolDefs.length === 0) return [];
|
||||
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
|
||||
const schemaFilePath = join(tmpdir(), `pi-claude-mcp-schemas-${suffix}.json`);
|
||||
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
|
||||
const serverPath = join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs");
|
||||
return [
|
||||
{ name: "custom-tools", command: "node", args: [serverPath, schemaFilePath], env: [] },
|
||||
];
|
||||
}
|
||||
|
||||
139
pnpm-lock.yaml
generated
139
pnpm-lock.yaml
generated
@@ -47,10 +47,10 @@ importers:
|
||||
dependencies:
|
||||
'@earendil-works/pi-ai':
|
||||
specifier: ^0.79.1
|
||||
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
'@earendil-works/pi-coding-agent':
|
||||
specifier: ^0.79.1
|
||||
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
version: 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
dockerode:
|
||||
specifier: ^4.0.12
|
||||
version: 4.0.12
|
||||
@@ -585,12 +585,15 @@ importers:
|
||||
|
||||
packages/pi-claude-cli:
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk':
|
||||
specifier: 0.24.0
|
||||
version: 0.24.0(zod@4.3.6)
|
||||
'@earendil-works/pi-ai':
|
||||
specifier: '*'
|
||||
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
'@earendil-works/pi-coding-agent':
|
||||
specifier: '*'
|
||||
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.5.2
|
||||
@@ -7911,20 +7914,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
ignore: 7.0.5
|
||||
typebox: 1.1.38
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
@@ -7953,6 +7942,20 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-agent-core@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
ignore: 7.0.5
|
||||
typebox: 1.1.38
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-agent-core@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
@@ -8001,26 +8004,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1048.0
|
||||
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
|
||||
'@mistralai/mistralai': 2.2.1
|
||||
'@smithy/node-http-handler': 4.7.3
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
||||
partial-json: 0.1.7
|
||||
typebox: 1.1.38
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
|
||||
@@ -8061,6 +8044,26 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-ai@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1048.0
|
||||
'@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
|
||||
'@mistralai/mistralai': 2.2.1
|
||||
'@smithy/node-http-handler': 4.7.3
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
||||
partial-json: 0.1.7
|
||||
typebox: 1.1.38
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-ai@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
|
||||
@@ -8130,35 +8133,6 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
'@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
'@earendil-works/pi-tui': 0.77.0
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
cross-spawn: 7.0.6
|
||||
diff: 8.0.4
|
||||
glob: 13.0.6
|
||||
highlight.js: 10.7.3
|
||||
hosted-git-info: 9.0.3
|
||||
ignore: 7.0.5
|
||||
jiti: 2.7.0
|
||||
minimatch: 10.2.5
|
||||
proper-lockfile: 4.1.2
|
||||
typebox: 1.1.38
|
||||
undici: 8.3.0
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@mariozechner/clipboard': 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
@@ -8217,6 +8191,35 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-coding-agent@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-agent-core': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
'@earendil-works/pi-ai': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
|
||||
'@earendil-works/pi-tui': 0.79.1
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
cross-spawn: 7.0.6
|
||||
diff: 8.0.4
|
||||
glob: 13.0.6
|
||||
highlight.js: 10.7.3
|
||||
hosted-git-info: 9.0.3
|
||||
ignore: 7.0.5
|
||||
jiti: 2.7.0
|
||||
minimatch: 10.2.5
|
||||
proper-lockfile: 4.1.2
|
||||
typebox: 1.1.38
|
||||
undici: 8.3.0
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@mariozechner/clipboard': 0.3.9
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@earendil-works/pi-coding-agent@0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@earendil-works/pi-agent-core': 0.79.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
@@ -9807,7 +9810,7 @@ snapshots:
|
||||
obug: 2.1.2
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.8(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest: 4.1.8(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@4.1.8':
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user