@runfusion/fusion 0.1.2 → 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 +2069 -865
  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 +797 -345
  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 +6 -5
  23. package/skill/fusion/SKILL.md +5 -3
  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 +28 -35
  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-Djv5vKo0.css +0 -1
  33. package/dist/client/assets/index-zfXYuUXG.js +0 -1241
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Provider orchestration for bridging pi requests to the Claude CLI subprocess.
3
+ *
4
+ * streamViaCli is the core function that:
5
+ * 1. Builds the prompt from conversation context
6
+ * 2. Spawns a Claude CLI subprocess with correct flags
7
+ * 3. Writes the user message to stdin as NDJSON
8
+ * 4. Reads stdout line-by-line, parsing NDJSON
9
+ * 5. Routes stream events through the event bridge to pi's stream
10
+ * 6. Handles result/error messages and cleans up the subprocess
11
+ * 7. Implements break-early: kills subprocess at message_stop when
12
+ * built-in or custom-tools MCP tool_use blocks are seen
13
+ * 8. Hardened lifecycle: inactivity timeout, subprocess exit handler,
14
+ * streamEnded guard, abort via SIGKILL, process registry
15
+ */
16
+
17
+ import { createInterface } from "node:readline";
18
+ import {
19
+ AssistantMessageEventStream,
20
+ type Api,
21
+ type Model,
22
+ type SimpleStreamOptions,
23
+ type TextContent,
24
+ type ThinkingContent,
25
+ type ToolCall,
26
+ } from "@mariozechner/pi-ai";
27
+ import {
28
+ buildPrompt,
29
+ buildSystemPrompt,
30
+ buildResumePrompt,
31
+ type PiContext,
32
+ } from "./prompt-builder.js";
33
+ import {
34
+ spawnClaude,
35
+ writeUserMessage,
36
+ cleanupProcess,
37
+ captureStderr,
38
+ forceKillProcess,
39
+ registerProcess,
40
+ cleanupSystemPromptFile,
41
+ } from "./process-manager.js";
42
+ import { parseLine } from "./stream-parser.js";
43
+ import { createEventBridge } from "./event-bridge.js";
44
+ import { handleControlRequest } from "./control-handler.js";
45
+ import { mapThinkingEffort } from "./thinking-config.js";
46
+ import { isPiKnownClaudeTool } from "./tool-mapping.js";
47
+ /** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
48
+ const INACTIVITY_TIMEOUT_MS = 180_000;
49
+
50
+ /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
51
+ type StreamViaCLiOptions = SimpleStreamOptions & {
52
+ cwd?: string;
53
+ mcpConfigPath?: string;
54
+ };
55
+
56
+ /**
57
+ * Stream a response from Claude CLI as an AssistantMessageEventStream.
58
+ *
59
+ * Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
60
+ * bridge events, handle result, and clean up. Implements break-early pattern:
61
+ * at message_stop, if any built-in or custom-tools MCP tool was seen, kills
62
+ * the subprocess before Claude CLI can auto-execute the tools.
63
+ *
64
+ * Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
65
+ * surfacing, streamEnded guard against double errors, abort via SIGKILL, and
66
+ * process registry integration for teardown cleanup.
67
+ *
68
+ * @param model - The model to use (from pi's model catalog)
69
+ * @param context - The conversation context with messages and system prompt
70
+ * @param options - Optional cwd, abort signal, reasoning level, thinking budgets, and mcpConfigPath
71
+ * @returns An AssistantMessageEventStream that receives bridged events
72
+ */
73
+ export function streamViaCli(
74
+ model: Model<Api>,
75
+ context: PiContext,
76
+ options?: StreamViaCLiOptions,
77
+ ): AssistantMessageEventStream {
78
+ // @ts-expect-error — tsc can't verify AssistantMessageEventStream is a value
79
+ // through pi-ai's `export *` re-export chain. The class constructor exists at runtime.
80
+ const stream = new AssistantMessageEventStream();
81
+
82
+ (async () => {
83
+ let proc: ReturnType<typeof spawnClaude> | undefined;
84
+ let abortHandler: (() => void) | undefined;
85
+
86
+ try {
87
+ const cwd = options?.cwd ?? process.cwd();
88
+
89
+ // Resume if pi provides a session ID AND this isn't the first turn.
90
+ // Pi passes sessionId on every call (including first), but we can only
91
+ // --resume a CLI session that already exists on disk from a prior turn.
92
+ const resumeSessionId =
93
+ options?.sessionId && context.messages.length > 1
94
+ ? options.sessionId
95
+ : undefined;
96
+
97
+ // Build prompt: if resuming, only send the latest user turn;
98
+ // otherwise build the full flattened conversation history
99
+ const prompt = resumeSessionId
100
+ ? buildResumePrompt(context)
101
+ : buildPrompt(context);
102
+ const systemPrompt = resumeSessionId
103
+ ? undefined
104
+ : buildSystemPrompt(context, cwd);
105
+
106
+ // Compute effort level from reasoning options
107
+ const effort = mapThinkingEffort(
108
+ options?.reasoning,
109
+ model.id,
110
+ options?.thinkingBudgets,
111
+ );
112
+
113
+ // Spawn subprocess
114
+ proc = spawnClaude(model.id, systemPrompt || undefined, {
115
+ cwd,
116
+ signal: options?.signal,
117
+ effort,
118
+ mcpConfigPath: options?.mcpConfigPath,
119
+ resumeSessionId,
120
+ newSessionId: !resumeSessionId ? options?.sessionId : undefined,
121
+ });
122
+ const getStderr = captureStderr(proc);
123
+
124
+ // Register in global process registry for teardown cleanup
125
+ registerProcess(proc);
126
+
127
+ // Write user message to subprocess stdin
128
+ writeUserMessage(proc, prompt);
129
+
130
+ // Create event bridge (before endStreamWithError so bridge is in scope)
131
+ const bridge = createEventBridge(stream, model);
132
+
133
+ // Guard against double stream.end() and double error events.
134
+ // First error path wins; subsequent ones are no-ops.
135
+ let streamEnded = false;
136
+
137
+ /**
138
+ * End the stream with an error, using a "done" event instead of "error".
139
+ *
140
+ * Why "done" not "error": AssistantMessageEventStream.extractResult()
141
+ * returns event.error (a string) for error events, but agent-loop.js
142
+ * then calls message.content.filter() on the result, crashing because
143
+ * a string has no .content property. By pushing "done" with a valid
144
+ * AssistantMessage (content:[]), pi gets a well-formed object.
145
+ */
146
+ function endStreamWithError(errMsg: string) {
147
+ if (streamEnded || broken) return;
148
+ streamEnded = true;
149
+ const output = bridge.getOutput();
150
+ const errorMessage = {
151
+ ...output,
152
+ content: output.content?.length
153
+ ? output.content
154
+ : [{ type: "text" as const, text: `Error: ${errMsg}` }],
155
+ stopReason: "stop" as const,
156
+ };
157
+ stream.push({
158
+ type: "done",
159
+ reason: "stop",
160
+ message: errorMessage,
161
+ });
162
+ stream.end();
163
+ }
164
+
165
+ // Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
166
+ let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
167
+
168
+ function resetInactivityTimer() {
169
+ if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
170
+ inactivityTimer = setTimeout(() => {
171
+ forceKillProcess(proc!);
172
+ endStreamWithError(
173
+ `Claude CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
174
+ );
175
+ }, INACTIVITY_TIMEOUT_MS);
176
+ }
177
+
178
+ // Set up abort signal handler -- uses SIGKILL for immediate force-kill
179
+ if (options?.signal) {
180
+ abortHandler = () => {
181
+ if (proc) {
182
+ forceKillProcess(proc);
183
+ }
184
+ };
185
+
186
+ if (options.signal.aborted) {
187
+ abortHandler();
188
+ return;
189
+ }
190
+ options.signal.addEventListener("abort", abortHandler, { once: true });
191
+ }
192
+
193
+ // Track tool_use blocks for break-early decision at message_stop
194
+ let sawBuiltInOrCustomTool = false;
195
+ // Guard against buffered readline lines firing after rl.close()
196
+ let broken = false;
197
+
198
+ // Set up readline for line-by-line NDJSON parsing
199
+ const rl = createInterface({
200
+ input: proc.stdout!,
201
+ crlfDelay: Infinity,
202
+ terminal: false,
203
+ });
204
+
205
+ // Handle process error -- use endStreamWithError for guard
206
+ proc.on("error", (err: Error) => {
207
+ if (broken) return; // Break-early killed the process intentionally
208
+ const stderr = getStderr();
209
+ endStreamWithError(stderr || err.message);
210
+ });
211
+
212
+ // Handle subprocess close -- surface crashes with stderr and exit code
213
+ proc.on("close", (code: number | null, _signal: string | null) => {
214
+ clearTimeout(inactivityTimer);
215
+ if (broken) return; // Break-early kill, expected
216
+ if (code !== 0 && code !== null) {
217
+ const stderr = getStderr();
218
+ const message = stderr
219
+ ? `Claude CLI exited with code ${code}: ${stderr.trim()}`
220
+ : `Claude CLI exited unexpectedly with code ${code}`;
221
+ endStreamWithError(message);
222
+ }
223
+ });
224
+
225
+ // Start inactivity timer after writing user message
226
+ resetInactivityTimer();
227
+
228
+ // Process NDJSON lines from stdout using event-based callback
229
+ // NOTE: Using 'line' event instead of `for await` because the async
230
+ // iterator batches lines, breaking real-time streaming to pi.
231
+ rl.on("line", (line: string) => {
232
+ if (broken) return; // Guard: ignore buffered lines after break-early
233
+
234
+ // Reset inactivity timer on each line of output
235
+ resetInactivityTimer();
236
+
237
+ const msg = parseLine(line);
238
+ if (!msg) return;
239
+
240
+ if (msg.type === "stream_event") {
241
+ // Only forward top-level events to pi's event bridge.
242
+ // Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
243
+ const isTopLevel = !msg.parent_tool_use_id;
244
+ if (isTopLevel) {
245
+ bridge.handleEvent(msg.event);
246
+ }
247
+
248
+ // Track tool_use blocks for break-early decision (top-level only)
249
+ if (
250
+ isTopLevel &&
251
+ msg.event.type === "content_block_start" &&
252
+ msg.event.content_block?.type === "tool_use"
253
+ ) {
254
+ const toolName = msg.event.content_block.name;
255
+ if (toolName && isPiKnownClaudeTool(toolName)) {
256
+ // Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
257
+ // Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
258
+ sawBuiltInOrCustomTool = true;
259
+ }
260
+ }
261
+
262
+ // Break-early at message_stop: kill subprocess before CLI auto-executes tools
263
+ // Only on top-level message_stop — sub-agent message_stop is internal
264
+ if (
265
+ isTopLevel &&
266
+ msg.event.type === "message_stop" &&
267
+ sawBuiltInOrCustomTool
268
+ ) {
269
+ broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
270
+ clearTimeout(inactivityTimer);
271
+ // Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
272
+ forceKillProcess(proc!);
273
+ rl.close();
274
+ return; // Don't process further -- done event already pushed by event bridge
275
+ }
276
+ } else if (msg.type === "control_request") {
277
+ handleControlRequest(msg, proc!.stdin!);
278
+ } else if (msg.type === "result") {
279
+ if (msg.subtype === "error") {
280
+ endStreamWithError(msg.error ?? "Unknown error from Claude CLI");
281
+ }
282
+ // For both success and error: clean up the subprocess
283
+ clearTimeout(inactivityTimer);
284
+ cleanupProcess(proc!);
285
+ rl.close();
286
+ }
287
+ });
288
+
289
+ // Wait for readline to close (result received or process ended)
290
+ await new Promise<void>((resolve) => {
291
+ rl.on("close", resolve);
292
+ });
293
+
294
+ // Push done event after readline closes (async). Pushing synchronously
295
+ // inside handleMessageStop prevents pi from executing tools.
296
+ // Guard with streamEnded to avoid pushing done after an error was already pushed.
297
+ if (!streamEnded) {
298
+ const output = bridge.getOutput();
299
+
300
+ // If stopReason is toolUse but there are no pi-known tool calls in content,
301
+ // it means only user MCP tools were called (filtered by event bridge).
302
+ // Override to "stop" so pi doesn't try to execute non-existent tools.
303
+ const piToolCalls = (output.content || []).filter(
304
+ (c: TextContent | ThinkingContent | ToolCall) => c.type === "toolCall",
305
+ );
306
+ const effectiveReason =
307
+ output.stopReason === "toolUse" && piToolCalls.length === 0
308
+ ? "stop"
309
+ : output.stopReason;
310
+
311
+ streamEnded = true;
312
+ stream.push({
313
+ type: "done",
314
+ reason:
315
+ effectiveReason === "toolUse"
316
+ ? "toolUse"
317
+ : effectiveReason === "length"
318
+ ? "length"
319
+ : "stop",
320
+ message: { ...output, stopReason: effectiveReason },
321
+ });
322
+ stream.end();
323
+ }
324
+ } catch (err) {
325
+ const errMsg = err instanceof Error ? err.message : String(err);
326
+ // Push a "done" event with a text error so pi gets a valid AssistantMessage.
327
+ // Pushing type:"error" would require an AssistantMessage in the error field,
328
+ // but we don't have a full AssistantMessage here.
329
+ stream.push({
330
+ type: "done",
331
+ reason: "stop",
332
+ message: {
333
+ role: "assistant" as const,
334
+ content: [{ type: "text" as const, text: `Error: ${errMsg}` }],
335
+ api: "pi-claude-cli",
336
+ provider: model.provider,
337
+ model: model.id,
338
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
339
+ stopReason: "stop" as const,
340
+ timestamp: Date.now(),
341
+ },
342
+ });
343
+ stream.end();
344
+ } finally {
345
+ // Clean up abort listener
346
+ if (options?.signal && abortHandler) {
347
+ options.signal.removeEventListener("abort", abortHandler);
348
+ }
349
+ cleanupSystemPromptFile();
350
+ }
351
+ })();
352
+
353
+ return stream;
354
+ }
@@ -0,0 +1,37 @@
1
+ import type { NdjsonMessage } from "./types";
2
+
3
+ /**
4
+ * Parse a single NDJSON line from Claude CLI stdout into a typed message.
5
+ *
6
+ * This function is deliberately resilient -- it never throws. Debug noise,
7
+ * empty lines, and malformed JSON all return null so the streaming pipeline
8
+ * can safely skip them and continue processing.
9
+ */
10
+ export function parseLine(line: string): NdjsonMessage | null {
11
+ const trimmed = line.trim();
12
+
13
+ // Skip empty lines
14
+ if (!trimmed) {
15
+ return null;
16
+ }
17
+
18
+ // Skip non-JSON lines (debug output like "[SandboxDebug] ...")
19
+ if (!trimmed.startsWith("{")) {
20
+ return null;
21
+ }
22
+
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(trimmed);
26
+ } catch {
27
+ console.error("Failed to parse NDJSON line:", trimmed);
28
+ return null;
29
+ }
30
+
31
+ // Validate that the parsed result is a non-null object (not array, not primitive)
32
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
33
+ return null;
34
+ }
35
+
36
+ return parsed as NdjsonMessage;
37
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Thinking effort configuration for mapping pi's ThinkingLevel to Claude CLI --effort flags.
3
+ *
4
+ * Maps pi's reasoning levels (minimal/low/medium/high/xhigh) to the CLI's effort
5
+ * levels (low/medium/high/max). Opus models get an elevated mapping where medium
6
+ * becomes high and high becomes max, leveraging their superior reasoning capability.
7
+ *
8
+ * IMPORTANT: The CLI does NOT support --thinking-budget. Only --effort is supported.
9
+ */
10
+
11
+ import type { ThinkingLevel, ThinkingBudgets } from "@mariozechner/pi-ai";
12
+
13
+ /** CLI effort levels accepted by the --effort flag */
14
+ export type CliEffortLevel = "low" | "medium" | "high" | "max";
15
+
16
+ /**
17
+ * Standard model mapping: pi ThinkingLevel -> CLI effort.
18
+ * Non-Opus models never receive "max" (would cause CLI error).
19
+ */
20
+ const STANDARD_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
21
+ minimal: "low",
22
+ low: "low",
23
+ medium: "medium",
24
+ high: "high",
25
+ xhigh: "high", // non-Opus: silently downgrade (max not supported)
26
+ };
27
+
28
+ /**
29
+ * Opus model mapping: shifted up for elevated reasoning.
30
+ * Opus models get max capability at high/xhigh levels.
31
+ */
32
+ const OPUS_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
33
+ minimal: "low",
34
+ low: "low",
35
+ medium: "high", // shifted: standard high
36
+ high: "max", // shifted: maximum capability
37
+ xhigh: "max", // Opus gets max
38
+ };
39
+
40
+ /**
41
+ * Detect whether a model ID refers to an Opus model.
42
+ * Uses includes('opus') for forward-compatibility with future Opus versions.
43
+ *
44
+ * @param modelId - The model identifier string
45
+ * @returns true if the model is an Opus variant
46
+ */
47
+ export function isOpusModel(modelId: string): boolean {
48
+ return modelId.includes("opus");
49
+ }
50
+
51
+ /**
52
+ * Map pi's ThinkingLevel to a CLI effort string.
53
+ *
54
+ * When reasoning is undefined, returns undefined so the --effort flag is omitted
55
+ * entirely, letting the CLI use its default behavior. When thinkingBudgets are
56
+ * provided, a console.warn is logged because the CLI only supports effort levels,
57
+ * not token budgets.
58
+ *
59
+ * @param reasoning - Pi's thinking level (undefined = omit flag)
60
+ * @param modelId - Model ID for Opus detection
61
+ * @param thinkingBudgets - Custom budgets (logged as unsupported, not applied)
62
+ * @returns CLI effort level string, or undefined if flag should be omitted
63
+ */
64
+ export function mapThinkingEffort(
65
+ reasoning?: ThinkingLevel,
66
+ modelId?: string,
67
+ thinkingBudgets?: ThinkingBudgets,
68
+ ): CliEffortLevel | undefined {
69
+ if (reasoning === undefined) {
70
+ return undefined; // omit --effort flag entirely
71
+ }
72
+
73
+ if (thinkingBudgets && Object.keys(thinkingBudgets).length > 0) {
74
+ console.warn(
75
+ "[pi-claude-cli] Custom thinkingBudgets are not supported with CLI subprocess. " +
76
+ "The CLI uses --effort levels instead of token budgets. Budgets will be ignored.",
77
+ );
78
+ }
79
+
80
+ const isOpus = modelId ? isOpusModel(modelId) : false;
81
+ const map = isOpus ? OPUS_EFFORT_MAP : STANDARD_EFFORT_MAP;
82
+ return map[reasoning];
83
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Single-source-of-truth tool mapping table for bidirectional translation
3
+ * between Claude CLI tool names/arguments and pi tool names/arguments.
4
+ *
5
+ * All lookup tables are derived from the TOOL_MAPPINGS array.
6
+ * Unknown tools and arguments pass through unchanged.
7
+ */
8
+
9
+ /**
10
+ * A mapping entry for a single tool.
11
+ * `args` maps Claude argument names to pi argument names (only renamed args).
12
+ */
13
+ export interface ToolMapping {
14
+ claude: string;
15
+ pi: string;
16
+ args: Record<string, string>;
17
+ }
18
+
19
+ /**
20
+ * The canonical tool mapping table. All other lookup structures are derived from this.
21
+ */
22
+ export const TOOL_MAPPINGS: ToolMapping[] = [
23
+ { claude: "Read", pi: "read", args: { file_path: "path" } },
24
+ { claude: "Write", pi: "write", args: { file_path: "path" } },
25
+ {
26
+ claude: "Edit",
27
+ pi: "edit",
28
+ args: { file_path: "path", old_string: "oldText", new_string: "newText" },
29
+ },
30
+ { claude: "Bash", pi: "bash", args: {} },
31
+ { claude: "Grep", pi: "grep", args: { head_limit: "limit" } },
32
+ { claude: "Glob", pi: "find", args: {} },
33
+ ];
34
+
35
+ /** Prefix for custom pi tools exposed via MCP. */
36
+ export const CUSTOM_TOOLS_MCP_PREFIX = "mcp__custom-tools__";
37
+
38
+ /** Set of built-in pi tool names derived from TOOL_MAPPINGS for O(1) lookup. */
39
+ const BUILT_IN_PI_NAMES = new Set(TOOL_MAPPINGS.map((m) => m.pi));
40
+
41
+ /**
42
+ * Check if a pi tool name is a custom tool (not one of the 6 built-in tools).
43
+ * Used by prompt builder to decide whether to add MCP prefix in history replay.
44
+ */
45
+ export function isCustomToolName(piName: string): boolean {
46
+ return !BUILT_IN_PI_NAMES.has(piName);
47
+ }
48
+
49
+ /**
50
+ * Check if a Claude tool name maps to a pi-known tool.
51
+ * Returns true for built-in tools (Read, Write, etc.) and custom MCP tools (mcp__custom-tools__*).
52
+ * Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.) that pi cannot execute.
53
+ * Used by event bridge to filter out internal tool calls.
54
+ */
55
+ export function isPiKnownClaudeTool(claudeName: string): boolean {
56
+ if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
57
+ return claudeName.toLowerCase() in CLAUDE_TO_PI_NAME;
58
+ }
59
+
60
+ // Derived lookup maps
61
+
62
+ /** Lowercase Claude name -> pi name */
63
+ const CLAUDE_TO_PI_NAME: Record<string, string> = {};
64
+ /** Pi name -> PascalCase Claude name */
65
+ const PI_TO_CLAUDE_NAME: Record<string, string> = {};
66
+ /** Lowercase Claude name -> { claudeArgName: piArgName } */
67
+ const CLAUDE_TO_PI_ARGS: Record<string, Record<string, string>> = {};
68
+ /** Pi name -> { piArgName: claudeArgName } */
69
+ const PI_TO_CLAUDE_ARGS: Record<string, Record<string, string>> = {};
70
+
71
+ for (const m of TOOL_MAPPINGS) {
72
+ CLAUDE_TO_PI_NAME[m.claude.toLowerCase()] = m.pi;
73
+ PI_TO_CLAUDE_NAME[m.pi] = m.claude;
74
+ CLAUDE_TO_PI_ARGS[m.claude.toLowerCase()] = m.args;
75
+
76
+ // Build reverse arg map
77
+ const reverseArgs: Record<string, string> = {};
78
+ for (const [from, to] of Object.entries(m.args)) {
79
+ reverseArgs[to] = from;
80
+ }
81
+ PI_TO_CLAUDE_ARGS[m.pi] = reverseArgs;
82
+ }
83
+
84
+ // Handle glob/find asymmetry: pi's "glob" also maps back to Claude's "Glob"
85
+ PI_TO_CLAUDE_NAME["glob"] = "Glob";
86
+
87
+ /**
88
+ * Map a Claude tool name to the corresponding pi tool name.
89
+ * Strips the mcp__custom-tools__ prefix for custom tools first,
90
+ * then falls back to case-insensitive built-in lookup.
91
+ * Unknown tool names pass through unchanged.
92
+ */
93
+ export function mapClaudeToolNameToPi(claudeName: string): string {
94
+ // Strip custom-tools MCP prefix first (e.g., "mcp__custom-tools__deploy" -> "deploy")
95
+ if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) {
96
+ return claudeName.slice(CUSTOM_TOOLS_MCP_PREFIX.length);
97
+ }
98
+ // Standard built-in tool mapping (case-insensitive)
99
+ return CLAUDE_TO_PI_NAME[claudeName.toLowerCase()] ?? claudeName;
100
+ }
101
+
102
+ /**
103
+ * Map a pi tool name to the corresponding Claude tool name.
104
+ * Direct lookup. Unknown tool names pass through unchanged.
105
+ */
106
+ export function mapPiToolNameToClaude(piName: string): string {
107
+ return PI_TO_CLAUDE_NAME[piName] ?? piName;
108
+ }
109
+
110
+ /**
111
+ * Translate Claude tool arguments to pi format.
112
+ * Only known renamed arguments are translated; all others pass through unchanged.
113
+ * This prevents dropping unknown/extra arguments (Pitfall 5).
114
+ */
115
+ export function translateClaudeArgsToPi(
116
+ claudeToolName: string,
117
+ args: Record<string, unknown>,
118
+ ): Record<string, unknown> {
119
+ const renames = CLAUDE_TO_PI_ARGS[claudeToolName.toLowerCase()];
120
+ if (!renames || Object.keys(renames).length === 0) return args;
121
+
122
+ const result: Record<string, unknown> = {};
123
+ for (const [key, value] of Object.entries(args)) {
124
+ const newKey = renames[key] ?? key;
125
+ result[newKey] = value;
126
+ }
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Translate pi tool arguments to Claude format.
132
+ * Only known renamed arguments are translated; all others pass through unchanged.
133
+ */
134
+ export function translatePiArgsToClaude(
135
+ piToolName: string,
136
+ args: Record<string, unknown>,
137
+ ): Record<string, unknown> {
138
+ const renames = PI_TO_CLAUDE_ARGS[piToolName];
139
+ if (!renames || Object.keys(renames).length === 0) return args;
140
+
141
+ const result: Record<string, unknown> = {};
142
+ for (const [key, value] of Object.entries(args)) {
143
+ const newKey = renames[key] ?? key;
144
+ result[newKey] = value;
145
+ }
146
+ return result;
147
+ }