@runfusion/fusion 0.1.2 → 0.2.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 (69) hide show
  1. package/README.md +2 -0
  2. package/dist/bin.js +4055 -1755
  3. package/dist/client/assets/AgentDetailView-CDZED6Dy.css +1 -0
  4. package/dist/client/assets/AgentDetailView-zycSdnO8.js +28 -0
  5. package/dist/client/assets/AgentsView-DoQkkDLf.css +1 -0
  6. package/dist/client/assets/AgentsView-pO7WiBS5.js +522 -0
  7. package/dist/client/assets/ChatView-BOd-sxbT.js +1 -0
  8. package/dist/client/assets/DevServerView-09GQf34f.js +11 -0
  9. package/dist/client/assets/DevServerView-ZeBGQkLI.css +1 -0
  10. package/dist/client/assets/DirectoryPicker-CcdN1Zs7.js +1 -0
  11. package/dist/client/assets/DocumentsView-CS8aiwtz.js +1 -0
  12. package/dist/client/assets/DocumentsView-Co9to4Zp.css +1 -0
  13. package/dist/client/assets/InsightsView-Bu9Cv8Ol.js +11 -0
  14. package/dist/client/assets/InsightsView-Egu71gmh.css +1 -0
  15. package/dist/client/assets/MemoryView-CtqgDtV9.js +2 -0
  16. package/dist/client/assets/MemoryView-DhinauGs.css +1 -0
  17. package/dist/client/assets/NodesView-BInPcedy.js +14 -0
  18. package/dist/client/assets/NodesView-DlQZHGXA.css +1 -0
  19. package/dist/client/assets/PiExtensionsManager-COxkYM2m.js +11 -0
  20. package/dist/client/assets/PiExtensionsManager-CPgmJgDk.css +1 -0
  21. package/dist/client/assets/PluginManager-CXUWZBOc.js +1 -0
  22. package/dist/client/assets/PluginManager-D64RIzmL.css +1 -0
  23. package/dist/client/assets/RoadmapsView-BOYnyMCh.css +1 -0
  24. package/dist/client/assets/RoadmapsView-BbCexaoi.js +6 -0
  25. package/dist/client/assets/SetupWizardModal-Cakxqkad.js +1 -0
  26. package/dist/client/assets/SkillsView-Cytf009Z.css +1 -0
  27. package/dist/client/assets/SkillsView-D3iqYCVf.js +1 -0
  28. package/dist/client/assets/folder-open-kO5Hsk66.js +6 -0
  29. package/dist/client/assets/index-BiSuUXCa.css +1 -0
  30. package/dist/client/assets/index-y194HxzU.js +644 -0
  31. package/dist/client/assets/upload-DHBQat92.js +6 -0
  32. package/dist/client/index.html +2 -2
  33. package/dist/client/sw.js +45 -1
  34. package/dist/client/theme-data.css +109 -0
  35. package/dist/extension.js +969 -408
  36. package/dist/pi-claude-cli/index.ts +131 -0
  37. package/dist/pi-claude-cli/package.json +39 -0
  38. package/dist/pi-claude-cli/src/__tests__/control-handler.test.ts +191 -0
  39. package/dist/pi-claude-cli/src/__tests__/event-bridge.test.ts +1244 -0
  40. package/dist/pi-claude-cli/src/__tests__/mcp-config.test.ts +272 -0
  41. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +619 -0
  42. package/dist/pi-claude-cli/src/__tests__/prompt-builder.test.ts +1067 -0
  43. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +1902 -0
  44. package/dist/pi-claude-cli/src/__tests__/stream-parser.test.ts +188 -0
  45. package/dist/pi-claude-cli/src/__tests__/thinking-config.test.ts +141 -0
  46. package/dist/pi-claude-cli/src/__tests__/tool-mapping.test.ts +252 -0
  47. package/dist/pi-claude-cli/src/control-handler.ts +68 -0
  48. package/dist/pi-claude-cli/src/event-bridge.ts +386 -0
  49. package/dist/pi-claude-cli/src/mcp-config.ts +111 -0
  50. package/dist/pi-claude-cli/src/mcp-schema-server.cjs +49 -0
  51. package/dist/pi-claude-cli/src/process-manager.ts +218 -0
  52. package/dist/pi-claude-cli/src/prompt-builder.ts +536 -0
  53. package/dist/pi-claude-cli/src/provider.ts +354 -0
  54. package/dist/pi-claude-cli/src/stream-parser.ts +37 -0
  55. package/dist/pi-claude-cli/src/thinking-config.ts +83 -0
  56. package/dist/pi-claude-cli/src/tool-mapping.ts +147 -0
  57. package/dist/pi-claude-cli/src/types.ts +87 -0
  58. package/package.json +11 -4
  59. package/skill/fusion/SKILL.md +5 -3
  60. package/skill/fusion/references/cli-commands.md +22 -22
  61. package/skill/fusion/references/extension-tools.md +3 -1
  62. package/skill/fusion/references/fusion-capabilities.md +28 -35
  63. package/skill/fusion/references/task-structure.md +4 -4
  64. package/skill/fusion/workflows/dashboard-cli.md +6 -6
  65. package/skill/fusion/workflows/specifications.md +5 -3
  66. package/skill/fusion/workflows/task-lifecycle.md +1 -1
  67. package/skill/fusion/workflows/task-management.md +3 -1
  68. package/dist/client/assets/index-Djv5vKo0.css +0 -1
  69. package/dist/client/assets/index-zfXYuUXG.js +0 -1241
@@ -0,0 +1,386 @@
1
+ import type { ClaudeApiEvent, TrackedContentBlock } from "./types";
2
+ import { calculateCost } from "@mariozechner/pi-ai";
3
+ import type {
4
+ Api,
5
+ AssistantMessage,
6
+ AssistantMessageEventStream,
7
+ Model,
8
+ TextContent,
9
+ ThinkingContent,
10
+ ToolCall,
11
+ } from "@mariozechner/pi-ai";
12
+ import {
13
+ mapClaudeToolNameToPi,
14
+ translateClaudeArgsToPi,
15
+ isPiKnownClaudeTool,
16
+ } from "./tool-mapping.js";
17
+
18
+ /**
19
+ * Extended tracking for tool_use content blocks during streaming.
20
+ * Stores the Claude tool name for argument translation at block_stop.
21
+ */
22
+ interface TrackedToolBlock {
23
+ type: "tool_use";
24
+ index: number;
25
+ id: string;
26
+ name: string; // Already mapped to pi name
27
+ claudeName: string; // Original Claude name for arg translation
28
+ arguments: Record<string, unknown>;
29
+ partialJson: string;
30
+ }
31
+
32
+ /** Union of tracked block types for the blocks array. */
33
+ type TrackedBlock = TrackedContentBlock | TrackedToolBlock;
34
+
35
+ /**
36
+ * The event bridge interface returned by createEventBridge.
37
+ * handleEvent processes each Claude API streaming event and pushes
38
+ * the appropriate pi events to the stream.
39
+ * getOutput returns the accumulated AssistantMessage.
40
+ */
41
+ export interface EventBridge {
42
+ handleEvent(event: ClaudeApiEvent): void;
43
+ getOutput(): AssistantMessage;
44
+ }
45
+
46
+ /**
47
+ * Map Claude API stop reasons to pi's stop reason format.
48
+ */
49
+ function mapStopReason(
50
+ reason: string | undefined,
51
+ ): "stop" | "length" | "toolUse" {
52
+ switch (reason) {
53
+ case "tool_use":
54
+ return "toolUse";
55
+ case "max_tokens":
56
+ return "length";
57
+ case "end_turn":
58
+ default:
59
+ return "stop";
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Create an event bridge that translates Claude API streaming events
65
+ * into pi's AssistantMessageEventStream events.
66
+ *
67
+ * The bridge maintains internal state to track content blocks and
68
+ * accumulate the final AssistantMessage. It handles:
69
+ * - text content blocks (start/delta/stop -> text_start/text_delta/text_end)
70
+ * - message lifecycle (message_start for usage, message_delta for stop reason, message_stop for done)
71
+ * - unsupported block types (tool_use, thinking) with warnings
72
+ */
73
+ export function createEventBridge(
74
+ stream: AssistantMessageEventStream,
75
+ model: Model<Api>,
76
+ ): EventBridge {
77
+ // Tracked content blocks indexed by Claude's content_block index
78
+ const blocks: TrackedBlock[] = [];
79
+
80
+ // The accumulated output message
81
+ const output: AssistantMessage = {
82
+ role: "assistant" as const,
83
+ content: [] as (TextContent | ThinkingContent | ToolCall)[],
84
+ api: "pi-claude-cli",
85
+ provider: model.provider,
86
+ model: model.id,
87
+ usage: {
88
+ input: 0,
89
+ output: 0,
90
+ cacheRead: 0,
91
+ cacheWrite: 0,
92
+ totalTokens: 0,
93
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
94
+ },
95
+ stopReason: "stop" as const,
96
+ timestamp: Date.now(),
97
+ };
98
+
99
+ let started = false;
100
+
101
+ function handleEvent(event: ClaudeApiEvent): void {
102
+ // Emit start event on first message — tells pi to begin incremental rendering
103
+ if (!started) {
104
+ stream.push({ type: "start", partial: output });
105
+ started = true;
106
+ }
107
+
108
+ switch (event.type) {
109
+ case "message_start":
110
+ handleMessageStart(event);
111
+ break;
112
+ case "content_block_start":
113
+ handleContentBlockStart(event);
114
+ break;
115
+ case "content_block_delta":
116
+ handleContentBlockDelta(event);
117
+ break;
118
+ case "content_block_stop":
119
+ handleContentBlockStop(event);
120
+ break;
121
+ case "message_delta":
122
+ handleMessageDelta(event);
123
+ break;
124
+ case "message_stop":
125
+ handleMessageStop();
126
+ break;
127
+ // Unknown event types are silently ignored
128
+ }
129
+ }
130
+
131
+ function handleMessageStart(event: ClaudeApiEvent): void {
132
+ const usage = event.message?.usage;
133
+ if (usage) {
134
+ output.usage.input = usage.input_tokens ?? 0;
135
+ output.usage.output = usage.output_tokens ?? 0;
136
+ output.usage.cacheRead = usage.cache_read_input_tokens ?? 0;
137
+ output.usage.cacheWrite = usage.cache_creation_input_tokens ?? 0;
138
+ output.usage.totalTokens =
139
+ output.usage.input +
140
+ output.usage.output +
141
+ output.usage.cacheRead +
142
+ output.usage.cacheWrite;
143
+ calculateCost(model, output.usage);
144
+ }
145
+ }
146
+
147
+ function handleContentBlockStart(event: ClaudeApiEvent): void {
148
+ const blockType = event.content_block?.type;
149
+
150
+ if (blockType === "text") {
151
+ const block: TrackedContentBlock = {
152
+ type: "text",
153
+ text: "",
154
+ index: event.index ?? 0,
155
+ };
156
+ blocks.push(block);
157
+ output.content.push({ type: "text" as const, text: "" });
158
+
159
+ stream.push({
160
+ type: "text_start",
161
+ contentIndex: output.content.length - 1,
162
+ partial: output,
163
+ });
164
+ } else if (blockType === "thinking") {
165
+ const block: TrackedContentBlock = {
166
+ type: "thinking",
167
+ text: "",
168
+ index: event.index ?? 0,
169
+ };
170
+ blocks.push(block);
171
+ output.content.push({
172
+ type: "thinking" as const,
173
+ thinking: "",
174
+ thinkingSignature: "",
175
+ });
176
+
177
+ stream.push({
178
+ type: "thinking_start",
179
+ contentIndex: output.content.length - 1,
180
+ partial: output,
181
+ });
182
+ } else if (blockType === "tool_use") {
183
+ const claudeName = event.content_block!.name!;
184
+
185
+ // Skip internal Claude Code tools (ToolSearch, Task, Agent, etc.)
186
+ // that pi cannot execute — only emit pi-known tools
187
+ if (!isPiKnownClaudeTool(claudeName)) {
188
+ return;
189
+ }
190
+
191
+ const piName = mapClaudeToolNameToPi(claudeName);
192
+ const id = event.content_block!.id!;
193
+
194
+ const block: TrackedToolBlock = {
195
+ type: "tool_use",
196
+ index: event.index ?? 0,
197
+ id,
198
+ name: piName,
199
+ claudeName,
200
+ arguments: {},
201
+ partialJson: "",
202
+ };
203
+ blocks.push(block);
204
+ output.content.push({
205
+ type: "toolCall" as const,
206
+ id,
207
+ name: piName,
208
+ arguments: {},
209
+ } as ToolCall);
210
+
211
+ stream.push({
212
+ type: "toolcall_start",
213
+ contentIndex: output.content.length - 1,
214
+ partial: output,
215
+ });
216
+ }
217
+ // Unknown block types silently ignored
218
+ }
219
+
220
+ function handleContentBlockDelta(event: ClaudeApiEvent): void {
221
+ const deltaType = event.delta?.type;
222
+
223
+ if (deltaType === "text_delta" && event.delta!.text != null) {
224
+ const idx = blocks.findIndex((b) => b.index === event.index);
225
+ if (idx === -1) return;
226
+
227
+ const block = blocks[idx];
228
+ if (block.type === "text") {
229
+ block.text += event.delta!.text;
230
+ const contentBlock = output.content[idx] as TextContent;
231
+ contentBlock.text = block.text;
232
+
233
+ stream.push({
234
+ type: "text_delta",
235
+ contentIndex: idx,
236
+ delta: event.delta!.text,
237
+ partial: output,
238
+ });
239
+ }
240
+ } else if (
241
+ deltaType === "thinking_delta" &&
242
+ event.delta!.thinking != null
243
+ ) {
244
+ const idx = blocks.findIndex((b) => b.index === event.index);
245
+ if (idx === -1) return;
246
+
247
+ const block = blocks[idx];
248
+ if (block.type === "thinking") {
249
+ block.text += event.delta!.thinking;
250
+ const contentBlock = output.content[idx] as ThinkingContent;
251
+ contentBlock.thinking = block.text;
252
+
253
+ stream.push({
254
+ type: "thinking_delta",
255
+ contentIndex: idx,
256
+ delta: event.delta!.thinking,
257
+ partial: output,
258
+ });
259
+ }
260
+ } else if (
261
+ deltaType === "input_json_delta" &&
262
+ event.delta!.partial_json != null
263
+ ) {
264
+ const idx = blocks.findIndex((b) => b.index === event.index);
265
+ if (idx === -1) return;
266
+
267
+ const block = blocks[idx];
268
+ if (block.type === "tool_use") {
269
+ block.partialJson += event.delta!.partial_json;
270
+
271
+ // Try to parse accumulated JSON -- on success update args, on failure keep previous
272
+ try {
273
+ block.arguments = JSON.parse(block.partialJson);
274
+ (output.content[idx] as ToolCall).arguments = block.arguments as Record<string, unknown>;
275
+ } catch {
276
+ // Partial JSON not yet parseable -- keep previous arguments
277
+ }
278
+
279
+ stream.push({
280
+ type: "toolcall_delta",
281
+ contentIndex: idx,
282
+ delta: event.delta!.partial_json,
283
+ partial: output,
284
+ });
285
+ }
286
+ } else if (
287
+ deltaType === "signature_delta" &&
288
+ event.delta!.signature != null
289
+ ) {
290
+ // Accumulate signature on the thinking block
291
+ const idx = blocks.findIndex((b) => b.index === event.index);
292
+ if (idx === -1) return;
293
+
294
+ const block = blocks[idx];
295
+ if (block.type === "thinking") {
296
+ const contentBlock = output.content[idx] as ThinkingContent;
297
+ contentBlock.thinkingSignature =
298
+ (contentBlock.thinkingSignature || "") + event.delta!.signature;
299
+ }
300
+ }
301
+ }
302
+
303
+ function handleContentBlockStop(event: ClaudeApiEvent): void {
304
+ const idx = blocks.findIndex((b) => b.index === event.index);
305
+ if (idx === -1) return;
306
+
307
+ const block = blocks[idx];
308
+ // Clean up the tracking index from the block (no longer needed)
309
+ delete (block as unknown as Record<string, unknown>).index;
310
+
311
+ if (block.type === "text") {
312
+ stream.push({
313
+ type: "text_end",
314
+ contentIndex: idx,
315
+ content: block.text,
316
+ partial: output,
317
+ });
318
+ } else if (block.type === "thinking") {
319
+ stream.push({
320
+ type: "thinking_end",
321
+ contentIndex: idx,
322
+ content: block.text,
323
+ partial: output,
324
+ });
325
+ } else if (block.type === "tool_use") {
326
+ // Final JSON parse with fallback to raw string
327
+ 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;
333
+ }
334
+
335
+ // Update output.content with final arguments
336
+ const contentBlock = output.content[idx] as ToolCall;
337
+ // ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
338
+ // intentionally emit a raw string when JSON parse fails completely.
339
+ // Pi handles string arguments gracefully at runtime.
340
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- finalArgs may be a raw string when JSON parse fails; pi-ai handles it at runtime
341
+ (contentBlock as any).arguments = finalArgs;
342
+ const toolCall = {
343
+ type: "toolCall" as const,
344
+ id: block.id,
345
+ name: block.name,
346
+ arguments: finalArgs,
347
+ } as ToolCall;
348
+
349
+ stream.push({
350
+ type: "toolcall_end",
351
+ contentIndex: idx,
352
+ toolCall,
353
+ partial: output,
354
+ });
355
+ }
356
+ }
357
+
358
+ function handleMessageDelta(event: ClaudeApiEvent): void {
359
+ if (event.delta?.stop_reason) {
360
+ output.stopReason = mapStopReason(event.delta.stop_reason);
361
+ }
362
+
363
+ const usage = event.usage;
364
+ if (usage) {
365
+ if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
366
+ if (usage.output_tokens != null)
367
+ output.usage.output = usage.output_tokens;
368
+ output.usage.totalTokens =
369
+ output.usage.input +
370
+ output.usage.output +
371
+ output.usage.cacheRead +
372
+ output.usage.cacheWrite;
373
+ calculateCost(model, output.usage);
374
+ }
375
+ }
376
+
377
+ function handleMessageStop(): void {
378
+ // No-op: done event is pushed by the provider after readline closes.
379
+ // Pushing done here (synchronously) prevents pi from executing tools.
380
+ }
381
+
382
+ return {
383
+ handleEvent,
384
+ getOutput: () => output,
385
+ };
386
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Custom tool discovery and MCP config file generation.
3
+ *
4
+ * Discovers non-built-in tools from pi, writes their schemas to a temp file,
5
+ * and generates an MCP config that points to the schema-only MCP server.
6
+ */
7
+
8
+ import { writeFileSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ /**
14
+ * A single tool descriptor returned by pi.getAllTools().
15
+ */
16
+ interface PiToolInfo {
17
+ name: string;
18
+ description: string;
19
+ parameters: Record<string, unknown>;
20
+ }
21
+
22
+ /**
23
+ * Minimal duck-type interface for the pi ExtensionAPI instance.
24
+ * We only call getAllTools(), so we only declare that method.
25
+ * The return type is unknown to accommodate defensive runtime checks.
26
+ */
27
+ interface PiInstance {
28
+ getAllTools(): unknown;
29
+ }
30
+
31
+ /** The 6 built-in tools that pi handles natively (match pi tool names). */
32
+ const BUILT_IN_TOOL_NAMES = new Set([
33
+ "read",
34
+ "write",
35
+ "edit",
36
+ "bash",
37
+ "grep",
38
+ "find",
39
+ ]);
40
+
41
+ /** A custom tool definition with MCP-compatible schema. */
42
+ export interface McpToolDef {
43
+ name: string;
44
+ description: string;
45
+ inputSchema: Record<string, unknown>;
46
+ }
47
+
48
+ /**
49
+ * Get custom tool definitions from pi, filtering out built-in tools.
50
+ *
51
+ * @param pi - The pi ExtensionAPI instance
52
+ * @returns Array of custom tool definitions (empty if all tools are built-in)
53
+ */
54
+ export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
55
+ const allTools = pi.getAllTools();
56
+
57
+ if (!Array.isArray(allTools)) {
58
+ return [];
59
+ }
60
+
61
+ return (allTools as PiToolInfo[])
62
+ .filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
63
+ .map((tool) => ({
64
+ name: tool.name,
65
+ description: tool.description,
66
+ inputSchema: tool.parameters,
67
+ }));
68
+ }
69
+
70
+ /**
71
+ * Write MCP config and tool schemas to temp files.
72
+ *
73
+ * Creates two temp files:
74
+ * 1. Schema file: JSON array of tool definitions
75
+ * 2. Config file: MCP config pointing to the schema-only server
76
+ *
77
+ * @param toolDefs - Array of custom tool definitions
78
+ * @returns Path to the MCP config file
79
+ */
80
+ export function writeMcpConfig(toolDefs: McpToolDef[]): string {
81
+ // Write tool schemas to temp file
82
+ const schemaFilePath = join(
83
+ tmpdir(),
84
+ `pi-claude-mcp-schemas-${process.pid}.json`,
85
+ );
86
+ writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
87
+
88
+ // Resolve path to the schema server .cjs file (sibling of this module)
89
+ const __filename = fileURLToPath(import.meta.url);
90
+ const __dirname = dirname(__filename);
91
+ const serverPath = join(__dirname, "mcp-schema-server.cjs");
92
+
93
+ // Build MCP config
94
+ const config = {
95
+ mcpServers: {
96
+ "custom-tools": {
97
+ command: "node",
98
+ args: [serverPath, schemaFilePath],
99
+ },
100
+ },
101
+ };
102
+
103
+ // Write config to temp file
104
+ const configFilePath = join(
105
+ tmpdir(),
106
+ `pi-claude-mcp-config-${process.pid}.json`,
107
+ );
108
+ writeFileSync(configFilePath, JSON.stringify(config));
109
+
110
+ return configFilePath;
111
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Schema-only MCP server. Reads tool schemas from a JSON file.
3
+ // Only implements initialize + tools/list. tools/call is never reached
4
+ // because the parent process kills the Claude subprocess at message_stop
5
+ // before tool execution (break-early pattern).
6
+ "use strict";
7
+
8
+ const fs = require("fs");
9
+ const readline = require("readline");
10
+
11
+ const schemaPath = process.argv[2];
12
+ if (!schemaPath) {
13
+ process.exit(1);
14
+ }
15
+
16
+ let tools = [];
17
+ try {
18
+ tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
19
+ } catch {
20
+ process.exit(1);
21
+ }
22
+
23
+ const rl = readline.createInterface({ input: process.stdin });
24
+ rl.on("line", (line) => {
25
+ let msg;
26
+ try {
27
+ msg = JSON.parse(line);
28
+ } catch {
29
+ return;
30
+ }
31
+
32
+ if (msg.method === "initialize") {
33
+ const resp = {
34
+ jsonrpc: "2.0",
35
+ id: msg.id,
36
+ result: {
37
+ protocolVersion: "2024-11-05",
38
+ capabilities: { tools: {} },
39
+ serverInfo: { name: "custom-tools", version: "1.0.0" },
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(resp) + "\n");
43
+ } else if (msg.method === "tools/list") {
44
+ const resp = { jsonrpc: "2.0", id: msg.id, result: { tools } };
45
+ process.stdout.write(JSON.stringify(resp) + "\n");
46
+ }
47
+ // notifications/initialized: no response needed (notification)
48
+ // tools/call: never reached (break-early kills subprocess first)
49
+ });