@saccolabs/pi-claude-cli 0.4.14 → 0.4.16
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 +31 -7
- package/package.json +1 -1
- package/src/process-manager.ts +35 -10
- package/src/prompt-builder.ts +5 -4
- package/src/provider.ts +30 -8
- package/src/session-map.ts +59 -1
- package/src/system-prompt-mode.ts +8 -4
package/README.md
CHANGED
|
@@ -100,10 +100,17 @@ timeout (CLI-side tools can be silent on stdout for minutes).
|
|
|
100
100
|
`PI_CLAUDE_CLI_SYSTEM_PROMPT` chooses whose system prompt the subprocess
|
|
101
101
|
runs under. It is read per spawn, so a host can change it between sessions.
|
|
102
102
|
|
|
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. |
|
|
103
|
+
| Value | Behaviour |
|
|
104
|
+
| -------------------- | ------------------------------------------------------------------------------ |
|
|
105
|
+
| `claude` _(default)_ | `--append-system-prompt-file`: pi's prompt layers on top of Claude Code's own. |
|
|
106
|
+
| `pi` | `--system-prompt-file`: pi's prompt replaces Claude Code's entirely. |
|
|
107
|
+
|
|
108
|
+
The `-file` suffix matters: `--system-prompt` / `--append-system-prompt`
|
|
109
|
+
(unsuffixed) take a **literal string**, not a path. Passing a temp-file path
|
|
110
|
+
to the unsuffixed flag makes the path itself the prompt — pi's instructions
|
|
111
|
+
never reach the model, silently, with no error. This shipped unnoticed since
|
|
112
|
+
the provider's system-prompt support was first added; see the correction
|
|
113
|
+
below.
|
|
107
114
|
|
|
108
115
|
`minimal` is accepted as an alias for `pi`, `append` for `claude`; anything
|
|
109
116
|
unrecognised falls back to the default rather than failing a session.
|
|
@@ -126,9 +133,26 @@ misleading, `pi` mode rewrites pi's tool sections — which name pi's tools
|
|
|
126
133
|
restyles its prompt so the `Available tools:` / `Guidelines:` anchors are
|
|
127
134
|
missing, the prompt passes through untouched rather than being mangled.
|
|
128
135
|
|
|
129
|
-
|
|
130
|
-
the
|
|
131
|
-
|
|
136
|
+
The system prompt goes on **every** spawn, not just the session-creating one:
|
|
137
|
+
the CLI does not keep `--system-prompt-file` across `--resume`, and a resumed
|
|
138
|
+
session without it silently reverts to Claude Code's default prompt from turn
|
|
139
|
+
2 onwards. Because an identical prefix is what keeps the prompt cache warm,
|
|
140
|
+
the prompt a session was created with is stored in the sidecar
|
|
141
|
+
(`~/.pi/agent/pi-claude-cli/sysprompt/<cli-session-id>.txt`) and replayed
|
|
142
|
+
verbatim rather than rebuilt. A change to the mode therefore takes effect on
|
|
143
|
+
the next new session, not the current one.
|
|
144
|
+
|
|
145
|
+
> **Correction (2026-08-29).** Both bullets above named the unsuffixed flags
|
|
146
|
+
> until this date. They were wrong the whole time the provider has supported a
|
|
147
|
+
> system prompt: `--system-prompt` / `--append-system-prompt` take a literal
|
|
148
|
+
> string, and the provider was handing them a temp-file path. That path string
|
|
149
|
+
> either became the entire "system prompt" (`pi` mode) or got appended as
|
|
150
|
+
> noise Claude Code's model ignored (`claude` mode) — either way, pi's actual
|
|
151
|
+
> instructions never reached the model, on ANY turn, since the very first spawn.
|
|
152
|
+
> Fixed by switching to `--system-prompt-file` / `--append-system-prompt-file`,
|
|
153
|
+
> which take a path. See
|
|
154
|
+
> [pidex's write-up](https://github.com/agustinsacco/pidex/blob/main/specs/log/2026-08-29-claude-cli-lifecycle-verification.md)
|
|
155
|
+
> for the live before/after.
|
|
132
156
|
|
|
133
157
|
## License
|
|
134
158
|
|
package/package.json
CHANGED
package/src/process-manager.ts
CHANGED
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
*
|
|
23
23
|
* @param modelId - The model ID to pass via --model flag
|
|
24
24
|
* @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
|
|
26
|
-
* it via --system-prompt.
|
|
25
|
+
* to Claude Code's own via --append-system-prompt-file; in `pi` mode it
|
|
26
|
+
* replaces it via --system-prompt-file. The `-file` suffix is required: the
|
|
27
|
+
* unsuffixed flags take a literal string. See src/system-prompt-mode.ts.
|
|
27
28
|
* @param options - Optional cwd, AbortSignal, effort level and prompt mode
|
|
28
29
|
* @returns The spawned ChildProcess with piped stdin/stdout/stderr
|
|
29
30
|
*/
|
|
@@ -33,6 +34,15 @@ function isHermetic(): boolean {
|
|
|
33
34
|
return value === "1" || value === "true" || value === "yes";
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Where a spawn's system prompt is staged. Scoped to the CLI session so
|
|
39
|
+
* concurrent turns in one pi process cannot clobber each other.
|
|
40
|
+
*/
|
|
41
|
+
function systemPromptFilePath(sessionKey?: string): string {
|
|
42
|
+
const suffix = sessionKey ? `-${sessionKey}` : "";
|
|
43
|
+
return join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}${suffix}.txt`);
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
export function spawnClaude(
|
|
37
47
|
modelId: string,
|
|
38
48
|
systemPrompt?: string,
|
|
@@ -89,18 +99,30 @@ export function spawnClaude(
|
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
if (systemPrompt) {
|
|
92
|
-
// Write system prompt to a temp file
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
// Write the system prompt to a temp file and pass the FILE flags.
|
|
103
|
+
//
|
|
104
|
+
// `--system-prompt` / `--append-system-prompt` take a literal string, NOT
|
|
105
|
+
// a path: handing them a path makes the path itself the system prompt, so
|
|
106
|
+
// pi's instructions never reach the model and the session silently runs on
|
|
107
|
+
// Claude Code's defaults. Verified on claude 2.1.231 — with a path the
|
|
108
|
+
// model denied having the codename its prompt assigned; with
|
|
109
|
+
// `--system-prompt-file` it answered correctly. The file variants also keep
|
|
110
|
+
// the prompt off the command line, which is what avoids ENAMETOOLONG on
|
|
111
|
+
// Windows.
|
|
112
|
+
//
|
|
113
|
+
// Keyed by CLI session, not just pid: the prompt goes on every spawn
|
|
114
|
+
// (see provider.ts), and pi can run two turns of one process at once —
|
|
115
|
+
// its own sub-agents do. A shared per-pid path would let one turn
|
|
116
|
+
// overwrite the prompt another turn is about to read.
|
|
117
|
+
const tmpFile = systemPromptFilePath(
|
|
118
|
+
options?.resumeSessionId ?? options?.newSessionId,
|
|
97
119
|
);
|
|
98
120
|
writeFileSync(tmpFile, systemPrompt, "utf-8");
|
|
99
121
|
// `pi` mode replaces Claude Code's prompt outright; `claude` mode layers
|
|
100
122
|
// pi's on top of it. See src/system-prompt-mode.ts for the trade-off.
|
|
101
123
|
const mode = options?.systemPromptMode ?? DEFAULT_SYSTEM_PROMPT_MODE;
|
|
102
124
|
args.push(
|
|
103
|
-
mode === "pi" ? "--system-prompt" : "--append-system-prompt",
|
|
125
|
+
mode === "pi" ? "--system-prompt-file" : "--append-system-prompt-file",
|
|
104
126
|
tmpFile,
|
|
105
127
|
);
|
|
106
128
|
}
|
|
@@ -132,10 +154,13 @@ export function spawnClaude(
|
|
|
132
154
|
/**
|
|
133
155
|
* Clean up the temp system prompt file created by spawnClaude.
|
|
134
156
|
* Safe to call multiple times or when no file exists.
|
|
157
|
+
*
|
|
158
|
+
* Pass the same CLI session key the spawn used; omitting it cleans the
|
|
159
|
+
* unscoped path, which is all a spawn without a session id creates.
|
|
135
160
|
*/
|
|
136
|
-
export function cleanupSystemPromptFile(): void {
|
|
161
|
+
export function cleanupSystemPromptFile(sessionKey?: string): void {
|
|
137
162
|
try {
|
|
138
|
-
unlinkSync(
|
|
163
|
+
unlinkSync(systemPromptFilePath(sessionKey));
|
|
139
164
|
} catch {
|
|
140
165
|
// File doesn't exist or already deleted — ignore
|
|
141
166
|
}
|
package/src/prompt-builder.ts
CHANGED
|
@@ -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`,
|
|
413
|
-
* Claude Code's own, so pi's tool sections are rewritten into Claude
|
|
414
|
-
* names first. In `claude` mode the prompt is appended to Claude
|
|
415
|
-
* pi's wording is left exactly as
|
|
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[] },
|
package/src/provider.ts
CHANGED
|
@@ -49,6 +49,9 @@ import {
|
|
|
49
49
|
getCliSession,
|
|
50
50
|
setCliSession,
|
|
51
51
|
clearCliSession,
|
|
52
|
+
getSystemPrompt,
|
|
53
|
+
setSystemPrompt,
|
|
54
|
+
clearSystemPrompt,
|
|
52
55
|
} from "./session-map.js";
|
|
53
56
|
import { randomUUID } from "node:crypto";
|
|
54
57
|
/** Inactivity timeout: kill subprocess if no stdout for 180 seconds (3 minutes). */
|
|
@@ -178,6 +181,9 @@ export function streamViaCli(
|
|
|
178
181
|
let selfInterrupted = false;
|
|
179
182
|
// Set on pi-initiated abort so the turn ends quietly, not as an error.
|
|
180
183
|
let aborted = false;
|
|
184
|
+
// CLI session this attempt staged its system prompt under, so the finally
|
|
185
|
+
// below can remove the right file. Set once the ids are resolved.
|
|
186
|
+
let promptFileKey: string | undefined;
|
|
181
187
|
|
|
182
188
|
try {
|
|
183
189
|
const cwd = options?.cwd ?? process.cwd();
|
|
@@ -198,6 +204,7 @@ export function streamViaCli(
|
|
|
198
204
|
// Fresh sessions get a provider-minted id, never pi's: the CLI refuses
|
|
199
205
|
// a --session-id it has already seen, and forks reuse pi ids.
|
|
200
206
|
const newCliId = resumeSessionId ? undefined : randomUUID();
|
|
207
|
+
promptFileKey = resumeSessionId ?? newCliId;
|
|
201
208
|
|
|
202
209
|
// Resume sends only the delta since the last assistant turn (new user
|
|
203
210
|
// text, handoff tool results). Create/import sends the full history.
|
|
@@ -205,14 +212,19 @@ export function streamViaCli(
|
|
|
205
212
|
? buildResumePrompt(context)
|
|
206
213
|
: buildPrompt(context);
|
|
207
214
|
// Resolved per spawn rather than once at module load so a host can flip
|
|
208
|
-
// the setting between sessions without restarting pi.
|
|
209
|
-
// session
|
|
210
|
-
//
|
|
211
|
-
// the next new session, not this one.
|
|
215
|
+
// the setting between sessions without restarting pi. Switching
|
|
216
|
+
// mid-session takes effect on the next NEW session, not this one: a
|
|
217
|
+
// resumed session replays the prompt it was created with (below).
|
|
212
218
|
const systemPromptMode = resolveSystemPromptMode();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
// The CLI does not keep --system-prompt across --resume, so it goes on
|
|
220
|
+
// EVERY spawn. On resume, replay the stored bytes rather than rebuilding
|
|
221
|
+
// them: an identical prompt keeps the cached prefix, a drifted one
|
|
222
|
+
// re-bills the whole transcript as cache write. See src/session-map.ts.
|
|
223
|
+
const storedSystemPrompt = resumeSessionId
|
|
224
|
+
? getSystemPrompt(resumeSessionId)
|
|
225
|
+
: undefined;
|
|
226
|
+
const systemPrompt =
|
|
227
|
+
storedSystemPrompt ?? buildSystemPrompt(context, cwd, systemPromptMode);
|
|
216
228
|
|
|
217
229
|
// Compute effort level from reasoning options
|
|
218
230
|
const effort = mapThinkingEffort(
|
|
@@ -234,6 +246,9 @@ export function streamViaCli(
|
|
|
234
246
|
// Record the mapping as soon as the session exists on disk. On a turn
|
|
235
247
|
// that later errors, the mapping is cleared so the next turn reimports.
|
|
236
248
|
if (piSessionId && newCliId) setCliSession(piSessionId, newCliId);
|
|
249
|
+
// Store the created prompt so every later turn re-passes these exact
|
|
250
|
+
// bytes. Without it, resume falls back to a rebuild that can drift.
|
|
251
|
+
if (newCliId && systemPrompt) setSystemPrompt(newCliId, systemPrompt);
|
|
237
252
|
const getStderr = captureStderr(proc);
|
|
238
253
|
|
|
239
254
|
// Register in global process registry for teardown cleanup
|
|
@@ -485,12 +500,17 @@ export function streamViaCli(
|
|
|
485
500
|
// Recoverable: the sidecar pointed at a CLI session that no
|
|
486
501
|
// longer exists. Clear it; the driver reimports once.
|
|
487
502
|
if (piSessionId) clearCliSession(piSessionId);
|
|
503
|
+
clearSystemPrompt(resumeSessionId);
|
|
488
504
|
resumeMiss = true;
|
|
489
505
|
broken = true;
|
|
490
506
|
} else {
|
|
491
507
|
// A failed turn may leave the CLI session ending on a user
|
|
492
508
|
// entry; resuming that would splice filler. Reimport next turn.
|
|
493
509
|
if (piSessionId) clearCliSession(piSessionId);
|
|
510
|
+
// This CLI session will never be resumed, so its stored prompt
|
|
511
|
+
// is dead weight.
|
|
512
|
+
const deadCliId = resumeSessionId ?? newCliId;
|
|
513
|
+
if (deadCliId) clearSystemPrompt(deadCliId);
|
|
494
514
|
endStreamWithError(errMsg);
|
|
495
515
|
}
|
|
496
516
|
}
|
|
@@ -581,6 +601,9 @@ export function streamViaCli(
|
|
|
581
601
|
if (options?.signal && abortHandler) {
|
|
582
602
|
options.signal.removeEventListener("abort", abortHandler);
|
|
583
603
|
}
|
|
604
|
+
// Staged prompt file is per CLI session, so it is removed here where
|
|
605
|
+
// the ids are in scope — a resume-miss retry stages a second one.
|
|
606
|
+
cleanupSystemPromptFile(promptFileKey);
|
|
584
607
|
}
|
|
585
608
|
}
|
|
586
609
|
|
|
@@ -601,7 +624,6 @@ export function streamViaCli(
|
|
|
601
624
|
} as any);
|
|
602
625
|
stream.end();
|
|
603
626
|
} finally {
|
|
604
|
-
cleanupSystemPromptFile();
|
|
605
627
|
// The sub-agent channel is state ABOUT a turn, so it must not outlive
|
|
606
628
|
// one. Left standing, the last snapshot pins whatever the agents were
|
|
607
629
|
// doing when the episode ended — a host then shows "running" for
|
package/src/session-map.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* given pi session at a time.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
|
|
@@ -65,3 +65,61 @@ export function clearCliSession(piSessionId: string): void {
|
|
|
65
65
|
writeMap(map);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Per-CLI-session system prompt.
|
|
71
|
+
//
|
|
72
|
+
// The CLI does NOT persist --system-prompt across --resume: a resumed session
|
|
73
|
+
// runs under Claude Code's DEFAULT prompt unless the flag is passed again.
|
|
74
|
+
// That is both a correctness bug (pi's instructions vanish from turn 2 on) and
|
|
75
|
+
// the single largest token cost in a pi session, because swapping the prompt
|
|
76
|
+
// invalidates the cached prefix and re-bills the whole transcript as cache
|
|
77
|
+
// WRITE. Verified 2026-08-29 with a shimmed `claude`: re-passing the same
|
|
78
|
+
// prompt on resume cost 112 tokens where dropping it cost 9,761.
|
|
79
|
+
//
|
|
80
|
+
// Re-passing is only cheap when the bytes are IDENTICAL, and rebuilding is not
|
|
81
|
+
// byte-stable — buildSystemPrompt() appends a tool-results paragraph the
|
|
82
|
+
// moment history contains a toolResult, and pi may restyle its own prompt
|
|
83
|
+
// between turns. So the prompt the session was CREATED with is stored here and
|
|
84
|
+
// replayed verbatim for the life of that CLI session.
|
|
85
|
+
//
|
|
86
|
+
// One file per session rather than a field in session-map.json: prompts run to
|
|
87
|
+
// tens of kilobytes, and the map is read on every spawn.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
function systemPromptPath(cliSessionId: string): string {
|
|
91
|
+
return join(stateDir(), "sysprompt", `${cliSessionId}.txt`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The system prompt a CLI session was created with, if it was recorded.
|
|
96
|
+
*
|
|
97
|
+
* Undefined for sessions created before this was stored, which correctly falls
|
|
98
|
+
* back to rebuilding: less cache-stable than a verbatim replay, still far
|
|
99
|
+
* better than sending no prompt at all.
|
|
100
|
+
*/
|
|
101
|
+
export function getSystemPrompt(cliSessionId: string): string | undefined {
|
|
102
|
+
try {
|
|
103
|
+
return readFileSync(systemPromptPath(cliSessionId), "utf-8");
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function setSystemPrompt(cliSessionId: string, prompt: string): void {
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(join(stateDir(), "sysprompt"), { recursive: true });
|
|
112
|
+
writeFileSync(systemPromptPath(cliSessionId), prompt, "utf-8");
|
|
113
|
+
} catch {
|
|
114
|
+
// Best effort: an unwritable sidecar degrades to rebuilding the prompt.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Drop a stored prompt. Paired with clearCliSession on a resume miss. */
|
|
119
|
+
export function clearSystemPrompt(cliSessionId: string): void {
|
|
120
|
+
try {
|
|
121
|
+
rmSync(systemPromptPath(cliSessionId), { force: true });
|
|
122
|
+
} catch {
|
|
123
|
+
// Already gone, or unwritable — both are fine.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -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`,
|
|
9
|
-
* only pi's — the point of a minimal harness being that it does not
|
|
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
|