@runfusion/fusion 0.1.1 → 0.1.3

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 (33) hide show
  1. package/README.md +2 -0
  2. package/dist/bin.js +14866 -12340
  3. package/dist/client/assets/index-BuenKJX0.css +1 -0
  4. package/dist/client/assets/index-CjGu8HRV.js +1250 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/sw.js +45 -1
  7. package/dist/client/theme-data.css +109 -0
  8. package/dist/extension.js +12264 -11527
  9. package/dist/pi-claude-cli/index.ts +131 -0
  10. package/dist/pi-claude-cli/package.json +39 -0
  11. package/dist/pi-claude-cli/src/control-handler.ts +68 -0
  12. package/dist/pi-claude-cli/src/event-bridge.ts +386 -0
  13. package/dist/pi-claude-cli/src/mcp-config.ts +111 -0
  14. package/dist/pi-claude-cli/src/mcp-schema-server.cjs +49 -0
  15. package/dist/pi-claude-cli/src/process-manager.ts +218 -0
  16. package/dist/pi-claude-cli/src/prompt-builder.ts +536 -0
  17. package/dist/pi-claude-cli/src/provider.ts +354 -0
  18. package/dist/pi-claude-cli/src/stream-parser.ts +37 -0
  19. package/dist/pi-claude-cli/src/thinking-config.ts +83 -0
  20. package/dist/pi-claude-cli/src/tool-mapping.ts +147 -0
  21. package/dist/pi-claude-cli/src/types.ts +87 -0
  22. package/package.json +12 -9
  23. package/skill/fusion/SKILL.md +6 -4
  24. package/skill/fusion/references/cli-commands.md +22 -22
  25. package/skill/fusion/references/extension-tools.md +3 -1
  26. package/skill/fusion/references/fusion-capabilities.md +30 -38
  27. package/skill/fusion/references/task-structure.md +4 -4
  28. package/skill/fusion/workflows/dashboard-cli.md +6 -6
  29. package/skill/fusion/workflows/specifications.md +5 -3
  30. package/skill/fusion/workflows/task-lifecycle.md +1 -1
  31. package/skill/fusion/workflows/task-management.md +3 -1
  32. package/dist/client/assets/index-B3ZN3sln.css +0 -1
  33. package/dist/client/assets/index-cgKoCmZP.js +0 -1241
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Pi extension entry point for pi-claude-cli.
3
+ *
4
+ * Registers a custom provider that routes LLM calls through the Claude Code CLI
5
+ * subprocess using stream-json NDJSON protocol.
6
+ */
7
+
8
+ import { getModels } from "@mariozechner/pi-ai";
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { streamViaCli } from "./src/provider.js";
11
+ import {
12
+ validateCliPresence,
13
+ validateCliAuth,
14
+ killAllProcesses,
15
+ } from "./src/process-manager.js";
16
+ import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
17
+
18
+ // Kill all active Claude subprocesses on process exit to prevent orphans
19
+ process.on("exit", killAllProcesses);
20
+
21
+ const PROVIDER_ID = "pi-claude-cli";
22
+
23
+ let mcpConfigPath: string | undefined;
24
+ let mcpConfigResolved = false;
25
+
26
+ /**
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.
30
+ *
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.
35
+ *
36
+ * Uses warn-don't-block: failure logs a warning but does not
37
+ * prevent the provider from functioning (built-ins still work).
38
+ */
39
+ function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
40
+ if (mcpConfigResolved) return mcpConfigPath;
41
+ try {
42
+ const allTools = pi.getAllTools();
43
+
44
+ // Registry not ready yet — don't lock, retry on next call
45
+ if (!Array.isArray(allTools)) {
46
+ return mcpConfigPath;
47
+ }
48
+
49
+ // Registry is ready — lock regardless of whether custom tools exist
50
+ mcpConfigResolved = true;
51
+
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
+ );
58
+ }
59
+ } catch (err) {
60
+ console.warn(
61
+ "[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
62
+ err,
63
+ );
64
+ }
65
+ return mcpConfigPath;
66
+ }
67
+
68
+ export default function (pi: ExtensionAPI) {
69
+ try {
70
+ // Startup validation
71
+ validateCliPresence(); // throws if CLI not on PATH
72
+ validateCliAuth(); // warns if not authenticated
73
+
74
+ const catalogModels = getModels("anthropic").map((model) => ({
75
+ id: model.id,
76
+ name: model.name,
77
+ reasoning: model.reasoning,
78
+ input: model.input,
79
+ cost: model.cost,
80
+ contextWindow: model.contextWindow,
81
+ maxTokens: model.maxTokens,
82
+ }));
83
+
84
+ // Newer models released after the pinned @mariozechner/pi-ai catalog
85
+ // was generated. Dedupe by id so this list is harmless once the upstream
86
+ // catalog catches up.
87
+ // https://platform.claude.com/docs/en/about-claude/models/overview
88
+ const extraModels: typeof catalogModels = [
89
+ {
90
+ id: "claude-opus-4-7",
91
+ name: "Claude Opus 4.7",
92
+ reasoning: true,
93
+ input: ["text", "image"],
94
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
95
+ contextWindow: 1_000_000,
96
+ maxTokens: 128_000,
97
+ },
98
+ ];
99
+
100
+ const seen = new Set(catalogModels.map((m) => m.id));
101
+ const models = [
102
+ ...catalogModels,
103
+ ...extraModels.filter((m) => !seen.has(m.id)),
104
+ ];
105
+
106
+ // Ensure all registered tools are active so pi can execute them.
107
+ // Some tools (find, grep, ls) are registered but not activated by default.
108
+ pi.on("session_start", async () => {
109
+ const allTools = pi.getAllTools();
110
+ if (Array.isArray(allTools)) {
111
+ pi.setActiveTools(allTools.map((t: { name: string }) => t.name));
112
+ }
113
+ });
114
+
115
+ pi.registerProvider(PROVIDER_ID, {
116
+ baseUrl: "pi-claude-cli",
117
+ apiKey: "unused",
118
+ api: "pi-claude-cli",
119
+ models,
120
+ streamSimple: (model, context, options) => {
121
+ const configPath = ensureMcpConfig(pi);
122
+ return streamViaCli(model, context, {
123
+ ...options,
124
+ mcpConfigPath: configPath,
125
+ });
126
+ },
127
+ });
128
+ } catch (err) {
129
+ console.error(`[pi-claude-cli] Failed to register provider:`, err);
130
+ }
131
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@fusion/pi-claude-cli",
3
+ "version": "0.3.1",
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
+ "license": "MIT",
6
+ "private": true,
7
+ "type": "module",
8
+ "main": "index.ts",
9
+ "keywords": [
10
+ "pi-package"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "index.ts"
15
+ ]
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/Runfusion/Fusion",
20
+ "directory": "packages/pi-claude-cli"
21
+ },
22
+ "dependencies": {
23
+ "cross-spawn": "^7.0.6"
24
+ },
25
+ "peerDependencies": {
26
+ "@mariozechner/pi-ai": "*",
27
+ "@mariozechner/pi-coding-agent": "*"
28
+ },
29
+ "devDependencies": {
30
+ "@types/cross-spawn": "^6.0.6",
31
+ "@types/node": "^22.0.0",
32
+ "typescript": "^5.7.0",
33
+ "vitest": "^3.0.0"
34
+ },
35
+ "scripts": {
36
+ "test": "vitest run --reporter=dot",
37
+ "typecheck": "tsc --noEmit"
38
+ }
39
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Control protocol handler for Claude CLI stream-json communication.
3
+ *
4
+ * Processes control_request messages from Claude CLI stdout and writes
5
+ * control_response messages to stdin.
6
+ *
7
+ * - Custom MCP tools (mcp__custom-tools__*): DENIED — pi executes these
8
+ * - Everything else (user MCP tools, internal tools): ALLOWED — Claude handles
9
+ */
10
+
11
+ import type { ClaudeControlRequest } from "./types";
12
+ import { CUSTOM_TOOLS_MCP_PREFIX } from "./tool-mapping.js";
13
+
14
+ export const TOOL_EXECUTION_DENIED_MESSAGE =
15
+ "Tool execution is unavailable in this environment.";
16
+
17
+ /** Prefix for MCP (Model Context Protocol) tool names. */
18
+ export const MCP_PREFIX = "mcp__";
19
+
20
+ interface ControlResponse {
21
+ type: "control_response";
22
+ request_id: string;
23
+ response: {
24
+ subtype: "success";
25
+ response: {
26
+ behavior: "allow" | "deny";
27
+ message?: string;
28
+ };
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Handle a control_request from the Claude CLI.
34
+ *
35
+ * Denies custom MCP tools (mcp__custom-tools__*) so pi can execute them.
36
+ * Allows everything else (user MCP tools, internal Claude tools).
37
+ *
38
+ * @returns true if the tool was allowed, false if denied
39
+ */
40
+ export function handleControlRequest(
41
+ msg: ClaudeControlRequest,
42
+ stdin: NodeJS.WritableStream,
43
+ ): boolean {
44
+ if (!msg.request_id || !msg.request) {
45
+ console.error(
46
+ "[pi-claude-cli] Malformed control_request: missing request_id or request object",
47
+ msg,
48
+ );
49
+ return false;
50
+ }
51
+
52
+ const toolName = msg.request?.tool_name ?? "";
53
+ const isCustomTool = toolName.startsWith(CUSTOM_TOOLS_MCP_PREFIX);
54
+
55
+ const response: ControlResponse = {
56
+ type: "control_response",
57
+ request_id: msg.request_id,
58
+ response: {
59
+ subtype: "success",
60
+ response: isCustomTool
61
+ ? { behavior: "deny", message: TOOL_EXECUTION_DENIED_MESSAGE }
62
+ : { behavior: "allow" },
63
+ },
64
+ };
65
+
66
+ stdin.write(JSON.stringify(response) + "\n");
67
+ return !isCustomTool;
68
+ }
@@ -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
+ }