oira666_pi-subagent 0.2.21 → 0.2.24

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/shared.ts CHANGED
@@ -1,97 +1,97 @@
1
- /**
2
- * Shared constants and utilities used by the subprocess runner.
3
- *
4
- * Single source of truth for parallel-execution defaults and helpers.
5
- */
6
-
7
- // ---------------------------------------------------------------------------
8
- // Parallel execution limits
9
- // ---------------------------------------------------------------------------
10
-
11
- export const DEFAULT_MAX_PARALLEL_TASKS = 30;
12
- export const DEFAULT_MAX_CONCURRENCY = 8;
13
- export const PARALLEL_HEARTBEAT_MS = 1000;
14
- export const SUBAGENT_MAX_PARALLEL_TASKS_ENV = "PI_SUBAGENT_MAX_PARALLEL_TASKS";
15
- export const SUBAGENT_MAX_CONCURRENCY_ENV = "PI_SUBAGENT_MAX_CONCURRENCY";
16
- export const RESUME_PROVIDER = "pi-subagent-resume";
17
- export const RESUME_MODEL_ID = "synthetic-tool-call";
18
-
19
- // ---------------------------------------------------------------------------
20
- // Shared helpers
21
- // ---------------------------------------------------------------------------
22
-
23
- export function parseBoolean(raw: unknown): boolean | null {
24
- if (typeof raw === "boolean") return raw;
25
- if (typeof raw !== "string") return null;
26
- const normalized = raw.trim().toLowerCase();
27
- if (["1", "true", "yes", "on"].includes(normalized)) return true;
28
- if (["0", "false", "no", "off"].includes(normalized)) return false;
29
- return null;
30
- }
31
-
32
- /** Parse a string into a non-negative safe integer, or null on failure. */
33
- export function parseNonNegativeInt(raw: unknown): number | null {
34
- if (typeof raw !== "string") return null;
35
- const trimmed = raw.trim();
36
- if (!/^\d+$/.test(trimmed)) return null;
37
- const parsed = Number(trimmed);
38
- return Number.isSafeInteger(parsed) ? parsed : null;
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // Object utilities
43
- // ---------------------------------------------------------------------------
44
-
45
- /** Returns true when `value` is a plain (non-array, non-null) object. */
46
- function isPlainObject(value: unknown): value is Record<string, unknown> {
47
- return typeof value === "object" && value !== null && !Array.isArray(value);
48
- }
49
-
50
- /**
51
- * Recursively merge `source` into a deep clone of `target`.
52
- *
53
- * Rules:
54
- * - Plain-object values are merged recursively.
55
- * - All other values (primitives, arrays, class instances) from `source`
56
- * overwrite the corresponding key in `target`.
57
- * - Keys present only in `target` are preserved unchanged.
58
- * - The original `target` and `source` objects are never mutated.
59
- */
60
- export function deepMerge(target: object, source: object): object {
61
- const output: Record<string, unknown> = { ...(target as Record<string, unknown>) };
62
- for (const [key, srcVal] of Object.entries(source as Record<string, unknown>)) {
63
- const tgtVal = (output as Record<string, unknown>)[key];
64
- if (isPlainObject(srcVal) && isPlainObject(tgtVal)) {
65
- output[key] = deepMerge(tgtVal, srcVal);
66
- } else {
67
- output[key] = srcVal;
68
- }
69
- }
70
- return output;
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
-
75
- /**
76
- * Map over items with a bounded number of concurrent async workers.
77
- * Order of results matches order of input regardless of completion order.
78
- */
79
- export async function mapConcurrent<TIn, TOut>(
80
- items: TIn[],
81
- concurrency: number,
82
- fn: (item: TIn, index: number) => Promise<TOut>,
83
- ): Promise<TOut[]> {
84
- if (items.length === 0) return [];
85
- const limit = Math.max(1, Math.min(concurrency, items.length));
86
- const results: TOut[] = new Array(items.length);
87
- let nextIndex = 0;
88
- const worker = async () => {
89
- while (true) {
90
- const i = nextIndex++;
91
- if (i >= items.length) return;
92
- results[i] = await fn(items[i], i);
93
- }
94
- };
95
- await Promise.all(Array.from({ length: limit }, () => worker()));
96
- return results;
97
- }
1
+ /**
2
+ * Shared constants and utilities used by the subprocess runner.
3
+ *
4
+ * Single source of truth for parallel-execution defaults and helpers.
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Parallel execution limits
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export const DEFAULT_MAX_PARALLEL_TASKS = 30;
12
+ export const DEFAULT_MAX_CONCURRENCY = 8;
13
+ export const PARALLEL_HEARTBEAT_MS = 1000;
14
+ export const SUBAGENT_MAX_PARALLEL_TASKS_ENV = "PI_SUBAGENT_MAX_PARALLEL_TASKS";
15
+ export const SUBAGENT_MAX_CONCURRENCY_ENV = "PI_SUBAGENT_MAX_CONCURRENCY";
16
+ export const RESUME_PROVIDER = "pi-subagent-resume";
17
+ export const RESUME_MODEL_ID = "synthetic-tool-call";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Shared helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export function parseBoolean(raw: unknown): boolean | null {
24
+ if (typeof raw === "boolean") return raw;
25
+ if (typeof raw !== "string") return null;
26
+ const normalized = raw.trim().toLowerCase();
27
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
28
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
29
+ return null;
30
+ }
31
+
32
+ /** Parse a string into a non-negative safe integer, or null on failure. */
33
+ export function parseNonNegativeInt(raw: unknown): number | null {
34
+ if (typeof raw !== "string") return null;
35
+ const trimmed = raw.trim();
36
+ if (!/^\d+$/.test(trimmed)) return null;
37
+ const parsed = Number(trimmed);
38
+ return Number.isSafeInteger(parsed) ? parsed : null;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Object utilities
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Returns true when `value` is a plain (non-array, non-null) object. */
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ /**
51
+ * Recursively merge `source` into a deep clone of `target`.
52
+ *
53
+ * Rules:
54
+ * - Plain-object values are merged recursively.
55
+ * - All other values (primitives, arrays, class instances) from `source`
56
+ * overwrite the corresponding key in `target`.
57
+ * - Keys present only in `target` are preserved unchanged.
58
+ * - The original `target` and `source` objects are never mutated.
59
+ */
60
+ export function deepMerge(target: object, source: object): object {
61
+ const output: Record<string, unknown> = { ...(target as Record<string, unknown>) };
62
+ for (const [key, srcVal] of Object.entries(source as Record<string, unknown>)) {
63
+ const tgtVal = (output as Record<string, unknown>)[key];
64
+ if (isPlainObject(srcVal) && isPlainObject(tgtVal)) {
65
+ output[key] = deepMerge(tgtVal, srcVal);
66
+ } else {
67
+ output[key] = srcVal;
68
+ }
69
+ }
70
+ return output;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Map over items with a bounded number of concurrent async workers.
77
+ * Order of results matches order of input regardless of completion order.
78
+ */
79
+ export async function mapConcurrent<TIn, TOut>(
80
+ items: TIn[],
81
+ concurrency: number,
82
+ fn: (item: TIn, index: number) => Promise<TOut>,
83
+ ): Promise<TOut[]> {
84
+ if (items.length === 0) return [];
85
+ const limit = Math.max(1, Math.min(concurrency, items.length));
86
+ const results: TOut[] = new Array(items.length);
87
+ let nextIndex = 0;
88
+ const worker = async () => {
89
+ while (true) {
90
+ const i = nextIndex++;
91
+ if (i >= items.length) return;
92
+ results[i] = await fn(items[i], i);
93
+ }
94
+ };
95
+ await Promise.all(Array.from({ length: limit }, () => worker()));
96
+ return results;
97
+ }
package/tree.ts ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Pure tree-building and line-rendering logic for the subagent TUI.
3
+ *
4
+ * This module deliberately has NO dependency on `@mariozechner/pi-tui` so it
5
+ * can be unit-tested without the (peer) TUI package installed. `render.ts`
6
+ * imports from here and wraps the produced lines in pi-tui Containers/Text.
7
+ */
8
+
9
+ import * as os from "node:os";
10
+ import {
11
+ type LiveLogEntry,
12
+ type NestedSubagentResult,
13
+ type SingleResult,
14
+ type SubagentDetails,
15
+ type UsageStats,
16
+ aggregateUsage,
17
+ getDisplayItems,
18
+ getFinalOutput,
19
+ getNestedSubagentResults,
20
+ isResultError,
21
+ isSubagentDetails,
22
+ } from "./types.js";
23
+
24
+ export const OUTPUT_PREVIEW_LINE_COUNT = 6;
25
+
26
+ let broadcastNumberingActive = false;
27
+
28
+ export function setBroadcastNumberingActive(active: boolean): void {
29
+ broadcastNumberingActive = active;
30
+ }
31
+
32
+ export type ThemeFg = (color: string, text: string) => string;
33
+ export type NodeStatus = "running" | "success" | "error";
34
+
35
+ export interface TreeNode {
36
+ label: string;
37
+ status: NodeStatus;
38
+ meta?: string;
39
+ task?: string;
40
+ outputPreview?: string[];
41
+ liveActivity?: LiveLogEntry[];
42
+ children: TreeNode[];
43
+ }
44
+
45
+ export interface TreeCounts {
46
+ total: number;
47
+ running: number;
48
+ success: number;
49
+ error: number;
50
+ finished: number;
51
+ }
52
+
53
+ interface PendingSubagentCall {
54
+ toolCallId: string;
55
+ tasks: Array<{ agent: string; task?: string }>;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Formatting helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export function formatTokens(count: number): string {
63
+ if (count < 1000) return count.toString();
64
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
65
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
66
+ return `${(count / 1000000).toFixed(1)}M`;
67
+ }
68
+
69
+ export function formatUsage(usage: Partial<UsageStats>, model?: string): string {
70
+ const parts: string[] = [];
71
+ const totalTokens =
72
+ (usage.input || 0) +
73
+ (usage.output || 0) +
74
+ (usage.cacheRead || 0) +
75
+ (usage.cacheWrite || 0);
76
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
77
+ if (totalTokens > 0) parts.push(`tok:${formatTokens(totalTokens)}`);
78
+ if (usage.input) parts.push(`in:${formatTokens(usage.input)}`);
79
+ if (usage.output) parts.push(`out:${formatTokens(usage.output)}`);
80
+ if (usage.cacheRead) parts.push(`cacheR:${formatTokens(usage.cacheRead)}`);
81
+ if (usage.cacheWrite) parts.push(`cacheW:${formatTokens(usage.cacheWrite)}`);
82
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
83
+ if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
84
+ if (model) parts.push(model);
85
+ return parts.join(" • ");
86
+ }
87
+
88
+ export function truncate(text: string, maxLen: number): string {
89
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
90
+ }
91
+
92
+ function splitOutputLines(text: string): string[] {
93
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
94
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
95
+ return lines;
96
+ }
97
+
98
+ function lastNonEmptyLines(text: string, limit: number): string[] {
99
+ return splitOutputLines(text)
100
+ .map((line) => line.trimEnd())
101
+ .filter((line) => line.trim().length > 0)
102
+ .slice(-limit);
103
+ }
104
+
105
+ export function statusEmoji(status: NodeStatus, theme: { fg: ThemeFg }): string {
106
+ switch (status) {
107
+ case "running":
108
+ return theme.fg("warning", "⏳");
109
+ case "error":
110
+ return theme.fg("error", "❌");
111
+ default:
112
+ return theme.fg("success", "✅");
113
+ }
114
+ }
115
+
116
+ function statusFromResult(result: SingleResult): NodeStatus {
117
+ if (result.exitCode === -1) return "running";
118
+ return isResultError(result) ? "error" : "success";
119
+ }
120
+
121
+ export function countNodes(nodes: TreeNode[]): TreeCounts {
122
+ const counts: TreeCounts = {
123
+ total: 0,
124
+ running: 0,
125
+ success: 0,
126
+ error: 0,
127
+ finished: 0,
128
+ };
129
+
130
+ const visit = (node: TreeNode) => {
131
+ counts.total++;
132
+ if (node.status === "running") counts.running++;
133
+ if (node.status === "success") counts.success++;
134
+ if (node.status === "error") counts.error++;
135
+ if (node.status !== "running") counts.finished++;
136
+ for (const child of node.children) visit(child);
137
+ };
138
+
139
+ for (const node of nodes) visit(node);
140
+ return counts;
141
+ }
142
+
143
+ export function hasNestedChildren(nodes: TreeNode[]): boolean {
144
+ return nodes.some((node) => node.children.length > 0 || hasNestedChildren(node.children));
145
+ }
146
+
147
+ function extractPendingSubagentCalls(messages: SingleResult["messages"]): PendingSubagentCall[] {
148
+ const calls: PendingSubagentCall[] = [];
149
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
150
+ const message = messages[messageIndex] as any;
151
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
152
+ for (let partIndex = 0; partIndex < message.content.length; partIndex++) {
153
+ const part = message.content[partIndex] as any;
154
+ if (part?.type !== "toolCall" || part?.name !== "subagent") continue;
155
+ const args = part.arguments && typeof part.arguments === "object" ? part.arguments : {};
156
+ const tasks = Array.isArray((args as any).tasks)
157
+ ? (args as any).tasks
158
+ .filter((task: any) => task && typeof task.agent === "string")
159
+ .map((task: any) => ({
160
+ agent: task.agent,
161
+ task: typeof task.task === "string" ? task.task : undefined,
162
+ }))
163
+ : [];
164
+ calls.push({
165
+ toolCallId:
166
+ typeof part.toolCallId === "string"
167
+ ? part.toolCallId
168
+ : typeof part.id === "string"
169
+ ? part.id
170
+ : `${messageIndex}:${partIndex}`,
171
+ tasks,
172
+ });
173
+ }
174
+ }
175
+ return calls;
176
+ }
177
+
178
+ function buildPendingNodes(call: PendingSubagentCall): TreeNode[] {
179
+ return call.tasks.map((task) => ({
180
+ label: task.agent,
181
+ status: "running",
182
+ task: task.task,
183
+ children: [],
184
+ }));
185
+ }
186
+
187
+ function buildNodesFromDetails(details: SubagentDetails): TreeNode[] {
188
+ return details.results.map((result) => buildResultNode(result));
189
+ }
190
+
191
+ function buildNodesFromNestedResult(nested: NestedSubagentResult): TreeNode[] {
192
+ return buildNodesFromDetails(nested.details);
193
+ }
194
+
195
+ function subagentCallSignature(call: PendingSubagentCall): string {
196
+ return JSON.stringify(call.tasks.map((task) => ({ agent: task.agent, task: task.task ?? "" })));
197
+ }
198
+
199
+ function nestedResultIsHealthy(nested: NestedSubagentResult | undefined): boolean {
200
+ if (!nested || nested.isError) return false;
201
+ return nested.details.results.every((result) => !isResultError(result));
202
+ }
203
+
204
+ function buildLiveDetailsSignature(details: SubagentDetails): string {
205
+ return JSON.stringify(details.results.map((result) => ({ agent: result.agent, task: result.task ?? "" })));
206
+ }
207
+
208
+ function findLiveNestedDetailsForCall(
209
+ result: SingleResult,
210
+ call: PendingSubagentCall,
211
+ usedLiveKeys: Set<string>,
212
+ ): SubagentDetails | undefined {
213
+ const live = result.liveNestedSubagents;
214
+ if (!live) return undefined;
215
+
216
+ const byId = live[call.toolCallId];
217
+ if (isSubagentDetails(byId)) {
218
+ usedLiveKeys.add(call.toolCallId);
219
+ return byId;
220
+ }
221
+
222
+ // Some pi versions pass a different internal id to Tool.execute than the id
223
+ // stored on the assistant toolCall part. Final toolResult messages still line
224
+ // up by id, but live `subagent_progress` events can be keyed differently. In
225
+ // that case match the running nested tree by the requested agent/task
226
+ // signature so grandchildren render live instead of falling back to static
227
+ // pending placeholders.
228
+ const signature = subagentCallSignature(call);
229
+ for (const [key, details] of Object.entries(live)) {
230
+ if (usedLiveKeys.has(key) || !isSubagentDetails(details)) continue;
231
+ if (buildLiveDetailsSignature(details) !== signature) continue;
232
+ usedLiveKeys.add(key);
233
+ return details;
234
+ }
235
+
236
+ // Fallback for cases where task text differs slightly by the time the child
237
+ // details are emitted. Still require the same agent sequence; count-only
238
+ // matching can attach progress to the wrong repeated/concurrent call.
239
+ const agentSignature = JSON.stringify(call.tasks.map((task) => task.agent));
240
+ for (const [key, details] of Object.entries(live)) {
241
+ if (usedLiveKeys.has(key) || !isSubagentDetails(details)) continue;
242
+ const liveAgentSignature = JSON.stringify(details.results.map((nestedResult) => nestedResult.agent));
243
+ if (liveAgentSignature !== agentSignature) continue;
244
+ usedLiveKeys.add(key);
245
+ return details;
246
+ }
247
+
248
+ return undefined;
249
+ }
250
+
251
+ function buildNestedChildren(result: SingleResult): TreeNode[] {
252
+ const parentIsRunning = result.exitCode === -1;
253
+ const completedByToolCallId = new Map<string, NestedSubagentResult>();
254
+ for (const nested of getNestedSubagentResults(result.messages)) {
255
+ completedByToolCallId.set(nested.toolCallId, nested);
256
+ }
257
+ const usedLiveKeys = new Set<string>();
258
+
259
+ const calls = extractPendingSubagentCalls(result.messages);
260
+ const laterResumeBySignature = new Map<string, number>();
261
+ calls.forEach((call, index) => {
262
+ const completed = completedByToolCallId.get(call.toolCallId);
263
+ // A resumed call has the same task signature as the interrupted call but a
264
+ // newer toolCallId. Prefer that newer running/successful tree over the old
265
+ // synthetic/aborted result so resumed nested subagents render in-place.
266
+ if (!completed || nestedResultIsHealthy(completed)) {
267
+ laterResumeBySignature.set(subagentCallSignature(call), index);
268
+ }
269
+ });
270
+
271
+ const nodes: TreeNode[] = [];
272
+ calls.forEach((call, index) => {
273
+ const completed = completedByToolCallId.get(call.toolCallId);
274
+ const newerEquivalent = laterResumeBySignature.get(subagentCallSignature(call));
275
+ if (
276
+ newerEquivalent !== undefined &&
277
+ newerEquivalent > index &&
278
+ (!completed || completed.isError || !nestedResultIsHealthy(completed))
279
+ ) {
280
+ return;
281
+ }
282
+
283
+ if (completed && isSubagentDetails(completed.details)) {
284
+ nodes.push(...buildNodesFromNestedResult(completed));
285
+ return;
286
+ }
287
+
288
+ const liveDetails = parentIsRunning
289
+ ? findLiveNestedDetailsForCall(result, call, usedLiveKeys)
290
+ : undefined;
291
+ if (liveDetails) {
292
+ nodes.push(...buildNodesFromDetails(liveDetails));
293
+ return;
294
+ }
295
+
296
+ // Unmatched subagent tool calls are useful while the parent is still
297
+ // running (they show live pending children). Once the parent finished,
298
+ // unmatched calls are stale history from an interrupted/resumed session and
299
+ // must not keep the whole tree in a perpetual "running" state.
300
+ if (parentIsRunning) nodes.push(...buildPendingNodes(call));
301
+ });
302
+ return nodes;
303
+ }
304
+
305
+ function formatToolArgPreview(toolName: string, args: Record<string, unknown>): string {
306
+ const shorten = (p: string) => {
307
+ const home = os.homedir();
308
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
309
+ };
310
+ const truncateTo = (s: string, n: number) =>
311
+ s.length > n ? s.slice(0, n) + "\u2026" : s;
312
+
313
+ switch (toolName) {
314
+ case "bash": {
315
+ const cmd = (args.command as string) || "";
316
+ return truncateTo(cmd.replace(/\s+/g, " "), 52);
317
+ }
318
+ case "read":
319
+ case "write":
320
+ case "edit":
321
+ return shorten(truncateTo((args.path ?? args.file_path ?? "") as string, 52));
322
+ case "grep":
323
+ return truncateTo(`/${args.pattern}/`, 30) +
324
+ (args.path ? ` in ${shorten(args.path as string)}` : "");
325
+ case "find":
326
+ return truncateTo((args.pattern ?? "*") as string, 30) +
327
+ (args.path ? ` in ${shorten(args.path as string)}` : "");
328
+ case "subagent": {
329
+ const tasks = (args.tasks as any[]) ?? [];
330
+ return tasks.map((t: any) => t.agent).join(", ");
331
+ }
332
+ default:
333
+ return "";
334
+ }
335
+ }
336
+
337
+ export function formatLiveLogEntry(
338
+ entry: LiveLogEntry,
339
+ theme: { fg: ThemeFg },
340
+ ): string {
341
+ switch (entry.kind) {
342
+ case "turn_start":
343
+ return theme.fg("muted", "\u27f3") + " " + theme.fg("dim", "thinking\u2026");
344
+
345
+ case "turn_end": {
346
+ const tokens = entry.inputTokens || entry.outputTokens
347
+ ? " " + theme.fg("dim",
348
+ `\u2191${formatTokens(entry.inputTokens)} \u2193${formatTokens(entry.outputTokens)}`)
349
+ : "";
350
+ return (
351
+ theme.fg("success", "\u2713") +
352
+ " " +
353
+ theme.fg("muted", `turn ${entry.turn}`) +
354
+ tokens
355
+ );
356
+ }
357
+
358
+ case "tool_start": {
359
+ const argPreview = formatToolArgPreview(entry.toolName, entry.args);
360
+ return (
361
+ theme.fg("muted", "\u2192") +
362
+ " " +
363
+ theme.fg("accent", entry.toolName) +
364
+ (argPreview ? " " + theme.fg("dim", argPreview) : "")
365
+ );
366
+ }
367
+
368
+ case "tool_end":
369
+ return (
370
+ theme.fg("success", "\u2713") +
371
+ " " +
372
+ theme.fg("accent", entry.toolName)
373
+ );
374
+ }
375
+ }
376
+
377
+ function buildLeafPreview(result: SingleResult): string[] | undefined {
378
+ const items = getDisplayItems(result.messages);
379
+ const lines: string[] = [];
380
+ for (const item of items) {
381
+ if (item.type === "text") {
382
+ lines.push(...lastNonEmptyLines(item.text, OUTPUT_PREVIEW_LINE_COUNT));
383
+ }
384
+ }
385
+ const finalOutput = getFinalOutput(result.messages);
386
+ if (finalOutput) lines.push(...lastNonEmptyLines(finalOutput, OUTPUT_PREVIEW_LINE_COUNT));
387
+ const unique = lines.filter((line, index) => line && lines.indexOf(line) === index);
388
+ return unique.length > 0 ? unique.slice(-OUTPUT_PREVIEW_LINE_COUNT) : undefined;
389
+ }
390
+
391
+ function buildResultNode(result: SingleResult): TreeNode {
392
+ const status = statusFromResult(result);
393
+ const usage = formatUsage(result.usage, result.model);
394
+ const metaParts: string[] = [result.agentSource];
395
+ if (usage) metaParts.push(usage);
396
+ if (status === "error") {
397
+ const errorText = result.errorMessage || result.stderr || result.stopReason;
398
+ if (errorText) metaParts.push(truncate(errorText.replace(/\s+/g, " "), 120));
399
+ }
400
+
401
+ const children = buildNestedChildren(result);
402
+ const isRunning = status === "running";
403
+ return {
404
+ label: result.agent,
405
+ status,
406
+ meta: metaParts.join(" • "),
407
+ task: result.task,
408
+ liveActivity: isRunning && result.liveLog?.length > 0 ? result.liveLog : undefined,
409
+ outputPreview: !isRunning && children.length === 0 ? buildLeafPreview(result) : undefined,
410
+ children,
411
+ };
412
+ }
413
+
414
+ export function buildTopLevelNodes(details: SubagentDetails): TreeNode[] {
415
+ return details.results.map((result) => buildResultNode(result));
416
+ }
417
+
418
+ export function renderTreeLines(
419
+ nodes: TreeNode[],
420
+ theme: { fg: ThemeFg },
421
+ showOutputPreview: boolean,
422
+ depth = 0,
423
+ prefix = "",
424
+ ): string[] {
425
+ const lines: string[] = [];
426
+
427
+ nodes.forEach((node, index) => {
428
+ const indent = " ".repeat(depth);
429
+ const number = prefix ? `${prefix}.${index + 1}` : `${index + 1}`;
430
+ const numberPrefix = broadcastNumberingActive ? `${number}. ` : "";
431
+ let line = `${indent}${numberPrefix}${statusEmoji(node.status, theme)} ${theme.fg("accent", node.label)}`;
432
+ if (node.meta) line += ` ${theme.fg("dim", node.meta)}`;
433
+ lines.push(line);
434
+
435
+ if (showOutputPreview && node.outputPreview && node.outputPreview.length > 0) {
436
+ for (const outputLine of node.outputPreview) {
437
+ lines.push(`${indent} ${theme.fg("toolOutput", outputLine)}`);
438
+ }
439
+ }
440
+
441
+ // Live activity (thinking / tool calls of a *running* agent) is always
442
+ // shown, at any depth and regardless of `showOutputPreview`. Previously it
443
+ // was gated behind `showOutputPreview`, which is disabled for the whole
444
+ // tree as soon as any nesting exists — so teamlead/nested runs showed only
445
+ // static status lines and looked frozen. liveActivity is only attached to
446
+ // running nodes (see buildResultNode), so completed nodes stay quiet.
447
+ if (node.liveActivity && node.liveActivity.length > 0) {
448
+ for (const entry of node.liveActivity) {
449
+ lines.push(`${indent} ${formatLiveLogEntry(entry, theme)}`);
450
+ }
451
+ }
452
+
453
+ if (node.children.length > 0) {
454
+ lines.push(...renderTreeLines(node.children, theme, showOutputPreview, depth + 1, number));
455
+ }
456
+ });
457
+
458
+ return lines;
459
+ }
460
+
461
+ export function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
462
+ // aggregatedUsage includes own agents + all their nested descendants;
463
+ // fall back to summing only direct results for old serialised data lacking the field.
464
+ const totalUsage = formatUsage(
465
+ details.aggregatedUsage ?? aggregateUsage(details.results),
466
+ );
467
+ const parts = [
468
+ `${counts.running} running`,
469
+ `${counts.finished}/${counts.total} finished`,
470
+ `${counts.success} ok`,
471
+ `${counts.error} error`,
472
+ ];
473
+ if (totalUsage) parts.push(totalUsage);
474
+ return parts.join(" • ");
475
+ }