oira666_pi-subagent 0.3.11 → 0.3.12

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.
Files changed (5) hide show
  1. package/README.md +4 -1
  2. package/package.json +1 -1
  3. package/render.ts +114 -25
  4. package/tree.ts +163 -76
  5. package/types.ts +11 -7
package/README.md CHANGED
@@ -175,7 +175,10 @@ reported immediately as failures.
175
175
  ## Timestamps & Status Footer
176
176
 
177
177
  Subagent tool calls and live activity lines render a dim `hh:mm:ss` timestamp
178
- (call start, per-subagent run start, and each live log entry).
178
+ (call start, per-subagent run start, and each live log entry). The collapsed tool
179
+ row keeps the normal compact progress text. Press `Ctrl+O` to expand it into the
180
+ subagent tree immediately; every running node shows its latest six activity
181
+ lines (thinking, tool starts/completions, and completed turns).
179
182
 
180
183
  In the interactive TUI the extension publishes the combined `total` usage line
181
184
  (parent + all subagents, recursively) via Pi's normal `ctx.ui.setStatus()`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-subagent",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
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
@@ -7,12 +7,19 @@
7
7
  */
8
8
 
9
9
  import { Container, Spacer, Text } from "@mariozechner/pi-tui";
10
- import type { SubagentDetails } from "./types.js";
10
+ import {
11
+ MAX_LIVE_LOG_ENTRIES,
12
+ isResultError,
13
+ isSubagentDetails,
14
+ type SingleResult,
15
+ type SubagentDetails,
16
+ } from "./types.js";
11
17
  import {
12
18
  type ThemeFg,
13
19
  buildTopLevelNodes,
14
20
  countNodes,
15
21
  formatClockTime,
22
+ formatLiveLogEntry,
16
23
  hasNestedChildren,
17
24
  renderTreeLines,
18
25
  setBroadcastNumberingActive,
@@ -122,37 +129,119 @@ export function renderResumeCall(
122
129
  // renderResult — shown after the tool completes / streams updates
123
130
  // ---------------------------------------------------------------------------
124
131
 
132
+ function getResultText(
133
+ result: { content?: Array<{ type: string; text?: string }> },
134
+ ): string {
135
+ const first = Array.isArray(result.content) ? result.content[0] : undefined;
136
+ return first?.type === "text" && typeof first.text === "string" && first.text
137
+ ? first.text
138
+ : "(no output)";
139
+ }
140
+
141
+ function compactText(text: string, maxLength = 240): string {
142
+ const firstLine = text.replace(/\r\n?/g, "\n").split("\n").find((line) => line.trim()) ?? "(no output)";
143
+ return truncate(firstLine, maxLength);
144
+ }
145
+
146
+ function isRenderableResult(value: unknown): value is SingleResult {
147
+ return value !== null &&
148
+ typeof value === "object" &&
149
+ !Array.isArray(value) &&
150
+ typeof (value as Partial<SingleResult>).agent === "string" &&
151
+ typeof (value as Partial<SingleResult>).exitCode === "number";
152
+ }
153
+
154
+ function getCollapsedResultText(details: SubagentDetails, fallbackText: string): string {
155
+ const results = details.results.filter(isRenderableResult);
156
+ if (results.length === 0) return compactText(fallbackText);
157
+
158
+ const running = results.filter((item) => item.exitCode === -1).length;
159
+ const finished = results.length - running;
160
+ const succeeded = results.filter((item) => item.exitCode === 0 && !isResultError(item)).length;
161
+ const failed = finished - succeeded;
162
+
163
+ if (details.mode === "parallel") {
164
+ return running > 0
165
+ ? `Parallel: ${finished}/${results.length} done, ${running} running...`
166
+ : `Parallel: ${succeeded}/${results.length} succeeded${failed > 0 ? `, ${failed} failed` : ""}`;
167
+ }
168
+
169
+ const item = results[0];
170
+ if (item.exitCode === -1) return `Agent ${item.agent}: running...`;
171
+ if (isResultError(item)) {
172
+ const reason = [item.errorMessage, item.stderr, item.stopReason]
173
+ .find((value): value is string => typeof value === "string" && value.length > 0);
174
+ return `Agent ${item.agent}: failed${reason ? ` — ${compactText(reason, 180)}` : ""}`;
175
+ }
176
+ return `Agent ${item.agent}: completed`;
177
+ }
178
+
125
179
  export function renderResult(
126
180
  result: { content: Array<{ type: string; text?: string }>; details?: unknown },
127
- _expanded: boolean,
181
+ expanded: boolean,
128
182
  theme: { fg: ThemeFg; bold: (s: string) => string },
129
183
  ): Container | Text {
130
- const details = result.details as SubagentDetails | undefined;
131
- if (!details || details.results.length === 0) {
132
- const first = result.content[0];
133
- return new Text(first?.type === "text" && first.text ? first.text : "(no output)", 0, 0);
184
+ const fallbackText = getResultText(result);
185
+
186
+ // Keep the normal row compact. Ctrl+O switches to the live tree below.
187
+ if (!expanded) {
188
+ const text = isSubagentDetails(result.details)
189
+ ? getCollapsedResultText(result.details, fallbackText)
190
+ : compactText(fallbackText);
191
+ return new Text(text, 0, 0);
134
192
  }
135
193
 
136
- const nodes = buildTopLevelNodes(details);
137
- const counts = countNodes(nodes);
138
- const showOutputPreview = !hasNestedChildren(nodes);
139
- const icon = counts.running > 0
140
- ? theme.fg("warning", "⏳")
141
- : counts.error > 0
142
- ? theme.fg("error", "❌")
143
- : theme.fg("success", "✅");
194
+ if (!isSubagentDetails(result.details) || result.details.results.length === 0) {
195
+ return new Text(fallbackText, 0, 0);
196
+ }
197
+ const details: SubagentDetails = result.details;
144
198
 
145
- const container = new Container();
146
- container.addChild(
147
- new Text(
148
- `${icon} ${theme.fg("toolTitle", theme.bold("subagent tree "))}${theme.fg("dim", topLevelSummary(details, counts))}`,
149
- 0,
150
- 0,
151
- ),
152
- );
199
+ try {
200
+ const nodes = buildTopLevelNodes(details);
201
+ const counts = countNodes(nodes);
202
+ const showOutputPreview = !hasNestedChildren(nodes);
203
+ const icon = counts.running > 0
204
+ ? theme.fg("warning", "⏳")
205
+ : counts.error > 0
206
+ ? theme.fg("error", "❌")
207
+ : theme.fg("success", "✅");
153
208
 
154
- container.addChild(new Spacer(1));
155
- container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
209
+ const container = new Container();
210
+ container.addChild(
211
+ new Text(
212
+ `${icon} ${theme.fg("toolTitle", theme.bold("subagent tree "))}${theme.fg("dim", topLevelSummary(details, counts))}`,
213
+ 0,
214
+ 0,
215
+ ),
216
+ );
156
217
 
157
- return container;
218
+ container.addChild(new Spacer(1));
219
+ container.addChild(new Text(renderTreeLines(nodes, theme, showOutputPreview).join("\n"), 0, 0));
220
+ return container;
221
+ } catch {
222
+ // Pi falls back to raw result.content when a custom renderer throws, which
223
+ // made Ctrl+O appear broken until the offending rolling log entry expired.
224
+ // Keep an expanded tree visible even for malformed third-party/RPC data.
225
+ const lines: string[] = [];
226
+ for (const item of details.results) {
227
+ const agent = typeof item?.agent === "string" ? item.agent : "unknown agent";
228
+ const icon = item?.exitCode === -1
229
+ ? theme.fg("warning", "⏳")
230
+ : item?.exitCode === 0
231
+ ? theme.fg("success", "✅")
232
+ : theme.fg("error", "❌");
233
+ lines.push(`${icon} ${theme.fg("accent", agent)}`);
234
+ const liveLog = Array.isArray(item?.liveLog)
235
+ ? item.liveLog.slice(-MAX_LIVE_LOG_ENTRIES)
236
+ : [];
237
+ for (const entry of liveLog) {
238
+ lines.push(` ${formatLiveLogEntry(entry, theme)}`);
239
+ }
240
+ }
241
+ return new Text(
242
+ `${theme.fg("toolTitle", theme.bold("subagent tree"))}\n${lines.join("\n") || fallbackText}`,
243
+ 0,
244
+ 0,
245
+ );
246
+ }
158
247
  }
package/tree.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type LiveLogEntry,
14
14
  type NestedSubagentResult,
15
15
  type SingleResult,
16
+ MAX_LIVE_LOG_ENTRIES,
16
17
  type SubagentDetails,
17
18
  type UsageStats,
18
19
  aggregateUsage,
@@ -65,17 +66,32 @@ interface PendingSubagentCall {
65
66
  // Formatting helpers
66
67
  // ---------------------------------------------------------------------------
67
68
 
69
+ function asRecord(value: unknown): Record<string, unknown> {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value)
71
+ ? value as Record<string, unknown>
72
+ : {};
73
+ }
74
+
75
+ function finiteNumber(value: unknown): number {
76
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
77
+ }
78
+
79
+ function stringValue(value: unknown, fallback = ""): string {
80
+ return typeof value === "string" ? value : fallback;
81
+ }
82
+
68
83
  export function formatClockTime(epochMs: number): string {
69
- const d = new Date(epochMs);
84
+ const d = new Date(Number.isFinite(epochMs) ? epochMs : 0);
70
85
  const pad = (n: number) => String(n).padStart(2, "0");
71
86
  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
72
87
  }
73
88
 
74
89
  export function formatTokens(count: number): string {
75
- if (count < 1000) return count.toString();
76
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
77
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
78
- return `${(count / 1000000).toFixed(1)}M`;
90
+ const safeCount = finiteNumber(count);
91
+ if (safeCount < 1000) return safeCount.toString();
92
+ if (safeCount < 10000) return `${(safeCount / 1000).toFixed(1)}k`;
93
+ if (safeCount < 1000000) return `${Math.round(safeCount / 1000)}k`;
94
+ return `${(safeCount / 1000000).toFixed(1)}M`;
79
95
  }
80
96
 
81
97
  export function formatCombinedUsageStatusLine(
@@ -95,36 +111,41 @@ export function formatCombinedUsageStatusLine(
95
111
  return `WITH SUBS: (${subagentCount}) Σ $${cost.toFixed(4)} • ↑${formatTokens(input)} ↓${formatTokens(output)}${cache} T${formatTokens(total)} • ${turns} turn${turns === 1 ? "" : "s"}`;
96
112
  }
97
113
 
98
- export function formatUsage(usage: Partial<UsageStats>, model?: string): string {
114
+ export function formatUsage(usage: Partial<UsageStats> | unknown, model?: unknown): string {
115
+ const safeUsage = asRecord(usage);
116
+ const input = finiteNumber(safeUsage.input);
117
+ const output = finiteNumber(safeUsage.output);
118
+ const cacheRead = finiteNumber(safeUsage.cacheRead);
119
+ const cacheWrite = finiteNumber(safeUsage.cacheWrite);
120
+ const cost = finiteNumber(safeUsage.cost);
121
+ const contextTokens = finiteNumber(safeUsage.contextTokens);
122
+ const turns = finiteNumber(safeUsage.turns);
99
123
  const parts: string[] = [];
100
- const totalTokens =
101
- (usage.input || 0) +
102
- (usage.output || 0) +
103
- (usage.cacheRead || 0) +
104
- (usage.cacheWrite || 0);
105
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
124
+ const totalTokens = input + output + cacheRead + cacheWrite;
125
+ if (turns) parts.push(`${turns} turn${turns > 1 ? "s" : ""}`);
106
126
  if (totalTokens > 0) parts.push(`tok:${formatTokens(totalTokens)}`);
107
- if (usage.input) parts.push(`in:${formatTokens(usage.input)}`);
108
- if (usage.output) parts.push(`out:${formatTokens(usage.output)}`);
109
- if (usage.cacheRead) parts.push(`cacheR:${formatTokens(usage.cacheRead)}`);
110
- if (usage.cacheWrite) parts.push(`cacheW:${formatTokens(usage.cacheWrite)}`);
111
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
112
- if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
113
- if (model) parts.push(model);
127
+ if (input) parts.push(`in:${formatTokens(input)}`);
128
+ if (output) parts.push(`out:${formatTokens(output)}`);
129
+ if (cacheRead) parts.push(`cacheR:${formatTokens(cacheRead)}`);
130
+ if (cacheWrite) parts.push(`cacheW:${formatTokens(cacheWrite)}`);
131
+ if (cost) parts.push(`$${cost.toFixed(4)}`);
132
+ if (contextTokens > 0) parts.push(`ctx:${formatTokens(contextTokens)}`);
133
+ if (typeof model === "string" && model) parts.push(model);
114
134
  return parts.join(" • ");
115
135
  }
116
136
 
117
- export function truncate(text: string, maxLen: number): string {
118
- return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
137
+ export function truncate(text: unknown, maxLen: number): string {
138
+ const safeText = stringValue(text);
139
+ return safeText.length > maxLen ? `${safeText.slice(0, maxLen)}...` : safeText;
119
140
  }
120
141
 
121
- function splitOutputLines(text: string): string[] {
122
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
142
+ function splitOutputLines(text: unknown): string[] {
143
+ const lines = stringValue(text).replace(/\r\n?/g, "\n").split("\n");
123
144
  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
124
145
  return lines;
125
146
  }
126
147
 
127
- function lastNonEmptyLines(text: string, limit: number): string[] {
148
+ function lastNonEmptyLines(text: unknown, limit: number): string[] {
128
149
  return splitOutputLines(text)
129
150
  .map((line) => line.trimEnd())
130
151
  .filter((line) => line.trim().length > 0)
@@ -173,11 +194,12 @@ export function hasNestedChildren(nodes: TreeNode[]): boolean {
173
194
  return nodes.some((node) => node.children.length > 0 || hasNestedChildren(node.children));
174
195
  }
175
196
 
176
- function extractPendingSubagentCalls(messages: SingleResult["messages"]): PendingSubagentCall[] {
197
+ function extractPendingSubagentCalls(messages: SingleResult["messages"] | unknown): PendingSubagentCall[] {
198
+ const history = Array.isArray(messages) ? messages : [];
177
199
  const calls: PendingSubagentCall[] = [];
178
- for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
179
- const message = messages[messageIndex] as any;
180
- if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
200
+ for (let messageIndex = 0; messageIndex < history.length; messageIndex++) {
201
+ const message = history[messageIndex] as any;
202
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
181
203
  for (let partIndex = 0; partIndex < message.content.length; partIndex++) {
182
204
  const part = message.content[partIndex] as any;
183
205
  if (part?.type !== "toolCall" || !isSubagentToolName(part?.name)) continue;
@@ -235,11 +257,16 @@ function subagentCallSignature(call: PendingSubagentCall): string {
235
257
 
236
258
  function nestedResultIsHealthy(nested: NestedSubagentResult | undefined): boolean {
237
259
  if (!nested || nested.isError) return false;
238
- return nested.details.results.every((result) => !isResultError(result));
260
+ return nested.details.results.every(
261
+ (result) => result !== null && typeof result === "object" && !Array.isArray(result) && !isResultError(result),
262
+ );
239
263
  }
240
264
 
241
265
  function buildLiveDetailsSignature(details: SubagentDetails): string {
242
- return JSON.stringify(details.results.map((result) => ({ agent: result.agent, task: result.task ?? "" })));
266
+ return JSON.stringify(details.results.map((result) => {
267
+ const value = asRecord(result);
268
+ return { agent: stringValue(value.agent), task: stringValue(value.task) };
269
+ }));
243
270
  }
244
271
 
245
272
  function findLiveNestedDetailsForCall(
@@ -276,7 +303,9 @@ function findLiveNestedDetailsForCall(
276
303
  const agentSignature = JSON.stringify(call.tasks.map((task) => task.agent));
277
304
  for (const [key, details] of Object.entries(live)) {
278
305
  if (usedLiveKeys.has(key) || !isSubagentDetails(details)) continue;
279
- const liveAgentSignature = JSON.stringify(details.results.map((nestedResult) => nestedResult.agent));
306
+ const liveAgentSignature = JSON.stringify(
307
+ details.results.map((nestedResult) => stringValue(asRecord(nestedResult).agent)),
308
+ );
280
309
  if (liveAgentSignature !== agentSignature) continue;
281
310
  usedLiveKeys.add(key);
282
311
  return details;
@@ -339,38 +368,53 @@ function buildNestedChildren(result: SingleResult): TreeNode[] {
339
368
  return nodes;
340
369
  }
341
370
 
342
- function formatToolArgPreview(toolName: string, args: Record<string, unknown>): string {
343
- const shorten = (p: string) => {
371
+ function formatToolArgPreview(toolName: unknown, rawArgs: unknown): string {
372
+ const args = asRecord(rawArgs);
373
+ const shorten = (value: unknown) => {
374
+ const p = stringValue(value);
344
375
  const home = os.homedir();
345
376
  return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
346
377
  };
347
- const truncateTo = (s: string, n: number) =>
348
- s.length > n ? s.slice(0, n) + "\u2026" : s;
378
+ const truncateTo = (value: unknown, n: number) => {
379
+ const text = stringValue(value);
380
+ return text.length > n ? text.slice(0, n) + "\u2026" : text;
381
+ };
349
382
 
350
383
  switch (toolName) {
351
- case "bash": {
352
- const cmd = (args.command as string) || "";
353
- return truncateTo(cmd.replace(/\s+/g, " "), 52);
354
- }
384
+ case "bash":
385
+ return truncateTo(stringValue(args.command).replace(/\s+/g, " "), 52);
355
386
  case "read":
356
387
  case "write":
357
388
  case "edit":
358
- return shorten(truncateTo((args.path ?? args.file_path ?? "") as string, 52));
359
- case "grep":
360
- return truncateTo(`/${args.pattern}/`, 30) +
361
- (args.path ? ` in ${shorten(args.path as string)}` : "");
362
- case "find":
363
- return truncateTo((args.pattern ?? "*") as string, 30) +
364
- (args.path ? ` in ${shorten(args.path as string)}` : "");
389
+ return shorten(truncateTo(args.path ?? args.file_path, 52));
390
+ case "grep": {
391
+ const pattern = stringValue(args.pattern);
392
+ const target = stringValue(args.path);
393
+ return truncateTo(`/${pattern}/`, 30) + (target ? ` in ${shorten(target)}` : "");
394
+ }
395
+ case "find": {
396
+ const pattern = stringValue(args.pattern, "*");
397
+ const target = stringValue(args.path);
398
+ return truncateTo(pattern, 30) + (target ? ` in ${shorten(target)}` : "");
399
+ }
365
400
  case "subagent":
366
401
  case "subagents": {
367
- const tasks = (args.tasks as any[]) ?? [];
368
- return tasks.map((t: any) => t.agent).join(", ");
402
+ const tasks = Array.isArray(args.tasks) ? args.tasks : [];
403
+ return tasks
404
+ .map((task) => stringValue(asRecord(task).agent))
405
+ .filter(Boolean)
406
+ .join(", ");
369
407
  }
370
408
  case "resume_subagents": {
371
- const raw = (args as any).resumes;
372
- const resumes = Array.isArray(raw) ? raw : raw ? [raw] : [];
373
- return resumes.map((r: any) => r?.subagent ?? r?.name).filter(Boolean).join(", ");
409
+ const raw = args.resumes;
410
+ const resumes = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? [raw] : [];
411
+ return resumes
412
+ .map((resume) => {
413
+ const item = asRecord(resume);
414
+ return stringValue(item.subagent) || stringValue(item.name);
415
+ })
416
+ .filter(Boolean)
417
+ .join(", ");
374
418
  }
375
419
  default:
376
420
  return "";
@@ -381,12 +425,14 @@ export function formatLiveLogEntry(
381
425
  entry: LiveLogEntry,
382
426
  theme: { fg: ThemeFg },
383
427
  ): string {
384
- const stamp = entry.at !== undefined ? theme.fg("dim", formatClockTime(entry.at)) + " " : "";
385
- return stamp + formatLiveLogEntryBody(entry, theme);
428
+ const value = asRecord(entry);
429
+ const at = typeof value.at === "number" && Number.isFinite(value.at) ? value.at : undefined;
430
+ const stamp = at !== undefined ? theme.fg("dim", formatClockTime(at)) + " " : "";
431
+ return stamp + formatLiveLogEntryBody(value, theme);
386
432
  }
387
433
 
388
434
  function formatLiveLogEntryBody(
389
- entry: LiveLogEntry,
435
+ entry: Record<string, unknown>,
390
436
  theme: { fg: ThemeFg },
391
437
  ): string {
392
438
  switch (entry.kind) {
@@ -394,24 +440,28 @@ function formatLiveLogEntryBody(
394
440
  return theme.fg("muted", "\u27f3") + " " + theme.fg("dim", "thinking\u2026");
395
441
 
396
442
  case "turn_end": {
397
- const tokens = entry.inputTokens || entry.outputTokens
443
+ const inputTokens = finiteNumber(entry.inputTokens);
444
+ const outputTokens = finiteNumber(entry.outputTokens);
445
+ const tokens = inputTokens || outputTokens
398
446
  ? " " + theme.fg("dim",
399
- `\u2191${formatTokens(entry.inputTokens)} \u2193${formatTokens(entry.outputTokens)}`)
447
+ `\u2191${formatTokens(inputTokens)} \u2193${formatTokens(outputTokens)}`)
400
448
  : "";
449
+ const turn = finiteNumber(entry.turn);
401
450
  return (
402
451
  theme.fg("success", "\u2713") +
403
452
  " " +
404
- theme.fg("muted", `turn ${entry.turn}`) +
453
+ theme.fg("muted", `turn ${turn}`) +
405
454
  tokens
406
455
  );
407
456
  }
408
457
 
409
458
  case "tool_start": {
410
- const argPreview = formatToolArgPreview(entry.toolName, entry.args);
459
+ const toolName = stringValue(entry.toolName, "unknown tool");
460
+ const argPreview = formatToolArgPreview(toolName, entry.args);
411
461
  return (
412
462
  theme.fg("muted", "\u2192") +
413
463
  " " +
414
- theme.fg("accent", entry.toolName) +
464
+ theme.fg("accent", toolName) +
415
465
  (argPreview ? " " + theme.fg("dim", argPreview) : "")
416
466
  );
417
467
  }
@@ -420,8 +470,11 @@ function formatLiveLogEntryBody(
420
470
  return (
421
471
  theme.fg("success", "\u2713") +
422
472
  " " +
423
- theme.fg("accent", entry.toolName)
473
+ theme.fg("accent", stringValue(entry.toolName, "unknown tool"))
424
474
  );
475
+
476
+ default:
477
+ return theme.fg("muted", "activity");
425
478
  }
426
479
  }
427
480
 
@@ -482,33 +535,64 @@ function loadMessagesFromSession(sessionDir: string | undefined): SingleResult["
482
535
  }
483
536
 
484
537
  function hydrateResultFromSession(result: SingleResult): SingleResult {
485
- if (result.messages?.length > 0) return result;
486
- const messages = loadMessagesFromSession(result.sessionDir);
487
- return messages.length > 0 ? { ...result, messages } : result;
488
- }
489
-
490
- function buildResultNode(result: SingleResult): TreeNode {
491
- result = hydrateResultFromSession(result);
538
+ if (Array.isArray(result.messages) && result.messages.length > 0) return result;
539
+ const messages = loadMessagesFromSession(
540
+ typeof result.sessionDir === "string" ? result.sessionDir : undefined,
541
+ );
542
+ if (messages.length > 0) return { ...result, messages };
543
+ return Array.isArray(result.messages) ? result : { ...result, messages: [] };
544
+ }
545
+
546
+ function buildResultNode(rawResult: SingleResult): TreeNode {
547
+ let result: SingleResult;
548
+ if (
549
+ rawResult !== null &&
550
+ typeof rawResult === "object" &&
551
+ !Array.isArray(rawResult) &&
552
+ typeof rawResult.agent === "string" &&
553
+ typeof rawResult.exitCode === "number"
554
+ ) {
555
+ result = hydrateResultFromSession(rawResult);
556
+ } else {
557
+ result = {
558
+ agent: "unknown agent",
559
+ agentSource: "unknown",
560
+ task: "",
561
+ exitCode: 1,
562
+ messages: [],
563
+ stderr: "Malformed subagent result.",
564
+ usage: {} as UsageStats,
565
+ toolCalls: {},
566
+ completedTurns: 0,
567
+ turnInProgress: false,
568
+ liveLog: [],
569
+ };
570
+ }
492
571
  const status = statusFromResult(result);
493
- const usage = formatUsage(result.usage ?? {}, result.model);
572
+ const usage = formatUsage(result.usage, result.model);
494
573
  const metaParts: string[] = [];
495
- if (result.name) metaParts.push(result.name);
496
- metaParts.push(result.agentSource);
574
+ if (typeof result.name === "string" && result.name) metaParts.push(result.name);
575
+ if (typeof result.agentSource === "string" && result.agentSource) metaParts.push(result.agentSource);
497
576
  if (usage) metaParts.push(usage);
498
577
  if (status === "error") {
499
578
  const errorText = result.errorMessage || result.stderr || result.stopReason;
500
- if (errorText) metaParts.push(truncate(errorText.replace(/\s+/g, " "), 120));
579
+ if (typeof errorText === "string" && errorText) {
580
+ metaParts.push(truncate(errorText.replace(/\s+/g, " "), 120));
581
+ }
501
582
  }
502
583
 
503
584
  const children = buildNestedChildren(result);
504
585
  const isRunning = status === "running";
586
+ const liveLog = Array.isArray(result.liveLog) ? result.liveLog : [];
505
587
  return {
506
- label: result.agent,
588
+ label: stringValue(result.agent, "unknown agent"),
507
589
  status,
508
590
  meta: metaParts.join(" • "),
509
- task: result.task,
510
- startedAt: result.startedAt,
511
- liveActivity: isRunning && (result.liveLog?.length ?? 0) > 0 ? result.liveLog : undefined,
591
+ task: typeof result.task === "string" ? result.task : undefined,
592
+ startedAt: typeof result.startedAt === "number" && Number.isFinite(result.startedAt)
593
+ ? result.startedAt
594
+ : undefined,
595
+ liveActivity: isRunning && liveLog.length > 0 ? liveLog.slice(-MAX_LIVE_LOG_ENTRIES) : undefined,
512
596
  outputPreview: !isRunning && children.length === 0 ? buildLeafPreview(result) : undefined,
513
597
  children,
514
598
  };
@@ -567,8 +651,11 @@ export function renderTreeLines(
567
651
  export function topLevelSummary(details: SubagentDetails, counts: TreeCounts): string {
568
652
  // aggregatedUsage includes own agents + all their nested descendants;
569
653
  // fall back to summing only direct results for old serialised data lacking the field.
654
+ const safeResults = details.results.filter(
655
+ (result): result is SingleResult => result !== null && typeof result === "object" && !Array.isArray(result),
656
+ );
570
657
  const totalUsage = formatUsage(
571
- usageSummaryToUsageStats(details.usageSummary) ?? details.aggregatedUsage ?? aggregateUsage(details.results),
658
+ usageSummaryToUsageStats(details.usageSummary) ?? details.aggregatedUsage ?? aggregateUsage(safeResults),
572
659
  );
573
660
  const parts = [
574
661
  `${counts.running} running`,
package/types.ts CHANGED
@@ -204,7 +204,7 @@ function asMessageArray(messages: unknown): Message[] {
204
204
  export function extractToolCalls(messages: unknown): ToolCallCounts {
205
205
  const counts: ToolCallCounts = {};
206
206
  for (const msg of asMessageArray(messages)) {
207
- if (msg.role !== "assistant") continue;
207
+ if ((msg as any)?.role !== "assistant") continue;
208
208
  for (const part of (Array.isArray((msg as any).content) ? (msg as any).content : [])) {
209
209
  if ((part as any)?.type !== "toolCall") continue;
210
210
  const name: string = typeof (part as any).name === "string" ? (part as any).name : "unknown";
@@ -479,10 +479,10 @@ export function getFinalOutput(messages: unknown, fallback?: string): string {
479
479
  const history = asMessageArray(messages);
480
480
  for (let i = history.length - 1; i >= 0; i--) {
481
481
  const msg = history[i];
482
- if (msg.role === "assistant" && Array.isArray((msg as any).content)) {
482
+ if ((msg as any)?.role === "assistant" && Array.isArray((msg as any).content)) {
483
483
  for (let j = (msg as any).content.length - 1; j >= 0; j--) {
484
484
  const part = (msg as any).content[j];
485
- if (part?.type === "text") return part.text;
485
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
486
486
  }
487
487
  }
488
488
  }
@@ -493,12 +493,16 @@ export function getFinalOutput(messages: unknown, fallback?: string): string {
493
493
  export function getDisplayItems(messages: unknown): DisplayItem[] {
494
494
  const items: DisplayItem[] = [];
495
495
  for (const msg of asMessageArray(messages)) {
496
- if (msg.role === "assistant" && Array.isArray((msg as any).content)) {
496
+ if ((msg as any)?.role === "assistant" && Array.isArray((msg as any).content)) {
497
497
  for (const part of (msg as any).content) {
498
- if (part?.type === "text") {
498
+ if (part?.type === "text" && typeof part.text === "string") {
499
499
  items.push({ type: "text", text: part.text });
500
- } else if (part?.type === "toolCall") {
501
- items.push({ type: "toolCall", name: part.name, args: part.arguments });
500
+ } else if (part?.type === "toolCall" && typeof part.name === "string") {
501
+ items.push({
502
+ type: "toolCall",
503
+ name: part.name,
504
+ args: part.arguments && typeof part.arguments === "object" ? part.arguments : {},
505
+ });
502
506
  }
503
507
  }
504
508
  }