@bacnh85/pi-subagent 0.1.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.
package/index.ts ADDED
@@ -0,0 +1,701 @@
1
+ /**
2
+ * pi-subagent — Minimal-overhead sub-agent extension for pi.
3
+ *
4
+ * Provides a `subagent` tool that delegates tasks to specialized agents
5
+ * running in isolated in-process SDK sessions. Supports three modes:
6
+ *
7
+ * - Single: { agent: "scout", task: "find auth code" }
8
+ * - Parallel: { tasks: [{ agent: "scout", task: "..." }, ...] }
9
+ * - Chain: { chain: [{ agent: "scout", task: "..." }, ...] }
10
+ *
11
+ * Compared to process-spawning, this saves ~4-11K tokens per sub-agent
12
+ * by using the pi SDK directly with a minimal system prompt, no AGENTS.md,
13
+ * no extensions, no skills, no thinking, and no compaction.
14
+ */
15
+
16
+ import * as path from "node:path";
17
+ import type { Model } from "@earendil-works/pi-ai";
18
+ import { getModel } from "@earendil-works/pi-ai/compat";
19
+ import { StringEnum } from "@earendil-works/pi-ai";
20
+ import {
21
+ AuthStorage,
22
+ CONFIG_DIR_NAME,
23
+ type ExtensionAPI,
24
+ getAgentDir,
25
+ getMarkdownTheme,
26
+ ModelRegistry,
27
+ } from "@earendil-works/pi-coding-agent";
28
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
29
+ import { Type } from "typebox";
30
+
31
+ import { type AgentConfig, type AgentScope, discoverAgents, invalidateAgentCache } from "./agents.ts";
32
+ import {
33
+ type SubAgentResult,
34
+ getFinalOutput,
35
+ getResultOutput,
36
+ isFailedResult,
37
+ mapWithConcurrencyLimit,
38
+ runSubAgent,
39
+ } from "./runner.ts";
40
+ import {
41
+ aggregateUsage,
42
+ formatUsageStats,
43
+ renderSingleResult,
44
+ } from "./render.ts";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Constants
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const MAX_PARALLEL_TASKS = 8;
51
+ const MAX_CONCURRENCY = 4;
52
+ const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Helpers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function truncateParallelOutput(output: string): string {
59
+ const byteLength = Buffer.byteLength(output, "utf8");
60
+ if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
61
+
62
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
63
+ while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
64
+ truncated = truncated.slice(0, -1);
65
+ }
66
+ return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
67
+ }
68
+
69
+ function resolveModel(
70
+ modelName: string | undefined,
71
+ parentModel: Model | undefined,
72
+ ): Model | null {
73
+ if (modelName) {
74
+ // Try as provider/id first, then fall back to anthropic/id
75
+ const parts = modelName.split("/");
76
+ if (parts.length === 2) {
77
+ return getModel(parts[0], parts[1]) ?? null;
78
+ }
79
+ // Assume Anthropic shorthand
80
+ return getModel("anthropic", modelName) ?? null;
81
+ }
82
+ return parentModel ?? null;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Tool parameter schema
87
+ // ---------------------------------------------------------------------------
88
+
89
+ const TaskItem = Type.Object({
90
+ agent: Type.String({ description: "Name of the agent to invoke" }),
91
+ task: Type.String({ description: "Task to delegate to the agent" }),
92
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
93
+ });
94
+
95
+ const ChainItem = Type.Object({
96
+ agent: Type.String({ description: "Name of the agent to invoke" }),
97
+ task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
98
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
99
+ });
100
+
101
+ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
102
+ description:
103
+ 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
104
+ default: "user",
105
+ });
106
+
107
+ const SubagentParams = Type.Object({
108
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
109
+ task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
110
+ tasks: Type.Optional(
111
+ Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" }),
112
+ ),
113
+ chain: Type.Optional(
114
+ Type.Array(ChainItem, {
115
+ description: "Array of {agent, task} for sequential execution with {previous}",
116
+ }),
117
+ ),
118
+ agentScope: Type.Optional(AgentScopeSchema),
119
+ confirmProjectAgents: Type.Optional(
120
+ Type.Boolean({
121
+ description: "Prompt before running project-local agents. Default: true.",
122
+ default: true,
123
+ }),
124
+ ),
125
+ cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Details type
130
+ // ---------------------------------------------------------------------------
131
+
132
+ interface SubagentDetails {
133
+ mode: "single" | "parallel" | "chain";
134
+ agentScope: AgentScope;
135
+ projectAgentsDir: string | null;
136
+ results: SubAgentResult[];
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Extension entry point
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export default function (pi: ExtensionAPI) {
144
+ // Invalidate agent cache on reload so edited agent files take effect
145
+ pi.on("session_start", (event) => {
146
+ if (event.reason === "reload") invalidateAgentCache();
147
+ });
148
+
149
+ // Resolve bundled agents directory relative to this extension file
150
+ const bundledAgentsDir = path.resolve(__dirname, "agents");
151
+
152
+ pi.registerTool({
153
+ name: "subagent",
154
+ label: "Subagent",
155
+ description: [
156
+ "Delegate tasks to specialized subagents with isolated context (SDK-based, minimal overhead).",
157
+ "Modes: single (agent + task), parallel (tasks array, max 8, 4 concurrent), chain (sequential with {previous}).",
158
+ `Default agent scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
159
+ `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
160
+ ].join(" "),
161
+ parameters: SubagentParams,
162
+
163
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
164
+ const agentScope: AgentScope = params.agentScope ?? "user";
165
+ const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
166
+ const agents = discovery.agents;
167
+ const confirmProjectAgents = params.confirmProjectAgents ?? true;
168
+
169
+ const hasChain = (params.chain?.length ?? 0) > 0;
170
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
171
+ const hasSingle = Boolean(params.agent && params.task);
172
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
173
+
174
+ const makeDetails =
175
+ (mode: "single" | "parallel" | "chain") =>
176
+ (results: SubAgentResult[]): SubagentDetails => ({
177
+ mode,
178
+ agentScope,
179
+ projectAgentsDir: discovery.projectAgentsDir,
180
+ results,
181
+ });
182
+
183
+ // Validate: exactly one mode
184
+ if (modeCount !== 1) {
185
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
186
+ return {
187
+ content: [
188
+ {
189
+ type: "text",
190
+ text: [
191
+ "Invalid parameters. Provide exactly one mode:",
192
+ " single: { agent, task }",
193
+ " parallel: { tasks: [...] }",
194
+ " chain: { chain: [...] }",
195
+ `Available agents: ${available}`,
196
+ ].join("\n"),
197
+ },
198
+ ],
199
+ details: makeDetails("single")([]),
200
+ };
201
+ }
202
+
203
+ // Confirm project-local agents
204
+ if (
205
+ (agentScope === "project" || agentScope === "both") &&
206
+ confirmProjectAgents &&
207
+ ctx.hasUI
208
+ ) {
209
+ const requestedAgentNames = new Set<string>();
210
+ if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
211
+ if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
212
+ if (params.agent) requestedAgentNames.add(params.agent);
213
+
214
+ const projectAgentsRequested = Array.from(requestedAgentNames)
215
+ .map((name) => agents.find((a) => a.name === name))
216
+ .filter((a): a is AgentConfig => a?.source === "project");
217
+
218
+ if (projectAgentsRequested.length > 0) {
219
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
220
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
221
+ const ok = await ctx.ui.confirm(
222
+ "Run project-local agents?",
223
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
224
+ );
225
+ if (!ok) {
226
+ return {
227
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
228
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
229
+ };
230
+ }
231
+ }
232
+ }
233
+
234
+ // Shared auth/model setup for SDK sessions
235
+ const authStorage = AuthStorage.create();
236
+ const modelRegistry = ModelRegistry.create(authStorage);
237
+
238
+ // Helper: inject parent's API key into child auth storage
239
+ async function injectApiKey(model: Model): Promise<void> {
240
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
241
+ if (auth.ok && auth.apiKey) {
242
+ authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
243
+ }
244
+ }
245
+
246
+ // Helper: run a single agent via SDK
247
+ async function runOne(
248
+ agentName: string,
249
+ task: string,
250
+ cwd: string | undefined,
251
+ ): Promise<SubAgentResult> {
252
+ const agent = agents.find((a) => a.name === agentName);
253
+
254
+ if (!agent) {
255
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
256
+ return {
257
+ agent: agentName,
258
+ task,
259
+ exitCode: 1,
260
+ messages: [],
261
+ stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
262
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
263
+ errorMessage: `Unknown agent: "${agentName}"`,
264
+ };
265
+ }
266
+
267
+ const model = resolveModel(agent.model, ctx.model);
268
+ if (!model) {
269
+ return {
270
+ agent: agentName,
271
+ task,
272
+ exitCode: 1,
273
+ messages: [],
274
+ stderr: `No model resolved for agent "${agentName}". Configure a model in the agent definition or select one in the parent session.`,
275
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
276
+ errorMessage: "No model resolved",
277
+ };
278
+ }
279
+
280
+ // Inject parent's API key so --api-key and other runtime overrides work
281
+ await injectApiKey(model);
282
+
283
+ const tools = agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
284
+
285
+ return runSubAgent({
286
+ cwd: cwd ?? ctx.cwd,
287
+ systemPrompt: agent.systemPrompt,
288
+ task,
289
+ tools,
290
+ model,
291
+ authStorage,
292
+ modelRegistry,
293
+ signal,
294
+ agentName,
295
+ });
296
+ }
297
+
298
+ // --- Chain mode ---
299
+ if (params.chain && params.chain.length > 0) {
300
+ const results: SubAgentResult[] = [];
301
+ let previousOutput = "";
302
+
303
+ for (let i = 0; i < params.chain.length; i++) {
304
+ const step = params.chain[i];
305
+ const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
306
+
307
+ const result = await runOne(step.agent, taskWithContext, step.cwd);
308
+ results.push(result);
309
+
310
+ const isError = isFailedResult(result);
311
+ if (isError) {
312
+ const errorMsg = getResultOutput(result);
313
+ if (onUpdate) {
314
+ onUpdate({
315
+ content: [{ type: "text", text: errorMsg }],
316
+ details: makeDetails("chain")(results),
317
+ });
318
+ }
319
+ return {
320
+ content: [
321
+ {
322
+ type: "text",
323
+ text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
324
+ },
325
+ ],
326
+ details: makeDetails("chain")(results),
327
+ isError: true,
328
+ };
329
+ }
330
+
331
+ previousOutput = getFinalOutput(result.messages);
332
+
333
+ if (onUpdate) {
334
+ onUpdate({
335
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
336
+ details: makeDetails("chain")(results),
337
+ });
338
+ }
339
+ }
340
+
341
+ const last = results[results.length - 1];
342
+ return {
343
+ content: [
344
+ { type: "text", text: getFinalOutput(last.messages) || "(no output)" },
345
+ ],
346
+ details: makeDetails("chain")(results),
347
+ };
348
+ }
349
+
350
+ // --- Parallel mode ---
351
+ if (params.tasks && params.tasks.length > 0) {
352
+ if (params.tasks.length > MAX_PARALLEL_TASKS) {
353
+ return {
354
+ content: [
355
+ {
356
+ type: "text",
357
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
358
+ },
359
+ ],
360
+ details: makeDetails("parallel")([]),
361
+ };
362
+ }
363
+
364
+ const allResults: SubAgentResult[] = new Array(params.tasks.length);
365
+ // Initialize placeholder results for streaming
366
+ for (let i = 0; i < params.tasks.length; i++) {
367
+ allResults[i] = {
368
+ agent: params.tasks[i].agent,
369
+ task: params.tasks[i].task,
370
+ exitCode: -1,
371
+ messages: [],
372
+ stderr: "",
373
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
374
+ };
375
+ }
376
+
377
+ const emitParallelUpdate = () => {
378
+ if (onUpdate) {
379
+ const running = allResults.filter((r) => r.exitCode === -1).length;
380
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
381
+ onUpdate({
382
+ content: [
383
+ {
384
+ type: "text",
385
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
386
+ },
387
+ ],
388
+ details: makeDetails("parallel")([...allResults]),
389
+ });
390
+ }
391
+ };
392
+
393
+ const results = await mapWithConcurrencyLimit(
394
+ params.tasks,
395
+ MAX_CONCURRENCY,
396
+ async (t, index) => {
397
+ const result = await runOne(t.agent, t.task, t.cwd);
398
+ allResults[index] = result;
399
+ emitParallelUpdate();
400
+ return result;
401
+ },
402
+ );
403
+
404
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
405
+ const summaries = results.map((r) => {
406
+ const output = truncateParallelOutput(getResultOutput(r));
407
+ const status = isFailedResult(r)
408
+ ? `failed${r.stopReason ? ` (${r.stopReason})` : ""}`
409
+ : "completed";
410
+ return `### [${r.agent}] ${status}\n\n${output}`;
411
+ });
412
+
413
+ return {
414
+ content: [
415
+ {
416
+ type: "text",
417
+ text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
418
+ },
419
+ ],
420
+ details: makeDetails("parallel")(results),
421
+ };
422
+ }
423
+
424
+ // --- Single mode ---
425
+ if (params.agent && params.task) {
426
+ const result = await runOne(params.agent, params.task, params.cwd);
427
+ const isError = isFailedResult(result);
428
+
429
+ if (onUpdate) {
430
+ onUpdate({
431
+ content: [
432
+ { type: "text", text: getFinalOutput(result.messages) || "(running...)" },
433
+ ],
434
+ details: makeDetails("single")([result]),
435
+ });
436
+ }
437
+
438
+ if (isError) {
439
+ const errorMsg = getResultOutput(result);
440
+ return {
441
+ content: [
442
+ {
443
+ type: "text",
444
+ text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
445
+ },
446
+ ],
447
+ details: makeDetails("single")([result]),
448
+ isError: true,
449
+ };
450
+ }
451
+
452
+ return {
453
+ content: [
454
+ { type: "text", text: getFinalOutput(result.messages) || "(no output)" },
455
+ ],
456
+ details: makeDetails("single")([result]),
457
+ };
458
+ }
459
+
460
+ // Should not reach here due to validation above
461
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
462
+ return {
463
+ content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
464
+ details: makeDetails("single")([]),
465
+ };
466
+ },
467
+
468
+ // ------------------------------------------------------------------
469
+ // TUI rendering
470
+ // ------------------------------------------------------------------
471
+
472
+ renderCall(args, theme, _context) {
473
+ const scope: AgentScope = args.agentScope ?? "user";
474
+ const fg = theme.fg.bind(theme);
475
+
476
+ // Chain
477
+ if (args.chain && args.chain.length > 0) {
478
+ let text =
479
+ fg("toolTitle", theme.bold("subagent ")) +
480
+ fg("accent", `chain (${args.chain.length} steps)`) +
481
+ fg("muted", ` [${scope}]`);
482
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
483
+ const step = args.chain[i];
484
+ const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
485
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
486
+ text +=
487
+ "\n " +
488
+ fg("muted", `${i + 1}.`) +
489
+ " " +
490
+ fg("accent", step.agent) +
491
+ fg("dim", ` ${preview}`);
492
+ }
493
+ if (args.chain.length > 3)
494
+ text += `\n ${fg("muted", `... +${args.chain.length - 3} more`)}`;
495
+ return new Text(text, 0, 0);
496
+ }
497
+
498
+ // Parallel
499
+ if (args.tasks && args.tasks.length > 0) {
500
+ let text =
501
+ fg("toolTitle", theme.bold("subagent ")) +
502
+ fg("accent", `parallel (${args.tasks.length} tasks)`) +
503
+ fg("muted", ` [${scope}]`);
504
+ for (const t of args.tasks.slice(0, 3)) {
505
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
506
+ text += `\n ${fg("accent", t.agent)}${fg("dim", ` ${preview}`)}`;
507
+ }
508
+ if (args.tasks.length > 3)
509
+ text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
510
+ return new Text(text, 0, 0);
511
+ }
512
+
513
+ // Single
514
+ const agentName = args.agent || "...";
515
+ const preview = args.task
516
+ ? args.task.length > 60
517
+ ? `${args.task.slice(0, 60)}...`
518
+ : args.task
519
+ : "...";
520
+ let text =
521
+ fg("toolTitle", theme.bold("subagent ")) +
522
+ fg("accent", agentName) +
523
+ fg("muted", ` [${scope}]`);
524
+ text += `\n ${fg("dim", preview)}`;
525
+ return new Text(text, 0, 0);
526
+ },
527
+
528
+ renderResult(result, { expanded }, theme, _context) {
529
+ const details = result.details as SubagentDetails | undefined;
530
+ if (!details || details.results.length === 0) {
531
+ const text = result.content[0];
532
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
533
+ }
534
+
535
+ const fg = theme.fg.bind(theme);
536
+ const mdTheme = getMarkdownTheme();
537
+
538
+ // --- Single ---
539
+ if (details.mode === "single" && details.results.length === 1) {
540
+ return renderSingleResult(details.results[0], expanded, theme);
541
+ }
542
+
543
+ // --- Chain ---
544
+ if (details.mode === "chain") {
545
+ const successCount = details.results.filter((r) => !isFailedResult(r)).length;
546
+ const icon =
547
+ successCount === details.results.length
548
+ ? fg("success", "✓")
549
+ : fg("error", "✗");
550
+
551
+ if (expanded) {
552
+ const container = new Container();
553
+ container.addChild(
554
+ new Text(
555
+ icon +
556
+ " " +
557
+ fg("toolTitle", theme.bold("chain ")) +
558
+ fg("accent", `${successCount}/${details.results.length} steps`),
559
+ 0,
560
+ 0,
561
+ ),
562
+ );
563
+ for (const r of details.results) {
564
+ container.addChild(new Spacer(1));
565
+ const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
566
+ container.addChild(
567
+ new Text(
568
+ fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
569
+ fg("accent", r.agent) +
570
+ ` ${stepIcon}`,
571
+ 0,
572
+ 0,
573
+ ),
574
+ );
575
+ if (r.errorMessage) {
576
+ container.addChild(
577
+ new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
578
+ );
579
+ }
580
+ const finalOutput = getResultOutput(r);
581
+ if (finalOutput) {
582
+ container.addChild(new Spacer(1));
583
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
584
+ }
585
+ const usageStr = formatUsageStats(r.usage, r.model);
586
+ if (usageStr)
587
+ container.addChild(new Text(fg("dim", usageStr), 0, 0));
588
+ }
589
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
590
+ if (totalUsage) {
591
+ container.addChild(new Spacer(1));
592
+ container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
593
+ }
594
+ return container;
595
+ }
596
+
597
+ let text =
598
+ icon +
599
+ " " +
600
+ fg("toolTitle", theme.bold("chain ")) +
601
+ fg("accent", `${successCount}/${details.results.length} steps`);
602
+ for (const r of details.results) {
603
+ const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
604
+ text += `\n ${stepIcon} ${fg("accent", r.agent)}`;
605
+ }
606
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
607
+ if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
608
+ text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
609
+ return new Text(text, 0, 0);
610
+ }
611
+
612
+ // --- Parallel ---
613
+ if (details.mode === "parallel") {
614
+ const running = details.results.filter((r) => r.exitCode === -1).length;
615
+ const successCount = details.results.filter(
616
+ (r) => r.exitCode !== -1 && !isFailedResult(r),
617
+ ).length;
618
+ const failCount = details.results.filter(
619
+ (r) => r.exitCode !== -1 && isFailedResult(r),
620
+ ).length;
621
+ const isRunning = running > 0;
622
+ const icon = isRunning
623
+ ? fg("warning", "⏳")
624
+ : failCount > 0
625
+ ? fg("warning", "◐")
626
+ : fg("success", "✓");
627
+ const status = isRunning
628
+ ? `${successCount + failCount}/${details.results.length} done, ${running} running`
629
+ : `${successCount}/${details.results.length} tasks`;
630
+
631
+ if (expanded && !isRunning) {
632
+ const container = new Container();
633
+ container.addChild(
634
+ new Text(
635
+ `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`,
636
+ 0,
637
+ 0,
638
+ ),
639
+ );
640
+ for (const r of details.results) {
641
+ container.addChild(new Spacer(1));
642
+ const taskIcon = isFailedResult(r)
643
+ ? fg("error", "✗")
644
+ : fg("success", "✓");
645
+ container.addChild(
646
+ new Text(
647
+ fg("muted", "─── ") + fg("accent", r.agent) + ` ${taskIcon}`,
648
+ 0,
649
+ 0,
650
+ ),
651
+ );
652
+ container.addChild(
653
+ new Text(fg("muted", "Task: ") + fg("dim", r.task), 0, 0),
654
+ );
655
+ if (r.errorMessage) {
656
+ container.addChild(
657
+ new Text(fg("error", `Error: ${r.errorMessage}`), 0, 0),
658
+ );
659
+ }
660
+ const finalOutput = getResultOutput(r);
661
+ if (finalOutput) {
662
+ container.addChild(new Spacer(1));
663
+ container.addChild(
664
+ new Markdown(finalOutput.trim(), 0, 0, mdTheme),
665
+ );
666
+ }
667
+ const taskUsage = formatUsageStats(r.usage, r.model);
668
+ if (taskUsage)
669
+ container.addChild(new Text(fg("dim", taskUsage), 0, 0));
670
+ }
671
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
672
+ if (totalUsage) {
673
+ container.addChild(new Spacer(1));
674
+ container.addChild(new Text(fg("dim", `Total: ${totalUsage}`), 0, 0));
675
+ }
676
+ return container;
677
+ }
678
+
679
+ let text = `${icon} ${fg("toolTitle", theme.bold("parallel "))}${fg("accent", status)}`;
680
+ for (const r of details.results) {
681
+ const taskIcon =
682
+ r.exitCode === -1
683
+ ? fg("warning", "⏳")
684
+ : isFailedResult(r)
685
+ ? fg("error", "✗")
686
+ : fg("success", "✓");
687
+ text += `\n ${taskIcon} ${fg("accent", r.agent)}`;
688
+ }
689
+ if (!isRunning) {
690
+ const totalUsage = formatUsageStats(aggregateUsage(details.results));
691
+ if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
692
+ }
693
+ if (!expanded) text += `\n${fg("muted", "(Ctrl+O to expand)")}`;
694
+ return new Text(text, 0, 0);
695
+ }
696
+
697
+ const fallback = result.content[0];
698
+ return new Text(fallback?.type === "text" ? fallback.text : "(no output)", 0, 0);
699
+ },
700
+ });
701
+ }