@bacnh85/pi-subagent 0.1.2 → 0.3.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.
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: general-purpose
3
+ description: General-purpose sub-agent for any delegated task. Use when no specialized agent fits. Good for complex research, multi-step operations, and code modifications.
4
+ tools: read, bash, edit, write, grep, find, ls
5
+ ---
6
+
7
+ You are a capable coding assistant running as a sub-agent. Complete the delegated task efficiently and return a concise summary of your findings or changes.
8
+
9
+ Guidelines:
10
+ - Use available tools to investigate and act on the task.
11
+ - If the task involves searching, use grep and find to locate relevant code.
12
+ - If the task involves implementation, make focused changes following existing patterns.
13
+ - Return a structured summary: what you found, what you changed, and any recommendations.
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
@@ -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
  // ---------------------------------------------------------------------------
@@ -146,6 +164,31 @@ export default function (pi: ExtensionAPI) {
146
164
  if (event.reason === "reload") invalidateAgentCache();
147
165
  });
148
166
 
167
+ // Proactively steer agents toward sub-agent delegation when users mention it
168
+ pi.on("before_agent_start", async (event) => {
169
+ const prompt = event.prompt.toLowerCase();
170
+ if (/\b(delegate to|use a subagent|run in parallel|spawn an agent|scout|review this|chain|worker agent)\b/.test(prompt)) {
171
+ return {
172
+ systemPrompt:
173
+ event.systemPrompt +
174
+ "\n\nThe subagent tool is available for delegating tasks to specialized agents with isolated context. Use /subagent to list available agents. Bundled: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback). Modes: single, parallel (max 8), chain.",
175
+ };
176
+ }
177
+ });
178
+
179
+ // Inject available agent list into system prompt on every session
180
+ pi.on("before_agent_start", async (event, ctx) => {
181
+ const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
182
+ if (discovery.agents.length > 0) {
183
+ const names = discovery.agents.map(a => a.name).join(", ");
184
+ return {
185
+ systemPrompt:
186
+ event.systemPrompt +
187
+ `\n\nAvailable sub-agents: ${names}. Use /subagent for details.`,
188
+ };
189
+ }
190
+ });
191
+
149
192
  // Resolve bundled agents directory relative to this extension file
150
193
  const bundledAgentsDir = path.resolve(__dirname, "agents");
151
194
 
@@ -232,7 +275,13 @@ export default function (pi: ExtensionAPI) {
232
275
  `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
233
276
  ].join(" "),
234
277
  parameters: SubagentParams,
235
-
278
+ promptSnippet: "Delegate tasks to specialized sub-agents (scout, reviewer, worker, general-purpose)",
279
+ promptGuidelines: [
280
+ "Use subagent to delegate work that would flood the main context with search results or file contents.",
281
+ "Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
282
+ "Bundled agents: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback).",
283
+ "Use /subagent to list all available agents or /subagent <name> for agent details.",
284
+ ],
236
285
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
237
286
  const agentScope: AgentScope = params.agentScope ?? "user";
238
287
  const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
@@ -321,6 +370,8 @@ export default function (pi: ExtensionAPI) {
321
370
  agentName: string,
322
371
  task: string,
323
372
  cwd: string | undefined,
373
+ parentSignal?: AbortSignal,
374
+ timeoutMs?: number,
324
375
  ): Promise<SubAgentResult> {
325
376
  const agent = agents.find((a) => a.name === agentName);
326
377
 
@@ -337,35 +388,76 @@ export default function (pi: ExtensionAPI) {
337
388
  };
338
389
  }
339
390
 
340
- const model = resolveModel(agent.model, ctx.model);
341
- if (!model) {
391
+ const resolved = resolveModel(agent.model, ctx.model);
392
+ if (!resolved.model) {
393
+ const tried = resolved.attempted.join(", ") || "none";
394
+ const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
342
395
  return {
343
396
  agent: agentName,
344
397
  task,
345
398
  exitCode: 1,
346
399
  messages: [],
347
- stderr: `No model resolved for agent "${agentName}". Configure a model in the agent definition or select one in the parent session.`,
400
+ stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
348
401
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
349
- errorMessage: "No model resolved",
402
+ errorMessage: `No model resolved (tried: ${tried})`,
350
403
  };
351
404
  }
352
405
 
353
406
  // Inject parent's API key so --api-key and other runtime overrides work
354
- await injectApiKey(model);
355
-
356
- const tools = agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
407
+ await injectApiKey(resolved.model);
408
+
409
+ // Resolve tools; strip "subagent" to prevent accidental recursion.
410
+ // Sub-agents cannot spawn further sub-agents (one level of delegation only).
411
+ const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
412
+ let tools = agent.tools ?? defaultTools;
413
+ tools = tools.filter((t) => t !== "subagent");
414
+
415
+ // Build timeout + parent signal into a combined AbortSignal
416
+ let combinedSignal = parentSignal;
417
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
418
+ let timeoutController: AbortController | undefined;
419
+ if (timeoutMs && timeoutMs > 0) {
420
+ timeoutController = new AbortController();
421
+ timeoutId = setTimeout(() => {
422
+ timeoutController!.abort();
423
+ }, timeoutMs);
424
+ // Combine with parent signal if present (Node 20+ AbortSignal.any)
425
+ if (parentSignal && typeof (AbortSignal as any).any === "function") {
426
+ combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
427
+ } else if (parentSignal) {
428
+ combinedSignal = timeoutController.signal;
429
+ // Link parent to timeout: if parent aborts, also abort our timeout controller
430
+ if (parentSignal.aborted) timeoutController.abort();
431
+ else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
432
+ } else {
433
+ combinedSignal = timeoutController.signal;
434
+ }
435
+ }
357
436
 
358
- return runSubAgent({
437
+ const result = await runSubAgent({
359
438
  cwd: cwd ?? ctx.cwd,
360
439
  systemPrompt: agent.systemPrompt,
361
440
  task,
362
441
  tools,
363
- model,
442
+ model: resolved.model,
364
443
  authStorage,
365
444
  modelRegistry,
366
- signal,
445
+ signal: combinedSignal,
367
446
  agentName,
368
447
  });
448
+
449
+ // Clean up timeout
450
+ if (timeoutId) clearTimeout(timeoutId);
451
+
452
+ // Detect timeout: our timeout controller fired, not the parent
453
+ const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
454
+ if (timedOut) {
455
+ result.exitCode = 1;
456
+ result.stopReason = "timeout";
457
+ if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
458
+ }
459
+
460
+ return result;
369
461
  }
370
462
 
371
463
  // --- Chain mode ---
@@ -377,7 +469,10 @@ export default function (pi: ExtensionAPI) {
377
469
  const step = params.chain[i];
378
470
  const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
379
471
 
380
- const result = await runOne(step.agent, taskWithContext, step.cwd);
472
+ const result = await runOne(
473
+ step.agent, taskWithContext, step.cwd,
474
+ signal, step.timeout ?? params.timeout,
475
+ );
381
476
  results.push(result);
382
477
 
383
478
  const isError = isFailedResult(result);
@@ -389,13 +484,21 @@ export default function (pi: ExtensionAPI) {
389
484
  details: makeDetails("chain")(results),
390
485
  });
391
486
  }
487
+ // Include successful previous step outputs in the error content
488
+ const prevCount = i;
489
+ let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
490
+ if (prevCount > 0) {
491
+ const prevSummaries = results
492
+ .slice(0, prevCount)
493
+ .map((r, j) => {
494
+ const out = getResultOutput(r).slice(0, 500);
495
+ return `Step ${j + 1} (${r.agent}): ${out}`;
496
+ })
497
+ .join("\n");
498
+ 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}`;
499
+ }
392
500
  return {
393
- content: [
394
- {
395
- type: "text",
396
- text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
397
- },
398
- ],
501
+ content: [{ type: "text", text: contentText }],
399
502
  details: makeDetails("chain")(results),
400
503
  isError: true,
401
504
  };
@@ -434,6 +537,22 @@ export default function (pi: ExtensionAPI) {
434
537
  };
435
538
  }
436
539
 
540
+ const abortOnFailure = params.abortOnFailure ?? false;
541
+ const parallelController = new AbortController();
542
+
543
+ // Combine parent signal with parallel abort controller
544
+ let parallelSignal: AbortSignal = parallelController.signal;
545
+ if (signal) {
546
+ // Always link parent abort into parallelController so queued tasks see aborted state
547
+ if (signal.aborted) parallelController.abort();
548
+ else signal.addEventListener("abort", () => parallelController.abort(), { once: true });
549
+ if (typeof (AbortSignal as any).any === "function") {
550
+ parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
551
+ } else {
552
+ parallelSignal = parallelController.signal;
553
+ }
554
+ }
555
+
437
556
  const allResults: SubAgentResult[] = new Array(params.tasks.length);
438
557
  // Initialize placeholder results for streaming
439
558
  for (let i = 0; i < params.tasks.length; i++) {
@@ -467,14 +586,40 @@ export default function (pi: ExtensionAPI) {
467
586
  params.tasks,
468
587
  MAX_CONCURRENCY,
469
588
  async (t, index) => {
470
- const result = await runOne(t.agent, t.task, t.cwd);
589
+ // Skip if already aborted by sibling failure or parent abort
590
+ if (parallelSignal.aborted || parallelController.signal.aborted) {
591
+ const skippedResult: SubAgentResult = {
592
+ agent: t.agent,
593
+ task: t.task,
594
+ exitCode: 1,
595
+ messages: [],
596
+ stderr: "",
597
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
598
+ stopReason: "aborted",
599
+ errorMessage: parallelController.signal.aborted
600
+ ? "Cancelled: sibling task failed"
601
+ : "Cancelled: parent operation aborted",
602
+ };
603
+ allResults[index] = skippedResult;
604
+ emitParallelUpdate();
605
+ return skippedResult;
606
+ }
607
+ const result = await runOne(
608
+ t.agent, t.task, t.cwd,
609
+ parallelSignal, t.timeout ?? params.timeout,
610
+ );
471
611
  allResults[index] = result;
612
+ // Early-abort: if this task failed and abortOnFailure is set
613
+ if (abortOnFailure && isFailedResult(result)) {
614
+ parallelController.abort();
615
+ }
472
616
  emitParallelUpdate();
473
617
  return result;
474
618
  },
475
619
  );
476
620
 
477
621
  const successCount = results.filter((r) => !isFailedResult(r)).length;
622
+ const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
478
623
  const summaries = results.map((r) => {
479
624
  const output = truncateParallelOutput(getResultOutput(r));
480
625
  const status = isFailedResult(r)
@@ -483,11 +628,13 @@ export default function (pi: ExtensionAPI) {
483
628
  return `### [${r.agent}] ${status}\n\n${output}`;
484
629
  });
485
630
 
631
+ let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
632
+ if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
486
633
  return {
487
634
  content: [
488
635
  {
489
636
  type: "text",
490
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
637
+ text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
491
638
  },
492
639
  ],
493
640
  details: makeDetails("parallel")(results),
@@ -496,7 +643,10 @@ export default function (pi: ExtensionAPI) {
496
643
 
497
644
  // --- Single mode ---
498
645
  if (params.agent && params.task) {
499
- const result = await runOne(params.agent, params.task, params.cwd);
646
+ const result = await runOne(
647
+ params.agent, params.task, params.cwd,
648
+ signal, params.timeout,
649
+ );
500
650
  const isError = isFailedResult(result);
501
651
 
502
652
  if (onUpdate) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.1.2",
3
+ "version": "0.3.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
@@ -66,6 +66,7 @@ export async function runSubAgent(options: {
66
66
  modelRegistry: ModelRegistry;
67
67
  signal?: AbortSignal;
68
68
  agentName?: string;
69
+ onUpdate?: (text: string) => void;
69
70
  }): Promise<SubAgentResult> {
70
71
  const {
71
72
  cwd,
@@ -77,6 +78,7 @@ export async function runSubAgent(options: {
77
78
  modelRegistry,
78
79
  signal,
79
80
  agentName = "subagent",
81
+ onUpdate,
80
82
  } = options;
81
83
 
82
84
  const result: SubAgentResult = {
@@ -123,6 +125,7 @@ export async function runSubAgent(options: {
123
125
 
124
126
  try {
125
127
  // Wire abort signal
128
+ let cleanupAbort: (() => void) | undefined;
126
129
  if (signal) {
127
130
  const onAbort = () => session.abort();
128
131
  if (signal.aborted) {
@@ -134,10 +137,7 @@ export async function runSubAgent(options: {
134
137
  return result;
135
138
  }
136
139
  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
140
+ cleanupAbort = () => signal.removeEventListener("abort", onAbort);
141
141
  }
142
142
 
143
143
  // Collect all messages and usage stats from events
@@ -190,6 +190,7 @@ export async function runSubAgent(options: {
190
190
  result.exitCode = 0;
191
191
  return result;
192
192
  } finally {
193
+ cleanupAbort?.();
193
194
  try {
194
195
  session.dispose();
195
196
  } catch {
@@ -210,13 +211,26 @@ export async function runSubAgent(options: {
210
211
  // ---------------------------------------------------------------------------
211
212
 
212
213
  export function getFinalOutput(messages: Message[]): string {
214
+ // Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
213
215
  for (let i = messages.length - 1; i >= 0; i--) {
214
216
  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
- }
217
+ if (msg.role !== "assistant") continue;
218
+ const texts: string[] = [];
219
+ let hasToolCalls = false;
220
+ for (const part of msg.content) {
221
+ if (part.type === "text" && part.text.trim()) texts.push(part.text);
222
+ else if (part.type === "toolCall") hasToolCalls = true;
219
223
  }
224
+ if (texts.length > 0 && !hasToolCalls) return texts.join("");
225
+ }
226
+ // Fallback: last assistant message with any non-empty text (even if it also has tool calls).
227
+ for (let i = messages.length - 1; i >= 0; i--) {
228
+ const msg = messages[i];
229
+ if (msg.role !== "assistant") continue;
230
+ const texts = msg.content
231
+ .filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
232
+ .map((p) => p.text);
233
+ if (texts.length > 0) return texts.join("");
220
234
  }
221
235
  return "";
222
236
  }
@@ -225,7 +239,8 @@ export function isFailedResult(result: SubAgentResult): boolean {
225
239
  return (
226
240
  result.exitCode !== 0 ||
227
241
  result.stopReason === "error" ||
228
- result.stopReason === "aborted"
242
+ result.stopReason === "aborted" ||
243
+ result.stopReason === "timeout"
229
244
  );
230
245
  }
231
246