@saccolabs/pi-claude-cli 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/package.json +1 -1
- package/src/event-bridge.ts +10 -7
- package/src/process-manager.ts +48 -4
- package/src/prompt-builder.ts +72 -1
- package/src/provider.ts +127 -83
- package/src/session-map.ts +67 -0
- package/src/system-prompt-mode.ts +45 -0
- package/src/tool-mapping.ts +11 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ A [pi](https://github.com/earendil-works/pi) extension that routes LLM calls thr
|
|
|
12
12
|
|
|
13
13
|
## How it works
|
|
14
14
|
|
|
15
|
-
The extension registers as a custom pi provider exposing all Claude models.
|
|
15
|
+
The extension registers as a custom pi provider exposing all Claude models. It runs in **observer mode**: the Claude Code CLI is a first-class agent that owns its loop, its tools and its session — pi is the system of record and observes the stream. One CLI session per pi session, resumed with `--resume` on every follow-up turn, so token use matches using the CLI directly. Built-in tools (Read, Bash, …) execute natively inside the CLI and surface to pi as `[Claude Code · Name]` activity markers. Custom pi tools are advertised via a schema-only MCP server and **handed off**: the provider interrupts the turn cleanly, pi executes the tool (all pi hooks fire), and the next turn resumes with the result.
|
|
16
16
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
@@ -44,7 +44,10 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
|
|
|
44
44
|
- Maps tool names and arguments bidirectionally between Claude and pi
|
|
45
45
|
- Exposes custom pi tools to Claude via MCP (schema-only, no execution)
|
|
46
46
|
- Break-early pattern prevents Claude CLI from auto-executing tools
|
|
47
|
-
-
|
|
47
|
+
- One CLI session per pi session (sidecar-mapped), resumed on every follow-up turn — native caching, no history replay
|
|
48
|
+
- Native tool execution: the CLI runs its own tools; guards are injected as Claude Code PreToolUse hooks via `PI_CLAUDE_CLI_SETTINGS`
|
|
49
|
+
- Reports account rate-limit state (window, reset, overage) to the front-end
|
|
50
|
+
on the `claude-rate-limit` status key — never mixed into turn content
|
|
48
51
|
- Configurable thinking effort across the full ladder (low to max) for all models, with elevated mapping for Opus
|
|
49
52
|
- Cross-platform subprocess management (Windows, macOS, Linux)
|
|
50
53
|
- Inactivity timeout and process registry for cleanup
|
|
@@ -55,6 +58,11 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
|
|
|
55
58
|
the two-ledger session model, error recovery, and the CLI compatibility
|
|
56
59
|
notes (including the 2.x control-protocol shape).
|
|
57
60
|
|
|
61
|
+
Two of its sections are **contracts a front-end can depend on**, so read
|
|
62
|
+
them before changing what this extension emits: the
|
|
63
|
+
`[Claude Code · Tool {args}]` marker string, and the `claude-rate-limit`
|
|
64
|
+
status key.
|
|
65
|
+
|
|
58
66
|
## What your Claude environment contributes
|
|
59
67
|
|
|
60
68
|
Each turn runs a real `claude -p` subprocess in your workspace, so your
|
|
@@ -81,6 +89,41 @@ allowlists). Model access and your subscription login are unaffected.
|
|
|
81
89
|
Related knobs: `PI_CLAUDE_CLI_TIMEOUT_MS` overrides the 300s inactivity
|
|
82
90
|
timeout (CLI-side tools can be silent on stdout for minutes).
|
|
83
91
|
|
|
92
|
+
### Which system prompt
|
|
93
|
+
|
|
94
|
+
`PI_CLAUDE_CLI_SYSTEM_PROMPT` chooses whose system prompt the subprocess
|
|
95
|
+
runs under. It is read per spawn, so a host can change it between sessions.
|
|
96
|
+
|
|
97
|
+
| Value | Behaviour |
|
|
98
|
+
| -------------------- | ------------------------------------------------------------------------- |
|
|
99
|
+
| `claude` _(default)_ | `--append-system-prompt`: pi's prompt layers on top of Claude Code's own. |
|
|
100
|
+
| `pi` | `--system-prompt`: pi's prompt replaces Claude Code's entirely. |
|
|
101
|
+
|
|
102
|
+
`minimal` is accepted as an alias for `pi`, `append` for `claude`; anything
|
|
103
|
+
unrecognised falls back to the default rather than failing a session.
|
|
104
|
+
|
|
105
|
+
**Why you might want `pi`.** The point of a minimal harness is not inheriting
|
|
106
|
+
another agent's preamble. Measured on a real session, the CLI's fixed cached
|
|
107
|
+
prefix was 17,475 tokens; the tool schemas (~4.3k) stay either way, but the
|
|
108
|
+
rest is Claude Code's prompt, and pi's own — after the tool-section rewrite
|
|
109
|
+
below — is ~674 tokens. That frees roughly 12k tokens of context window per
|
|
110
|
+
call. It is a window win, not a cost win: the prefix is cached and bills at
|
|
111
|
+
0.1x.
|
|
112
|
+
|
|
113
|
+
**Why the default is still `claude`.** Claude Code's prompt carries operating
|
|
114
|
+
guidance for its own tools. Replacing it leaves the model with pi's
|
|
115
|
+
instructions plus the raw tool schemas. To stop that being actively
|
|
116
|
+
misleading, `pi` mode rewrites pi's tool sections — which name pi's tools
|
|
117
|
+
(`read`, `edit`, `grep`, `find`, `ls`) and pi's parameters (`path`,
|
|
118
|
+
`oldText`, `newText`) — into Claude Code's vocabulary (`Read`, `Edit`,
|
|
119
|
+
`Grep`, `Glob`, with `file_path`, `old_string`, `new_string`). If pi ever
|
|
120
|
+
restyles its prompt so the `Available tools:` / `Guidelines:` anchors are
|
|
121
|
+
missing, the prompt passes through untouched rather than being mangled.
|
|
122
|
+
|
|
123
|
+
Only the session-creating turn sends a system prompt — the CLI keeps it for
|
|
124
|
+
the life of the session — so a change takes effect on the next new session,
|
|
125
|
+
not the current one.
|
|
126
|
+
|
|
84
127
|
## License
|
|
85
128
|
|
|
86
129
|
MIT
|
package/package.json
CHANGED
package/src/event-bridge.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
import {
|
|
18
18
|
mapClaudeToolNameToPi,
|
|
19
19
|
translateClaudeArgsToPi,
|
|
20
|
-
|
|
20
|
+
isHandoffClaudeTool,
|
|
21
21
|
} from "./tool-mapping.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -267,9 +267,11 @@ export function createEventBridge(
|
|
|
267
267
|
} else if (blockType === "tool_use") {
|
|
268
268
|
const claudeName = event.content_block!.name!;
|
|
269
269
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
270
|
+
// Observer mode: the CLI executes its own tools (built-ins, WebSearch,
|
|
271
|
+
// user MCP, Task). Those surface as marker text via the envelope path,
|
|
272
|
+
// never as pi toolCall blocks. Only HANDOFF tools — custom pi tools
|
|
273
|
+
// behind the schema-only MCP server — become toolCalls for pi's loop.
|
|
274
|
+
if (!isHandoffClaudeTool(claudeName)) {
|
|
273
275
|
return;
|
|
274
276
|
}
|
|
275
277
|
|
|
@@ -501,9 +503,10 @@ export function createEventBridge(
|
|
|
501
503
|
if (envelope.parent_tool_use_id) return;
|
|
502
504
|
for (const block of envelope.message?.content ?? []) {
|
|
503
505
|
if (block.type !== "tool_use" || !block.name || !block.id) continue;
|
|
504
|
-
//
|
|
505
|
-
// tool calls — markers are
|
|
506
|
-
|
|
506
|
+
// Handoff tools already streamed through the SSE path as real pi
|
|
507
|
+
// tool calls — markers are for everything the CLI executes itself,
|
|
508
|
+
// which in observer mode includes the built-in file tools.
|
|
509
|
+
if (isHandoffClaudeTool(block.name)) continue;
|
|
507
510
|
if (markedToolIds.has(block.id)) continue;
|
|
508
511
|
markedToolIds.add(block.id);
|
|
509
512
|
|
package/src/process-manager.ts
CHANGED
|
@@ -12,13 +12,19 @@ import { writeFileSync, unlinkSync } from "node:fs";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import type { ChildProcess } from "node:child_process";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_SYSTEM_PROMPT_MODE,
|
|
17
|
+
type SystemPromptMode,
|
|
18
|
+
} from "./system-prompt-mode.js";
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Spawn a Claude CLI subprocess with all required flags for stream-json communication.
|
|
18
22
|
*
|
|
19
23
|
* @param modelId - The model ID to pass via --model flag
|
|
20
|
-
* @param systemPrompt - Optional system prompt
|
|
21
|
-
*
|
|
24
|
+
* @param systemPrompt - Optional system prompt. In `claude` mode it is appended
|
|
25
|
+
* to Claude Code's own via --append-system-prompt; in `pi` mode it replaces
|
|
26
|
+
* it via --system-prompt. See src/system-prompt-mode.ts.
|
|
27
|
+
* @param options - Optional cwd, AbortSignal, effort level and prompt mode
|
|
22
28
|
* @returns The spawned ChildProcess with piped stdin/stdout/stderr
|
|
23
29
|
*/
|
|
24
30
|
/** Truthy PI_CLAUDE_CLI_HERMETIC opts in to hermetic mode (see README). */
|
|
@@ -37,6 +43,7 @@ export function spawnClaude(
|
|
|
37
43
|
mcpConfigPath?: string;
|
|
38
44
|
resumeSessionId?: string;
|
|
39
45
|
newSessionId?: string;
|
|
46
|
+
systemPromptMode?: SystemPromptMode;
|
|
40
47
|
},
|
|
41
48
|
): ChildProcess {
|
|
42
49
|
const args = [
|
|
@@ -73,13 +80,27 @@ export function spawnClaude(
|
|
|
73
80
|
|
|
74
81
|
if (systemPrompt) {
|
|
75
82
|
// Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
|
|
76
|
-
//
|
|
83
|
+
// Both flags accept a file path or literal text.
|
|
77
84
|
const tmpFile = join(
|
|
78
85
|
tmpdir(),
|
|
79
86
|
`pi-claude-cli-sysprompt-${process.pid}.txt`,
|
|
80
87
|
);
|
|
81
88
|
writeFileSync(tmpFile, systemPrompt, "utf-8");
|
|
82
|
-
|
|
89
|
+
// `pi` mode replaces Claude Code's prompt outright; `claude` mode layers
|
|
90
|
+
// pi's on top of it. See src/system-prompt-mode.ts for the trade-off.
|
|
91
|
+
const mode = options?.systemPromptMode ?? DEFAULT_SYSTEM_PROMPT_MODE;
|
|
92
|
+
args.push(
|
|
93
|
+
mode === "pi" ? "--system-prompt" : "--append-system-prompt",
|
|
94
|
+
tmpFile,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Host-supplied Claude Code settings (hooks, permissions). This is how a
|
|
99
|
+
// host injects PreToolUse guards — e.g. pidex's worktree-paths guard —
|
|
100
|
+
// without pi intercepting the CLI's native tool execution.
|
|
101
|
+
const settingsPath = process.env.PI_CLAUDE_CLI_SETTINGS;
|
|
102
|
+
if (settingsPath) {
|
|
103
|
+
args.push("--settings", settingsPath);
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
if (options?.effort) {
|
|
@@ -135,6 +156,29 @@ export function writeUserMessage(
|
|
|
135
156
|
proc.stdin!.write(JSON.stringify(message) + "\n");
|
|
136
157
|
}
|
|
137
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Ask the CLI to end the current turn cleanly — the same interrupt a human
|
|
161
|
+
* Esc produces. Unlike SIGKILL this lets the CLI persist the turn, so the
|
|
162
|
+
* session stays resumable without transcript corruption. The turn then ends
|
|
163
|
+
* with a `result` of subtype `error_during_execution`, which callers must
|
|
164
|
+
* treat as expected.
|
|
165
|
+
*/
|
|
166
|
+
export function sendInterrupt(proc: ChildProcess): void {
|
|
167
|
+
// `exitCode != null` (loose): undefined means "has not exited" on mocks and
|
|
168
|
+
// some stream wrappers, and must count as alive.
|
|
169
|
+
if (proc.killed || proc.exitCode != null || !proc.stdin) return;
|
|
170
|
+
const request = {
|
|
171
|
+
type: "control_request",
|
|
172
|
+
request_id: `int-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`,
|
|
173
|
+
request: { subtype: "interrupt" },
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
proc.stdin.write(JSON.stringify(request) + "\n");
|
|
177
|
+
} catch {
|
|
178
|
+
// stdin already closed — the force-kill fallback will handle it.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
/**
|
|
139
183
|
* Force-kill a subprocess immediately via SIGKILL.
|
|
140
184
|
* No-ops if the process is already dead (killed or exited).
|
package/src/prompt-builder.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { resolve, join, dirname } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_SYSTEM_PROMPT_MODE,
|
|
16
|
+
type SystemPromptMode,
|
|
17
|
+
} from "./system-prompt-mode.js";
|
|
14
18
|
import {
|
|
15
19
|
mapPiToolNameToClaude,
|
|
16
20
|
translatePiArgsToClaude,
|
|
@@ -343,19 +347,86 @@ function findFinalUserMessageIndex(messages: any[]): number {
|
|
|
343
347
|
return -1;
|
|
344
348
|
}
|
|
345
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Tool guidance in Claude Code's vocabulary, used only in `pi` prompt mode.
|
|
352
|
+
*
|
|
353
|
+
* Replacing Claude Code's system prompt means the model still receives its
|
|
354
|
+
* tool *schemas* from the API but loses the prose about when and how to use
|
|
355
|
+
* them. Pi's own prose can't stand in unedited: it names pi's tools
|
|
356
|
+
* (`read`, `edit`, `grep`, `find`, `ls`) and pi's parameters (`path`,
|
|
357
|
+
* `oldText`, `newText`), none of which match what the model is actually
|
|
358
|
+
* handed (`Read`, `Edit`, `Grep`, `Glob`, with `file_path`, `old_string`,
|
|
359
|
+
* `new_string`). Left in place it is not merely useless but actively
|
|
360
|
+
* misleading, so it is swapped for this.
|
|
361
|
+
*/
|
|
362
|
+
const CLAUDE_CODE_TOOLS_SECTION = `Available tools:
|
|
363
|
+
- Read: Read file contents (key param: file_path)
|
|
364
|
+
- Write: Create or overwrite files (key params: file_path, content)
|
|
365
|
+
- Edit: Make precise edits with exact text replacement (key params: file_path, old_string, new_string)
|
|
366
|
+
- Bash: Execute bash commands (key param: command)
|
|
367
|
+
- Grep: Search file contents for patterns (key params: pattern, path)
|
|
368
|
+
- Glob: Find files by glob pattern (key params: pattern, path)
|
|
369
|
+
|
|
370
|
+
In addition to the tools above, you may have access to other custom tools depending on the project.
|
|
371
|
+
|
|
372
|
+
Guidelines:
|
|
373
|
+
- Prefer Grep and Glob over Bash for file exploration (faster, respects .gitignore)
|
|
374
|
+
- Use Read to examine files before editing
|
|
375
|
+
- Use Edit for precise changes (old_string must match exactly)
|
|
376
|
+
- Use Write only for new files or complete rewrites
|
|
377
|
+
- When summarizing your actions, output plain text directly — do NOT use Bash to display what you did
|
|
378
|
+
- Be concise in your responses
|
|
379
|
+
- Show file paths clearly when working with files`;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Swap pi's tool documentation for Claude Code's.
|
|
383
|
+
*
|
|
384
|
+
* Pi's prompt is a sequence of blank-line-separated blocks; the tool material
|
|
385
|
+
* runs from the `Available tools:` block through the `Guidelines:` block
|
|
386
|
+
* (verified against pi 0.84.2, where those are blocks 1 and 3 of 5). Both
|
|
387
|
+
* anchors must be present and in order, otherwise the prompt is returned
|
|
388
|
+
* untouched — if pi restyles its prompt the failure mode should be "kept the
|
|
389
|
+
* original", never a mangled one.
|
|
390
|
+
*
|
|
391
|
+
* Exported for tests.
|
|
392
|
+
*/
|
|
393
|
+
export function rewritePiToolSections(systemPrompt: string): string {
|
|
394
|
+
const blocks = systemPrompt.split("\n\n");
|
|
395
|
+
|
|
396
|
+
const start = blocks.findIndex((b) => b.startsWith("Available tools:"));
|
|
397
|
+
const end = blocks.findIndex((b) => b.startsWith("Guidelines:"));
|
|
398
|
+
if (start === -1 || end === -1 || end < start) return systemPrompt;
|
|
399
|
+
|
|
400
|
+
const kept = blocks.filter((_, i) => i < start || i > end);
|
|
401
|
+
// Reinstate the replacement where the originals were, so the intro still
|
|
402
|
+
// reads into it and anything after (pi's docs section) still follows.
|
|
403
|
+
kept.splice(start, 0, CLAUDE_CODE_TOOLS_SECTION);
|
|
404
|
+
return kept.join("\n\n");
|
|
405
|
+
}
|
|
406
|
+
|
|
346
407
|
/**
|
|
347
408
|
* Builds the system prompt from the context's systemPrompt field,
|
|
348
409
|
* appending AGENTS.md content if found (walking up from cwd, then global fallback).
|
|
349
410
|
* Sanitizes .pi references to .claude for Claude Code compatibility.
|
|
411
|
+
*
|
|
412
|
+
* In `pi` mode the caller passes the prompt to `--system-prompt`, replacing
|
|
413
|
+
* Claude Code's own, so pi's tool sections are rewritten into Claude Code's
|
|
414
|
+
* names first. In `claude` mode the prompt is appended to Claude Code's and
|
|
415
|
+
* pi's wording is left exactly as pi wrote it.
|
|
350
416
|
*/
|
|
351
417
|
export function buildSystemPrompt(
|
|
352
418
|
context: { systemPrompt?: string; messages: any[] },
|
|
353
419
|
cwd: string,
|
|
420
|
+
mode: SystemPromptMode = DEFAULT_SYSTEM_PROMPT_MODE,
|
|
354
421
|
): string {
|
|
355
422
|
const parts: string[] = [];
|
|
356
423
|
|
|
357
424
|
if (context.systemPrompt) {
|
|
358
|
-
parts.push(
|
|
425
|
+
parts.push(
|
|
426
|
+
mode === "pi"
|
|
427
|
+
? rewritePiToolSections(context.systemPrompt)
|
|
428
|
+
: context.systemPrompt,
|
|
429
|
+
);
|
|
359
430
|
}
|
|
360
431
|
|
|
361
432
|
// Look for AGENTS.md
|
package/src/provider.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider orchestration
|
|
2
|
+
* Provider orchestration — observer mode (docs/SPEC-observer-mode.md).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
4
|
+
* The Claude CLI owns its loop, its tools, and its session. streamViaCli:
|
|
5
|
+
* 1. Resolves the pi session's CLI session (session-map) — resume, or
|
|
6
|
+
* create/import a fresh one from pi's full history
|
|
7
|
+
* 2. Spawns `claude -p`, writes the turn's prompt to stdin as NDJSON
|
|
8
|
+
* 3. Streams events to pi: prose/thinking verbatim; the CLI's own tool
|
|
9
|
+
* executions as `[Claude Code · Name]` markers; HANDOFF tools (custom pi
|
|
10
|
+
* tools) as real pi toolCall blocks
|
|
11
|
+
* 4. On a handoff tool at message_stop: sends a clean `interrupt` (never a
|
|
12
|
+
* kill — a SIGKILL mid-turn corrupts the CLI transcript and poisons every
|
|
13
|
+
* later resume) and ends the stream stopReason=toolUse so pi executes
|
|
14
|
+
* 5. Hardened lifecycle: inactivity timeout, exit handler, streamEnded
|
|
15
|
+
* guard, abort = interrupt + delayed SIGKILL backstop, process registry
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { createInterface } from "node:readline";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
buildSystemPrompt,
|
|
27
28
|
buildResumePrompt,
|
|
28
29
|
} from "./prompt-builder.js";
|
|
30
|
+
import { resolveSystemPromptMode } from "./system-prompt-mode.js";
|
|
29
31
|
import {
|
|
30
32
|
spawnClaude,
|
|
31
33
|
writeUserMessage,
|
|
@@ -34,12 +36,19 @@ import {
|
|
|
34
36
|
forceKillProcess,
|
|
35
37
|
registerProcess,
|
|
36
38
|
cleanupSystemPromptFile,
|
|
39
|
+
sendInterrupt,
|
|
37
40
|
} from "./process-manager.js";
|
|
38
41
|
import { parseLine } from "./stream-parser.js";
|
|
39
42
|
import { createEventBridge } from "./event-bridge.js";
|
|
40
43
|
import { handleControlRequest } from "./control-handler.js";
|
|
41
44
|
import { mapThinkingEffort } from "./thinking-config.js";
|
|
42
|
-
import {
|
|
45
|
+
import { isHandoffClaudeTool } from "./tool-mapping.js";
|
|
46
|
+
import {
|
|
47
|
+
getCliSession,
|
|
48
|
+
setCliSession,
|
|
49
|
+
clearCliSession,
|
|
50
|
+
} from "./session-map.js";
|
|
51
|
+
import { randomUUID } from "node:crypto";
|
|
43
52
|
/** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
|
|
44
53
|
/**
|
|
45
54
|
* Inactivity timeout. CLI-side tool executions (web search, user MCP
|
|
@@ -59,17 +68,32 @@ type StreamViaCLiOptions = SimpleStreamOptions & {
|
|
|
59
68
|
onRateLimit?: (info: Record<string, unknown>) => void;
|
|
60
69
|
};
|
|
61
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The mapped CLI session is stale when pi's history moved on without it:
|
|
73
|
+
* an assistant turn from another provider (model switch) after — or with no —
|
|
74
|
+
* pi-claude-cli turn means the CLI never saw that exchange. Resuming would
|
|
75
|
+
* answer from a conversation missing turns, so reimport instead.
|
|
76
|
+
*/
|
|
77
|
+
function cliSessionIsStale(messages: any[]): boolean {
|
|
78
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
79
|
+
const m = messages[i];
|
|
80
|
+
if (m?.role !== "assistant") continue;
|
|
81
|
+
return !(m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli");
|
|
82
|
+
}
|
|
83
|
+
return false; // no assistant turns at all: nothing to be behind
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
/**
|
|
63
87
|
* Stream a response from Claude CLI as an AssistantMessageEventStream.
|
|
64
88
|
*
|
|
65
|
-
* Orchestrates the full subprocess lifecycle:
|
|
66
|
-
* bridge events, handle result,
|
|
67
|
-
*
|
|
68
|
-
*
|
|
89
|
+
* Orchestrates the full subprocess lifecycle: resolve/resume the CLI session,
|
|
90
|
+
* spawn, write prompt, parse NDJSON, bridge events, handle result, clean up.
|
|
91
|
+
* The CLI executes its own tools; only handoff (custom pi) tools end the turn
|
|
92
|
+
* early, via a clean interrupt at message_stop.
|
|
69
93
|
*
|
|
70
|
-
* Hardened with: inactivity timeout
|
|
71
|
-
* surfacing, streamEnded guard against double errors, abort via
|
|
72
|
-
* process registry integration for teardown cleanup.
|
|
94
|
+
* Hardened with: inactivity timeout, subprocess exit handler with stderr
|
|
95
|
+
* surfacing, streamEnded guard against double errors, abort via interrupt with
|
|
96
|
+
* a SIGKILL backstop, and process registry integration for teardown cleanup.
|
|
73
97
|
*
|
|
74
98
|
* @param model - The model to use (from pi's model catalog)
|
|
75
99
|
* @param context - The conversation context with messages and system prompt
|
|
@@ -85,11 +109,9 @@ export function streamViaCli(
|
|
|
85
109
|
|
|
86
110
|
/**
|
|
87
111
|
* One subprocess attempt. Returns "resume-miss" (without touching the
|
|
88
|
-
* stream) when
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* The driver below retries once with a full-history replay, which also
|
|
92
|
-
* re-registers the CLI cache under the current session id.
|
|
112
|
+
* stream) when the sidecar pointed --resume at a CLI session that no longer
|
|
113
|
+
* exists on disk. The driver below retries once with a fresh session
|
|
114
|
+
* imported from pi's full history, re-recording the mapping.
|
|
93
115
|
*/
|
|
94
116
|
async function runOnce(
|
|
95
117
|
forceFullReplay: boolean,
|
|
@@ -97,36 +119,49 @@ export function streamViaCli(
|
|
|
97
119
|
let proc: ReturnType<typeof spawnClaude> | undefined;
|
|
98
120
|
let abortHandler: (() => void) | undefined;
|
|
99
121
|
let resumeMiss = false;
|
|
122
|
+
// Track HANDOFF tool_use blocks (custom pi tools) for the interrupt
|
|
123
|
+
// decision at message_stop. Built-ins run natively and never interrupt.
|
|
124
|
+
let sawHandoffTool = false;
|
|
125
|
+
// Set once we have asked the CLI to end the turn (handoff or abort):
|
|
126
|
+
// stream content is frozen and only the result envelope is awaited.
|
|
127
|
+
let selfInterrupted = false;
|
|
128
|
+
// Set on pi-initiated abort so the turn ends quietly, not as an error.
|
|
129
|
+
let aborted = false;
|
|
100
130
|
|
|
101
131
|
try {
|
|
102
132
|
const cwd = options?.cwd ?? process.cwd();
|
|
103
133
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// assistant message.
|
|
112
|
-
const hasPriorCliTurn = (context.messages as any[]).some(
|
|
113
|
-
(m) =>
|
|
114
|
-
m?.role === "assistant" &&
|
|
115
|
-
(m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli"),
|
|
116
|
-
);
|
|
134
|
+
// One CLI session per pi session, resumed across turns and restarts.
|
|
135
|
+
// Resume only when a mapping exists AND the CLI session is not behind
|
|
136
|
+
// pi's history (a foreign-provider turn after our last one means the
|
|
137
|
+
// CLI never saw that exchange). Anything else — first turn, fork,
|
|
138
|
+
// model switch, lost sidecar, resume miss — is one reimport.
|
|
139
|
+
const piSessionId = options?.sessionId;
|
|
140
|
+
const mappedCliId = piSessionId ? getCliSession(piSessionId) : undefined;
|
|
117
141
|
const resumeSessionId =
|
|
118
|
-
!forceFullReplay &&
|
|
119
|
-
|
|
142
|
+
!forceFullReplay &&
|
|
143
|
+
mappedCliId &&
|
|
144
|
+
!cliSessionIsStale(context.messages as any[])
|
|
145
|
+
? mappedCliId
|
|
120
146
|
: undefined;
|
|
147
|
+
// Fresh sessions get a provider-minted id, never pi's: the CLI refuses
|
|
148
|
+
// a --session-id it has already seen, and forks reuse pi ids.
|
|
149
|
+
const newCliId = resumeSessionId ? undefined : randomUUID();
|
|
121
150
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
151
|
+
// Resume sends only the delta since the last assistant turn (new user
|
|
152
|
+
// text, handoff tool results). Create/import sends the full history.
|
|
124
153
|
const prompt = resumeSessionId
|
|
125
154
|
? buildResumePrompt(context)
|
|
126
155
|
: buildPrompt(context);
|
|
156
|
+
// Resolved per spawn rather than once at module load so a host can flip
|
|
157
|
+
// the setting between sessions without restarting pi. Only the
|
|
158
|
+
// session-creating turn carries a system prompt — the CLI keeps it for
|
|
159
|
+
// the life of the session — so switching mid-session takes effect on
|
|
160
|
+
// the next new session, not this one.
|
|
161
|
+
const systemPromptMode = resolveSystemPromptMode();
|
|
127
162
|
const systemPrompt = resumeSessionId
|
|
128
163
|
? undefined
|
|
129
|
-
: buildSystemPrompt(context, cwd);
|
|
164
|
+
: buildSystemPrompt(context, cwd, systemPromptMode);
|
|
130
165
|
|
|
131
166
|
// Compute effort level from reasoning options
|
|
132
167
|
const effort = mapThinkingEffort(
|
|
@@ -142,8 +177,12 @@ export function streamViaCli(
|
|
|
142
177
|
effort,
|
|
143
178
|
mcpConfigPath: options?.mcpConfigPath,
|
|
144
179
|
resumeSessionId,
|
|
145
|
-
newSessionId:
|
|
180
|
+
newSessionId: newCliId,
|
|
181
|
+
systemPromptMode,
|
|
146
182
|
});
|
|
183
|
+
// Record the mapping as soon as the session exists on disk. On a turn
|
|
184
|
+
// that later errors, the mapping is cleared so the next turn reimports.
|
|
185
|
+
if (piSessionId && newCliId) setCliSession(piSessionId, newCliId);
|
|
147
186
|
const getStderr = captureStderr(proc);
|
|
148
187
|
|
|
149
188
|
// Register in global process registry for teardown cleanup
|
|
@@ -200,12 +239,16 @@ export function streamViaCli(
|
|
|
200
239
|
}, INACTIVITY_TIMEOUT_MS);
|
|
201
240
|
}
|
|
202
241
|
|
|
203
|
-
//
|
|
242
|
+
// Abort = the CLI's own interrupt (keeps the session resumable), with a
|
|
243
|
+
// SIGKILL backstop in case the CLI is wedged and never emits a result.
|
|
204
244
|
if (options?.signal) {
|
|
205
245
|
abortHandler = () => {
|
|
206
|
-
if (proc)
|
|
207
|
-
|
|
208
|
-
|
|
246
|
+
if (!proc) return;
|
|
247
|
+
aborted = true;
|
|
248
|
+
selfInterrupted = true;
|
|
249
|
+
sendInterrupt(proc);
|
|
250
|
+
const backstop = setTimeout(() => forceKillProcess(proc!), 2000);
|
|
251
|
+
proc.once("close", () => clearTimeout(backstop));
|
|
209
252
|
};
|
|
210
253
|
|
|
211
254
|
if (options.signal.aborted) {
|
|
@@ -215,8 +258,6 @@ export function streamViaCli(
|
|
|
215
258
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
216
259
|
}
|
|
217
260
|
|
|
218
|
-
// Track tool_use blocks for break-early decision at message_stop
|
|
219
|
-
let sawBuiltInOrCustomTool = false;
|
|
220
261
|
// Guard against buffered readline lines firing after rl.close()
|
|
221
262
|
let broken = false;
|
|
222
263
|
|
|
@@ -229,7 +270,7 @@ export function streamViaCli(
|
|
|
229
270
|
|
|
230
271
|
// Handle process error -- use endStreamWithError for guard
|
|
231
272
|
proc.on("error", (err: Error) => {
|
|
232
|
-
if (broken) return; //
|
|
273
|
+
if (broken) return; // resume-miss retry owns the stream
|
|
233
274
|
const stderr = getStderr();
|
|
234
275
|
endStreamWithError(stderr || err.message);
|
|
235
276
|
});
|
|
@@ -237,7 +278,7 @@ export function streamViaCli(
|
|
|
237
278
|
// Handle subprocess close -- surface crashes with stderr and exit code
|
|
238
279
|
proc.on("close", (code: number | null, _signal: string | null) => {
|
|
239
280
|
clearTimeout(inactivityTimer);
|
|
240
|
-
if (broken) return; //
|
|
281
|
+
if (broken) return; // resume-miss retry owns the stream
|
|
241
282
|
if (code !== 0 && code !== null) {
|
|
242
283
|
const stderr = getStderr();
|
|
243
284
|
const message = stderr
|
|
@@ -254,7 +295,7 @@ export function streamViaCli(
|
|
|
254
295
|
// NOTE: Using 'line' event instead of `for await` because the async
|
|
255
296
|
// iterator batches lines, breaking real-time streaming to pi.
|
|
256
297
|
rl.on("line", (line: string) => {
|
|
257
|
-
if (broken) return; // Guard: ignore buffered lines after
|
|
298
|
+
if (broken) return; // Guard: ignore buffered lines after a resume miss
|
|
258
299
|
|
|
259
300
|
// Reset inactivity timer on each line of output
|
|
260
301
|
resetInactivityTimer();
|
|
@@ -266,37 +307,38 @@ export function streamViaCli(
|
|
|
266
307
|
// Only forward top-level events to pi's event bridge.
|
|
267
308
|
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
|
|
268
309
|
const isTopLevel = !(msg as any).parent_tool_use_id;
|
|
269
|
-
if (isTopLevel) {
|
|
310
|
+
if (isTopLevel && !selfInterrupted) {
|
|
270
311
|
bridge.handleEvent(msg.event);
|
|
271
312
|
}
|
|
272
313
|
|
|
273
|
-
// Track tool_use blocks
|
|
314
|
+
// Track handoff tool_use blocks (top-level only). Built-ins and
|
|
315
|
+
// CLI-internal tools execute natively and must not interrupt.
|
|
274
316
|
if (
|
|
275
317
|
isTopLevel &&
|
|
276
318
|
msg.event.type === "content_block_start" &&
|
|
277
319
|
msg.event.content_block?.type === "tool_use"
|
|
278
320
|
) {
|
|
279
321
|
const toolName = msg.event.content_block.name;
|
|
280
|
-
if (toolName &&
|
|
281
|
-
|
|
282
|
-
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
|
283
|
-
sawBuiltInOrCustomTool = true;
|
|
322
|
+
if (toolName && isHandoffClaudeTool(toolName)) {
|
|
323
|
+
sawHandoffTool = true;
|
|
284
324
|
}
|
|
285
325
|
}
|
|
286
326
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
327
|
+
// Handoff at message_stop: ask the CLI to end the turn CLEANLY so
|
|
328
|
+
// the session file stays truthful and resumable, then wait for the
|
|
329
|
+
// result envelope. Never SIGKILL here — a kill truncates the
|
|
330
|
+
// transcript before the assistant turn is written, and every later
|
|
331
|
+
// --resume then splices in synthetic "No response requested."
|
|
332
|
+
// filler that the model eventually imitates.
|
|
289
333
|
if (
|
|
290
334
|
isTopLevel &&
|
|
291
335
|
msg.event.type === "message_stop" &&
|
|
292
|
-
|
|
336
|
+
sawHandoffTool &&
|
|
337
|
+
!selfInterrupted
|
|
293
338
|
) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
forceKillProcess(proc!);
|
|
298
|
-
rl.close();
|
|
299
|
-
return; // Don't process further -- done event already pushed by event bridge
|
|
339
|
+
selfInterrupted = true;
|
|
340
|
+
sendInterrupt(proc!);
|
|
341
|
+
return;
|
|
300
342
|
}
|
|
301
343
|
} else if (msg.type === "rate_limit_event") {
|
|
302
344
|
// Account-level state, not turn content: hand it to the host so a
|
|
@@ -311,25 +353,22 @@ export function streamViaCli(
|
|
|
311
353
|
}
|
|
312
354
|
}
|
|
313
355
|
} else if (msg.type === "assistant") {
|
|
314
|
-
// Complete-block envelopes: marker text for CLI
|
|
315
|
-
//
|
|
316
|
-
|
|
356
|
+
// Complete-block envelopes: marker text for the CLI's own tool
|
|
357
|
+
// executions (built-ins, WebSearch, user MCP, …), which in observer
|
|
358
|
+
// mode is every tool except handoffs.
|
|
359
|
+
if (!selfInterrupted) bridge.handleAssistantEnvelope(msg as any);
|
|
317
360
|
} else if (msg.type === "user") {
|
|
318
361
|
// Tool results the CLI feeds back between cycles — internal.
|
|
319
362
|
} else if (msg.type === "control_request") {
|
|
320
363
|
handleControlRequest(msg, proc!.stdin!);
|
|
321
364
|
} else if (msg.type === "result") {
|
|
322
|
-
// Surface every non-success result as an error so silent failures
|
|
323
|
-
// (e.g. subtype "error_during_execution" from --resume against an
|
|
324
|
-
// unknown session id, or any future error variant) don't get
|
|
325
|
-
// swallowed into an empty assistant message.
|
|
326
365
|
const r: any = msg as any;
|
|
327
366
|
const isError =
|
|
328
367
|
r.subtype !== "success" ||
|
|
329
368
|
r.is_error === true ||
|
|
330
369
|
typeof r.error === "string" ||
|
|
331
370
|
(Array.isArray(r.errors) && r.errors.length > 0);
|
|
332
|
-
if (isError) {
|
|
371
|
+
if (isError && !selfInterrupted && !aborted) {
|
|
333
372
|
const errMsg =
|
|
334
373
|
r.error ??
|
|
335
374
|
(Array.isArray(r.errors) && r.errors.length > 0
|
|
@@ -339,20 +378,25 @@ export function streamViaCli(
|
|
|
339
378
|
resumeSessionId &&
|
|
340
379
|
/No conversation found with session ID/i.test(errMsg)
|
|
341
380
|
) {
|
|
342
|
-
// Recoverable: the
|
|
343
|
-
//
|
|
344
|
-
|
|
381
|
+
// Recoverable: the sidecar pointed at a CLI session that no
|
|
382
|
+
// longer exists. Clear it; the driver reimports once.
|
|
383
|
+
if (piSessionId) clearCliSession(piSessionId);
|
|
345
384
|
resumeMiss = true;
|
|
346
385
|
broken = true;
|
|
347
386
|
} else {
|
|
387
|
+
// A failed turn may leave the CLI session ending on a user
|
|
388
|
+
// entry; resuming that would splice filler. Reimport next turn.
|
|
389
|
+
if (piSessionId) clearCliSession(piSessionId);
|
|
348
390
|
endStreamWithError(errMsg);
|
|
349
391
|
}
|
|
350
392
|
}
|
|
351
|
-
if (!isError) {
|
|
352
|
-
// Authoritative episode usage
|
|
393
|
+
if (!isError || selfInterrupted || aborted) {
|
|
394
|
+
// Authoritative episode usage. After a self-interrupt the result
|
|
395
|
+
// is `error_during_execution` BY DESIGN — the turn content (the
|
|
396
|
+
// handoff toolCall) is already accumulated; usage still applies.
|
|
353
397
|
bridge.applyResult(r);
|
|
354
398
|
}
|
|
355
|
-
// For
|
|
399
|
+
// For success, handoff and error alike: clean up the subprocess
|
|
356
400
|
clearTimeout(inactivityTimer);
|
|
357
401
|
cleanupProcess(proc!);
|
|
358
402
|
rl.close();
|
|
@@ -410,7 +454,7 @@ export function streamViaCli(
|
|
|
410
454
|
const outcome = await runOnce(false);
|
|
411
455
|
if (outcome === "resume-miss") {
|
|
412
456
|
console.error(
|
|
413
|
-
"[pi-claude-cli] CLI session missing for --resume —
|
|
457
|
+
"[pi-claude-cli] CLI session missing for --resume — importing pi history into a fresh CLI session",
|
|
414
458
|
);
|
|
415
459
|
await runOnce(true);
|
|
416
460
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi session → Claude CLI session mapping.
|
|
3
|
+
*
|
|
4
|
+
* Observer mode keeps ONE CLI session per pi session and resumes it across
|
|
5
|
+
* turns and pi restarts. pi stays the system of record: this sidecar is
|
|
6
|
+
* derived state, and losing it only costs one full-history reimport.
|
|
7
|
+
*
|
|
8
|
+
* A flat JSON file rather than per-session files: entries are tiny, writes
|
|
9
|
+
* are rare (one per created CLI session), and a single pi process owns a
|
|
10
|
+
* given pi session at a time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
function stateDir(): string {
|
|
18
|
+
return (
|
|
19
|
+
process.env.PI_CLAUDE_CLI_STATE_DIR ||
|
|
20
|
+
join(homedir(), ".pi", "agent", "pi-claude-cli")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mapPath(): string {
|
|
25
|
+
return join(stateDir(), "session-map.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readMap(): Record<string, string> {
|
|
29
|
+
try {
|
|
30
|
+
const parsed: unknown = JSON.parse(readFileSync(mapPath(), "utf-8"));
|
|
31
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
32
|
+
return parsed as Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Missing or corrupt — both mean "no mappings", which is always safe.
|
|
36
|
+
}
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeMap(map: Record<string, string>): void {
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
43
|
+
writeFileSync(mapPath(), JSON.stringify(map, null, 2), "utf-8");
|
|
44
|
+
} catch {
|
|
45
|
+
// Best effort: an unwritable sidecar degrades to reimport-per-restart.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** CLI session id for a pi session, if one was created and recorded. */
|
|
50
|
+
export function getCliSession(piSessionId: string): string | undefined {
|
|
51
|
+
return readMap()[piSessionId];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setCliSession(piSessionId: string, cliSessionId: string): void {
|
|
55
|
+
const map = readMap();
|
|
56
|
+
map[piSessionId] = cliSessionId;
|
|
57
|
+
writeMap(map);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Forget a mapping so the next turn reimports from pi history. */
|
|
61
|
+
export function clearCliSession(piSessionId: string): void {
|
|
62
|
+
const map = readMap();
|
|
63
|
+
if (piSessionId in map) {
|
|
64
|
+
delete map[piSessionId];
|
|
65
|
+
writeMap(map);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which system prompt the Claude CLI subprocess runs under.
|
|
3
|
+
*
|
|
4
|
+
* `claude` (the default) appends pi's prompt to Claude Code's own via
|
|
5
|
+
* `--append-system-prompt`. Everything the CLI normally knows about its
|
|
6
|
+
* built-in tools stays in place, and pi's instructions ride on top.
|
|
7
|
+
*
|
|
8
|
+
* `pi` replaces Claude Code's prompt outright via `--system-prompt`, leaving
|
|
9
|
+
* only pi's — the point of a minimal harness being that it does not inherit
|
|
10
|
+
* another agent's preamble.
|
|
11
|
+
*
|
|
12
|
+
* Sizing, measured rather than assumed: in a real session the CLI's fixed
|
|
13
|
+
* cached prefix sat at 17,475 tokens. That is Claude Code's system prompt
|
|
14
|
+
* *plus* the tool schemas, and the schemas (~4.3k, per pi's own breakdown)
|
|
15
|
+
* stay either way — only the prompt is replaceable. Pi's prompt after the
|
|
16
|
+
* tool-section rewrite is ~674 tokens, so the realistic saving is roughly
|
|
17
|
+
* 12k tokens of context per call, not the full difference. It is a
|
|
18
|
+
* context-window win rather than a cost win: that prefix is cached and bills
|
|
19
|
+
* at 0.1x, so it was never a meaningful part of a runaway bill.
|
|
20
|
+
*
|
|
21
|
+
* The trade is real, which is why this is a choice and not a default flip:
|
|
22
|
+
* Claude Code's prompt carries operating guidance for its own tools, so
|
|
23
|
+
* dropping it means the model works from pi's instructions plus the tool
|
|
24
|
+
* schemas alone. `rewritePiToolSections` compensates by restating pi's tool
|
|
25
|
+
* documentation in Claude Code's names, but a model may still behave
|
|
26
|
+
* differently. Default stays `claude`.
|
|
27
|
+
*/
|
|
28
|
+
export type SystemPromptMode = "claude" | "pi";
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_SYSTEM_PROMPT_MODE: SystemPromptMode = "claude";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the mode from the environment.
|
|
34
|
+
*
|
|
35
|
+
* Unset or unrecognised values fall back to the default rather than throwing:
|
|
36
|
+
* a typo in a launcher's env should not stop a session from starting.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveSystemPromptMode(
|
|
39
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
40
|
+
): SystemPromptMode {
|
|
41
|
+
const raw = (env.PI_CLAUDE_CLI_SYSTEM_PROMPT ?? "").trim().toLowerCase();
|
|
42
|
+
if (raw === "pi" || raw === "minimal") return "pi";
|
|
43
|
+
if (raw === "claude" || raw === "append") return "claude";
|
|
44
|
+
return DEFAULT_SYSTEM_PROMPT_MODE;
|
|
45
|
+
}
|
package/src/tool-mapping.ts
CHANGED
|
@@ -49,14 +49,23 @@ export function isCustomToolName(piName: string): boolean {
|
|
|
49
49
|
/**
|
|
50
50
|
* Check if a Claude tool name maps to a pi-known tool.
|
|
51
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.)
|
|
53
|
-
* Used by event bridge to filter out internal tool calls.
|
|
52
|
+
* Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.).
|
|
54
53
|
*/
|
|
55
54
|
export function isPiKnownClaudeTool(claudeName: string): boolean {
|
|
56
55
|
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
|
|
57
56
|
return claudeName.toLowerCase() in CLAUDE_TO_PI_NAME;
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Handoff tools are the ONLY tools pi executes in observer mode: custom pi
|
|
61
|
+
* tools exposed through the schema-only MCP server. Built-ins run natively in
|
|
62
|
+
* the CLI. This is the gate for interrupt-at-message_stop and for emitting pi
|
|
63
|
+
* toolCall blocks — see docs/SPEC-observer-mode.md.
|
|
64
|
+
*/
|
|
65
|
+
export function isHandoffClaudeTool(claudeName: string): boolean {
|
|
66
|
+
return claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX);
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
// Derived lookup maps
|
|
61
70
|
|
|
62
71
|
/** Lowercase Claude name -> pi name */
|