@aprimediet/minion 1.0.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/subagent.ts ADDED
@@ -0,0 +1,766 @@
1
+ /**
2
+ * The `subagent` tool — delegates tasks to specialized agents, each running as an
3
+ * isolated `pi` subprocess. Single / parallel / chain modes.
4
+ *
5
+ * Ported from pi's bundled `examples/extensions/subagent/index.ts`, with the child
6
+ * `--model` resolved via the per-agent model config (agents.ts).
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
14
+ import type { Message } from "@earendil-works/pi-ai";
15
+ import { StringEnum } from "@earendil-works/pi-ai";
16
+ import {
17
+ CONFIG_DIR_NAME,
18
+ type ExtensionAPI,
19
+ getAgentDir,
20
+ getMarkdownTheme,
21
+ withFileMutationQueue,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
24
+ import { Type } from "typebox";
25
+ import { type AgentConfig, type AgentScope, discoverAgents, resolveAgentModel } from "./agents.ts";
26
+ import { resolveProject } from "./project.ts";
27
+ import { loadTaskInstruction, markTaskDelegating, recordDelegation, updateTaskAfterDelegation } from "./tasks.ts";
28
+
29
+ const MAX_PARALLEL_TASKS = 8;
30
+ const MAX_CONCURRENCY = 4;
31
+ const COLLAPSED_ITEM_COUNT = 10;
32
+ const PER_TASK_OUTPUT_CAP = 50 * 1024;
33
+
34
+ function formatTokens(count: number): string {
35
+ if (count < 1000) return count.toString();
36
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
37
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
38
+ return `${(count / 1000000).toFixed(1)}M`;
39
+ }
40
+
41
+ function formatUsageStats(usage: UsageStats, model?: string): string {
42
+ const parts: string[] = [];
43
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
44
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
45
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
46
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
47
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
48
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
49
+ if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
50
+ if (model) parts.push(model);
51
+ return parts.join(" ");
52
+ }
53
+
54
+ function formatToolCall(
55
+ toolName: string,
56
+ args: Record<string, unknown>,
57
+ themeFg: (color: any, text: string) => string,
58
+ ): string {
59
+ const shortenPath = (p: string) => {
60
+ const home = os.homedir();
61
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
62
+ };
63
+ switch (toolName) {
64
+ case "bash": {
65
+ const command = (args.command as string) || "...";
66
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
67
+ return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
68
+ }
69
+ case "read": {
70
+ const filePath = shortenPath((args.file_path || args.path || "...") as string);
71
+ const offset = args.offset as number | undefined;
72
+ const limit = args.limit as number | undefined;
73
+ let text = themeFg("accent", filePath);
74
+ if (offset !== undefined || limit !== undefined) {
75
+ const startLine = offset ?? 1;
76
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
77
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
78
+ }
79
+ return themeFg("muted", "read ") + text;
80
+ }
81
+ case "write": {
82
+ const filePath = shortenPath((args.file_path || args.path || "...") as string);
83
+ const lines = ((args.content || "") as string).split("\n").length;
84
+ let text = themeFg("muted", "write ") + themeFg("accent", filePath);
85
+ if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
86
+ return text;
87
+ }
88
+ case "edit":
89
+ return themeFg("muted", "edit ") + themeFg("accent", shortenPath((args.file_path || args.path || "...") as string));
90
+ case "ls":
91
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath((args.path || ".") as string));
92
+ case "find":
93
+ return (
94
+ themeFg("muted", "find ") +
95
+ themeFg("accent", (args.pattern || "*") as string) +
96
+ themeFg("dim", ` in ${shortenPath((args.path || ".") as string)}`)
97
+ );
98
+ case "grep":
99
+ return (
100
+ themeFg("muted", "grep ") +
101
+ themeFg("accent", `/${(args.pattern || "") as string}/`) +
102
+ themeFg("dim", ` in ${shortenPath((args.path || ".") as string)}`)
103
+ );
104
+ default: {
105
+ const argsStr = JSON.stringify(args);
106
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
107
+ return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
108
+ }
109
+ }
110
+ }
111
+
112
+ interface UsageStats {
113
+ input: number;
114
+ output: number;
115
+ cacheRead: number;
116
+ cacheWrite: number;
117
+ cost: number;
118
+ contextTokens: number;
119
+ turns: number;
120
+ }
121
+
122
+ interface SingleResult {
123
+ agent: string;
124
+ agentSource: "bundled" | "user" | "project" | "unknown";
125
+ task: string;
126
+ exitCode: number;
127
+ messages: Message[];
128
+ stderr: string;
129
+ usage: UsageStats;
130
+ model?: string;
131
+ stopReason?: string;
132
+ errorMessage?: string;
133
+ step?: number;
134
+ }
135
+
136
+ interface SubagentDetails {
137
+ mode: "single" | "parallel" | "chain";
138
+ agentScope: AgentScope;
139
+ projectAgentsDir: string | null;
140
+ results: SingleResult[];
141
+ }
142
+
143
+ const emptyUsage = (): UsageStats => ({
144
+ input: 0,
145
+ output: 0,
146
+ cacheRead: 0,
147
+ cacheWrite: 0,
148
+ cost: 0,
149
+ contextTokens: 0,
150
+ turns: 0,
151
+ });
152
+
153
+ function getFinalOutput(messages: Message[]): string {
154
+ for (let i = messages.length - 1; i >= 0; i--) {
155
+ const msg = messages[i];
156
+ if (msg.role === "assistant") {
157
+ for (const part of msg.content) if (part.type === "text") return part.text;
158
+ }
159
+ }
160
+ return "";
161
+ }
162
+
163
+ function isFailedResult(r: SingleResult): boolean {
164
+ return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
165
+ }
166
+
167
+ function getResultOutput(r: SingleResult): string {
168
+ if (isFailedResult(r)) return r.errorMessage || r.stderr || getFinalOutput(r.messages) || "(no output)";
169
+ return getFinalOutput(r.messages) || "(no output)";
170
+ }
171
+
172
+ function truncateParallelOutput(output: string): string {
173
+ const byteLength = Buffer.byteLength(output, "utf8");
174
+ if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
175
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
176
+ while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) truncated = truncated.slice(0, -1);
177
+ return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted. Full output preserved in tool details.]`;
178
+ }
179
+
180
+ type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
181
+
182
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
183
+ const items: DisplayItem[] = [];
184
+ for (const msg of messages) {
185
+ if (msg.role === "assistant") {
186
+ for (const part of msg.content) {
187
+ if (part.type === "text") items.push({ type: "text", text: part.text });
188
+ else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
189
+ }
190
+ }
191
+ }
192
+ return items;
193
+ }
194
+
195
+ async function mapWithConcurrencyLimit<TIn, TOut>(
196
+ items: TIn[],
197
+ concurrency: number,
198
+ fn: (item: TIn, index: number) => Promise<TOut>,
199
+ ): Promise<TOut[]> {
200
+ if (items.length === 0) return [];
201
+ const limit = Math.max(1, Math.min(concurrency, items.length));
202
+ const results: TOut[] = new Array(items.length);
203
+ let nextIndex = 0;
204
+ const workers = new Array(limit).fill(null).map(async () => {
205
+ for (;;) {
206
+ const current = nextIndex++;
207
+ if (current >= items.length) return;
208
+ results[current] = await fn(items[current], current);
209
+ }
210
+ });
211
+ await Promise.all(workers);
212
+ return results;
213
+ }
214
+
215
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
216
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "minion-"));
217
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
218
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
219
+ await withFileMutationQueue(filePath, async () => {
220
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
221
+ });
222
+ return { dir: tmpDir, filePath };
223
+ }
224
+
225
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
226
+ const currentScript = process.argv[1];
227
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
228
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
229
+ return { command: process.execPath, args: [currentScript, ...args] };
230
+ }
231
+ const execName = path.basename(process.execPath).toLowerCase();
232
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
233
+ if (!isGenericRuntime) return { command: process.execPath, args };
234
+ return { command: "pi", args };
235
+ }
236
+
237
+ type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
238
+
239
+ async function runSingleAgent(
240
+ defaultCwd: string,
241
+ agents: AgentConfig[],
242
+ agentName: string,
243
+ task: string,
244
+ cwd: string | undefined,
245
+ step: number | undefined,
246
+ signal: AbortSignal | undefined,
247
+ onUpdate: OnUpdateCallback | undefined,
248
+ makeDetails: (results: SingleResult[]) => SubagentDetails,
249
+ ): Promise<SingleResult> {
250
+ const agent = agents.find((a) => a.name === agentName);
251
+ if (!agent) {
252
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
253
+ return {
254
+ agent: agentName,
255
+ agentSource: "unknown",
256
+ task,
257
+ exitCode: 1,
258
+ messages: [],
259
+ stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
260
+ usage: emptyUsage(),
261
+ step,
262
+ };
263
+ }
264
+
265
+ const model = resolveAgentModel(agent, cwd ?? defaultCwd);
266
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
267
+ if (model) args.push("--model", model);
268
+ if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
269
+
270
+ let tmpPromptDir: string | null = null;
271
+ let tmpPromptPath: string | null = null;
272
+
273
+ const currentResult: SingleResult = {
274
+ agent: agentName,
275
+ agentSource: agent.source,
276
+ task,
277
+ exitCode: 0,
278
+ messages: [],
279
+ stderr: "",
280
+ usage: emptyUsage(),
281
+ model,
282
+ step,
283
+ };
284
+
285
+ const emitUpdate = () => {
286
+ onUpdate?.({
287
+ content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
288
+ details: makeDetails([currentResult]),
289
+ });
290
+ };
291
+
292
+ try {
293
+ if (agent.systemPrompt.trim()) {
294
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
295
+ tmpPromptDir = tmp.dir;
296
+ tmpPromptPath = tmp.filePath;
297
+ args.push("--append-system-prompt", tmpPromptPath);
298
+ }
299
+
300
+ args.push(`Task: ${task}`);
301
+ let wasAborted = false;
302
+
303
+ const exitCode = await new Promise<number>((resolve) => {
304
+ const invocation = getPiInvocation(args);
305
+ const proc = spawn(invocation.command, invocation.args, {
306
+ cwd: cwd ?? defaultCwd,
307
+ shell: false,
308
+ stdio: ["ignore", "pipe", "pipe"],
309
+ });
310
+ let buffer = "";
311
+
312
+ const processLine = (line: string) => {
313
+ if (!line.trim()) return;
314
+ let event: any;
315
+ try {
316
+ event = JSON.parse(line);
317
+ } catch {
318
+ return;
319
+ }
320
+ if (event.type === "message_end" && event.message) {
321
+ const msg = event.message as Message;
322
+ currentResult.messages.push(msg);
323
+ if (msg.role === "assistant") {
324
+ currentResult.usage.turns++;
325
+ const usage = (msg as any).usage;
326
+ if (usage) {
327
+ currentResult.usage.input += usage.input || 0;
328
+ currentResult.usage.output += usage.output || 0;
329
+ currentResult.usage.cacheRead += usage.cacheRead || 0;
330
+ currentResult.usage.cacheWrite += usage.cacheWrite || 0;
331
+ currentResult.usage.cost += usage.cost?.total || 0;
332
+ currentResult.usage.contextTokens = usage.totalTokens || 0;
333
+ }
334
+ if (!currentResult.model && (msg as any).model) currentResult.model = (msg as any).model;
335
+ if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
336
+ if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
337
+ }
338
+ emitUpdate();
339
+ }
340
+ if (event.type === "tool_result_end" && event.message) {
341
+ currentResult.messages.push(event.message as Message);
342
+ emitUpdate();
343
+ }
344
+ };
345
+
346
+ proc.stdout.on("data", (data) => {
347
+ buffer += data.toString();
348
+ const lines = buffer.split("\n");
349
+ buffer = lines.pop() || "";
350
+ for (const line of lines) processLine(line);
351
+ });
352
+ proc.stderr.on("data", (data) => {
353
+ currentResult.stderr += data.toString();
354
+ });
355
+ proc.on("close", (code) => {
356
+ if (buffer.trim()) processLine(buffer);
357
+ resolve(code ?? 0);
358
+ });
359
+ proc.on("error", () => resolve(1));
360
+
361
+ if (signal) {
362
+ const killProc = () => {
363
+ wasAborted = true;
364
+ proc.kill("SIGTERM");
365
+ setTimeout(() => {
366
+ if (!proc.killed) proc.kill("SIGKILL");
367
+ }, 5000);
368
+ };
369
+ if (signal.aborted) killProc();
370
+ else signal.addEventListener("abort", killProc, { once: true });
371
+ }
372
+ });
373
+
374
+ currentResult.exitCode = exitCode;
375
+ if (wasAborted) throw new Error("Subagent was aborted");
376
+ return currentResult;
377
+ } finally {
378
+ if (tmpPromptPath)
379
+ try {
380
+ fs.unlinkSync(tmpPromptPath);
381
+ } catch {
382
+ /* ignore */
383
+ }
384
+ if (tmpPromptDir)
385
+ try {
386
+ fs.rmdirSync(tmpPromptDir);
387
+ } catch {
388
+ /* ignore */
389
+ }
390
+ }
391
+ }
392
+
393
+ const TaskItem = Type.Object({
394
+ agent: Type.String({ description: "Name of the agent to invoke" }),
395
+ task: Type.String({ description: "Task to delegate to the agent" }),
396
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
397
+ });
398
+ const ChainItem = Type.Object({
399
+ agent: Type.String({ description: "Name of the agent to invoke" }),
400
+ task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
401
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
402
+ });
403
+ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
404
+ description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
405
+ default: "user",
406
+ });
407
+ const SubagentParams = Type.Object({
408
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
409
+ task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
410
+ taskId: Type.Optional(
411
+ Type.String({
412
+ description:
413
+ "Board task id (single mode): loads its structured instruction, marks it in_progress, and sets it to review (success) or blocked (failure) when done.",
414
+ }),
415
+ ),
416
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
417
+ chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
418
+ agentScope: Type.Optional(AgentScopeSchema),
419
+ confirmProjectAgents: Type.Optional(
420
+ Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
421
+ ),
422
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
423
+ });
424
+
425
+ export function registerSubagentTool(pi: ExtensionAPI): void {
426
+ pi.registerTool({
427
+ name: "subagent",
428
+ label: "Subagent",
429
+ description: [
430
+ "Delegate tasks to specialized subagents with isolated context.",
431
+ "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
432
+ `Default agent scope is "user"+bundled. Set agentScope:"both" to include project-local agents in ${CONFIG_DIR_NAME}/agents.`,
433
+ ].join(" "),
434
+ promptSnippet: "Delegate a task to a specialized subagent (isolated context)",
435
+ promptGuidelines: [
436
+ "Use the subagent tool to delegate well-scoped work — broad code exploration, planning, code review, debugging, writing tests or docs — to specialized agents that run in isolated context windows, especially to preserve your own context.",
437
+ "Use subagent parallel mode (the tasks array) to run independent investigations at once, and chain mode to feed one agent's output into the next via the {previous} placeholder.",
438
+ "Delegate when it is genuinely worthwhile; do small, direct steps yourself instead of delegating them.",
439
+ ],
440
+ parameters: SubagentParams,
441
+
442
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
443
+ const proj = resolveProject(ctx.cwd);
444
+ const taskId = (params as { taskId?: string }).taskId;
445
+ // single-mode: load the structured instruction from a board task when taskId is given
446
+ if (taskId && !params.task && !params.tasks && !params.chain) {
447
+ const loaded = loadTaskInstruction(proj.tasksDir, taskId);
448
+ if (loaded) {
449
+ params.task = loaded.instruction;
450
+ if (!params.agent && loaded.agent) params.agent = loaded.agent;
451
+ }
452
+ if (params.agent) await markTaskDelegating(proj.tasksDir, taskId, params.agent, ctx.cwd).catch(() => {});
453
+ }
454
+
455
+ const __run = async (): Promise<AgentToolResult<SubagentDetails>> => {
456
+ const agentScope: AgentScope = params.agentScope ?? "user";
457
+ const discovery = discoverAgents(ctx.cwd, agentScope);
458
+ const agents = discovery.agents;
459
+ const confirmProjectAgents = params.confirmProjectAgents ?? true;
460
+
461
+ const hasChain = (params.chain?.length ?? 0) > 0;
462
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
463
+ const hasSingle = Boolean(params.agent && params.task);
464
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
465
+
466
+ const makeDetails =
467
+ (mode: "single" | "parallel" | "chain") =>
468
+ (results: SingleResult[]): SubagentDetails => ({
469
+ mode,
470
+ agentScope,
471
+ projectAgentsDir: discovery.projectAgentsDir,
472
+ results,
473
+ });
474
+
475
+ if (modeCount !== 1) {
476
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
477
+ return {
478
+ content: [{ type: "text", text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}` }],
479
+ details: makeDetails("single")([]),
480
+ };
481
+ }
482
+
483
+ if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
484
+ const requested = new Set<string>();
485
+ if (params.chain) for (const s of params.chain) requested.add(s.agent);
486
+ if (params.tasks) for (const t of params.tasks) requested.add(t.agent);
487
+ if (params.agent) requested.add(params.agent);
488
+ const projectRequested = Array.from(requested)
489
+ .map((name) => agents.find((a) => a.name === name))
490
+ .filter((a): a is AgentConfig => a?.source === "project");
491
+ if (projectRequested.length > 0) {
492
+ const names = projectRequested.map((a) => a.name).join(", ");
493
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
494
+ const ok = await ctx.ui.confirm(
495
+ "Run project-local agents?",
496
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
497
+ );
498
+ if (!ok)
499
+ return {
500
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
501
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
502
+ };
503
+ }
504
+ }
505
+
506
+ // chain
507
+ if (params.chain && params.chain.length > 0) {
508
+ const results: SingleResult[] = [];
509
+ let previousOutput = "";
510
+ for (let i = 0; i < params.chain.length; i++) {
511
+ const step = params.chain[i];
512
+ const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
513
+ const chainUpdate: OnUpdateCallback | undefined = onUpdate
514
+ ? (partial) => {
515
+ const current = partial.details?.results[0];
516
+ if (current) onUpdate({ content: partial.content, details: makeDetails("chain")([...results, current]) });
517
+ }
518
+ : undefined;
519
+ const result = await runSingleAgent(
520
+ ctx.cwd,
521
+ agents,
522
+ step.agent,
523
+ taskWithContext,
524
+ step.cwd,
525
+ i + 1,
526
+ signal,
527
+ chainUpdate,
528
+ makeDetails("chain"),
529
+ );
530
+ results.push(result);
531
+ if (isFailedResult(result)) {
532
+ return {
533
+ content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${getResultOutput(result)}` }],
534
+ details: makeDetails("chain")(results),
535
+ isError: true,
536
+ };
537
+ }
538
+ previousOutput = getFinalOutput(result.messages);
539
+ }
540
+ return {
541
+ content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
542
+ details: makeDetails("chain")(results),
543
+ };
544
+ }
545
+
546
+ // parallel
547
+ if (params.tasks && params.tasks.length > 0) {
548
+ if (params.tasks.length > MAX_PARALLEL_TASKS)
549
+ return {
550
+ content: [{ type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` }],
551
+ details: makeDetails("parallel")([]),
552
+ };
553
+ const allResults: SingleResult[] = new Array(params.tasks.length);
554
+ for (let i = 0; i < params.tasks.length; i++) {
555
+ allResults[i] = {
556
+ agent: params.tasks[i].agent,
557
+ agentSource: "unknown",
558
+ task: params.tasks[i].task,
559
+ exitCode: -1,
560
+ messages: [],
561
+ stderr: "",
562
+ usage: emptyUsage(),
563
+ };
564
+ }
565
+ const emitParallelUpdate = () => {
566
+ if (!onUpdate) return;
567
+ const running = allResults.filter((r) => r.exitCode === -1).length;
568
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
569
+ onUpdate({
570
+ content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
571
+ details: makeDetails("parallel")([...allResults]),
572
+ });
573
+ };
574
+ const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
575
+ const result = await runSingleAgent(
576
+ ctx.cwd,
577
+ agents,
578
+ t.agent,
579
+ t.task,
580
+ t.cwd,
581
+ undefined,
582
+ signal,
583
+ (partial) => {
584
+ if (partial.details?.results[0]) {
585
+ allResults[index] = partial.details.results[0];
586
+ emitParallelUpdate();
587
+ }
588
+ },
589
+ makeDetails("parallel"),
590
+ );
591
+ allResults[index] = result;
592
+ emitParallelUpdate();
593
+ return result;
594
+ });
595
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
596
+ const summaries = results.map((r) => {
597
+ const output = truncateParallelOutput(getResultOutput(r));
598
+ const status = isFailedResult(r)
599
+ ? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}`
600
+ : "completed";
601
+ return `### [${r.agent}] ${status}\n\n${output}`;
602
+ });
603
+ return {
604
+ content: [{ type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}` }],
605
+ details: makeDetails("parallel")(results),
606
+ };
607
+ }
608
+
609
+ // single
610
+ if (params.agent && params.task) {
611
+ const result = await runSingleAgent(
612
+ ctx.cwd,
613
+ agents,
614
+ params.agent,
615
+ params.task,
616
+ params.cwd,
617
+ undefined,
618
+ signal,
619
+ onUpdate,
620
+ makeDetails("single"),
621
+ );
622
+ if (isFailedResult(result)) {
623
+ return {
624
+ content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${getResultOutput(result)}` }],
625
+ details: makeDetails("single")([result]),
626
+ isError: true,
627
+ };
628
+ }
629
+ return {
630
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
631
+ details: makeDetails("single")([result]),
632
+ };
633
+ }
634
+
635
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
636
+ return {
637
+ content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
638
+ details: makeDetails("single")([]),
639
+ };
640
+ };
641
+
642
+ const out = await __run();
643
+ // persist a full record of this delegation, and update the linked board task (if any)
644
+ try {
645
+ const details = out.details as SubagentDetails | undefined;
646
+ if (details && details.results.length > 0) {
647
+ await recordDelegation(
648
+ proj.delegationsDir,
649
+ details.mode,
650
+ details.results.map((r) => ({
651
+ agent: r.agent,
652
+ task: r.task,
653
+ output: getResultOutput(r),
654
+ failed: isFailedResult(r),
655
+ stopReason: r.stopReason,
656
+ model: r.model,
657
+ })),
658
+ );
659
+ if (taskId) {
660
+ const ok = out.isError !== true && details.results.every((r) => !isFailedResult(r));
661
+ const summary = details.results.map((r) => getResultOutput(r)).join(" | ");
662
+ await updateTaskAfterDelegation(proj.tasksDir, taskId, summary, ok);
663
+ }
664
+ }
665
+ } catch {
666
+ /* non-fatal */
667
+ }
668
+ return out;
669
+ },
670
+
671
+ renderCall(args, theme) {
672
+ const scope: AgentScope = args.agentScope ?? "user";
673
+ const head = theme.fg("toolTitle", theme.bold("subagent "));
674
+ if (args.chain && args.chain.length > 0) {
675
+ let text = head + theme.fg("accent", `chain (${args.chain.length} steps)`) + theme.fg("muted", ` [${scope}]`);
676
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
677
+ const step = args.chain[i];
678
+ const clean = step.task.replace(/\{previous\}/g, "").trim();
679
+ const preview = clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
680
+ text += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", step.agent)}${theme.fg("dim", ` ${preview}`)}`;
681
+ }
682
+ if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
683
+ return new Text(text, 0, 0);
684
+ }
685
+ if (args.tasks && args.tasks.length > 0) {
686
+ let text = head + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + theme.fg("muted", ` [${scope}]`);
687
+ for (const t of args.tasks.slice(0, 3)) {
688
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
689
+ text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
690
+ }
691
+ if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
692
+ return new Text(text, 0, 0);
693
+ }
694
+ const agentName = args.agent || "...";
695
+ const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
696
+ return new Text(`${head}${theme.fg("accent", agentName)}${theme.fg("muted", ` [${scope}]`)}\n ${theme.fg("dim", preview)}`, 0, 0);
697
+ },
698
+
699
+ renderResult(result, { expanded }, theme) {
700
+ const details = result.details as SubagentDetails | undefined;
701
+ if (!details || details.results.length === 0) {
702
+ const text = result.content[0];
703
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
704
+ }
705
+ const mdTheme = getMarkdownTheme();
706
+
707
+ const renderItems = (items: DisplayItem[], limit?: number): string => {
708
+ const toShow = limit ? items.slice(-limit) : items;
709
+ const skipped = limit && items.length > limit ? items.length - limit : 0;
710
+ let text = "";
711
+ if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
712
+ for (const item of toShow) {
713
+ if (item.type === "text") {
714
+ const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
715
+ text += `${theme.fg("toolOutput", preview)}\n`;
716
+ } else {
717
+ text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
718
+ }
719
+ }
720
+ return text.trimEnd();
721
+ };
722
+
723
+ const resultBlock = (r: SingleResult, container: Container) => {
724
+ const failed = isFailedResult(r);
725
+ const icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓");
726
+ const running = r.exitCode === -1;
727
+ let header = `${running ? theme.fg("warning", "⏳") : icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
728
+ if (r.step) header = `${theme.fg("muted", `${r.step}.`)} ${header}`;
729
+ if (failed && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
730
+ container.addChild(new Text(header, 0, 0));
731
+ if (failed && r.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
732
+ const items = getDisplayItems(r.messages);
733
+ const finalOutput = getFinalOutput(r.messages);
734
+ if (expanded) {
735
+ container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
736
+ container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
737
+ for (const it of items)
738
+ if (it.type === "toolCall")
739
+ container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(it.name, it.args, theme.fg.bind(theme)), 0, 0));
740
+ if (finalOutput) {
741
+ container.addChild(new Spacer(1));
742
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
743
+ }
744
+ } else {
745
+ const body = items.length === 0 ? theme.fg("muted", running ? "(running...)" : "(no output)") : renderItems(items, COLLAPSED_ITEM_COUNT);
746
+ container.addChild(new Text(body, 0, 0));
747
+ }
748
+ const usageStr = formatUsageStats(r.usage, r.model);
749
+ if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
750
+ };
751
+
752
+ const container = new Container();
753
+ if (details.results.length > 1) {
754
+ const done = details.results.filter((r) => r.exitCode !== -1 && !isFailedResult(r)).length;
755
+ container.addChild(
756
+ new Text(theme.fg("toolTitle", theme.bold(`${details.mode} `)) + theme.fg("muted", `${done}/${details.results.length} ok`), 0, 0),
757
+ );
758
+ }
759
+ details.results.forEach((r, i) => {
760
+ if (i > 0) container.addChild(new Spacer(1));
761
+ resultBlock(r, container);
762
+ });
763
+ return container;
764
+ },
765
+ });
766
+ }