oira666_pi-subagent 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,7 +130,9 @@ The Markdown body becomes the agent's system prompt (appended to Pi's default, n
130
130
 
131
131
  ## Delegation Guards
132
132
 
133
- Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer. Cycle checks are applied per task: in a mixed parallel call, cyclic tasks fail while legal siblings still run.
133
+ Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer. When cycle prevention is enabled, agents already in the current delegation stack are omitted from the child model's available-agent list. The runner still checks every task as a safety boundary: in a mixed parallel call, cyclic tasks fail while legal siblings still run.
134
+
135
+ A nested delegation failure is returned to its calling agent as a recoverable tool error. If that agent subsequently retries, completes the work itself, and produces a successful final answer, the earlier tool error does not incorrectly turn the completed agent—and all of its ancestors—into failures.
134
136
 
135
137
  | Config | Default | Description |
136
138
  | ------------------------------ | ------- | ------------------------------------------------ |
@@ -175,7 +177,10 @@ reported immediately as failures.
175
177
  ## Timestamps & Status Footer
176
178
 
177
179
  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).
180
+ (call start, per-subagent run start, and each live log entry). The collapsed tool
181
+ row keeps the normal compact progress text. Press `Ctrl+O` to expand it into the
182
+ subagent tree immediately; every running node shows its latest six activity
183
+ lines (thinking, tool starts/completions, and completed turns).
179
184
 
180
185
  In the interactive TUI the extension publishes the combined `total` usage line
181
186
  (parent + all subagents, recursively) via Pi's normal `ctx.ui.setStatus()`
package/agents.ts CHANGED
@@ -200,6 +200,27 @@ export function isAgentEnabledAtLayer(
200
200
  return true;
201
201
  }
202
202
 
203
+ /**
204
+ * Return only agents that may be advertised for the next delegation.
205
+ *
206
+ * The runner still enforces cycle prevention as a final safety boundary, but
207
+ * forbidden agents must not be presented to the model as available choices.
208
+ */
209
+ export function filterAdvertisedAgents(
210
+ agents: AgentConfig[],
211
+ targetDepth: number,
212
+ maxDepth: number,
213
+ delegationStack: string[],
214
+ preventCycles: boolean,
215
+ ): AgentConfig[] {
216
+ const blocked = preventCycles ? new Set(delegationStack) : null;
217
+ return agents.filter(
218
+ (agent) =>
219
+ isAgentEnabledAtLayer(agent, targetDepth, maxDepth) &&
220
+ !blocked?.has(agent.name),
221
+ );
222
+ }
223
+
203
224
  /**
204
225
  * Discover all available agents according to the requested scope.
205
226
  *
package/index.ts CHANGED
@@ -18,7 +18,12 @@ import {
18
18
  lazyStream,
19
19
  } from "@mariozechner/pi-ai";
20
20
  import { Type } from "@sinclair/typebox";
21
- import { type AgentConfig, discoverAgents, isAgentEnabledAtLayer } from "./agents.js";
21
+ import {
22
+ type AgentConfig,
23
+ discoverAgents,
24
+ filterAdvertisedAgents,
25
+ isAgentEnabledAtLayer,
26
+ } from "./agents.js";
22
27
  import { loadPiSubagentsConfig } from "./config.js";
23
28
  import {
24
29
  allocateSubagentNames,
@@ -430,6 +435,22 @@ function filterAgentsForCurrentLayer(
430
435
  return agents.filter((agent) => isAgentEnabledAtLayer(agent, targetDepth, maxDepth));
431
436
  }
432
437
 
438
+ function filterAgentsForPrompt(
439
+ agents: AgentConfig[],
440
+ currentDepth: number,
441
+ maxDepth: number,
442
+ delegationStack: string[],
443
+ preventCycles: boolean,
444
+ ): AgentConfig[] {
445
+ return filterAdvertisedAgents(
446
+ agents,
447
+ currentDepth + 1,
448
+ maxDepth,
449
+ delegationStack,
450
+ preventCycles,
451
+ );
452
+ }
453
+
433
454
  function formatAgentNames(agents: AgentConfig[]): string {
434
455
  return agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
435
456
  }
@@ -1424,7 +1445,13 @@ export default function (pi: ExtensionAPI) {
1424
1445
  if (!canDelegate) return;
1425
1446
 
1426
1447
  const discovery = discoverAgents(ctx.cwd, "both");
1427
- discoveredAgents = filterAgentsForCurrentLayer(discovery.agents, currentDepth, maxDepth);
1448
+ discoveredAgents = filterAgentsForPrompt(
1449
+ discovery.agents,
1450
+ currentDepth,
1451
+ maxDepth,
1452
+ ancestorAgentStack,
1453
+ preventCycles,
1454
+ );
1428
1455
  currentSessionId = ctx.sessionManager.getSessionId?.() ?? "ephemeral";
1429
1456
  currentSubagentSessionRoot = getDefaultSubagentSessionRoot(ctx);
1430
1457
  if (resumableSubagentsDisabled()) {
@@ -1720,11 +1747,12 @@ export default function (pi: ExtensionAPI) {
1720
1747
  pi.on("before_agent_start", async (event) => {
1721
1748
  try {
1722
1749
  if (!canDelegate) return;
1723
- if (discoveredAgents.length === 0) return;
1724
1750
 
1725
- const agentList = discoveredAgents
1726
- .map((a) => `- **${a.name}**: ${a.description}`)
1727
- .join("\n");
1751
+ const agentList = discoveredAgents.length > 0
1752
+ ? discoveredAgents
1753
+ .map((a) => `- **${a.name}**: ${a.description}`)
1754
+ .join("\n")
1755
+ : "_No agents are available for the next delegation layer. Do not call the subagents tool._";
1728
1756
  const subagentsGuidance = configuredToolPrompts[SUBAGENT_TOOL_NAME] ?? `### How to call the subagents tool
1729
1757
 
1730
1758
  Each subagent runs in an **isolated process**.
@@ -1746,8 +1774,18 @@ calls one after another. Do NOT put dependent tasks in the same array.
1746
1774
  { "tasks": [{ "agent": "agent-a", "task": "..." }, { "agent": "agent-b", "task": "..." }] }
1747
1775
  \`\`\`
1748
1776
 
1749
- - Max depth: current depth ${currentDepth}, max depth ${maxDepth}
1750
1777
  - Max subagents per tool call: ${maxParallelTasks}`;
1778
+ const delegationStackText = ancestorAgentStack.length > 0
1779
+ ? ancestorAgentStack.join(" -> ")
1780
+ : "(root)";
1781
+ const delegationGuardGuidance = `### Delegation guards
1782
+
1783
+ - Current depth: ${currentDepth}; max depth: ${maxDepth}
1784
+ - Cycle prevention: ${preventCycles ? "enabled" : "disabled"}
1785
+ - Current delegation stack: ${delegationStackText}
1786
+ ${preventCycles
1787
+ ? "- Agents already in this stack are intentionally omitted from the available list. Do not request omitted agent names."
1788
+ : "- Cyclic delegation is allowed by configuration."}`;
1751
1789
  const resumeGuidance = resumableSubagentsDisabled()
1752
1790
  ? ""
1753
1791
  : configuredToolPrompts[RESUME_SUBAGENTS_TOOL_NAME] ?? `### Resumable subagents
@@ -1772,7 +1810,7 @@ keeping their full previous context:
1772
1810
 
1773
1811
  The following subagents are available via the \`subagents\` tool:
1774
1812
 
1775
- ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` : ""}`,
1813
+ ${agentList}\n\n${subagentsGuidance}\n\n${delegationGuardGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` : ""}`,
1776
1814
  };
1777
1815
  } catch (err) {
1778
1816
  console.error("[pi-subagent] Error in before_agent_start:", err);
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.13",
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/runner.ts CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  emptyUsage,
22
22
  extractToolCalls,
23
23
  getFinalOutput,
24
- getNestedSubagentErrorSummary,
25
24
  isResultError,
26
25
  isResultSuccess,
27
26
  isSubagentDetails,
@@ -1239,16 +1238,11 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
1239
1238
  if (!result.stderr.trim()) result.stderr = result.errorMessage;
1240
1239
  }
1241
1240
 
1242
- if (result.exitCode === 0) {
1243
- const nestedErrorSummary = getNestedSubagentErrorSummary(result.messages);
1244
- if (nestedErrorSummary) {
1245
- result.exitCode = 1;
1246
- result.stopReason = "error";
1247
- result.errorMessage = nestedErrorSummary;
1248
- if (!result.stderr.trim()) result.stderr = nestedErrorSummary;
1249
- }
1250
- }
1251
-
1241
+ // A failed nested delegation is a recoverable tool error, just like a
1242
+ // failed bash/read call. Pi returns that error to the calling model, which
1243
+ // may retry, choose another approach, or finish the task itself. Do not
1244
+ // overwrite a later successful terminal answer with an earlier nested
1245
+ // tool failure.
1252
1246
  return result;
1253
1247
  } catch (err) {
1254
1248
  const msg = err instanceof Error ? err.message : String(err);
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
  }