@saccolabs/pi-claude-cli 0.4.6 → 0.4.7

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
@@ -81,6 +81,41 @@ allowlists). Model access and your subscription login are unaffected.
81
81
  Related knobs: `PI_CLAUDE_CLI_TIMEOUT_MS` overrides the 300s inactivity
82
82
  timeout (CLI-side tools can be silent on stdout for minutes).
83
83
 
84
+ ### Which system prompt
85
+
86
+ `PI_CLAUDE_CLI_SYSTEM_PROMPT` chooses whose system prompt the subprocess
87
+ runs under. It is read per spawn, so a host can change it between sessions.
88
+
89
+ | Value | Behaviour |
90
+ | -------------------- | ------------------------------------------------------------------------- |
91
+ | `claude` _(default)_ | `--append-system-prompt`: pi's prompt layers on top of Claude Code's own. |
92
+ | `pi` | `--system-prompt`: pi's prompt replaces Claude Code's entirely. |
93
+
94
+ `minimal` is accepted as an alias for `pi`, `append` for `claude`; anything
95
+ unrecognised falls back to the default rather than failing a session.
96
+
97
+ **Why you might want `pi`.** The point of a minimal harness is not inheriting
98
+ another agent's preamble. Measured on a real session, the CLI's fixed cached
99
+ prefix was 17,475 tokens; the tool schemas (~4.3k) stay either way, but the
100
+ rest is Claude Code's prompt, and pi's own — after the tool-section rewrite
101
+ below — is ~674 tokens. That frees roughly 12k tokens of context window per
102
+ call. It is a window win, not a cost win: the prefix is cached and bills at
103
+ 0.1x.
104
+
105
+ **Why the default is still `claude`.** Claude Code's prompt carries operating
106
+ guidance for its own tools. Replacing it leaves the model with pi's
107
+ instructions plus the raw tool schemas. To stop that being actively
108
+ misleading, `pi` mode rewrites pi's tool sections — which name pi's tools
109
+ (`read`, `edit`, `grep`, `find`, `ls`) and pi's parameters (`path`,
110
+ `oldText`, `newText`) — into Claude Code's vocabulary (`Read`, `Edit`,
111
+ `Grep`, `Glob`, with `file_path`, `old_string`, `new_string`). If pi ever
112
+ restyles its prompt so the `Available tools:` / `Guidelines:` anchors are
113
+ missing, the prompt passes through untouched rather than being mangled.
114
+
115
+ Only the session-creating turn sends a system prompt — the CLI keeps it for
116
+ the life of the session — so a change takes effect on the next new session,
117
+ not the current one.
118
+
84
119
  ## License
85
120
 
86
121
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -12,13 +12,19 @@ import { writeFileSync, unlinkSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { tmpdir } from "node:os";
14
14
  import type { ChildProcess } from "node:child_process";
15
+ import {
16
+ DEFAULT_SYSTEM_PROMPT_MODE,
17
+ type SystemPromptMode,
18
+ } from "./system-prompt-mode.js";
15
19
 
16
20
  /**
17
21
  * Spawn a Claude CLI subprocess with all required flags for stream-json communication.
18
22
  *
19
23
  * @param modelId - The model ID to pass via --model flag
20
- * @param systemPrompt - Optional system prompt appended via --append-system-prompt
21
- * @param options - Optional cwd, AbortSignal, and effort level
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 replaces
26
+ * it via --system-prompt. See src/system-prompt-mode.ts.
27
+ * @param options - Optional cwd, AbortSignal, effort level and prompt mode
22
28
  * @returns The spawned ChildProcess with piped stdin/stdout/stderr
23
29
  */
24
30
  /** Truthy PI_CLAUDE_CLI_HERMETIC opts in to hermetic mode (see README). */
@@ -37,6 +43,7 @@ export function spawnClaude(
37
43
  mcpConfigPath?: string;
38
44
  resumeSessionId?: string;
39
45
  newSessionId?: string;
46
+ systemPromptMode?: SystemPromptMode;
40
47
  },
41
48
  ): ChildProcess {
42
49
  const args = [
@@ -73,13 +80,19 @@ export function spawnClaude(
73
80
 
74
81
  if (systemPrompt) {
75
82
  // Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
76
- // Claude CLI's --append-system-prompt accepts a file path or literal text.
83
+ // Both flags accept a file path or literal text.
77
84
  const tmpFile = join(
78
85
  tmpdir(),
79
86
  `pi-claude-cli-sysprompt-${process.pid}.txt`,
80
87
  );
81
88
  writeFileSync(tmpFile, systemPrompt, "utf-8");
82
- args.push("--append-system-prompt", tmpFile);
89
+ // `pi` mode replaces Claude Code's prompt outright; `claude` mode layers
90
+ // pi's on top of it. See src/system-prompt-mode.ts for the trade-off.
91
+ const mode = options?.systemPromptMode ?? DEFAULT_SYSTEM_PROMPT_MODE;
92
+ args.push(
93
+ mode === "pi" ? "--system-prompt" : "--append-system-prompt",
94
+ tmpFile,
95
+ );
83
96
  }
84
97
 
85
98
  if (options?.effort) {
@@ -11,6 +11,10 @@
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { resolve, join, dirname } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ import {
15
+ DEFAULT_SYSTEM_PROMPT_MODE,
16
+ type SystemPromptMode,
17
+ } from "./system-prompt-mode.js";
14
18
  import {
15
19
  mapPiToolNameToClaude,
16
20
  translatePiArgsToClaude,
@@ -343,19 +347,86 @@ function findFinalUserMessageIndex(messages: any[]): number {
343
347
  return -1;
344
348
  }
345
349
 
350
+ /**
351
+ * Tool guidance in Claude Code's vocabulary, used only in `pi` prompt mode.
352
+ *
353
+ * Replacing Claude Code's system prompt means the model still receives its
354
+ * tool *schemas* from the API but loses the prose about when and how to use
355
+ * them. Pi's own prose can't stand in unedited: it names pi's tools
356
+ * (`read`, `edit`, `grep`, `find`, `ls`) and pi's parameters (`path`,
357
+ * `oldText`, `newText`), none of which match what the model is actually
358
+ * handed (`Read`, `Edit`, `Grep`, `Glob`, with `file_path`, `old_string`,
359
+ * `new_string`). Left in place it is not merely useless but actively
360
+ * misleading, so it is swapped for this.
361
+ */
362
+ const CLAUDE_CODE_TOOLS_SECTION = `Available tools:
363
+ - Read: Read file contents (key param: file_path)
364
+ - Write: Create or overwrite files (key params: file_path, content)
365
+ - Edit: Make precise edits with exact text replacement (key params: file_path, old_string, new_string)
366
+ - Bash: Execute bash commands (key param: command)
367
+ - Grep: Search file contents for patterns (key params: pattern, path)
368
+ - Glob: Find files by glob pattern (key params: pattern, path)
369
+
370
+ In addition to the tools above, you may have access to other custom tools depending on the project.
371
+
372
+ Guidelines:
373
+ - Prefer Grep and Glob over Bash for file exploration (faster, respects .gitignore)
374
+ - Use Read to examine files before editing
375
+ - Use Edit for precise changes (old_string must match exactly)
376
+ - Use Write only for new files or complete rewrites
377
+ - When summarizing your actions, output plain text directly — do NOT use Bash to display what you did
378
+ - Be concise in your responses
379
+ - Show file paths clearly when working with files`;
380
+
381
+ /**
382
+ * Swap pi's tool documentation for Claude Code's.
383
+ *
384
+ * Pi's prompt is a sequence of blank-line-separated blocks; the tool material
385
+ * runs from the `Available tools:` block through the `Guidelines:` block
386
+ * (verified against pi 0.84.2, where those are blocks 1 and 3 of 5). Both
387
+ * anchors must be present and in order, otherwise the prompt is returned
388
+ * untouched — if pi restyles its prompt the failure mode should be "kept the
389
+ * original", never a mangled one.
390
+ *
391
+ * Exported for tests.
392
+ */
393
+ export function rewritePiToolSections(systemPrompt: string): string {
394
+ const blocks = systemPrompt.split("\n\n");
395
+
396
+ const start = blocks.findIndex((b) => b.startsWith("Available tools:"));
397
+ const end = blocks.findIndex((b) => b.startsWith("Guidelines:"));
398
+ if (start === -1 || end === -1 || end < start) return systemPrompt;
399
+
400
+ const kept = blocks.filter((_, i) => i < start || i > end);
401
+ // Reinstate the replacement where the originals were, so the intro still
402
+ // reads into it and anything after (pi's docs section) still follows.
403
+ kept.splice(start, 0, CLAUDE_CODE_TOOLS_SECTION);
404
+ return kept.join("\n\n");
405
+ }
406
+
346
407
  /**
347
408
  * Builds the system prompt from the context's systemPrompt field,
348
409
  * appending AGENTS.md content if found (walking up from cwd, then global fallback).
349
410
  * Sanitizes .pi references to .claude for Claude Code compatibility.
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.
350
416
  */
351
417
  export function buildSystemPrompt(
352
418
  context: { systemPrompt?: string; messages: any[] },
353
419
  cwd: string,
420
+ mode: SystemPromptMode = DEFAULT_SYSTEM_PROMPT_MODE,
354
421
  ): string {
355
422
  const parts: string[] = [];
356
423
 
357
424
  if (context.systemPrompt) {
358
- parts.push(context.systemPrompt);
425
+ parts.push(
426
+ mode === "pi"
427
+ ? rewritePiToolSections(context.systemPrompt)
428
+ : context.systemPrompt,
429
+ );
359
430
  }
360
431
 
361
432
  // Look for AGENTS.md
package/src/provider.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  buildSystemPrompt,
27
27
  buildResumePrompt,
28
28
  } from "./prompt-builder.js";
29
+ import { resolveSystemPromptMode } from "./system-prompt-mode.js";
29
30
  import {
30
31
  spawnClaude,
31
32
  writeUserMessage,
@@ -124,9 +125,15 @@ export function streamViaCli(
124
125
  const prompt = resumeSessionId
125
126
  ? buildResumePrompt(context)
126
127
  : buildPrompt(context);
128
+ // Resolved per spawn rather than once at module load so a host can flip
129
+ // the setting between sessions without restarting pi. Only the
130
+ // session-creating turn carries a system prompt — the CLI keeps it for
131
+ // the life of the session — so switching mid-session takes effect on
132
+ // the next new session, not this one.
133
+ const systemPromptMode = resolveSystemPromptMode();
127
134
  const systemPrompt = resumeSessionId
128
135
  ? undefined
129
- : buildSystemPrompt(context, cwd);
136
+ : buildSystemPrompt(context, cwd, systemPromptMode);
130
137
 
131
138
  // Compute effort level from reasoning options
132
139
  const effort = mapThinkingEffort(
@@ -143,6 +150,7 @@ export function streamViaCli(
143
150
  mcpConfigPath: options?.mcpConfigPath,
144
151
  resumeSessionId,
145
152
  newSessionId: !resumeSessionId ? options?.sessionId : undefined,
153
+ systemPromptMode,
146
154
  });
147
155
  const getStderr = captureStderr(proc);
148
156
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Which system prompt the Claude CLI subprocess runs under.
3
+ *
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
6
+ * built-in tools stays in place, and pi's instructions ride on top.
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.
11
+ *
12
+ * Sizing, measured rather than assumed: in a real session the CLI's fixed
13
+ * cached prefix sat at 17,475 tokens. That is Claude Code's system prompt
14
+ * *plus* the tool schemas, and the schemas (~4.3k, per pi's own breakdown)
15
+ * stay either way — only the prompt is replaceable. Pi's prompt after the
16
+ * tool-section rewrite is ~674 tokens, so the realistic saving is roughly
17
+ * 12k tokens of context per call, not the full difference. It is a
18
+ * context-window win rather than a cost win: that prefix is cached and bills
19
+ * at 0.1x, so it was never a meaningful part of a runaway bill.
20
+ *
21
+ * The trade is real, which is why this is a choice and not a default flip:
22
+ * Claude Code's prompt carries operating guidance for its own tools, so
23
+ * dropping it means the model works from pi's instructions plus the tool
24
+ * schemas alone. `rewritePiToolSections` compensates by restating pi's tool
25
+ * documentation in Claude Code's names, but a model may still behave
26
+ * differently. Default stays `claude`.
27
+ */
28
+ export type SystemPromptMode = "claude" | "pi";
29
+
30
+ export const DEFAULT_SYSTEM_PROMPT_MODE: SystemPromptMode = "claude";
31
+
32
+ /**
33
+ * Resolve the mode from the environment.
34
+ *
35
+ * Unset or unrecognised values fall back to the default rather than throwing:
36
+ * a typo in a launcher's env should not stop a session from starting.
37
+ */
38
+ export function resolveSystemPromptMode(
39
+ env: NodeJS.ProcessEnv = process.env,
40
+ ): SystemPromptMode {
41
+ const raw = (env.PI_CLAUDE_CLI_SYSTEM_PROMPT ?? "").trim().toLowerCase();
42
+ if (raw === "pi" || raw === "minimal") return "pi";
43
+ if (raw === "claude" || raw === "append") return "claude";
44
+ return DEFAULT_SYSTEM_PROMPT_MODE;
45
+ }