@saccolabs/pi-claude-cli 0.4.14 → 0.4.15

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
@@ -126,9 +126,14 @@ misleading, `pi` mode rewrites pi's tool sections — which name pi's tools
126
126
  restyles its prompt so the `Available tools:` / `Guidelines:` anchors are
127
127
  missing, the prompt passes through untouched rather than being mangled.
128
128
 
129
- Only the session-creating turn sends a system prompt the CLI keeps it for
130
- the life of the session so a change takes effect on the next new session,
131
- not the current one.
129
+ 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
131
+ session without it silently reverts to Claude Code's default prompt from turn
132
+ 2 onwards. Because an identical prefix is what keeps the prompt cache warm,
133
+ the prompt a session was created with is stored in the sidecar
134
+ (`~/.pi/agent/pi-claude-cli/sysprompt/<cli-session-id>.txt`) and replayed
135
+ verbatim rather than rebuilt. A change to the mode therefore takes effect on
136
+ the next new session, not the current one.
132
137
 
133
138
  ## License
134
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -33,6 +33,15 @@ function isHermetic(): boolean {
33
33
  return value === "1" || value === "true" || value === "yes";
34
34
  }
35
35
 
36
+ /**
37
+ * Where a spawn's system prompt is staged. Scoped to the CLI session so
38
+ * concurrent turns in one pi process cannot clobber each other.
39
+ */
40
+ function systemPromptFilePath(sessionKey?: string): string {
41
+ const suffix = sessionKey ? `-${sessionKey}` : "";
42
+ return join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}${suffix}.txt`);
43
+ }
44
+
36
45
  export function spawnClaude(
37
46
  modelId: string,
38
47
  systemPrompt?: string,
@@ -91,9 +100,13 @@ export function spawnClaude(
91
100
  if (systemPrompt) {
92
101
  // Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
93
102
  // Both flags accept a file path or literal text.
94
- const tmpFile = join(
95
- tmpdir(),
96
- `pi-claude-cli-sysprompt-${process.pid}.txt`,
103
+ //
104
+ // Keyed by CLI session, not just pid: the prompt goes on every spawn
105
+ // (see provider.ts), and pi can run two turns of one process at once —
106
+ // its own sub-agents do. A shared per-pid path would let one turn
107
+ // overwrite the prompt another turn is about to read.
108
+ const tmpFile = systemPromptFilePath(
109
+ options?.resumeSessionId ?? options?.newSessionId,
97
110
  );
98
111
  writeFileSync(tmpFile, systemPrompt, "utf-8");
99
112
  // `pi` mode replaces Claude Code's prompt outright; `claude` mode layers
@@ -132,10 +145,13 @@ export function spawnClaude(
132
145
  /**
133
146
  * Clean up the temp system prompt file created by spawnClaude.
134
147
  * Safe to call multiple times or when no file exists.
148
+ *
149
+ * Pass the same CLI session key the spawn used; omitting it cleans the
150
+ * unscoped path, which is all a spawn without a session id creates.
135
151
  */
136
- export function cleanupSystemPromptFile(): void {
152
+ export function cleanupSystemPromptFile(sessionKey?: string): void {
137
153
  try {
138
- unlinkSync(join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}.txt`));
154
+ unlinkSync(systemPromptFilePath(sessionKey));
139
155
  } catch {
140
156
  // File doesn't exist or already deleted — ignore
141
157
  }
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. Only the
209
- // session-creating turn carries a system prompt the CLI keeps it for
210
- // the life of the session so switching mid-session takes effect on
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
- const systemPrompt = resumeSessionId
214
- ? undefined
215
- : buildSystemPrompt(context, cwd, systemPromptMode);
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
@@ -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
+ }