@tianhai/pi-workflow-kit 0.5.1 → 0.6.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.
Files changed (62) hide show
  1. package/README.md +44 -494
  2. package/docs/developer-usage-guide.md +41 -401
  3. package/docs/oversight-model.md +13 -34
  4. package/docs/workflow-phases.md +32 -46
  5. package/extensions/workflow-guard.ts +67 -0
  6. package/package.json +3 -7
  7. package/skills/brainstorming/SKILL.md +16 -59
  8. package/skills/executing-tasks/SKILL.md +26 -227
  9. package/skills/finalizing/SKILL.md +33 -0
  10. package/skills/writing-plans/SKILL.md +23 -132
  11. package/ROADMAP.md +0 -16
  12. package/agents/code-reviewer.md +0 -18
  13. package/agents/config.ts +0 -5
  14. package/agents/implementer.md +0 -26
  15. package/agents/spec-reviewer.md +0 -13
  16. package/agents/worker.md +0 -17
  17. package/docs/plans/completed/2026-04-09-cleanup-legacy-state-and-enforce-think-phases-design.md +0 -56
  18. package/docs/plans/completed/2026-04-09-cleanup-legacy-state-and-enforce-think-phases-implementation.md +0 -196
  19. package/docs/plans/completed/2026-04-09-workflow-next-autocomplete-design.md +0 -185
  20. package/docs/plans/completed/2026-04-09-workflow-next-autocomplete-implementation.md +0 -334
  21. package/docs/plans/completed/2026-04-09-workflow-next-handoff-state-design.md +0 -251
  22. package/docs/plans/completed/2026-04-09-workflow-next-handoff-state-implementation.md +0 -253
  23. package/extensions/constants.ts +0 -15
  24. package/extensions/lib/logging.ts +0 -138
  25. package/extensions/plan-tracker.ts +0 -502
  26. package/extensions/subagent/agents.ts +0 -144
  27. package/extensions/subagent/concurrency.ts +0 -52
  28. package/extensions/subagent/env.ts +0 -47
  29. package/extensions/subagent/index.ts +0 -1181
  30. package/extensions/subagent/lifecycle.ts +0 -25
  31. package/extensions/subagent/timeout.ts +0 -13
  32. package/extensions/workflow-monitor/debug-monitor.ts +0 -98
  33. package/extensions/workflow-monitor/git.ts +0 -31
  34. package/extensions/workflow-monitor/heuristics.ts +0 -58
  35. package/extensions/workflow-monitor/investigation.ts +0 -52
  36. package/extensions/workflow-monitor/reference-tool.ts +0 -42
  37. package/extensions/workflow-monitor/skip-confirmation.ts +0 -19
  38. package/extensions/workflow-monitor/tdd-monitor.ts +0 -137
  39. package/extensions/workflow-monitor/test-runner.ts +0 -37
  40. package/extensions/workflow-monitor/verification-monitor.ts +0 -61
  41. package/extensions/workflow-monitor/warnings.ts +0 -81
  42. package/extensions/workflow-monitor/workflow-handler.ts +0 -358
  43. package/extensions/workflow-monitor/workflow-next-completions.ts +0 -68
  44. package/extensions/workflow-monitor/workflow-next-state.ts +0 -112
  45. package/extensions/workflow-monitor/workflow-tracker.ts +0 -253
  46. package/extensions/workflow-monitor/workflow-transitions.ts +0 -55
  47. package/extensions/workflow-monitor.ts +0 -872
  48. package/skills/dispatching-parallel-agents/SKILL.md +0 -194
  49. package/skills/receiving-code-review/SKILL.md +0 -196
  50. package/skills/systematic-debugging/SKILL.md +0 -170
  51. package/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  52. package/skills/systematic-debugging/condition-based-waiting.md +0 -115
  53. package/skills/systematic-debugging/defense-in-depth.md +0 -122
  54. package/skills/systematic-debugging/find-polluter.sh +0 -63
  55. package/skills/systematic-debugging/reference/rationalizations.md +0 -61
  56. package/skills/systematic-debugging/root-cause-tracing.md +0 -169
  57. package/skills/test-driven-development/SKILL.md +0 -266
  58. package/skills/test-driven-development/reference/examples.md +0 -101
  59. package/skills/test-driven-development/reference/rationalizations.md +0 -67
  60. package/skills/test-driven-development/reference/when-stuck.md +0 -33
  61. package/skills/test-driven-development/testing-anti-patterns.md +0 -299
  62. package/skills/using-git-worktrees/SKILL.md +0 -231
@@ -1,1181 +0,0 @@
1
- /**
2
- * Subagent Tool - Delegate tasks to specialized agents
3
- *
4
- * Spawns a separate `pi` process for each subagent invocation,
5
- * giving it an isolated context window.
6
- *
7
- * Supports three modes:
8
- * - Single: { agent: "name", task: "..." }
9
- * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
10
- * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
11
- *
12
- * Uses JSON mode to capture structured output from subagents.
13
- */
14
-
15
- import { spawn } from "node:child_process";
16
- import * as fs from "node:fs";
17
- import * as os from "node:os";
18
- import * as path from "node:path";
19
- import type { Message } from "@mariozechner/pi-ai";
20
- import { StringEnum } from "@mariozechner/pi-ai";
21
- import type { AgentToolResult } from "@mariozechner/pi-coding-agent";
22
- import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
23
- import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
24
- import { Type } from "@sinclair/typebox";
25
- import { DEFAULT_MODEL } from "../../agents/config.js";
26
- import { log } from "../lib/logging.js";
27
- import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
28
- import { getSubagentConcurrency, Semaphore } from "./concurrency.js";
29
- import { buildSubagentEnv } from "./env.js";
30
- import { ProcessTracker } from "./lifecycle.js";
31
- import { getSubagentTimeoutMs } from "./timeout.js";
32
-
33
- const MAX_PARALLEL_TASKS = 8;
34
- const COLLAPSED_ITEM_COUNT = 10;
35
- export const INACTIVITY_TIMEOUT_MS = 120_000;
36
-
37
- function formatTokens(count: number): string {
38
- if (count < 1000) return count.toString();
39
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
40
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
41
- return `${(count / 1000000).toFixed(1)}M`;
42
- }
43
-
44
- function formatUsageStats(
45
- usage: {
46
- input: number;
47
- output: number;
48
- cacheRead: number;
49
- cacheWrite: number;
50
- cost: number;
51
- contextTokens?: number;
52
- turns?: number;
53
- },
54
- model?: string,
55
- ): string {
56
- const parts: string[] = [];
57
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
58
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
59
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
60
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
61
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
62
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
63
- if (usage.contextTokens && usage.contextTokens > 0) {
64
- parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
65
- }
66
- if (model) parts.push(model);
67
- return parts.join(" ");
68
- }
69
-
70
- function formatToolCall(
71
- toolName: string,
72
- args: Record<string, unknown>,
73
- // biome-ignore lint/suspicious/noExplicitAny: pi SDK theme callback type
74
- themeFg: (color: any, text: string) => string,
75
- ): string {
76
- const shortenPath = (p: string) => {
77
- const home = os.homedir();
78
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
79
- };
80
-
81
- switch (toolName) {
82
- case "bash": {
83
- const command = (args.command as string) || "...";
84
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
85
- return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
86
- }
87
- case "read": {
88
- const rawPath = (args.file_path || args.path || "...") as string;
89
- const filePath = shortenPath(rawPath);
90
- const offset = args.offset as number | undefined;
91
- const limit = args.limit as number | undefined;
92
- let text = themeFg("accent", filePath);
93
- if (offset !== undefined || limit !== undefined) {
94
- const startLine = offset ?? 1;
95
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
96
- text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
97
- }
98
- return themeFg("muted", "read ") + text;
99
- }
100
- case "write": {
101
- const rawPath = (args.file_path || args.path || "...") as string;
102
- const filePath = shortenPath(rawPath);
103
- const content = (args.content || "") as string;
104
- const lines = content.split("\n").length;
105
- let text = themeFg("muted", "write ") + themeFg("accent", filePath);
106
- if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
107
- return text;
108
- }
109
- case "edit": {
110
- const rawPath = (args.file_path || args.path || "...") as string;
111
- return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
112
- }
113
- case "ls": {
114
- const rawPath = (args.path || ".") as string;
115
- return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
116
- }
117
- case "find": {
118
- const pattern = (args.pattern || "*") as string;
119
- const rawPath = (args.path || ".") as string;
120
- return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
121
- }
122
- case "grep": {
123
- const pattern = (args.pattern || "") as string;
124
- const rawPath = (args.path || ".") as string;
125
- return (
126
- themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`)
127
- );
128
- }
129
- default: {
130
- const argsStr = JSON.stringify(args);
131
- const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
132
- return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
133
- }
134
- }
135
- }
136
-
137
- interface UsageStats {
138
- input: number;
139
- output: number;
140
- cacheRead: number;
141
- cacheWrite: number;
142
- cost: number;
143
- contextTokens: number;
144
- turns: number;
145
- }
146
-
147
- interface SingleResult {
148
- agent: string;
149
- agentSource: "user" | "project" | "bundled" | "unknown";
150
- task: string;
151
- exitCode: number;
152
- messages: Message[];
153
- stderr: string;
154
- usage: UsageStats;
155
- model?: string;
156
- modelProvider?: string;
157
- modelSource?: "agent" | "parent" | "default";
158
- stopReason?: string;
159
- errorMessage?: string;
160
- step?: number;
161
- }
162
-
163
- interface SubagentDetails {
164
- mode: "single" | "parallel" | "chain";
165
- agentScope: AgentScope;
166
- projectAgentsDir: string | null;
167
- results: SingleResult[];
168
- }
169
-
170
- interface ParentModelInfo {
171
- id: string;
172
- provider: string;
173
- }
174
-
175
- interface ResolvedModelSelection {
176
- model: string;
177
- provider?: string;
178
- source: "agent" | "parent" | "default";
179
- }
180
-
181
- function resolveModelSelection(
182
- agentModel: string | undefined,
183
- parentModel: ParentModelInfo | undefined,
184
- ): ResolvedModelSelection {
185
- if (agentModel) {
186
- return { model: agentModel, provider: undefined, source: "agent" };
187
- }
188
-
189
- if (parentModel?.id) {
190
- return { model: parentModel.id, provider: parentModel.provider, source: "parent" };
191
- }
192
-
193
- return { model: DEFAULT_MODEL, provider: undefined, source: "default" };
194
- }
195
-
196
- function getFinalOutput(messages: Message[]): string {
197
- for (let i = messages.length - 1; i >= 0; i--) {
198
- const msg = messages[i];
199
- if (msg.role === "assistant") {
200
- for (const part of msg.content) {
201
- if (part.type === "text") return part.text;
202
- }
203
- }
204
- }
205
- return "";
206
- }
207
-
208
- function buildModelArgs(selection: ResolvedModelSelection): string[] {
209
- const args: string[] = [];
210
- if (selection.provider) args.push("--provider", selection.provider);
211
- args.push("--model", selection.model);
212
- return args;
213
- }
214
-
215
- function formatModelSelection(
216
- result: Pick<SingleResult, "model" | "modelProvider" | "modelSource">,
217
- ): string | undefined {
218
- if (!result.model) return undefined;
219
- const modelLabel = result.modelProvider ? `${result.modelProvider}/${result.model}` : result.model;
220
- switch (result.modelSource) {
221
- case "parent":
222
- return `${modelLabel} (inherited from parent session)`;
223
- case "agent":
224
- return `${modelLabel} (pinned by agent config)`;
225
- case "default":
226
- return `${modelLabel} (default fallback)`;
227
- default:
228
- return modelLabel;
229
- }
230
- }
231
-
232
- function buildFailureMessage(prefix: string, result: SingleResult): string {
233
- const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
234
- const modelSelection = formatModelSelection(result);
235
- return modelSelection ? `${prefix}: ${errorMsg}\nModel: ${modelSelection}` : `${prefix}: ${errorMsg}`;
236
- }
237
-
238
- // biome-ignore lint/suspicious/noExplicitAny: pi SDK message content type
239
- type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
240
-
241
- function getDisplayItems(messages: Message[]): DisplayItem[] {
242
- const items: DisplayItem[] = [];
243
- for (const msg of messages) {
244
- if (msg.role === "assistant") {
245
- for (const part of msg.content) {
246
- if (part.type === "text") items.push({ type: "text", text: part.text });
247
- else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
248
- }
249
- }
250
- }
251
- return items;
252
- }
253
-
254
- function isTestCommand(cmd: string): boolean {
255
- return (
256
- /\bvitest\b/.test(cmd) ||
257
- /\bpytest\b/.test(cmd) ||
258
- /\bnpm\s+test\b/.test(cmd) ||
259
- /\bpnpm\s+test\b/.test(cmd) ||
260
- /\byarn\s+test\b/.test(cmd)
261
- );
262
- }
263
-
264
- function collectSummary(messages: Message[]): { filesChanged: string[]; testsRan: boolean } {
265
- const files = new Set<string>();
266
- let testsRan = false;
267
-
268
- for (const msg of messages) {
269
- if (msg.role !== "assistant") continue;
270
- for (const part of msg.content) {
271
- if (part.type !== "toolCall") continue;
272
- // biome-ignore lint/suspicious/noExplicitAny: pi SDK message content type
273
- if ((part.name === "write" || part.name === "edit") && typeof (part.arguments as any)?.path === "string") {
274
- // biome-ignore lint/suspicious/noExplicitAny: pi SDK message content type
275
- files.add((part.arguments as any).path);
276
- }
277
- if (part.name === "bash") {
278
- // biome-ignore lint/suspicious/noExplicitAny: pi SDK message content type
279
- const cmd = (part.arguments as any)?.command;
280
- if (typeof cmd === "string" && isTestCommand(cmd)) testsRan = true;
281
- }
282
- }
283
- }
284
-
285
- return { filesChanged: Array.from(files), testsRan };
286
- }
287
-
288
- export const __internal = { collectSummary, resolveModelSelection };
289
-
290
- async function mapWithConcurrencyLimit<TIn, TOut>(
291
- items: TIn[],
292
- concurrency: number,
293
- fn: (item: TIn, index: number) => Promise<TOut>,
294
- ): Promise<TOut[]> {
295
- if (items.length === 0) return [];
296
- const limit = Math.max(1, Math.min(concurrency, items.length));
297
- const results: TOut[] = new Array(items.length);
298
- let nextIndex = 0;
299
- const workers = new Array(limit).fill(null).map(async () => {
300
- while (true) {
301
- const current = nextIndex++;
302
- if (current >= items.length) return;
303
- results[current] = await fn(items[current], current);
304
- }
305
- });
306
- await Promise.all(workers);
307
- return results;
308
- }
309
-
310
- function writePromptToTempFile(tmpDir: string, agentName: string, prompt: string): { filePath: string } {
311
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
312
- const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
313
- fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
314
- return { filePath };
315
- }
316
-
317
- type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
318
-
319
- async function runSingleAgent(
320
- defaultCwd: string,
321
- agents: AgentConfig[],
322
- agentName: string,
323
- task: string,
324
- cwd: string | undefined,
325
- step: number | undefined,
326
- parentModel: ParentModelInfo | undefined,
327
- signal: AbortSignal | undefined,
328
- onUpdate: OnUpdateCallback | undefined,
329
- makeDetails: (results: SingleResult[]) => SubagentDetails,
330
- processTracker: ProcessTracker,
331
- semaphore: Semaphore,
332
- ): Promise<SingleResult> {
333
- const agent = agents.find((a) => a.name === agentName);
334
-
335
- if (!agent) {
336
- const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
337
- return {
338
- agent: agentName,
339
- agentSource: "unknown",
340
- task,
341
- exitCode: 1,
342
- messages: [],
343
- stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
344
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
345
- step,
346
- };
347
- }
348
-
349
- const args: string[] = ["--mode", "json", "-p", "--no-session"];
350
- const selectedModel = resolveModelSelection(agent.model, parentModel);
351
- args.push(...buildModelArgs(selectedModel));
352
- if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
353
- if (agent.extensions) {
354
- for (const ext of agent.extensions) {
355
- args.push("--extension", path.resolve(path.dirname(agent.filePath), ext));
356
- }
357
- }
358
-
359
- let tmpDir: string | null = null;
360
- let tmpPromptPath: string | null = null;
361
- const currentResult: SingleResult = {
362
- agent: agentName,
363
- agentSource: agent.source,
364
- task,
365
- exitCode: 0,
366
- messages: [],
367
- stderr: "",
368
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
369
- model: selectedModel.model,
370
- modelProvider: selectedModel.provider,
371
- modelSource: selectedModel.source,
372
- step,
373
- };
374
-
375
- const emitUpdate = () => {
376
- if (onUpdate) {
377
- onUpdate({
378
- content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
379
- details: makeDetails([currentResult]),
380
- });
381
- }
382
- };
383
-
384
- if (semaphore.active >= semaphore.limit) {
385
- log.debug(`Subagent queued — ${semaphore.active}/${semaphore.limit} slots in use`);
386
- }
387
- const release = await semaphore.acquire();
388
- try {
389
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
390
- if (agent.systemPrompt.trim()) {
391
- const tmp = writePromptToTempFile(tmpDir, agent.name, agent.systemPrompt);
392
- tmpPromptPath = tmp.filePath;
393
- args.push("--append-system-prompt", tmpPromptPath);
394
- }
395
-
396
- args.push(`Task: ${task}`);
397
-
398
- const resolvedCwd = path.resolve(cwd ?? defaultCwd);
399
- let cwdError: string | undefined;
400
- try {
401
- const stat = fs.statSync(resolvedCwd);
402
- if (!stat.isDirectory()) cwdError = `Subagent cwd is not a directory: ${resolvedCwd}`;
403
- } catch {
404
- cwdError = `Subagent cwd does not exist: ${resolvedCwd}`;
405
- }
406
- if (cwdError) {
407
- return {
408
- agent: agentName,
409
- agentSource: agent.source,
410
- task,
411
- exitCode: 1,
412
- messages: [],
413
- stderr: cwdError,
414
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
415
- step,
416
- errorMessage: cwdError,
417
- };
418
- }
419
-
420
- let wasAborted = false;
421
-
422
- const exitCode = await new Promise<number>((resolve) => {
423
- const proc = spawn("pi", args, {
424
- cwd: resolvedCwd,
425
- shell: false,
426
- stdio: ["ignore", "pipe", "pipe"],
427
- env: buildSubagentEnv(),
428
- });
429
- processTracker.add(proc);
430
- let buffer = "";
431
- let inactivityTimer: ReturnType<typeof setTimeout> | null = null;
432
- let absoluteTimer: ReturnType<typeof setTimeout> | null = null;
433
- let exitResolved = false;
434
-
435
- const resolveOnce = (code: number) => {
436
- if (exitResolved) return;
437
- exitResolved = true;
438
- if (inactivityTimer) clearTimeout(inactivityTimer);
439
- if (absoluteTimer) clearTimeout(absoluteTimer);
440
- resolve(code);
441
- };
442
-
443
- let resetInactivityTimer = () => {};
444
-
445
- const processLine = (line: string) => {
446
- if (!line.trim()) return;
447
- // biome-ignore lint/suspicious/noExplicitAny: pi SDK JSON event type
448
- let event: any;
449
- try {
450
- event = JSON.parse(line);
451
- } catch (_err) {
452
- log.debug(`Ignoring non-JSON line from subagent stdout: ${line.slice(0, 120)}`);
453
- return;
454
- }
455
-
456
- if (event.type === "message_end" && event.message) {
457
- const msg = event.message as Message;
458
- currentResult.messages.push(msg);
459
-
460
- if (msg.role === "assistant") {
461
- currentResult.usage.turns++;
462
- const usage = msg.usage;
463
- if (usage) {
464
- currentResult.usage.input += usage.input || 0;
465
- currentResult.usage.output += usage.output || 0;
466
- currentResult.usage.cacheRead += usage.cacheRead || 0;
467
- currentResult.usage.cacheWrite += usage.cacheWrite || 0;
468
- currentResult.usage.cost += usage.cost?.total || 0;
469
- currentResult.usage.contextTokens = usage.totalTokens || 0;
470
- }
471
- if (!currentResult.model && msg.model) currentResult.model = msg.model;
472
- if (msg.stopReason) currentResult.stopReason = msg.stopReason;
473
- if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
474
- }
475
- emitUpdate();
476
- resetInactivityTimer();
477
- }
478
-
479
- if (event.type === "tool_result_end" && event.message) {
480
- currentResult.messages.push(event.message as Message);
481
- emitUpdate();
482
- }
483
- };
484
-
485
- resetInactivityTimer = () => {
486
- if (inactivityTimer) clearTimeout(inactivityTimer);
487
- inactivityTimer = setTimeout(() => {
488
- if (exitResolved) return;
489
- log.debug(`Subagent killed after ${INACTIVITY_TIMEOUT_MS}ms of inactivity`);
490
- currentResult.errorMessage = `Subagent killed after ${INACTIVITY_TIMEOUT_MS / 1000}s of inactivity`;
491
- if (buffer.trim()) processLine(buffer);
492
- proc.kill("SIGTERM");
493
- setTimeout(() => {
494
- try {
495
- proc.kill("SIGKILL");
496
- } catch {
497
- /* already exited */
498
- }
499
- }, 5000);
500
- resolveOnce(1);
501
- }, INACTIVITY_TIMEOUT_MS);
502
- };
503
-
504
- resetInactivityTimer();
505
-
506
- // Absolute timeout — kills regardless of activity
507
- const absoluteTimeoutMs = getSubagentTimeoutMs(agent.timeout);
508
- absoluteTimer = setTimeout(() => {
509
- if (exitResolved) return;
510
- const seconds = Math.round(absoluteTimeoutMs / 1000);
511
- log.debug(`Subagent killed after ${seconds}s absolute timeout`);
512
- currentResult.errorMessage = `Subagent timed out after ${seconds}s`;
513
- if (buffer.trim()) processLine(buffer);
514
- proc.kill("SIGTERM");
515
- setTimeout(() => {
516
- try {
517
- proc.kill("SIGKILL");
518
- } catch {
519
- /* already exited */
520
- }
521
- }, 5000);
522
- resolveOnce(1);
523
- }, absoluteTimeoutMs);
524
-
525
- proc.stdout.on("data", (data) => {
526
- buffer += data.toString();
527
- const lines = buffer.split("\n");
528
- buffer = lines.pop() || "";
529
- for (const line of lines) processLine(line);
530
- });
531
-
532
- proc.stderr.on("data", (data) => {
533
- currentResult.stderr += data.toString();
534
- });
535
-
536
- proc.on("exit", (code) => {
537
- processTracker.remove(proc);
538
- if (exitResolved) return;
539
- if (inactivityTimer) clearTimeout(inactivityTimer);
540
- // Wait for any remaining stdout data to arrive after process exit, then drain.
541
- // 2s is generous for local I/O; the process has already exited at this point.
542
- setTimeout(() => {
543
- if (buffer.trim()) processLine(buffer);
544
- resolveOnce(code ?? 0);
545
- }, 2000);
546
- });
547
-
548
- proc.on("error", () => {
549
- processTracker.remove(proc);
550
- if (inactivityTimer) clearTimeout(inactivityTimer);
551
- resolveOnce(1);
552
- });
553
-
554
- if (signal) {
555
- const killProc = () => {
556
- wasAborted = true;
557
- proc.kill("SIGTERM");
558
- setTimeout(() => {
559
- try {
560
- proc.kill("SIGKILL");
561
- } catch {
562
- /* already exited */
563
- }
564
- }, 5000);
565
- };
566
- if (signal.aborted) killProc();
567
- else signal.addEventListener("abort", killProc, { once: true });
568
- }
569
- });
570
-
571
- currentResult.exitCode = exitCode;
572
- if (wasAborted) throw new Error("Subagent was aborted");
573
- return currentResult;
574
- } finally {
575
- release();
576
- if (tmpPromptPath)
577
- try {
578
- fs.unlinkSync(tmpPromptPath);
579
- } catch (err) {
580
- log.debug(
581
- `Failed to clean up temp prompt file: ${tmpPromptPath} — ${err instanceof Error ? err.message : err}`,
582
- );
583
- }
584
- if (tmpDir)
585
- try {
586
- fs.rmSync(tmpDir, { recursive: true, force: true });
587
- } catch (err) {
588
- log.debug(`Failed to clean up temp directory: ${tmpDir} — ${err instanceof Error ? err.message : err}`);
589
- }
590
- }
591
- }
592
-
593
- const TaskItem = Type.Object({
594
- agent: Type.String({ description: "Name of the agent to invoke" }),
595
- task: Type.String({ description: "Task to delegate to the agent" }),
596
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
597
- });
598
-
599
- const ChainItem = Type.Object({
600
- agent: Type.String({ description: "Name of the agent to invoke" }),
601
- task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
602
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
603
- });
604
-
605
- const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
606
- description:
607
- 'Which agent directories to use. Default: "user". Use "both" to include bundled and project-local agents.',
608
- default: "user",
609
- });
610
-
611
- const SubagentParams = Type.Object({
612
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
613
- task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
614
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
615
- chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
616
- agentScope: Type.Optional(AgentScopeSchema),
617
- confirmProjectAgents: Type.Optional(
618
- Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
619
- ),
620
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
621
- });
622
-
623
- export default function (pi: ExtensionAPI) {
624
- const processTracker = new ProcessTracker();
625
- const semaphore = new Semaphore(getSubagentConcurrency());
626
-
627
- process.on("exit", () => processTracker.killAll());
628
-
629
- pi.registerTool({
630
- name: "subagent",
631
- label: "Subagent",
632
- description: [
633
- "Delegate tasks to specialized subagents with isolated context.",
634
- "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
635
- 'Default agent scope is "user" (from ~/.pi/agent/agents).',
636
- 'Bundled agents (worker, implementer, code-reviewer, spec-reviewer) require agentScope: "both" or "project".',
637
- 'To also include project-local agents in .pi/agents, set agentScope: "both".',
638
- ].join(" "),
639
- parameters: SubagentParams,
640
-
641
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
642
- const agentScope: AgentScope = params.agentScope ?? "user";
643
- const discovery = discoverAgents(ctx.cwd, agentScope);
644
- const agents = discovery.agents;
645
- const confirmProjectAgents = params.confirmProjectAgents ?? true;
646
-
647
- const hasChain = (params.chain?.length ?? 0) > 0;
648
- const hasTasks = (params.tasks?.length ?? 0) > 0;
649
- const hasSingle = Boolean(params.agent && params.task);
650
- const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
651
-
652
- const makeDetails =
653
- (mode: "single" | "parallel" | "chain") =>
654
- (results: SingleResult[]): SubagentDetails => ({
655
- mode,
656
- agentScope,
657
- projectAgentsDir: discovery.projectAgentsDir,
658
- results,
659
- });
660
- const parentModel = ctx.model ? { id: ctx.model.id, provider: ctx.model.provider } : undefined;
661
-
662
- if (modeCount !== 1) {
663
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
664
- return {
665
- content: [
666
- {
667
- type: "text",
668
- text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
669
- },
670
- ],
671
- details: makeDetails("single")([]),
672
- };
673
- }
674
-
675
- if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
676
- const requestedAgentNames = new Set<string>();
677
- if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
678
- if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
679
- if (params.agent) requestedAgentNames.add(params.agent);
680
-
681
- const projectAgentsRequested = Array.from(requestedAgentNames)
682
- .map((name) => agents.find((a) => a.name === name))
683
- .filter((a): a is AgentConfig => a?.source === "project");
684
-
685
- if (projectAgentsRequested.length > 0) {
686
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
687
- const dir = discovery.projectAgentsDir ?? "(unknown)";
688
- const ok = await ctx.ui.confirm(
689
- "Run project-local agents?",
690
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
691
- );
692
- if (!ok)
693
- return {
694
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
695
- details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
696
- };
697
- }
698
- }
699
-
700
- if (params.chain && params.chain.length > 0) {
701
- const results: SingleResult[] = [];
702
- let previousOutput = "";
703
-
704
- for (let i = 0; i < params.chain.length; i++) {
705
- const step = params.chain[i];
706
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
707
-
708
- // Create update callback that includes all previous results
709
- const chainUpdate: OnUpdateCallback | undefined = onUpdate
710
- ? (partial) => {
711
- // Combine completed results with current streaming result
712
- const currentResult = partial.details?.results[0];
713
- if (currentResult) {
714
- const allResults = [...results, currentResult];
715
- onUpdate({
716
- content: partial.content,
717
- details: makeDetails("chain")(allResults),
718
- });
719
- }
720
- }
721
- : undefined;
722
-
723
- const result = await runSingleAgent(
724
- ctx.cwd,
725
- agents,
726
- step.agent,
727
- taskWithContext,
728
- step.cwd,
729
- i + 1,
730
- parentModel,
731
- signal,
732
- chainUpdate,
733
- makeDetails("chain"),
734
- processTracker,
735
- semaphore,
736
- );
737
- results.push(result);
738
-
739
- const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
740
- if (isError) {
741
- return {
742
- content: [
743
- { type: "text", text: buildFailureMessage(`Chain stopped at step ${i + 1} (${step.agent})`, result) },
744
- ],
745
- details: makeDetails("chain")(results),
746
- isError: true,
747
- };
748
- }
749
- previousOutput = getFinalOutput(result.messages);
750
- }
751
- return {
752
- content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
753
- details: makeDetails("chain")(results),
754
- };
755
- }
756
-
757
- if (params.tasks && params.tasks.length > 0) {
758
- if (params.tasks.length > MAX_PARALLEL_TASKS)
759
- return {
760
- content: [
761
- {
762
- type: "text",
763
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
764
- },
765
- ],
766
- details: makeDetails("parallel")([]),
767
- };
768
-
769
- // Track all results for streaming updates
770
- const allResults: SingleResult[] = new Array(params.tasks.length);
771
-
772
- // Initialize placeholder results
773
- for (let i = 0; i < params.tasks.length; i++) {
774
- allResults[i] = {
775
- agent: params.tasks[i].agent,
776
- agentSource: "unknown",
777
- task: params.tasks[i].task,
778
- exitCode: -1, // -1 = still running
779
- messages: [],
780
- stderr: "",
781
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
782
- };
783
- }
784
-
785
- const emitParallelUpdate = () => {
786
- if (onUpdate) {
787
- const running = allResults.filter((r) => r.exitCode === -1).length;
788
- const done = allResults.filter((r) => r.exitCode !== -1).length;
789
- onUpdate({
790
- content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
791
- details: makeDetails("parallel")([...allResults]),
792
- });
793
- }
794
- };
795
-
796
- const results = await mapWithConcurrencyLimit(params.tasks, params.tasks.length, async (t, index) => {
797
- const result = await runSingleAgent(
798
- ctx.cwd,
799
- agents,
800
- t.agent,
801
- t.task,
802
- t.cwd,
803
- undefined,
804
- parentModel,
805
- signal,
806
- // Per-task update callback
807
- (partial) => {
808
- if (partial.details?.results[0]) {
809
- allResults[index] = partial.details.results[0];
810
- emitParallelUpdate();
811
- }
812
- },
813
- makeDetails("parallel"),
814
- processTracker,
815
- semaphore,
816
- );
817
- allResults[index] = result;
818
- emitParallelUpdate();
819
- return result;
820
- });
821
-
822
- const successCount = results.filter((r) => r.exitCode === 0).length;
823
- const summaries = results.map((r) => {
824
- const output = getFinalOutput(r.messages);
825
- const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
826
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
827
- });
828
- return {
829
- content: [
830
- {
831
- type: "text",
832
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
833
- },
834
- ],
835
- details: makeDetails("parallel")(results),
836
- };
837
- }
838
-
839
- if (params.agent && params.task) {
840
- const result = await runSingleAgent(
841
- ctx.cwd,
842
- agents,
843
- params.agent,
844
- params.task,
845
- params.cwd,
846
- undefined,
847
- parentModel,
848
- signal,
849
- onUpdate,
850
- makeDetails("single"),
851
- processTracker,
852
- semaphore,
853
- );
854
- const summary = collectSummary(result.messages);
855
- const stableDetails = {
856
- ...makeDetails("single")([result]),
857
- status:
858
- result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"
859
- ? ("failed" as const)
860
- : ("completed" as const),
861
- agent: result.agent,
862
- task: result.task,
863
- result: getFinalOutput(result.messages),
864
- filesChanged: summary.filesChanged,
865
- testsRan: summary.testsRan,
866
- };
867
- const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
868
- if (isError) {
869
- return {
870
- content: [{ type: "text", text: buildFailureMessage(`Agent ${result.stopReason || "failed"}`, result) }],
871
- details: stableDetails,
872
- isError: true,
873
- };
874
- }
875
- return {
876
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
877
- details: stableDetails,
878
- };
879
- }
880
-
881
- const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
882
- return {
883
- content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
884
- details: makeDetails("single")([]),
885
- };
886
- },
887
-
888
- renderCall(args, theme) {
889
- const scope: AgentScope = args.agentScope ?? "user";
890
- if (args.chain && args.chain.length > 0) {
891
- let text =
892
- theme.fg("toolTitle", theme.bold("subagent ")) +
893
- theme.fg("accent", `chain (${args.chain.length} steps)`) +
894
- theme.fg("muted", ` [${scope}]`);
895
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
896
- const step = args.chain[i];
897
- // Clean up {previous} placeholder for display
898
- const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
899
- const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
900
- text +=
901
- "\n " +
902
- theme.fg("muted", `${i + 1}.`) +
903
- " " +
904
- theme.fg("accent", step.agent) +
905
- theme.fg("dim", ` ${preview}`);
906
- }
907
- if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
908
- return new Text(text, 0, 0);
909
- }
910
- if (args.tasks && args.tasks.length > 0) {
911
- let text =
912
- theme.fg("toolTitle", theme.bold("subagent ")) +
913
- theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
914
- theme.fg("muted", ` [${scope}]`);
915
- for (const t of args.tasks.slice(0, 3)) {
916
- const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
917
- text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
918
- }
919
- if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
920
- return new Text(text, 0, 0);
921
- }
922
- const agentName = args.agent || "...";
923
- const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
924
- let text =
925
- theme.fg("toolTitle", theme.bold("subagent ")) +
926
- theme.fg("accent", agentName) +
927
- theme.fg("muted", ` [${scope}]`);
928
- text += `\n ${theme.fg("dim", preview)}`;
929
- return new Text(text, 0, 0);
930
- },
931
-
932
- renderResult(result, { expanded }, theme) {
933
- const details = result.details as SubagentDetails | undefined;
934
- if (!details || details.results.length === 0) {
935
- const text = result.content[0];
936
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
937
- }
938
-
939
- const mdTheme = getMarkdownTheme();
940
-
941
- const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
942
- const toShow = limit ? items.slice(-limit) : items;
943
- const skipped = limit && items.length > limit ? items.length - limit : 0;
944
- let text = "";
945
- if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
946
- for (const item of toShow) {
947
- if (item.type === "text") {
948
- const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
949
- text += `${theme.fg("toolOutput", preview)}\n`;
950
- } else {
951
- text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
952
- }
953
- }
954
- return text.trimEnd();
955
- };
956
-
957
- if (details.mode === "single" && details.results.length === 1) {
958
- const r = details.results[0];
959
- const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
960
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
961
- const displayItems = getDisplayItems(r.messages);
962
- const finalOutput = getFinalOutput(r.messages);
963
-
964
- if (expanded) {
965
- const container = new Container();
966
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
967
- if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
968
- container.addChild(new Text(header, 0, 0));
969
- if (isError && r.errorMessage)
970
- container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
971
- container.addChild(new Spacer(1));
972
- container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
973
- container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
974
- container.addChild(new Spacer(1));
975
- container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
976
- if (displayItems.length === 0 && !finalOutput) {
977
- container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
978
- } else {
979
- for (const item of displayItems) {
980
- if (item.type === "toolCall")
981
- container.addChild(
982
- new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
983
- );
984
- }
985
- if (finalOutput) {
986
- container.addChild(new Spacer(1));
987
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
988
- }
989
- }
990
- const usageStr = formatUsageStats(r.usage, r.model);
991
- if (usageStr) {
992
- container.addChild(new Spacer(1));
993
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
994
- }
995
- return container;
996
- }
997
-
998
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
999
- if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1000
- if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
1001
- else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
1002
- else {
1003
- text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
1004
- if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1005
- }
1006
- const usageStr = formatUsageStats(r.usage, r.model);
1007
- if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
1008
- return new Text(text, 0, 0);
1009
- }
1010
-
1011
- const aggregateUsage = (results: SingleResult[]) => {
1012
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
1013
- for (const r of results) {
1014
- total.input += r.usage.input;
1015
- total.output += r.usage.output;
1016
- total.cacheRead += r.usage.cacheRead;
1017
- total.cacheWrite += r.usage.cacheWrite;
1018
- total.cost += r.usage.cost;
1019
- total.turns += r.usage.turns;
1020
- }
1021
- return total;
1022
- };
1023
-
1024
- if (details.mode === "chain") {
1025
- const successCount = details.results.filter((r) => r.exitCode === 0).length;
1026
- const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
1027
-
1028
- if (expanded) {
1029
- const container = new Container();
1030
- container.addChild(
1031
- new Text(
1032
- icon +
1033
- " " +
1034
- theme.fg("toolTitle", theme.bold("chain ")) +
1035
- theme.fg("accent", `${successCount}/${details.results.length} steps`),
1036
- 0,
1037
- 0,
1038
- ),
1039
- );
1040
-
1041
- for (const r of details.results) {
1042
- const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1043
- const displayItems = getDisplayItems(r.messages);
1044
- const finalOutput = getFinalOutput(r.messages);
1045
-
1046
- container.addChild(new Spacer(1));
1047
- container.addChild(
1048
- new Text(`${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
1049
- );
1050
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
1051
-
1052
- // Show tool calls
1053
- for (const item of displayItems) {
1054
- if (item.type === "toolCall") {
1055
- container.addChild(
1056
- new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
1057
- );
1058
- }
1059
- }
1060
-
1061
- // Show final output as markdown
1062
- if (finalOutput) {
1063
- container.addChild(new Spacer(1));
1064
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1065
- }
1066
-
1067
- const stepUsage = formatUsageStats(r.usage, r.model);
1068
- if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
1069
- }
1070
-
1071
- const usageStr = formatUsageStats(aggregateUsage(details.results));
1072
- if (usageStr) {
1073
- container.addChild(new Spacer(1));
1074
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
1075
- }
1076
- return container;
1077
- }
1078
-
1079
- // Collapsed view
1080
- let text =
1081
- icon +
1082
- " " +
1083
- theme.fg("toolTitle", theme.bold("chain ")) +
1084
- theme.fg("accent", `${successCount}/${details.results.length} steps`);
1085
- for (const r of details.results) {
1086
- const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1087
- const displayItems = getDisplayItems(r.messages);
1088
- text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
1089
- if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
1090
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
1091
- }
1092
- const usageStr = formatUsageStats(aggregateUsage(details.results));
1093
- if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
1094
- text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1095
- return new Text(text, 0, 0);
1096
- }
1097
-
1098
- if (details.mode === "parallel") {
1099
- const running = details.results.filter((r) => r.exitCode === -1).length;
1100
- const successCount = details.results.filter((r) => r.exitCode === 0).length;
1101
- const failCount = details.results.filter((r) => r.exitCode > 0).length;
1102
- const isRunning = running > 0;
1103
- const icon = isRunning
1104
- ? theme.fg("warning", "⏳")
1105
- : failCount > 0
1106
- ? theme.fg("warning", "◐")
1107
- : theme.fg("success", "✓");
1108
- const status = isRunning
1109
- ? `${successCount + failCount}/${details.results.length} done, ${running} running`
1110
- : `${successCount}/${details.results.length} tasks`;
1111
-
1112
- if (expanded && !isRunning) {
1113
- const container = new Container();
1114
- container.addChild(
1115
- new Text(`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, 0, 0),
1116
- );
1117
-
1118
- for (const r of details.results) {
1119
- const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1120
- const displayItems = getDisplayItems(r.messages);
1121
- const finalOutput = getFinalOutput(r.messages);
1122
-
1123
- container.addChild(new Spacer(1));
1124
- container.addChild(new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0));
1125
- container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
1126
-
1127
- // Show tool calls
1128
- for (const item of displayItems) {
1129
- if (item.type === "toolCall") {
1130
- container.addChild(
1131
- new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0),
1132
- );
1133
- }
1134
- }
1135
-
1136
- // Show final output as markdown
1137
- if (finalOutput) {
1138
- container.addChild(new Spacer(1));
1139
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1140
- }
1141
-
1142
- const taskUsage = formatUsageStats(r.usage, r.model);
1143
- if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
1144
- }
1145
-
1146
- const usageStr = formatUsageStats(aggregateUsage(details.results));
1147
- if (usageStr) {
1148
- container.addChild(new Spacer(1));
1149
- container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
1150
- }
1151
- return container;
1152
- }
1153
-
1154
- // Collapsed view (or still running)
1155
- let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
1156
- for (const r of details.results) {
1157
- const rIcon =
1158
- r.exitCode === -1
1159
- ? theme.fg("warning", "⏳")
1160
- : r.exitCode === 0
1161
- ? theme.fg("success", "✓")
1162
- : theme.fg("error", "✗");
1163
- const displayItems = getDisplayItems(r.messages);
1164
- text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
1165
- if (displayItems.length === 0)
1166
- text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
1167
- else text += `\n${renderDisplayItems(displayItems, 5)}`;
1168
- }
1169
- if (!isRunning) {
1170
- const usageStr = formatUsageStats(aggregateUsage(details.results));
1171
- if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
1172
- }
1173
- if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1174
- return new Text(text, 0, 0);
1175
- }
1176
-
1177
- const text = result.content[0];
1178
- return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1179
- },
1180
- });
1181
- }