@narumitw/pi-subagents 0.16.0 → 0.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/execution.ts CHANGED
@@ -286,6 +286,34 @@ export async function executeSubagent(
286
286
  const emitParallelUpdate = () => {
287
287
  status.update(parallelStatus(doneCount, allResults.length, runningCount));
288
288
  if (onUpdate) {
289
+ const aggregator = params.aggregator;
290
+ const pendingAggregator: SingleResult | undefined =
291
+ aggregator && !signal?.aborted && doneCount === allResults.length
292
+ ? {
293
+ agent: aggregator.agent,
294
+ agentSource:
295
+ agents.find((agent) => agent.name === aggregator.agent)?.source ?? "unknown",
296
+ task: aggregator.task,
297
+ exitCode: -1,
298
+ messages: [],
299
+ stderr: "",
300
+ usage: {
301
+ input: 0,
302
+ output: 0,
303
+ cacheRead: 0,
304
+ cacheWrite: 0,
305
+ cost: 0,
306
+ contextTokens: 0,
307
+ turns: 0,
308
+ },
309
+ thinkingLevel: resolveThinkingLevel(
310
+ aggregator.agent,
311
+ aggregator.thinkingLevel,
312
+ ),
313
+ timeoutMs: resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
314
+ finalOutput: "",
315
+ }
316
+ : undefined;
289
317
  onUpdate({
290
318
  content: [
291
319
  {
@@ -293,7 +321,7 @@ export async function executeSubagent(
293
321
  text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
294
322
  },
295
323
  ],
296
- details: makeDetails("parallel")([...allResults]),
324
+ details: makeDetails("parallel")([...allResults], pendingAggregator),
297
325
  });
298
326
  }
299
327
  };
package/src/render.ts CHANGED
@@ -38,6 +38,8 @@ export function formatUsageStats(
38
38
  },
39
39
  model?: string,
40
40
  thinkingLevel?: SubagentThinkingLevel,
41
+ actualProvider?: string,
42
+ actualModel?: string,
41
43
  ): string {
42
44
  const parts: string[] = [];
43
45
  if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
@@ -46,14 +48,27 @@ export function formatUsageStats(
46
48
  if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
47
49
  if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
48
50
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
49
- if (usage.contextTokens && usage.contextTokens > 0) {
51
+ if (usage.contextTokens && usage.contextTokens > 0)
50
52
  parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
51
- }
52
- if (model) parts.push(model);
53
- if (thinkingLevel) parts.push(`thinking:${thinkingLevel}`);
53
+ const actual =
54
+ actualProvider && actualModel
55
+ ? `${actualProvider}/${actualModel}`
56
+ : (actualModel ?? actualProvider);
57
+ if (actual ?? model) parts.push(actual ?? model ?? "");
58
+ if (thinkingLevel) parts.push(`requested-thinking:${thinkingLevel}`);
54
59
  return parts.join(" ");
55
60
  }
56
61
 
62
+ function formatResultUsageStats(result: SingleResult): string {
63
+ return formatUsageStats(
64
+ result.usage,
65
+ result.model,
66
+ result.thinkingLevel,
67
+ result.actualProvider,
68
+ result.actualModel,
69
+ );
70
+ }
71
+
57
72
  function formatToolCall(
58
73
  toolName: string,
59
74
  args: Record<string, unknown>,
@@ -103,7 +118,11 @@ function formatToolCall(
103
118
  case "find": {
104
119
  const pattern = (args.pattern || "*") as string;
105
120
  const rawPath = (args.path || ".") as string;
106
- return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
121
+ return (
122
+ themeFg("muted", "find ") +
123
+ themeFg("accent", pattern) +
124
+ themeFg("dim", ` in ${shortenPath(rawPath)}`)
125
+ );
107
126
  }
108
127
  case "grep": {
109
128
  const pattern = (args.pattern || "") as string;
@@ -131,400 +150,496 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
131
150
  for (const msg of messages) {
132
151
  if (msg.role === "assistant") {
133
152
  for (const part of msg.content) {
134
- if (part.type === "text") items.push({ type: "text", text: part.text });
135
- else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
153
+ if (part.type === "text") {
154
+ const text = part.text.trim();
155
+ if (text) items.push({ type: "text", text });
156
+ } else if (part.type === "toolCall") {
157
+ items.push({ type: "toolCall", name: part.name, args: part.arguments });
158
+ }
136
159
  }
137
160
  }
138
161
  }
139
162
  return items;
140
163
  }
164
+ function getCollapsedDisplayItems(result: SingleResult): { items: DisplayItem[]; total: number } {
165
+ if (result.recentActivity && result.recentActivity.length > 0) {
166
+ return {
167
+ items: result.recentActivity,
168
+ total: Math.max(result.recentActivity.length, result.recentActivityTotal ?? 0),
169
+ };
170
+ }
171
+ const items = getDisplayItems(result.messages);
172
+ return { items, total: items.length };
173
+ }
141
174
 
142
175
  export function renderSubagentCall(args: SubagentParams, theme: Theme) {
143
- const scope: AgentScope = args.agentScope ?? "user";
144
- if (args.chain && args.chain.length > 0) {
145
- let text =
146
- theme.fg("toolTitle", theme.bold("subagent ")) +
147
- theme.fg("accent", `chain (${args.chain.length} steps)`) +
148
- theme.fg("muted", ` [${scope}]`);
149
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
150
- const step = args.chain[i];
151
- // Clean up {previous} placeholder for display
152
- const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
153
- const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
154
- text +=
155
- "\n " +
156
- theme.fg("muted", `${i + 1}.`) +
157
- " " +
158
- theme.fg("accent", step.agent) +
159
- theme.fg("dim", ` ${preview}`);
160
- }
161
- if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
162
- return new Text(text, 0, 0);
163
- }
164
- if (args.tasks && args.tasks.length > 0) {
165
- let text =
166
- theme.fg("toolTitle", theme.bold("subagent ")) +
167
- theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
168
- theme.fg("muted", ` [${scope}]`);
169
- for (const t of args.tasks.slice(0, 3)) {
170
- const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
171
- text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
172
- }
173
- if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
174
- if (args.aggregator) {
175
- const preview =
176
- args.aggregator.task.length > 40 ? `${args.aggregator.task.slice(0, 40)}...` : args.aggregator.task;
177
- text += `\n ${theme.fg("muted", "fan-in → ")}${theme.fg("accent", args.aggregator.agent)}${theme.fg(
178
- "dim",
179
- ` ${preview}`,
180
- )}`;
181
- }
182
- return new Text(text, 0, 0);
183
- }
184
- const agentName = args.agent || "...";
185
- const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
186
- let text =
187
- theme.fg("toolTitle", theme.bold("subagent ")) +
188
- theme.fg("accent", agentName) +
189
- theme.fg("muted", ` [${scope}]`);
190
- text += `\n ${theme.fg("dim", preview)}`;
191
- return new Text(text, 0, 0);
176
+ const scope: AgentScope = args.agentScope ?? "user";
177
+ if (args.chain && args.chain.length > 0) {
178
+ let text =
179
+ theme.fg("toolTitle", theme.bold("subagent ")) +
180
+ theme.fg("accent", `chain (${args.chain.length} steps)`) +
181
+ theme.fg("muted", ` [${scope}]`);
182
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
183
+ const step = args.chain[i];
184
+ // Clean up {previous} placeholder for display
185
+ const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
186
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
187
+ text +=
188
+ "\n " +
189
+ theme.fg("muted", `${i + 1}.`) +
190
+ " " +
191
+ theme.fg("accent", step.agent) +
192
+ theme.fg("dim", ` ${preview}`);
193
+ }
194
+ if (args.chain.length > 3)
195
+ text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
196
+ return new Text(text, 0, 0);
197
+ }
198
+ if (args.tasks && args.tasks.length > 0) {
199
+ let text =
200
+ theme.fg("toolTitle", theme.bold("subagent ")) +
201
+ theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
202
+ theme.fg("muted", ` [${scope}]`);
203
+ for (const t of args.tasks.slice(0, 3)) {
204
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
205
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
206
+ }
207
+ if (args.tasks.length > 3)
208
+ text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
209
+ if (args.aggregator) {
210
+ const preview =
211
+ args.aggregator.task.length > 40
212
+ ? `${args.aggregator.task.slice(0, 40)}...`
213
+ : args.aggregator.task;
214
+ text += `\n ${theme.fg("muted", "fan-in → ")}${theme.fg("accent", args.aggregator.agent)}${theme.fg(
215
+ "dim",
216
+ ` ${preview}`,
217
+ )}`;
218
+ }
219
+ return new Text(text, 0, 0);
220
+ }
221
+ const agentName = args.agent || "...";
222
+ const preview = args.task
223
+ ? args.task.length > 60
224
+ ? `${args.task.slice(0, 60)}...`
225
+ : args.task
226
+ : "...";
227
+ let text =
228
+ theme.fg("toolTitle", theme.bold("subagent ")) +
229
+ theme.fg("accent", agentName) +
230
+ theme.fg("muted", ` [${scope}]`);
231
+ text += `\n ${theme.fg("dim", preview)}`;
232
+ return new Text(text, 0, 0);
192
233
  }
193
234
 
194
235
  export function renderSubagentResult(
195
236
  result: AgentToolResult<SubagentDetails>,
196
- { expanded }: ToolRenderResultOptions,
237
+ { expanded, isPartial }: ToolRenderResultOptions,
197
238
  theme: Theme,
198
239
  ) {
199
- const details = result.details as SubagentDetails | undefined;
200
- if (!details || details.results.length === 0) {
201
- const text = result.content[0];
202
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
240
+ const details = result.details as SubagentDetails | undefined;
241
+ if (!details || details.results.length === 0) {
242
+ const text = result.content[0];
243
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
244
+ }
245
+
246
+ const mdTheme = getMarkdownTheme();
247
+
248
+ const renderDisplayItems = (items: DisplayItem[], limit?: number, total = items.length) => {
249
+ const toShow = limit ? items.slice(-limit) : items;
250
+ const skipped = Math.max(0, total - toShow.length);
251
+ let text = "";
252
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
253
+ for (const item of toShow) {
254
+ if (item.type === "text") {
255
+ const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
256
+ text += `${theme.fg("toolOutput", preview)}\n`;
257
+ } else {
258
+ text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
203
259
  }
260
+ }
261
+ return text.trimEnd();
262
+ };
204
263
 
205
- const mdTheme = getMarkdownTheme();
206
-
207
- const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
208
- const toShow = limit ? items.slice(-limit) : items;
209
- const skipped = limit && items.length > limit ? items.length - limit : 0;
210
- let text = "";
211
- if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
212
- for (const item of toShow) {
213
- if (item.type === "text") {
214
- const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
215
- text += `${theme.fg("toolOutput", preview)}\n`;
216
- } else {
217
- text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
218
- }
264
+ if (details.mode === "single" && details.results.length === 1) {
265
+ const r = details.results[0];
266
+ const isError = isResultError(r);
267
+ const icon = isError
268
+ ? theme.fg("error", "✗")
269
+ : isPartial
270
+ ? theme.fg("warning", "⏳")
271
+ : theme.fg("success", "✓");
272
+ const displayItems = getDisplayItems(r.messages);
273
+ const finalOutput = getResultFinalOutput(r);
274
+
275
+ if (expanded) {
276
+ const container = new Container();
277
+ let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
278
+ if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
279
+ container.addChild(new Text(header, 0, 0));
280
+ if (isError && r.errorMessage)
281
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
282
+ container.addChild(new Spacer(1));
283
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
284
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
285
+ container.addChild(new Spacer(1));
286
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
287
+ if (displayItems.length === 0 && !finalOutput) {
288
+ container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
289
+ } else {
290
+ for (const item of displayItems) {
291
+ if (item.type === "toolCall")
292
+ container.addChild(
293
+ new Text(
294
+ theme.fg("muted", "→ ") +
295
+ formatToolCall(item.name, item.args, theme.fg.bind(theme)),
296
+ 0,
297
+ 0,
298
+ ),
299
+ );
300
+ }
301
+ if (finalOutput) {
302
+ container.addChild(new Spacer(1));
303
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
219
304
  }
220
- return text.trimEnd();
221
- };
305
+ }
306
+ const usageStr = formatResultUsageStats(r);
307
+ if (usageStr) {
308
+ container.addChild(new Spacer(1));
309
+ container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
310
+ }
311
+ return container;
312
+ }
222
313
 
223
- if (details.mode === "single" && details.results.length === 1) {
224
- const r = details.results[0];
225
- const isError = isResultError(r);
226
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
314
+ const collapsed = getCollapsedDisplayItems(r);
315
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
316
+ if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
317
+ if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
318
+ if (collapsed.items.length > 0) {
319
+ text += `\n${renderDisplayItems(collapsed.items, COLLAPSED_ITEM_COUNT, collapsed.total)}`;
320
+ if (collapsed.total > COLLAPSED_ITEM_COUNT)
321
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
322
+ } else if (finalOutput.trim()) {
323
+ text += `\n${theme.fg("toolOutput", finalOutput.trim().split("\n").slice(0, 3).join("\n"))}`;
324
+ } else if (!isError || !r.errorMessage) {
325
+ text += `\n${theme.fg("muted", isPartial && !isError ? "(running...)" : "(no output)")}`;
326
+ }
327
+ const usageStr = formatResultUsageStats(r);
328
+ if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
329
+ return new Text(text, 0, 0);
330
+ }
331
+
332
+ const aggregateUsage = (results: SingleResult[]) => {
333
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
334
+ for (const r of results) {
335
+ total.input += r.usage.input;
336
+ total.output += r.usage.output;
337
+ total.cacheRead += r.usage.cacheRead;
338
+ total.cacheWrite += r.usage.cacheWrite;
339
+ total.cost += r.usage.cost;
340
+ total.turns += r.usage.turns;
341
+ }
342
+ return total;
343
+ };
344
+
345
+ if (details.mode === "chain") {
346
+ const currentResult = details.results.at(-1);
347
+ const currentIsRunning =
348
+ isPartial && currentResult !== undefined && !isResultError(currentResult);
349
+ const successCount = details.results.filter(
350
+ (result) => !isResultError(result) && (!currentIsRunning || result !== currentResult),
351
+ ).length;
352
+ const icon = currentIsRunning
353
+ ? theme.fg("warning", "⏳")
354
+ : successCount === details.results.length
355
+ ? theme.fg("success", "✓")
356
+ : theme.fg("error", "✗");
357
+
358
+ if (expanded) {
359
+ const container = new Container();
360
+ container.addChild(
361
+ new Text(
362
+ icon +
363
+ " " +
364
+ theme.fg("toolTitle", theme.bold("chain ")) +
365
+ theme.fg("accent", `${successCount}/${details.results.length} steps`),
366
+ 0,
367
+ 0,
368
+ ),
369
+ );
370
+
371
+ for (const r of details.results) {
372
+ const rFailed = isResultError(r);
373
+ const rIcon = rFailed
374
+ ? theme.fg("error", "✗")
375
+ : currentIsRunning && r === currentResult
376
+ ? theme.fg("warning", "⏳")
377
+ : theme.fg("success", "✓");
227
378
  const displayItems = getDisplayItems(r.messages);
228
379
  const finalOutput = getResultFinalOutput(r);
229
380
 
230
- if (expanded) {
231
- const container = new Container();
232
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
233
- if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
234
- container.addChild(new Text(header, 0, 0));
235
- if (isError && r.errorMessage)
236
- container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
237
- container.addChild(new Spacer(1));
238
- container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
239
- container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
240
- container.addChild(new Spacer(1));
241
- container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
242
- if (displayItems.length === 0 && !finalOutput) {
243
- container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
244
- } else {
245
- for (const item of displayItems) {
246
- if (item.type === "toolCall")
247
- container.addChild(
248
- new Text(
249
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
250
- 0,
251
- 0,
252
- ),
253
- );
254
- }
255
- if (finalOutput) {
256
- container.addChild(new Spacer(1));
257
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
258
- }
259
- }
260
- const usageStr = formatUsageStats(r.usage, r.model, r.thinkingLevel);
261
- if (usageStr) {
262
- container.addChild(new Spacer(1));
263
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
381
+ container.addChild(new Spacer(1));
382
+ container.addChild(
383
+ new Text(
384
+ `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
385
+ 0,
386
+ 0,
387
+ ),
388
+ );
389
+ container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
390
+ if (rFailed && r.errorMessage)
391
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
392
+
393
+ // Show tool calls
394
+ for (const item of displayItems) {
395
+ if (item.type === "toolCall") {
396
+ container.addChild(
397
+ new Text(
398
+ theme.fg("muted", "→ ") +
399
+ formatToolCall(item.name, item.args, theme.fg.bind(theme)),
400
+ 0,
401
+ 0,
402
+ ),
403
+ );
264
404
  }
265
- return container;
266
405
  }
267
406
 
268
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
269
- if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
270
- if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
271
- else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
272
- else {
273
- text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
274
- if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
407
+ // Show final output as markdown
408
+ if (finalOutput) {
409
+ container.addChild(new Spacer(1));
410
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
275
411
  }
276
- const usageStr = formatUsageStats(r.usage, r.model, r.thinkingLevel);
277
- if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
278
- return new Text(text, 0, 0);
412
+
413
+ const stepUsage = formatResultUsageStats(r);
414
+ if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
279
415
  }
280
416
 
281
- const aggregateUsage = (results: SingleResult[]) => {
282
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
283
- for (const r of results) {
284
- total.input += r.usage.input;
285
- total.output += r.usage.output;
286
- total.cacheRead += r.usage.cacheRead;
287
- total.cacheWrite += r.usage.cacheWrite;
288
- total.cost += r.usage.cost;
289
- total.turns += r.usage.turns;
290
- }
291
- return total;
292
- };
417
+ const usageStr = formatUsageStats(aggregateUsage(details.results));
418
+ if (usageStr) {
419
+ container.addChild(new Spacer(1));
420
+ container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
421
+ }
422
+ return container;
423
+ }
293
424
 
294
- if (details.mode === "chain") {
295
- const successCount = details.results.filter((result) => !isResultError(result)).length;
296
- const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
425
+ // Collapsed view
426
+ let text =
427
+ icon +
428
+ " " +
429
+ theme.fg("toolTitle", theme.bold("chain ")) +
430
+ theme.fg("accent", `${successCount}/${details.results.length} steps`);
431
+ for (const r of details.results) {
432
+ const rFailed = isResultError(r);
433
+ const rIcon = rFailed
434
+ ? theme.fg("error", "✗")
435
+ : currentIsRunning && r === currentResult
436
+ ? theme.fg("warning", "⏳")
437
+ : theme.fg("success", "✓");
438
+ const collapsed = getCollapsedDisplayItems(r);
439
+ const finalOutput = getResultFinalOutput(r).trim();
440
+ text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
441
+ if (rFailed && r.errorMessage)
442
+ text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
443
+ if (collapsed.items.length > 0)
444
+ text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
445
+ else if (currentIsRunning && r === currentResult)
446
+ text += `\n${theme.fg("muted", "(running...)")}`;
447
+ else if (finalOutput)
448
+ text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 3).join("\n"))}`;
449
+ else if (!rFailed || !r.errorMessage) text += `\n${theme.fg("muted", "(no output)")}`;
450
+ }
451
+ const usageStr = formatUsageStats(aggregateUsage(details.results));
452
+ if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
453
+ text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
454
+ return new Text(text, 0, 0);
455
+ }
297
456
 
298
- if (expanded) {
299
- const container = new Container();
300
- container.addChild(
301
- new Text(
302
- icon +
303
- " " +
304
- theme.fg("toolTitle", theme.bold("chain ")) +
305
- theme.fg("accent", `${successCount}/${details.results.length} steps`),
306
- 0,
307
- 0,
308
- ),
309
- );
457
+ if (details.mode === "parallel") {
458
+ const resultIsRunning = (result: SingleResult) =>
459
+ result.exitCode === -1 && !isResultError(result);
460
+ const running = details.results.filter(resultIsRunning).length;
461
+ const successCount = details.results.filter(
462
+ (result) => result.exitCode !== -1 && !isResultError(result),
463
+ ).length;
464
+ const failCount = details.results.filter(isResultError).length;
465
+ const aggregator = details.aggregator;
466
+ const aggregatorFailed = aggregator ? isResultError(aggregator) : false;
467
+ const aggregatorRunning = aggregator
468
+ ? !aggregatorFailed && (isPartial || aggregator.exitCode === -1)
469
+ : false;
470
+ const pendingSuccessfulSettlement =
471
+ isPartial && !aggregator && running === 0 && failCount === 0;
472
+ const isRunning = running > 0 || aggregatorRunning || pendingSuccessfulSettlement;
473
+ const icon = isRunning
474
+ ? theme.fg("warning", "⏳")
475
+ : failCount > 0 || aggregatorFailed
476
+ ? theme.fg("warning", "◐")
477
+ : theme.fg("success", "✓");
478
+ const status = isRunning
479
+ ? aggregatorRunning
480
+ ? `${successCount + failCount}/${details.results.length} done, fan-in running`
481
+ : running > 0
482
+ ? `${successCount + failCount}/${details.results.length} done, ${running} running`
483
+ : `${successCount + failCount}/${details.results.length} done, running`
484
+ : aggregator
485
+ ? `${successCount}/${details.results.length} tasks + fan-in`
486
+ : `${successCount}/${details.results.length} tasks`;
487
+
488
+ if (expanded && !isRunning) {
489
+ const container = new Container();
490
+ container.addChild(
491
+ new Text(
492
+ `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
493
+ 0,
494
+ 0,
495
+ ),
496
+ );
310
497
 
311
- for (const r of details.results) {
312
- const rIcon = !isResultError(r) ? theme.fg("success", "✓") : theme.fg("error", "✗");
313
- const displayItems = getDisplayItems(r.messages);
314
- const finalOutput = getResultFinalOutput(r);
498
+ for (const r of details.results) {
499
+ const rFailed = isResultError(r);
500
+ const rIcon = rFailed
501
+ ? theme.fg("error", "✗")
502
+ : resultIsRunning(r)
503
+ ? theme.fg("warning", "⏳")
504
+ : theme.fg("success", "✓");
505
+ const displayItems = getDisplayItems(r.messages);
506
+ const finalOutput = getResultFinalOutput(r);
315
507
 
316
- container.addChild(new Spacer(1));
508
+ container.addChild(new Spacer(1));
509
+ container.addChild(
510
+ new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
511
+ );
512
+ container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
513
+ if (rFailed && r.errorMessage)
514
+ container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
515
+
516
+ // Show tool calls
517
+ for (const item of displayItems) {
518
+ if (item.type === "toolCall") {
317
519
  container.addChild(
318
520
  new Text(
319
- `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
521
+ theme.fg("muted", "→ ") +
522
+ formatToolCall(item.name, item.args, theme.fg.bind(theme)),
320
523
  0,
321
524
  0,
322
525
  ),
323
526
  );
324
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
325
-
326
- // Show tool calls
327
- for (const item of displayItems) {
328
- if (item.type === "toolCall") {
329
- container.addChild(
330
- new Text(
331
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
332
- 0,
333
- 0,
334
- ),
335
- );
336
- }
337
- }
338
-
339
- // Show final output as markdown
340
- if (finalOutput) {
341
- container.addChild(new Spacer(1));
342
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
343
- }
344
-
345
- const stepUsage = formatUsageStats(r.usage, r.model, r.thinkingLevel);
346
- if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
347
- }
348
-
349
- const usageStr = formatUsageStats(aggregateUsage(details.results));
350
- if (usageStr) {
351
- container.addChild(new Spacer(1));
352
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
353
527
  }
354
- return container;
355
528
  }
356
529
 
357
- // Collapsed view
358
- let text =
359
- icon +
360
- " " +
361
- theme.fg("toolTitle", theme.bold("chain ")) +
362
- theme.fg("accent", `${successCount}/${details.results.length} steps`);
363
- for (const r of details.results) {
364
- const rIcon = !isResultError(r) ? theme.fg("success", "✓") : theme.fg("error", "✗");
365
- const displayItems = getDisplayItems(r.messages);
366
- text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
367
- if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
368
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
530
+ // Show final output as markdown
531
+ if (finalOutput) {
532
+ container.addChild(new Spacer(1));
533
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
369
534
  }
370
- const usageStr = formatUsageStats(aggregateUsage(details.results));
371
- if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
372
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
373
- return new Text(text, 0, 0);
535
+
536
+ const taskUsage = formatResultUsageStats(r);
537
+ if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
374
538
  }
375
539
 
376
- if (details.mode === "parallel") {
377
- const running = details.results.filter((result) => result.exitCode === -1).length;
378
- const successCount = details.results.filter(
379
- (result) => result.exitCode !== -1 && !isResultError(result),
380
- ).length;
381
- const failCount = details.results.filter(
382
- (result) => result.exitCode !== -1 && isResultError(result),
383
- ).length;
384
- const aggregator = details.aggregator;
385
- const aggregatorRunning = aggregator?.exitCode === -1;
386
- const aggregatorFailed = aggregator
387
- ? aggregator.exitCode !== -1 && isResultError(aggregator)
388
- : false;
389
- const isRunning = running > 0 || aggregatorRunning;
390
- const icon = isRunning
391
- ? theme.fg("warning", "⏳")
392
- : failCount > 0 || aggregatorFailed
393
- ? theme.fg("warning", "◐")
540
+ if (aggregator) {
541
+ const rIcon = aggregatorFailed
542
+ ? theme.fg("error", "✗")
543
+ : aggregatorRunning
544
+ ? theme.fg("warning", "⏳")
394
545
  : theme.fg("success", "✓");
395
- const status = isRunning
396
- ? aggregatorRunning
397
- ? `${successCount + failCount}/${details.results.length} done, fan-in running`
398
- : `${successCount + failCount}/${details.results.length} done, ${running} running`
399
- : aggregator
400
- ? `${successCount}/${details.results.length} tasks + fan-in`
401
- : `${successCount}/${details.results.length} tasks`;
402
-
403
- if (expanded && !isRunning) {
404
- const container = new Container();
546
+ const displayItems = getDisplayItems(aggregator.messages);
547
+ const finalOutput = getResultFinalOutput(aggregator);
548
+
549
+ container.addChild(new Spacer(1));
550
+ container.addChild(
551
+ new Text(
552
+ `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon}`,
553
+ 0,
554
+ 0,
555
+ ),
556
+ );
557
+ container.addChild(
558
+ new Text(theme.fg("muted", "Task: ") + theme.fg("dim", aggregator.task), 0, 0),
559
+ );
560
+ if (aggregatorFailed && aggregator.errorMessage)
405
561
  container.addChild(
406
- new Text(
407
- `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
408
- 0,
409
- 0,
410
- ),
562
+ new Text(theme.fg("error", `Error: ${aggregator.errorMessage}`), 0, 0),
411
563
  );
412
-
413
- for (const r of details.results) {
414
- const rIcon = !isResultError(r) ? theme.fg("success", "✓") : theme.fg("error", "✗");
415
- const displayItems = getDisplayItems(r.messages);
416
- const finalOutput = getResultFinalOutput(r);
417
-
418
- container.addChild(new Spacer(1));
419
- container.addChild(
420
- new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
421
- );
422
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
423
-
424
- // Show tool calls
425
- for (const item of displayItems) {
426
- if (item.type === "toolCall") {
427
- container.addChild(
428
- new Text(
429
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
430
- 0,
431
- 0,
432
- ),
433
- );
434
- }
435
- }
436
-
437
- // Show final output as markdown
438
- if (finalOutput) {
439
- container.addChild(new Spacer(1));
440
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
441
- }
442
-
443
- const taskUsage = formatUsageStats(r.usage, r.model, r.thinkingLevel);
444
- if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
445
- }
446
-
447
- if (aggregator) {
448
- const rIcon = !isResultError(aggregator)
449
- ? theme.fg("success", "✓")
450
- : theme.fg("error", "✗");
451
- const displayItems = getDisplayItems(aggregator.messages);
452
- const finalOutput = getResultFinalOutput(aggregator);
453
-
454
- container.addChild(new Spacer(1));
564
+ for (const item of displayItems) {
565
+ if (item.type === "toolCall") {
455
566
  container.addChild(
456
567
  new Text(
457
- `${theme.fg("muted", "─── fan-in → ") + theme.fg("accent", aggregator.agent)} ${rIcon}`,
568
+ theme.fg("muted", "→ ") +
569
+ formatToolCall(item.name, item.args, theme.fg.bind(theme)),
458
570
  0,
459
571
  0,
460
572
  ),
461
573
  );
462
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", aggregator.task), 0, 0));
463
- for (const item of displayItems) {
464
- if (item.type === "toolCall") {
465
- container.addChild(
466
- new Text(
467
- theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
468
- 0,
469
- 0,
470
- ),
471
- );
472
- }
473
- }
474
- if (finalOutput) {
475
- container.addChild(new Spacer(1));
476
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
477
- }
478
- const fanInUsage = formatUsageStats(aggregator.usage, aggregator.model, aggregator.thinkingLevel);
479
- if (fanInUsage) container.addChild(new Text(theme.fg("dim", fanInUsage), 0, 0));
480
- }
481
-
482
- const usageResults = aggregator ? [...details.results, aggregator] : details.results;
483
- const usageStr = formatUsageStats(aggregateUsage(usageResults));
484
- if (usageStr) {
485
- container.addChild(new Spacer(1));
486
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
487
574
  }
488
- return container;
489
- }
490
-
491
- // Collapsed view (or still running)
492
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
493
- for (const r of details.results) {
494
- const rIcon =
495
- r.exitCode === -1
496
- ? theme.fg("warning", "⏳")
497
- : !isResultError(r)
498
- ? theme.fg("success", "✓")
499
- : theme.fg("error", "✗");
500
- const displayItems = getDisplayItems(r.messages);
501
- text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
502
- if (displayItems.length === 0)
503
- text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
504
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
505
575
  }
506
- if (aggregator) {
507
- const rIcon =
508
- aggregator.exitCode === -1
509
- ? theme.fg("warning", "⏳")
510
- : !isResultError(aggregator)
511
- ? theme.fg("success", "✓")
512
- : theme.fg("error", "✗");
513
- const displayItems = getDisplayItems(aggregator.messages);
514
- text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon}`;
515
- if (displayItems.length === 0)
516
- text += `\n${theme.fg("muted", aggregator.exitCode === -1 ? "(running...)" : "(no output)")}`;
517
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
518
- }
519
- if (!isRunning) {
520
- const usageResults = aggregator ? [...details.results, aggregator] : details.results;
521
- const usageStr = formatUsageStats(aggregateUsage(usageResults));
522
- if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
576
+ if (finalOutput) {
577
+ container.addChild(new Spacer(1));
578
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
523
579
  }
524
- if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
525
- return new Text(text, 0, 0);
580
+ const fanInUsage = formatResultUsageStats(aggregator);
581
+ if (fanInUsage) container.addChild(new Text(theme.fg("dim", fanInUsage), 0, 0));
582
+ }
583
+
584
+ const usageResults = aggregator ? [...details.results, aggregator] : details.results;
585
+ const usageStr = formatUsageStats(aggregateUsage(usageResults));
586
+ if (usageStr) {
587
+ container.addChild(new Spacer(1));
588
+ container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
526
589
  }
590
+ return container;
591
+ }
592
+
593
+ // Collapsed view (or still running)
594
+ let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
595
+ for (const r of details.results) {
596
+ const rFailed = isResultError(r);
597
+ const rRunning = resultIsRunning(r);
598
+ const rIcon = rFailed
599
+ ? theme.fg("error", "✗")
600
+ : rRunning
601
+ ? theme.fg("warning", "⏳")
602
+ : theme.fg("success", "✓");
603
+ const collapsed = getCollapsedDisplayItems(r);
604
+ const finalOutput = getResultFinalOutput(r).trim();
605
+ text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
606
+ if (rFailed && r.errorMessage)
607
+ text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
608
+ if (collapsed.items.length > 0)
609
+ text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
610
+ else if (rRunning) text += `\n${theme.fg("muted", "(running...)")}`;
611
+ else if (finalOutput)
612
+ text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 3).join("\n"))}`;
613
+ else if (!rFailed || !r.errorMessage) text += `\n${theme.fg("muted", "(no output)")}`;
614
+ }
615
+ if (aggregator) {
616
+ const rIcon = aggregatorFailed
617
+ ? theme.fg("error", "✗")
618
+ : aggregatorRunning
619
+ ? theme.fg("warning", "⏳")
620
+ : theme.fg("success", "✓");
621
+ const collapsed = getCollapsedDisplayItems(aggregator);
622
+ const finalOutput = getResultFinalOutput(aggregator).trim();
623
+ text += `\n\n${theme.fg("muted", "─── fan-in → ")}${theme.fg("accent", aggregator.agent)} ${rIcon}`;
624
+ if (aggregatorFailed && aggregator.errorMessage)
625
+ text += `\n${theme.fg("error", `Error: ${aggregator.errorMessage}`)}`;
626
+ if (collapsed.items.length > 0)
627
+ text += `\n${renderDisplayItems(collapsed.items, 5, collapsed.total)}`;
628
+ else if (aggregatorRunning) text += `\n${theme.fg("muted", "(running...)")}`;
629
+ else if (finalOutput)
630
+ text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 3).join("\n"))}`;
631
+ else if (!aggregatorFailed || !aggregator.errorMessage)
632
+ text += `\n${theme.fg("muted", "(no output)")}`;
633
+ }
634
+ if (!isRunning) {
635
+ const usageResults = aggregator ? [...details.results, aggregator] : details.results;
636
+ const usageStr = formatUsageStats(aggregateUsage(usageResults));
637
+ if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
638
+ }
639
+ if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
640
+ return new Text(text, 0, 0);
641
+ }
527
642
 
528
- const text = result.content[0];
529
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
643
+ const text = result.content[0];
644
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
530
645
  }
package/src/runner.ts CHANGED
@@ -5,12 +5,7 @@ import * as path from "node:path";
5
5
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
6
6
  import type { Message } from "@earendil-works/pi-ai";
7
7
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
8
- import type {
9
- AgentConfig,
10
- AgentScope,
11
- AgentSource,
12
- SubagentThinkingLevel,
13
- } from "./agents.js";
8
+ import type { AgentConfig, AgentScope, AgentSource, SubagentThinkingLevel } from "./agents.js";
14
9
  import {
15
10
  appendBounded,
16
11
  DEFAULT_MAX_CONTEXT_BYTES,
@@ -32,6 +27,13 @@ export interface UsageStats {
32
27
  contextTokens: number;
33
28
  turns: number;
34
29
  }
30
+ export type RecentActivityItem =
31
+ | { type: "text"; text: string }
32
+ | { type: "toolCall"; name: string; args: Record<string, unknown> };
33
+
34
+ const MAX_RECENT_ACTIVITY_ITEMS = 10;
35
+ const MAX_RECENT_ACTIVITY_BYTES = 8 * 1024;
36
+ const MAX_RECENT_ACTIVITY_ARGUMENT_BYTES = 1024;
35
37
 
36
38
  export interface SingleResult {
37
39
  agent: string;
@@ -42,6 +44,10 @@ export interface SingleResult {
42
44
  stderr: string;
43
45
  usage: UsageStats;
44
46
  model?: string;
47
+ actualProvider?: string;
48
+ actualModel?: string;
49
+ recentActivity?: RecentActivityItem[];
50
+ recentActivityTotal?: number;
45
51
  thinkingLevel?: SubagentThinkingLevel;
46
52
  stopReason?: string;
47
53
  errorMessage?: string;
@@ -89,6 +95,8 @@ export function getResultFinalOutput(result: SingleResult): string {
89
95
  export function isResultError(result: SingleResult): boolean {
90
96
  return (
91
97
  (result.exitCode !== 0 && result.exitCode !== -1) ||
98
+ result.timedOut === true ||
99
+ result.stopReason === "timeout" ||
92
100
  result.stopReason === "error" ||
93
101
  result.stopReason === "aborted"
94
102
  );
@@ -102,27 +110,124 @@ export function formatResultFailure(result: SingleResult): string {
102
110
  return truncateUtf8(combined, DEFAULT_MAX_CONTEXT_BYTES).text;
103
111
  }
104
112
 
105
- function boundMessageText(message: Message, maxBytes: number): { message: Message; bytes: number; truncated: boolean } {
106
- const serializedBytes = Buffer.byteLength(JSON.stringify(message), "utf8");
107
- if (serializedBytes <= maxBytes) return { message, bytes: serializedBytes, truncated: false };
108
- if (!Array.isArray(message.content)) return { message, bytes: Math.min(serializedBytes, maxBytes), truncated: true };
109
- let remaining = maxBytes;
110
- const content = message.content
111
- .filter((part) => part.type === "text")
112
- .map((part) => {
113
- const bounded = truncateUtf8(part.text, remaining);
114
- remaining = Math.max(0, remaining - Buffer.byteLength(bounded.text, "utf8"));
115
- return { ...part, text: bounded.text };
116
- });
117
- const boundedMessage = { ...message, content } as Message;
118
- return {
119
- message: boundedMessage,
120
- bytes: Math.min(Buffer.byteLength(JSON.stringify(boundedMessage), "utf8"), maxBytes),
121
- truncated: true,
113
+ function boundMessageText(
114
+ message: Message,
115
+ maxBytes: number,
116
+ ): { message?: Message; bytes: number; truncated: boolean } {
117
+ const originalBytes = Buffer.byteLength(JSON.stringify(message), "utf8");
118
+ if (Number.isSafeInteger(maxBytes) && maxBytes >= 0 && originalBytes <= maxBytes) {
119
+ return { message, bytes: originalBytes, truncated: false };
120
+ }
121
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) return { bytes: 0, truncated: true };
122
+
123
+ const content: Array<
124
+ | { type: "text"; text: string }
125
+ | { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> }
126
+ > = [];
127
+ const bounded = () => ({ ...message, content }) as Message;
128
+ const fits = () => Buffer.byteLength(JSON.stringify(bounded()), "utf8") <= maxBytes;
129
+ const addText = (text: string, prepend = false) => {
130
+ if (!text.trim()) return;
131
+ const part = { type: "text" as const, text: "" };
132
+ if (prepend) content.unshift(part);
133
+ else content.push(part);
134
+ if (!fits()) {
135
+ content.splice(content.indexOf(part), 1);
136
+ return;
137
+ }
138
+ let low = 0;
139
+ let high = Buffer.byteLength(text, "utf8");
140
+ while (low < high) {
141
+ const middle = Math.ceil((low + high) / 2);
142
+ part.text = truncateUtf8(text, middle).text;
143
+ if (fits()) low = middle;
144
+ else high = middle - 1;
145
+ }
146
+ part.text = truncateUtf8(text, low).text;
147
+ if (!part.text.trim()) content.splice(content.indexOf(part), 1);
148
+ };
149
+ const addToolCall = (part: Extract<Message["content"][number], { type: "toolCall" }>) => {
150
+ const toolCall = {
151
+ type: "toolCall" as const,
152
+ id: part.id,
153
+ name: part.name,
154
+ arguments: part.arguments,
155
+ };
156
+ content.unshift(toolCall);
157
+ if (fits()) return;
158
+ const arguments_: Record<string, unknown> = {};
159
+ for (const key of ["command", "path", "file_path", "pattern", "url"]) {
160
+ const value = part.arguments[key];
161
+ if (typeof value === "string") arguments_[key] = truncateUtf8(value, 256).text;
162
+ }
163
+ toolCall.arguments = arguments_;
164
+ if (fits()) return;
165
+ toolCall.arguments = {};
166
+ if (!fits()) content.shift();
167
+ };
168
+
169
+ if (message.role === "assistant") {
170
+ for (let index = message.content.length - 1; index >= 0; index--) {
171
+ const part = message.content[index];
172
+ if (part.type === "text") addText(part.text, true);
173
+ else if (part.type === "toolCall") addToolCall(part);
174
+ }
175
+ } else {
176
+ for (const part of message.content) {
177
+ if (typeof part === "object" && part && part.type === "text") addText(part.text);
178
+ }
179
+ }
180
+
181
+ if (content.length === 0) return { bytes: 0, truncated: true };
182
+ const result = bounded();
183
+ const bytes = Buffer.byteLength(JSON.stringify(result), "utf8");
184
+ return { message: result, bytes, truncated: true };
185
+ }
186
+ function compactRecentActivityArguments(args: Record<string, unknown>): Record<string, unknown> {
187
+ if (Buffer.byteLength(JSON.stringify(args), "utf8") <= MAX_RECENT_ACTIVITY_ARGUMENT_BYTES)
188
+ return args;
189
+ const compact: Record<string, unknown> = {};
190
+ for (const key of ["command", "path", "file_path", "pattern", "url", "selector"]) {
191
+ const value = args[key];
192
+ if (typeof value === "string") compact[key] = truncateUtf8(value, 256).text;
193
+ else if (typeof value === "number" || typeof value === "boolean") compact[key] = value;
194
+ }
195
+ return compact;
196
+ }
197
+
198
+ function appendRecentActivity(result: SingleResult, message: Message): void {
199
+ if (message.role !== "assistant") return;
200
+ const append = (item: RecentActivityItem) => {
201
+ result.recentActivity ??= [];
202
+ result.recentActivityTotal = (result.recentActivityTotal ?? 0) + 1;
203
+ result.recentActivity.push(item);
204
+ if (result.recentActivity.length > MAX_RECENT_ACTIVITY_ITEMS) {
205
+ result.recentActivity.splice(0, result.recentActivity.length - MAX_RECENT_ACTIVITY_ITEMS);
206
+ }
207
+ while (
208
+ Buffer.byteLength(JSON.stringify(result.recentActivity), "utf8") > MAX_RECENT_ACTIVITY_BYTES
209
+ ) {
210
+ result.recentActivity.shift();
211
+ }
122
212
  };
213
+ for (const part of message.content) {
214
+ if (part.type === "text") {
215
+ const text = part.text.trim();
216
+ if (text) append({ type: "text", text: truncateUtf8(text, 1024).text });
217
+ } else if (part.type === "toolCall") {
218
+ append({
219
+ type: "toolCall",
220
+ name: part.name,
221
+ args: compactRecentActivityArguments(part.arguments),
222
+ });
223
+ }
224
+ }
123
225
  }
124
226
 
125
- export function buildFanInContext(results: SingleResult[], maxBytes = DEFAULT_MAX_CONTEXT_BYTES): string {
227
+ export function buildFanInContext(
228
+ results: SingleResult[],
229
+ maxBytes = DEFAULT_MAX_CONTEXT_BYTES,
230
+ ): string {
126
231
  const text = results
127
232
  .map((result, index) => {
128
233
  const failed = isResultError(result);
@@ -170,7 +275,10 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
170
275
  return results;
171
276
  }
172
277
 
173
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
278
+ async function writePromptToTempFile(
279
+ agentName: string,
280
+ prompt: string,
281
+ ): Promise<{ dir: string; filePath: string }> {
174
282
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
175
283
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
176
284
  const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
@@ -231,8 +339,15 @@ function signalProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals):
231
339
  }
232
340
  }
233
341
 
234
- export function terminateProcess(proc: ReturnType<typeof spawn>, graceMs = KILL_GRACE_MS): () => void {
235
- let closed = proc.exitCode !== null || proc.signalCode !== null;
342
+ export function terminateProcess(
343
+ proc: ReturnType<typeof spawn>,
344
+ graceMs = KILL_GRACE_MS,
345
+ ): () => void {
346
+ const leaderExited = proc.exitCode !== null || proc.signalCode !== null;
347
+ const capturedOutputClosed = [proc.stdout, proc.stderr].every(
348
+ (stream) => !stream || stream.readableEnded || stream.destroyed,
349
+ );
350
+ let closed = leaderExited && capturedOutputClosed;
236
351
  const onClose = () => {
237
352
  closed = true;
238
353
  };
@@ -275,7 +390,15 @@ export async function runSingleAgent(
275
390
  exitCode: 1,
276
391
  messages: [],
277
392
  stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
278
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
393
+ usage: {
394
+ input: 0,
395
+ output: 0,
396
+ cacheRead: 0,
397
+ cacheWrite: 0,
398
+ cost: 0,
399
+ contextTokens: 0,
400
+ turns: 0,
401
+ },
279
402
  thinkingLevel,
280
403
  step,
281
404
  finalOutput: "",
@@ -295,7 +418,15 @@ export async function runSingleAgent(
295
418
  exitCode: 0,
296
419
  messages: [],
297
420
  stderr: "",
298
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
421
+ usage: {
422
+ input: 0,
423
+ output: 0,
424
+ cacheRead: 0,
425
+ cacheWrite: 0,
426
+ cost: 0,
427
+ contextTokens: 0,
428
+ turns: 0,
429
+ },
299
430
  model: agent.model ?? undefined,
300
431
  thinkingLevel,
301
432
  step,
@@ -394,21 +525,37 @@ export async function runSingleAgent(
394
525
  },
395
526
  });
396
527
  } catch (error) {
397
- currentResult.stderr = setErrorMessage(error instanceof Error ? error.message : String(error));
528
+ currentResult.stderr = setErrorMessage(
529
+ error instanceof Error ? error.message : String(error),
530
+ );
398
531
  finish(1);
399
532
  return;
400
533
  }
401
534
 
402
- let capturedMessageBytes = 0;
403
535
  const addMessage = (msg: Message) => {
404
- if (currentResult.messages.length >= DEFAULT_MAX_MESSAGES) {
536
+ const boundedMessage = boundMessageText(msg, DEFAULT_MAX_OUTPUT_BYTES - 2);
537
+ currentResult.truncated ||= boundedMessage.truncated;
538
+ if (!boundedMessage.message) return;
539
+ while (
540
+ currentResult.messages.length >= DEFAULT_MAX_MESSAGES ||
541
+ Buffer.byteLength(
542
+ JSON.stringify([...currentResult.messages, boundedMessage.message]),
543
+ "utf8",
544
+ ) > DEFAULT_MAX_OUTPUT_BYTES
545
+ ) {
546
+ const removed = currentResult.messages.shift();
547
+ if (!removed) break;
548
+ currentResult.truncated = true;
549
+ }
550
+ if (
551
+ Buffer.byteLength(
552
+ JSON.stringify([...currentResult.messages, boundedMessage.message]),
553
+ "utf8",
554
+ ) > DEFAULT_MAX_OUTPUT_BYTES
555
+ ) {
405
556
  currentResult.truncated = true;
406
557
  return;
407
558
  }
408
- const remaining = Math.max(0, DEFAULT_MAX_OUTPUT_BYTES - capturedMessageBytes);
409
- const boundedMessage = boundMessageText(msg, remaining);
410
- capturedMessageBytes += boundedMessage.bytes;
411
- currentResult.truncated ||= boundedMessage.truncated;
412
559
  currentResult.messages.push(boundedMessage.message);
413
560
  };
414
561
  const processEvent = (raw: unknown) => {
@@ -424,6 +571,7 @@ export async function runSingleAgent(
424
571
  terminalAssistantOutput = output.text;
425
572
  }
426
573
  }
574
+ appendRecentActivity(currentResult, msg);
427
575
  addMessage(msg);
428
576
  if (msg.role === "assistant") {
429
577
  currentResult.usage.turns++;
@@ -436,7 +584,9 @@ export async function runSingleAgent(
436
584
  currentResult.usage.cost += usage.cost?.total || 0;
437
585
  currentResult.usage.contextTokens = usage.totalTokens || 0;
438
586
  }
439
- if (!currentResult.model && msg.model) currentResult.model = msg.model;
587
+ if (msg.provider) currentResult.actualProvider = msg.provider;
588
+ if (msg.responseModel ?? msg.model)
589
+ currentResult.actualModel = msg.responseModel ?? msg.model;
440
590
  if (msg.stopReason) currentResult.stopReason = msg.stopReason;
441
591
  if (msg.errorMessage) setErrorMessage(msg.errorMessage);
442
592
  }
@@ -475,7 +625,11 @@ export async function runSingleAgent(
475
625
 
476
626
  proc.stdout?.on("data", (data) => decoder.push(data));
477
627
  proc.stderr?.on("data", (data) => {
478
- const bounded = appendBounded(currentResult.stderr, data.toString(), DEFAULT_MAX_STDERR_BYTES);
628
+ const bounded = appendBounded(
629
+ currentResult.stderr,
630
+ data.toString(),
631
+ DEFAULT_MAX_STDERR_BYTES,
632
+ );
479
633
  currentResult.stderr = bounded.text;
480
634
  currentResult.truncated ||= bounded.truncated;
481
635
  });