@saccolabs/pi-claude-cli 0.4.15 → 0.5.0

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
@@ -95,15 +95,54 @@ allowlists). Model access and your subscription login are unaffected.
95
95
  Related knobs: `PI_CLAUDE_CLI_TIMEOUT_MS` overrides the 300s inactivity
96
96
  timeout (CLI-side tools can be silent on stdout for minutes).
97
97
 
98
+ ### Auto-compact window
99
+
100
+ The provider resumes **one** CLI session for a pi session's whole life, and
101
+ nothing else ever shrinks it. On 1M-context models the CLI's own auto-compact
102
+ default lets that session ratchet toward a million tokens — measured across 26
103
+ real sessions, contexts reached 480k+, the average request carried 202k
104
+ tokens, and every request re-reads the full context. So the provider passes
105
+ `--autocompact 200000` **by default**: Claude Code compacts the session itself
106
+ when its context nears 200k, keeping the cached system-prompt prefix and full
107
+ transcript fidelity.
108
+
109
+ `PI_CLAUDE_CLI_AUTOCOMPACT` configures it (read per spawn, like the flags
110
+ above):
111
+
112
+ | Value | Behaviour |
113
+ | ----------------------- | ---------------------------------------------------------------------------------------------- |
114
+ | _(unset)_ | `--autocompact 200000` — the 200k budget these models run under everywhere the 1M beta is off. |
115
+ | `400k`, `400000`, `400` | Any window from 100k to 1M; bare numbers are thousands (CLI shorthand). |
116
+ | `auto` | `--autocompact auto` — the CLI's own default (≈ the model's full window). |
117
+ | `off` | Omit the flag entirely (use on CLIs that predate `--autocompact`). |
118
+
119
+ The value is a token **count**, not a percentage: cache read/write bill per
120
+ token and every request re-reads the whole context, so the sane budget is the
121
+ same on a 200k model and a 1M one. Invalid values warn and fall back to the
122
+ default instead of reaching the CLI, which rejects them by refusing to start.
123
+
124
+ Note for pre-existing sessions: the first resumed turn of a session already
125
+ past the window compacts immediately — one summarization pass, then the
126
+ session continues small. That is the remediation, not a bug. pi's own
127
+ compaction is separate (it rewrites pi's transcript, never the CLI session's)
128
+ and with this cap it should rarely trigger.
129
+
98
130
  ### Which system prompt
99
131
 
100
132
  `PI_CLAUDE_CLI_SYSTEM_PROMPT` chooses whose system prompt the subprocess
101
133
  runs under. It is read per spawn, so a host can change it between sessions.
102
134
 
103
- | Value | Behaviour |
104
- | -------------------- | ------------------------------------------------------------------------- |
105
- | `claude` _(default)_ | `--append-system-prompt`: pi's prompt layers on top of Claude Code's own. |
106
- | `pi` | `--system-prompt`: pi's prompt replaces Claude Code's entirely. |
135
+ | Value | Behaviour |
136
+ | -------------------- | ------------------------------------------------------------------------------ |
137
+ | `claude` _(default)_ | `--append-system-prompt-file`: pi's prompt layers on top of Claude Code's own. |
138
+ | `pi` | `--system-prompt-file`: pi's prompt replaces Claude Code's entirely. |
139
+
140
+ The `-file` suffix matters: `--system-prompt` / `--append-system-prompt`
141
+ (unsuffixed) take a **literal string**, not a path. Passing a temp-file path
142
+ to the unsuffixed flag makes the path itself the prompt — pi's instructions
143
+ never reach the model, silently, with no error. This shipped unnoticed since
144
+ the provider's system-prompt support was first added; see the correction
145
+ below.
107
146
 
108
147
  `minimal` is accepted as an alias for `pi`, `append` for `claude`; anything
109
148
  unrecognised falls back to the default rather than failing a session.
@@ -127,7 +166,7 @@ restyles its prompt so the `Available tools:` / `Guidelines:` anchors are
127
166
  missing, the prompt passes through untouched rather than being mangled.
128
167
 
129
168
  The system prompt goes on **every** spawn, not just the session-creating one:
130
- the CLI does not keep `--system-prompt` across `--resume`, and a resumed
169
+ the CLI does not keep `--system-prompt-file` across `--resume`, and a resumed
131
170
  session without it silently reverts to Claude Code's default prompt from turn
132
171
  2 onwards. Because an identical prefix is what keeps the prompt cache warm,
133
172
  the prompt a session was created with is stored in the sidecar
@@ -135,6 +174,18 @@ the prompt a session was created with is stored in the sidecar
135
174
  verbatim rather than rebuilt. A change to the mode therefore takes effect on
136
175
  the next new session, not the current one.
137
176
 
177
+ > **Correction (2026-08-29).** Both bullets above named the unsuffixed flags
178
+ > until this date. They were wrong the whole time the provider has supported a
179
+ > system prompt: `--system-prompt` / `--append-system-prompt` take a literal
180
+ > string, and the provider was handing them a temp-file path. That path string
181
+ > either became the entire "system prompt" (`pi` mode) or got appended as
182
+ > noise Claude Code's model ignored (`claude` mode) — either way, pi's actual
183
+ > instructions never reached the model, on ANY turn, since the very first spawn.
184
+ > Fixed by switching to `--system-prompt-file` / `--append-system-prompt-file`,
185
+ > which take a path. See
186
+ > [pidex's write-up](https://github.com/agustinsacco/pidex/blob/main/specs/log/2026-08-29-claude-cli-lifecycle-verification.md)
187
+ > for the live before/after.
188
+
138
189
  ## License
139
190
 
140
191
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.15",
3
+ "version": "0.5.0",
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,99 @@
1
+ /**
2
+ * Auto-compact window resolution (PI_CLAUDE_CLI_AUTOCOMPACT).
3
+ *
4
+ * Claude Code compacts a session automatically when its context approaches
5
+ * the auto-compact window (`--autocompact <auto|tokens>`, claude 2.1.x). On
6
+ * 1M-context models the CLI's own default lets a long-lived session ratchet
7
+ * toward a million tokens, and this provider resumes ONE CLI session for a
8
+ * pi session's whole life — nothing else ever shrinks it. Measured across
9
+ * 26 real pidex sessions (2026-08-30): contexts ratcheted to 480k+, the
10
+ * average request carried 202k tokens, and cache reads alone were ~53% of
11
+ * total spend. Sessions that stayed near 100–150k did the same work at a
12
+ * fraction of the cost.
13
+ *
14
+ * So the provider caps the window at 200k tokens BY DEFAULT — the budget
15
+ * these models run under everywhere the 1M beta is not enabled — and lets
16
+ * the host raise, lower, or disable it:
17
+ *
18
+ * PI_CLAUDE_CLI_AUTOCOMPACT=200k cap at 200k (the default)
19
+ * PI_CLAUDE_CLI_AUTOCOMPACT=400000 plain token counts work too
20
+ * PI_CLAUDE_CLI_AUTOCOMPACT=400 bare numbers are thousands (CLI shorthand)
21
+ * PI_CLAUDE_CLI_AUTOCOMPACT=auto the CLI's own default behaviour
22
+ * PI_CLAUDE_CLI_AUTOCOMPACT=off omit the flag entirely (also: 0, none,
23
+ * disable, disabled — for CLIs that
24
+ * predate --autocompact)
25
+ *
26
+ * The value is a token COUNT, not a percentage of the model's window. The
27
+ * costs this guards against are absolute — cache read/write bill per token,
28
+ * and every request re-reads the whole context — so "half the window" means
29
+ * something completely different on a 200k model than on a 1M one, while
30
+ * 200k tokens costs the same everywhere.
31
+ *
32
+ * An invalid or out-of-range value falls back to the default with a warning
33
+ * rather than being passed through: the CLI rejects bad values by refusing
34
+ * to start, which would kill every turn of every session over a typo.
35
+ */
36
+
37
+ /** Default auto-compact window, in tokens. */
38
+ export const DEFAULT_AUTOCOMPACT_TOKENS = 200_000;
39
+
40
+ /** The CLI accepts 100k–1M (claude 2.1.231: "It must be 'auto', or between 100k and 1M"). */
41
+ const MIN_TOKENS = 100_000;
42
+ const MAX_TOKENS = 1_000_000;
43
+
44
+ const OFF_VALUES = new Set([
45
+ "off",
46
+ "0",
47
+ "none",
48
+ "disable",
49
+ "disabled",
50
+ "false",
51
+ ]);
52
+
53
+ /**
54
+ * Parse a user-supplied window size into a token count.
55
+ * Mirrors the CLI's accepted forms: `500k`, `1M`, `200000`, and bare `200`
56
+ * meaning thousands. Returns undefined when unparseable.
57
+ */
58
+ export function parseAutocompactTokens(raw: string): number | undefined {
59
+ const m = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(raw.trim());
60
+ if (!m) return undefined;
61
+ const n = Number(m[1]);
62
+ if (!Number.isFinite(n) || n <= 0) return undefined;
63
+ const suffix = (m[2] ?? "").toLowerCase();
64
+ if (suffix === "k") return Math.round(n * 1_000);
65
+ if (suffix === "m") return Math.round(n * 1_000_000);
66
+ // Bare number: the CLI reads `200` as 200k shorthand; anything that already
67
+ // looks like a token count (>= 100k) is taken literally.
68
+ return n < MIN_TOKENS ? Math.round(n * 1_000) : Math.round(n);
69
+ }
70
+
71
+ /**
72
+ * Resolve the `--autocompact` argument from the environment.
73
+ *
74
+ * Returns the string to pass to the flag, or undefined to omit the flag
75
+ * entirely (explicit off). Unset resolves to the 200k default; `auto` is
76
+ * passed through so the CLI applies its own default; invalid values warn
77
+ * and fall back to the default.
78
+ */
79
+ export function resolveAutocompact(
80
+ env: NodeJS.ProcessEnv = process.env,
81
+ ): string | undefined {
82
+ const raw = (env.PI_CLAUDE_CLI_AUTOCOMPACT ?? "").trim();
83
+ if (raw === "") return String(DEFAULT_AUTOCOMPACT_TOKENS);
84
+
85
+ const lowered = raw.toLowerCase();
86
+ if (OFF_VALUES.has(lowered)) return undefined;
87
+ if (lowered === "auto") return "auto";
88
+
89
+ const tokens = parseAutocompactTokens(raw);
90
+ if (tokens === undefined || tokens < MIN_TOKENS || tokens > MAX_TOKENS) {
91
+ console.warn(
92
+ `[pi-claude-cli] PI_CLAUDE_CLI_AUTOCOMPACT=${JSON.stringify(raw)} is not ` +
93
+ `'auto', 'off', or a window between 100k and 1M — using the default ` +
94
+ `${DEFAULT_AUTOCOMPACT_TOKENS} tokens`,
95
+ );
96
+ return String(DEFAULT_AUTOCOMPACT_TOKENS);
97
+ }
98
+ return String(tokens);
99
+ }
@@ -16,14 +16,16 @@ import {
16
16
  DEFAULT_SYSTEM_PROMPT_MODE,
17
17
  type SystemPromptMode,
18
18
  } from "./system-prompt-mode.js";
19
+ import { resolveAutocompact } from "./autocompact.js";
19
20
 
20
21
  /**
21
22
  * Spawn a Claude CLI subprocess with all required flags for stream-json communication.
22
23
  *
23
24
  * @param modelId - The model ID to pass via --model flag
24
25
  * @param systemPrompt - Optional system prompt. In `claude` mode it is appended
25
- * to Claude Code's own via --append-system-prompt; in `pi` mode it replaces
26
- * it via --system-prompt. See src/system-prompt-mode.ts.
26
+ * to Claude Code's own via --append-system-prompt-file; in `pi` mode it
27
+ * replaces it via --system-prompt-file. The `-file` suffix is required: the
28
+ * unsuffixed flags take a literal string. See src/system-prompt-mode.ts.
27
29
  * @param options - Optional cwd, AbortSignal, effort level and prompt mode
28
30
  * @returns The spawned ChildProcess with piped stdin/stdout/stderr
29
31
  */
@@ -98,8 +100,16 @@ export function spawnClaude(
98
100
  }
99
101
 
100
102
  if (systemPrompt) {
101
- // Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
102
- // Both flags accept a file path or literal text.
103
+ // Write the system prompt to a temp file and pass the FILE flags.
104
+ //
105
+ // `--system-prompt` / `--append-system-prompt` take a literal string, NOT
106
+ // a path: handing them a path makes the path itself the system prompt, so
107
+ // pi's instructions never reach the model and the session silently runs on
108
+ // Claude Code's defaults. Verified on claude 2.1.231 — with a path the
109
+ // model denied having the codename its prompt assigned; with
110
+ // `--system-prompt-file` it answered correctly. The file variants also keep
111
+ // the prompt off the command line, which is what avoids ENAMETOOLONG on
112
+ // Windows.
103
113
  //
104
114
  // Keyed by CLI session, not just pid: the prompt goes on every spawn
105
115
  // (see provider.ts), and pi can run two turns of one process at once —
@@ -113,7 +123,7 @@ export function spawnClaude(
113
123
  // pi's on top of it. See src/system-prompt-mode.ts for the trade-off.
114
124
  const mode = options?.systemPromptMode ?? DEFAULT_SYSTEM_PROMPT_MODE;
115
125
  args.push(
116
- mode === "pi" ? "--system-prompt" : "--append-system-prompt",
126
+ mode === "pi" ? "--system-prompt-file" : "--append-system-prompt-file",
117
127
  tmpFile,
118
128
  );
119
129
  }
@@ -134,6 +144,16 @@ export function spawnClaude(
134
144
  args.push("--mcp-config", options.mcpConfigPath);
135
145
  }
136
146
 
147
+ // Auto-compact window (PI_CLAUDE_CLI_AUTOCOMPACT, default 200k — see
148
+ // src/autocompact.ts). The CLI does not keep flags across --resume, so
149
+ // like the system prompt this goes on every spawn. Resolved per spawn so
150
+ // a host can change the setting without restarting pi; the flag is
151
+ // config, not context, so changing it never invalidates the prompt cache.
152
+ const autocompact = resolveAutocompact();
153
+ if (autocompact !== undefined) {
154
+ args.push("--autocompact", autocompact);
155
+ }
156
+
137
157
  const proc = spawn("claude", args, {
138
158
  stdio: ["pipe", "pipe", "pipe"],
139
159
  cwd: options?.cwd ?? process.cwd(),
@@ -409,10 +409,11 @@ export function rewritePiToolSections(systemPrompt: string): string {
409
409
  * appending AGENTS.md content if found (walking up from cwd, then global fallback).
410
410
  * Sanitizes .pi references to .claude for Claude Code compatibility.
411
411
  *
412
- * In `pi` mode the caller passes the prompt to `--system-prompt`, replacing
413
- * Claude Code's own, so pi's tool sections are rewritten into Claude Code's
414
- * names first. In `claude` mode the prompt is appended to Claude Code's and
415
- * pi's wording is left exactly as pi wrote it.
412
+ * In `pi` mode the caller passes the prompt to `--system-prompt-file`,
413
+ * replacing Claude Code's own, so pi's tool sections are rewritten into Claude
414
+ * Code's names first. In `claude` mode the prompt is appended to Claude
415
+ * Code's (`--append-system-prompt-file`) and pi's wording is left exactly as
416
+ * pi wrote it.
416
417
  */
417
418
  export function buildSystemPrompt(
418
419
  context: { systemPrompt?: string; messages: any[] },
@@ -2,12 +2,16 @@
2
2
  * Which system prompt the Claude CLI subprocess runs under.
3
3
  *
4
4
  * `claude` (the default) appends pi's prompt to Claude Code's own via
5
- * `--append-system-prompt`. Everything the CLI normally knows about its
5
+ * `--append-system-prompt-file`. Everything the CLI normally knows about its
6
6
  * built-in tools stays in place, and pi's instructions ride on top.
7
7
  *
8
- * `pi` replaces Claude Code's prompt outright via `--system-prompt`, leaving
9
- * only pi's — the point of a minimal harness being that it does not inherit
10
- * another agent's preamble.
8
+ * `pi` replaces Claude Code's prompt outright via `--system-prompt-file`,
9
+ * leaving only pi's — the point of a minimal harness being that it does not
10
+ * inherit another agent's preamble.
11
+ *
12
+ * The `-file` suffix is not optional: the unsuffixed flags take a literal
13
+ * string, and passing them a path (as this provider did until 2026-08-29)
14
+ * makes the path itself the prompt, silently. See process-manager.ts.
11
15
  *
12
16
  * Sizing, measured rather than assumed: in a real session the CLI's fixed
13
17
  * cached prefix sat at 17,475 tokens. That is Claude Code's system prompt