pi-orchestrator-config 4.1.5

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 (175) hide show
  1. package/README.md +808 -0
  2. package/agents/api-documenter.md +32 -0
  3. package/agents/bash-expert.md +53 -0
  4. package/agents/code-reviewer-docs.md +145 -0
  5. package/agents/code-reviewer-guidelines.md +97 -0
  6. package/agents/code-reviewer-quality.md +142 -0
  7. package/agents/code-reviewer-security.md +107 -0
  8. package/agents/code-reviewer-spec.md +164 -0
  9. package/agents/debugger.md +63 -0
  10. package/agents/docker-expert.md +40 -0
  11. package/agents/docs-fetcher.md +48 -0
  12. package/agents/git-expert.md +63 -0
  13. package/agents/github-expert.md +83 -0
  14. package/agents/go-expert.md +62 -0
  15. package/agents/issue-reviewer-feasibility.md +60 -0
  16. package/agents/issue-reviewer-scope.md +63 -0
  17. package/agents/issue-reviewer-spec.md +77 -0
  18. package/agents/java-expert.md +31 -0
  19. package/agents/jenkins-expert.md +38 -0
  20. package/agents/kubernetes-expert.md +40 -0
  21. package/agents/planner.md +42 -0
  22. package/agents/python-expert.md +61 -0
  23. package/agents/reviewer.md +26 -0
  24. package/agents/scout.md +40 -0
  25. package/agents/security-auditor.md +254 -0
  26. package/agents/technical-documentation-writer.md +37 -0
  27. package/agents/test-automator.md +55 -0
  28. package/agents/test-runner.md +47 -0
  29. package/agents/ts-expert.md +23 -0
  30. package/agents/worker.md +30 -0
  31. package/extensions/acpx-provider/index.ts +104 -0
  32. package/extensions/acpx-provider/load-runtime.ts +99 -0
  33. package/extensions/acpx-provider/runtime-models.ts +40 -0
  34. package/extensions/cli-provider/agents/claude.ts +128 -0
  35. package/extensions/cli-provider/agents/cursor.ts +84 -0
  36. package/extensions/cli-provider/agents/gemini.ts +100 -0
  37. package/extensions/cli-provider/discover.ts +77 -0
  38. package/extensions/cli-provider/index.ts +57 -0
  39. package/extensions/cli-provider/parsers.ts +304 -0
  40. package/extensions/cli-provider/providers.ts +58 -0
  41. package/extensions/cli-provider/runner.ts +225 -0
  42. package/extensions/cli-provider/runtime-models.ts +32 -0
  43. package/extensions/cli-provider/session-reaper.ts +122 -0
  44. package/extensions/cli-provider/sessions.ts +533 -0
  45. package/extensions/cli-provider/shared/discover-cache.ts +235 -0
  46. package/extensions/cli-provider/types.ts +48 -0
  47. package/extensions/coms/coms-net-server.ts +1642 -0
  48. package/extensions/coms/coms-net-wrapper.ts +521 -0
  49. package/extensions/coms/coms-net.ts +1589 -0
  50. package/extensions/coms/coms-p2p.ts +1879 -0
  51. package/extensions/coms/coms-shared.ts +510 -0
  52. package/extensions/coms/coms-wrapper.ts +138 -0
  53. package/extensions/coms/index.ts +20 -0
  54. package/extensions/coms/themeMap.ts +147 -0
  55. package/extensions/image-gen/image-gen.ts +332 -0
  56. package/extensions/image-gen/index.ts +14 -0
  57. package/extensions/orchestrator/agents.ts +134 -0
  58. package/extensions/orchestrator/ask-user.ts +293 -0
  59. package/extensions/orchestrator/async-agents.ts +1037 -0
  60. package/extensions/orchestrator/async-capability.ts +129 -0
  61. package/extensions/orchestrator/async-runner.ts +324 -0
  62. package/extensions/orchestrator/async-status-parse.ts +31 -0
  63. package/extensions/orchestrator/async-status-ui.ts +201 -0
  64. package/extensions/orchestrator/async-wait.ts +36 -0
  65. package/extensions/orchestrator/btw.ts +210 -0
  66. package/extensions/orchestrator/cron-status-format.ts +126 -0
  67. package/extensions/orchestrator/cron-status-ui.ts +132 -0
  68. package/extensions/orchestrator/cron.ts +730 -0
  69. package/extensions/orchestrator/dreaming.ts +355 -0
  70. package/extensions/orchestrator/enforcement-helpers.ts +530 -0
  71. package/extensions/orchestrator/enforcement-rules.ts +329 -0
  72. package/extensions/orchestrator/enforcement.ts +828 -0
  73. package/extensions/orchestrator/extended-autocomplete.ts +593 -0
  74. package/extensions/orchestrator/git-helpers.ts +420 -0
  75. package/extensions/orchestrator/github-autocomplete.ts +250 -0
  76. package/extensions/orchestrator/icons.ts +17 -0
  77. package/extensions/orchestrator/index.ts +100 -0
  78. package/extensions/orchestrator/memory-embeddings.ts +281 -0
  79. package/extensions/orchestrator/memory-promotion.ts +449 -0
  80. package/extensions/orchestrator/memory-provenance.ts +80 -0
  81. package/extensions/orchestrator/memory-query-class.ts +90 -0
  82. package/extensions/orchestrator/memory-scoring.ts +434 -0
  83. package/extensions/orchestrator/memory-tools.ts +883 -0
  84. package/extensions/orchestrator/memory-tree.ts +212 -0
  85. package/extensions/orchestrator/nvim.ts +169 -0
  86. package/extensions/orchestrator/overlay-dashboard-utils.ts +25 -0
  87. package/extensions/orchestrator/overlay-dashboard.ts +512 -0
  88. package/extensions/orchestrator/pi-config-review-state.ts +303 -0
  89. package/extensions/orchestrator/preference-extractor.ts +106 -0
  90. package/extensions/orchestrator/project-settings.ts +468 -0
  91. package/extensions/orchestrator/promotion-queue.ts +311 -0
  92. package/extensions/orchestrator/resolve-agent-model.ts +32 -0
  93. package/extensions/orchestrator/review-ui.ts +204 -0
  94. package/extensions/orchestrator/rule-placeholders.ts +12 -0
  95. package/extensions/orchestrator/rules.ts +481 -0
  96. package/extensions/orchestrator/session-search.ts +243 -0
  97. package/extensions/orchestrator/session-validation.ts +374 -0
  98. package/extensions/orchestrator/situation-report.ts +370 -0
  99. package/extensions/orchestrator/status-bar.ts +31 -0
  100. package/extensions/orchestrator/status-line.ts +192 -0
  101. package/extensions/orchestrator/status.ts +110 -0
  102. package/extensions/orchestrator/subagent-tool.ts +1748 -0
  103. package/extensions/orchestrator/task-lifecycle.ts +88 -0
  104. package/extensions/orchestrator/utils.ts +227 -0
  105. package/extensions/pidash/index.ts +15 -0
  106. package/extensions/pidash/pidash.ts +941 -0
  107. package/extensions/pidiff/index.ts +13 -0
  108. package/extensions/pidiff/pidiff.ts +369 -0
  109. package/extensions/providers/acpx-driver.ts +479 -0
  110. package/extensions/providers/built-in-drivers.ts +52 -0
  111. package/extensions/providers/claude-driver.ts +319 -0
  112. package/extensions/providers/cursor-acpx-driver.ts +398 -0
  113. package/extensions/providers/cursor-cli-driver.ts +484 -0
  114. package/extensions/providers/gemini-driver.ts +302 -0
  115. package/extensions/providers/index.ts +513 -0
  116. package/extensions/shared/build-system-prompt.ts +57 -0
  117. package/extensions/shared/create-runtime-provider.ts +190 -0
  118. package/extensions/shared/daemon-manager.ts +248 -0
  119. package/extensions/shared/file-logger.ts +115 -0
  120. package/extensions/shared/managed-refresh.ts +211 -0
  121. package/extensions/shared/provider-driver.ts +295 -0
  122. package/extensions/shared/provider-errors.ts +305 -0
  123. package/extensions/shared/provider-registry.ts +388 -0
  124. package/extensions/shared/stream-builder.ts +275 -0
  125. package/extensions/shared/types.ts +20 -0
  126. package/extensions/shared/ui/README.md +10 -0
  127. package/extensions/shared/ui/badge.tsx +52 -0
  128. package/extensions/shared/ui/button.tsx +58 -0
  129. package/extensions/shared/ui/collapsible.tsx +21 -0
  130. package/extensions/shared/ui/dropdown-menu.tsx +266 -0
  131. package/extensions/shared/ui/input.tsx +20 -0
  132. package/extensions/shared/ui/popover.tsx +114 -0
  133. package/extensions/shared/ui/resizable.tsx +48 -0
  134. package/extensions/shared/ui/scroll-area.tsx +53 -0
  135. package/extensions/shared/ui/select.tsx +201 -0
  136. package/extensions/shared/ui/separator.tsx +25 -0
  137. package/extensions/shared/ui/tooltip.tsx +64 -0
  138. package/extensions/shared/ws-client.ts +74 -0
  139. package/package.json +59 -0
  140. package/prompts/coderabbit-rate-limit.md +95 -0
  141. package/prompts/create-coms-feature-manager.md +186 -0
  142. package/prompts/create-skill.md +128 -0
  143. package/prompts/domain-model.md +109 -0
  144. package/prompts/external-ai.md +665 -0
  145. package/prompts/implement-and-review.md +25 -0
  146. package/prompts/implement.md +26 -0
  147. package/prompts/issue-review.md +256 -0
  148. package/prompts/pr-review.md +753 -0
  149. package/prompts/query-db.md +154 -0
  150. package/prompts/refine-review.md +208 -0
  151. package/prompts/release.md +235 -0
  152. package/prompts/remember.md +33 -0
  153. package/prompts/review-handler-status.md +27 -0
  154. package/prompts/review-handler.md +677 -0
  155. package/prompts/review-local.md +159 -0
  156. package/prompts/scout-and-plan.md +21 -0
  157. package/rules/00-orchestrator-core.md +42 -0
  158. package/rules/05-issue-first-workflow.md +119 -0
  159. package/rules/10-agent-routing.md +58 -0
  160. package/rules/15-mcp-launchpad.md +46 -0
  161. package/rules/20-code-review-loop.md +234 -0
  162. package/rules/25-documentation-updates.md +15 -0
  163. package/rules/30-prompt-templates.md +19 -0
  164. package/rules/35-memory.md +240 -0
  165. package/rules/40-critical-rules.md +124 -0
  166. package/rules/45-file-preview.md +29 -0
  167. package/rules/50-agent-bug-reporting.md +90 -0
  168. package/rules/55-coms-protocol.md +63 -0
  169. package/rules/60-task-tracking.md +66 -0
  170. package/skills/conflict-resolver/SKILL.md +213 -0
  171. package/skills/skill-creator/SKILL.md +85 -0
  172. package/skills/systematic-debugging/SKILL.md +353 -0
  173. package/skills/test-driven-development/SKILL.md +335 -0
  174. package/skills/update-agents-md/SKILL.md +282 -0
  175. package/templates/coms-feature-manager-prompt.md +313 -0
@@ -0,0 +1,1748 @@
1
+ /**
2
+ * Subagent tool — delegates tasks to specialist subagents.
3
+ */
4
+
5
+ import { spawn } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ import { createRequire } from "node:module";
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
+ import type { Message } from "@earendil-works/pi-ai";
17
+ import { StringEnum } from "@earendil-works/pi-ai";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ getMarkdownTheme,
21
+ withFileMutationQueue,
22
+ } from "@earendil-works/pi-coding-agent";
23
+ import {
24
+ Container,
25
+ Markdown,
26
+ Spacer,
27
+ Text,
28
+ } from "@earendil-works/pi-tui";
29
+ import { Type } from "typebox";
30
+ import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
31
+
32
+ // Import TaskStore for taskId validation (bypasses raw file reads)
33
+ let TaskStoreClass: any = null;
34
+ (async () => {
35
+ const candidates = [
36
+ "@tintinweb/pi-tasks/dist/task-store.js",
37
+ pathToFileURL(path.join(os.homedir(), ".pi/agent/npm/node_modules/@tintinweb/pi-tasks/dist/task-store.js")).href,
38
+ ];
39
+ for (const c of candidates) {
40
+ try { const mod = await import(c); if (mod.TaskStore) { TaskStoreClass = mod.TaskStore; break; } } catch { continue; }
41
+ }
42
+ if (!TaskStoreClass) {
43
+ throw new Error("[subagent] FATAL: TaskStore not found — @tintinweb/pi-tasks is required but failed to load");
44
+ }
45
+ })();
46
+ import {
47
+ decideAsyncLlmDispatch,
48
+ supportsAsyncLlm,
49
+ } from "./async-capability.js";
50
+ import { autoMarkInProgress, autoCompleteTask } from "./async-agents.js";
51
+ import { resolveAgentModelProvider } from "./resolve-agent-model.js";
52
+ import { clockHHMM, getPiInvocation, getProjectTmpDir, djb2Hash } from "./utils.js";
53
+
54
+ export { resolveAgentModelProvider };
55
+
56
+ // ── Constants ────────────────────────────────────────────────────────────
57
+
58
+ const MAX_PARALLEL_TASKS = 8;
59
+ const MAX_CONCURRENCY = 4;
60
+ const COLLAPSED_ITEM_COUNT = 10;
61
+ const MISSING_CWD_ERROR = "Missing required parameter: cwd. Always specify the working directory for subagent tasks.";
62
+ const MAX_SYNC_SECONDS = 30;
63
+ const SYNC_TIME_EXCEEDED_ERROR = (seconds: number) =>
64
+ `Estimated time ${seconds}s meets or exceeds ${MAX_SYNC_SECONDS}s sync limit. Use async: true instead.`;
65
+ const MISSING_ESTIMATE_ERROR = "Missing required parameter: estimatedSeconds. Provide an estimated duration in seconds for sync agent tasks.";
66
+
67
+ /** Agents that MUST be dispatched with async: true. Sync calls are rejected.
68
+ * Keep in sync with rules/20-code-review-loop.md when changing this list. */
69
+ const ASYNC_ONLY_AGENTS = new Set([
70
+ "code-reviewer-quality",
71
+ "code-reviewer-guidelines",
72
+ "code-reviewer-security",
73
+ "code-reviewer-docs",
74
+ "code-reviewer-spec",
75
+ ]);
76
+ // ── Schemas ──────────────────────────────────────────────────────────────
77
+
78
+ const TaskItem = Type.Object({
79
+ agent: Type.String({ description: "Agent name" }),
80
+ task: Type.String({ description: "Task to delegate" }),
81
+ cwd: Type.String({ description: "Working directory" }),
82
+ name: Type.Optional(Type.String({ description: "Display name for async status" })),
83
+ estimatedSeconds: Type.Optional(Type.Number({ description: "Estimated task duration in seconds. Required for sync parallel tasks." })),
84
+ taskId: Type.Optional(Type.String({ description: "Task ID to auto-complete when this async agent finishes" })),
85
+ });
86
+ const ChainItem = Type.Object({
87
+ agent: Type.String({ description: "Agent name" }),
88
+ task: Type.String({
89
+ description: "Task with optional {previous} placeholder",
90
+ }),
91
+ cwd: Type.String({ description: "Working directory" }),
92
+ estimatedSeconds: Type.Number({ description: "Estimated step duration in seconds" }),
93
+ });
94
+ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
95
+ description: 'Agent directories to use. Default: "both".',
96
+ default: "both",
97
+ });
98
+
99
+ const SubagentParams = Type.Object({
100
+ agent: Type.Optional(
101
+ Type.String({ description: "Agent name (single mode)" }),
102
+ ),
103
+ task: Type.Optional(
104
+ Type.String({ description: "Task to delegate (single mode)" }),
105
+ ),
106
+ tasks: Type.Optional(
107
+ Type.Array(TaskItem, { description: "Parallel execution" }),
108
+ ),
109
+ chain: Type.Optional(
110
+ Type.Array(ChainItem, { description: "Sequential execution" }),
111
+ ),
112
+ agentScope: Type.Optional(AgentScopeSchema),
113
+ confirmProjectAgents: Type.Optional(
114
+ Type.Boolean({
115
+ description: "Prompt before project agents. Default: true.",
116
+ default: true,
117
+ }),
118
+ ),
119
+ cwd: Type.Optional(
120
+ Type.String({ description: "Working directory. Required for single/async mode." }),
121
+ ),
122
+ // Optional at schema level because async mode doesn't need it; runtime enforces for sync
123
+ estimatedSeconds: Type.Optional(
124
+ Type.Number({ description: "Estimated task duration in seconds. Required for sync agents. If >= 30s, must use async: true instead." }),
125
+ ),
126
+ async: Type.Optional(
127
+ Type.Boolean({ description: "Run in background (default: false). Agent runs detached, results surface when complete.", default: false }),
128
+ ),
129
+ fireAndForget: Type.Optional(
130
+ Type.Boolean({ description: "When true with async, skip result delivery to conversation. Agent runs silently — only terminal notification on completion. Use for maintenance tasks like memory dreaming.", default: false }),
131
+ ),
132
+ name: Type.Optional(
133
+ Type.String({ description: "Display name for async agents in status line and notifications (e.g., 'Dream', 'Code Review'). Defaults to agent name." }),
134
+ ),
135
+ taskId: Type.Optional(
136
+ Type.String({ description: "Task ID to auto-complete when this async agent finishes. Required for async agents — pass \"-1\" if not linked to any task." }),
137
+ ),
138
+ asyncKill: Type.Optional(
139
+ Type.String({ description: "Kill async agent(s) by name, id prefix, or 'all'. Returns which agents were killed." }),
140
+ ),
141
+ persistSession: Type.Optional(
142
+ Type.Boolean({ description: "Persist agent session to disk for reuse across calls. Default: false (ephemeral). When true, the same agent in the same project reuses its session — keeps context, saves tokens." }),
143
+ ),
144
+ });
145
+
146
+ // ── Interfaces ───────────────────────────────────────────────────────────
147
+
148
+ export interface UsageStats {
149
+ input: number;
150
+ output: number;
151
+ cacheRead: number;
152
+ cacheWrite: number;
153
+ cost: number;
154
+ contextTokens: number;
155
+ turns: number;
156
+ }
157
+
158
+ export interface SingleResult {
159
+ agent: string;
160
+ agentSource: "user" | "project" | "package" | "unknown";
161
+ task: string;
162
+ exitCode: number;
163
+ messages: Message[];
164
+ stderr: string;
165
+ usage: UsageStats;
166
+ model?: string;
167
+ stopReason?: string;
168
+ durationMs?: number;
169
+ errorMessage?: string;
170
+ step?: number;
171
+ }
172
+
173
+ export interface SubagentDetails {
174
+ mode: "single" | "parallel" | "chain";
175
+ agentScope: AgentScope;
176
+ projectAgentsDir: string | null;
177
+ results: SingleResult[];
178
+ }
179
+
180
+ export type DisplayItem =
181
+ | { type: "text"; text: string }
182
+ | { type: "toolCall"; name: string; args: Record<string, any> };
183
+
184
+ // ── Helper functions ─────────────────────────────────────────────────────
185
+
186
+ function formatTokens(count: number): string {
187
+ if (count < 1000) return count.toString();
188
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
189
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
190
+ return `${(count / 1000000).toFixed(1)}M`;
191
+ }
192
+
193
+ function formatUsageStats(
194
+ usage: {
195
+ input: number;
196
+ output: number;
197
+ cacheRead: number;
198
+ cacheWrite: number;
199
+ cost: number;
200
+ contextTokens?: number;
201
+ turns?: number;
202
+ durationMs?: number;
203
+ },
204
+ model?: string,
205
+ ): string {
206
+ const parts: string[] = [];
207
+ if (usage.durationMs != null) {
208
+ const s = Math.floor(usage.durationMs / 1000);
209
+ if (s < 60) parts.push(`${s}s`);
210
+ else if (s < 3600) parts.push(`${Math.floor(s / 60)}m${s % 60}s`);
211
+ else parts.push(`${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`);
212
+ }
213
+ if (usage.turns)
214
+ parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
215
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
216
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
217
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
218
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
219
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
220
+ if (usage.contextTokens && usage.contextTokens > 0)
221
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
222
+ if (model) parts.push(model);
223
+ return parts.join(" ");
224
+ }
225
+
226
+ function formatToolCall(
227
+ toolName: string,
228
+ args: Record<string, unknown>,
229
+ fg: (c: any, t: string) => string,
230
+ ): string {
231
+ const shorten = (p: string) => {
232
+ const h = os.homedir();
233
+ return p.startsWith(h) ? `~${p.slice(h.length)}` : p;
234
+ };
235
+ switch (toolName) {
236
+ case "bash": {
237
+ const c = (args.command as string) || "...";
238
+ const p = c.length > 60 ? `${c.slice(0, 60)}...` : c;
239
+ return fg("muted", "$ ") + fg("toolOutput", p);
240
+ }
241
+ case "read": {
242
+ const f = shorten((args.file_path || args.path || "...") as string);
243
+ let t = fg("accent", f);
244
+ const o = args.offset as number | undefined;
245
+ const l = args.limit as number | undefined;
246
+ if (o !== undefined || l !== undefined) {
247
+ const s = o ?? 1;
248
+ const e = l !== undefined ? s + l - 1 : "";
249
+ t += fg("warning", `:${s}${e ? `-${e}` : ""}`);
250
+ }
251
+ return fg("muted", "read ") + t;
252
+ }
253
+ case "write": {
254
+ const f = shorten((args.file_path || args.path || "...") as string);
255
+ const c = (args.content || "") as string;
256
+ let t = fg("muted", "write ") + fg("accent", f);
257
+ if (c.split("\n").length > 1)
258
+ t += fg("dim", ` (${c.split("\n").length} lines)`);
259
+ return t;
260
+ }
261
+ case "edit":
262
+ return (
263
+ fg("muted", "edit ") +
264
+ fg("accent", shorten((args.file_path || args.path || "...") as string))
265
+ );
266
+ case "ls":
267
+ return (
268
+ fg("muted", "ls ") + fg("accent", shorten((args.path || ".") as string))
269
+ );
270
+ case "find":
271
+ return (
272
+ fg("muted", "find ") +
273
+ fg("accent", (args.pattern || "*") as string) +
274
+ fg("dim", ` in ${shorten((args.path || ".") as string)}`)
275
+ );
276
+ case "grep":
277
+ return (
278
+ fg("muted", "grep ") +
279
+ fg("accent", `/${(args.pattern || "") as string}/`) +
280
+ fg("dim", ` in ${shorten((args.path || ".") as string)}`)
281
+ );
282
+ default: {
283
+ const s = JSON.stringify(args);
284
+ return (
285
+ fg("accent", toolName) +
286
+ fg("dim", ` ${s.length > 50 ? s.slice(0, 50) + "..." : s}`)
287
+ );
288
+ }
289
+ }
290
+ }
291
+
292
+ function getFinalOutput(messages: Message[]): string {
293
+ for (let i = messages.length - 1; i >= 0; i--) {
294
+ const m = messages[i];
295
+ if (m.role === "assistant")
296
+ for (const p of m.content) if (p.type === "text") return p.text;
297
+ }
298
+ return "";
299
+ }
300
+
301
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
302
+ const items: DisplayItem[] = [];
303
+ for (const m of messages)
304
+ if (m.role === "assistant")
305
+ for (const p of m.content) {
306
+ if (p.type === "text") items.push({ type: "text", text: p.text });
307
+ else if (p.type === "toolCall")
308
+ items.push({ type: "toolCall", name: p.name, args: p.arguments });
309
+ }
310
+ return items;
311
+ }
312
+
313
+ async function mapWithConcurrency<I, O>(
314
+ items: I[],
315
+ concurrency: number,
316
+ fn: (item: I, i: number) => Promise<O>,
317
+ ): Promise<O[]> {
318
+ if (items.length === 0) return [];
319
+ const limit = Math.max(1, Math.min(concurrency, items.length));
320
+ const results: O[] = new Array(items.length);
321
+ let next = 0;
322
+ await Promise.all(
323
+ Array.from({ length: limit }, async () => {
324
+ while (true) {
325
+ const i = next++;
326
+ if (i >= items.length) return;
327
+ results[i] = await fn(items[i], i);
328
+ }
329
+ }),
330
+ );
331
+ return results;
332
+ }
333
+
334
+ async function writePromptFile(
335
+ name: string,
336
+ prompt: string,
337
+ cwd?: string,
338
+ ): Promise<{ dir: string; filePath: string }> {
339
+ let dir: string;
340
+ const projectDir = getProjectTmpDir(cwd || process.cwd());
341
+ dir = await fs.promises.mkdtemp(path.join(projectDir, "subagent-"));
342
+ const filePath = path.join(
343
+ dir,
344
+ `prompt-${name.replace(/[^\w.-]+/g, "_")}.md`,
345
+ );
346
+ await withFileMutationQueue(filePath, async () => {
347
+ await fs.promises.writeFile(filePath, prompt, {
348
+ encoding: "utf-8",
349
+ mode: 0o600,
350
+ });
351
+ });
352
+ return { dir, filePath };
353
+ }
354
+
355
+ // ── Types ────────────────────────────────────────────────────────────────
356
+
357
+ type OnUpdate = (partial: AgentToolResult<SubagentDetails>) => void;
358
+
359
+ // ── runSingleAgent ───────────────────────────────────────────────────────
360
+
361
+ export async function runSingleAgent(
362
+ agents: AgentConfig[],
363
+ agentName: string,
364
+ task: string,
365
+ cwd: string,
366
+ step: number | undefined,
367
+ signal: AbortSignal | undefined,
368
+ onUpdate: OnUpdate | undefined,
369
+ makeDetails: (r: SingleResult[]) => SubagentDetails,
370
+ parentModelId?: string,
371
+ parentProvider?: string,
372
+ persistSession?: boolean,
373
+ ): Promise<SingleResult> {
374
+ const agent = agents.find((a) => a.name === agentName);
375
+ if (!agent) {
376
+ const avail = agents.map((a) => `"${a.name}"`).join(", ") || "none";
377
+ return {
378
+ agent: agentName,
379
+ agentSource: "unknown",
380
+ task,
381
+ exitCode: 1,
382
+ messages: [],
383
+ stderr: `Unknown agent: "${agentName}". Available: ${avail}.`,
384
+ usage: {
385
+ input: 0,
386
+ output: 0,
387
+ cacheRead: 0,
388
+ cacheWrite: 0,
389
+ cost: 0,
390
+ contextTokens: 0,
391
+ turns: 0,
392
+ },
393
+ step,
394
+ };
395
+ }
396
+
397
+ const args: string[] = ["--mode", "json", "-p"];
398
+ if (!persistSession) {
399
+ args.push("--no-session");
400
+ } else {
401
+ // Deterministic session ID based on agent + cwd for session reuse
402
+ args.push("--session-id", `sync-${agentName}-${djb2Hash(agentName + ':' + cwd).toString(36)}`);
403
+ }
404
+ const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, parentModelId, parentProvider, cwd);
405
+ if (effectiveModel) args.push("--model", effectiveModel);
406
+ if (effectiveProvider) args.push("--provider", effectiveProvider);
407
+ if (agent.tools && agent.tools.length > 0)
408
+ args.push("--tools", agent.tools.join(","));
409
+
410
+ let tmpDir: string | null = null;
411
+ let tmpFile: string | null = null;
412
+
413
+ const startTime = Date.now();
414
+ const cur: SingleResult = {
415
+ agent: agentName,
416
+ agentSource: agent.source,
417
+ task,
418
+ exitCode: 0,
419
+ messages: [],
420
+ stderr: "",
421
+ usage: {
422
+ input: 0,
423
+ output: 0,
424
+ cacheRead: 0,
425
+ cacheWrite: 0,
426
+ cost: 0,
427
+ contextTokens: 0,
428
+ turns: 0,
429
+ },
430
+ model: effectiveModel,
431
+ step,
432
+ };
433
+
434
+ const emit = () => {
435
+ if (onUpdate)
436
+ onUpdate({
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: getFinalOutput(cur.messages) || "(running...)",
441
+ },
442
+ ],
443
+ details: makeDetails([cur]),
444
+ });
445
+ };
446
+
447
+ try {
448
+ if (agent.systemPrompt.trim()) {
449
+ const tmp = await writePromptFile(agent.name, agent.systemPrompt, cwd);
450
+ tmpDir = tmp.dir;
451
+ tmpFile = tmp.filePath;
452
+ args.push("--append-system-prompt", tmpFile);
453
+ }
454
+ args.push(`Task: ${task}`);
455
+ let aborted = false;
456
+
457
+ const exitCode = await new Promise<number>((resolve) => {
458
+ const inv = getPiInvocation(args);
459
+ const proc = spawn(inv.command, inv.args, {
460
+ cwd: cwd,
461
+ shell: false,
462
+ stdio: ["ignore", "pipe", "pipe"],
463
+ env: { ...process.env, PI_SUBAGENT_CHILD: "1", PI_AGENT_NAME: agentName },
464
+ });
465
+ let buf = "";
466
+
467
+ const processLine = (line: string) => {
468
+ if (!line.trim()) return;
469
+ let ev: any;
470
+ try {
471
+ ev = JSON.parse(line);
472
+ } catch {
473
+ return;
474
+ }
475
+ if (ev.type === "message_end" && ev.message) {
476
+ const msg = ev.message as Message;
477
+ cur.messages.push(msg);
478
+ if (msg.role === "assistant") {
479
+ cur.usage.turns++;
480
+ const u = msg.usage;
481
+ if (u) {
482
+ cur.usage.input += u.input || 0;
483
+ cur.usage.output += u.output || 0;
484
+ cur.usage.cacheRead += u.cacheRead || 0;
485
+ cur.usage.cacheWrite += u.cacheWrite || 0;
486
+ cur.usage.cost += u.cost?.total || 0;
487
+ cur.usage.contextTokens = u.totalTokens || 0;
488
+ }
489
+ if (!cur.model && msg.model) cur.model = msg.model;
490
+ if (msg.stopReason) cur.stopReason = msg.stopReason;
491
+ if (msg.errorMessage) cur.errorMessage = msg.errorMessage;
492
+ }
493
+ emit();
494
+ }
495
+ if (ev.type === "tool_result_end" && ev.message) {
496
+ cur.messages.push(ev.message as Message);
497
+ emit();
498
+ }
499
+ };
500
+
501
+ proc.stdout.on("data", (d) => {
502
+ buf += d.toString();
503
+ const lines = buf.split("\n");
504
+ buf = lines.pop() || "";
505
+ for (const l of lines) processLine(l);
506
+ });
507
+ proc.stderr.on("data", (d) => {
508
+ cur.stderr += d.toString();
509
+ });
510
+ proc.on("close", (c) => {
511
+ if (buf.trim()) processLine(buf);
512
+ resolve(c ?? 0);
513
+ });
514
+ proc.on("error", () => resolve(1));
515
+
516
+ if (signal) {
517
+ const kill = () => {
518
+ aborted = true;
519
+ proc.kill("SIGTERM");
520
+ setTimeout(() => {
521
+ if (!proc.killed) proc.kill("SIGKILL");
522
+ }, 5000);
523
+ };
524
+ if (signal.aborted) kill();
525
+ else signal.addEventListener("abort", kill, { once: true });
526
+ }
527
+ });
528
+
529
+ cur.exitCode = exitCode;
530
+ cur.durationMs = Date.now() - startTime;
531
+ if (aborted) throw new Error("Subagent was aborted");
532
+ return cur;
533
+ } finally {
534
+ if (tmpFile)
535
+ try {
536
+ fs.unlinkSync(tmpFile);
537
+ } catch {}
538
+ if (tmpDir)
539
+ try {
540
+ fs.rmdirSync(tmpDir);
541
+ } catch {}
542
+ }
543
+ }
544
+
545
+ /** Validate that a taskId references an existing task in the pi-tasks store. */
546
+ function validateTaskId(taskId: string, cwd: string, sessionId?: string): string | null {
547
+ if (taskId === "-1") return null;
548
+
549
+ const tasksDir = path.join(cwd, ".pi", "tasks");
550
+ const paths: string[] = [];
551
+ if (sessionId) paths.push(path.join(tasksDir, `tasks-${sessionId}.json`));
552
+ paths.push(path.join(tasksDir, "tasks.json"));
553
+
554
+ for (const p of paths) {
555
+ try {
556
+ if (TaskStoreClass) {
557
+ const store = new TaskStoreClass(p);
558
+ if (store.get(taskId)) return null;
559
+ } else {
560
+ // Fallback: raw file read when TaskStore hasn't loaded yet (async init race)
561
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
562
+ if ((data.tasks || []).some((t: any) => t.id === taskId)) return null;
563
+ }
564
+ } catch (e: any) {
565
+ // Only continue silently for missing files; report other errors
566
+ if (e?.code === "ENOENT" || e?.message?.includes("ENOENT")) continue;
567
+ return `Task store error for ${path.basename(p)}: ${e?.message || e}. Fix the task store or pass taskId: "-1".`;
568
+ }
569
+ }
570
+
571
+ return `Task #${taskId} not found. Verify the task ID exists (use TaskList to check), or pass taskId: "-1" if not linked to a task.`;
572
+ }
573
+
574
+ // ── Registration ─────────────────────────────────────────────────────────
575
+
576
+ export function registerSubagentTool(
577
+ pi: ExtensionAPI,
578
+ spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string }) => { id: string; error?: string; model?: string },
579
+ killAsyncAgent: (target: string) => { killed: string[]; errors: string[] },
580
+ ): void {
581
+ // Only the orchestrator (top-level pi) can spawn subagents.
582
+ // Child processes set PI_SUBAGENT_CHILD=1 to prevent infinite recursion.
583
+ if (process.env.PI_SUBAGENT_CHILD === "1") return;
584
+
585
+ // ── Extracted execute helpers (closure over pi, spawnAsyncAgent, killAsyncAgent) ──
586
+
587
+ function executeAsyncKill(
588
+ params: { asyncKill: string },
589
+ mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
590
+ ) {
591
+ const { killed, errors } = killAsyncAgent(params.asyncKill);
592
+ const lines: string[] = [];
593
+ if (killed.length > 0) lines.push(`Killed: ${killed.join(", ")}`);
594
+ if (errors.length > 0) lines.push(errors.join("\n"));
595
+ return {
596
+ content: [{ type: "text" as const, text: lines.join("\n") || "No action taken." }],
597
+ details: mkd("single")([]),
598
+ isError: errors.length > 0 && killed.length === 0,
599
+ };
600
+ }
601
+
602
+ function executeAsync(
603
+ params: any,
604
+ agents: AgentConfig[],
605
+ mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
606
+ parentModelId: string | undefined,
607
+ parentProvider: string | undefined,
608
+ ctx: any,
609
+ ) {
610
+ if (params.chain) {
611
+ return {
612
+ content: [{ type: "text" as const, text: "Async mode does not support chain. Use agent + task or tasks array." }],
613
+ details: mkd("single")([]),
614
+ isError: true,
615
+ };
616
+ }
617
+
618
+ // Parallel async — spawn each task as a separate async agent
619
+ if (params.tasks && params.tasks.length > 0) {
620
+ // Validate all tasks have names
621
+ const unnamed = params.tasks.filter((t: any) => !(t as any).name);
622
+ if (unnamed.length > 0) {
623
+ return {
624
+ content: [{ type: "text" as const, text: `Async agents require a name for display in status line. Missing name for: ${unnamed.map((t: any) => t.agent).join(", ")}` }],
625
+ details: mkd("single")([]),
626
+ isError: true,
627
+ };
628
+ }
629
+ const noTaskId = params.tasks.filter((t: any) => (t as any).taskId == null);
630
+ if (noTaskId.length > 0) {
631
+ const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nMissing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}\n\nIf an agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf an agent is NOT linked to any task: pass taskId: "-1"`;
632
+ // Force a follow-up turn so the AI MUST deal with this error
633
+ pi.sendMessage({
634
+ customType: "subagent-taskid-error",
635
+ content: `🚨 CRITICAL: Async subagent call was REJECTED — missing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}. NO agents were spawned. You MUST retry with taskId for each agent. Use TaskList to find task IDs, or pass taskId: "-1" if not linked.`,
636
+ display: true,
637
+ }, { triggerTurn: true, deliverAs: "followUp" });
638
+ return {
639
+ content: [{ type: "text" as const, text: errorMsg }],
640
+ details: mkd("single")([]),
641
+ isError: true,
642
+ };
643
+ }
644
+ for (const t of params.tasks) {
645
+ const tid = (t as any).taskId as string;
646
+ const taskErr = validateTaskId(tid, ctx.cwd, ctx.sessionManager?.getSessionId?.());
647
+ if (taskErr) {
648
+ pi.sendMessage({
649
+ customType: "subagent-taskid-error",
650
+ content: `🚨 CRITICAL: ${taskErr} (agent: ${t.agent}). NO agents were spawned. Fix the taskId and retry.`,
651
+ display: true,
652
+ }, { triggerTurn: true, deliverAs: "followUp" });
653
+ return {
654
+ content: [{ type: "text" as const, text: `${taskErr} (agent: ${t.agent})` }],
655
+ details: mkd("single")([]),
656
+ isError: true,
657
+ };
658
+ }
659
+ }
660
+ const results: string[] = [];
661
+ const errors: string[] = [];
662
+ // Group parallel async tasks so results are delivered together
663
+ const groupId = params.tasks.length > 1
664
+ ? `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
665
+ : undefined;
666
+ for (const t of params.tasks) {
667
+ const r = spawnAsyncAgent(t.agent, t.task, t.cwd, agents, {
668
+ fireAndForget: params.fireAndForget,
669
+ name: (t as any).name,
670
+ parentModelId,
671
+ parentProvider,
672
+ groupId,
673
+ taskId: (t as any).taskId,
674
+ persistSession: params.persistSession,
675
+ });
676
+ if (r.error) {
677
+ errors.push(`${t.agent}: ${r.error}`);
678
+ } else {
679
+ const label = (t as any).name || t.agent;
680
+ results.push(`${label} [${r.id}]${r.model ? ` (${r.model})` : ""}`);
681
+ }
682
+ }
683
+ const lines: string[] = [];
684
+ if (results.length > 0) lines.push(`Spawned ${results.length} async agents:\n${results.map(r => `- ${r}`).join("\n")}`);
685
+ if (errors.length > 0) lines.push(`Failed:\n${errors.map(e => `- ${e}`).join("\n")}`);
686
+ lines.push("\nUse /async-status to check progress.");
687
+ return {
688
+ content: [{ type: "text" as const, text: lines.join("\n") }],
689
+ details: mkd("single")([]),
690
+ isError: errors.length > 0 && results.length === 0,
691
+ };
692
+ }
693
+
694
+ // Single async
695
+ if (!params.agent || !params.task) {
696
+ return {
697
+ content: [{ type: "text" as const, text: "Async mode requires agent and task (or tasks array)." }],
698
+ details: mkd("single")([]),
699
+ isError: true,
700
+ };
701
+ }
702
+ if (!params.cwd) {
703
+ return {
704
+ content: [{ type: "text" as const, text: MISSING_CWD_ERROR }],
705
+ details: mkd("single")([]),
706
+ isError: true,
707
+ };
708
+ }
709
+ if (!params.name) {
710
+ return {
711
+ content: [{ type: "text" as const, text: "Async agents require a name for display in status line (e.g., 'Dream', 'Code Review', 'PR #42')." }],
712
+ details: mkd("single")([]),
713
+ isError: true,
714
+ };
715
+ }
716
+ if (params.taskId == null) {
717
+ const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nIf this agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf this agent is NOT linked to any task: pass taskId: "-1"\n\nExample: subagent(agent="${params.agent}", task="...", async=true, name="${params.name}", taskId="-1")`;
718
+ // Force a follow-up turn so the AI MUST deal with this error
719
+ pi.sendMessage({
720
+ customType: "subagent-taskid-error",
721
+ content: `🚨 CRITICAL: Async subagent call for "${params.agent}" was REJECTED — missing taskId. The agent was NOT spawned. You MUST retry the subagent call with taskId. Use TaskList to find the task ID, or pass taskId: "-1" if not linked to any task. Do NOT proceed without fixing this.`,
722
+ display: true,
723
+ }, { triggerTurn: true, deliverAs: "followUp" });
724
+ return {
725
+ content: [{ type: "text" as const, text: errorMsg }],
726
+ details: mkd("single")([]),
727
+ isError: true,
728
+ };
729
+ }
730
+ {
731
+ const taskErr = validateTaskId(params.taskId, ctx.cwd, ctx.sessionManager?.getSessionId?.());
732
+ if (taskErr) {
733
+ pi.sendMessage({
734
+ customType: "subagent-taskid-error",
735
+ content: `🚨 CRITICAL: ${taskErr} The agent was NOT spawned. Fix the taskId and retry.`,
736
+ display: true,
737
+ }, { triggerTurn: true, deliverAs: "followUp" });
738
+ return {
739
+ content: [{ type: "text" as const, text: taskErr }],
740
+ details: mkd("single")([]),
741
+ isError: true,
742
+ };
743
+ }
744
+ }
745
+ const result = spawnAsyncAgent(params.agent, params.task, params.cwd, agents, { fireAndForget: params.fireAndForget, name: params.name, parentModelId, parentProvider, taskId: params.taskId, persistSession: params.persistSession });
746
+ if (result.error) {
747
+ return {
748
+ content: [{ type: "text" as const, text: result.error }],
749
+ details: mkd("single")([]),
750
+ isError: true,
751
+ };
752
+ }
753
+ const label = params.name || params.agent;
754
+ const modelInfo = result.model ? ` (${result.model})` : "";
755
+ return {
756
+ content: [{ type: "text" as const, text: `Async agent spawned: ${label} [${result.id}]${modelInfo}\nUse /async-status to check progress. Results will appear when complete.` }],
757
+ details: mkd("single")([]),
758
+ };
759
+ }
760
+
761
+ async function executeChain(
762
+ params: any,
763
+ agents: AgentConfig[],
764
+ mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
765
+ signal: AbortSignal | undefined,
766
+ onUpdate: OnUpdate | undefined,
767
+ activeAgents: Set<string>,
768
+ updateWorking: () => void,
769
+ parentModelId: string | undefined,
770
+ parentProvider: string | undefined,
771
+ asyncOk: boolean,
772
+ sessionId?: string,
773
+ ) {
774
+ const results: SingleResult[] = [];
775
+ let prev = "";
776
+
777
+ for (let i = 0; i < params.chain.length; i++) {
778
+ if (!params.chain[i].cwd) {
779
+ return {
780
+ content: [
781
+ {
782
+ type: "text" as const,
783
+ text: `${MISSING_CWD_ERROR} (chain step ${i + 1}, agent: ${params.chain[i].agent})`,
784
+ },
785
+ ],
786
+ details: mkd("chain")([]),
787
+ isError: true,
788
+ };
789
+ }
790
+ }
791
+ // defensive: LLMs may omit schema-required fields
792
+ const missingChainEstimate = params.chain.find((s: any) => s.estimatedSeconds == null);
793
+ if (missingChainEstimate) {
794
+ return {
795
+ content: [{ type: "text" as const, text: `${MISSING_ESTIMATE_ERROR} (chain step, agent: ${missingChainEstimate.agent})` }],
796
+ details: mkd("chain")([]),
797
+ isError: true,
798
+ };
799
+ }
800
+ const invalidChainEstimate = params.chain.find((s: any) => s.estimatedSeconds != null && s.estimatedSeconds <= 0);
801
+ if (invalidChainEstimate) {
802
+ return {
803
+ content: [{ type: "text" as const, text: `estimatedSeconds must be a positive number. (chain step, agent: ${invalidChainEstimate.agent})` }],
804
+ details: mkd("chain")([]),
805
+ isError: true,
806
+ };
807
+ }
808
+ // chain runs sequentially — sum all steps
809
+ const totalChainSeconds = params.chain.reduce((sum: number, s: any) => sum + s.estimatedSeconds, 0);
810
+ if (totalChainSeconds >= MAX_SYNC_SECONDS && asyncOk) {
811
+ return {
812
+ content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(totalChainSeconds) }],
813
+ details: mkd("chain")([]),
814
+ isError: true,
815
+ };
816
+ }
817
+
818
+ for (let i = 0; i < params.chain.length; i++) {
819
+ const s = params.chain[i];
820
+ const t = s.task.replace(/\{previous\}/g, prev);
821
+ const chainUpdate: OnUpdate | undefined = onUpdate
822
+ ? (p) => {
823
+ const c = p.details?.results[0];
824
+ if (c)
825
+ onUpdate({
826
+ content: p.content,
827
+ details: mkd("chain")([...results, c]),
828
+ });
829
+ }
830
+ : undefined;
831
+ activeAgents.clear();
832
+ activeAgents.add(s.agent);
833
+ updateWorking();
834
+ // Auto-mark linked task as in_progress (same as async path)
835
+ const chainTaskId = s.taskId as string | undefined;
836
+ if (chainTaskId && chainTaskId !== "-1") {
837
+ await autoMarkInProgress(chainTaskId, s.cwd, sessionId).catch(() => {});
838
+ }
839
+ const r = await runSingleAgent(
840
+ agents,
841
+ s.agent,
842
+ t,
843
+ s.cwd,
844
+ i + 1,
845
+ signal,
846
+ chainUpdate,
847
+ mkd("chain"),
848
+ parentModelId,
849
+ parentProvider,
850
+ params.persistSession,
851
+ );
852
+ activeAgents.delete(s.agent);
853
+ updateWorking();
854
+ results.push(r);
855
+ if (
856
+ r.exitCode !== 0 ||
857
+ r.stopReason === "error" ||
858
+ r.stopReason === "aborted"
859
+ ) {
860
+ return {
861
+ content: [
862
+ {
863
+ type: "text" as const,
864
+ text: `Chain stopped at step ${i + 1} (${s.agent}): ${r.errorMessage || r.stderr || getFinalOutput(r.messages) || "(no output)"}`,
865
+ },
866
+ ],
867
+ details: mkd("chain")(results),
868
+ isError: true,
869
+ };
870
+ }
871
+ // Auto-complete linked task on success (same as async path)
872
+ if (chainTaskId && chainTaskId !== "-1") {
873
+ await autoCompleteTask(chainTaskId, s.cwd, sessionId).catch(() => {});
874
+ }
875
+ prev = getFinalOutput(r.messages);
876
+ }
877
+ return {
878
+ content: [
879
+ {
880
+ type: "text" as const,
881
+ text:
882
+ getFinalOutput(results[results.length - 1].messages) ||
883
+ "(no output)",
884
+ },
885
+ ],
886
+ details: mkd("chain")(results),
887
+ };
888
+ }
889
+
890
+ async function executeParallel(
891
+ params: any,
892
+ agents: AgentConfig[],
893
+ mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
894
+ signal: AbortSignal | undefined,
895
+ onUpdate: OnUpdate | undefined,
896
+ activeAgents: Set<string>,
897
+ updateWorking: () => void,
898
+ parentModelId: string | undefined,
899
+ parentProvider: string | undefined,
900
+ asyncOk: boolean,
901
+ sessionId?: string,
902
+ ) {
903
+ if (params.tasks.length > MAX_PARALLEL_TASKS)
904
+ return {
905
+ content: [
906
+ {
907
+ type: "text" as const,
908
+ text: `Too many tasks (${params.tasks.length}). Max ${MAX_PARALLEL_TASKS}.`,
909
+ },
910
+ ],
911
+ details: mkd("parallel")([]),
912
+ isError: true,
913
+ };
914
+ // defensive: LLMs may omit required fields
915
+ const missingEstimate = params.tasks.find((t: any) => t.estimatedSeconds == null);
916
+ if (missingEstimate) {
917
+ return {
918
+ content: [{ type: "text" as const, text: `${MISSING_ESTIMATE_ERROR} (parallel task, agent: ${missingEstimate.agent})` }],
919
+ details: mkd("parallel")([]),
920
+ isError: true,
921
+ };
922
+ }
923
+ const invalidEstimate = params.tasks.find((t: any) => t.estimatedSeconds != null && t.estimatedSeconds <= 0);
924
+ if (invalidEstimate) {
925
+ return {
926
+ content: [{ type: "text" as const, text: `estimatedSeconds must be a positive number. (parallel task, agent: ${invalidEstimate.agent})` }],
927
+ details: mkd("parallel")([]),
928
+ isError: true,
929
+ };
930
+ }
931
+ // parallel runs concurrently — use longest task
932
+ const maxParallelSeconds = Math.max(...params.tasks.map((t: any) => t.estimatedSeconds!));
933
+ if (maxParallelSeconds >= MAX_SYNC_SECONDS && asyncOk) {
934
+ return {
935
+ content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(maxParallelSeconds) }],
936
+ details: mkd("parallel")([]),
937
+ isError: true,
938
+ };
939
+ }
940
+ const all: SingleResult[] = params.tasks.map((t: any) => ({
941
+ agent: t.agent,
942
+ agentSource: "unknown" as const,
943
+ task: t.task,
944
+ exitCode: -1,
945
+ messages: [],
946
+ stderr: "",
947
+ usage: {
948
+ input: 0,
949
+ output: 0,
950
+ cacheRead: 0,
951
+ cacheWrite: 0,
952
+ cost: 0,
953
+ contextTokens: 0,
954
+ turns: 0,
955
+ },
956
+ }));
957
+ const emitAll = () => {
958
+ if (onUpdate) {
959
+ const run = all.filter((r) => r.exitCode === -1).length;
960
+ const done = all.filter((r) => r.exitCode !== -1).length;
961
+ onUpdate({
962
+ content: [
963
+ {
964
+ type: "text",
965
+ text: `Parallel: ${done}/${all.length} done, ${run} running...`,
966
+ },
967
+ ],
968
+ details: mkd("parallel")([...all]),
969
+ });
970
+ }
971
+ };
972
+ for (let i = 0; i < params.tasks.length; i++) {
973
+ if (!params.tasks[i].cwd) {
974
+ return {
975
+ content: [
976
+ {
977
+ type: "text" as const,
978
+ text: `${MISSING_CWD_ERROR} (parallel task ${i + 1}, agent: ${params.tasks[i].agent})`,
979
+ },
980
+ ],
981
+ details: mkd("parallel")([]),
982
+ isError: true,
983
+ };
984
+ }
985
+ }
986
+ // Auto-mark linked tasks as in_progress
987
+ for (const t of params.tasks) {
988
+ const tid = (t as any).taskId as string | undefined;
989
+ if (tid && tid !== "-1") {
990
+ await autoMarkInProgress(tid, t.cwd, sessionId).catch(() => {});
991
+ }
992
+ }
993
+ const results = await mapWithConcurrency(
994
+ params.tasks,
995
+ MAX_CONCURRENCY,
996
+ async (t: any, i: number) => {
997
+ const label = t.name || t.agent;
998
+ activeAgents.add(label);
999
+ updateWorking();
1000
+ const r = await runSingleAgent(
1001
+ agents,
1002
+ t.agent,
1003
+ t.task,
1004
+ t.cwd,
1005
+ undefined,
1006
+ signal,
1007
+ (p) => {
1008
+ if (p.details?.results[0]) {
1009
+ all[i] = p.details.results[0];
1010
+ emitAll();
1011
+ }
1012
+ },
1013
+ mkd("parallel"),
1014
+ parentModelId,
1015
+ parentProvider,
1016
+ params.persistSession,
1017
+ );
1018
+ all[i] = r;
1019
+ activeAgents.delete(t.name || t.agent);
1020
+ updateWorking();
1021
+ emitAll();
1022
+ // Auto-complete linked task on success (same as async path)
1023
+ const tid = t.taskId as string | undefined;
1024
+ if (
1025
+ tid &&
1026
+ tid !== "-1" &&
1027
+ r.exitCode === 0 &&
1028
+ r.stopReason !== "error" &&
1029
+ r.stopReason !== "aborted"
1030
+ ) {
1031
+ await autoCompleteTask(tid, t.cwd, sessionId).catch(() => {});
1032
+ }
1033
+ return r;
1034
+ },
1035
+ );
1036
+ const ok = results.filter((r) => r.exitCode === 0).length;
1037
+ const summaries = results.map((r) => {
1038
+ const o = getFinalOutput(r.messages);
1039
+ const p = o.slice(0, 100) + (o.length > 100 ? "..." : "");
1040
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${p || "(no output)"}`;
1041
+ });
1042
+ return {
1043
+ content: [
1044
+ {
1045
+ type: "text" as const,
1046
+ text: `Parallel: ${ok}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
1047
+ },
1048
+ ],
1049
+ details: mkd("parallel")(results),
1050
+ };
1051
+ }
1052
+
1053
+ async function executeSingle(
1054
+ params: any,
1055
+ agents: AgentConfig[],
1056
+ mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
1057
+ signal: AbortSignal | undefined,
1058
+ onUpdate: OnUpdate | undefined,
1059
+ activeAgents: Set<string>,
1060
+ updateWorking: () => void,
1061
+ parentModelId: string | undefined,
1062
+ parentProvider: string | undefined,
1063
+ asyncOk: boolean,
1064
+ sessionId?: string,
1065
+ ) {
1066
+ if (!params.cwd) {
1067
+ return {
1068
+ content: [
1069
+ {
1070
+ type: "text" as const,
1071
+ text: MISSING_CWD_ERROR,
1072
+ },
1073
+ ],
1074
+ details: mkd("single")([]),
1075
+ isError: true,
1076
+ };
1077
+ }
1078
+ if (params.estimatedSeconds == null) {
1079
+ return {
1080
+ content: [{ type: "text" as const, text: MISSING_ESTIMATE_ERROR }],
1081
+ details: mkd("single")([]),
1082
+ isError: true,
1083
+ };
1084
+ }
1085
+ if (params.estimatedSeconds <= 0) {
1086
+ return {
1087
+ content: [{ type: "text" as const, text: "estimatedSeconds must be a positive number." }],
1088
+ details: mkd("single")([]),
1089
+ isError: true,
1090
+ };
1091
+ }
1092
+ if (params.estimatedSeconds >= MAX_SYNC_SECONDS && asyncOk) {
1093
+ return {
1094
+ content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(params.estimatedSeconds) }],
1095
+ details: mkd("single")([]),
1096
+ isError: true,
1097
+ };
1098
+ }
1099
+ const label = params.name || params.agent;
1100
+ activeAgents.add(label);
1101
+ updateWorking();
1102
+ // Auto-mark linked task as in_progress (same as async path)
1103
+ const syncTaskId = params.taskId as string | undefined;
1104
+ if (syncTaskId && syncTaskId !== "-1") {
1105
+ await autoMarkInProgress(syncTaskId, params.cwd, sessionId).catch(() => {});
1106
+ }
1107
+ const r = await runSingleAgent(
1108
+ agents,
1109
+ params.agent,
1110
+ params.task,
1111
+ params.cwd,
1112
+ undefined,
1113
+ signal,
1114
+ onUpdate,
1115
+ mkd("single"),
1116
+ parentModelId,
1117
+ parentProvider,
1118
+ params.persistSession,
1119
+ );
1120
+ activeAgents.delete(label);
1121
+ updateWorking();
1122
+ const err =
1123
+ r.exitCode !== 0 ||
1124
+ r.stopReason === "error" ||
1125
+ r.stopReason === "aborted";
1126
+ if (err)
1127
+ return {
1128
+ content: [
1129
+ {
1130
+ type: "text" as const,
1131
+ text: `Agent ${r.stopReason || "failed"}: ${r.errorMessage || r.stderr || getFinalOutput(r.messages) || "(no output)"}`,
1132
+ },
1133
+ ],
1134
+ details: mkd("single")([r]),
1135
+ isError: true,
1136
+ };
1137
+ // Auto-complete linked task on success (same as async path)
1138
+ if (syncTaskId && syncTaskId !== "-1") {
1139
+ await autoCompleteTask(syncTaskId, params.cwd, sessionId).catch(() => {});
1140
+ }
1141
+ return {
1142
+ content: [
1143
+ { type: "text" as const, text: getFinalOutput(r.messages) || "(no output)" },
1144
+ ],
1145
+ details: mkd("single")([r]),
1146
+ };
1147
+ }
1148
+
1149
+ // ── Extracted renderResult helpers ──
1150
+
1151
+ function renderSingleResult(
1152
+ r: SingleResult,
1153
+ expanded: boolean,
1154
+ theme: any,
1155
+ ts: string,
1156
+ mdTheme: any,
1157
+ renderItems: (items: DisplayItem[], limit?: number) => string,
1158
+ ) {
1159
+ const isErr =
1160
+ r.exitCode !== 0 ||
1161
+ r.stopReason === "error" ||
1162
+ r.stopReason === "aborted";
1163
+ const icon = isErr ? theme.fg("error", "✗") : theme.fg("success", "✓");
1164
+ const items = getDisplayItems(r.messages);
1165
+ const final = getFinalOutput(r.messages);
1166
+ if (expanded) {
1167
+ const c = new Container();
1168
+ let h = `${ts}${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
1169
+ if (isErr && r.stopReason)
1170
+ h += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1171
+ c.addChild(new Text(h, 0, 0));
1172
+ if (isErr && r.errorMessage)
1173
+ c.addChild(
1174
+ new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0),
1175
+ );
1176
+ c.addChild(new Spacer(1));
1177
+ c.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
1178
+ c.addChild(new Text(theme.fg("dim", r.task), 0, 0));
1179
+ c.addChild(new Spacer(1));
1180
+ c.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
1181
+ if (items.length === 0 && !final)
1182
+ c.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
1183
+ else {
1184
+ for (const i of items)
1185
+ if (i.type === "toolCall")
1186
+ c.addChild(
1187
+ new Text(
1188
+ theme.fg("muted", "→ ") +
1189
+ formatToolCall(i.name, i.args, theme.fg.bind(theme)),
1190
+ 0,
1191
+ 0,
1192
+ ),
1193
+ );
1194
+ if (final) {
1195
+ c.addChild(new Spacer(1));
1196
+ c.addChild(new Markdown(final.trim(), 0, 0, mdTheme));
1197
+ }
1198
+ }
1199
+ const u = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
1200
+ if (u) {
1201
+ c.addChild(new Spacer(1));
1202
+ c.addChild(new Text(theme.fg("dim", u), 0, 0));
1203
+ }
1204
+ return c;
1205
+ }
1206
+ let t = `${ts}${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
1207
+ if (isErr && r.stopReason)
1208
+ t += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
1209
+ if (isErr && r.errorMessage)
1210
+ t += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
1211
+ else if (items.length === 0)
1212
+ t += `\n${theme.fg("muted", "(no output)")}`;
1213
+ else {
1214
+ t += `\n${renderItems(items, COLLAPSED_ITEM_COUNT)}`;
1215
+ if (items.length > COLLAPSED_ITEM_COUNT)
1216
+ t += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1217
+ }
1218
+ const u = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
1219
+ if (u) t += `\n${theme.fg("dim", u)}`;
1220
+ return new Text(t, 0, 0);
1221
+ }
1222
+
1223
+ function renderChainResults(
1224
+ details: SubagentDetails,
1225
+ expanded: boolean,
1226
+ theme: any,
1227
+ ts: string,
1228
+ mdTheme: any,
1229
+ renderItems: (items: DisplayItem[], limit?: number) => string,
1230
+ aggUsage: (rs: SingleResult[]) => { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number; durationMs: number },
1231
+ ) {
1232
+ const ok = details.results.filter((r) => r.exitCode === 0).length;
1233
+ const icon =
1234
+ ok === details.results.length
1235
+ ? theme.fg("success", "✓")
1236
+ : theme.fg("error", "✗");
1237
+ if (expanded) {
1238
+ const c = new Container();
1239
+ c.addChild(
1240
+ new Text(
1241
+ `${ts}${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${ok}/${details.results.length} steps`)}`,
1242
+ 0,
1243
+ 0,
1244
+ ),
1245
+ );
1246
+ for (const r of details.results) {
1247
+ const ri =
1248
+ r.exitCode === 0
1249
+ ? theme.fg("success", "✓")
1250
+ : theme.fg("error", "✗");
1251
+ c.addChild(new Spacer(1));
1252
+ c.addChild(
1253
+ new Text(
1254
+ `${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${ri}`,
1255
+ 0,
1256
+ 0,
1257
+ ),
1258
+ );
1259
+ c.addChild(
1260
+ new Text(
1261
+ theme.fg("muted", "Task: ") + theme.fg("dim", r.task),
1262
+ 0,
1263
+ 0,
1264
+ ),
1265
+ );
1266
+ for (const i of getDisplayItems(r.messages))
1267
+ if (i.type === "toolCall")
1268
+ c.addChild(
1269
+ new Text(
1270
+ theme.fg("muted", "→ ") +
1271
+ formatToolCall(i.name, i.args, theme.fg.bind(theme)),
1272
+ 0,
1273
+ 0,
1274
+ ),
1275
+ );
1276
+ const f = getFinalOutput(r.messages);
1277
+ if (f) {
1278
+ c.addChild(new Spacer(1));
1279
+ c.addChild(new Markdown(f.trim(), 0, 0, mdTheme));
1280
+ }
1281
+ const su = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
1282
+ if (su) c.addChild(new Text(theme.fg("dim", su), 0, 0));
1283
+ }
1284
+ const tu = formatUsageStats(aggUsage(details.results));
1285
+ if (tu) {
1286
+ c.addChild(new Spacer(1));
1287
+ c.addChild(new Text(theme.fg("dim", `Total: ${tu}`), 0, 0));
1288
+ }
1289
+ return c;
1290
+ }
1291
+ let t = `${ts}${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${ok}/${details.results.length} steps`)}`;
1292
+ for (const r of details.results) {
1293
+ const ri =
1294
+ r.exitCode === 0
1295
+ ? theme.fg("success", "✓")
1296
+ : theme.fg("error", "✗");
1297
+ const di = getDisplayItems(r.messages);
1298
+ t += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${ri}`;
1299
+ t +=
1300
+ di.length === 0
1301
+ ? `\n${theme.fg("muted", "(no output)")}`
1302
+ : `\n${renderItems(di, 5)}`;
1303
+ }
1304
+ const tu = formatUsageStats(aggUsage(details.results));
1305
+ if (tu) t += `\n\n${theme.fg("dim", `Total: ${tu}`)}`;
1306
+ t += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1307
+ return new Text(t, 0, 0);
1308
+ }
1309
+
1310
+ function renderParallelResults(
1311
+ details: SubagentDetails,
1312
+ expanded: boolean,
1313
+ theme: any,
1314
+ ts: string,
1315
+ mdTheme: any,
1316
+ renderItems: (items: DisplayItem[], limit?: number) => string,
1317
+ aggUsage: (rs: SingleResult[]) => { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number; durationMs: number },
1318
+ ) {
1319
+ const running = details.results.filter((r) => r.exitCode === -1).length;
1320
+ const ok = details.results.filter((r) => r.exitCode === 0).length;
1321
+ const fail = details.results.filter((r) => r.exitCode > 0).length;
1322
+ const isRunning = running > 0;
1323
+ const icon = isRunning
1324
+ ? theme.fg("warning", "⏳")
1325
+ : fail > 0
1326
+ ? theme.fg("warning", "◐")
1327
+ : theme.fg("success", "✓");
1328
+ const status = isRunning
1329
+ ? `${ok + fail}/${details.results.length} done, ${running} running`
1330
+ : `${ok}/${details.results.length} tasks`;
1331
+ if (expanded && !isRunning) {
1332
+ const c = new Container();
1333
+ c.addChild(
1334
+ new Text(
1335
+ `${ts}${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
1336
+ 0,
1337
+ 0,
1338
+ ),
1339
+ );
1340
+ for (const r of details.results) {
1341
+ const ri =
1342
+ r.exitCode === 0
1343
+ ? theme.fg("success", "✓")
1344
+ : theme.fg("error", "✗");
1345
+ c.addChild(new Spacer(1));
1346
+ c.addChild(
1347
+ new Text(
1348
+ `${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${ri}`,
1349
+ 0,
1350
+ 0,
1351
+ ),
1352
+ );
1353
+ c.addChild(
1354
+ new Text(
1355
+ theme.fg("muted", "Task: ") + theme.fg("dim", r.task),
1356
+ 0,
1357
+ 0,
1358
+ ),
1359
+ );
1360
+ for (const i of getDisplayItems(r.messages))
1361
+ if (i.type === "toolCall")
1362
+ c.addChild(
1363
+ new Text(
1364
+ theme.fg("muted", "→ ") +
1365
+ formatToolCall(i.name, i.args, theme.fg.bind(theme)),
1366
+ 0,
1367
+ 0,
1368
+ ),
1369
+ );
1370
+ const f = getFinalOutput(r.messages);
1371
+ if (f) {
1372
+ c.addChild(new Spacer(1));
1373
+ c.addChild(new Markdown(f.trim(), 0, 0, mdTheme));
1374
+ }
1375
+ const su = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
1376
+ if (su) c.addChild(new Text(theme.fg("dim", su), 0, 0));
1377
+ }
1378
+ const tu = formatUsageStats(aggUsage(details.results));
1379
+ if (tu) {
1380
+ c.addChild(new Spacer(1));
1381
+ c.addChild(new Text(theme.fg("dim", `Total: ${tu}`), 0, 0));
1382
+ }
1383
+ return c;
1384
+ }
1385
+ let t = `${ts}${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
1386
+ for (const r of details.results) {
1387
+ const ri =
1388
+ r.exitCode === -1
1389
+ ? theme.fg("warning", "⏳")
1390
+ : r.exitCode === 0
1391
+ ? theme.fg("success", "✓")
1392
+ : theme.fg("error", "✗");
1393
+ const di = getDisplayItems(r.messages);
1394
+ t += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${ri}`;
1395
+ t +=
1396
+ di.length === 0
1397
+ ? `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`
1398
+ : `\n${renderItems(di, 5)}`;
1399
+ }
1400
+ if (!isRunning) {
1401
+ const tu = formatUsageStats(aggUsage(details.results));
1402
+ if (tu) t += `\n\n${theme.fg("dim", `Total: ${tu}`)}`;
1403
+ }
1404
+ if (!expanded) t += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1405
+ return new Text(t, 0, 0);
1406
+ }
1407
+
1408
+ pi.registerTool({
1409
+ name: "subagent",
1410
+ label: "Subagent",
1411
+ description: [
1412
+ "Delegate tasks to specialized subagents with isolated context.",
1413
+ "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
1414
+ "Agents are bundled with this package, plus user (~/.pi/agent/agents) and project (.pi/agents) agents.",
1415
+ ].join(" "),
1416
+ promptSnippet:
1417
+ "Delegate tasks to specialized subagents (single, parallel, or chain mode)",
1418
+ promptGuidelines: [
1419
+ "Use subagent to delegate code changes, git operations, debugging, tests, and reviews to specialist agents.",
1420
+ "Route by intent: python code → python-expert, git commit → git-expert, PR → github-expert, etc.",
1421
+ "Run independent tasks in parallel using the tasks array.",
1422
+ "For multi-step workflows, use chain mode with {previous} placeholder.",
1423
+ "Set async: true when you don't need the result immediately for your next step. The result will surface automatically when complete. Use sync (default) only when the next step depends on this agent's output.",
1424
+ "ALWAYS use async: true for independent tasks that can run in parallel — code reviews, opening issues, research, analysis. Only use sync when the very next step depends on this agent's output (e.g., chain where step 2 needs step 1's result).",
1425
+ "ALWAYS pass cwd — use the project directory for current repo work, or the target path for external repos (e.g., ${PROJECT_TMP_DIR}/...).",
1426
+ "ALWAYS provide estimatedSeconds for sync agents. If estimated time is 30 seconds or more, you MUST use async: true instead.",
1427
+ "After spawning async agents, END YOUR TURN. Do NOT write bash loops, sleep commands, or poll for results — the system delivers results automatically as a follow-up message. Spawn the agent and stop.",
1428
+ ],
1429
+ parameters: SubagentParams,
1430
+
1431
+ async execute(_id, params, signal, onUpdate, ctx) {
1432
+ // Track active subagents for working indicator
1433
+ const activeAgents = new Set<string>();
1434
+ const updateWorking = () => {
1435
+ if (!ctx.hasUI) return;
1436
+ if (activeAgents.size === 0) {
1437
+ ctx.ui.setWorkingMessage();
1438
+ return;
1439
+ }
1440
+ const names = [...activeAgents];
1441
+ if (names.length === 1) {
1442
+ ctx.ui.setWorkingMessage(`🔧 ${names[0]} working...`);
1443
+ } else {
1444
+ ctx.ui.setWorkingMessage(`🔧 ${names.length} agents (${names.join(", ")})...`);
1445
+ }
1446
+ };
1447
+
1448
+ const scope: AgentScope = params.agentScope ?? "both";
1449
+ const discovery = discoverAgents(ctx.cwd, scope);
1450
+ const agents = discovery.agents;
1451
+ const confirm = params.confirmProjectAgents ?? true;
1452
+ let parentModelId = ctx.model?.id as string | undefined;
1453
+ let parentProvider = ctx.model?.provider as string | undefined;
1454
+ const asyncOk = supportsAsyncLlm(parentProvider, ctx.cwd);
1455
+ let capabilityNote = "";
1456
+
1457
+ const hasChain = (params.chain?.length ?? 0) > 0;
1458
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
1459
+ const hasSingle = Boolean(params.agent && params.task);
1460
+ const modes = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
1461
+
1462
+ const mkd =
1463
+ (mode: "single" | "parallel" | "chain") =>
1464
+ (results: SingleResult[]): SubagentDetails => ({
1465
+ mode,
1466
+ agentScope: scope,
1467
+ projectAgentsDir: discovery.projectAgentsDir,
1468
+ results,
1469
+ });
1470
+
1471
+ // Kill async agents — early return before mode validation
1472
+ if (params.asyncKill) {
1473
+ return executeAsyncKill(params as { asyncKill: string }, mkd);
1474
+ }
1475
+
1476
+ // Enforce async-only agents — force async on native; on acpx leave sync (coerce path).
1477
+ {
1478
+ const requested: string[] = [];
1479
+ if (params.agent) requested.push(params.agent);
1480
+ if (params.tasks) for (const t of params.tasks) requested.push(t.agent);
1481
+ if (params.chain) for (const s of params.chain) requested.push(s.agent);
1482
+ const violators = [...new Set(requested.filter(n => ASYNC_ONLY_AGENTS.has(n)))];
1483
+ if (violators.length > 0) {
1484
+ const inChain = params.chain?.some(s => ASYNC_ONLY_AGENTS.has(s.agent));
1485
+ if (inChain) {
1486
+ return {
1487
+ content: [{ type: "text", text: `These agents cannot be used in chain mode: ${violators.join(", ")}. Dispatch them as separate async tasks instead.` }],
1488
+ details: mkd("single")([]),
1489
+ isError: true,
1490
+ };
1491
+ }
1492
+ if (asyncOk && params.async !== true) {
1493
+ params.async = true;
1494
+ // Set default name(s) for async display — async path requires names
1495
+ if (params.agent && !params.name) {
1496
+ params.name = params.agent;
1497
+ }
1498
+ if (params.tasks) {
1499
+ for (const t of params.tasks) {
1500
+ if (!(t as any).name) (t as any).name = t.agent;
1501
+ }
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ if (modes !== 1) {
1508
+ const avail =
1509
+ agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
1510
+ return {
1511
+ content: [
1512
+ {
1513
+ type: "text",
1514
+ text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${avail}`,
1515
+ },
1516
+ ],
1517
+ details: mkd("single")([]),
1518
+ };
1519
+ }
1520
+
1521
+ // Confirm project agents
1522
+ if ((scope === "project" || scope === "both") && confirm && ctx.hasUI) {
1523
+ const requested = new Set<string>();
1524
+ if (params.chain) for (const s of params.chain) requested.add(s.agent);
1525
+ if (params.tasks) for (const t of params.tasks) requested.add(t.agent);
1526
+ if (params.agent) requested.add(params.agent);
1527
+ const projAgents = Array.from(requested)
1528
+ .map((n) => agents.find((a) => a.name === n))
1529
+ .filter((a): a is AgentConfig => a?.source === "project");
1530
+ if (projAgents.length > 0) {
1531
+ const ok = await ctx.ui.confirm(
1532
+ "Run project-local agents?",
1533
+ `Agents: ${projAgents.map((a) => a.name).join(", ")}\nSource: ${discovery.projectAgentsDir}`,
1534
+ );
1535
+ if (!ok)
1536
+ return {
1537
+ content: [
1538
+ {
1539
+ type: "text",
1540
+ text: "Canceled: project-local agents not approved.",
1541
+ },
1542
+ ],
1543
+ details: mkd(
1544
+ hasChain ? "chain" : hasTasks ? "parallel" : "single",
1545
+ )([]),
1546
+ };
1547
+ }
1548
+ }
1549
+
1550
+ // acpx parent: coerce LLM async → sync, or use sidecar for fireAndForget must-async.
1551
+ // Capability uses parent session cwd (asyncOk); settingsCwd is only for sidecar lookup.
1552
+ if (params.async === true && !asyncOk) {
1553
+ const settingsCwd =
1554
+ params.cwd ||
1555
+ params.tasks?.[0]?.cwd ||
1556
+ ctx.cwd;
1557
+ const decision = decideAsyncLlmDispatch({
1558
+ parentProvider,
1559
+ cwd: settingsCwd,
1560
+ mustAsync: params.fireAndForget === true,
1561
+ parentSupportsAsyncLlm: asyncOk,
1562
+ });
1563
+ if (decision.action === "skip") {
1564
+ return {
1565
+ content: [{ type: "text", text: decision.note }],
1566
+ details: mkd(hasTasks ? "parallel" : "single")([]),
1567
+ };
1568
+ }
1569
+ if (decision.action === "sidecar-async") {
1570
+ parentProvider = decision.sidecar.provider;
1571
+ parentModelId = decision.sidecar.model;
1572
+ capabilityNote = decision.note;
1573
+ } else if (decision.action === "coerce-sync") {
1574
+ params.async = false;
1575
+ capabilityNote = decision.note;
1576
+ console.debug(`[subagent] ${capabilityNote}`);
1577
+ }
1578
+ }
1579
+
1580
+ // Async mode — spawn in background and return immediately
1581
+ if (params.async === true) {
1582
+ const result = executeAsync(params, agents, mkd, parentModelId, parentProvider, ctx);
1583
+ if (capabilityNote && result.content?.[0]?.type === "text") {
1584
+ result.content[0].text = `${capabilityNote}\n\n${result.content[0].text}`;
1585
+ }
1586
+ return result;
1587
+ }
1588
+
1589
+ const withCapabilityNote = <T extends { content?: Array<{ type: string; text?: string }> }>(
1590
+ result: T,
1591
+ ): T => {
1592
+ if (capabilityNote && result.content?.[0]?.type === "text" && result.content[0].text) {
1593
+ result.content[0].text = `${capabilityNote}\n\n${result.content[0].text}`;
1594
+ }
1595
+ return result;
1596
+ };
1597
+
1598
+ const sessionId = ctx.sessionManager?.getSessionId?.();
1599
+
1600
+ // Chain mode
1601
+ if (params.chain && params.chain.length > 0) {
1602
+ return withCapabilityNote(
1603
+ await executeChain(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId),
1604
+ );
1605
+ }
1606
+
1607
+ // Parallel mode
1608
+ if (params.tasks && params.tasks.length > 0) {
1609
+ return withCapabilityNote(
1610
+ await executeParallel(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId),
1611
+ );
1612
+ }
1613
+
1614
+ // Single mode
1615
+ if (params.agent && params.task) {
1616
+ return withCapabilityNote(
1617
+ await executeSingle(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId),
1618
+ );
1619
+ }
1620
+
1621
+ return {
1622
+ content: [
1623
+ {
1624
+ type: "text",
1625
+ text: `Invalid parameters. Available: ${agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"}`,
1626
+ },
1627
+ ],
1628
+ details: mkd("single")([]),
1629
+ };
1630
+ },
1631
+
1632
+ renderCall(args, theme, context) {
1633
+ if (!context.state.startedAt) context.state.startedAt = clockHHMM();
1634
+ const ts = theme.fg("dim", `[${context.state.startedAt}] `);
1635
+ const scope: AgentScope = args.agentScope ?? "both";
1636
+ if (args.chain?.length > 0) {
1637
+ const chainEst = args.chain.every((s: any) => s.estimatedSeconds != null)
1638
+ ? theme.fg("dim", ` ~${args.chain.reduce((sum: number, s: any) => sum + s.estimatedSeconds, 0)}s (sum)`)
1639
+ : "";
1640
+ let t =
1641
+ ts +
1642
+ theme.fg("toolTitle", theme.bold("subagent ")) +
1643
+ theme.fg("accent", `chain (${args.chain.length} steps)`) +
1644
+ theme.fg("muted", ` [${scope}]`) + chainEst;
1645
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
1646
+ const s = args.chain[i];
1647
+ const p = s.task.replace(/\{previous\}/g, "").trim();
1648
+ t += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", s.agent)}${theme.fg("dim", ` ${p.length > 40 ? p.slice(0, 40) + "..." : p}`)}`;
1649
+ }
1650
+ if (args.chain.length > 3)
1651
+ t += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
1652
+ return new Text(t, 0, 0);
1653
+ }
1654
+ if (args.tasks?.length > 0) {
1655
+ const parallelEst = args.tasks.every((tk: any) => tk.estimatedSeconds != null)
1656
+ ? theme.fg("dim", ` ~${Math.max(...args.tasks.map((tk: any) => tk.estimatedSeconds))}s (max)`)
1657
+ : "";
1658
+ let t =
1659
+ ts +
1660
+ theme.fg("toolTitle", theme.bold("subagent ")) +
1661
+ theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
1662
+ theme.fg("muted", ` [${scope}]`) + parallelEst;
1663
+ for (const tk of args.tasks.slice(0, 3))
1664
+ t += `\n ${theme.fg("accent", tk.agent)}${theme.fg("dim", ` ${tk.task.length > 40 ? tk.task.slice(0, 40) + "..." : tk.task}`)}`;
1665
+ if (args.tasks.length > 3)
1666
+ t += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
1667
+ return new Text(t, 0, 0);
1668
+ }
1669
+ const asyncLabel = args.async === true ? theme.fg("warning", " [async]") : "";
1670
+ const estLabel = args.estimatedSeconds != null && args.async !== true ? theme.fg("dim", ` ~${args.estimatedSeconds}s`) : "";
1671
+ let t =
1672
+ ts +
1673
+ theme.fg("toolTitle", theme.bold("subagent ")) +
1674
+ theme.fg("accent", args.agent || "...") +
1675
+ theme.fg("muted", ` [${scope}]`) + asyncLabel + estLabel;
1676
+ t += `\n ${theme.fg("dim", args.task ? (args.task.length > 60 ? args.task.slice(0, 60) + "..." : args.task) : "...")}`;
1677
+ return new Text(t, 0, 0);
1678
+ },
1679
+
1680
+ renderResult(result, { expanded }, theme, context) {
1681
+ if (!context.state.completedAt) context.state.completedAt = clockHHMM();
1682
+ const ts = theme.fg("dim", `[${context.state.completedAt}] `);
1683
+ const details = result.details as SubagentDetails | undefined;
1684
+ if (!details || details.results.length === 0) {
1685
+ const t = result.content[0];
1686
+ return new Text(ts + (t?.type === "text" ? t.text : "(no output)"), 0, 0);
1687
+ }
1688
+ const mdTheme = getMarkdownTheme();
1689
+
1690
+ const renderItems = (items: DisplayItem[], limit?: number) => {
1691
+ const show = limit ? items.slice(-limit) : items;
1692
+ const skip = limit && items.length > limit ? items.length - limit : 0;
1693
+ let t = "";
1694
+ if (skip > 0) t += theme.fg("muted", `... ${skip} earlier items\n`);
1695
+ for (const i of show) {
1696
+ if (i.type === "text") {
1697
+ const p = expanded
1698
+ ? i.text
1699
+ : i.text.split("\n").slice(0, 3).join("\n");
1700
+ t += `${theme.fg("toolOutput", p)}\n`;
1701
+ } else
1702
+ t += `${theme.fg("muted", "→ ") + formatToolCall(i.name, i.args, theme.fg.bind(theme))}\n`;
1703
+ }
1704
+ return t.trimEnd();
1705
+ };
1706
+
1707
+ const aggUsage = (rs: SingleResult[]) => {
1708
+ const t = {
1709
+ input: 0,
1710
+ output: 0,
1711
+ cacheRead: 0,
1712
+ cacheWrite: 0,
1713
+ cost: 0,
1714
+ turns: 0,
1715
+ durationMs: 0,
1716
+ };
1717
+ for (const r of rs) {
1718
+ t.input += r.usage.input;
1719
+ t.output += r.usage.output;
1720
+ t.cacheRead += r.usage.cacheRead;
1721
+ t.cacheWrite += r.usage.cacheWrite;
1722
+ t.cost += r.usage.cost;
1723
+ t.turns += r.usage.turns;
1724
+ t.durationMs = Math.max(t.durationMs, r.durationMs || 0);
1725
+ }
1726
+ return t;
1727
+ };
1728
+
1729
+ // Single
1730
+ if (details.mode === "single" && details.results.length === 1) {
1731
+ return renderSingleResult(details.results[0], expanded, theme, ts, mdTheme, renderItems);
1732
+ }
1733
+
1734
+ // Chain
1735
+ if (details.mode === "chain") {
1736
+ return renderChainResults(details, expanded, theme, ts, mdTheme, renderItems, aggUsage);
1737
+ }
1738
+
1739
+ // Parallel
1740
+ if (details.mode === "parallel") {
1741
+ return renderParallelResults(details, expanded, theme, ts, mdTheme, renderItems, aggUsage);
1742
+ }
1743
+
1744
+ const t = result.content[0];
1745
+ return new Text(t?.type === "text" ? t.text : "(no output)", 0, 0);
1746
+ },
1747
+ });
1748
+ }