@saccolabs/pi-claude-cli 0.4.12 → 0.4.14

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
@@ -48,6 +48,12 @@ Requires the `claude` binary on your login-shell PATH (`npm install -g @anthropi
48
48
  - Native tool execution: the CLI runs its own tools; guards are injected as Claude Code PreToolUse hooks via `PI_CLAUDE_CLI_SETTINGS`
49
49
  - Reports account rate-limit state (window, reset, overage) to the front-end
50
50
  on the `claude-rate-limit` status key — never mixed into turn content
51
+ - Surfaces sub-agent fan-outs: one marker when a `Task` agent starts and one
52
+ when it reports, plus live per-agent progress on the `claude-subagents`
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
51
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
52
58
  - Cross-platform subprocess management (Windows, macOS, Linux)
53
59
  - Inactivity timeout and process registry for cleanup
package/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
18
18
  import { rewriteOverflowMessage } from "./src/overflow.js";
19
19
  import { buildRateLimitPayload, rateLimitIdentity } from "./src/rate-limit.js";
20
+ import type { TaskTrackerState } from "./src/types.js";
20
21
 
21
22
  // Kill all active Claude subprocesses on process exit to prevent orphans
22
23
  process.on("exit", killAllProcesses);
@@ -58,6 +59,41 @@ function publishRateLimit(info: Record<string, unknown>): void {
58
59
  }
59
60
  }
60
61
 
62
+ /**
63
+ * Live sub-agent state, on its own status key.
64
+ *
65
+ * Same reasoning as the rate-limit channel: this is state ABOUT the turn, not
66
+ * content OF it. `task_progress` fires once per sub-agent tool call (roughly
67
+ * 700 times in the incident that motivated #23), so folding it into the
68
+ * transcript would bury the turn and cost context on every later replay. The
69
+ * durable half — one marker when a sub-agent starts, one when it finishes —
70
+ * goes in the turn instead, and needs no host change to render.
71
+ */
72
+ const SUBAGENTS_STATUS_KEY = "claude-subagents";
73
+ /** Last payload pushed, so an unchanged snapshot does not rewrite the status. */
74
+ let lastSubagentsJson: string | undefined;
75
+
76
+ function publishTaskProgress(state: TaskTrackerState): void {
77
+ const setStatus = uiContext?.ui?.setStatus;
78
+ if (typeof setStatus !== "function") return;
79
+ const json = JSON.stringify(state);
80
+ if (json === lastSubagentsJson) return;
81
+ lastSubagentsJson = json;
82
+ try {
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
+ );
92
+ } catch {
93
+ /* never break a turn over a status push */
94
+ }
95
+ }
96
+
61
97
  let mcpConfigPath: string | undefined;
62
98
  let mcpConfigResolved = false;
63
99
 
@@ -130,6 +166,7 @@ export default function (pi: ExtensionAPI) {
130
166
  pi.on("session_start", async (_event: unknown, ctx: unknown) => {
131
167
  uiContext = ctx as typeof uiContext;
132
168
  lastRateLimitJson = undefined;
169
+ lastSubagentsJson = undefined;
133
170
  const allTools = pi.getAllTools();
134
171
  if (Array.isArray(allTools)) {
135
172
  pi.setActiveTools(allTools.map((t: any) => t.name));
@@ -146,6 +183,7 @@ export default function (pi: ExtensionAPI) {
146
183
  ...options,
147
184
  mcpConfigPath: configPath,
148
185
  onRateLimit: publishRateLimit,
186
+ onTaskProgress: publishTaskProgress,
149
187
  });
150
188
  };
151
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saccolabs/pi-claude-cli",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Pi coding agent extension that routes LLM calls through the Claude Code CLI",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -56,6 +56,13 @@ export interface EventBridge {
56
56
  * otherwise be invisible — each becomes a one-line marker text block.
57
57
  */
58
58
  handleAssistantEnvelope(envelope: ClaudeAssistantEnvelope): void;
59
+ /**
60
+ * Append a pre-built marker text block. Used for sub-agent lifecycle, whose
61
+ * events arrive as top-level `system` envelopes rather than content blocks
62
+ * (`src/task-tracker.ts`). The bridge stays the only writer of
63
+ * `output.content`.
64
+ */
65
+ appendMarker(text: string): void;
59
66
  /**
60
67
  * The final `result` envelope: authoritative cumulative usage for the
61
68
  * whole episode, plus a safety net that appends the final answer text if
@@ -649,6 +656,7 @@ export function createEventBridge(
649
656
  return {
650
657
  handleEvent,
651
658
  handleAssistantEnvelope,
659
+ appendMarker: appendTextBlock,
652
660
  applyResult,
653
661
  getOutput: () => output,
654
662
  };
@@ -58,6 +58,16 @@ export function spawnClaude(
58
58
  modelId,
59
59
  "--permission-prompt-tool",
60
60
  "stdio",
61
+ // `AskUserQuestion` renders a picker in the CLI's own TUI, and `-p` has
62
+ // no TUI. The call therefore cannot be answered by anyone: it returns
63
+ // "The user did not answer the questions." after a full round trip, and
64
+ // the model reads that as a person who declined and falls back to asking
65
+ // in prose. Captured 2026-08-28 on claude-sonnet-5, which then wrote
66
+ // "happy to discuss in plain text instead". Take the tool away so the
67
+ // model asks in prose the first time — the host's UI shows prose, and
68
+ // the user can answer it.
69
+ "--disallowedTools",
70
+ "AskUserQuestion",
61
71
  ];
62
72
 
63
73
  // Hermetic mode: keep the user's Claude Code environment out of pi turns.
package/src/provider.ts CHANGED
@@ -40,6 +40,8 @@ import {
40
40
  } from "./process-manager.js";
41
41
  import { parseLine } from "./stream-parser.js";
42
42
  import { createEventBridge } from "./event-bridge.js";
43
+ import { createTaskTracker, isTaskSubtype } from "./task-tracker.js";
44
+ import type { TaskTrackerState } from "./types.js";
43
45
  import { handleControlRequest } from "./control-handler.js";
44
46
  import { mapThinkingEffort } from "./thinking-config.js";
45
47
  import { isHandoffClaudeTool } from "./tool-mapping.js";
@@ -60,12 +62,61 @@ const INACTIVITY_TIMEOUT_MS =
60
62
  ? Number(process.env.PI_CLAUDE_CLI_TIMEOUT_MS)
61
63
  : 300_000;
62
64
 
65
+ /**
66
+ * BACKGROUND SUB-AGENTS REPORT BACK, AND THIS IS WHY THEY DIDN'T.
67
+ *
68
+ * The CLI answers a background `Agent` call in milliseconds with "Async agent
69
+ * launched successfully… you will be notified when it completes", and the
70
+ * model — correctly, per its own tool contract — ends its turn to wait. The
71
+ * CLI then emits `result` for that turn WHILE the agents keep running.
72
+ * Killing on that first result is what made every fan-out a dead end: the
73
+ * agents died mid-tool-call, their reports were never written, and the turn
74
+ * closed on "Now waiting on the three investigation agents".
75
+ *
76
+ * The loop the host was assumed to owe the CLI turned out to be the CLI's
77
+ * own. Captured 2026-08-28 on claude 2.1.231, holding the process open past
78
+ * `result`: the sub-agent finished at +25s, `task_notification` carried its
79
+ * report, the CLI re-invoked the model unprompted, and it answered with the
80
+ * findings and emitted a SECOND `result`. Nothing was written to stdin to
81
+ * make that happen.
82
+ *
83
+ * So the fix is to stop killing: while `pendingAgents()` is non-zero, a
84
+ * `result` is a cycle boundary, not the end of the episode. Two bounds keep a
85
+ * runaway fan-out from holding a turn open forever — the inactivity timer
86
+ * (which `task_progress` keeps resetting for as long as agents do real work)
87
+ * and the wall clock below.
88
+ *
89
+ * `applyResult` is safe to call per cycle: the CLI's `modelUsage` is
90
+ * cumulative for the session, verified across a two-result episode
91
+ * (cache-read 68,718 → 161,295), so the last one wins rather than summing.
92
+ */
93
+ const WAIT_FOR_AGENTS = process.env.PI_CLAUDE_CLI_NO_AGENT_WAIT !== "1";
94
+
95
+ /** Hard ceiling on holding a turn open for sub-agents. */
96
+ const AGENT_WAIT_TIMEOUT_MS =
97
+ Number(process.env.PI_CLAUDE_CLI_AGENT_WAIT_MS) > 0
98
+ ? Number(process.env.PI_CLAUDE_CLI_AGENT_WAIT_MS)
99
+ : 900_000;
100
+
101
+ /**
102
+ * Backstop on continuation cycles. Each completing agent costs one, and an
103
+ * agent may launch more; this only exists so a pathological loop cannot spin
104
+ * without end.
105
+ */
106
+ const MAX_AGENT_CONTINUATIONS = 32;
107
+
63
108
  /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
64
109
  type StreamViaCLiOptions = SimpleStreamOptions & {
65
110
  cwd?: string;
66
111
  mcpConfigPath?: string;
67
112
  /** Called with account rate-limit state as the CLI reports it. */
68
113
  onRateLimit?: (info: Record<string, unknown>) => void;
114
+ /**
115
+ * Called with live sub-agent state as the CLI reports it. Ephemeral: this is
116
+ * progress, not transcript, and must never be folded into turn content. The
117
+ * durable half (start/finish) rides in the turn as markers instead.
118
+ */
119
+ onTaskProgress?: (state: TaskTrackerState) => void;
69
120
  };
70
121
 
71
122
  /**
@@ -193,6 +244,9 @@ export function streamViaCli(
193
244
 
194
245
  // Create event bridge (before endStreamWithError so bridge is in scope)
195
246
  const bridge = createEventBridge(stream, model);
247
+ // Per-attempt: a resume-miss retry replays the episode, and its
248
+ // sub-agents must not be counted twice.
249
+ const taskTracker = createTaskTracker();
196
250
 
197
251
  // Guard against double stream.end() and double error events.
198
252
  // First error path wins; subsequent ones are no-ops.
@@ -229,9 +283,41 @@ export function streamViaCli(
229
283
  // Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
230
284
  let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
231
285
 
286
+ // Sub-agent wait state (see WAIT_FOR_AGENTS): how many `result`
287
+ // envelopes have been treated as cycle boundaries, and the wall-clock
288
+ // backstop that stops waiting no matter what the agents are doing.
289
+ let agentContinuations = 0;
290
+ let agentWaitTimer: ReturnType<typeof setTimeout> | undefined;
291
+ let agentWaitExpired = false;
292
+ let waitingForAgents = false;
293
+
294
+ /**
295
+ * End the episode on what it already has: kill the CLI and close the
296
+ * reader without pushing an error.
297
+ *
298
+ * Only correct once a `result` has been seen. Before that, silence means
299
+ * a wedged CLI and the turn has nothing — which is what the inactivity
300
+ * timeout's error is for.
301
+ */
302
+ function endTurnOnPartialWork() {
303
+ agentWaitExpired = true;
304
+ clearTimeout(inactivityTimer);
305
+ clearTimeout(agentWaitTimer);
306
+ cleanupProcess(proc!);
307
+ rl.close();
308
+ }
309
+
232
310
  function resetInactivityTimer() {
233
311
  if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
234
312
  inactivityTimer = setTimeout(() => {
313
+ // Waiting on sub-agents is the one state where silence is not a
314
+ // failed turn: the model already spoke and the result already
315
+ // landed. An agent that dies without notifying must not convert
316
+ // that into an error and throw the content away.
317
+ if (waitingForAgents) {
318
+ endTurnOnPartialWork();
319
+ return;
320
+ }
235
321
  forceKillProcess(proc!);
236
322
  endStreamWithError(
237
323
  `Claude CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
@@ -278,6 +364,7 @@ export function streamViaCli(
278
364
  // Handle subprocess close -- surface crashes with stderr and exit code
279
365
  proc.on("close", (code: number | null, _signal: string | null) => {
280
366
  clearTimeout(inactivityTimer);
367
+ clearTimeout(agentWaitTimer);
281
368
  if (broken) return; // resume-miss retry owns the stream
282
369
  if (code !== 0 && code !== null) {
283
370
  const stderr = getStderr();
@@ -352,6 +439,23 @@ export function streamViaCli(
352
439
  /* a status push must never break a turn */
353
440
  }
354
441
  }
442
+ } else if (
443
+ msg.type === "system" &&
444
+ isTaskSubtype((msg as any).subtype)
445
+ ) {
446
+ // Sub-agent lifecycle. The agents' own envelopes carry
447
+ // parent_tool_use_id and stay internal to the CLI, but these arrive
448
+ // at top level even for deeply nested agents — the one channel that
449
+ // makes a fan-out visible at all (#23).
450
+ if (!selfInterrupted) {
451
+ const marker = taskTracker.apply(msg as any);
452
+ if (marker) bridge.appendMarker(marker);
453
+ try {
454
+ options?.onTaskProgress?.(taskTracker.snapshot());
455
+ } catch {
456
+ /* a status push must never break a turn */
457
+ }
458
+ }
355
459
  } else if (msg.type === "assistant") {
356
460
  // Complete-block envelopes: marker text for the CLI's own tool
357
461
  // executions (built-ins, WebSearch, user MCP, …), which in observer
@@ -396,8 +500,39 @@ export function streamViaCli(
396
500
  // handoff toolCall) is already accumulated; usage still applies.
397
501
  bridge.applyResult(r);
398
502
  }
503
+
504
+ // Sub-agents still working: this result ends a CYCLE, not the
505
+ // episode. Leave the CLI alive and keep reading — it re-invokes the
506
+ // model itself once they report, and emits another result. See
507
+ // WAIT_FOR_AGENTS for the capture this is built on.
508
+ if (
509
+ WAIT_FOR_AGENTS &&
510
+ !isError &&
511
+ !selfInterrupted &&
512
+ !aborted &&
513
+ !agentWaitExpired &&
514
+ agentContinuations < MAX_AGENT_CONTINUATIONS &&
515
+ taskTracker.pendingAgents() > 0
516
+ ) {
517
+ agentContinuations++;
518
+ waitingForAgents = true;
519
+ if (agentWaitTimer === undefined) {
520
+ agentWaitTimer = setTimeout(() => {
521
+ // Give up waiting, but let the turn end on its own content:
522
+ // the launch markers and whatever the model already said are
523
+ // real, and an error here would throw them away.
524
+ endTurnOnPartialWork();
525
+ }, AGENT_WAIT_TIMEOUT_MS);
526
+ // A pending wait must never hold the host process open.
527
+ agentWaitTimer.unref?.();
528
+ }
529
+ resetInactivityTimer();
530
+ return;
531
+ }
532
+
399
533
  // For success, handoff and error alike: clean up the subprocess
400
534
  clearTimeout(inactivityTimer);
535
+ clearTimeout(agentWaitTimer);
401
536
  cleanupProcess(proc!);
402
537
  rl.close();
403
538
  }
@@ -467,6 +602,16 @@ export function streamViaCli(
467
602
  stream.end();
468
603
  } finally {
469
604
  cleanupSystemPromptFile();
605
+ // The sub-agent channel is state ABOUT a turn, so it must not outlive
606
+ // one. Left standing, the last snapshot pins whatever the agents were
607
+ // doing when the episode ended — a host then shows "running" for
608
+ // agents that finished, or for agents that died, until the next turn
609
+ // happens to publish something else.
610
+ try {
611
+ options?.onTaskProgress?.({ tasks: [], active: 0, completed: 0 });
612
+ } catch {
613
+ /* a status push must never break a turn */
614
+ }
470
615
  }
471
616
  })();
472
617
 
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Sub-agent (Task) visibility.
3
+ *
4
+ * The CLI runs sub-agents inside its own process. Their stream envelopes carry
5
+ * `parent_tool_use_id` and are deliberately not forwarded — they are the CLI's
6
+ * internal loop, and forwarding hundreds of nested tool calls would bury the
7
+ * turn. But dropping them left a host with nothing at all: a single
8
+ * `[Claude Code · Task {…}]` marker and then silence for as long as the
9
+ * fan-out ran. One pidex turn sat blank for eight minutes behind 14 nested
10
+ * agents and was killed as hung (issue #23).
11
+ *
12
+ * The CLI already publishes a purpose-built feed for exactly this, and the
13
+ * provider was not reading it. `system` envelopes with a `task_*` subtype
14
+ * arrive at TOP level (`parent_tool_use_id` is null) even for agents nested
15
+ * several deep, verified on claude 2.1.231 at spawn depth 2. They are low
16
+ * volume and carry description, sub-agent type, tool count, tokens and
17
+ * duration.
18
+ *
19
+ * Two channels, split by durability:
20
+ *
21
+ * - **Lifecycle goes in the turn** as marker text. `task_started` and the
22
+ * terminal `task_notification` are durable facts about what the turn did,
23
+ * and belong in the transcript beside the CLI's other tool markers. Two
24
+ * lines per sub-agent, so a 14-agent fan-out costs 28.
25
+ * - **Progress goes out of band**, like `rate_limit_event` before it.
26
+ * `task_progress` fires once per sub-agent tool call — ~700 times in the
27
+ * incident above — which is live state, not transcript. It must never be
28
+ * folded into turn content.
29
+ *
30
+ * What this deliberately does NOT do: build a tree. No task envelope names its
31
+ * parent task, so a nested agent is indistinguishable from a top-level one
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.
50
+ */
51
+
52
+ import type {
53
+ ClaudeTaskEvent,
54
+ TaskSnapshot,
55
+ TaskTrackerState,
56
+ } from "./types.js";
57
+
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
+ }
100
+
101
+ /**
102
+ * Build a `[Claude Code · Task …]` marker.
103
+ *
104
+ * WIRE CONTRACT — the same shape `event-bridge.ts` emits for CLI-side tool
105
+ * executions, and front-ends parse it with
106
+ * `/^\[Claude Code · ([^\s\]]+)(?:\s+([\s\S]*))?\]$/`. The tool name must stay
107
+ * space-free; everything else rides in the argument JSON, which is truncated
108
+ * here and must never be parsed as JSON by a consumer.
109
+ */
110
+ function taskMarker(args: Record<string, unknown>): string {
111
+ let preview = "";
112
+ try {
113
+ const json = JSON.stringify(args);
114
+ preview =
115
+ json === "{}"
116
+ ? ""
117
+ : ` ${json.slice(0, ARGS_PREVIEW_LIMIT)}${json.length > ARGS_PREVIEW_LIMIT ? "…" : ""}`;
118
+ } catch {
119
+ /* unserializable — the marker still names the tool */
120
+ }
121
+ return `[Claude Code · Task${preview}]`;
122
+ }
123
+
124
+ export interface TaskTracker {
125
+ /**
126
+ * Fold one `task_*` envelope in. Returns a marker string when the event is a
127
+ * durable lifecycle transition (start, finish) and nothing when it is live
128
+ * progress. Unknown subtypes and events without a `task_id` are ignored.
129
+ */
130
+ apply(event: ClaudeTaskEvent): string | undefined;
131
+ /** Current state of every sub-agent seen this episode. */
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;
141
+ }
142
+
143
+ export function createTaskTracker(): TaskTracker {
144
+ /** Insertion-ordered, so the snapshot reads in launch order. */
145
+ const tasks = new Map<string, TaskSnapshot>();
146
+
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 {
157
+ const existing = tasks.get(id);
158
+ if (!existing) return undefined;
159
+ Object.assign(existing, changes);
160
+ return existing;
161
+ }
162
+
163
+ return {
164
+ apply(event: ClaudeTaskEvent): string | undefined {
165
+ const id = event.task_id;
166
+ if (!id) return undefined;
167
+
168
+ switch (event.subtype) {
169
+ case "task_started": {
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,
175
+ description: event.description ?? id,
176
+ subagentType: event.subagent_type,
177
+ taskType: event.task_type,
178
+ toolUseId: event.tool_use_id,
179
+ status: "running",
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;
186
+ return taskMarker({
187
+ status: "started",
188
+ description: clipDescription(task.description),
189
+ subagent_type: task.subagentType,
190
+ task_id: task.taskId,
191
+ });
192
+ }
193
+
194
+ case "task_progress": {
195
+ patch(id, {
196
+ // `description` on a progress event is the CURRENT step ("Running
197
+ // …"), not the task's own description. Keep them apart: the task
198
+ // name was set at start and must not be overwritten by a step.
199
+ currentStep: event.description,
200
+ subagentType: event.subagent_type ?? tasks.get(id)?.subagentType,
201
+ lastToolName: event.last_tool_name,
202
+ toolUses: event.usage?.tool_uses,
203
+ totalTokens: event.usage?.total_tokens,
204
+ durationMs: event.usage?.duration_ms,
205
+ });
206
+ return undefined;
207
+ }
208
+
209
+ case "task_updated": {
210
+ const status = event.patch?.status;
211
+ if (status) patch(id, { status });
212
+ return undefined;
213
+ }
214
+
215
+ case "task_notification": {
216
+ const task = patch(id, {
217
+ status: event.status ?? "completed",
218
+ outputFile: event.output_file,
219
+ summary: event.summary,
220
+ toolUses: event.usage?.tool_uses ?? tasks.get(id)?.toolUses,
221
+ totalTokens:
222
+ event.usage?.total_tokens ?? tasks.get(id)?.totalTokens,
223
+ durationMs: event.usage?.duration_ms ?? tasks.get(id)?.durationMs,
224
+ currentStep: undefined,
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;
230
+ // The sub-agent's full report reaches the model as the Task tool's
231
+ // own result. Repeating it here would duplicate kilobytes into the
232
+ // transcript, so the marker carries the shape of the work, not its
233
+ // output.
234
+ return taskMarker({
235
+ status: task.status,
236
+ description: clipDescription(task.description),
237
+ task_id: task.taskId,
238
+ tool_uses: task.toolUses,
239
+ total_tokens: task.totalTokens,
240
+ duration_ms: task.durationMs,
241
+ });
242
+ }
243
+
244
+ default:
245
+ return undefined;
246
+ }
247
+ },
248
+
249
+ snapshot(): TaskTrackerState {
250
+ const list = [...tasks.values()].map((t) => ({ ...t }));
251
+ return {
252
+ tasks: list,
253
+ active: list.filter((t) => t.status === "running").length,
254
+ completed: list.filter((t) => t.status !== "running").length,
255
+ };
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
+ },
265
+ };
266
+ }
267
+
268
+ /** The `system` subtypes this module handles. */
269
+ const TASK_SUBTYPES = new Set([
270
+ "task_started",
271
+ "task_progress",
272
+ "task_updated",
273
+ "task_notification",
274
+ ]);
275
+
276
+ /**
277
+ * Whether a `system` envelope's subtype is sub-agent lifecycle.
278
+ *
279
+ * `system` also carries init/status/summary envelopes that have nothing to do
280
+ * with sub-agents, so the provider narrows before handing anything over.
281
+ */
282
+ export function isTaskSubtype(subtype: unknown): boolean {
283
+ return typeof subtype === "string" && TASK_SUBTYPES.has(subtype);
284
+ }
package/src/types.ts CHANGED
@@ -106,6 +106,87 @@ export interface ClaudeSystemMessage {
106
106
  tools?: unknown[];
107
107
  }
108
108
 
109
+ /**
110
+ * Sub-agent lifecycle, emitted by the CLI as `system` envelopes.
111
+ *
112
+ * These arrive at TOP level — `parent_tool_use_id` is null — even for agents
113
+ * nested several deep, verified on claude 2.1.231 at spawn depth 2. That is
114
+ * what makes them usable: the sub-agents' own `assistant` envelopes are
115
+ * tagged with `parent_tool_use_id` and stay internal to the CLI, but their
116
+ * lifecycle is published here in the open.
117
+ *
118
+ * `description` means two different things by subtype. On `task_started` it
119
+ * names the task; on `task_progress` it is the step running right now
120
+ * ("Running …"). `src/task-tracker.ts` keeps them apart.
121
+ */
122
+ export interface ClaudeTaskEvent {
123
+ type: "system";
124
+ subtype:
125
+ | "task_started"
126
+ | "task_progress"
127
+ | "task_updated"
128
+ | "task_notification"
129
+ | string;
130
+ task_id?: string;
131
+ tool_use_id?: string;
132
+ description?: string;
133
+ subagent_type?: string;
134
+ task_type?: string;
135
+ /** Terminal status on `task_notification`. */
136
+ status?: string;
137
+ /** Where the CLI wrote the sub-agent's full report. */
138
+ output_file?: string;
139
+ /** Partial state change on `task_updated`. */
140
+ patch?: { status?: string; end_time?: number };
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;
150
+ usage?: {
151
+ total_tokens?: number;
152
+ tool_uses?: number;
153
+ duration_ms?: number;
154
+ };
155
+ }
156
+
157
+ /** One sub-agent's state, as the host sees it. */
158
+ export interface TaskSnapshot {
159
+ taskId: string;
160
+ /** Names the task. Set at `task_started`, never overwritten by a step. */
161
+ description: string;
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;
171
+ status: string;
172
+ /** The step running right now, cleared when the task ends. */
173
+ currentStep?: string;
174
+ lastToolName?: string;
175
+ toolUses?: number;
176
+ totalTokens?: number;
177
+ durationMs?: number;
178
+ outputFile?: string;
179
+ /** The sub-agent's own report, once it finishes. */
180
+ summary?: string;
181
+ }
182
+
183
+ /** Every sub-agent seen this episode, in launch order. */
184
+ export interface TaskTrackerState {
185
+ tasks: TaskSnapshot[];
186
+ active: number;
187
+ completed: number;
188
+ }
189
+
109
190
  export interface ClaudeControlRequest {
110
191
  type: "control_request";
111
192
  request_id: string;
@@ -120,6 +201,7 @@ export type NdjsonMessage =
120
201
  | ClaudeStreamEventMessage
121
202
  | ClaudeResultMessage
122
203
  | ClaudeSystemMessage
204
+ | ClaudeTaskEvent
123
205
  | ClaudeControlRequest
124
206
  | ClaudeAssistantEnvelope
125
207
  | ClaudeUserEnvelope