@runfusion/fusion 0.2.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/bin.js +55392 -51635
  2. package/dist/client/assets/{AgentDetailView-BMrHuWGs.css → AgentDetailView-C1b9PC5l.css} +1 -1
  3. package/dist/client/assets/{AgentDetailView-B4lRk--v.js → AgentDetailView-DJwWfkpv.js} +1 -1
  4. package/dist/client/assets/{AgentsView-yCYBY2km.js → AgentsView-DegK8aw-.js} +5 -5
  5. package/dist/client/assets/ChatView-CYpEShLS.js +1 -0
  6. package/dist/client/assets/{DevServerView-jXXtoQUx.js → DevServerView-DfCTA9fx.js} +2 -2
  7. package/dist/client/assets/{DirectoryPicker-izgMlS27.js → DirectoryPicker-B0qNpfLW.js} +1 -1
  8. package/dist/client/assets/DirectoryPicker-DzKVmxOf.css +1 -0
  9. package/dist/client/assets/{DocumentsView-DkkoHRwL.js → DocumentsView-CsQxuyz3.js} +1 -1
  10. package/dist/client/assets/{InsightsView-DaRtUPHX.js → InsightsView-Bzs7A2jv.js} +2 -2
  11. package/dist/client/assets/MemoryView-Cl5ASqjW.js +2 -0
  12. package/dist/client/assets/MemoryView-DiajLXby.css +1 -0
  13. package/dist/client/assets/{NodesView-BsUk_oiU.js → NodesView-BpiqRlvc.js} +1 -1
  14. package/dist/client/assets/PiExtensionsManager-Cr6EoC7S.js +11 -0
  15. package/dist/client/assets/PiExtensionsManager-kgTOHPE9.css +1 -0
  16. package/dist/client/assets/PluginManager-DRiIqol2.css +1 -0
  17. package/dist/client/assets/PluginManager-DXtQdfns.js +1 -0
  18. package/dist/client/assets/{RoadmapsView-SQol126Y.js → RoadmapsView-CYPLTTB0.js} +2 -2
  19. package/dist/client/assets/SettingsModal-CDWvhvrd.css +1 -0
  20. package/dist/client/assets/SettingsModal-CNdVTVqD.js +1 -0
  21. package/dist/client/assets/SettingsModal-CyCC7MzL.js +31 -0
  22. package/dist/client/assets/SettingsModal-G0ESQXRD.css +1 -0
  23. package/dist/client/assets/{SetupWizardModal-CQc1uGSq.js → SetupWizardModal-BLiljNn7.js} +1 -1
  24. package/dist/client/assets/SetupWizardModal-BMa6p24b.css +1 -0
  25. package/dist/client/assets/SkillsView-Dlpw5LKI.js +1 -0
  26. package/dist/client/assets/{folder-open-CI4TCD7P.js → folder-open-B_38R5AA.js} +1 -1
  27. package/dist/client/assets/index-DQKtk17v.js +616 -0
  28. package/dist/client/assets/index-DjOxzdj3.css +1 -0
  29. package/dist/client/assets/{upload-CAlKC4qI.js → upload-DNQF7XCK.js} +1 -1
  30. package/dist/client/assets/users-CG2_rCdk.js +6 -0
  31. package/dist/client/index.html +2 -2
  32. package/dist/client/version.json +1 -0
  33. package/dist/extension.js +3222 -937
  34. package/dist/pi-claude-cli/index.ts +72 -28
  35. package/dist/pi-claude-cli/package.json +1 -1
  36. package/dist/pi-claude-cli/src/__tests__/event-bridge.test.ts +34 -0
  37. package/dist/pi-claude-cli/src/__tests__/mcp-config.test.ts +22 -0
  38. package/dist/pi-claude-cli/src/__tests__/prompt-builder.test.ts +72 -10
  39. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +9 -9
  40. package/dist/pi-claude-cli/src/event-bridge.ts +17 -6
  41. package/dist/pi-claude-cli/src/mcp-config.ts +36 -3
  42. package/dist/pi-claude-cli/src/prompt-builder.ts +111 -7
  43. package/dist/pi-claude-cli/src/provider.ts +17 -2
  44. package/package.json +17 -16
  45. package/skill/fusion/SKILL.md +6 -1
  46. package/skill/fusion/references/engine-tools.md +54 -0
  47. package/skill/fusion/references/extension-tools.md +83 -84
  48. package/skill/fusion/references/fusion-capabilities.md +33 -31
  49. package/LICENSE +0 -21
  50. package/dist/client/assets/ChatView-CH9T0dDs.js +0 -1
  51. package/dist/client/assets/MemoryView-85NKuU3h.js +0 -2
  52. package/dist/client/assets/MemoryView-DhinauGs.css +0 -1
  53. package/dist/client/assets/PiExtensionsManager-BF5pxrSE.js +0 -11
  54. package/dist/client/assets/PiExtensionsManager-K7HQ08L4.css +0 -1
  55. package/dist/client/assets/PluginManager-ccq3uK50.css +0 -1
  56. package/dist/client/assets/PluginManager-s6btydh5.js +0 -1
  57. package/dist/client/assets/SkillsView-BtUhs_QW.js +0 -1
  58. package/dist/client/assets/index-Ct-OqLpP.css +0 -1
  59. package/dist/client/assets/index-rNf7s96d.js +0 -649
@@ -13,56 +13,93 @@ import {
13
13
  validateCliAuth,
14
14
  killAllProcesses,
15
15
  } from "./src/process-manager.js";
16
- import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
16
+ import { createHash } from "node:crypto";
17
+ import {
18
+ getCustomToolDefs,
19
+ toolsFromContext,
20
+ writeMcpConfig,
21
+ type McpToolDef,
22
+ } from "./src/mcp-config.js";
17
23
 
18
24
  // Kill all active Claude subprocesses on process exit to prevent orphans
19
25
  process.on("exit", killAllProcesses);
20
26
 
21
27
  const PROVIDER_ID = "pi-claude-cli";
22
28
 
23
- let mcpConfigPath: string | undefined;
24
- let mcpConfigResolved = false;
29
+ let cachedMcpConfig: { hash: string; configPath: string } | undefined;
25
30
 
26
31
  /**
27
- * Lazily generate MCP config on first request (not at load time).
28
- * pi.getAllTools() fails during extension loading; this defers it
29
- * until the pi runtime is fully initialized.
32
+ * Resolve the MCP config path for the current request, regenerating it when
33
+ * the set of custom tools changes.
34
+ *
35
+ * Source of truth (in order of preference):
36
+ * 1. `context.tools` — the per-session tool list pi-ai actually hands to
37
+ * `streamSimple`. This is what the session is asking the model to see, so
38
+ * it includes session-scoped registrations (e.g. `fn_review_spec` and
39
+ * `fn_review_step` injected by the engine's triage/executor sessions).
40
+ * 2. `pi.getAllTools()` — fallback for older callers that don't supply
41
+ * `context.tools`.
30
42
  *
31
- * Only locks (sets mcpConfigResolved) when getAllTools() returns a
32
- * real array if it returns undefined/null (registry not ready),
33
- * we retry on the next request. Once the registry is ready we
34
- * commit to the result even if there are zero custom tools.
43
+ * Why not a single once-and-lock cache:
44
+ * - The engine spawns triage/executor sessions with session-scoped tools.
45
+ * A locked-on-first-call cache silently drops them and the Claude CLI
46
+ * subprocess refuses with "unknown tool fn_review_spec".
47
+ * - Hashing the tool defs lets us reuse temp files when the tool set is
48
+ * unchanged across calls and produce fresh files (with the hash in the
49
+ * filename to avoid races) when it changes.
35
50
  *
36
- * Uses warn-don't-block: failure logs a warning but does not
37
- * prevent the provider from functioning (built-ins still work).
51
+ * Uses warn-don't-block: failure logs a warning but does not prevent the
52
+ * provider from functioning (built-ins still work).
38
53
  */
39
- function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
40
- if (mcpConfigResolved) return mcpConfigPath;
54
+ function ensureMcpConfig(
55
+ pi: ExtensionAPI,
56
+ contextTools?: ReadonlyArray<{
57
+ name: string;
58
+ description: string;
59
+ parameters: Record<string, unknown>;
60
+ }>,
61
+ ): string | undefined {
41
62
  try {
42
- const allTools = pi.getAllTools();
63
+ let toolDefs: McpToolDef[] = toolsFromContext(contextTools);
64
+
65
+ // Fallback to the pi runtime registry if the context didn't carry tools.
66
+ // (Older agent-loop versions don't populate Context.tools for streamSimple.)
67
+ if (toolDefs.length === 0) {
68
+ const allTools = pi.getAllTools();
69
+ if (!Array.isArray(allTools)) {
70
+ return cachedMcpConfig?.configPath;
71
+ }
72
+ toolDefs = getCustomToolDefs(pi);
73
+ }
43
74
 
44
- // Registry not ready yet — don't lock, retry on next call
45
- if (!Array.isArray(allTools)) {
46
- return mcpConfigPath;
75
+ if (toolDefs.length === 0) {
76
+ cachedMcpConfig = undefined;
77
+ return undefined;
47
78
  }
48
79
 
49
- // Registry is ready — lock regardless of whether custom tools exist
50
- mcpConfigResolved = true;
80
+ const hash = createHash("sha1")
81
+ .update(JSON.stringify(toolDefs))
82
+ .digest("hex")
83
+ .slice(0, 12);
51
84
 
52
- const toolDefs = getCustomToolDefs(pi);
53
- if (toolDefs.length > 0) {
54
- mcpConfigPath = writeMcpConfig(toolDefs);
55
- console.error(
56
- `[pi-claude-cli] MCP config generated with ${toolDefs.length} custom tool(s)`,
57
- );
85
+ if (cachedMcpConfig?.hash === hash) {
86
+ return cachedMcpConfig.configPath;
58
87
  }
88
+
89
+ const configPath = writeMcpConfig(toolDefs, hash);
90
+ cachedMcpConfig = { hash, configPath };
91
+ const toolNames = toolDefs.map((t) => t.name).join(", ");
92
+ console.error(
93
+ `[pi-claude-cli] MCP config refreshed with ${toolDefs.length} custom tool(s) [${toolNames}] (hash=${hash})`,
94
+ );
95
+ return configPath;
59
96
  } catch (err) {
60
97
  console.warn(
61
98
  "[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
62
99
  err,
63
100
  );
101
+ return cachedMcpConfig?.configPath;
64
102
  }
65
- return mcpConfigPath;
66
103
  }
67
104
 
68
105
  export default function (pi: ExtensionAPI) {
@@ -118,7 +155,14 @@ export default function (pi: ExtensionAPI) {
118
155
  api: "pi-claude-cli",
119
156
  models,
120
157
  streamSimple: (model, context, options) => {
121
- const configPath = ensureMcpConfig(pi);
158
+ const configPath = ensureMcpConfig(
159
+ pi,
160
+ (context as { tools?: ReadonlyArray<{
161
+ name: string;
162
+ description: string;
163
+ parameters: Record<string, unknown>;
164
+ }> }).tools,
165
+ );
122
166
  return streamViaCli(model, context, {
123
167
  ...options,
124
168
  mcpConfigPath: configPath,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusion/pi-claude-cli",
3
- "version": "0.2.7",
3
+ "version": "1.0.0",
4
4
  "description": "Fusion vendored fork: pi coding-agent extension that routes LLM calls through the Claude Code CLI. Forked from rchern/pi-claude-cli (MIT). See UPSTREAM.md.",
5
5
  "license": "MIT",
6
6
  "private": true,
@@ -444,6 +444,40 @@ describe("createEventBridge", () => {
444
444
  expect(event.toolCall.arguments).toEqual({ path: "/foo.ts" });
445
445
  });
446
446
 
447
+ it("emits {} for parameterless MCP tool calls (no input_json_delta)", () => {
448
+ // Parameterless MCP tools (e.g. fn_review_spec, schema
449
+ // {type:"object", properties:{}}) emit ZERO input_json_delta events.
450
+ // Without the empty-partialJson guard, finalArgs would fall through to
451
+ // the raw-string fallback ("") and pi's TypeBox validator would reject
452
+ // the call with "Validation failed for tool ...: root: must be object".
453
+ const bridge = createBridgeWithStart();
454
+ bridge.handleEvent({
455
+ type: "content_block_start",
456
+ index: 0,
457
+ content_block: {
458
+ type: "tool_use",
459
+ id: "toolu_01XYZ",
460
+ name: "mcp__custom-tools__fn_review_spec",
461
+ },
462
+ });
463
+ // No content_block_delta with input_json_delta — Claude emits none for
464
+ // parameterless tools.
465
+ stream.push.mockClear();
466
+ stream.events.length = 0;
467
+
468
+ bridge.handleEvent({
469
+ type: "content_block_stop",
470
+ index: 0,
471
+ });
472
+
473
+ expect(stream.push).toHaveBeenCalledTimes(1);
474
+ const event = stream.events[0] as any;
475
+ expect(event.type).toBe("toolcall_end");
476
+ expect(event.toolCall.arguments).toEqual({});
477
+ // The MCP prefix should be stripped: pi sees the bare tool name.
478
+ expect(event.toolCall.name).toBe("fn_review_spec");
479
+ });
480
+
447
481
  it("tracks multiple tool_use blocks independently by Claude event.index", () => {
448
482
  const bridge = createBridgeWithStart();
449
483
 
@@ -269,4 +269,26 @@ describe("writeMcpConfig", () => {
269
269
  expect(result).toMatch(/pi-claude-mcp-config/);
270
270
  expect(result).toMatch(/\.json$/);
271
271
  });
272
+
273
+ it("includes cacheKey in filenames when provided so distinct tool sets do not collide", () => {
274
+ const toolDefs: McpToolDef[] = [
275
+ {
276
+ name: "search",
277
+ description: "Search",
278
+ inputSchema: { type: "object" },
279
+ },
280
+ ];
281
+
282
+ const pathA = writeMcpConfig(toolDefs, "aaaaaaaaaaaa");
283
+ const pathB = writeMcpConfig(toolDefs, "bbbbbbbbbbbb");
284
+
285
+ expect(pathA).toContain("aaaaaaaaaaaa");
286
+ expect(pathB).toContain("bbbbbbbbbbbb");
287
+ expect(pathA).not.toBe(pathB);
288
+
289
+ const schemaPathA = mocks.writeFileSync.mock.calls[0][0];
290
+ const schemaPathB = mocks.writeFileSync.mock.calls[2][0];
291
+ expect(schemaPathA).toContain("aaaaaaaaaaaa");
292
+ expect(schemaPathB).toContain("bbbbbbbbbbbb");
293
+ });
272
294
  });
@@ -21,7 +21,7 @@ describe("buildPrompt", () => {
21
21
  expect(buildPrompt(context)).toBe("ASSISTANT:\nHi there");
22
22
  });
23
23
 
24
- it("produces 'TOOL RESULT (historical {claudeName}):\\n{content}' for a tool result message", () => {
24
+ it("produces 'TOOL RESULT ({claudeName}):\\n{content}' for a tool result message", () => {
25
25
  const context = {
26
26
  messages: [
27
27
  {
@@ -33,7 +33,7 @@ describe("buildPrompt", () => {
33
33
  } as unknown as any;
34
34
  // Pi tool name "read" should be mapped to Claude name "Read" in the label
35
35
  expect(buildPrompt(context)).toBe(
36
- "TOOL RESULT (historical Read):\nfile contents here",
36
+ "TOOL RESULT (Read):\nfile contents here",
37
37
  );
38
38
  });
39
39
 
@@ -57,7 +57,7 @@ describe("buildPrompt", () => {
57
57
  "What is in file.ts?",
58
58
  "ASSISTANT:",
59
59
  "Let me read that file.",
60
- "TOOL RESULT (historical Read):",
60
+ "TOOL RESULT (Read):",
61
61
  "export const x = 1;",
62
62
  "USER:",
63
63
  "Now explain it.",
@@ -109,7 +109,7 @@ describe("buildPrompt", () => {
109
109
  // Tool name should be mapped from pi "read" to Claude "Read"
110
110
  // Arg "path" should be mapped from pi format to Claude "file_path"
111
111
  expect(result).toContain(
112
- 'Historical tool call (non-executable): Read args={"file_path":"/file.ts"}',
112
+ '[Prior tool call — already executed; result follows in TOOL RESULT (Read):] args={"file_path":"/file.ts"}',
113
113
  );
114
114
  });
115
115
 
@@ -158,7 +158,7 @@ describe("buildPrompt", () => {
158
158
  const result = buildPrompt(context);
159
159
  // Pi "bash" maps to Claude "Bash"
160
160
  expect(result).toContain(
161
- "Historical tool call (non-executable): Bash args={}",
161
+ "[Prior tool call — already executed; result follows in TOOL RESULT (Bash):] args={}",
162
162
  );
163
163
  });
164
164
 
@@ -177,7 +177,7 @@ describe("buildPrompt", () => {
177
177
  } as unknown as any;
178
178
 
179
179
  const result = buildPrompt(context);
180
- expect(result).toBe("TOOL RESULT (historical Bash):\nline 1\nline 2");
180
+ expect(result).toBe("TOOL RESULT (Bash):\nline 1\nline 2");
181
181
  });
182
182
 
183
183
  describe("tool name and argument reverse mapping", () => {
@@ -237,7 +237,7 @@ describe("buildPrompt", () => {
237
237
  } as unknown as any;
238
238
 
239
239
  const result = buildPrompt(context);
240
- expect(result).toContain("TOOL RESULT (historical Read):");
240
+ expect(result).toContain("TOOL RESULT (Read):");
241
241
  });
242
242
 
243
243
  it("prefixes custom (non-built-in) tool names with MCP prefix", () => {
@@ -281,7 +281,8 @@ describe("buildPrompt", () => {
281
281
 
282
282
  const result = buildPrompt(context);
283
283
  // String arguments should be serialized as JSON string
284
- expect(result).toContain('Read args="raw string args"');
284
+ expect(result).toContain('TOOL RESULT (Read):');
285
+ expect(result).toContain('args="raw string args"');
285
286
  });
286
287
  });
287
288
  });
@@ -688,7 +689,7 @@ describe("custom tool history replay", () => {
688
689
  } as unknown as any;
689
690
 
690
691
  const result = buildPrompt(context);
691
- expect(result).toContain("TOOL RESULT (historical Read):");
692
+ expect(result).toContain("TOOL RESULT (Read):");
692
693
  expect(result).not.toContain("mcp__custom-tools__");
693
694
  });
694
695
 
@@ -910,6 +911,67 @@ describe("buildSystemPrompt", () => {
910
911
  expect(result).toContain("IMPORTANT:");
911
912
  expect(result).toContain("tool results");
912
913
  });
914
+
915
+ it("rewrites bare custom tool references to MCP-prefixed names", async () => {
916
+ vi.resetModules();
917
+ vi.doMock("node:fs", () => ({
918
+ existsSync: () => false,
919
+ readFileSync: () => "",
920
+ }));
921
+
922
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
923
+ const context = {
924
+ systemPrompt:
925
+ "Write the PROMPT.md, then call `fn_review_spec()` for review. " +
926
+ "If REVISE, call fn_review_spec again. Do not call mcp__custom-tools__fn_review_spec twice manually.",
927
+ messages: [],
928
+ tools: [
929
+ { name: "fn_review_spec", description: "review", parameters: {} },
930
+ { name: "read", description: "builtin", parameters: {} },
931
+ ],
932
+ } as unknown as any;
933
+ const result = bsp(context, "/some/project");
934
+
935
+ // Bare name occurrences are rewritten
936
+ expect(result).toContain(
937
+ "call `mcp__custom-tools__fn_review_spec()` for review",
938
+ );
939
+ expect(result).toContain(
940
+ "call mcp__custom-tools__fn_review_spec again",
941
+ );
942
+ // Already-prefixed occurrence is not double-prefixed
943
+ expect(result).not.toContain(
944
+ "mcp__custom-tools__mcp__custom-tools__fn_review_spec",
945
+ );
946
+ // Built-in pi tool names are not rewritten
947
+ expect(result).not.toContain("mcp__custom-tools__read");
948
+ // The addendum still lists the custom tool with its full mapping
949
+ expect(result).toContain("mcp__custom-tools__fn_review_spec");
950
+ });
951
+
952
+ it("does not rewrite identifier substrings that happen to overlap a tool name", async () => {
953
+ vi.resetModules();
954
+ vi.doMock("node:fs", () => ({
955
+ existsSync: () => false,
956
+ readFileSync: () => "",
957
+ }));
958
+
959
+ const { buildSystemPrompt: bsp } = await import("../prompt-builder");
960
+ const context = {
961
+ systemPrompt: "fn_review_specifier and fn_reviews are not the tool.",
962
+ messages: [],
963
+ tools: [
964
+ { name: "fn_review", description: "x", parameters: {} },
965
+ { name: "fn_review_spec", description: "y", parameters: {} },
966
+ ],
967
+ } as unknown as any;
968
+ const result = bsp(context, "/some/project");
969
+ // Neither substring should be rewritten — they're different identifiers.
970
+ expect(result).toContain("fn_review_specifier");
971
+ expect(result).toContain("fn_reviews");
972
+ expect(result).not.toContain("mcp__custom-tools__fn_review_specifier");
973
+ expect(result).not.toContain("mcp__custom-tools__fn_reviews");
974
+ });
913
975
  });
914
976
 
915
977
  describe("buildResumePrompt", () => {
@@ -958,7 +1020,7 @@ describe("buildResumePrompt", () => {
958
1020
  ],
959
1021
  };
960
1022
  const result = buildResumePrompt(context) as string;
961
- expect(result).toContain("TOOL RESULT (historical Read):");
1023
+ expect(result).toContain("TOOL RESULT (Read):");
962
1024
  expect(result).toContain("file contents here");
963
1025
  expect(result).toContain("Now explain it");
964
1026
  });
@@ -1292,7 +1292,7 @@ describe("streamViaCli", () => {
1292
1292
  });
1293
1293
 
1294
1294
  describe("inactivity timeout", () => {
1295
- it("kills subprocess and pushes error after 180s of no output", async () => {
1295
+ it("kills subprocess and pushes error after 1800s of no output", async () => {
1296
1296
  const model = mockModels[0] as any;
1297
1297
  const context = {
1298
1298
  messages: [{ role: "user", content: "Hello" }],
@@ -1303,8 +1303,8 @@ describe("streamViaCli", () => {
1303
1303
 
1304
1304
  const proc = (spawn as any).mock.results[0].value;
1305
1305
 
1306
- // Advance timers by 180 seconds without writing to stdout
1307
- await vi.advanceTimersByTimeAsync(180_000);
1306
+ // Advance timers by 1800 seconds without writing to stdout
1307
+ await vi.advanceTimersByTimeAsync(1_800_000);
1308
1308
 
1309
1309
  const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1310
1310
  const doneEvent = mockStream._events.find(
@@ -1330,8 +1330,8 @@ describe("streamViaCli", () => {
1330
1330
 
1331
1331
  const proc = (spawn as any).mock.results[0].value;
1332
1332
 
1333
- // Advance to 170s then write a line
1334
- await vi.advanceTimersByTimeAsync(170_000);
1333
+ // Advance to 290s then write a line (just under the 300s cap)
1334
+ await vi.advanceTimersByTimeAsync(1_790_000);
1335
1335
 
1336
1336
  // Write a stream event line
1337
1337
  proc.stdout.write(
@@ -1345,8 +1345,8 @@ describe("streamViaCli", () => {
1345
1345
  );
1346
1346
  await vi.advanceTimersByTimeAsync(0);
1347
1347
 
1348
- // Advance another 170s (340s total, 170s since last line) -- should NOT timeout
1349
- await vi.advanceTimersByTimeAsync(170_000);
1348
+ // Advance another 290s (580s total, 290s since last line) -- should NOT timeout
1349
+ await vi.advanceTimersByTimeAsync(1_790_000);
1350
1350
 
1351
1351
  const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1352
1352
  const doneEvent = mockStream._events.find(
@@ -1354,7 +1354,7 @@ describe("streamViaCli", () => {
1354
1354
  );
1355
1355
  expect(doneEvent).toBeUndefined();
1356
1356
 
1357
- // Advance 10 more seconds (180s since last line) -- NOW should timeout
1357
+ // Advance 10 more seconds (1800s since last line) -- NOW should timeout
1358
1358
  await vi.advanceTimersByTimeAsync(10_000);
1359
1359
 
1360
1360
  const doneEvent2 = mockStream._events.find(
@@ -1401,7 +1401,7 @@ describe("streamViaCli", () => {
1401
1401
  await vi.advanceTimersByTimeAsync(100);
1402
1402
 
1403
1403
  // Advance past 180s -- should NOT timeout since result was received
1404
- await vi.advanceTimersByTimeAsync(180_000);
1404
+ await vi.advanceTimersByTimeAsync(1_800_000);
1405
1405
 
1406
1406
  const mockStream = MockAssistantMessageEventStream.mock.instances[0];
1407
1407
  const errorEvents = mockStream._events.filter(
@@ -323,13 +323,24 @@ export function createEventBridge(
323
323
  partial: output,
324
324
  });
325
325
  } else if (block.type === "tool_use") {
326
- // Final JSON parse with fallback to raw string
326
+ // Final JSON parse with fallback to raw string.
327
+ // Special case: parameterless MCP tools (e.g. fn_review_spec, schema
328
+ // `{type:"object", properties:{}}`) emit ZERO input_json_delta events,
329
+ // so `partialJson` stays "". Without this guard we'd JSON.parse("")
330
+ // → throw → fall through to `finalArgs = ""` (raw string), and pi's
331
+ // TypeBox validator then rejects with "root: must be object" because
332
+ // an empty string is not an object. Default to `{}` so the call lands.
327
333
  let finalArgs: Record<string, unknown> | string;
328
- try {
329
- const parsed = JSON.parse(block.partialJson);
330
- finalArgs = translateClaudeArgsToPi(block.claudeName, parsed);
331
- } catch {
332
- finalArgs = block.partialJson;
334
+ const trimmedJson = block.partialJson.trim();
335
+ if (trimmedJson === "") {
336
+ finalArgs = {};
337
+ } else {
338
+ try {
339
+ const parsed = JSON.parse(trimmedJson);
340
+ finalArgs = translateClaudeArgsToPi(block.claudeName, parsed);
341
+ } catch {
342
+ finalArgs = block.partialJson;
343
+ }
333
344
  }
334
345
 
335
346
  // Update output.content with final arguments
@@ -67,6 +67,31 @@ export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
67
67
  }));
68
68
  }
69
69
 
70
+ /** Minimal pi-ai Tool shape (the subset we need from `Context.tools`). */
71
+ interface PiAiToolLike {
72
+ name: string;
73
+ description: string;
74
+ parameters: Record<string, unknown>;
75
+ }
76
+
77
+ /**
78
+ * Convert the pi-ai `Context.tools` array (the authoritative per-session tool
79
+ * list pi-coding-agent passes to streamSimple) into MCP tool defs, filtering
80
+ * out the 6 built-ins that pi handles natively.
81
+ */
82
+ export function toolsFromContext(
83
+ contextTools: ReadonlyArray<PiAiToolLike> | undefined,
84
+ ): McpToolDef[] {
85
+ if (!Array.isArray(contextTools)) return [];
86
+ return contextTools
87
+ .filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
88
+ .map((tool) => ({
89
+ name: tool.name,
90
+ description: tool.description,
91
+ inputSchema: tool.parameters,
92
+ }));
93
+ }
94
+
70
95
  /**
71
96
  * Write MCP config and tool schemas to temp files.
72
97
  *
@@ -75,13 +100,21 @@ export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
75
100
  * 2. Config file: MCP config pointing to the schema-only server
76
101
  *
77
102
  * @param toolDefs - Array of custom tool definitions
103
+ * @param cacheKey - Optional suffix appended to filenames so that distinct
104
+ * tool sets (e.g. session-scoped tool registrations) get distinct files
105
+ * and don't race on a single shared path.
78
106
  * @returns Path to the MCP config file
79
107
  */
80
- export function writeMcpConfig(toolDefs: McpToolDef[]): string {
108
+ export function writeMcpConfig(
109
+ toolDefs: McpToolDef[],
110
+ cacheKey?: string,
111
+ ): string {
112
+ const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
113
+
81
114
  // Write tool schemas to temp file
82
115
  const schemaFilePath = join(
83
116
  tmpdir(),
84
- `pi-claude-mcp-schemas-${process.pid}.json`,
117
+ `pi-claude-mcp-schemas-${suffix}.json`,
85
118
  );
86
119
  writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
87
120
 
@@ -103,7 +136,7 @@ export function writeMcpConfig(toolDefs: McpToolDef[]): string {
103
136
  // Write config to temp file
104
137
  const configFilePath = join(
105
138
  tmpdir(),
106
- `pi-claude-mcp-config-${process.pid}.json`,
139
+ `pi-claude-mcp-config-${suffix}.json`,
107
140
  );
108
141
  writeFileSync(configFilePath, JSON.stringify(config));
109
142