@bacnh85/pi-subagent 0.1.1 → 0.2.0

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.
@@ -0,0 +1,59 @@
1
+ # Agent Definition Format
2
+
3
+ Sub-agents are defined as Markdown files with YAML frontmatter.
4
+
5
+ ## File Location
6
+
7
+ | Location | Scope |
8
+ |----------|-------|
9
+ | `~/.pi/agent/agents/*.md` | User-level (all projects) |
10
+ | `.pi/agents/*.md` | Project-level |
11
+ | `<skill>/agents/*.md` | Bundled with pi-sugagents |
12
+
13
+ Project agents override user agents with the same name when `agentScope: "both"`.
14
+
15
+ ## Frontmatter Fields
16
+
17
+ ```yaml
18
+ ---
19
+ name: my-agent # Required. Unique identifier (kebab-case).
20
+ description: ... # Required. When to use this agent.
21
+ tools: read, grep, ... # Optional. Comma-separated tool names. Defaults to all.
22
+ model: claude-haiku-4-5 # Optional. Model ID. Defaults to parent's model.
23
+ ---
24
+ ```
25
+
26
+ Only `name` and `description` are required.
27
+
28
+ ## Body
29
+
30
+ The body after frontmatter becomes the agent's **entire system prompt**. No pi defaults, no AGENTS.md files, no skills — only what you write here. Keep it focused.
31
+
32
+ ## Available Tools
33
+
34
+ Built-in pi tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`
35
+
36
+ The `subagent` tool is never available to sub-agents (prevents accidental recursion). Sub-agents run at one level of delegation only; they cannot spawn further sub-agents.
37
+
38
+ Custom/extension tools are NOT available to sub-agents by default (each runs in an isolated in-memory session with no extensions).
39
+
40
+ ## Model Resolution
41
+
42
+ Model IDs are resolved via `getModel("provider", "id")`. Common values:
43
+ - `claude-haiku-4-5` (Anthropic Haiku — fast, cheap)
44
+ - `claude-sonnet-4-20250514` (Anthropic Sonnet — balanced)
45
+ - `gpt-4o` (OpenAI)
46
+ - Any model available in your pi configuration.
47
+
48
+ If not specified, defaults to the parent session's model.
49
+
50
+ ## Token Budget
51
+
52
+ Each sub-agent runs with:
53
+ - **System prompt**: agent body only (~200-1K tokens typical)
54
+ - **No AGENTS.md**: saves 500-5K tokens
55
+ - **No extensions/skills loaded**: saves 200-1K tokens
56
+ - **Thinking off**: saves reasoning overhead
57
+ - **No compaction**: avoids compaction token cost
58
+
59
+ This is ~10x leaner than spawning a full `pi` process.
@@ -2,7 +2,7 @@
2
2
  name: reviewer
3
3
  description: Code review specialist. Use for reviewing changes, finding bugs, suggesting improvements.
4
4
  tools: read, grep, find, ls, bash
5
- model: claude-sonnet-4-20250514
5
+ model: openai-codex/gpt-5.5
6
6
  ---
7
7
 
8
8
  You are a senior code reviewer. Review code changes and provide specific, actionable feedback.
package/agents/scout.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: scout
3
3
  description: Fast codebase recon that returns compressed context for handoff. Use for finding files, understanding structure, locating symbols.
4
4
  tools: read, grep, find, ls
5
- model: claude-haiku-4-5
5
+ model: opencode-go/deepseek-v4-flash
6
6
  ---
7
7
 
8
8
  You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
package/agents/worker.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: worker
3
3
  description: General-purpose coding agent with full tool access. Use for implementation, refactoring, debugging, and complex multi-step tasks.
4
- model: claude-sonnet-4-20250514
4
+ model: opencode-go/deepseek-v4-pro
5
5
  ---
6
6
 
7
7
  You are a skilled software engineer. Implement the requested task with care and precision.
package/agents.ts CHANGED
@@ -34,6 +34,8 @@ interface AgentCache {
34
34
  bundledDir: string;
35
35
  agents: AgentConfig[];
36
36
  projectAgentsDir: string | null;
37
+ /** File-level signature per directory (name:mtime:size for each .md file) */
38
+ dirSignatures: Map<string, string>;
37
39
  }
38
40
 
39
41
  let _cache: AgentCache | null = null;
@@ -98,6 +100,26 @@ function isDirectory(p: string): boolean {
98
100
  }
99
101
  }
100
102
 
103
+ /** Build a stable signature for agent .md files in a directory.
104
+ * Returns "missing" if the directory doesn't exist, or a sorted
105
+ * list of `name:mtimeMs:size` entries that catches both content
106
+ * edits and add/remove/rename operations. */
107
+ function dirSignature(dir: string): string {
108
+ try {
109
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
110
+ .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
111
+ .map((e) => {
112
+ const file = path.join(dir, e.name);
113
+ const st = fs.statSync(file);
114
+ return `${e.name}:${st.mtimeMs}:${st.size}`;
115
+ })
116
+ .sort();
117
+ return `exists:${entries.join("|")}`;
118
+ } catch {
119
+ return "missing";
120
+ }
121
+ }
122
+
101
123
  function findNearestProjectAgentsDir(cwd: string): string | null {
102
124
  let currentDir = cwd;
103
125
  while (true) {
@@ -124,14 +146,25 @@ export function discoverAgents(
124
146
  const userDir = path.join(getAgentDir(), "agents");
125
147
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
126
148
 
127
- // Check cache
149
+ // Check cache (with file-signature invalidation so editing agent .md files auto-detects changes)
128
150
  if (
129
151
  _cache &&
130
152
  _cache.userDir === userDir &&
131
153
  _cache.projectDir === projectAgentsDir &&
132
154
  _cache.bundledDir === bundledAgentsDir
133
155
  ) {
134
- return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir };
156
+ let stale = false;
157
+ for (const [dir, cachedSig] of _cache.dirSignatures) {
158
+ if (dirSignature(dir) !== cachedSig) {
159
+ stale = true;
160
+ break;
161
+ }
162
+ }
163
+ if (!stale) {
164
+ return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir };
165
+ }
166
+ // Cache is stale — rebuild below
167
+ _cache = null;
135
168
  }
136
169
 
137
170
  const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
@@ -152,12 +185,19 @@ export function discoverAgents(
152
185
 
153
186
  const agents = Array.from(agentMap.values());
154
187
 
188
+ const dirSignatures = new Map<string, string>();
189
+ for (const dir of [userDir, projectAgentsDir, bundledAgentsDir]) {
190
+ if (!dir) continue;
191
+ dirSignatures.set(dir, dirSignature(dir));
192
+ }
193
+
155
194
  _cache = {
156
195
  userDir,
157
196
  projectDir: projectAgentsDir,
158
197
  bundledDir: bundledAgentsDir,
159
198
  agents,
160
199
  projectAgentsDir,
200
+ dirSignatures,
161
201
  };
162
202
 
163
203
  return { agents, projectAgentsDir };
package/index.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
29
29
  import { Type } from "typebox";
30
30
 
31
- import { type AgentConfig, type AgentScope, discoverAgents, invalidateAgentCache } from "./agents.ts";
31
+ import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
32
32
  import {
33
33
  type SubAgentResult,
34
34
  getFinalOutput,
@@ -66,20 +66,34 @@ function truncateParallelOutput(output: string): string {
66
66
  return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
67
67
  }
68
68
 
69
+ interface ResolvedModel {
70
+ model: Model | null;
71
+ attempted: string[];
72
+ }
73
+
69
74
  function resolveModel(
70
75
  modelName: string | undefined,
71
76
  parentModel: Model | undefined,
72
- ): Model | null {
77
+ ): ResolvedModel {
78
+ const attempted: string[] = [];
73
79
  if (modelName) {
74
80
  // Try as provider/id first, then fall back to anthropic/id
75
81
  const parts = modelName.split("/");
76
82
  if (parts.length === 2) {
77
- return getModel(parts[0], parts[1]) ?? null;
83
+ attempted.push(modelName);
84
+ const found = getModel(parts[0], parts[1]) ?? null;
85
+ if (found) return { model: found, attempted };
86
+ } else {
87
+ // Assume Anthropic shorthand
88
+ attempted.push(`anthropic/${modelName}`);
89
+ const found = getModel("anthropic", modelName) ?? null;
90
+ if (found) return { model: found, attempted };
78
91
  }
79
- // Assume Anthropic shorthand
80
- return getModel("anthropic", modelName) ?? null;
92
+ } else if (parentModel) {
93
+ attempted.push(`${parentModel.provider}/${parentModel.id}`);
94
+ return { model: parentModel, attempted };
81
95
  }
82
- return parentModel ?? null;
96
+ return { model: null, attempted };
83
97
  }
84
98
 
85
99
  // ---------------------------------------------------------------------------
@@ -90,12 +104,14 @@ const TaskItem = Type.Object({
90
104
  agent: Type.String({ description: "Name of the agent to invoke" }),
91
105
  task: Type.String({ description: "Task to delegate to the agent" }),
92
106
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
107
+ timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this task" })),
93
108
  });
94
109
 
95
110
  const ChainItem = Type.Object({
96
111
  agent: Type.String({ description: "Name of the agent to invoke" }),
97
112
  task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
98
113
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
114
+ timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this step" })),
99
115
  });
100
116
 
101
117
  const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
@@ -123,6 +139,8 @@ const SubagentParams = Type.Object({
123
139
  }),
124
140
  ),
125
141
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
142
+ timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
143
+ abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
126
144
  });
127
145
 
128
146
  // ---------------------------------------------------------------------------
@@ -171,6 +189,19 @@ export default function (pi: ExtensionAPI) {
171
189
  return;
172
190
  }
173
191
 
192
+ // Handle listing keywords before agent lookup
193
+ if (cmd === "all" || cmd === "list" || cmd === "agents") {
194
+ const list = formatAgentList(discovery.agents, 20);
195
+ const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
196
+ const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
197
+ pi.sendMessage({
198
+ customType: "pi-subagent",
199
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
200
+ display: true,
201
+ });
202
+ return;
203
+ }
204
+
174
205
  if (cmd) {
175
206
  // Show details for a specific agent
176
207
  const agent = discovery.agents.find(
@@ -308,6 +339,8 @@ export default function (pi: ExtensionAPI) {
308
339
  agentName: string,
309
340
  task: string,
310
341
  cwd: string | undefined,
342
+ parentSignal?: AbortSignal,
343
+ timeoutMs?: number,
311
344
  ): Promise<SubAgentResult> {
312
345
  const agent = agents.find((a) => a.name === agentName);
313
346
 
@@ -324,35 +357,76 @@ export default function (pi: ExtensionAPI) {
324
357
  };
325
358
  }
326
359
 
327
- const model = resolveModel(agent.model, ctx.model);
328
- if (!model) {
360
+ const resolved = resolveModel(agent.model, ctx.model);
361
+ if (!resolved.model) {
362
+ const tried = resolved.attempted.join(", ") || "none";
363
+ const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
329
364
  return {
330
365
  agent: agentName,
331
366
  task,
332
367
  exitCode: 1,
333
368
  messages: [],
334
- stderr: `No model resolved for agent "${agentName}". Configure a model in the agent definition or select one in the parent session.`,
369
+ stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
335
370
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
336
- errorMessage: "No model resolved",
371
+ errorMessage: `No model resolved (tried: ${tried})`,
337
372
  };
338
373
  }
339
374
 
340
375
  // Inject parent's API key so --api-key and other runtime overrides work
341
- await injectApiKey(model);
342
-
343
- const tools = agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
376
+ await injectApiKey(resolved.model);
377
+
378
+ // Resolve tools; strip "subagent" to prevent accidental recursion.
379
+ // Sub-agents cannot spawn further sub-agents (one level of delegation only).
380
+ const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
381
+ let tools = agent.tools ?? defaultTools;
382
+ tools = tools.filter((t) => t !== "subagent");
383
+
384
+ // Build timeout + parent signal into a combined AbortSignal
385
+ let combinedSignal = parentSignal;
386
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
387
+ let timeoutController: AbortController | undefined;
388
+ if (timeoutMs && timeoutMs > 0) {
389
+ timeoutController = new AbortController();
390
+ timeoutId = setTimeout(() => {
391
+ timeoutController!.abort();
392
+ }, timeoutMs);
393
+ // Combine with parent signal if present (Node 20+ AbortSignal.any)
394
+ if (parentSignal && typeof (AbortSignal as any).any === "function") {
395
+ combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
396
+ } else if (parentSignal) {
397
+ combinedSignal = timeoutController.signal;
398
+ // Link parent to timeout: if parent aborts, also abort our timeout controller
399
+ if (parentSignal.aborted) timeoutController.abort();
400
+ else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
401
+ } else {
402
+ combinedSignal = timeoutController.signal;
403
+ }
404
+ }
344
405
 
345
- return runSubAgent({
406
+ const result = await runSubAgent({
346
407
  cwd: cwd ?? ctx.cwd,
347
408
  systemPrompt: agent.systemPrompt,
348
409
  task,
349
410
  tools,
350
- model,
411
+ model: resolved.model,
351
412
  authStorage,
352
413
  modelRegistry,
353
- signal,
414
+ signal: combinedSignal,
354
415
  agentName,
355
416
  });
417
+
418
+ // Clean up timeout
419
+ if (timeoutId) clearTimeout(timeoutId);
420
+
421
+ // Detect timeout: our timeout controller fired, not the parent
422
+ const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
423
+ if (timedOut) {
424
+ result.exitCode = 1;
425
+ result.stopReason = "timeout";
426
+ if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
427
+ }
428
+
429
+ return result;
356
430
  }
357
431
 
358
432
  // --- Chain mode ---
@@ -364,7 +438,10 @@ export default function (pi: ExtensionAPI) {
364
438
  const step = params.chain[i];
365
439
  const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
366
440
 
367
- const result = await runOne(step.agent, taskWithContext, step.cwd);
441
+ const result = await runOne(
442
+ step.agent, taskWithContext, step.cwd,
443
+ signal, step.timeout ?? params.timeout,
444
+ );
368
445
  results.push(result);
369
446
 
370
447
  const isError = isFailedResult(result);
@@ -376,13 +453,21 @@ export default function (pi: ExtensionAPI) {
376
453
  details: makeDetails("chain")(results),
377
454
  });
378
455
  }
456
+ // Include successful previous step outputs in the error content
457
+ const prevCount = i;
458
+ let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
459
+ if (prevCount > 0) {
460
+ const prevSummaries = results
461
+ .slice(0, prevCount)
462
+ .map((r, j) => {
463
+ const out = getResultOutput(r).slice(0, 500);
464
+ return `Step ${j + 1} (${r.agent}): ${out}`;
465
+ })
466
+ .join("\n");
467
+ contentText = `Chain stopped at step ${i + 1}/${params.chain.length}. ${prevCount} previous step(s) succeeded:\n\n${prevSummaries}\n\nError at step ${i + 1} (${step.agent}): ${errorMsg}`;
468
+ }
379
469
  return {
380
- content: [
381
- {
382
- type: "text",
383
- text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
384
- },
385
- ],
470
+ content: [{ type: "text", text: contentText }],
386
471
  details: makeDetails("chain")(results),
387
472
  isError: true,
388
473
  };
@@ -421,6 +506,22 @@ export default function (pi: ExtensionAPI) {
421
506
  };
422
507
  }
423
508
 
509
+ const abortOnFailure = params.abortOnFailure ?? false;
510
+ const parallelController = new AbortController();
511
+
512
+ // Combine parent signal with parallel abort controller
513
+ let parallelSignal: AbortSignal = parallelController.signal;
514
+ if (signal) {
515
+ // Always link parent abort into parallelController so queued tasks see aborted state
516
+ if (signal.aborted) parallelController.abort();
517
+ else signal.addEventListener("abort", () => parallelController.abort(), { once: true });
518
+ if (typeof (AbortSignal as any).any === "function") {
519
+ parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
520
+ } else {
521
+ parallelSignal = parallelController.signal;
522
+ }
523
+ }
524
+
424
525
  const allResults: SubAgentResult[] = new Array(params.tasks.length);
425
526
  // Initialize placeholder results for streaming
426
527
  for (let i = 0; i < params.tasks.length; i++) {
@@ -454,14 +555,40 @@ export default function (pi: ExtensionAPI) {
454
555
  params.tasks,
455
556
  MAX_CONCURRENCY,
456
557
  async (t, index) => {
457
- const result = await runOne(t.agent, t.task, t.cwd);
558
+ // Skip if already aborted by sibling failure or parent abort
559
+ if (parallelSignal.aborted || parallelController.signal.aborted) {
560
+ const skippedResult: SubAgentResult = {
561
+ agent: t.agent,
562
+ task: t.task,
563
+ exitCode: 1,
564
+ messages: [],
565
+ stderr: "",
566
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
567
+ stopReason: "aborted",
568
+ errorMessage: parallelController.signal.aborted
569
+ ? "Cancelled: sibling task failed"
570
+ : "Cancelled: parent operation aborted",
571
+ };
572
+ allResults[index] = skippedResult;
573
+ emitParallelUpdate();
574
+ return skippedResult;
575
+ }
576
+ const result = await runOne(
577
+ t.agent, t.task, t.cwd,
578
+ parallelSignal, t.timeout ?? params.timeout,
579
+ );
458
580
  allResults[index] = result;
581
+ // Early-abort: if this task failed and abortOnFailure is set
582
+ if (abortOnFailure && isFailedResult(result)) {
583
+ parallelController.abort();
584
+ }
459
585
  emitParallelUpdate();
460
586
  return result;
461
587
  },
462
588
  );
463
589
 
464
590
  const successCount = results.filter((r) => !isFailedResult(r)).length;
591
+ const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
465
592
  const summaries = results.map((r) => {
466
593
  const output = truncateParallelOutput(getResultOutput(r));
467
594
  const status = isFailedResult(r)
@@ -470,11 +597,13 @@ export default function (pi: ExtensionAPI) {
470
597
  return `### [${r.agent}] ${status}\n\n${output}`;
471
598
  });
472
599
 
600
+ let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
601
+ if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
473
602
  return {
474
603
  content: [
475
604
  {
476
605
  type: "text",
477
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
606
+ text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
478
607
  },
479
608
  ],
480
609
  details: makeDetails("parallel")(results),
@@ -483,7 +612,10 @@ export default function (pi: ExtensionAPI) {
483
612
 
484
613
  // --- Single mode ---
485
614
  if (params.agent && params.task) {
486
- const result = await runOne(params.agent, params.task, params.cwd);
615
+ const result = await runOne(
616
+ params.agent, params.task, params.cwd,
617
+ signal, params.timeout,
618
+ );
487
619
  const isError = isFailedResult(result);
488
620
 
489
621
  if (onUpdate) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,8 @@
30
30
  "agents.ts",
31
31
  "runner.ts",
32
32
  "render.ts",
33
- "agents/"
33
+ "agents/",
34
+ "agent-format.md"
34
35
  ],
35
36
  "pi": {
36
37
  "extensions": [
package/render.ts CHANGED
@@ -180,10 +180,14 @@ export function renderSingleResult(
180
180
  const mdTheme = getMarkdownTheme();
181
181
  const container = new Container();
182
182
  let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
183
- if (isError && result.stopReason) header += ` ${theme.fg("error", `[${result.stopReason}]`)}`;
183
+ if (isError && result.stopReason) {
184
+ const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
185
+ header += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
186
+ }
184
187
  container.addChild(new Text(header, 0, 0));
185
188
  if (isError && result.errorMessage) {
186
- container.addChild(new Text(theme.fg("error", `Error: ${result.errorMessage}`), 0, 0));
189
+ const messageColor = result.stopReason === "timeout" ? "warning" : "error";
190
+ container.addChild(new Text(theme.fg(messageColor, `Error: ${result.errorMessage}`), 0, 0));
187
191
  }
188
192
  container.addChild(new Spacer(1));
189
193
  container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
@@ -218,8 +222,14 @@ export function renderSingleResult(
218
222
 
219
223
  // Collapsed
220
224
  let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
221
- if (isError && result.stopReason) text += ` ${theme.fg("error", `[${result.stopReason}]`)}`;
222
- if (isError && result.errorMessage) text += `\n${theme.fg("error", `Error: ${result.errorMessage}`)}`;
225
+ if (isError && result.stopReason) {
226
+ const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
227
+ text += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
228
+ }
229
+ if (isError && result.errorMessage) {
230
+ const messageColor = result.stopReason === "timeout" ? "warning" : "error";
231
+ text += `\n${theme.fg(messageColor, `Error: ${result.errorMessage}`)}`;
232
+ }
223
233
  else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
224
234
  else {
225
235
  text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
package/runner.ts CHANGED
@@ -123,6 +123,7 @@ export async function runSubAgent(options: {
123
123
 
124
124
  try {
125
125
  // Wire abort signal
126
+ let cleanupAbort: (() => void) | undefined;
126
127
  if (signal) {
127
128
  const onAbort = () => session.abort();
128
129
  if (signal.aborted) {
@@ -134,10 +135,7 @@ export async function runSubAgent(options: {
134
135
  return result;
135
136
  }
136
137
  signal.addEventListener("abort", onAbort, { once: true });
137
-
138
- // Cleanup listener on completion
139
- const cleanup = () => signal.removeEventListener("abort", onAbort);
140
- // We'll clean up in finally via a flag
138
+ cleanupAbort = () => signal.removeEventListener("abort", onAbort);
141
139
  }
142
140
 
143
141
  // Collect all messages and usage stats from events
@@ -190,6 +188,7 @@ export async function runSubAgent(options: {
190
188
  result.exitCode = 0;
191
189
  return result;
192
190
  } finally {
191
+ cleanupAbort?.();
193
192
  try {
194
193
  session.dispose();
195
194
  } catch {
@@ -210,13 +209,26 @@ export async function runSubAgent(options: {
210
209
  // ---------------------------------------------------------------------------
211
210
 
212
211
  export function getFinalOutput(messages: Message[]): string {
212
+ // Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
213
213
  for (let i = messages.length - 1; i >= 0; i--) {
214
214
  const msg = messages[i];
215
- if (msg.role === "assistant") {
216
- for (const part of msg.content) {
217
- if (part.type === "text") return part.text;
218
- }
215
+ if (msg.role !== "assistant") continue;
216
+ const texts: string[] = [];
217
+ let hasToolCalls = false;
218
+ for (const part of msg.content) {
219
+ if (part.type === "text" && part.text.trim()) texts.push(part.text);
220
+ else if (part.type === "toolCall") hasToolCalls = true;
219
221
  }
222
+ if (texts.length > 0 && !hasToolCalls) return texts.join("");
223
+ }
224
+ // Fallback: last assistant message with any non-empty text (even if it also has tool calls).
225
+ for (let i = messages.length - 1; i >= 0; i--) {
226
+ const msg = messages[i];
227
+ if (msg.role !== "assistant") continue;
228
+ const texts = msg.content
229
+ .filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
230
+ .map((p) => p.text);
231
+ if (texts.length > 0) return texts.join("");
220
232
  }
221
233
  return "";
222
234
  }
@@ -225,7 +237,8 @@ export function isFailedResult(result: SubAgentResult): boolean {
225
237
  return (
226
238
  result.exitCode !== 0 ||
227
239
  result.stopReason === "error" ||
228
- result.stopReason === "aborted"
240
+ result.stopReason === "aborted" ||
241
+ result.stopReason === "timeout"
229
242
  );
230
243
  }
231
244