@saccolabs/pi-claude-cli 0.4.5 → 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 +92 -26
- 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,
|
|
@@ -140,15 +144,22 @@ function buildCustomToolResultPrompt(messages: any[]): string | null {
|
|
|
140
144
|
/**
|
|
141
145
|
* Build a prompt for a resumed session.
|
|
142
146
|
*
|
|
143
|
-
* When resuming via --resume, the CLI already has the full conversation history
|
|
144
|
-
*
|
|
145
|
-
*
|
|
147
|
+
* When resuming via --resume, the CLI already has the full conversation history
|
|
148
|
+
* up through the most recent assistant turn it produced. We only need to send
|
|
149
|
+
* the delta since that turn: trailing tool results for the last assistant
|
|
150
|
+
* tool_use, and/or a new user message.
|
|
146
151
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
152
|
+
* Why anchor on the last assistant message rather than the last user message?
|
|
153
|
+
* Pi's tool loop appends [user, assistant(toolUse), toolResult,
|
|
154
|
+
* assistant(toolUse), toolResult, ...] — the only `user` entry stays at index 0
|
|
155
|
+
* across many provider invocations. Anchoring there made every iteration replay
|
|
156
|
+
* the entire transcript, and when that first user message carried an image the
|
|
157
|
+
* image branch below returned early with just the image, so tool results never
|
|
158
|
+
* reached the model at all. The model then re-issued the same tool call
|
|
159
|
+
* indefinitely while each turn re-billed the whole accumulated context.
|
|
150
160
|
*
|
|
151
|
-
*
|
|
161
|
+
* Returns "" when there's nothing new to send (e.g. only an assistant message
|
|
162
|
+
* exists in the context — can happen mid-shutdown).
|
|
152
163
|
*/
|
|
153
164
|
export function buildResumePrompt(context: {
|
|
154
165
|
messages: any[];
|
|
@@ -156,28 +167,16 @@ export function buildResumePrompt(context: {
|
|
|
156
167
|
const messages = context.messages;
|
|
157
168
|
if (messages.length === 0) return "";
|
|
158
169
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Collect new messages: everything from the last assistant turn onwards
|
|
164
|
-
// (tool results from the last assistant + the new user message)
|
|
165
|
-
const newMessages: any[] = [];
|
|
166
|
-
|
|
167
|
-
// Walk backwards from finalUserIndex to find where new content starts.
|
|
168
|
-
// Include trailing toolResult messages that follow the last assistant turn.
|
|
169
|
-
let startIdx = finalUserIndex;
|
|
170
|
-
for (let i = finalUserIndex - 1; i >= 0; i--) {
|
|
171
|
-
if (messages[i].role === "toolResult") {
|
|
172
|
-
startIdx = i;
|
|
173
|
-
} else {
|
|
170
|
+
let lastAssistantIdx = -1;
|
|
171
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
172
|
+
if (messages[i].role === "assistant") {
|
|
173
|
+
lastAssistantIdx = i;
|
|
174
174
|
break;
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
178
|
+
const newMessages = messages.slice(lastAssistantIdx + 1);
|
|
179
|
+
if (newMessages.length === 0) return "";
|
|
181
180
|
|
|
182
181
|
// If there are only tool results + one user message, build a combined prompt
|
|
183
182
|
const parts: string[] = [];
|
|
@@ -348,19 +347,86 @@ function findFinalUserMessageIndex(messages: any[]): number {
|
|
|
348
347
|
return -1;
|
|
349
348
|
}
|
|
350
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
|
+
|
|
351
407
|
/**
|
|
352
408
|
* Builds the system prompt from the context's systemPrompt field,
|
|
353
409
|
* appending AGENTS.md content if found (walking up from cwd, then global fallback).
|
|
354
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.
|
|
355
416
|
*/
|
|
356
417
|
export function buildSystemPrompt(
|
|
357
418
|
context: { systemPrompt?: string; messages: any[] },
|
|
358
419
|
cwd: string,
|
|
420
|
+
mode: SystemPromptMode = DEFAULT_SYSTEM_PROMPT_MODE,
|
|
359
421
|
): string {
|
|
360
422
|
const parts: string[] = [];
|
|
361
423
|
|
|
362
424
|
if (context.systemPrompt) {
|
|
363
|
-
parts.push(
|
|
425
|
+
parts.push(
|
|
426
|
+
mode === "pi"
|
|
427
|
+
? rewritePiToolSections(context.systemPrompt)
|
|
428
|
+
: context.systemPrompt,
|
|
429
|
+
);
|
|
364
430
|
}
|
|
365
431
|
|
|
366
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
|
+
}
|