@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 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
- Only the session-creating turn sends a system prompt the CLI keeps it for
127
- the life of the session so a change takes effect on the next new session,
128
- 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.
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
- setStatus.call(uiContext!.ui, SUBAGENTS_STATUS_KEY, json);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.13",
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,
@@ -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
- const tmpFile = join(
85
- tmpdir(),
86
- `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,
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(join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}.txt`));
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. Only the
166
- // session-creating turn carries a system prompt the CLI keeps it for
167
- // the life of the session so switching mid-session takes effect on
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
- const systemPrompt = resumeSessionId
171
- ? undefined
172
- : 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);
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
- cleanupSystemPromptFile();
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
 
@@ -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
+ }
@@ -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
- /** Marker argument previews are truncated to keep one row on one line. */
42
- const ARGS_PREVIEW_LIMIT = 120;
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
- function upsert(id: string, patch: Partial<TaskSnapshot>): TaskSnapshot {
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
- const next: TaskSnapshot = existing ?? {
85
- taskId: id,
86
- // A progress event can arrive before the start it belongs to if the CLI
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
- const task = upsert(id, {
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
- upsert(id, {
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
- upsert(id, status ? { status } : {});
211
+ if (status) patch(id, { status });
133
212
  return undefined;
134
213
  }
135
214
 
136
215
  case "task_notification": {
137
- const task = upsert(id, {
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. */