@runfusion/fusion 0.8.2 → 0.8.4

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 (48) hide show
  1. package/dist/bin.js +1714 -840
  2. package/dist/client/assets/{AgentDetailView-B6wfIQ9j.js → AgentDetailView-C_DD03d6.js} +1 -1
  3. package/dist/client/assets/{AgentsView-Fcym0XCw.js → AgentsView-CsEX_Fsi.js} +3 -3
  4. package/dist/client/assets/ChatView-DhudD-jT.js +1 -0
  5. package/dist/client/assets/{DevServerView-CmMS4D6V.js → DevServerView-CNItMoga.js} +1 -1
  6. package/dist/client/assets/DirectoryPicker-BqcVbDZX.js +1 -0
  7. package/dist/client/assets/{DocumentsView-C4HDkN_0.js → DocumentsView-zpHkjZdf.js} +1 -1
  8. package/dist/client/assets/{InsightsView-Egu71gmh.css → InsightsView-6LHF7OdE.css} +1 -1
  9. package/dist/client/assets/InsightsView-CgguV1au.js +11 -0
  10. package/dist/client/assets/{MemoryView-DPYGW09y.js → MemoryView-BWXP8uGT.js} +1 -1
  11. package/dist/client/assets/{NodesView-DsM-c8RF.js → NodesView-DP_O4ae0.js} +1 -1
  12. package/dist/client/assets/{PiExtensionsManager-DHgMjjRE.js → PiExtensionsManager-DGEPbF0y.js} +3 -3
  13. package/dist/client/assets/{PluginManager-N-7Sw_tE.js → PluginManager-4TehPpcf.js} +1 -1
  14. package/dist/client/assets/{RoadmapsView-BgX79uYM.js → RoadmapsView-DNaToPqN.js} +2 -2
  15. package/dist/client/assets/SettingsModal-BluRnKGd.js +31 -0
  16. package/dist/client/assets/{SettingsModal-BIKEMPwb.js → SettingsModal-C-RjolQ5.js} +1 -1
  17. package/dist/client/assets/SettingsModal-C7gPLBaR.css +1 -0
  18. package/dist/client/assets/{SetupWizardModal-D2m0i9Io.js → SetupWizardModal-BQoo_AvX.js} +1 -1
  19. package/dist/client/assets/{SkillsView-Eb1Mngnt.js → SkillsView-AS8Cr_Md.js} +1 -1
  20. package/dist/client/assets/{TodoView-BN8FQYyp.js → TodoView-nWOpOg3R.js} +2 -2
  21. package/dist/client/assets/{folder-open-BqZBHfoZ.js → folder-open-D0LfE0ZP.js} +1 -1
  22. package/dist/client/assets/index-D6xr8Oa2.css +1 -0
  23. package/dist/client/assets/index-heCcln3Z.js +656 -0
  24. package/dist/client/assets/{list-checks-D2EURsP0.js → list-checks-D1faMe1o.js} +1 -1
  25. package/dist/client/assets/{star-CNQlAD8p.js → star-B_uA5YGG.js} +1 -1
  26. package/dist/client/assets/{upload-uoxlYkig.js → upload-DPQ3hWf0.js} +1 -1
  27. package/dist/client/assets/{users-BLvidusm.js → users-CEcYlrZO.js} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/client/version.json +1 -1
  30. package/dist/extension.js +1033 -372
  31. package/dist/pi-claude-cli/package.json +1 -1
  32. package/dist/pi-claude-cli/src/__tests__/control-handler.test.ts +39 -66
  33. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +7 -9
  34. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +73 -148
  35. package/dist/pi-claude-cli/src/__tests__/setup-test-isolation.test.ts +2 -0
  36. package/dist/pi-claude-cli/src/__tests__/setup-test-isolation.ts +8 -0
  37. package/dist/pi-claude-cli/src/control-handler.ts +22 -8
  38. package/dist/pi-claude-cli/src/process-manager.ts +10 -3
  39. package/dist/pi-claude-cli/src/prompt-builder.ts +2 -1
  40. package/dist/pi-claude-cli/src/provider.ts +10 -2
  41. package/package.json +1 -1
  42. package/dist/client/assets/ChatView-DEckS3f3.js +0 -1
  43. package/dist/client/assets/DirectoryPicker-CQtE-YyA.js +0 -1
  44. package/dist/client/assets/InsightsView-oNQ7h5e8.js +0 -11
  45. package/dist/client/assets/SettingsModal-C4EwtN6K.js +0 -31
  46. package/dist/client/assets/SettingsModal-D5hLoLXp.css +0 -1
  47. package/dist/client/assets/index-D2fXOwWF.css +0 -1
  48. package/dist/client/assets/index-DlHPhpDu.js +0 -656
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusion/pi-claude-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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,
@@ -1,5 +1,4 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { PassThrough } from "node:stream";
3
2
  import type { ClaudeControlRequest } from "../types";
4
3
  import {
5
4
  handleControlRequest,
@@ -7,13 +6,6 @@ import {
7
6
  MCP_PREFIX,
8
7
  } from "../control-handler";
9
8
 
10
- function createMockStdin() {
11
- const stream = new PassThrough();
12
- const chunks: string[] = [];
13
- stream.on("data", (data: Buffer) => chunks.push(data.toString()));
14
- return { stream, chunks };
15
- }
16
-
17
9
  function makeControlRequest(
18
10
  toolName: string,
19
11
  requestId = "req-test-001",
@@ -44,147 +36,128 @@ describe("control-handler", () => {
44
36
  });
45
37
 
46
38
  describe("denies custom MCP tools (mcp__custom-tools__*)", () => {
47
- it("denies mcp__custom-tools__weather and returns false", () => {
48
- const { stream, chunks } = createMockStdin();
39
+ it("denies mcp__custom-tools__weather", () => {
49
40
  const msg = makeControlRequest("mcp__custom-tools__weather");
50
41
 
51
- const result = handleControlRequest(msg, stream);
42
+ const result = handleControlRequest(msg);
52
43
 
53
- expect(result).toBe(false);
54
- const response = JSON.parse(chunks[0].trim());
55
- expect(response.response.response.behavior).toBe("deny");
56
- expect(response.response.response.message).toBe(
44
+ expect(result.allowed).toBe(false);
45
+ expect(result.response.response.response.behavior).toBe("deny");
46
+ expect(result.response.response.response.message).toBe(
57
47
  TOOL_EXECUTION_DENIED_MESSAGE,
58
48
  );
59
49
  });
60
50
 
61
51
  it("denies mcp__custom-tools__deploy", () => {
62
- const { stream, chunks } = createMockStdin();
63
52
  const msg = makeControlRequest("mcp__custom-tools__deploy");
64
53
 
65
- const result = handleControlRequest(msg, stream);
54
+ const result = handleControlRequest(msg);
66
55
 
67
- expect(result).toBe(false);
68
- const response = JSON.parse(chunks[0].trim());
69
- expect(response.response.response.behavior).toBe("deny");
56
+ expect(result.allowed).toBe(false);
57
+ expect(result.response.response.response.behavior).toBe("deny");
70
58
  });
71
59
  });
72
60
 
73
61
  describe("allows user MCP tools and other tools", () => {
74
- it("allows user MCP tool mcp__database__query and returns true", () => {
75
- const { stream, chunks } = createMockStdin();
62
+ it("allows user MCP tool mcp__database__query", () => {
76
63
  const msg = makeControlRequest("mcp__database__query");
77
64
 
78
- const result = handleControlRequest(msg, stream);
65
+ const result = handleControlRequest(msg);
79
66
 
80
- expect(result).toBe(true);
81
- const response = JSON.parse(chunks[0].trim());
82
- expect(response.response.response.behavior).toBe("allow");
67
+ expect(result.allowed).toBe(true);
68
+ expect(result.response.response.response.behavior).toBe("allow");
83
69
  });
84
70
 
85
71
  it("allows built-in tool Read", () => {
86
- const { stream, chunks } = createMockStdin();
87
72
  const msg = makeControlRequest("Read");
88
73
 
89
- const result = handleControlRequest(msg, stream);
74
+ const result = handleControlRequest(msg);
90
75
 
91
- expect(result).toBe(true);
92
- const response = JSON.parse(chunks[0].trim());
93
- expect(response.response.response.behavior).toBe("allow");
76
+ expect(result.allowed).toBe(true);
77
+ expect(result.response.response.response.behavior).toBe("allow");
94
78
  });
95
79
 
96
80
  it("allows internal tools like ToolSearch", () => {
97
- const { stream, chunks } = createMockStdin();
98
81
  const msg = makeControlRequest("ToolSearch");
99
82
 
100
- const result = handleControlRequest(msg, stream);
83
+ const result = handleControlRequest(msg);
101
84
 
102
- expect(result).toBe(true);
103
- const response = JSON.parse(chunks[0].trim());
104
- expect(response.response.response.behavior).toBe("allow");
85
+ expect(result.allowed).toBe(true);
86
+ expect(result.response.response.response.behavior).toBe("allow");
105
87
  });
106
88
 
107
89
  it("allows unknown tools", () => {
108
- const { stream, chunks } = createMockStdin();
109
90
  const msg = makeControlRequest("SomeUnknownTool");
110
91
 
111
- const result = handleControlRequest(msg, stream);
92
+ const result = handleControlRequest(msg);
112
93
 
113
- expect(result).toBe(true);
114
- const response = JSON.parse(chunks[0].trim());
115
- expect(response.response.response.behavior).toBe("allow");
94
+ expect(result.allowed).toBe(true);
95
+ expect(result.response.response.response.behavior).toBe("allow");
116
96
  });
117
97
  });
118
98
 
119
99
  describe("response format", () => {
120
100
  it("includes matching request_id", () => {
121
- const { stream, chunks } = createMockStdin();
122
101
  const msg = makeControlRequest("Read", "custom-req-id-42");
123
102
 
124
- handleControlRequest(msg, stream);
103
+ const result = handleControlRequest(msg);
125
104
 
126
- const response = JSON.parse(chunks[0].trim());
127
- expect(response.request_id).toBe("custom-req-id-42");
105
+ expect(result.response.request_id).toBe("custom-req-id-42");
128
106
  });
129
107
 
130
- it("writes response as NDJSON (JSON + newline)", () => {
131
- const { stream, chunks } = createMockStdin();
108
+ it("returns a JSON-serializable response object", () => {
132
109
  const msg = makeControlRequest("Read");
133
110
 
134
- handleControlRequest(msg, stream);
111
+ const result = handleControlRequest(msg);
112
+ const serialized = JSON.stringify(result.response);
135
113
 
136
- expect(chunks[0].endsWith("\n")).toBe(true);
137
- expect(() => JSON.parse(chunks[0].trim())).not.toThrow();
114
+ expect(() => JSON.parse(serialized)).not.toThrow();
138
115
  });
139
116
 
140
117
  it("deny response includes message field", () => {
141
- const { stream, chunks } = createMockStdin();
142
118
  const msg = makeControlRequest("mcp__custom-tools__foo");
143
119
 
144
- handleControlRequest(msg, stream);
120
+ const result = handleControlRequest(msg);
145
121
 
146
- const response = JSON.parse(chunks[0].trim());
147
- expect(response.response.response.message).toBe(
122
+ expect(result.response.response.response.message).toBe(
148
123
  TOOL_EXECUTION_DENIED_MESSAGE,
149
124
  );
150
125
  });
151
126
 
152
127
  it("allow response does not include a message field", () => {
153
- const { stream, chunks } = createMockStdin();
154
128
  const msg = makeControlRequest("mcp__database__query");
155
129
 
156
- handleControlRequest(msg, stream);
130
+ const result = handleControlRequest(msg);
157
131
 
158
- const response = JSON.parse(chunks[0].trim());
159
- expect(response.response.response.message).toBeUndefined();
132
+ expect(result.response.response.response.message).toBeUndefined();
160
133
  });
161
134
  });
162
135
 
163
136
  describe("malformed input", () => {
164
- it("returns false for missing request_id", () => {
165
- const { stream } = createMockStdin();
137
+ it("returns denied decision object for missing request_id", () => {
166
138
  const spy = vi.spyOn(console, "error").mockImplementation(() => {});
167
139
 
168
140
  const msg = {
169
141
  type: "control_request",
170
142
  } as unknown as ClaudeControlRequest;
171
- const result = handleControlRequest(msg, stream);
143
+ const result = handleControlRequest(msg);
172
144
 
173
- expect(result).toBe(false);
145
+ expect(result.allowed).toBe(false);
146
+ expect(result.response.response.response.behavior).toBe("deny");
174
147
  spy.mockRestore();
175
148
  });
176
149
 
177
- it("returns false for missing request object", () => {
178
- const { stream } = createMockStdin();
150
+ it("returns denied decision object for missing request object", () => {
179
151
  const spy = vi.spyOn(console, "error").mockImplementation(() => {});
180
152
 
181
153
  const msg = {
182
154
  type: "control_request",
183
155
  request_id: "req-001",
184
156
  } as unknown as ClaudeControlRequest;
185
- const result = handleControlRequest(msg, stream);
157
+ const result = handleControlRequest(msg);
186
158
 
187
- expect(result).toBe(false);
159
+ expect(result.allowed).toBe(false);
160
+ expect(result.response.response.response.behavior).toBe("deny");
188
161
  spy.mockRestore();
189
162
  });
190
163
  });
@@ -105,8 +105,8 @@ describe("spawnClaude", () => {
105
105
  expect(args).not.toContain("--no-session-persistence");
106
106
  expect(args).toContain("--model");
107
107
  expect(args).toContain("claude-sonnet-4-5-20250929");
108
- expect(args).toContain("--permission-prompt-tool");
109
- expect(args).toContain("stdio");
108
+ expect(args).not.toContain("--permission-prompt-tool");
109
+ expect(args).not.toContain("stdio");
110
110
  });
111
111
 
112
112
  it("passes stream-json for both input-format and output-format", () => {
@@ -255,13 +255,13 @@ describe("writeUserMessage", () => {
255
255
  expect(written.endsWith("\n")).toBe(true);
256
256
  });
257
257
 
258
- it("does NOT call stdin.end()", () => {
258
+ it("calls stdin.end() after writing user message", () => {
259
259
  const mockStdin = { write: vi.fn(), end: vi.fn() };
260
260
  const proc = { stdin: mockStdin } as unknown as ChildProcess;
261
261
 
262
262
  writeUserMessage(proc, "test");
263
263
 
264
- expect(mockStdin.end).not.toHaveBeenCalled();
264
+ expect(mockStdin.end).toHaveBeenCalledTimes(1);
265
265
  });
266
266
 
267
267
  it("sends string content in NDJSON when given string", () => {
@@ -420,13 +420,11 @@ describe("CLI flags", () => {
420
420
  expect(args).not.toContain("dontAsk");
421
421
  });
422
422
 
423
- it("spawnClaude includes --permission-prompt-tool followed by stdio in args", () => {
423
+ it("spawnClaude does NOT include --permission-prompt-tool in args", () => {
424
424
  spawnClaude("claude-sonnet-4-5-20250929");
425
425
  const args = (spawn as any).mock.calls[0][1] as string[];
426
426
 
427
- expect(args).toContain("--permission-prompt-tool");
428
- const idx = args.indexOf("--permission-prompt-tool");
429
- expect(args[idx + 1]).toBe("stdio");
427
+ expect(args).not.toContain("--permission-prompt-tool");
430
428
  });
431
429
  });
432
430
 
@@ -472,7 +470,7 @@ describe("mcp-config flag", () => {
472
470
  expect(args).toContain("--append-system-prompt");
473
471
  expect(args).toContain("--effort");
474
472
  expect(args).not.toContain("--mcp-config");
475
- expect(args).toContain("--permission-prompt-tool");
473
+ expect(args).not.toContain("--permission-prompt-tool");
476
474
  });
477
475
  });
478
476
 
@@ -238,6 +238,79 @@ describe("streamViaCli", () => {
238
238
  expect(parsed.message.role).toBe("user");
239
239
  });
240
240
 
241
+ describe("stdin close behavior", () => {
242
+ it("stdin.end() is called after writeUserMessage", async () => {
243
+ const model = mockModels[0] as any;
244
+ const context = {
245
+ messages: [{ role: "user", content: "Hello" }],
246
+ };
247
+
248
+ streamViaCli(model, context);
249
+ await vi.advanceTimersByTimeAsync(0);
250
+
251
+ const proc = (spawn as any).mock.results[0].value;
252
+ expect(proc.stdin.end).toHaveBeenCalledTimes(1);
253
+ expect(proc.stdin.write.mock.invocationCallOrder[0]).toBeLessThan(
254
+ proc.stdin.end.mock.invocationCallOrder[0],
255
+ );
256
+ });
257
+
258
+ it("unexpected control_request on stdout is logged and ignored", async () => {
259
+ process.env.PI_CLAUDE_CLI_DEBUG = "1";
260
+ const model = mockModels[0] as any;
261
+ const context = {
262
+ messages: [{ role: "user", content: "Hello" }],
263
+ };
264
+ const errorSpy = vi.spyOn(console, "error");
265
+
266
+ streamViaCli(model, context);
267
+ await vi.advanceTimersByTimeAsync(0);
268
+
269
+ const proc = (spawn as any).mock.results[0].value;
270
+
271
+ const lines = [
272
+ JSON.stringify({
273
+ type: "control_request",
274
+ request_id: "req_123",
275
+ request: {
276
+ subtype: "can_use_tool",
277
+ tool_name: "Read",
278
+ input: { file_path: "/foo.ts" },
279
+ },
280
+ }),
281
+ JSON.stringify({
282
+ type: "stream_event",
283
+ event: {
284
+ type: "message_start",
285
+ message: { usage: { input_tokens: 10, output_tokens: 0 } },
286
+ },
287
+ }),
288
+ JSON.stringify({
289
+ type: "stream_event",
290
+ event: { type: "message_stop" },
291
+ }),
292
+ JSON.stringify({
293
+ type: "result",
294
+ subtype: "success",
295
+ result: "ok",
296
+ }),
297
+ ];
298
+
299
+ for (const line of lines) {
300
+ proc.stdout.write(line + "\n");
301
+ }
302
+ proc.stdout.end();
303
+ await vi.advanceTimersByTimeAsync(100);
304
+
305
+ const mockStream = MockAssistantMessageEventStream.mock.instances[0];
306
+ expect(mockStream._events.some((e: any) => e.type === "done")).toBe(true);
307
+ expect(proc.stdin.write).toHaveBeenCalledTimes(1);
308
+ expect(errorSpy).toHaveBeenCalledWith(
309
+ expect.stringContaining("unexpected control_request received"),
310
+ );
311
+ });
312
+ });
313
+
241
314
  it("handles full text streaming sequence via NDJSON", async () => {
242
315
  const model = mockModels[0] as any;
243
316
  const context = {
@@ -406,74 +479,7 @@ describe("streamViaCli", () => {
406
479
  await vi.advanceTimersByTimeAsync(100);
407
480
  });
408
481
 
409
- it("routes control_request through handleControlRequest and writes response to stdin", async () => {
410
- const model = mockModels[0] as any;
411
- const context = {
412
- messages: [{ role: "user", content: "Hello" }],
413
- };
414
-
415
- streamViaCli(model, context);
416
- await vi.advanceTimersByTimeAsync(0);
417
-
418
- const proc = (spawn as any).mock.results[0].value;
419
-
420
- // Clear initial stdin.write (user message)
421
- proc.stdin.write.mockClear();
422
-
423
- // Simulate a control_request NDJSON line arriving on stdout
424
- const controlRequest = JSON.stringify({
425
- type: "control_request",
426
- request_id: "req_123",
427
- request: {
428
- subtype: "can_use_tool",
429
- tool_name: "Read",
430
- input: { file_path: "/foo.ts" },
431
- },
432
- });
433
-
434
- // Then follow with stream events and result so stream completes
435
- const lines = [
436
- controlRequest,
437
- JSON.stringify({
438
- type: "stream_event",
439
- event: {
440
- type: "message_start",
441
- message: { usage: { input_tokens: 10, output_tokens: 0 } },
442
- },
443
- }),
444
- JSON.stringify({
445
- type: "stream_event",
446
- event: { type: "message_stop" },
447
- }),
448
- JSON.stringify({
449
- type: "result",
450
- subtype: "success",
451
- result: "ok",
452
- }),
453
- ];
454
-
455
- for (const line of lines) {
456
- proc.stdout.write(line + "\n");
457
- }
458
- proc.stdout.end();
459
- await vi.advanceTimersByTimeAsync(100);
460
482
 
461
- // Verify control_response was written to stdin
462
- expect(proc.stdin.write).toHaveBeenCalled();
463
- const stdinCalls = proc.stdin.write.mock.calls;
464
- const controlResponse = stdinCalls.find((call: any[]) => {
465
- try {
466
- const parsed = JSON.parse(call[0]);
467
- return parsed.type === "control_response";
468
- } catch {
469
- return false;
470
- }
471
- });
472
- expect(controlResponse).toBeDefined();
473
- const parsed = JSON.parse(controlResponse[0]);
474
- expect(parsed.request_id).toBe("req_123");
475
- expect(parsed.response.response.behavior).toBe("allow");
476
- });
477
483
 
478
484
  describe("thinking effort wiring", () => {
479
485
  it("passes effort to spawnClaude when options.reasoning is provided on non-Opus model", async () => {
@@ -550,88 +556,7 @@ describe("streamViaCli", () => {
550
556
  });
551
557
  });
552
558
 
553
- it("stream events continue flowing after control_request handling", async () => {
554
- const model = mockModels[0] as any;
555
- const context = {
556
- messages: [{ role: "user", content: "Hello" }],
557
- };
558
-
559
- streamViaCli(model, context);
560
- await vi.advanceTimersByTimeAsync(0);
561
-
562
- const proc = (spawn as any).mock.results[0].value;
563
559
 
564
- // control_request followed by normal stream events
565
- const lines = [
566
- JSON.stringify({
567
- type: "control_request",
568
- request_id: "req_456",
569
- request: {
570
- subtype: "can_use_tool",
571
- tool_name: "Bash",
572
- input: { command: "ls" },
573
- },
574
- }),
575
- JSON.stringify({
576
- type: "stream_event",
577
- event: {
578
- type: "message_start",
579
- message: { usage: { input_tokens: 10, output_tokens: 0 } },
580
- },
581
- }),
582
- JSON.stringify({
583
- type: "stream_event",
584
- event: {
585
- type: "content_block_start",
586
- index: 0,
587
- content_block: { type: "text", text: "" },
588
- },
589
- }),
590
- JSON.stringify({
591
- type: "stream_event",
592
- event: {
593
- type: "content_block_delta",
594
- index: 0,
595
- delta: { type: "text_delta", text: "After control" },
596
- },
597
- }),
598
- JSON.stringify({
599
- type: "stream_event",
600
- event: { type: "content_block_stop", index: 0 },
601
- }),
602
- JSON.stringify({
603
- type: "stream_event",
604
- event: {
605
- type: "message_delta",
606
- delta: { stop_reason: "end_turn" },
607
- usage: { output_tokens: 3 },
608
- },
609
- }),
610
- JSON.stringify({
611
- type: "stream_event",
612
- event: { type: "message_stop" },
613
- }),
614
- JSON.stringify({
615
- type: "result",
616
- subtype: "success",
617
- result: "ok",
618
- }),
619
- ];
620
-
621
- for (const line of lines) {
622
- proc.stdout.write(line + "\n");
623
- }
624
- proc.stdout.end();
625
- await vi.advanceTimersByTimeAsync(100);
626
-
627
- // Verify the stream still received text events after the control_request
628
- const mockStream = MockAssistantMessageEventStream.mock.instances[0];
629
- const events = mockStream._events;
630
- const eventTypes = events.map((e: any) => e.type);
631
- expect(eventTypes).toContain("text_start");
632
- expect(eventTypes).toContain("text_delta");
633
- expect(eventTypes).toContain("done");
634
- });
635
560
 
636
561
  describe("mcpConfigPath passthrough", () => {
637
562
  it("passes mcpConfigPath to spawnClaude options", async () => {
@@ -7,10 +7,12 @@ const TEMP_HOME_PREFIX = "fn-test-home-";
7
7
  describe("test isolation setup", () => {
8
8
  it("overrides process.env.HOME to a temp directory", () => {
9
9
  const home = process.env.HOME;
10
+ const userProfile = process.env.USERPROFILE;
10
11
 
11
12
  expect(home).toBeDefined();
12
13
  expect(home).toContain(tmpdir());
13
14
  expect(home).toContain(TEMP_HOME_PREFIX);
15
+ expect(userProfile).toBe(home);
14
16
  });
15
17
 
16
18
  it("resolves homedir() to the temp HOME", () => {
@@ -4,3 +4,11 @@ import { join } from "node:path";
4
4
 
5
5
  const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
6
6
  process.env.HOME = tempHome;
7
+ process.env.USERPROFILE = tempHome;
8
+ if (process.platform === "win32") {
9
+ const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
10
+ if (match) {
11
+ process.env.HOMEDRIVE = match[1];
12
+ process.env.HOMEPATH = match[2] || "\\";
13
+ }
14
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Control protocol handler for Claude CLI stream-json communication.
3
3
  *
4
- * Processes control_request messages from Claude CLI stdout and writes
5
- * control_response messages to stdin.
4
+ * Processes control_request messages from Claude CLI stdout and returns a
5
+ * control_response decision object.
6
6
  *
7
7
  * - Custom MCP tools (mcp__custom-tools__*): DENIED — pi executes these
8
8
  * - Everything else (user MCP tools, internal tools): ALLOWED — Claude handles
@@ -35,18 +35,33 @@ interface ControlResponse {
35
35
  * Denies custom MCP tools (mcp__custom-tools__*) so pi can execute them.
36
36
  * Allows everything else (user MCP tools, internal Claude tools).
37
37
  *
38
- * @returns true if the tool was allowed, false if denied
38
+ * Pure function: no side effects and no stdin writes.
39
+ *
40
+ * @returns Decision payload with allow/deny result and serialized response object
39
41
  */
40
42
  export function handleControlRequest(
41
43
  msg: ClaudeControlRequest,
42
- stdin: NodeJS.WritableStream,
43
- ): boolean {
44
+ ): { allowed: boolean; response: ControlResponse } {
44
45
  if (!msg.request_id || !msg.request) {
45
46
  console.error(
46
47
  "[pi-claude-cli] Malformed control_request: missing request_id or request object",
47
48
  msg,
48
49
  );
49
- return false;
50
+
51
+ return {
52
+ allowed: false,
53
+ response: {
54
+ type: "control_response",
55
+ request_id: msg.request_id ?? "",
56
+ response: {
57
+ subtype: "success",
58
+ response: {
59
+ behavior: "deny",
60
+ message: TOOL_EXECUTION_DENIED_MESSAGE,
61
+ },
62
+ },
63
+ },
64
+ };
50
65
  }
51
66
 
52
67
  const toolName = msg.request?.tool_name ?? "";
@@ -63,6 +78,5 @@ export function handleControlRequest(
63
78
  },
64
79
  };
65
80
 
66
- stdin.write(JSON.stringify(response) + "\n");
67
- return !isCustomTool;
81
+ return { allowed: !isCustomTool, response };
68
82
  }
@@ -11,6 +11,11 @@ import { writeFileSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
13
 
14
+ function debugLog(message: string): void {
15
+ if (process.env.PI_CLAUDE_CLI_DEBUG !== "1") return;
16
+ console.error(`[pi-claude-cli] ${message}`);
17
+ }
18
+
14
19
  /**
15
20
  * Spawn a Claude CLI subprocess with all required flags for stream-json communication.
16
21
  *
@@ -39,8 +44,6 @@ export function buildClaudeSpawnArgs(
39
44
  "--include-partial-messages",
40
45
  "--model",
41
46
  modelId,
42
- "--permission-prompt-tool",
43
- "stdio",
44
47
  ];
45
48
 
46
49
  if (options?.resumeSessionId) {
@@ -97,6 +100,8 @@ export function spawnClaude(
97
100
  cwd: options?.cwd ?? process.cwd(),
98
101
  });
99
102
 
103
+ debugLog(`spawnClaude: pid=${proc.pid} model=${modelId}`);
104
+
100
105
  return proc as ChildProcess;
101
106
  }
102
107
 
@@ -114,7 +119,8 @@ export function cleanupSystemPromptFile(): void {
114
119
 
115
120
  /**
116
121
  * Write a user message to the subprocess stdin as NDJSON.
117
- * Does NOT call stdin.end() -- stdin stays open for control_response in Phase 2.
122
+ * Calls stdin.end() after writing the user message to signal EOF, allowing
123
+ * Claude CLI to process the input and start generating.
118
124
  *
119
125
  * Accepts both string (text-only prompt) and array (ContentBlock[] with images)
120
126
  * content. JSON.stringify handles both natively. The stream-json protocol
@@ -135,6 +141,7 @@ export function writeUserMessage(
135
141
  },
136
142
  };
137
143
  proc.stdin!.write(JSON.stringify(message) + "\n");
144
+ proc.stdin!.end();
138
145
  }
139
146
 
140
147
  /**
@@ -615,7 +615,8 @@ function resolveAgentsMdPath(cwd: string): string | undefined {
615
615
  }
616
616
 
617
617
  // Fall back to global path
618
- const globalPath = join(homedir(), ".pi", "agent", "AGENTS.md");
618
+ const globalHome = process.env.HOME || process.env.USERPROFILE || homedir();
619
+ const globalPath = join(globalHome, ".pi", "agent", "AGENTS.md");
619
620
  if (existsSync(globalPath)) return globalPath;
620
621
 
621
622
  return undefined;