oira666_pi-subagent 0.1.3 → 0.1.4

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
@@ -112,6 +112,309 @@ pi --no-subagent-prevent-cycles # allow cycles (not recommended)
112
112
  | `PI_SUBAGENT_MAX_PARALLEL_TASKS` | `16` | Max tasks per single call |
113
113
  | `PI_SUBAGENT_MAX_CONCURRENCY` | `8` | Max subagents running simultaneously |
114
114
 
115
+ ## CLI Argument Proxying
116
+
117
+ All flags passed to the parent `pi` process are forwarded to subagent child processes, so they
118
+ inherit the same provider, API key, model, and other runtime settings. Flags the extension manages
119
+ itself are blocked from being forwarded.
120
+
121
+ **Always forwarded verbatim:**
122
+
123
+ | Flag(s) | Purpose |
124
+ | --- | --- |
125
+ | `--provider` | AI provider |
126
+ | `--api-key` | API key |
127
+ | `--system-prompt` | Base system prompt override |
128
+ | `--session-dir` | Session storage directory |
129
+ | `--models` | Model cycling list |
130
+ | `--skill`, `--no-skills`/`-ns` | Skill loading |
131
+ | `--prompt-template`, `--no-prompt-templates`/`-np` | Prompt templates |
132
+ | `--theme`, `--no-themes` | Themes |
133
+ | `--verbose` | Verbose startup output |
134
+ | Unknown/custom flags | Forwarded with heuristic value detection |
135
+
136
+ **Forwarded as fallback** (agent frontmatter overrides if set):
137
+
138
+ | Flag | Overridden by |
139
+ | --- | --- |
140
+ | `--model` | `model:` in agent frontmatter |
141
+ | `--thinking` | `thinking:` in agent frontmatter |
142
+ | `--tools` / `--no-tools` | `tools:` in agent frontmatter |
143
+
144
+ **Never forwarded** (managed by the extension itself):
145
+ `--mode`, `-p`/`--print`, `--session`/`--no-session`, `--continue`, `--resume`,
146
+ `--append-system-prompt`, `--offline`, `--extension`/`-e`, `--no-extensions`/`-ne`,
147
+ `--subagent-max-depth`, `--subagent-prevent-cycles`, `--export`, `--list-models`,
148
+ `--help`, `--version`.
149
+
150
+ ---
151
+
152
+ ## Programmatic Usage (JSON RPC)
153
+
154
+ When running `pi` programmatically with `--mode rpc` (or `--mode json`), the stream contains
155
+ `tool_result_end` events whenever the agent completes a `subagent` tool call. The `details` field
156
+ of these events carries the full stats for that delegation — including recursive usage and tool
157
+ call counts from all subagents in the tree.
158
+
159
+ ### Stream event shape
160
+
161
+ ```
162
+ tool_result_end
163
+ └── message
164
+ ├── role: "toolResult"
165
+ ├── toolName: "subagent"
166
+ ├── toolCallId: string
167
+ ├── isError: boolean
168
+ ├── content: [{ type: "text", text: "<final output>" }]
169
+ └── details: SubagentDetails
170
+ ```
171
+
172
+ ### `SubagentDetails` object
173
+
174
+ ```ts
175
+ interface SubagentDetails {
176
+ // Execution metadata
177
+ mode: "single" | "parallel"; // one task vs multiple parallel tasks
178
+ delegationMode: "spawn" | "fork"; // context mode used
179
+ projectAgentsDir: string | null; // path to .pi/agents/ dir if used
180
+
181
+ // Individual agent results (one per task)
182
+ results: SingleResult[];
183
+
184
+ // ── Stats summary (own + all descendants, recursively) ──────────────────
185
+ aggregatedUsage: UsageStats; // token counts and cost, full tree
186
+ aggregatedToolCalls: ToolCallCounts; // { toolName: callCount }, full tree
187
+
188
+ // ── Per-agent breakdown ──────────────────────────────────────────────────
189
+ usageTree: UsageTreeNode[]; // one root node per result
190
+ }
191
+
192
+ interface SingleResult {
193
+ agent: string; // agent name
194
+ agentSource: "user" | "project" | "builtin" | "unknown";
195
+ task: string; // task string passed to this agent
196
+ exitCode: number; // 0 = success, >0 = error, -1 = still running
197
+ messages: Message[]; // full conversation history of the subagent
198
+ stderr: string;
199
+ usage: UsageStats; // this agent's OWN token usage only
200
+ toolCalls: ToolCallCounts; // this agent's OWN tool calls only
201
+ model?: string;
202
+ stopReason?: string; // "end_turn" | "error" | "aborted" | ...
203
+ errorMessage?: string;
204
+ }
205
+
206
+ interface UsageStats {
207
+ input: number; // input tokens
208
+ output: number; // output tokens
209
+ cacheRead: number; // cache read tokens
210
+ cacheWrite: number; // cache write tokens
211
+ cost: number; // total cost in USD
212
+ contextTokens: number; // snapshot: last context window size (not summed in aggregates)
213
+ turns: number; // number of assistant turns
214
+ }
215
+
216
+ // toolName → call count, e.g. { "bash": 5, "read": 3, "subagent": 1 }
217
+ type ToolCallCounts = Record<string, number>;
218
+
219
+ interface UsageTreeNode {
220
+ agent: string;
221
+ task: string;
222
+ ownUsage: UsageStats; // only this agent's turns
223
+ ownToolCalls: ToolCallCounts; // only this agent's tool calls
224
+ aggregatedUsage: UsageStats; // ownUsage + all children recursively
225
+ aggregatedToolCalls: ToolCallCounts; // ownToolCalls + all children recursively
226
+ children: UsageTreeNode[]; // one node per nested subagent invocation
227
+ }
228
+ ```
229
+
230
+ ### Important notes on stats
231
+
232
+ - **`SingleResult.usage`** and **`SingleResult.toolCalls`** cover **only that one agent's own work** —
233
+ not its children. Children run in separate processes; their tokens never appear in the parent's usage.
234
+ - **`aggregatedUsage`** / **`aggregatedToolCalls`** on `SubagentDetails` (and on each `UsageTreeNode`)
235
+ are the correct totals to use when you want the cost or tool call count for an entire delegation
236
+ subtree.
237
+ - **`contextTokens`** is a point-in-time snapshot of the context window size at the last turn of that
238
+ agent. It is **not** summed in aggregated stats (it would be meaningless as a cross-process sum).
239
+ - **`toolCalls`** includes **all** tool calls an agent made, including the `"subagent"` call itself.
240
+ You can use the `"subagent"` count to see how many nested delegations an agent spawned.
241
+
242
+ ### Annotated example JSON
243
+
244
+ The scenario below: main agent delegates to `code-writer`, which does some file work and then
245
+ delegates to `code-reviwer` before finishing.
246
+
247
+ ```json
248
+ {
249
+ "type": "tool_result_end",
250
+ "message": {
251
+ "role": "toolResult",
252
+ "toolName": "subagent",
253
+ "toolCallId": "toolu_01XYZ",
254
+ "isError": false,
255
+ "content": [
256
+ {
257
+ "type": "text",
258
+ "text": "Feature implemented and reviewed. Added validation logic in auth.ts and updated the test suite."
259
+ }
260
+ ],
261
+ "details": {
262
+ "mode": "single",
263
+ "delegationMode": "spawn",
264
+ "projectAgentsDir": null,
265
+
266
+ "aggregatedUsage": {
267
+ "input": 2180,
268
+ "output": 615,
269
+ "cacheRead": 940,
270
+ "cacheWrite": 120,
271
+ "cost": 0.0079,
272
+ "contextTokens": 0,
273
+ "turns": 3
274
+ },
275
+ "aggregatedToolCalls": {
276
+ "read": 3,
277
+ "bash": 2,
278
+ "edit": 1,
279
+ "subagent": 1
280
+ },
281
+
282
+ "usageTree": [
283
+ {
284
+ "agent": "code-writer",
285
+ "task": "Implement the auth feature and have it reviewed",
286
+ "ownUsage": {
287
+ "input": 1380,
288
+ "output": 365,
289
+ "cacheRead": 540,
290
+ "cacheWrite": 120,
291
+ "cost": 0.0058,
292
+ "contextTokens": 2840,
293
+ "turns": 2
294
+ },
295
+ "ownToolCalls": {
296
+ "read": 1,
297
+ "bash": 1,
298
+ "edit": 1,
299
+ "subagent": 1
300
+ },
301
+ "aggregatedUsage": {
302
+ "input": 2180,
303
+ "output": 615,
304
+ "cacheRead": 940,
305
+ "cacheWrite": 120,
306
+ "cost": 0.0079,
307
+ "contextTokens": 0,
308
+ "turns": 3
309
+ },
310
+ "aggregatedToolCalls": {
311
+ "read": 3,
312
+ "bash": 2,
313
+ "edit": 1,
314
+ "subagent": 1
315
+ },
316
+ "children": [
317
+ {
318
+ "agent": "code-reviwer",
319
+ "task": "Review the auth implementation in auth.ts",
320
+ "ownUsage": {
321
+ "input": 800,
322
+ "output": 250,
323
+ "cacheRead": 400,
324
+ "cacheWrite": 0,
325
+ "cost": 0.0021,
326
+ "contextTokens": 1450,
327
+ "turns": 1
328
+ },
329
+ "ownToolCalls": {
330
+ "read": 2,
331
+ "bash": 1
332
+ },
333
+ "aggregatedUsage": {
334
+ "input": 800,
335
+ "output": 250,
336
+ "cacheRead": 400,
337
+ "cacheWrite": 0,
338
+ "cost": 0.0021,
339
+ "contextTokens": 0,
340
+ "turns": 1
341
+ },
342
+ "aggregatedToolCalls": {
343
+ "read": 2,
344
+ "bash": 1
345
+ },
346
+ "children": []
347
+ }
348
+ ]
349
+ }
350
+ ],
351
+
352
+ "results": [
353
+ {
354
+ "agent": "code-writer",
355
+ "agentSource": "builtin",
356
+ "task": "Implement the auth feature and have it reviewed",
357
+ "exitCode": 0,
358
+ "stopReason": "end_turn",
359
+ "model": "claude-opus-4-5",
360
+ "stderr": "",
361
+ "usage": {
362
+ "input": 1380,
363
+ "output": 365,
364
+ "cacheRead": 540,
365
+ "cacheWrite": 120,
366
+ "cost": 0.0058,
367
+ "contextTokens": 2840,
368
+ "turns": 2
369
+ },
370
+ "toolCalls": {
371
+ "read": 1,
372
+ "bash": 1,
373
+ "edit": 1,
374
+ "subagent": 1
375
+ },
376
+ "messages": [
377
+ "... full conversation history of code-writer (includes the nested subagent tool_result) ..."
378
+ ]
379
+ }
380
+ ]
381
+ }
382
+ }
383
+ }
384
+ ```
385
+
386
+ ### Collecting stats across an entire session
387
+
388
+ If you are consuming the JSON stream programmatically and want to track the total cost and tool
389
+ usage across all subagent work in a session, listen for every `tool_result_end` event where
390
+ `message.toolName === "subagent"` and sum `message.details.aggregatedUsage` across them.
391
+
392
+ ```js
393
+ let totalCost = 0;
394
+ const totalToolCalls = {};
395
+
396
+ for await (const line of jsonLines) {
397
+ const event = JSON.parse(line);
398
+ if (
399
+ event.type === "tool_result_end" &&
400
+ event.message?.toolName === "subagent" &&
401
+ event.message?.details
402
+ ) {
403
+ const { aggregatedUsage, aggregatedToolCalls } = event.message.details;
404
+ totalCost += aggregatedUsage.cost;
405
+ for (const [tool, count] of Object.entries(aggregatedToolCalls)) {
406
+ totalToolCalls[tool] = (totalToolCalls[tool] ?? 0) + count;
407
+ }
408
+ }
409
+ }
410
+ ```
411
+
412
+ Note: if you also track the main agent's own usage from `message_end` events, make sure **not** to
413
+ double-count the subagent costs there — the main agent's own token usage (from its own `message_end`
414
+ events) does not include subagent work; they are always separate processes.
415
+
416
+ ---
417
+
115
418
  ## create-subagent Skill
116
419
 
117
420
  If you want the agent to **create new subagent definition files** for itself, install the [`create-subagent` skill](https://github.com/gee666/pi-subagent/tree/main/create-subagent). Once installed, the agent will know how to scaffold new `.md` agent files in the right location with correct frontmatter.
package/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type SingleResult,
24
24
  type SubagentDetails,
25
25
  DEFAULT_DELEGATION_MODE,
26
+ buildSubagentDetails,
26
27
  emptyUsage,
27
28
  getFinalOutput,
28
29
  isResultError,
@@ -332,12 +333,8 @@ function makeDetailsFactory(
332
333
  delegationMode: DelegationMode,
333
334
  ) {
334
335
  return (mode: "single" | "parallel") =>
335
- (results: SingleResult[]): SubagentDetails => ({
336
- mode,
337
- delegationMode,
338
- projectAgentsDir,
339
- results,
340
- });
336
+ (results: SingleResult[]): SubagentDetails =>
337
+ buildSubagentDetails(mode, delegationMode, projectAgentsDir, results);
341
338
  }
342
339
 
343
340
  function formatAgentNames(agents: AgentConfig[]): string {
@@ -785,6 +782,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
785
782
  messages: [],
786
783
  stderr: "",
787
784
  usage: emptyUsage(),
785
+ toolCalls: {},
788
786
  }));
789
787
 
790
788
  const emitProgress = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Subagent extension for Pi coding agent. Delegate tasks to specialized agents.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/render.ts CHANGED
@@ -271,7 +271,11 @@ function renderTreeLines(
271
271
  }
272
272
 
273
273
  function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
274
- const totalUsage = formatUsage(aggregateUsage(details.results));
274
+ // aggregatedUsage includes own agents + all their nested descendants;
275
+ // fall back to summing only direct results for old serialised data lacking the field.
276
+ const totalUsage = formatUsage(
277
+ details.aggregatedUsage ?? aggregateUsage(details.results),
278
+ );
275
279
  const parts = [
276
280
  `${counts.running} running`,
277
281
  `${counts.finished}/${counts.total} finished`,
package/runner.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  type SingleResult,
17
17
  type SubagentDetails,
18
18
  emptyUsage,
19
+ extractToolCalls,
19
20
  getFinalOutput,
20
21
  getNestedSubagentErrorSummary,
21
22
  } from "./types.js";
@@ -74,32 +75,168 @@ function resolveExtensionArg(value: string): string {
74
75
  return fs.existsSync(resolved) ? resolved : value;
75
76
  }
76
77
 
77
- function getInheritedExtensionArgs(argv: string[]): string[] {
78
- const args: string[] = [];
79
- for (let i = 2; i < argv.length; i++) {
80
- const arg = argv[i];
78
+ interface InheritedCliArgs {
79
+ /** --extension/-e and --no-extensions/-ne args (with path resolution) */
80
+ extensionArgs: string[];
81
+ /** All other non-blocked flags to forward verbatim to every child */
82
+ alwaysProxy: string[];
83
+ /** Parent --model value; used only when agent config doesn't specify model */
84
+ fallbackModel: string | undefined;
85
+ /** Parent --thinking value; used only when agent config doesn't specify thinking */
86
+ fallbackThinking: string | undefined;
87
+ /** Parent --tools value; used only when agent config doesn't specify tools */
88
+ fallbackTools: string | undefined;
89
+ /** Parent passed --no-tools; used only when agent config doesn't specify tools */
90
+ fallbackNoTools: boolean;
91
+ }
92
+
93
+ /**
94
+ * Parse process.argv into categorised groups for child-process arg construction.
95
+ *
96
+ * Categories:
97
+ * - BLOCKED : flags the extension manages itself — never forwarded
98
+ * - extensionArgs : --extension/-e and --no-extensions/-ne (with path resolution)
99
+ * - alwaysProxy : all other non-blocked flags forwarded verbatim
100
+ * - fallback* : flags the agent config may override
101
+ *
102
+ * Handles both "--flag value" and "--flag=value" forms.
103
+ * Unknown flags use a heuristic: if the next token doesn't start with "-",
104
+ * it is treated as the flag's value.
105
+ */
106
+ function parseInheritedCliArgs(argv: string[]): InheritedCliArgs {
107
+ const extensionArgs: string[] = [];
108
+ const alwaysProxy: string[] = [];
109
+ let fallbackModel: string | undefined;
110
+ let fallbackThinking: string | undefined;
111
+ let fallbackTools: string | undefined;
112
+ let fallbackNoTools = false;
113
+
114
+ let i = 2; // skip "node" and "pi"
115
+ while (i < argv.length) {
116
+ const raw = argv[i];
117
+ // Positional args (prompt text, @file refs) — skip, not proxied to children
118
+ if (!raw.startsWith("-")) { i++; continue; }
119
+
120
+ // Normalise: detect --flag=value inline form
121
+ const eqIdx = raw.indexOf("=");
122
+ const flagName = eqIdx !== -1 ? raw.slice(0, eqIdx) : raw;
123
+ const inlineValue: string | undefined = eqIdx !== -1 ? raw.slice(eqIdx + 1) : undefined;
124
+
125
+ const nextToken = argv[i + 1];
126
+ const nextIsValue = nextToken !== undefined && !nextToken.startsWith("-");
127
+
128
+ // Returns [resolvedValue | undefined, tokensToConsume]
129
+ const getVal = (): [string | undefined, number] => {
130
+ if (inlineValue !== undefined) return [inlineValue, 1];
131
+ if (nextIsValue) return [nextToken, 2];
132
+ return [undefined, 1];
133
+ };
134
+
135
+ // ── BLOCKED: value flags ─────────────────────────────────────────────────
136
+ // Extension manages these; consume flag + value, never proxy.
137
+ if ([
138
+ "--mode", "--session", "--append-system-prompt",
139
+ "--export", "--subagent-max-depth",
140
+ ].includes(flagName)) {
141
+ const [, skip] = getVal();
142
+ i += skip; continue;
143
+ }
81
144
 
82
- if (arg === "--no-extensions" || arg === "-ne") {
83
- args.push("--no-extensions");
145
+ // --subagent-prevent-cycles takes an optional value
146
+ if (flagName === "--subagent-prevent-cycles") {
147
+ if (inlineValue !== undefined || nextIsValue) { i += inlineValue !== undefined ? 1 : 2; }
148
+ else { i++; }
84
149
  continue;
85
150
  }
86
151
 
87
- if (arg === "--extension" || arg === "-e") {
88
- const value = argv[i + 1];
89
- if (value !== undefined) {
90
- args.push("--extension", resolveExtensionArg(value));
91
- i++;
92
- }
152
+ // --list-models has an optional search term
153
+ if (flagName === "--list-models") {
154
+ if (inlineValue !== undefined || nextIsValue) { i += inlineValue !== undefined ? 1 : 2; }
155
+ else { i++; }
93
156
  continue;
94
157
  }
95
158
 
96
- if (arg.startsWith("--extension=")) {
97
- args.push("--extension", resolveExtensionArg(arg.slice("--extension=".length)));
159
+ // ── BLOCKED: boolean flags ────────────────────────────────────────────────
160
+ if ([
161
+ "--print", "-p", "--no-session",
162
+ "--continue", "-c", "--resume", "-r",
163
+ "--offline", "--help", "-h", "--version", "-v",
164
+ "--no-subagent-prevent-cycles",
165
+ ].includes(flagName)) {
166
+ i++; continue;
167
+ }
168
+
169
+ // ── EXTENSION FLAGS: handled separately with path resolution ─────────────
170
+ if (flagName === "--no-extensions" || flagName === "-ne") {
171
+ extensionArgs.push(flagName);
172
+ i++; continue;
173
+ }
174
+ if (flagName === "--extension" || flagName === "-e") {
175
+ const [value, skip] = getVal();
176
+ if (value !== undefined) extensionArgs.push(flagName, resolveExtensionArg(value));
177
+ i += skip; continue;
178
+ }
179
+
180
+ // ── ALWAYS-PROXY: known value flags ──────────────────────────────────────
181
+ if ([
182
+ "--provider", "--api-key", "--system-prompt", "--session-dir",
183
+ "--models", "--skill", "--prompt-template", "--theme",
184
+ ].includes(flagName)) {
185
+ const [value, skip] = getVal();
186
+ if (value !== undefined) alwaysProxy.push(flagName, value);
187
+ i += skip; continue;
188
+ }
189
+
190
+ // ── ALWAYS-PROXY: known boolean flags ────────────────────────────────────
191
+ if ([
192
+ "--no-skills", "-ns", "--no-prompt-templates", "-np",
193
+ "--no-themes", "--verbose",
194
+ ].includes(flagName)) {
195
+ alwaysProxy.push(flagName);
196
+ i++; continue;
98
197
  }
198
+
199
+ // ── FALLBACK: agent config may override ───────────────────────────────────
200
+ if (flagName === "--model") {
201
+ const [value, skip] = getVal();
202
+ if (value !== undefined) fallbackModel = value;
203
+ i += skip; continue;
204
+ }
205
+ if (flagName === "--thinking") {
206
+ const [value, skip] = getVal();
207
+ if (value !== undefined) fallbackThinking = value;
208
+ i += skip; continue;
209
+ }
210
+ if (flagName === "--tools") {
211
+ const [value, skip] = getVal();
212
+ if (value !== undefined) fallbackTools = value;
213
+ i += skip; continue;
214
+ }
215
+ if (flagName === "--no-tools") {
216
+ fallbackNoTools = true;
217
+ i++; continue;
218
+ }
219
+
220
+ // ── UNKNOWN: heuristic passthrough ───────────────────────────────────────
221
+ // Likely a custom extension flag. Forward with value if next token looks like one.
222
+ if (inlineValue !== undefined) {
223
+ alwaysProxy.push(flagName, inlineValue);
224
+ i++; continue;
225
+ }
226
+ if (nextIsValue) {
227
+ alwaysProxy.push(flagName, nextToken);
228
+ i += 2; continue;
229
+ }
230
+ alwaysProxy.push(flagName);
231
+ i++;
99
232
  }
100
- return args;
233
+
234
+ return { extensionArgs, alwaysProxy, fallbackModel, fallbackThinking, fallbackTools, fallbackNoTools };
101
235
  }
102
236
 
237
+ /** Cached once — process.argv is immutable at runtime */
238
+ const _inheritedCliArgs = parseInheritedCliArgs(process.argv);
239
+
103
240
  // ---------------------------------------------------------------------------
104
241
  // JSON-line stream processing
105
242
  // ---------------------------------------------------------------------------
@@ -158,7 +295,8 @@ function buildPiArgs(
158
295
  const args: string[] = [
159
296
  "--mode",
160
297
  "json",
161
- ...getInheritedExtensionArgs(process.argv),
298
+ ..._inheritedCliArgs.extensionArgs,
299
+ ..._inheritedCliArgs.alwaysProxy,
162
300
  "-p",
163
301
  ];
164
302
 
@@ -168,10 +306,25 @@ function buildPiArgs(
168
306
  args.push("--session", forkSessionPath);
169
307
  }
170
308
 
171
- if (agent.model) args.push("--model", agent.model);
172
- if (agent.thinking) args.push("--thinking", agent.thinking);
173
- if (agent.tools && agent.tools.length > 0)
309
+ // Agent config takes priority; fall back to parent CLI value
310
+ const model = agent.model ?? _inheritedCliArgs.fallbackModel;
311
+ if (model) args.push("--model", model);
312
+
313
+ const thinking = agent.thinking ?? _inheritedCliArgs.fallbackThinking;
314
+ if (thinking) args.push("--thinking", thinking);
315
+
316
+ // agent.tools is set only when the agent file specifies tools (length > 0)
317
+ if (agent.tools && agent.tools.length > 0) {
174
318
  args.push("--tools", agent.tools.join(","));
319
+ } else if (agent.tools === undefined) {
320
+ // Agent didn't restrict tools — inherit parent's preference
321
+ if (_inheritedCliArgs.fallbackTools !== undefined) {
322
+ args.push("--tools", _inheritedCliArgs.fallbackTools);
323
+ } else if (_inheritedCliArgs.fallbackNoTools) {
324
+ args.push("--no-tools");
325
+ }
326
+ }
327
+
175
328
  if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
176
329
  args.push(`Task: ${task}`);
177
330
  return args;
@@ -246,6 +399,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<SingleResult> {
246
399
  messages: [],
247
400
  stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
248
401
  usage: emptyUsage(),
402
+ toolCalls: {},
249
403
  };
250
404
  }
251
405
 
@@ -262,6 +416,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<SingleResult> {
262
416
  stderr:
263
417
  "Cannot run in fork mode: missing parent session snapshot context.",
264
418
  usage: emptyUsage(),
419
+ toolCalls: {},
265
420
  model: agent.model,
266
421
  stopReason: "error",
267
422
  errorMessage:
@@ -277,6 +432,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<SingleResult> {
277
432
  messages: [],
278
433
  stderr: "",
279
434
  usage: emptyUsage(),
435
+ toolCalls: {},
280
436
  model: agent.model,
281
437
  };
282
438
 
@@ -379,6 +535,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<SingleResult> {
379
535
  });
380
536
 
381
537
  result.exitCode = exitCode;
538
+ result.toolCalls = extractToolCalls(result.messages); // populate from parsed messages
382
539
  if (wasAborted) {
383
540
  result.exitCode = 130;
384
541
  result.stopReason = "aborted";
package/types.ts CHANGED
@@ -21,6 +21,9 @@ export interface UsageStats {
21
21
  turns: number;
22
22
  }
23
23
 
24
+ /** Tool calls made by an agent: toolName → call count */
25
+ export type ToolCallCounts = Record<string, number>;
26
+
24
27
  /** Result of a single subagent invocation. */
25
28
  export interface SingleResult {
26
29
  agent: string;
@@ -30,17 +33,40 @@ export interface SingleResult {
30
33
  messages: Message[];
31
34
  stderr: string;
32
35
  usage: UsageStats;
36
+ toolCalls: ToolCallCounts;
33
37
  model?: string;
34
38
  stopReason?: string;
35
39
  errorMessage?: string;
36
40
  }
37
41
 
42
+ /** A node in the per-subagent usage tree (own stats + recursive children) */
43
+ export interface UsageTreeNode {
44
+ agent: string;
45
+ task: string;
46
+ /** Token/cost usage for this agent's own turns only */
47
+ ownUsage: UsageStats;
48
+ /** Tool calls this agent made directly (all tools, including "subagent") */
49
+ ownToolCalls: ToolCallCounts;
50
+ /** ownUsage summed with all descendants recursively */
51
+ aggregatedUsage: UsageStats;
52
+ /** ownToolCalls merged with all descendants recursively */
53
+ aggregatedToolCalls: ToolCallCounts;
54
+ /** Nested subagent invocations, recursively populated */
55
+ children: UsageTreeNode[];
56
+ }
57
+
38
58
  /** Metadata attached to every tool result for rendering. */
39
59
  export interface SubagentDetails {
40
60
  mode: "single" | "parallel";
41
61
  delegationMode: DelegationMode;
42
62
  projectAgentsDir: string | null;
43
63
  results: SingleResult[];
64
+ /** Usage summed across all results and all their nested descendants */
65
+ aggregatedUsage: UsageStats;
66
+ /** Tool calls merged across all results and all their nested descendants */
67
+ aggregatedToolCalls: ToolCallCounts;
68
+ /** Per-agent recursive usage breakdown */
69
+ usageTree: UsageTreeNode[];
44
70
  }
45
71
 
46
72
  /** Nested subagent tool result captured from a delegated run. */
@@ -74,6 +100,97 @@ export function aggregateUsage(results: SingleResult[]): UsageStats {
74
100
  return total;
75
101
  }
76
102
 
103
+ /** Add delta into total in-place (contextTokens is a snapshot—not summed, left as-is in total) */
104
+ export function addUsage(total: UsageStats, delta: UsageStats): void {
105
+ total.input += delta.input;
106
+ total.output += delta.output;
107
+ total.cacheRead += delta.cacheRead;
108
+ total.cacheWrite += delta.cacheWrite;
109
+ total.cost += delta.cost;
110
+ total.turns += delta.turns;
111
+ }
112
+
113
+ /** Merge tool call counts from `source` into `target` in-place */
114
+ export function mergeToolCalls(target: ToolCallCounts, source: ToolCallCounts): void {
115
+ for (const [name, count] of Object.entries(source)) {
116
+ target[name] = (target[name] ?? 0) + count;
117
+ }
118
+ }
119
+
120
+ /** Extract all tool calls made by assistant turns in a message list */
121
+ export function extractToolCalls(messages: Message[]): ToolCallCounts {
122
+ const counts: ToolCallCounts = {};
123
+ for (const msg of messages) {
124
+ if (msg.role !== "assistant") continue;
125
+ for (const part of (msg.content as any[]) ?? []) {
126
+ if ((part as any)?.type !== "toolCall") continue;
127
+ const name: string = typeof (part as any).name === "string" ? (part as any).name : "unknown";
128
+ counts[name] = (counts[name] ?? 0) + 1;
129
+ }
130
+ }
131
+ return counts;
132
+ }
133
+
134
+ /** Build a UsageTreeNode for one result, recursing into nested subagent tool results */
135
+ function buildUsageTreeNode(result: SingleResult): UsageTreeNode {
136
+ const children: UsageTreeNode[] = [];
137
+ for (const nested of getNestedSubagentResults(result.messages)) {
138
+ for (const nestedResult of nested.details.results) {
139
+ children.push(buildUsageTreeNode(nestedResult));
140
+ }
141
+ }
142
+
143
+ const ownUsage = result.usage;
144
+ const ownToolCalls: ToolCallCounts = result.toolCalls ?? extractToolCalls(result.messages);
145
+
146
+ const aggregatedUsage = emptyUsage();
147
+ addUsage(aggregatedUsage, ownUsage);
148
+ for (const child of children) addUsage(aggregatedUsage, child.aggregatedUsage);
149
+
150
+ const aggregatedToolCalls: ToolCallCounts = { ...ownToolCalls };
151
+ for (const child of children) mergeToolCalls(aggregatedToolCalls, child.aggregatedToolCalls);
152
+
153
+ return {
154
+ agent: result.agent,
155
+ task: result.task,
156
+ ownUsage,
157
+ ownToolCalls,
158
+ aggregatedUsage,
159
+ aggregatedToolCalls,
160
+ children,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Construct a complete SubagentDetails with aggregated stats.
166
+ * This replaces the plain object literal previously used by makeDetailsFactory.
167
+ */
168
+ export function buildSubagentDetails(
169
+ mode: "single" | "parallel",
170
+ delegationMode: DelegationMode,
171
+ projectAgentsDir: string | null,
172
+ results: SingleResult[],
173
+ ): SubagentDetails {
174
+ const usageTree = results.map(buildUsageTreeNode);
175
+
176
+ const aggregatedUsage = emptyUsage();
177
+ const aggregatedToolCalls: ToolCallCounts = {};
178
+ for (const node of usageTree) {
179
+ addUsage(aggregatedUsage, node.aggregatedUsage);
180
+ mergeToolCalls(aggregatedToolCalls, node.aggregatedToolCalls);
181
+ }
182
+
183
+ return {
184
+ mode,
185
+ delegationMode,
186
+ projectAgentsDir,
187
+ results,
188
+ aggregatedUsage,
189
+ aggregatedToolCalls,
190
+ usageTree,
191
+ };
192
+ }
193
+
77
194
  /** Whether a result represents an error. */
78
195
  export function isResultError(r: SingleResult): boolean {
79
196
  return r.exitCode > 0 || r.stopReason === "error" || r.stopReason === "aborted";