@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.
- package/dist/bin.js +1714 -840
- package/dist/client/assets/{AgentDetailView-B6wfIQ9j.js → AgentDetailView-C_DD03d6.js} +1 -1
- package/dist/client/assets/{AgentsView-Fcym0XCw.js → AgentsView-CsEX_Fsi.js} +3 -3
- package/dist/client/assets/ChatView-DhudD-jT.js +1 -0
- package/dist/client/assets/{DevServerView-CmMS4D6V.js → DevServerView-CNItMoga.js} +1 -1
- package/dist/client/assets/DirectoryPicker-BqcVbDZX.js +1 -0
- package/dist/client/assets/{DocumentsView-C4HDkN_0.js → DocumentsView-zpHkjZdf.js} +1 -1
- package/dist/client/assets/{InsightsView-Egu71gmh.css → InsightsView-6LHF7OdE.css} +1 -1
- package/dist/client/assets/InsightsView-CgguV1au.js +11 -0
- package/dist/client/assets/{MemoryView-DPYGW09y.js → MemoryView-BWXP8uGT.js} +1 -1
- package/dist/client/assets/{NodesView-DsM-c8RF.js → NodesView-DP_O4ae0.js} +1 -1
- package/dist/client/assets/{PiExtensionsManager-DHgMjjRE.js → PiExtensionsManager-DGEPbF0y.js} +3 -3
- package/dist/client/assets/{PluginManager-N-7Sw_tE.js → PluginManager-4TehPpcf.js} +1 -1
- package/dist/client/assets/{RoadmapsView-BgX79uYM.js → RoadmapsView-DNaToPqN.js} +2 -2
- package/dist/client/assets/SettingsModal-BluRnKGd.js +31 -0
- package/dist/client/assets/{SettingsModal-BIKEMPwb.js → SettingsModal-C-RjolQ5.js} +1 -1
- package/dist/client/assets/SettingsModal-C7gPLBaR.css +1 -0
- package/dist/client/assets/{SetupWizardModal-D2m0i9Io.js → SetupWizardModal-BQoo_AvX.js} +1 -1
- package/dist/client/assets/{SkillsView-Eb1Mngnt.js → SkillsView-AS8Cr_Md.js} +1 -1
- package/dist/client/assets/{TodoView-BN8FQYyp.js → TodoView-nWOpOg3R.js} +2 -2
- package/dist/client/assets/{folder-open-BqZBHfoZ.js → folder-open-D0LfE0ZP.js} +1 -1
- package/dist/client/assets/index-D6xr8Oa2.css +1 -0
- package/dist/client/assets/index-heCcln3Z.js +656 -0
- package/dist/client/assets/{list-checks-D2EURsP0.js → list-checks-D1faMe1o.js} +1 -1
- package/dist/client/assets/{star-CNQlAD8p.js → star-B_uA5YGG.js} +1 -1
- package/dist/client/assets/{upload-uoxlYkig.js → upload-DPQ3hWf0.js} +1 -1
- package/dist/client/assets/{users-BLvidusm.js → users-CEcYlrZO.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/version.json +1 -1
- package/dist/extension.js +1033 -372
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/pi-claude-cli/src/__tests__/control-handler.test.ts +39 -66
- package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +7 -9
- package/dist/pi-claude-cli/src/__tests__/provider.test.ts +73 -148
- package/dist/pi-claude-cli/src/__tests__/setup-test-isolation.test.ts +2 -0
- package/dist/pi-claude-cli/src/__tests__/setup-test-isolation.ts +8 -0
- package/dist/pi-claude-cli/src/control-handler.ts +22 -8
- package/dist/pi-claude-cli/src/process-manager.ts +10 -3
- package/dist/pi-claude-cli/src/prompt-builder.ts +2 -1
- package/dist/pi-claude-cli/src/provider.ts +10 -2
- package/package.json +1 -1
- package/dist/client/assets/ChatView-DEckS3f3.js +0 -1
- package/dist/client/assets/DirectoryPicker-CQtE-YyA.js +0 -1
- package/dist/client/assets/InsightsView-oNQ7h5e8.js +0 -11
- package/dist/client/assets/SettingsModal-C4EwtN6K.js +0 -31
- package/dist/client/assets/SettingsModal-D5hLoLXp.css +0 -1
- package/dist/client/assets/index-D2fXOwWF.css +0 -1
- 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.
|
|
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
|
|
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
|
|
42
|
+
const result = handleControlRequest(msg);
|
|
52
43
|
|
|
53
|
-
expect(result).toBe(false);
|
|
54
|
-
|
|
55
|
-
expect(response.response.response.
|
|
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
|
|
54
|
+
const result = handleControlRequest(msg);
|
|
66
55
|
|
|
67
|
-
expect(result).toBe(false);
|
|
68
|
-
|
|
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
|
|
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
|
|
65
|
+
const result = handleControlRequest(msg);
|
|
79
66
|
|
|
80
|
-
expect(result).toBe(true);
|
|
81
|
-
|
|
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
|
|
74
|
+
const result = handleControlRequest(msg);
|
|
90
75
|
|
|
91
|
-
expect(result).toBe(true);
|
|
92
|
-
|
|
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
|
|
83
|
+
const result = handleControlRequest(msg);
|
|
101
84
|
|
|
102
|
-
expect(result).toBe(true);
|
|
103
|
-
|
|
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
|
|
92
|
+
const result = handleControlRequest(msg);
|
|
112
93
|
|
|
113
|
-
expect(result).toBe(true);
|
|
114
|
-
|
|
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
|
|
103
|
+
const result = handleControlRequest(msg);
|
|
125
104
|
|
|
126
|
-
|
|
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("
|
|
131
|
-
const { stream, chunks } = createMockStdin();
|
|
108
|
+
it("returns a JSON-serializable response object", () => {
|
|
132
109
|
const msg = makeControlRequest("Read");
|
|
133
110
|
|
|
134
|
-
handleControlRequest(msg
|
|
111
|
+
const result = handleControlRequest(msg);
|
|
112
|
+
const serialized = JSON.stringify(result.response);
|
|
135
113
|
|
|
136
|
-
expect(
|
|
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
|
|
120
|
+
const result = handleControlRequest(msg);
|
|
145
121
|
|
|
146
|
-
|
|
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
|
|
130
|
+
const result = handleControlRequest(msg);
|
|
157
131
|
|
|
158
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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).
|
|
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
|
|
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
|
|
5
|
-
* control_response
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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;
|