@saccolabs/pi-claude-cli 0.4.12 → 0.4.13

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,9 @@ 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
51
54
  - 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
55
  - Cross-platform subprocess management (Windows, macOS, Linux)
53
56
  - 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,33 @@ 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
+ setStatus.call(uiContext!.ui, SUBAGENTS_STATUS_KEY, json);
84
+ } catch {
85
+ /* never break a turn over a status push */
86
+ }
87
+ }
88
+
61
89
  let mcpConfigPath: string | undefined;
62
90
  let mcpConfigResolved = false;
63
91
 
@@ -130,6 +158,7 @@ export default function (pi: ExtensionAPI) {
130
158
  pi.on("session_start", async (_event: unknown, ctx: unknown) => {
131
159
  uiContext = ctx as typeof uiContext;
132
160
  lastRateLimitJson = undefined;
161
+ lastSubagentsJson = undefined;
133
162
  const allTools = pi.getAllTools();
134
163
  if (Array.isArray(allTools)) {
135
164
  pi.setActiveTools(allTools.map((t: any) => t.name));
@@ -146,6 +175,7 @@ export default function (pi: ExtensionAPI) {
146
175
  ...options,
147
176
  mcpConfigPath: configPath,
148
177
  onRateLimit: publishRateLimit,
178
+ onTaskProgress: publishTaskProgress,
149
179
  });
150
180
  };
151
181
 
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.13",
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
  };
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";
@@ -66,6 +68,12 @@ type StreamViaCLiOptions = SimpleStreamOptions & {
66
68
  mcpConfigPath?: string;
67
69
  /** Called with account rate-limit state as the CLI reports it. */
68
70
  onRateLimit?: (info: Record<string, unknown>) => void;
71
+ /**
72
+ * Called with live sub-agent state as the CLI reports it. Ephemeral: this is
73
+ * progress, not transcript, and must never be folded into turn content. The
74
+ * durable half (start/finish) rides in the turn as markers instead.
75
+ */
76
+ onTaskProgress?: (state: TaskTrackerState) => void;
69
77
  };
70
78
 
71
79
  /**
@@ -193,6 +201,9 @@ export function streamViaCli(
193
201
 
194
202
  // Create event bridge (before endStreamWithError so bridge is in scope)
195
203
  const bridge = createEventBridge(stream, model);
204
+ // Per-attempt: a resume-miss retry replays the episode, and its
205
+ // sub-agents must not be counted twice.
206
+ const taskTracker = createTaskTracker();
196
207
 
197
208
  // Guard against double stream.end() and double error events.
198
209
  // First error path wins; subsequent ones are no-ops.
@@ -352,6 +363,23 @@ export function streamViaCli(
352
363
  /* a status push must never break a turn */
353
364
  }
354
365
  }
366
+ } else if (
367
+ msg.type === "system" &&
368
+ isTaskSubtype((msg as any).subtype)
369
+ ) {
370
+ // Sub-agent lifecycle. The agents' own envelopes carry
371
+ // parent_tool_use_id and stay internal to the CLI, but these arrive
372
+ // at top level even for deeply nested agents — the one channel that
373
+ // makes a fan-out visible at all (#23).
374
+ if (!selfInterrupted) {
375
+ const marker = taskTracker.apply(msg as any);
376
+ if (marker) bridge.appendMarker(marker);
377
+ try {
378
+ options?.onTaskProgress?.(taskTracker.snapshot());
379
+ } catch {
380
+ /* a status push must never break a turn */
381
+ }
382
+ }
355
383
  } else if (msg.type === "assistant") {
356
384
  // Complete-block envelopes: marker text for the CLI's own tool
357
385
  // executions (built-ins, WebSearch, user MCP, …), which in observer
@@ -0,0 +1,191 @@
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
+
35
+ import type {
36
+ ClaudeTaskEvent,
37
+ TaskSnapshot,
38
+ TaskTrackerState,
39
+ } from "./types.js";
40
+
41
+ /** Marker argument previews are truncated to keep one row on one line. */
42
+ const ARGS_PREVIEW_LIMIT = 120;
43
+
44
+ /**
45
+ * Build a `[Claude Code · Task …]` marker.
46
+ *
47
+ * WIRE CONTRACT — the same shape `event-bridge.ts` emits for CLI-side tool
48
+ * executions, and front-ends parse it with
49
+ * `/^\[Claude Code · ([^\s\]]+)(?:\s+([\s\S]*))?\]$/`. The tool name must stay
50
+ * space-free; everything else rides in the argument JSON, which is truncated
51
+ * here and must never be parsed as JSON by a consumer.
52
+ */
53
+ function taskMarker(args: Record<string, unknown>): string {
54
+ let preview = "";
55
+ try {
56
+ const json = JSON.stringify(args);
57
+ preview =
58
+ json === "{}"
59
+ ? ""
60
+ : ` ${json.slice(0, ARGS_PREVIEW_LIMIT)}${json.length > ARGS_PREVIEW_LIMIT ? "…" : ""}`;
61
+ } catch {
62
+ /* unserializable — the marker still names the tool */
63
+ }
64
+ return `[Claude Code · Task${preview}]`;
65
+ }
66
+
67
+ export interface TaskTracker {
68
+ /**
69
+ * Fold one `task_*` envelope in. Returns a marker string when the event is a
70
+ * durable lifecycle transition (start, finish) and nothing when it is live
71
+ * progress. Unknown subtypes and events without a `task_id` are ignored.
72
+ */
73
+ apply(event: ClaudeTaskEvent): string | undefined;
74
+ /** Current state of every sub-agent seen this episode. */
75
+ snapshot(): TaskTrackerState;
76
+ }
77
+
78
+ export function createTaskTracker(): TaskTracker {
79
+ /** Insertion-ordered, so the snapshot reads in launch order. */
80
+ const tasks = new Map<string, TaskSnapshot>();
81
+
82
+ function upsert(id: string, patch: Partial<TaskSnapshot>): TaskSnapshot {
83
+ 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;
94
+ }
95
+
96
+ return {
97
+ apply(event: ClaudeTaskEvent): string | undefined {
98
+ const id = event.task_id;
99
+ if (!id) return undefined;
100
+
101
+ switch (event.subtype) {
102
+ case "task_started": {
103
+ const task = upsert(id, {
104
+ description: event.description ?? id,
105
+ subagentType: event.subagent_type,
106
+ status: "running",
107
+ });
108
+ return taskMarker({
109
+ status: "started",
110
+ description: task.description,
111
+ subagent_type: task.subagentType,
112
+ });
113
+ }
114
+
115
+ case "task_progress": {
116
+ upsert(id, {
117
+ // `description` on a progress event is the CURRENT step ("Running
118
+ // …"), not the task's own description. Keep them apart: the task
119
+ // name was set at start and must not be overwritten by a step.
120
+ currentStep: event.description,
121
+ subagentType: event.subagent_type ?? tasks.get(id)?.subagentType,
122
+ lastToolName: event.last_tool_name,
123
+ toolUses: event.usage?.tool_uses,
124
+ totalTokens: event.usage?.total_tokens,
125
+ durationMs: event.usage?.duration_ms,
126
+ });
127
+ return undefined;
128
+ }
129
+
130
+ case "task_updated": {
131
+ const status = event.patch?.status;
132
+ upsert(id, status ? { status } : {});
133
+ return undefined;
134
+ }
135
+
136
+ case "task_notification": {
137
+ const task = upsert(id, {
138
+ status: event.status ?? "completed",
139
+ outputFile: event.output_file,
140
+ toolUses: event.usage?.tool_uses ?? tasks.get(id)?.toolUses,
141
+ totalTokens:
142
+ event.usage?.total_tokens ?? tasks.get(id)?.totalTokens,
143
+ durationMs: event.usage?.duration_ms ?? tasks.get(id)?.durationMs,
144
+ currentStep: undefined,
145
+ });
146
+ // The sub-agent's full report reaches the model as the Task tool's
147
+ // own result. Repeating it here would duplicate kilobytes into the
148
+ // transcript, so the marker carries the shape of the work, not its
149
+ // output.
150
+ return taskMarker({
151
+ status: task.status,
152
+ description: task.description,
153
+ tool_uses: task.toolUses,
154
+ total_tokens: task.totalTokens,
155
+ duration_ms: task.durationMs,
156
+ });
157
+ }
158
+
159
+ default:
160
+ return undefined;
161
+ }
162
+ },
163
+
164
+ snapshot(): TaskTrackerState {
165
+ const list = [...tasks.values()].map((t) => ({ ...t }));
166
+ return {
167
+ tasks: list,
168
+ active: list.filter((t) => t.status === "running").length,
169
+ completed: list.filter((t) => t.status !== "running").length,
170
+ };
171
+ },
172
+ };
173
+ }
174
+
175
+ /** The `system` subtypes this module handles. */
176
+ const TASK_SUBTYPES = new Set([
177
+ "task_started",
178
+ "task_progress",
179
+ "task_updated",
180
+ "task_notification",
181
+ ]);
182
+
183
+ /**
184
+ * Whether a `system` envelope's subtype is sub-agent lifecycle.
185
+ *
186
+ * `system` also carries init/status/summary envelopes that have nothing to do
187
+ * with sub-agents, so the provider narrows before handing anything over.
188
+ */
189
+ export function isTaskSubtype(subtype: unknown): boolean {
190
+ return typeof subtype === "string" && TASK_SUBTYPES.has(subtype);
191
+ }
package/src/types.ts CHANGED
@@ -106,6 +106,69 @@ 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
+ usage?: {
143
+ total_tokens?: number;
144
+ tool_uses?: number;
145
+ duration_ms?: number;
146
+ };
147
+ }
148
+
149
+ /** One sub-agent's state, as the host sees it. */
150
+ export interface TaskSnapshot {
151
+ taskId: string;
152
+ /** Names the task. Set at `task_started`, never overwritten by a step. */
153
+ description: string;
154
+ subagentType?: string;
155
+ status: string;
156
+ /** The step running right now, cleared when the task ends. */
157
+ currentStep?: string;
158
+ lastToolName?: string;
159
+ toolUses?: number;
160
+ totalTokens?: number;
161
+ durationMs?: number;
162
+ outputFile?: string;
163
+ }
164
+
165
+ /** Every sub-agent seen this episode, in launch order. */
166
+ export interface TaskTrackerState {
167
+ tasks: TaskSnapshot[];
168
+ active: number;
169
+ completed: number;
170
+ }
171
+
109
172
  export interface ClaudeControlRequest {
110
173
  type: "control_request";
111
174
  request_id: string;
@@ -120,6 +183,7 @@ export type NdjsonMessage =
120
183
  | ClaudeStreamEventMessage
121
184
  | ClaudeResultMessage
122
185
  | ClaudeSystemMessage
186
+ | ClaudeTaskEvent
123
187
  | ClaudeControlRequest
124
188
  | ClaudeAssistantEnvelope
125
189
  | ClaudeUserEnvelope