From 85c180508c2e35357f93e1f27355a2ddff6a39db Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 15 Jun 2026 06:08:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(acp):=20U11=20=E2=80=94=20drive=20pi-claud?= =?UTF-8?q?e-cli=20provider=20via=20the=20ACP=20bridge=20(kill-switch=20OF?= =?UTF-8?q?F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/pi-claude-cli/index.ts | 55 ++++- packages/pi-claude-cli/package.json | 3 + .../src/__tests__/acp-driver.test.ts | 105 +++++++++ packages/pi-claude-cli/src/acp-driver.ts | 200 ++++++++++++++++++ packages/pi-claude-cli/src/mcp-config.ts | 29 +++ pnpm-lock.yaml | 139 ++++++------ 6 files changed, 455 insertions(+), 76 deletions(-) create mode 100644 packages/pi-claude-cli/src/__tests__/acp-driver.test.ts create mode 100644 packages/pi-claude-cli/src/acp-driver.ts diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index 1c14946e17..110386876e 100644 --- a/packages/pi-claude-cli/index.ts +++ b/packages/pi-claude-cli/index.ts @@ -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 }>, +): 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; - }> }).tools, - ); + const contextTools = (context as { tools?: ReadonlyArray<{ + name: string; + description: string; + parameters: Record; + }> }).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, diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index 734242a7b1..38930ec1c5 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -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": "*" diff --git a/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts b/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts new file mode 100644 index 0000000000..afdb9a4430 --- /dev/null +++ b/packages/pi-claude-cli/src/__tests__/acp-driver.test.ts @@ -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> = []; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => { + const proc = new EventEmitter() as EventEmitter & Record; + 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, factory: () => { sessionUpdate: (p: unknown) => Promise }) { + 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) { + const events: Array> = []; + this.push = vi.fn((e: Record) => 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> }) { + 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> }; + 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> }; + 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> }; + await flush(); + expect(eventsOf(stream).some((e) => e.type === "done")).toBe(true); + }); +}); diff --git a/packages/pi-claude-cli/src/acp-driver.ts b/packages/pi-claude-cli/src/acp-driver.ts new file mode 100644 index 0000000000..95168ce60b --- /dev/null +++ b/packages/pi-claude-cli/src/acp-driver.ts @@ -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, + 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 } & Record) { + if (ended) return; + const u = (params.update ?? params) as Record; + 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, + Readable.toWeb(child.stdout!) as unknown as ReadableStream, + ); + const conn = new ClientSideConnection(() => clientHandler, acpStream); + + const init = await Promise.race([ + conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } }), + new Promise((_, 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; +} diff --git a/packages/pi-claude-cli/src/mcp-config.ts b/packages/pi-claude-cli/src/mcp-config.ts index 32deba7f9a..82ad4cb112 100644 --- a/packages/pi-claude-cli/src/mcp-config.ts +++ b/packages/pi-claude-cli/src/mcp-config.ts @@ -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: [] }, + ]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf2801404f..bc93675090 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: