@saccolabs/pi-claude-cli 0.4.7 → 0.4.9

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
package/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./src/process-manager.js";
17
17
  import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
18
18
  import { rewriteOverflowMessage } from "./src/overflow.js";
19
+ import { buildRateLimitPayload, rateLimitIdentity } from "./src/rate-limit.js";
19
20
 
20
21
  // Kill all active Claude subprocesses on process exit to prevent orphans
21
22
  process.on("exit", killAllProcesses);
@@ -40,21 +41,18 @@ let lastRateLimitJson: string | undefined;
40
41
  function publishRateLimit(info: Record<string, unknown>): void {
41
42
  const setStatus = uiContext?.ui?.setStatus;
42
43
  if (typeof setStatus !== "function") return;
43
- const payload = JSON.stringify({
44
- status: info.status,
45
- resetsAt: info.resetsAt,
46
- rateLimitType: info.rateLimitType,
47
- overageStatus: info.overageStatus,
48
- isUsingOverage: info.isUsingOverage === true,
49
- observedAt: Math.floor(Date.now() / 1000),
50
- });
44
+ const payload = buildRateLimitPayload(info);
51
45
  // Push only on change: the event repeats every turn, and a status that
52
46
  // rewrites itself constantly is noise for whatever renders it.
53
- const withoutObservedAt = payload.replace(/,"observedAt":\d+/, "");
54
- if (withoutObservedAt === lastRateLimitJson) return;
55
- lastRateLimitJson = withoutObservedAt;
47
+ const identity = rateLimitIdentity(payload);
48
+ if (identity === lastRateLimitJson) return;
49
+ lastRateLimitJson = identity;
56
50
  try {
57
- setStatus.call(uiContext!.ui, RATE_LIMIT_STATUS_KEY, payload);
51
+ setStatus.call(
52
+ uiContext!.ui,
53
+ RATE_LIMIT_STATUS_KEY,
54
+ JSON.stringify(payload),
55
+ );
58
56
  } catch {
59
57
  /* never break a turn over a status push */
60
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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
 
@@ -95,6 +95,14 @@ export function spawnClaude(
95
95
  );
96
96
  }
97
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);
104
+ }
105
+
98
106
  if (options?.effort) {
99
107
  args.push("--effort", options.effort);
100
108
  }
@@ -148,6 +156,29 @@ export function writeUserMessage(
148
156
  proc.stdin!.write(JSON.stringify(message) + "\n");
149
157
  }
150
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
+
151
182
  /**
152
183
  * Force-kill a subprocess immediately via SIGKILL.
153
184
  * No-ops if the process is already dead (killed or exited).
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";
@@ -35,12 +36,19 @@ import {
35
36
  forceKillProcess,
36
37
  registerProcess,
37
38
  cleanupSystemPromptFile,
39
+ sendInterrupt,
38
40
  } from "./process-manager.js";
39
41
  import { parseLine } from "./stream-parser.js";
40
42
  import { createEventBridge } from "./event-bridge.js";
41
43
  import { handleControlRequest } from "./control-handler.js";
42
44
  import { mapThinkingEffort } from "./thinking-config.js";
43
- 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";
44
52
  /** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
45
53
  /**
46
54
  * Inactivity timeout. CLI-side tool executions (web search, user MCP
@@ -60,17 +68,32 @@ type StreamViaCLiOptions = SimpleStreamOptions & {
60
68
  onRateLimit?: (info: Record<string, unknown>) => void;
61
69
  };
62
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
+
63
86
  /**
64
87
  * Stream a response from Claude CLI as an AssistantMessageEventStream.
65
88
  *
66
- * Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
67
- * bridge events, handle result, and clean up. Implements break-early pattern:
68
- * at message_stop, if any built-in or custom-tools MCP tool was seen, kills
69
- * 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.
70
93
  *
71
- * Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
72
- * surfacing, streamEnded guard against double errors, abort via SIGKILL, and
73
- * 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.
74
97
  *
75
98
  * @param model - The model to use (from pi's model catalog)
76
99
  * @param context - The conversation context with messages and system prompt
@@ -86,11 +109,9 @@ export function streamViaCli(
86
109
 
87
110
  /**
88
111
  * One subprocess attempt. Returns "resume-miss" (without touching the
89
- * stream) when a --resume pointed at a CLI session that does not exist —
90
- * forks copy pi history into a NEW session id, so the prior-provider-turn
91
- * heuristic says resume while the CLI cache is keyed to the old id (#2).
92
- * The driver below retries once with a full-history replay, which also
93
- * 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.
94
115
  */
95
116
  async function runOnce(
96
117
  forceFullReplay: boolean,
@@ -98,30 +119,37 @@ export function streamViaCli(
98
119
  let proc: ReturnType<typeof spawnClaude> | undefined;
99
120
  let abortHandler: (() => void) | undefined;
100
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;
101
130
 
102
131
  try {
103
132
  const cwd = options?.cwd ?? process.cwd();
104
133
 
105
- // Resume only when this conversation already contains a prior assistant
106
- // turn produced by pi-claude-cli (which means a CLI session has been
107
- // established under this session id). Otherwise e.g. when the user
108
- // just switched to pi-claude-cli from another provider mid session, or
109
- // when this is the first turnstart a fresh CLI session via
110
- // --session-id. Using --resume against an unknown id fails silently
111
- // with "No conversation found with session ID" and produces an empty
112
- // assistant message.
113
- const hasPriorCliTurn = (context.messages as any[]).some(
114
- (m) =>
115
- m?.role === "assistant" &&
116
- (m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli"),
117
- );
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;
118
141
  const resumeSessionId =
119
- !forceFullReplay && options?.sessionId && hasPriorCliTurn
120
- ? options.sessionId
142
+ !forceFullReplay &&
143
+ mappedCliId &&
144
+ !cliSessionIsStale(context.messages as any[])
145
+ ? mappedCliId
121
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();
122
150
 
123
- // Build prompt: if resuming, only send the latest user turn;
124
- // 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.
125
153
  const prompt = resumeSessionId
126
154
  ? buildResumePrompt(context)
127
155
  : buildPrompt(context);
@@ -149,9 +177,12 @@ export function streamViaCli(
149
177
  effort,
150
178
  mcpConfigPath: options?.mcpConfigPath,
151
179
  resumeSessionId,
152
- newSessionId: !resumeSessionId ? options?.sessionId : undefined,
180
+ newSessionId: newCliId,
153
181
  systemPromptMode,
154
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);
155
186
  const getStderr = captureStderr(proc);
156
187
 
157
188
  // Register in global process registry for teardown cleanup
@@ -208,12 +239,16 @@ export function streamViaCli(
208
239
  }, INACTIVITY_TIMEOUT_MS);
209
240
  }
210
241
 
211
- // 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.
212
244
  if (options?.signal) {
213
245
  abortHandler = () => {
214
- if (proc) {
215
- forceKillProcess(proc);
216
- }
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));
217
252
  };
218
253
 
219
254
  if (options.signal.aborted) {
@@ -223,8 +258,6 @@ export function streamViaCli(
223
258
  options.signal.addEventListener("abort", abortHandler, { once: true });
224
259
  }
225
260
 
226
- // Track tool_use blocks for break-early decision at message_stop
227
- let sawBuiltInOrCustomTool = false;
228
261
  // Guard against buffered readline lines firing after rl.close()
229
262
  let broken = false;
230
263
 
@@ -237,7 +270,7 @@ export function streamViaCli(
237
270
 
238
271
  // Handle process error -- use endStreamWithError for guard
239
272
  proc.on("error", (err: Error) => {
240
- if (broken) return; // Break-early killed the process intentionally
273
+ if (broken) return; // resume-miss retry owns the stream
241
274
  const stderr = getStderr();
242
275
  endStreamWithError(stderr || err.message);
243
276
  });
@@ -245,7 +278,7 @@ export function streamViaCli(
245
278
  // Handle subprocess close -- surface crashes with stderr and exit code
246
279
  proc.on("close", (code: number | null, _signal: string | null) => {
247
280
  clearTimeout(inactivityTimer);
248
- if (broken) return; // Break-early kill, expected
281
+ if (broken) return; // resume-miss retry owns the stream
249
282
  if (code !== 0 && code !== null) {
250
283
  const stderr = getStderr();
251
284
  const message = stderr
@@ -262,7 +295,7 @@ export function streamViaCli(
262
295
  // NOTE: Using 'line' event instead of `for await` because the async
263
296
  // iterator batches lines, breaking real-time streaming to pi.
264
297
  rl.on("line", (line: string) => {
265
- if (broken) return; // Guard: ignore buffered lines after break-early
298
+ if (broken) return; // Guard: ignore buffered lines after a resume miss
266
299
 
267
300
  // Reset inactivity timer on each line of output
268
301
  resetInactivityTimer();
@@ -274,37 +307,38 @@ export function streamViaCli(
274
307
  // Only forward top-level events to pi's event bridge.
275
308
  // Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
276
309
  const isTopLevel = !(msg as any).parent_tool_use_id;
277
- if (isTopLevel) {
310
+ if (isTopLevel && !selfInterrupted) {
278
311
  bridge.handleEvent(msg.event);
279
312
  }
280
313
 
281
- // 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.
282
316
  if (
283
317
  isTopLevel &&
284
318
  msg.event.type === "content_block_start" &&
285
319
  msg.event.content_block?.type === "tool_use"
286
320
  ) {
287
321
  const toolName = msg.event.content_block.name;
288
- if (toolName && isPiKnownClaudeTool(toolName)) {
289
- // Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
290
- // Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
291
- sawBuiltInOrCustomTool = true;
322
+ if (toolName && isHandoffClaudeTool(toolName)) {
323
+ sawHandoffTool = true;
292
324
  }
293
325
  }
294
326
 
295
- // Break-early at message_stop: kill subprocess before CLI auto-executes tools
296
- // 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.
297
333
  if (
298
334
  isTopLevel &&
299
335
  msg.event.type === "message_stop" &&
300
- sawBuiltInOrCustomTool
336
+ sawHandoffTool &&
337
+ !selfInterrupted
301
338
  ) {
302
- broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
303
- clearTimeout(inactivityTimer);
304
- // Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
305
- forceKillProcess(proc!);
306
- rl.close();
307
- return; // Don't process further -- done event already pushed by event bridge
339
+ selfInterrupted = true;
340
+ sendInterrupt(proc!);
341
+ return;
308
342
  }
309
343
  } else if (msg.type === "rate_limit_event") {
310
344
  // Account-level state, not turn content: hand it to the host so a
@@ -319,25 +353,22 @@ export function streamViaCli(
319
353
  }
320
354
  }
321
355
  } else if (msg.type === "assistant") {
322
- // Complete-block envelopes: marker text for CLI-side tools that
323
- // would otherwise be invisible between cycles.
324
- 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);
325
360
  } else if (msg.type === "user") {
326
361
  // Tool results the CLI feeds back between cycles — internal.
327
362
  } else if (msg.type === "control_request") {
328
363
  handleControlRequest(msg, proc!.stdin!);
329
364
  } else if (msg.type === "result") {
330
- // Surface every non-success result as an error so silent failures
331
- // (e.g. subtype "error_during_execution" from --resume against an
332
- // unknown session id, or any future error variant) don't get
333
- // swallowed into an empty assistant message.
334
365
  const r: any = msg as any;
335
366
  const isError =
336
367
  r.subtype !== "success" ||
337
368
  r.is_error === true ||
338
369
  typeof r.error === "string" ||
339
370
  (Array.isArray(r.errors) && r.errors.length > 0);
340
- if (isError) {
371
+ if (isError && !selfInterrupted && !aborted) {
341
372
  const errMsg =
342
373
  r.error ??
343
374
  (Array.isArray(r.errors) && r.errors.length > 0
@@ -347,20 +378,25 @@ export function streamViaCli(
347
378
  resumeSessionId &&
348
379
  /No conversation found with session ID/i.test(errMsg)
349
380
  ) {
350
- // Recoverable: the driver replays full history once. The CLI
351
- // also exits non-zero after this result silence this
352
- // 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);
353
384
  resumeMiss = true;
354
385
  broken = true;
355
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);
356
390
  endStreamWithError(errMsg);
357
391
  }
358
392
  }
359
- if (!isError) {
360
- // 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.
361
397
  bridge.applyResult(r);
362
398
  }
363
- // For both success and error: clean up the subprocess
399
+ // For success, handoff and error alike: clean up the subprocess
364
400
  clearTimeout(inactivityTimer);
365
401
  cleanupProcess(proc!);
366
402
  rl.close();
@@ -418,7 +454,7 @@ export function streamViaCli(
418
454
  const outcome = await runOnce(false);
419
455
  if (outcome === "resume-miss") {
420
456
  console.error(
421
- "[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",
422
458
  );
423
459
  await runOnce(true);
424
460
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Account rate-limit state, shaped for the host's status channel.
3
+ *
4
+ * The CLI emits a `rate_limit_event` per turn describing ONE window: the first
5
+ * one whose warning threshold has been crossed, walking
6
+ * `5h -> 7d -> 7d_oi -> overage`. So the reported window is the binding
7
+ * constraint, not an arbitrary pick — and there is no way to see all four at
8
+ * once from this stream. A front-end that wants "which limit will stop me, how
9
+ * close am I, and when does it reset" has everything it needs; one that wants
10
+ * a full dashboard does not, and should not pretend otherwise.
11
+ *
12
+ * Kept pure and separate from `index.ts` so the payload contract is testable
13
+ * without a pi runtime.
14
+ */
15
+
16
+ /** What the host receives under the `claude-rate-limit` status key. */
17
+ export interface RateLimitPayload {
18
+ status: unknown;
19
+ resetsAt: unknown;
20
+ rateLimitType: unknown;
21
+ overageStatus: unknown;
22
+ isUsingOverage: boolean;
23
+ /** Fraction of the window consumed: 1.01 means 101%, i.e. over. */
24
+ utilization: number | null;
25
+ /** Which warning step tripped, when one has. */
26
+ surpassedThreshold: number | null;
27
+ observedAt: number;
28
+ }
29
+
30
+ function num(value: unknown): number | null {
31
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
32
+ }
33
+
34
+ export function buildRateLimitPayload(
35
+ info: Record<string, unknown>,
36
+ nowSeconds: number = Math.floor(Date.now() / 1000),
37
+ ): RateLimitPayload {
38
+ return {
39
+ status: info.status,
40
+ resetsAt: info.resetsAt,
41
+ rateLimitType: info.rateLimitType,
42
+ overageStatus: info.overageStatus,
43
+ isUsingOverage: info.isUsingOverage === true,
44
+ // The CLI has always sent these two; dropping them left front-ends able to
45
+ // say WHICH limit was in play and when it resets, but never how close it
46
+ // was — so a user could not watch themselves approach a wall, only hit it.
47
+ utilization: num(info.utilization),
48
+ surpassedThreshold: num(info.surpassedThreshold),
49
+ observedAt: nowSeconds,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Identity of a payload ignoring `observedAt`, for change detection.
55
+ *
56
+ * The event repeats every turn with a fresh timestamp; pushing that verbatim
57
+ * would rewrite the host's status constantly and make anything rendering it
58
+ * flicker. Comparing everything EXCEPT the timestamp is what makes the push
59
+ * "on change" rather than "on turn".
60
+ */
61
+ export function rateLimitIdentity(payload: RateLimitPayload): string {
62
+ const { observedAt: _observedAt, ...rest } = payload;
63
+ return JSON.stringify(rest);
64
+ }
@@ -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
+ }
@@ -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 */