@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 +32 -0
- package/index.ts +8 -0
- package/package.json +1 -1
- package/src/event-bridge.ts +122 -96
- package/src/overflow.ts +63 -0
- package/src/process-manager.ts +16 -0
- package/src/types.ts +4 -0
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
package/src/event-bridge.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/src/overflow.ts
ADDED
|
@@ -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
|
+
}
|
package/src/process-manager.ts
CHANGED
|
@@ -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
|
}
|