@saccolabs/pi-claude-cli 0.4.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.
@@ -0,0 +1,358 @@
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
+ type AssistantMessageEventStream,
20
+ createAssistantMessageEventStream,
21
+ type Model,
22
+ type SimpleStreamOptions,
23
+ } from "@earendil-works/pi-ai";
24
+ import {
25
+ buildPrompt,
26
+ buildSystemPrompt,
27
+ buildResumePrompt,
28
+ } from "./prompt-builder.js";
29
+ import {
30
+ spawnClaude,
31
+ writeUserMessage,
32
+ cleanupProcess,
33
+ captureStderr,
34
+ forceKillProcess,
35
+ registerProcess,
36
+ cleanupSystemPromptFile,
37
+ } from "./process-manager.js";
38
+ import { parseLine } from "./stream-parser.js";
39
+ import { createEventBridge } from "./event-bridge.js";
40
+ import { handleControlRequest } from "./control-handler.js";
41
+ import { mapThinkingEffort } from "./thinking-config.js";
42
+ import { isPiKnownClaudeTool } from "./tool-mapping.js";
43
+ /** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
44
+ const INACTIVITY_TIMEOUT_MS = 180_000;
45
+
46
+ /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
47
+ type StreamViaCLiOptions = SimpleStreamOptions & {
48
+ cwd?: string;
49
+ mcpConfigPath?: string;
50
+ };
51
+
52
+ /**
53
+ * Stream a response from Claude CLI as an AssistantMessageEventStream.
54
+ *
55
+ * Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
56
+ * bridge events, handle result, and clean up. Implements break-early pattern:
57
+ * at message_stop, if any built-in or custom-tools MCP tool was seen, kills
58
+ * the subprocess before Claude CLI can auto-execute the tools.
59
+ *
60
+ * Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
61
+ * surfacing, streamEnded guard against double errors, abort via SIGKILL, and
62
+ * process registry integration for teardown cleanup.
63
+ *
64
+ * @param model - The model to use (from pi's model catalog)
65
+ * @param context - The conversation context with messages and system prompt
66
+ * @param options - Optional cwd, abort signal, reasoning level, thinking budgets, and mcpConfigPath
67
+ * @returns An AssistantMessageEventStream that receives bridged events
68
+ */
69
+ export function streamViaCli(
70
+ model: Model<any>,
71
+ context: { messages: any[]; systemPrompt?: string },
72
+ options?: StreamViaCLiOptions,
73
+ ): AssistantMessageEventStream {
74
+ const stream = createAssistantMessageEventStream();
75
+
76
+ (async () => {
77
+ let proc: ReturnType<typeof spawnClaude> | undefined;
78
+ let abortHandler: (() => void) | undefined;
79
+
80
+ try {
81
+ const cwd = options?.cwd ?? process.cwd();
82
+
83
+ // Resume only when this conversation already contains a prior assistant
84
+ // turn produced by pi-claude-cli (which means a CLI session has been
85
+ // established under this session id). Otherwise — e.g. when the user
86
+ // just switched to pi-claude-cli from another provider mid session, or
87
+ // when this is the first turn — start a fresh CLI session via
88
+ // --session-id. Using --resume against an unknown id fails silently
89
+ // with "No conversation found with session ID" and produces an empty
90
+ // assistant message.
91
+ const hasPriorCliTurn = (context.messages as any[]).some(
92
+ (m) =>
93
+ m?.role === "assistant" &&
94
+ (m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli"),
95
+ );
96
+ const resumeSessionId =
97
+ options?.sessionId && hasPriorCliTurn ? options.sessionId : undefined;
98
+
99
+ // Build prompt: if resuming, only send the latest user turn;
100
+ // otherwise build the full flattened conversation history
101
+ const prompt = resumeSessionId
102
+ ? buildResumePrompt(context)
103
+ : buildPrompt(context);
104
+ const systemPrompt = resumeSessionId
105
+ ? undefined
106
+ : buildSystemPrompt(context, cwd);
107
+
108
+ // Compute effort level from reasoning options
109
+ const effort = mapThinkingEffort(
110
+ options?.reasoning,
111
+ model.id,
112
+ options?.thinkingBudgets,
113
+ );
114
+
115
+ // Spawn subprocess
116
+ proc = spawnClaude(model.id, systemPrompt || undefined, {
117
+ cwd,
118
+ signal: options?.signal,
119
+ effort,
120
+ mcpConfigPath: options?.mcpConfigPath,
121
+ resumeSessionId,
122
+ newSessionId: !resumeSessionId ? options?.sessionId : undefined,
123
+ });
124
+ const getStderr = captureStderr(proc);
125
+
126
+ // Register in global process registry for teardown cleanup
127
+ registerProcess(proc);
128
+
129
+ // Write user message to subprocess stdin
130
+ writeUserMessage(proc, prompt);
131
+
132
+ // Create event bridge (before endStreamWithError so bridge is in scope)
133
+ const bridge = createEventBridge(stream, model);
134
+
135
+ // Guard against double stream.end() and double error events.
136
+ // First error path wins; subsequent ones are no-ops.
137
+ let streamEnded = false;
138
+
139
+ /**
140
+ * End the stream with an error, using a "done" event instead of "error".
141
+ *
142
+ * Why "done" not "error": AssistantMessageEventStream.extractResult()
143
+ * returns event.error (a string) for error events, but agent-loop.js
144
+ * then calls message.content.filter() on the result, crashing because
145
+ * a string has no .content property. By pushing "done" with a valid
146
+ * AssistantMessage (content:[]), pi gets a well-formed object.
147
+ */
148
+ function endStreamWithError(errMsg: string) {
149
+ if (streamEnded || broken) return;
150
+ streamEnded = true;
151
+ const output = bridge.getOutput();
152
+ const errorMessage = {
153
+ ...output,
154
+ content: output.content?.length
155
+ ? output.content
156
+ : [{ type: "text" as const, text: `Error: ${errMsg}` }],
157
+ stopReason: "stop" as const,
158
+ };
159
+ stream.push({
160
+ type: "done",
161
+ reason: "stop",
162
+ message: errorMessage,
163
+ } as any);
164
+ stream.end();
165
+ }
166
+
167
+ // Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
168
+ let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
169
+
170
+ function resetInactivityTimer() {
171
+ if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
172
+ inactivityTimer = setTimeout(() => {
173
+ forceKillProcess(proc!);
174
+ endStreamWithError(
175
+ `Claude CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
176
+ );
177
+ }, INACTIVITY_TIMEOUT_MS);
178
+ }
179
+
180
+ // Set up abort signal handler -- uses SIGKILL for immediate force-kill
181
+ if (options?.signal) {
182
+ abortHandler = () => {
183
+ if (proc) {
184
+ forceKillProcess(proc);
185
+ }
186
+ };
187
+
188
+ if (options.signal.aborted) {
189
+ abortHandler();
190
+ return;
191
+ }
192
+ options.signal.addEventListener("abort", abortHandler, { once: true });
193
+ }
194
+
195
+ // Track tool_use blocks for break-early decision at message_stop
196
+ let sawBuiltInOrCustomTool = false;
197
+ // Guard against buffered readline lines firing after rl.close()
198
+ let broken = false;
199
+
200
+ // Set up readline for line-by-line NDJSON parsing
201
+ const rl = createInterface({
202
+ input: proc.stdout!,
203
+ crlfDelay: Infinity,
204
+ terminal: false,
205
+ });
206
+
207
+ // Handle process error -- use endStreamWithError for guard
208
+ proc.on("error", (err: Error) => {
209
+ if (broken) return; // Break-early killed the process intentionally
210
+ const stderr = getStderr();
211
+ endStreamWithError(stderr || err.message);
212
+ });
213
+
214
+ // Handle subprocess close -- surface crashes with stderr and exit code
215
+ proc.on("close", (code: number | null, _signal: string | null) => {
216
+ clearTimeout(inactivityTimer);
217
+ if (broken) return; // Break-early kill, expected
218
+ if (code !== 0 && code !== null) {
219
+ const stderr = getStderr();
220
+ const message = stderr
221
+ ? `Claude CLI exited with code ${code}: ${stderr.trim()}`
222
+ : `Claude CLI exited unexpectedly with code ${code}`;
223
+ endStreamWithError(message);
224
+ }
225
+ });
226
+
227
+ // Start inactivity timer after writing user message
228
+ resetInactivityTimer();
229
+
230
+ // Process NDJSON lines from stdout using event-based callback
231
+ // NOTE: Using 'line' event instead of `for await` because the async
232
+ // iterator batches lines, breaking real-time streaming to pi.
233
+ rl.on("line", (line: string) => {
234
+ if (broken) return; // Guard: ignore buffered lines after break-early
235
+
236
+ // Reset inactivity timer on each line of output
237
+ resetInactivityTimer();
238
+
239
+ const msg = parseLine(line);
240
+ if (!msg) return;
241
+
242
+ if (msg.type === "stream_event") {
243
+ // Only forward top-level events to pi's event bridge.
244
+ // Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
245
+ const isTopLevel = !(msg as any).parent_tool_use_id;
246
+ if (isTopLevel) {
247
+ bridge.handleEvent(msg.event);
248
+ }
249
+
250
+ // Track tool_use blocks for break-early decision (top-level only)
251
+ if (
252
+ isTopLevel &&
253
+ msg.event.type === "content_block_start" &&
254
+ msg.event.content_block?.type === "tool_use"
255
+ ) {
256
+ const toolName = msg.event.content_block.name;
257
+ if (toolName && isPiKnownClaudeTool(toolName)) {
258
+ // Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
259
+ // Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
260
+ sawBuiltInOrCustomTool = true;
261
+ }
262
+ }
263
+
264
+ // Break-early at message_stop: kill subprocess before CLI auto-executes tools
265
+ // Only on top-level message_stop — sub-agent message_stop is internal
266
+ if (
267
+ isTopLevel &&
268
+ msg.event.type === "message_stop" &&
269
+ sawBuiltInOrCustomTool
270
+ ) {
271
+ broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
272
+ clearTimeout(inactivityTimer);
273
+ // Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
274
+ forceKillProcess(proc!);
275
+ rl.close();
276
+ return; // Don't process further -- done event already pushed by event bridge
277
+ }
278
+ } else if (msg.type === "control_request") {
279
+ handleControlRequest(msg, proc!.stdin!);
280
+ } else if (msg.type === "result") {
281
+ // Surface every non-success result as an error so silent failures
282
+ // (e.g. subtype "error_during_execution" from --resume against an
283
+ // unknown session id, or any future error variant) don't get
284
+ // swallowed into an empty assistant message.
285
+ const r: any = msg as any;
286
+ const isError =
287
+ r.subtype !== "success" ||
288
+ r.is_error === true ||
289
+ typeof r.error === "string" ||
290
+ (Array.isArray(r.errors) && r.errors.length > 0);
291
+ if (isError) {
292
+ const errMsg =
293
+ r.error ??
294
+ (Array.isArray(r.errors) && r.errors.length > 0
295
+ ? r.errors.join("; ")
296
+ : `Claude CLI returned ${r.subtype ?? "non-success result"}`);
297
+ endStreamWithError(errMsg);
298
+ }
299
+ // For both success and error: clean up the subprocess
300
+ clearTimeout(inactivityTimer);
301
+ cleanupProcess(proc!);
302
+ rl.close();
303
+ }
304
+ });
305
+
306
+ // Wait for readline to close (result received or process ended)
307
+ await new Promise<void>((resolve) => {
308
+ rl.on("close", resolve);
309
+ });
310
+
311
+ // Push done event after readline closes (async). Pushing synchronously
312
+ // inside handleMessageStop prevents pi from executing tools.
313
+ // Guard with streamEnded to avoid pushing done after an error was already pushed.
314
+ if (!streamEnded) {
315
+ const output = bridge.getOutput();
316
+
317
+ // If stopReason is toolUse but there are no pi-known tool calls in content,
318
+ // it means only user MCP tools were called (filtered by event bridge).
319
+ // Override to "stop" so pi doesn't try to execute non-existent tools.
320
+ const piToolCalls = (output.content || []).filter(
321
+ (c: any) => c.type === "toolCall",
322
+ );
323
+ const effectiveReason =
324
+ output.stopReason === "toolUse" && piToolCalls.length === 0
325
+ ? "stop"
326
+ : output.stopReason;
327
+
328
+ streamEnded = true;
329
+ stream.push({
330
+ type: "done",
331
+ reason:
332
+ effectiveReason === "toolUse"
333
+ ? "toolUse"
334
+ : effectiveReason === "length"
335
+ ? "length"
336
+ : "stop",
337
+ message: { ...output, stopReason: effectiveReason },
338
+ });
339
+ stream.end();
340
+ }
341
+ } catch (err: any) {
342
+ stream.push({
343
+ type: "error",
344
+ reason: "error",
345
+ error: err.message ?? "Unexpected error in streamViaCli",
346
+ } as any);
347
+ stream.end();
348
+ } finally {
349
+ // Clean up abort listener
350
+ if (options?.signal && abortHandler) {
351
+ options.signal.removeEventListener("abort", abortHandler);
352
+ }
353
+ cleanupSystemPromptFile();
354
+ }
355
+ })();
356
+
357
+ return stream;
358
+ }
@@ -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,87 @@
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/max) to the CLI's effort
5
+ * levels (low/medium/high/xhigh/max). Opus models keep the elevated mapping where
6
+ * medium becomes high and high becomes max; all other models pass through 1:1.
7
+ *
8
+ * IMPORTANT: The CLI does NOT support --thinking-budget. Only --effort is supported.
9
+ */
10
+
11
+ import type { ThinkingLevel, ThinkingBudgets } from "@earendil-works/pi-ai";
12
+
13
+ /** CLI effort levels accepted by the --effort flag */
14
+ export type CliEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
15
+
16
+ /**
17
+ * Standard model mapping: pi ThinkingLevel -> CLI effort.
18
+ * The CLI accepts the full ladder (low/medium/high/xhigh/max) for current
19
+ * models (verified with claude-fable-5 and claude-sonnet-5 on claude CLI
20
+ * 2.x), so levels pass through 1:1 instead of capping at high.
21
+ */
22
+ const STANDARD_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
23
+ minimal: "low",
24
+ low: "low",
25
+ medium: "medium",
26
+ high: "high",
27
+ xhigh: "xhigh",
28
+ max: "max",
29
+ };
30
+
31
+ /**
32
+ * Opus model mapping: shifted up for elevated reasoning.
33
+ * Opus models get max capability at high/xhigh/max levels.
34
+ */
35
+ const OPUS_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
36
+ minimal: "low",
37
+ low: "low",
38
+ medium: "high", // shifted: standard high
39
+ high: "max", // shifted: maximum capability
40
+ xhigh: "max", // Opus gets max
41
+ max: "max",
42
+ };
43
+
44
+ /**
45
+ * Detect whether a model ID refers to an Opus model.
46
+ * Uses includes('opus') for forward-compatibility with future Opus versions.
47
+ *
48
+ * @param modelId - The model identifier string
49
+ * @returns true if the model is an Opus variant
50
+ */
51
+ export function isOpusModel(modelId: string): boolean {
52
+ return modelId.includes("opus");
53
+ }
54
+
55
+ /**
56
+ * Map pi's ThinkingLevel to a CLI effort string.
57
+ *
58
+ * When reasoning is undefined, returns undefined so the --effort flag is omitted
59
+ * entirely, letting the CLI use its default behavior. When thinkingBudgets are
60
+ * provided, a console.warn is logged because the CLI only supports effort levels,
61
+ * not token budgets.
62
+ *
63
+ * @param reasoning - Pi's thinking level (undefined = omit flag)
64
+ * @param modelId - Model ID for Opus detection
65
+ * @param thinkingBudgets - Custom budgets (logged as unsupported, not applied)
66
+ * @returns CLI effort level string, or undefined if flag should be omitted
67
+ */
68
+ export function mapThinkingEffort(
69
+ reasoning?: ThinkingLevel,
70
+ modelId?: string,
71
+ thinkingBudgets?: ThinkingBudgets,
72
+ ): CliEffortLevel | undefined {
73
+ if (reasoning === undefined) {
74
+ return undefined; // omit --effort flag entirely
75
+ }
76
+
77
+ if (thinkingBudgets && Object.keys(thinkingBudgets).length > 0) {
78
+ console.warn(
79
+ "[pi-claude-cli] Custom thinkingBudgets are not supported with CLI subprocess. " +
80
+ "The CLI uses --effort levels instead of token budgets. Budgets will be ignored.",
81
+ );
82
+ }
83
+
84
+ const isOpus = modelId ? isOpusModel(modelId) : false;
85
+ const map = isOpus ? OPUS_EFFORT_MAP : STANDARD_EFFORT_MAP;
86
+ return map[reasoning];
87
+ }
@@ -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
+ }