@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 +35 -0
- package/package.json +1 -1
- package/src/process-manager.ts +17 -4
- package/src/prompt-builder.ts +72 -1
- package/src/provider.ts +9 -1
- package/src/system-prompt-mode.ts +45 -0
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
package/src/process-manager.ts
CHANGED
|
@@ -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
|
|
21
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
package/src/prompt-builder.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|