@saccolabs/pi-claude-cli 0.4.2 → 0.4.4

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
@@ -49,6 +49,38 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
49
49
  - Cross-platform subprocess management (Windows, macOS, Linux)
50
50
  - Inactivity timeout and process registry for cleanup
51
51
 
52
+ ## Architecture
53
+
54
+ `docs/ARCHITECTURE.md` covers the turn lifecycle, the three-way tool split,
55
+ the two-ledger session model, error recovery, and the CLI compatibility
56
+ notes (including the 2.x control-protocol shape).
57
+
58
+ ## What your Claude environment contributes
59
+
60
+ Each turn runs a real `claude -p` subprocess in your workspace, so your
61
+ Claude Code environment participates through three doors:
62
+
63
+ 1. **Bridged tools** — the six built-ins (Read/Write/Edit/Bash/Grep/Glob)
64
+ and pi custom tools become pi tool calls; pi executes them.
65
+ 2. **CLI-side execution** — your personal/project MCP servers, WebSearch,
66
+ and sub-agents run _inside_ the CLI between cycles. They appear in the
67
+ transcript as one-line markers (`[Claude Code · WebSearch {…}]`) and
68
+ bill your plan.
69
+ 3. **Prompt-level osmosis** — the CLI auto-loads project CLAUDE.md and
70
+ memory, your hooks fire, and skills can load twice (natively via
71
+ claude, and again via pi's own `~/.claude/skills` support).
72
+
73
+ ### Hermetic mode
74
+
75
+ Set `PI_CLAUDE_CLI_HERMETIC=1` to keep that environment out of pi turns:
76
+ the subprocess runs with `--strict-mcp-config` (only this extension's
77
+ schema-only custom-tools server loads) and an empty `--setting-sources`
78
+ (no user/project/local settings — hooks, auto-memory, permission
79
+ allowlists). Model access and your subscription login are unaffected.
80
+
81
+ Related knobs: `PI_CLAUDE_CLI_TIMEOUT_MS` overrides the 300s inactivity
82
+ timeout (CLI-side tools can be silent on stdout for minutes).
83
+
52
84
  ## License
53
85
 
54
86
  MIT
package/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  killAllProcesses,
16
16
  } from "./src/process-manager.js";
17
17
  import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
18
+ import { rewriteOverflowMessage } from "./src/overflow.js";
18
19
 
19
20
  // Kill all active Claude subprocesses on process exit to prevent orphans
20
21
  process.on("exit", killAllProcesses);
@@ -120,6 +121,13 @@ export default function (pi: ExtensionAPI) {
120
121
  PROVIDER_ID,
121
122
  );
122
123
 
124
+ // Overflow recovery: rewrite provider-scoped context-limit errors to
125
+ // the prefix pi's auto-compaction recognizes (see src/overflow.ts).
126
+
127
+ (pi.on as any)("message_end", (event: any, ctx: any) => {
128
+ return rewriteOverflowMessage(event?.message ?? {}, ctx?.model?.provider);
129
+ });
130
+
123
131
  pi.registerProvider(PROVIDER_ID, {
124
132
  baseUrl: "pi-claude-cli",
125
133
  apiKey: "unused",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -28,6 +28,8 @@ interface TrackedToolBlock {
28
28
  type: "tool_use";
29
29
  index: number;
30
30
  cycle: number;
31
+ /** Position in output.content, or -1 when not materialized. */
32
+ contentIndex: number;
31
33
  id: string;
32
34
  name: string; // Already mapped to pi name
33
35
  claudeName: string; // Original Claude name for arg translation
@@ -152,20 +154,18 @@ export function createEventBridge(
152
154
  }
153
155
 
154
156
  /**
155
- * Append a complete text block through proper pi stream events.
156
- *
157
- * `blocks` and `output.content` are parallel arrays (SSE handlers index
158
- * into both with the same idx), so the marker must occupy a slot in both;
159
- * cycle -1 / index -1 can never match a real SSE event.
157
+ * Append a complete text block through proper pi stream events. Tracked
158
+ * blocks carry their own contentIndex, so appended blocks (markers, the
159
+ * final-answer safety net) need no relationship to any SSE index.
160
160
  */
161
161
  function appendTextBlock(text: string): void {
162
162
  if (!started) {
163
163
  stream.push({ type: "start", partial: output });
164
164
  started = true;
165
165
  }
166
- blocks.push({ type: "text", text, index: -1, cycle: -1 });
166
+ const contentIndex = output.content.length;
167
+ blocks.push({ type: "text", text, index: -1, cycle: -1, contentIndex });
167
168
  output.content.push({ type: "text" as const, text: "" });
168
- const contentIndex = output.content.length - 1;
169
169
  stream.push({ type: "text_start", contentIndex, partial: output });
170
170
  (output.content[contentIndex] as TextContent).text = text;
171
171
  stream.push({
@@ -238,34 +238,32 @@ export function createEventBridge(
238
238
  text: "",
239
239
  index: event.index ?? 0,
240
240
  cycle,
241
+ contentIndex: output.content.length,
241
242
  };
242
243
  blocks.push(block);
243
244
  output.content.push({ type: "text" as const, text: "" });
244
245
 
245
246
  stream.push({
246
247
  type: "text_start",
247
- contentIndex: output.content.length - 1,
248
+ contentIndex: block.contentIndex,
248
249
  partial: output,
249
250
  });
250
251
  } else if (blockType === "thinking") {
252
+ // Deliberately NOT materialized yet. Several Claude models (verified:
253
+ // fable-5, opus-5, sonnet-5 at effort medium; haiku-4-5 is the
254
+ // exception) stream ENCRYPTED thinking: a multi-kilobyte
255
+ // signature_delta with no thinking_delta at all. Materializing on
256
+ // start produced a thinking block with no text, which front-ends
257
+ // faithfully rendered as an empty "thought". The block is created on
258
+ // the first plaintext delta and dropped at block_stop if none arrives.
251
259
  const block: TrackedContentBlock = {
252
260
  type: "thinking",
253
261
  text: "",
254
262
  index: event.index ?? 0,
255
263
  cycle,
264
+ contentIndex: -1,
256
265
  };
257
266
  blocks.push(block);
258
- output.content.push({
259
- type: "thinking" as const,
260
- thinking: "",
261
- thinkingSignature: "",
262
- });
263
-
264
- stream.push({
265
- type: "thinking_start",
266
- contentIndex: output.content.length - 1,
267
- partial: output,
268
- });
269
267
  } else if (blockType === "tool_use") {
270
268
  const claudeName = event.content_block!.name!;
271
269
 
@@ -282,6 +280,7 @@ export function createEventBridge(
282
280
  type: "tool_use",
283
281
  index: event.index ?? 0,
284
282
  cycle,
283
+ contentIndex: output.content.length,
285
284
  id,
286
285
  name: piName,
287
286
  claudeName,
@@ -298,101 +297,112 @@ export function createEventBridge(
298
297
 
299
298
  stream.push({
300
299
  type: "toolcall_start",
301
- contentIndex: output.content.length - 1,
300
+ contentIndex: block.contentIndex,
302
301
  partial: output,
303
302
  });
304
303
  }
305
304
  // Unknown block types silently ignored
306
305
  }
307
306
 
307
+ /** Locate the tracked block for this cycle's SSE index. */
308
+ function trackedFor(event: ClaudeApiEvent): TrackedBlock | undefined {
309
+ const idx = blocks.findIndex(
310
+ (b) => b.cycle === cycle && b.index === event.index,
311
+ );
312
+ return idx === -1 ? undefined : blocks[idx];
313
+ }
314
+
315
+ /**
316
+ * Create the pi content block for a thinking block that has now proven it
317
+ * carries plaintext. Encrypted thinking never reaches this path, so it
318
+ * never becomes an empty "thought" downstream.
319
+ */
320
+ function materializeThinking(block: TrackedContentBlock): void {
321
+ block.contentIndex = output.content.length;
322
+ output.content.push({
323
+ type: "thinking" as const,
324
+ thinking: "",
325
+ thinkingSignature: block.pendingSignature ?? "",
326
+ });
327
+ block.pendingSignature = undefined;
328
+ stream.push({
329
+ type: "thinking_start",
330
+ contentIndex: block.contentIndex,
331
+ partial: output,
332
+ });
333
+ }
334
+
308
335
  function handleContentBlockDelta(event: ClaudeApiEvent): void {
309
336
  const deltaType = event.delta?.type;
337
+ const block = trackedFor(event);
338
+ if (!block) return;
310
339
 
311
340
  if (deltaType === "text_delta" && event.delta!.text != null) {
312
- const idx = blocks.findIndex(
313
- (b) => b.cycle === cycle && b.index === event.index,
314
- );
315
- if (idx === -1) return;
316
-
317
- const block = blocks[idx];
318
- if (block.type === "text") {
319
- block.text += event.delta!.text;
320
- const contentBlock = output.content[idx] as TextContent;
321
- contentBlock.text = block.text;
322
-
323
- stream.push({
324
- type: "text_delta",
325
- contentIndex: idx,
326
- delta: event.delta!.text,
327
- partial: output,
328
- });
329
- }
341
+ if (block.type !== "text") return;
342
+ block.text += event.delta!.text;
343
+ (output.content[block.contentIndex] as TextContent).text = block.text;
344
+ stream.push({
345
+ type: "text_delta",
346
+ contentIndex: block.contentIndex,
347
+ delta: event.delta!.text,
348
+ partial: output,
349
+ });
330
350
  } else if (
331
351
  deltaType === "thinking_delta" &&
332
352
  event.delta!.thinking != null
333
353
  ) {
334
- const idx = blocks.findIndex(
335
- (b) => b.cycle === cycle && b.index === event.index,
336
- );
337
- if (idx === -1) return;
338
-
339
- const block = blocks[idx];
340
- if (block.type === "thinking") {
341
- block.text += event.delta!.thinking;
342
- const contentBlock = output.content[idx] as ThinkingContent;
343
- contentBlock.thinking = block.text;
344
-
345
- stream.push({
346
- type: "thinking_delta",
347
- contentIndex: idx,
348
- delta: event.delta!.thinking,
349
- partial: output,
350
- });
354
+ if (block.type !== "thinking") return;
355
+ // Empty thinking_delta events accompany encrypted thinking (verified
356
+ // on sonnet-5): they carry no plaintext, so they must not bring a
357
+ // thinking block into existence.
358
+ if (block.contentIndex === -1) {
359
+ if (event.delta!.thinking.length === 0) return;
360
+ materializeThinking(block);
351
361
  }
362
+ block.text += event.delta!.thinking;
363
+ (output.content[block.contentIndex] as ThinkingContent).thinking =
364
+ block.text;
365
+ stream.push({
366
+ type: "thinking_delta",
367
+ contentIndex: block.contentIndex,
368
+ delta: event.delta!.thinking,
369
+ partial: output,
370
+ });
352
371
  } else if (
353
372
  deltaType === "input_json_delta" &&
354
373
  event.delta!.partial_json != null
355
374
  ) {
356
- const idx = blocks.findIndex(
357
- (b) => b.cycle === cycle && b.index === event.index,
358
- );
359
- if (idx === -1) return;
360
-
361
- const block = blocks[idx];
362
- if (block.type === "tool_use") {
363
- block.partialJson += event.delta!.partial_json;
364
-
365
- // Try to parse accumulated JSON -- on success update args, on failure keep previous
366
- try {
367
- block.arguments = JSON.parse(block.partialJson);
368
- (output.content[idx] as any).arguments = block.arguments;
369
- } catch {
370
- // Partial JSON not yet parseable -- keep previous arguments
371
- }
372
-
373
- stream.push({
374
- type: "toolcall_delta",
375
- contentIndex: idx,
376
- delta: event.delta!.partial_json,
377
- partial: output,
378
- });
375
+ if (block.type !== "tool_use") return;
376
+ block.partialJson += event.delta!.partial_json;
377
+ try {
378
+ block.arguments = JSON.parse(block.partialJson);
379
+ (output.content[block.contentIndex] as any).arguments = block.arguments;
380
+ } catch {
381
+ // Partial JSON not yet parseable -- keep previous arguments
379
382
  }
383
+ stream.push({
384
+ type: "toolcall_delta",
385
+ contentIndex: block.contentIndex,
386
+ delta: event.delta!.partial_json,
387
+ partial: output,
388
+ });
380
389
  } else if (
381
390
  deltaType === "signature_delta" &&
382
391
  event.delta!.signature != null
383
392
  ) {
384
- // Accumulate signature on the thinking block
385
- const idx = blocks.findIndex(
386
- (b) => b.cycle === cycle && b.index === event.index,
387
- );
388
- if (idx === -1) return;
389
-
390
- const block = blocks[idx];
391
- if (block.type === "thinking") {
392
- const contentBlock = output.content[idx] as ThinkingContent;
393
- contentBlock.thinkingSignature =
394
- (contentBlock.thinkingSignature || "") + event.delta!.signature;
393
+ if (block.type !== "thinking") return;
394
+ if (block.contentIndex === -1) {
395
+ // Signature before (or without) any plaintext: hold it in case
396
+ // plaintext follows. If it never does, the block is dropped.
397
+ block.pendingSignature =
398
+ (block.pendingSignature ?? "") + event.delta!.signature;
399
+ return;
395
400
  }
401
+ const contentBlock = output.content[
402
+ block.contentIndex
403
+ ] as ThinkingContent;
404
+ contentBlock.thinkingSignature =
405
+ (contentBlock.thinkingSignature || "") + event.delta!.signature;
396
406
  }
397
407
  }
398
408
 
@@ -401,22 +411,29 @@ export function createEventBridge(
401
411
  (b) => b.cycle === cycle && b.index === event.index,
402
412
  );
403
413
  if (idx === -1) return;
404
-
405
414
  const block = blocks[idx];
415
+
416
+ // Encrypted thinking: signature only, no plaintext. Drop it rather than
417
+ // emit a content block with nothing to show.
418
+ if (block.type === "thinking" && block.contentIndex === -1) {
419
+ blocks.splice(idx, 1);
420
+ return;
421
+ }
422
+
406
423
  // Clean up the tracking index from the block (no longer needed)
407
424
  delete (block as any).index;
408
425
 
409
426
  if (block.type === "text") {
410
427
  stream.push({
411
428
  type: "text_end",
412
- contentIndex: idx,
429
+ contentIndex: block.contentIndex,
413
430
  content: block.text,
414
431
  partial: output,
415
432
  });
416
433
  } else if (block.type === "thinking") {
417
434
  stream.push({
418
435
  type: "thinking_end",
419
- contentIndex: idx,
436
+ contentIndex: block.contentIndex,
420
437
  content: block.text,
421
438
  partial: output,
422
439
  });
@@ -430,9 +447,7 @@ export function createEventBridge(
430
447
  finalArgs = block.partialJson;
431
448
  }
432
449
 
433
- // Update output.content with final arguments
434
- const contentBlock = output.content[idx] as ToolCall;
435
- (contentBlock as any).arguments = finalArgs;
450
+ (output.content[block.contentIndex] as any).arguments = finalArgs;
436
451
 
437
452
  // ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
438
453
  // intentionally emit a raw string when JSON parse fails completely.
@@ -446,7 +461,7 @@ export function createEventBridge(
446
461
 
447
462
  stream.push({
448
463
  type: "toolcall_end",
449
- contentIndex: idx,
464
+ contentIndex: block.contentIndex,
450
465
  toolCall,
451
466
  partial: output,
452
467
  });
@@ -502,6 +517,17 @@ export function createEventBridge(
502
517
  } catch {
503
518
  /* unserializable input — marker still names the tool */
504
519
  }
520
+ // WIRE CONTRACT — front-ends parse this string.
521
+ //
522
+ // [Claude Code · <ToolName>] (no arguments)
523
+ // [Claude Code · <ToolName> <argsJson>] (preview, may be truncated)
524
+ //
525
+ // pidex matches /^\[Claude Code · ([^\s\]]+)(?:\s+([\s\S]*))?\]$/ to
526
+ // render these as activity rows instead of prose; anything it cannot
527
+ // match falls back to being shown as raw markdown, which is what this
528
+ // marker existed to avoid. Change the shape only together with the
529
+ // consumers, and keep the argument preview opaque — it is truncated
530
+ // here, so it is frequently invalid JSON and must never be parsed.
505
531
  appendTextBlock(`[Claude Code · ${block.name}${argsPreview}]`);
506
532
  }
507
533
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Context-overflow error normalization (issue #4).
3
+ *
4
+ * When a request exceeds the model's context window, pi can recover by
5
+ * compacting the conversation and retrying — but only if it recognizes the
6
+ * failure. Detection runs on the finalized assistant message: pi checks
7
+ * `errorMessage` against its known overflow patterns, and the generic
8
+ * fallback it always recognizes is a `context_length_exceeded` prefix
9
+ * (see pi's custom-provider docs, "Context Overflow Errors").
10
+ *
11
+ * The Claude CLI surfaces the Anthropic API's error text verbatim, which pi
12
+ * does not recognize. This module rewrites ONLY provider-scoped, clearly
13
+ * overflow-shaped errors; rate limits and transient failures must never be
14
+ * rewritten (that would trigger compaction instead of pi's retry/backoff).
15
+ */
16
+
17
+ /**
18
+ * Anthropic API overflow phrasings, matched conservatively:
19
+ * - "prompt is too long: 214315 tokens > 200000 maximum"
20
+ * - "input length and `max_tokens` exceed context limit: ..."
21
+ */
22
+ const OVERFLOW_PATTERNS: RegExp[] = [
23
+ /prompt is too long/i,
24
+ /input length and .?max_tokens.? exceed context limit/i,
25
+ ];
26
+
27
+ export const OVERFLOW_PREFIX = "context_length_exceeded";
28
+
29
+ /** True when the error text is an overflow pi should recover from. */
30
+ export function isOverflowError(errorMessage: string): boolean {
31
+ if (errorMessage.includes(OVERFLOW_PREFIX)) return false; // already rewritten
32
+ return OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage));
33
+ }
34
+
35
+ /**
36
+ * `message_end` handler body: returns the rewritten assistant message when
37
+ * this provider produced a recognizable overflow error, undefined otherwise
38
+ * (pi keeps the message unchanged).
39
+ */
40
+ export function rewriteOverflowMessage(
41
+ message: {
42
+ role?: string;
43
+ provider?: string;
44
+ stopReason?: string;
45
+ errorMessage?: string;
46
+ },
47
+ ctxProvider?: string,
48
+ ): { message: Record<string, unknown> } | undefined {
49
+ if (message.role !== "assistant") return undefined;
50
+ if (message.stopReason !== "error") return undefined;
51
+ if (message.provider !== "pi-claude-cli" && ctxProvider !== "pi-claude-cli")
52
+ return undefined;
53
+
54
+ const errorMessage = message.errorMessage ?? "";
55
+ if (!isOverflowError(errorMessage)) return undefined;
56
+
57
+ return {
58
+ message: {
59
+ ...message,
60
+ errorMessage: `${OVERFLOW_PREFIX}: ${errorMessage}`,
61
+ },
62
+ };
63
+ }
@@ -21,6 +21,12 @@ import type { ChildProcess } from "node:child_process";
21
21
  * @param options - Optional cwd, AbortSignal, and effort level
22
22
  * @returns The spawned ChildProcess with piped stdin/stdout/stderr
23
23
  */
24
+ /** Truthy PI_CLAUDE_CLI_HERMETIC opts in to hermetic mode (see README). */
25
+ function isHermetic(): boolean {
26
+ const value = (process.env.PI_CLAUDE_CLI_HERMETIC ?? "").toLowerCase();
27
+ return value === "1" || value === "true" || value === "yes";
28
+ }
29
+
24
30
  export function spawnClaude(
25
31
  modelId: string,
26
32
  systemPrompt?: string,
@@ -47,6 +53,16 @@ export function spawnClaude(
47
53
  "stdio",
48
54
  ];
49
55
 
56
+ // Hermetic mode: keep the user's Claude Code environment out of pi turns.
57
+ // --strict-mcp-config loads ONLY the servers from --mcp-config (our
58
+ // schema-only custom-tools server survives; personal/project MCP servers
59
+ // do not), and an empty --setting-sources skips user/project/local
60
+ // settings — hooks, CLAUDE.md auto-memory, permission allowlists.
61
+ // Both flags verified accepted on claude 2.1.237.
62
+ if (isHermetic()) {
63
+ args.push("--strict-mcp-config", "--setting-sources", "");
64
+ }
65
+
50
66
  if (options?.resumeSessionId) {
51
67
  // Resume an existing session — CLI loads prior conversation from disk
52
68
  args.push("--resume", options.resumeSessionId);
package/src/types.ts CHANGED
@@ -128,4 +128,8 @@ export interface TrackedContentBlock {
128
128
  text: string;
129
129
  index: number; // Claude's content_block index (resets each cycle)
130
130
  cycle: number; // Which API call of the episode this block belongs to
131
+ /** Position in output.content, or -1 while not materialized. */
132
+ contentIndex: number;
133
+ /** signature_delta received before any plaintext (encrypted thinking). */
134
+ pendingSignature?: string;
131
135
  }