@saccolabs/pi-claude-cli 0.4.1 → 0.4.3

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.1",
3
+ "version": "0.4.3",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -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/provider.ts CHANGED
@@ -81,9 +81,20 @@ export function streamViaCli(
81
81
  ): AssistantMessageEventStream {
82
82
  const stream = createAssistantMessageEventStream();
83
83
 
84
- (async () => {
84
+ /**
85
+ * One subprocess attempt. Returns "resume-miss" (without touching the
86
+ * stream) when a --resume pointed at a CLI session that does not exist —
87
+ * forks copy pi history into a NEW session id, so the prior-provider-turn
88
+ * heuristic says resume while the CLI cache is keyed to the old id (#2).
89
+ * The driver below retries once with a full-history replay, which also
90
+ * re-registers the CLI cache under the current session id.
91
+ */
92
+ async function runOnce(
93
+ forceFullReplay: boolean,
94
+ ): Promise<"ok" | "resume-miss"> {
85
95
  let proc: ReturnType<typeof spawnClaude> | undefined;
86
96
  let abortHandler: (() => void) | undefined;
97
+ let resumeMiss = false;
87
98
 
88
99
  try {
89
100
  const cwd = options?.cwd ?? process.cwd();
@@ -102,7 +113,9 @@ export function streamViaCli(
102
113
  (m?.provider === "pi-claude-cli" || m?.api === "pi-claude-cli"),
103
114
  );
104
115
  const resumeSessionId =
105
- options?.sessionId && hasPriorCliTurn ? options.sessionId : undefined;
116
+ !forceFullReplay && options?.sessionId && hasPriorCliTurn
117
+ ? options.sessionId
118
+ : undefined;
106
119
 
107
120
  // Build prompt: if resuming, only send the latest user turn;
108
121
  // otherwise build the full flattened conversation history
@@ -195,7 +208,7 @@ export function streamViaCli(
195
208
 
196
209
  if (options.signal.aborted) {
197
210
  abortHandler();
198
- return;
211
+ return "ok";
199
212
  }
200
213
  options.signal.addEventListener("abort", abortHandler, { once: true });
201
214
  }
@@ -308,7 +321,18 @@ export function streamViaCli(
308
321
  (Array.isArray(r.errors) && r.errors.length > 0
309
322
  ? r.errors.join("; ")
310
323
  : `Claude CLI returned ${r.subtype ?? "non-success result"}`);
311
- endStreamWithError(errMsg);
324
+ if (
325
+ resumeSessionId &&
326
+ /No conversation found with session ID/i.test(errMsg)
327
+ ) {
328
+ // Recoverable: the driver replays full history once. The CLI
329
+ // also exits non-zero after this result — silence this
330
+ // attempt's close/error handlers so the retry owns the stream.
331
+ resumeMiss = true;
332
+ broken = true;
333
+ } else {
334
+ endStreamWithError(errMsg);
335
+ }
312
336
  }
313
337
  if (!isError) {
314
338
  // Authoritative episode usage + final-answer safety net.
@@ -326,6 +350,8 @@ export function streamViaCli(
326
350
  rl.on("close", resolve);
327
351
  });
328
352
 
353
+ if (resumeMiss) return "resume-miss";
354
+
329
355
  // Push done event after readline closes (async). Pushing synchronously
330
356
  // inside handleMessageStop prevents pi from executing tools.
331
357
  // Guard with streamEnded to avoid pushing done after an error was already pushed.
@@ -356,6 +382,24 @@ export function streamViaCli(
356
382
  });
357
383
  stream.end();
358
384
  }
385
+ return "ok";
386
+ } finally {
387
+ // Clean up this attempt's abort listener
388
+ if (options?.signal && abortHandler) {
389
+ options.signal.removeEventListener("abort", abortHandler);
390
+ }
391
+ }
392
+ }
393
+
394
+ (async () => {
395
+ try {
396
+ const outcome = await runOnce(false);
397
+ if (outcome === "resume-miss") {
398
+ console.error(
399
+ "[pi-claude-cli] CLI session missing for --resume — replaying full history under the current session id",
400
+ );
401
+ await runOnce(true);
402
+ }
359
403
  } catch (err: any) {
360
404
  stream.push({
361
405
  type: "error",
@@ -364,10 +408,6 @@ export function streamViaCli(
364
408
  } as any);
365
409
  stream.end();
366
410
  } finally {
367
- // Clean up abort listener
368
- if (options?.signal && abortHandler) {
369
- options.signal.removeEventListener("abort", abortHandler);
370
- }
371
411
  cleanupSystemPromptFile();
372
412
  }
373
413
  })();