@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 +32 -0
- package/index.ts +8 -0
- package/package.json +1 -1
- package/src/overflow.ts +63 -0
- package/src/process-manager.ts +16 -0
- package/src/provider.ts +48 -8
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/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/provider.ts
CHANGED
|
@@ -81,9 +81,20 @@ export function streamViaCli(
|
|
|
81
81
|
): AssistantMessageEventStream {
|
|
82
82
|
const stream = createAssistantMessageEventStream();
|
|
83
83
|
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
})();
|