@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 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. Each request spawns a `claude -p` subprocess using the stream-json wire protocol, with `--resume` on follow-up turns to reuse the CLI's session state instead of replaying full history. Claude proposes tool calls, pi executes them natively. Custom pi tools are exposed to Claude via a schema-only MCP server.
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
- - Session resume via `--resume` eliminates history replay on follow-up turns
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -17,7 +17,7 @@ import type {
17
17
  import {
18
18
  mapClaudeToolNameToPi,
19
19
  translateClaudeArgsToPi,
20
- isPiKnownClaudeTool,
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
- // Skip internal Claude Code tools (ToolSearch, Task, Agent, etc.)
271
- // that pi cannot execute only emit pi-known tools
272
- if (!isPiKnownClaudeTool(claudeName)) {
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
- // Pi-known tools already streamed through the SSE path as real pi
505
- // tool calls — markers are only for tools the CLI executes itself.
506
- if (isPiKnownClaudeTool(block.name)) continue;
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
 
@@ -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 appended via --append-system-prompt
21
- * @param options - Optional cwd, AbortSignal, and effort level
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
- // Claude CLI's --append-system-prompt accepts a file path or literal text.
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
- args.push("--append-system-prompt", tmpFile);
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).
@@ -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(context.systemPrompt);
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 for bridging pi requests to the Claude CLI subprocess.
2
+ * Provider orchestration observer mode (docs/SPEC-observer-mode.md).
3
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
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 { isPiKnownClaudeTool } from "./tool-mapping.js";
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: spawn, write prompt, parse NDJSON,
66
- * bridge events, handle result, and clean up. Implements break-early pattern:
67
- * at message_stop, if any built-in or custom-tools MCP tool was seen, kills
68
- * the subprocess before Claude CLI can auto-execute the tools.
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 (180s), subprocess exit handler with stderr
71
- * surfacing, streamEnded guard against double errors, abort via SIGKILL, and
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 a --resume pointed at a CLI session that does not exist —
89
- * forks copy pi history into a NEW session id, so the prior-provider-turn
90
- * heuristic says resume while the CLI cache is keyed to the old id (#2).
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
- // Resume only when this conversation already contains a prior assistant
105
- // turn produced by pi-claude-cli (which means a CLI session has been
106
- // established under this session id). Otherwise e.g. when the user
107
- // just switched to pi-claude-cli from another provider mid session, or
108
- // when this is the first turnstart a fresh CLI session via
109
- // --session-id. Using --resume against an unknown id fails silently
110
- // with "No conversation found with session ID" and produces an empty
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 missis one reimport.
139
+ const piSessionId = options?.sessionId;
140
+ const mappedCliId = piSessionId ? getCliSession(piSessionId) : undefined;
117
141
  const resumeSessionId =
118
- !forceFullReplay && options?.sessionId && hasPriorCliTurn
119
- ? options.sessionId
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
- // Build prompt: if resuming, only send the latest user turn;
123
- // otherwise build the full flattened conversation history
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: !resumeSessionId ? options?.sessionId : undefined,
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
- // Set up abort signal handler -- uses SIGKILL for immediate force-kill
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
- forceKillProcess(proc);
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; // Break-early killed the process intentionally
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; // Break-early kill, expected
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 break-early
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 for break-early decision (top-level only)
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 && isPiKnownClaudeTool(toolName)) {
281
- // Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
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
- // Break-early at message_stop: kill subprocess before CLI auto-executes tools
288
- // Only on top-level message_stop sub-agent message_stop is internal
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
- sawBuiltInOrCustomTool
336
+ sawHandoffTool &&
337
+ !selfInterrupted
293
338
  ) {
294
- broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
295
- clearTimeout(inactivityTimer);
296
- // Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
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-side tools that
315
- // would otherwise be invisible between cycles.
316
- bridge.handleAssistantEnvelope(msg as any);
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 driver replays full history once. The CLI
343
- // also exits non-zero after this result silence this
344
- // attempt's close/error handlers so the retry owns the stream.
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 + final-answer safety net.
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 both success and error: clean up the subprocess
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 — replaying full history under the current session id",
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
+ }
@@ -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.) that pi cannot execute.
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 */