@saccolabs/pi-claude-cli 0.4.13 → 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 +11 -3
- package/index.ts +9 -1
- package/package.json +1 -1
- package/src/process-manager.ts +31 -5
- package/src/provider.ts +147 -8
- package/src/session-map.ts +59 -1
- package/src/task-tracker.ts +113 -20
- package/src/types.ts +18 -0
package/README.md
CHANGED
|
@@ -51,6 +51,9 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
|
|
|
51
51
|
- Surfaces sub-agent fan-outs: one marker when a `Task` agent starts and one
|
|
52
52
|
when it reports, plus live per-agent progress on the `claude-subagents`
|
|
53
53
|
status key — so a fan-out is no longer a blank pane
|
|
54
|
+
- Background sub-agents get to finish: a `result` while agents are still
|
|
55
|
+
running ends a cycle, not the turn, so their reports reach the model
|
|
56
|
+
instead of dying with the subprocess
|
|
54
57
|
- Configurable thinking effort across the full ladder (low to max), mapped 1:1 for every model: the level the host asks for is the level the CLI gets
|
|
55
58
|
- Cross-platform subprocess management (Windows, macOS, Linux)
|
|
56
59
|
- Inactivity timeout and process registry for cleanup
|
|
@@ -123,9 +126,14 @@ misleading, `pi` mode rewrites pi's tool sections — which name pi's tools
|
|
|
123
126
|
restyles its prompt so the `Available tools:` / `Guidelines:` anchors are
|
|
124
127
|
missing, the prompt passes through untouched rather than being mangled.
|
|
125
128
|
|
|
126
|
-
|
|
127
|
-
the
|
|
128
|
-
|
|
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.
|
|
129
137
|
|
|
130
138
|
## License
|
|
131
139
|
|
package/index.ts
CHANGED
|
@@ -80,7 +80,15 @@ function publishTaskProgress(state: TaskTrackerState): void {
|
|
|
80
80
|
if (json === lastSubagentsJson) return;
|
|
81
81
|
lastSubagentsJson = json;
|
|
82
82
|
try {
|
|
83
|
-
|
|
83
|
+
// An empty snapshot means the episode is over: CLEAR the key rather than
|
|
84
|
+
// pushing `{"tasks":[],...}`. A host reads a present status as live
|
|
85
|
+
// state, so an empty one left standing is a strip that still claims
|
|
86
|
+
// agents when there are none.
|
|
87
|
+
setStatus.call(
|
|
88
|
+
uiContext!.ui,
|
|
89
|
+
SUBAGENTS_STATUS_KEY,
|
|
90
|
+
state.tasks.length === 0 ? undefined : json,
|
|
91
|
+
);
|
|
84
92
|
} catch {
|
|
85
93
|
/* never break a turn over a status push */
|
|
86
94
|
}
|
package/package.json
CHANGED
package/src/process-manager.ts
CHANGED
|
@@ -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,
|
|
@@ -58,6 +67,16 @@ export function spawnClaude(
|
|
|
58
67
|
modelId,
|
|
59
68
|
"--permission-prompt-tool",
|
|
60
69
|
"stdio",
|
|
70
|
+
// `AskUserQuestion` renders a picker in the CLI's own TUI, and `-p` has
|
|
71
|
+
// no TUI. The call therefore cannot be answered by anyone: it returns
|
|
72
|
+
// "The user did not answer the questions." after a full round trip, and
|
|
73
|
+
// the model reads that as a person who declined and falls back to asking
|
|
74
|
+
// in prose. Captured 2026-08-28 on claude-sonnet-5, which then wrote
|
|
75
|
+
// "happy to discuss in plain text instead". Take the tool away so the
|
|
76
|
+
// model asks in prose the first time — the host's UI shows prose, and
|
|
77
|
+
// the user can answer it.
|
|
78
|
+
"--disallowedTools",
|
|
79
|
+
"AskUserQuestion",
|
|
61
80
|
];
|
|
62
81
|
|
|
63
82
|
// Hermetic mode: keep the user's Claude Code environment out of pi turns.
|
|
@@ -81,9 +100,13 @@ export function spawnClaude(
|
|
|
81
100
|
if (systemPrompt) {
|
|
82
101
|
// Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
|
|
83
102
|
// Both flags accept a file path or literal text.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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,
|
|
87
110
|
);
|
|
88
111
|
writeFileSync(tmpFile, systemPrompt, "utf-8");
|
|
89
112
|
// `pi` mode replaces Claude Code's prompt outright; `claude` mode layers
|
|
@@ -122,10 +145,13 @@ export function spawnClaude(
|
|
|
122
145
|
/**
|
|
123
146
|
* Clean up the temp system prompt file created by spawnClaude.
|
|
124
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.
|
|
125
151
|
*/
|
|
126
|
-
export function cleanupSystemPromptFile(): void {
|
|
152
|
+
export function cleanupSystemPromptFile(sessionKey?: string): void {
|
|
127
153
|
try {
|
|
128
|
-
unlinkSync(
|
|
154
|
+
unlinkSync(systemPromptFilePath(sessionKey));
|
|
129
155
|
} catch {
|
|
130
156
|
// File doesn't exist or already deleted — ignore
|
|
131
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). */
|
|
@@ -62,6 +65,49 @@ const INACTIVITY_TIMEOUT_MS =
|
|
|
62
65
|
? Number(process.env.PI_CLAUDE_CLI_TIMEOUT_MS)
|
|
63
66
|
: 300_000;
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* BACKGROUND SUB-AGENTS REPORT BACK, AND THIS IS WHY THEY DIDN'T.
|
|
70
|
+
*
|
|
71
|
+
* The CLI answers a background `Agent` call in milliseconds with "Async agent
|
|
72
|
+
* launched successfully… you will be notified when it completes", and the
|
|
73
|
+
* model — correctly, per its own tool contract — ends its turn to wait. The
|
|
74
|
+
* CLI then emits `result` for that turn WHILE the agents keep running.
|
|
75
|
+
* Killing on that first result is what made every fan-out a dead end: the
|
|
76
|
+
* agents died mid-tool-call, their reports were never written, and the turn
|
|
77
|
+
* closed on "Now waiting on the three investigation agents".
|
|
78
|
+
*
|
|
79
|
+
* The loop the host was assumed to owe the CLI turned out to be the CLI's
|
|
80
|
+
* own. Captured 2026-08-28 on claude 2.1.231, holding the process open past
|
|
81
|
+
* `result`: the sub-agent finished at +25s, `task_notification` carried its
|
|
82
|
+
* report, the CLI re-invoked the model unprompted, and it answered with the
|
|
83
|
+
* findings and emitted a SECOND `result`. Nothing was written to stdin to
|
|
84
|
+
* make that happen.
|
|
85
|
+
*
|
|
86
|
+
* So the fix is to stop killing: while `pendingAgents()` is non-zero, a
|
|
87
|
+
* `result` is a cycle boundary, not the end of the episode. Two bounds keep a
|
|
88
|
+
* runaway fan-out from holding a turn open forever — the inactivity timer
|
|
89
|
+
* (which `task_progress` keeps resetting for as long as agents do real work)
|
|
90
|
+
* and the wall clock below.
|
|
91
|
+
*
|
|
92
|
+
* `applyResult` is safe to call per cycle: the CLI's `modelUsage` is
|
|
93
|
+
* cumulative for the session, verified across a two-result episode
|
|
94
|
+
* (cache-read 68,718 → 161,295), so the last one wins rather than summing.
|
|
95
|
+
*/
|
|
96
|
+
const WAIT_FOR_AGENTS = process.env.PI_CLAUDE_CLI_NO_AGENT_WAIT !== "1";
|
|
97
|
+
|
|
98
|
+
/** Hard ceiling on holding a turn open for sub-agents. */
|
|
99
|
+
const AGENT_WAIT_TIMEOUT_MS =
|
|
100
|
+
Number(process.env.PI_CLAUDE_CLI_AGENT_WAIT_MS) > 0
|
|
101
|
+
? Number(process.env.PI_CLAUDE_CLI_AGENT_WAIT_MS)
|
|
102
|
+
: 900_000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Backstop on continuation cycles. Each completing agent costs one, and an
|
|
106
|
+
* agent may launch more; this only exists so a pathological loop cannot spin
|
|
107
|
+
* without end.
|
|
108
|
+
*/
|
|
109
|
+
const MAX_AGENT_CONTINUATIONS = 32;
|
|
110
|
+
|
|
65
111
|
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
|
|
66
112
|
type StreamViaCLiOptions = SimpleStreamOptions & {
|
|
67
113
|
cwd?: string;
|
|
@@ -135,6 +181,9 @@ export function streamViaCli(
|
|
|
135
181
|
let selfInterrupted = false;
|
|
136
182
|
// Set on pi-initiated abort so the turn ends quietly, not as an error.
|
|
137
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;
|
|
138
187
|
|
|
139
188
|
try {
|
|
140
189
|
const cwd = options?.cwd ?? process.cwd();
|
|
@@ -155,6 +204,7 @@ export function streamViaCli(
|
|
|
155
204
|
// Fresh sessions get a provider-minted id, never pi's: the CLI refuses
|
|
156
205
|
// a --session-id it has already seen, and forks reuse pi ids.
|
|
157
206
|
const newCliId = resumeSessionId ? undefined : randomUUID();
|
|
207
|
+
promptFileKey = resumeSessionId ?? newCliId;
|
|
158
208
|
|
|
159
209
|
// Resume sends only the delta since the last assistant turn (new user
|
|
160
210
|
// text, handoff tool results). Create/import sends the full history.
|
|
@@ -162,14 +212,19 @@ export function streamViaCli(
|
|
|
162
212
|
? buildResumePrompt(context)
|
|
163
213
|
: buildPrompt(context);
|
|
164
214
|
// Resolved per spawn rather than once at module load so a host can flip
|
|
165
|
-
// the setting between sessions without restarting pi.
|
|
166
|
-
// session
|
|
167
|
-
//
|
|
168
|
-
// 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).
|
|
169
218
|
const systemPromptMode = resolveSystemPromptMode();
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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);
|
|
173
228
|
|
|
174
229
|
// Compute effort level from reasoning options
|
|
175
230
|
const effort = mapThinkingEffort(
|
|
@@ -191,6 +246,9 @@ export function streamViaCli(
|
|
|
191
246
|
// Record the mapping as soon as the session exists on disk. On a turn
|
|
192
247
|
// that later errors, the mapping is cleared so the next turn reimports.
|
|
193
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);
|
|
194
252
|
const getStderr = captureStderr(proc);
|
|
195
253
|
|
|
196
254
|
// Register in global process registry for teardown cleanup
|
|
@@ -240,9 +298,41 @@ export function streamViaCli(
|
|
|
240
298
|
// Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
|
|
241
299
|
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
242
300
|
|
|
301
|
+
// Sub-agent wait state (see WAIT_FOR_AGENTS): how many `result`
|
|
302
|
+
// envelopes have been treated as cycle boundaries, and the wall-clock
|
|
303
|
+
// backstop that stops waiting no matter what the agents are doing.
|
|
304
|
+
let agentContinuations = 0;
|
|
305
|
+
let agentWaitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
306
|
+
let agentWaitExpired = false;
|
|
307
|
+
let waitingForAgents = false;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* End the episode on what it already has: kill the CLI and close the
|
|
311
|
+
* reader without pushing an error.
|
|
312
|
+
*
|
|
313
|
+
* Only correct once a `result` has been seen. Before that, silence means
|
|
314
|
+
* a wedged CLI and the turn has nothing — which is what the inactivity
|
|
315
|
+
* timeout's error is for.
|
|
316
|
+
*/
|
|
317
|
+
function endTurnOnPartialWork() {
|
|
318
|
+
agentWaitExpired = true;
|
|
319
|
+
clearTimeout(inactivityTimer);
|
|
320
|
+
clearTimeout(agentWaitTimer);
|
|
321
|
+
cleanupProcess(proc!);
|
|
322
|
+
rl.close();
|
|
323
|
+
}
|
|
324
|
+
|
|
243
325
|
function resetInactivityTimer() {
|
|
244
326
|
if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
|
|
245
327
|
inactivityTimer = setTimeout(() => {
|
|
328
|
+
// Waiting on sub-agents is the one state where silence is not a
|
|
329
|
+
// failed turn: the model already spoke and the result already
|
|
330
|
+
// landed. An agent that dies without notifying must not convert
|
|
331
|
+
// that into an error and throw the content away.
|
|
332
|
+
if (waitingForAgents) {
|
|
333
|
+
endTurnOnPartialWork();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
246
336
|
forceKillProcess(proc!);
|
|
247
337
|
endStreamWithError(
|
|
248
338
|
`Claude CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
|
|
@@ -289,6 +379,7 @@ export function streamViaCli(
|
|
|
289
379
|
// Handle subprocess close -- surface crashes with stderr and exit code
|
|
290
380
|
proc.on("close", (code: number | null, _signal: string | null) => {
|
|
291
381
|
clearTimeout(inactivityTimer);
|
|
382
|
+
clearTimeout(agentWaitTimer);
|
|
292
383
|
if (broken) return; // resume-miss retry owns the stream
|
|
293
384
|
if (code !== 0 && code !== null) {
|
|
294
385
|
const stderr = getStderr();
|
|
@@ -409,12 +500,17 @@ export function streamViaCli(
|
|
|
409
500
|
// Recoverable: the sidecar pointed at a CLI session that no
|
|
410
501
|
// longer exists. Clear it; the driver reimports once.
|
|
411
502
|
if (piSessionId) clearCliSession(piSessionId);
|
|
503
|
+
clearSystemPrompt(resumeSessionId);
|
|
412
504
|
resumeMiss = true;
|
|
413
505
|
broken = true;
|
|
414
506
|
} else {
|
|
415
507
|
// A failed turn may leave the CLI session ending on a user
|
|
416
508
|
// entry; resuming that would splice filler. Reimport next turn.
|
|
417
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);
|
|
418
514
|
endStreamWithError(errMsg);
|
|
419
515
|
}
|
|
420
516
|
}
|
|
@@ -424,8 +520,39 @@ export function streamViaCli(
|
|
|
424
520
|
// handoff toolCall) is already accumulated; usage still applies.
|
|
425
521
|
bridge.applyResult(r);
|
|
426
522
|
}
|
|
523
|
+
|
|
524
|
+
// Sub-agents still working: this result ends a CYCLE, not the
|
|
525
|
+
// episode. Leave the CLI alive and keep reading — it re-invokes the
|
|
526
|
+
// model itself once they report, and emits another result. See
|
|
527
|
+
// WAIT_FOR_AGENTS for the capture this is built on.
|
|
528
|
+
if (
|
|
529
|
+
WAIT_FOR_AGENTS &&
|
|
530
|
+
!isError &&
|
|
531
|
+
!selfInterrupted &&
|
|
532
|
+
!aborted &&
|
|
533
|
+
!agentWaitExpired &&
|
|
534
|
+
agentContinuations < MAX_AGENT_CONTINUATIONS &&
|
|
535
|
+
taskTracker.pendingAgents() > 0
|
|
536
|
+
) {
|
|
537
|
+
agentContinuations++;
|
|
538
|
+
waitingForAgents = true;
|
|
539
|
+
if (agentWaitTimer === undefined) {
|
|
540
|
+
agentWaitTimer = setTimeout(() => {
|
|
541
|
+
// Give up waiting, but let the turn end on its own content:
|
|
542
|
+
// the launch markers and whatever the model already said are
|
|
543
|
+
// real, and an error here would throw them away.
|
|
544
|
+
endTurnOnPartialWork();
|
|
545
|
+
}, AGENT_WAIT_TIMEOUT_MS);
|
|
546
|
+
// A pending wait must never hold the host process open.
|
|
547
|
+
agentWaitTimer.unref?.();
|
|
548
|
+
}
|
|
549
|
+
resetInactivityTimer();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
427
553
|
// For success, handoff and error alike: clean up the subprocess
|
|
428
554
|
clearTimeout(inactivityTimer);
|
|
555
|
+
clearTimeout(agentWaitTimer);
|
|
429
556
|
cleanupProcess(proc!);
|
|
430
557
|
rl.close();
|
|
431
558
|
}
|
|
@@ -474,6 +601,9 @@ export function streamViaCli(
|
|
|
474
601
|
if (options?.signal && abortHandler) {
|
|
475
602
|
options.signal.removeEventListener("abort", abortHandler);
|
|
476
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);
|
|
477
607
|
}
|
|
478
608
|
}
|
|
479
609
|
|
|
@@ -494,7 +624,16 @@ export function streamViaCli(
|
|
|
494
624
|
} as any);
|
|
495
625
|
stream.end();
|
|
496
626
|
} finally {
|
|
497
|
-
|
|
627
|
+
// The sub-agent channel is state ABOUT a turn, so it must not outlive
|
|
628
|
+
// one. Left standing, the last snapshot pins whatever the agents were
|
|
629
|
+
// doing when the episode ended — a host then shows "running" for
|
|
630
|
+
// agents that finished, or for agents that died, until the next turn
|
|
631
|
+
// happens to publish something else.
|
|
632
|
+
try {
|
|
633
|
+
options?.onTaskProgress?.({ tasks: [], active: 0, completed: 0 });
|
|
634
|
+
} catch {
|
|
635
|
+
/* a status push must never break a turn */
|
|
636
|
+
}
|
|
498
637
|
}
|
|
499
638
|
})();
|
|
500
639
|
|
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
|
+
}
|
package/src/task-tracker.ts
CHANGED
|
@@ -30,6 +30,23 @@
|
|
|
30
30
|
* What this deliberately does NOT do: build a tree. No task envelope names its
|
|
31
31
|
* parent task, so a nested agent is indistinguishable from a top-level one
|
|
32
32
|
* here. The list is flat, and honestly so.
|
|
33
|
+
*
|
|
34
|
+
* TWO THINGS THIS CHANNEL IS NOT. Both were shipped as sub-agents once, and
|
|
35
|
+
* both put rows in pidex that named work no agent ever did:
|
|
36
|
+
*
|
|
37
|
+
* - **Not every task is an agent.** `task_started` carries `task_type`, and
|
|
38
|
+
* the CLI auto-backgrounds a slow `Bash` into a `local_bash` task with the
|
|
39
|
+
* tool's own `description`. Captured 2026-08-28 on claude 2.1.231: a
|
|
40
|
+
* sub-agent's internal `find` surfaced as `[Claude Code · Task
|
|
41
|
+
* {"status":"started","description":"Search for local source checkout of
|
|
42
|
+
* pi-claude-cli"}]` — a fourth "agent" in a three-agent fan-out. Only
|
|
43
|
+
* `local_agent` and `remote_agent` are sub-agents.
|
|
44
|
+
* - **Not every notification belongs to this episode.** `task_notification`
|
|
45
|
+
* carries no `description` and no `task_type` — only `task_id`. A task
|
|
46
|
+
* started in an EARLIER turn notifies in a later one, and a tracker that
|
|
47
|
+
* invents a placeholder from the id emits `{"status":"stopped",
|
|
48
|
+
* "description":"a8de7d982d824b56a"}`. An id this tracker never saw start
|
|
49
|
+
* is not this episode's business, so it is dropped.
|
|
33
50
|
*/
|
|
34
51
|
|
|
35
52
|
import type {
|
|
@@ -38,8 +55,48 @@ import type {
|
|
|
38
55
|
TaskTrackerState,
|
|
39
56
|
} from "./types.js";
|
|
40
57
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Marker argument previews are truncated to keep one row on one line.
|
|
60
|
+
*
|
|
61
|
+
* Raised from 120 when `task_id` joined the payload: at 120 the id pushed
|
|
62
|
+
* `subagent_type` off the end, so a fan-out stopped saying WHICH kind of
|
|
63
|
+
* agent it launched — the one fact the row exists to carry.
|
|
64
|
+
*/
|
|
65
|
+
export const ARGS_PREVIEW_LIMIT = 200;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-field cap on the description, applied BEFORE the whole-payload cap.
|
|
69
|
+
*
|
|
70
|
+
* Ordering fields human-first is not enough on its own: one long description
|
|
71
|
+
* eats the entire preview and takes `subagent_type` and `task_id` with it,
|
|
72
|
+
* which is how a 300-character task name could cost a host the identity it
|
|
73
|
+
* needs to collapse the row. Clip the one unbounded field instead, so the
|
|
74
|
+
* small structural ones always survive.
|
|
75
|
+
*/
|
|
76
|
+
export const DESCRIPTION_PREVIEW_LIMIT = 120;
|
|
77
|
+
|
|
78
|
+
function clipDescription(value: string): string {
|
|
79
|
+
return value.length > DESCRIPTION_PREVIEW_LIMIT
|
|
80
|
+
? `${value.slice(0, DESCRIPTION_PREVIEW_LIMIT)}…`
|
|
81
|
+
: value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `task_type` values that mean "a sub-agent". Everything else the CLI tracks
|
|
86
|
+
* as a task — `local_bash`, `local_shell`, `local_workflow`, `main_session` —
|
|
87
|
+
* is plumbing this channel must not report as an agent.
|
|
88
|
+
*
|
|
89
|
+
* An event with NO `task_type` is treated as an agent: older CLIs omit the
|
|
90
|
+
* field, and going silent on them would be a worse regression than the stray
|
|
91
|
+
* row this filter exists to remove.
|
|
92
|
+
*/
|
|
93
|
+
const AGENT_TASK_TYPES = new Set(["local_agent", "remote_agent"]);
|
|
94
|
+
|
|
95
|
+
export function isAgentTaskType(taskType: unknown): boolean {
|
|
96
|
+
return taskType === undefined || taskType === null
|
|
97
|
+
? true
|
|
98
|
+
: typeof taskType === "string" && AGENT_TASK_TYPES.has(taskType);
|
|
99
|
+
}
|
|
43
100
|
|
|
44
101
|
/**
|
|
45
102
|
* Build a `[Claude Code · Task …]` marker.
|
|
@@ -73,24 +130,34 @@ export interface TaskTracker {
|
|
|
73
130
|
apply(event: ClaudeTaskEvent): string | undefined;
|
|
74
131
|
/** Current state of every sub-agent seen this episode. */
|
|
75
132
|
snapshot(): TaskTrackerState;
|
|
133
|
+
/**
|
|
134
|
+
* Sub-agents that started this episode and have not reported yet.
|
|
135
|
+
*
|
|
136
|
+
* The provider waits on this before it tears the CLI down: the CLI emits
|
|
137
|
+
* its turn `result` the moment the model stops talking, which for a
|
|
138
|
+
* background fan-out is long before the agents finish.
|
|
139
|
+
*/
|
|
140
|
+
pendingAgents(): number;
|
|
76
141
|
}
|
|
77
142
|
|
|
78
143
|
export function createTaskTracker(): TaskTracker {
|
|
79
144
|
/** Insertion-ordered, so the snapshot reads in launch order. */
|
|
80
145
|
const tasks = new Map<string, TaskSnapshot>();
|
|
81
146
|
|
|
82
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Patch a task this tracker already knows. Returns undefined for an id it
|
|
149
|
+
* never saw start — a task from an earlier episode, or one filtered out as
|
|
150
|
+
* not-an-agent. Inventing a placeholder here is what named rows after raw
|
|
151
|
+
* task ids; see the header.
|
|
152
|
+
*/
|
|
153
|
+
function patch(
|
|
154
|
+
id: string,
|
|
155
|
+
changes: Partial<TaskSnapshot>,
|
|
156
|
+
): TaskSnapshot | undefined {
|
|
83
157
|
const existing = tasks.get(id);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// reorders; the id is a truthful placeholder until the start names it.
|
|
88
|
-
description: id,
|
|
89
|
-
status: "running",
|
|
90
|
-
};
|
|
91
|
-
Object.assign(next, patch);
|
|
92
|
-
tasks.set(id, next);
|
|
93
|
-
return next;
|
|
158
|
+
if (!existing) return undefined;
|
|
159
|
+
Object.assign(existing, changes);
|
|
160
|
+
return existing;
|
|
94
161
|
}
|
|
95
162
|
|
|
96
163
|
return {
|
|
@@ -100,20 +167,32 @@ export function createTaskTracker(): TaskTracker {
|
|
|
100
167
|
|
|
101
168
|
switch (event.subtype) {
|
|
102
169
|
case "task_started": {
|
|
103
|
-
|
|
170
|
+
// Plumbing, not a sub-agent: an auto-backgrounded Bash arrives here
|
|
171
|
+
// wearing the tool's own description. See the header.
|
|
172
|
+
if (!isAgentTaskType(event.task_type)) return undefined;
|
|
173
|
+
const task: TaskSnapshot = {
|
|
174
|
+
taskId: id,
|
|
104
175
|
description: event.description ?? id,
|
|
105
176
|
subagentType: event.subagent_type,
|
|
177
|
+
taskType: event.task_type,
|
|
178
|
+
toolUseId: event.tool_use_id,
|
|
106
179
|
status: "running",
|
|
107
|
-
}
|
|
180
|
+
};
|
|
181
|
+
tasks.set(id, task);
|
|
182
|
+
// `skip_transcript` suppresses the ROW, never the tracking: a
|
|
183
|
+
// hidden sub-agent still holds the turn open, and the provider
|
|
184
|
+
// reads `pendingAgents()` to decide when the turn may end.
|
|
185
|
+
if (event.skip_transcript === true) return undefined;
|
|
108
186
|
return taskMarker({
|
|
109
187
|
status: "started",
|
|
110
|
-
description: task.description,
|
|
188
|
+
description: clipDescription(task.description),
|
|
111
189
|
subagent_type: task.subagentType,
|
|
190
|
+
task_id: task.taskId,
|
|
112
191
|
});
|
|
113
192
|
}
|
|
114
193
|
|
|
115
194
|
case "task_progress": {
|
|
116
|
-
|
|
195
|
+
patch(id, {
|
|
117
196
|
// `description` on a progress event is the CURRENT step ("Running
|
|
118
197
|
// …"), not the task's own description. Keep them apart: the task
|
|
119
198
|
// name was set at start and must not be overwritten by a step.
|
|
@@ -129,27 +208,33 @@ export function createTaskTracker(): TaskTracker {
|
|
|
129
208
|
|
|
130
209
|
case "task_updated": {
|
|
131
210
|
const status = event.patch?.status;
|
|
132
|
-
|
|
211
|
+
if (status) patch(id, { status });
|
|
133
212
|
return undefined;
|
|
134
213
|
}
|
|
135
214
|
|
|
136
215
|
case "task_notification": {
|
|
137
|
-
const task =
|
|
216
|
+
const task = patch(id, {
|
|
138
217
|
status: event.status ?? "completed",
|
|
139
218
|
outputFile: event.output_file,
|
|
219
|
+
summary: event.summary,
|
|
140
220
|
toolUses: event.usage?.tool_uses ?? tasks.get(id)?.toolUses,
|
|
141
221
|
totalTokens:
|
|
142
222
|
event.usage?.total_tokens ?? tasks.get(id)?.totalTokens,
|
|
143
223
|
durationMs: event.usage?.duration_ms ?? tasks.get(id)?.durationMs,
|
|
144
224
|
currentStep: undefined,
|
|
145
225
|
});
|
|
226
|
+
// Not ours: a task from an earlier episode, or one filtered out at
|
|
227
|
+
// start. A notification carries no description, so the only row
|
|
228
|
+
// this could produce would be named after a raw task id.
|
|
229
|
+
if (!task) return undefined;
|
|
146
230
|
// The sub-agent's full report reaches the model as the Task tool's
|
|
147
231
|
// own result. Repeating it here would duplicate kilobytes into the
|
|
148
232
|
// transcript, so the marker carries the shape of the work, not its
|
|
149
233
|
// output.
|
|
150
234
|
return taskMarker({
|
|
151
235
|
status: task.status,
|
|
152
|
-
description: task.description,
|
|
236
|
+
description: clipDescription(task.description),
|
|
237
|
+
task_id: task.taskId,
|
|
153
238
|
tool_uses: task.toolUses,
|
|
154
239
|
total_tokens: task.totalTokens,
|
|
155
240
|
duration_ms: task.durationMs,
|
|
@@ -169,6 +254,14 @@ export function createTaskTracker(): TaskTracker {
|
|
|
169
254
|
completed: list.filter((t) => t.status !== "running").length,
|
|
170
255
|
};
|
|
171
256
|
},
|
|
257
|
+
|
|
258
|
+
pendingAgents(): number {
|
|
259
|
+
let pending = 0;
|
|
260
|
+
for (const task of tasks.values()) {
|
|
261
|
+
if (task.status === "running") pending++;
|
|
262
|
+
}
|
|
263
|
+
return pending;
|
|
264
|
+
},
|
|
172
265
|
};
|
|
173
266
|
}
|
|
174
267
|
|
package/src/types.ts
CHANGED
|
@@ -139,6 +139,14 @@ export interface ClaudeTaskEvent {
|
|
|
139
139
|
/** Partial state change on `task_updated`. */
|
|
140
140
|
patch?: { status?: string; end_time?: number };
|
|
141
141
|
last_tool_name?: string;
|
|
142
|
+
/**
|
|
143
|
+
* The CLI's own "do not put this in a transcript" hint. Set on tasks it
|
|
144
|
+
* considers plumbing; honoured for markers, not for tracking, because a
|
|
145
|
+
* hidden sub-agent still holds the turn open.
|
|
146
|
+
*/
|
|
147
|
+
skip_transcript?: boolean;
|
|
148
|
+
/** The sub-agent's report, on `task_notification`. Live state, not content. */
|
|
149
|
+
summary?: string;
|
|
142
150
|
usage?: {
|
|
143
151
|
total_tokens?: number;
|
|
144
152
|
tool_uses?: number;
|
|
@@ -152,6 +160,14 @@ export interface TaskSnapshot {
|
|
|
152
160
|
/** Names the task. Set at `task_started`, never overwritten by a step. */
|
|
153
161
|
description: string;
|
|
154
162
|
subagentType?: string;
|
|
163
|
+
/**
|
|
164
|
+
* The CLI's task kind — `local_agent` / `remote_agent` for a sub-agent.
|
|
165
|
+
* Undefined on a CLI too old to send it, which is read as "agent" so the
|
|
166
|
+
* tracker keeps working rather than going silent.
|
|
167
|
+
*/
|
|
168
|
+
taskType?: string;
|
|
169
|
+
/** The `Agent` tool call that launched this task; the join key a host needs. */
|
|
170
|
+
toolUseId?: string;
|
|
155
171
|
status: string;
|
|
156
172
|
/** The step running right now, cleared when the task ends. */
|
|
157
173
|
currentStep?: string;
|
|
@@ -160,6 +176,8 @@ export interface TaskSnapshot {
|
|
|
160
176
|
totalTokens?: number;
|
|
161
177
|
durationMs?: number;
|
|
162
178
|
outputFile?: string;
|
|
179
|
+
/** The sub-agent's own report, once it finishes. */
|
|
180
|
+
summary?: string;
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
/** Every sub-agent seen this episode, in launch order. */
|