@oh-my-pi/pi-coding-agent 3.30.0 → 3.31.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 (155) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -21,7 +21,6 @@ import { formatDuration } from "../render-utils";
21
21
  import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir } from "./artifacts";
22
22
  import { discoverAgents, getAgent } from "./discovery";
23
23
  import { runSubprocess } from "./executor";
24
- import { generateTaskName } from "./name-generator";
25
24
  import { mapWithConcurrencyLimit } from "./parallel";
26
25
  import { renderCall, renderResult } from "./render";
27
26
  import {
@@ -135,17 +134,34 @@ export async function createTaskTool(
135
134
  execute: async (_toolCallId, params, signal, onUpdate) => {
136
135
  const startTime = Date.now();
137
136
  const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
138
- const context = params.context;
139
- const outputSchema = params.output_schema;
137
+ const { agent: agentName, context, model, output: outputSchema } = params;
138
+
139
+ // Validate agent exists
140
+ const agent = getAgent(agents, agentName);
141
+ if (!agent) {
142
+ const available = agents.map((a) => a.name).join(", ") || "none";
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `Unknown agent "${agentName}". Available: ${available}`,
148
+ },
149
+ ],
150
+ details: {
151
+ projectAgentsDir,
152
+ results: [],
153
+ totalDurationMs: 0,
154
+ },
155
+ };
156
+ }
140
157
 
141
158
  // Handle empty or missing tasks
142
159
  if (!params.tasks || params.tasks.length === 0) {
143
- const available = agents.map((a) => a.name).join(", ") || "none";
144
160
  return {
145
161
  content: [
146
162
  {
147
163
  type: "text",
148
- text: `No tasks provided. Use: { tasks: [{agent, task}, ...] }\nAvailable agents: ${available}`,
164
+ text: `No tasks provided. Use: { agent, context, tasks: [{id, task, description}, ...] }`,
149
165
  },
150
166
  ],
151
167
  details: {
@@ -173,6 +189,56 @@ export async function createTaskTool(
173
189
  };
174
190
  }
175
191
 
192
+ const tasks = params.tasks;
193
+ const missingTaskIndexes: number[] = [];
194
+ const idIndexes = new Map<string, number[]>();
195
+
196
+ for (let i = 0; i < tasks.length; i++) {
197
+ const id = tasks[i]?.id;
198
+ if (typeof id !== "string" || id.trim() === "") {
199
+ missingTaskIndexes.push(i);
200
+ continue;
201
+ }
202
+ const normalizedId = id.toLowerCase();
203
+ const indexes = idIndexes.get(normalizedId);
204
+ if (indexes) {
205
+ indexes.push(i);
206
+ } else {
207
+ idIndexes.set(normalizedId, [i]);
208
+ }
209
+ }
210
+
211
+ const duplicateIds: Array<{ id: string; indexes: number[] }> = [];
212
+ for (const [normalizedId, indexes] of idIndexes.entries()) {
213
+ if (indexes.length > 1) {
214
+ duplicateIds.push({
215
+ id: tasks[indexes[0]]?.id ?? normalizedId,
216
+ indexes,
217
+ });
218
+ }
219
+ }
220
+
221
+ if (missingTaskIndexes.length > 0 || duplicateIds.length > 0) {
222
+ const problems: string[] = [];
223
+ if (missingTaskIndexes.length > 0) {
224
+ problems.push(`Missing task ids at indexes: ${missingTaskIndexes.join(", ")}`);
225
+ }
226
+ if (duplicateIds.length > 0) {
227
+ const details = duplicateIds
228
+ .map((entry) => `${entry.id} (indexes ${entry.indexes.join(", ")})`)
229
+ .join("; ");
230
+ problems.push(`Duplicate task ids detected (case-insensitive): ${details}`);
231
+ }
232
+ return {
233
+ content: [{ type: "text", text: `Invalid tasks: ${problems.join(". ")}` }],
234
+ details: {
235
+ projectAgentsDir,
236
+ results: [],
237
+ totalDurationMs: 0,
238
+ },
239
+ };
240
+ }
241
+
176
242
  // Derive artifacts directory
177
243
  const sessionFile = session.getSessionFile();
178
244
  const artifactsDir = sessionFile ? getArtifactsDir(sessionFile) : null;
@@ -197,88 +263,59 @@ export async function createTaskTool(
197
263
  };
198
264
 
199
265
  try {
200
- let tasks = params.tasks;
201
- let skippedSelfRecursion = 0;
202
-
203
- // Filter out blocked agent (self-recursion prevention)
204
- if (blockedAgent) {
205
- const blockedTasks = tasks.filter((t) => t.agent === blockedAgent);
206
- tasks = tasks.filter((t) => t.agent !== blockedAgent);
207
- skippedSelfRecursion = blockedTasks.length;
208
-
209
- if (skippedSelfRecursion > 0 && tasks.length === 0) {
210
- return {
211
- content: [
212
- {
213
- type: "text",
214
- text: `Cannot spawn ${blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`,
215
- },
216
- ],
217
- details: {
218
- projectAgentsDir,
219
- results: [],
220
- totalDurationMs: Date.now() - startTime,
266
+ // Check self-recursion prevention
267
+ if (blockedAgent && agentName === blockedAgent) {
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text",
272
+ text: `Cannot spawn ${blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`,
221
273
  },
222
- };
223
- }
224
- }
225
-
226
- // Validate all agents exist
227
- for (const task of tasks) {
228
- if (!getAgent(agents, task.agent)) {
229
- const available = agents.map((a) => a.name).join(", ");
230
- return {
231
- content: [{ type: "text", text: `Unknown agent: ${task.agent}. Available: ${available}` }],
232
- details: {
233
- projectAgentsDir,
234
- results: [],
235
- totalDurationMs: Date.now() - startTime,
236
- },
237
- };
238
- }
274
+ ],
275
+ details: {
276
+ projectAgentsDir,
277
+ results: [],
278
+ totalDurationMs: Date.now() - startTime,
279
+ },
280
+ };
239
281
  }
240
282
 
241
283
  // Check spawn restrictions from parent
242
284
  const parentSpawns = session.getSessionSpawns() ?? "*";
243
285
  const allowedSpawns = parentSpawns.split(",").map((s) => s.trim());
244
- const isSpawnAllowed = (agentName: string): boolean => {
286
+ const isSpawnAllowed = (): boolean => {
245
287
  if (parentSpawns === "") return false; // Empty = deny all
246
288
  if (parentSpawns === "*") return true; // Wildcard = allow all
247
289
  return allowedSpawns.includes(agentName);
248
290
  };
249
291
 
250
- for (const task of tasks) {
251
- if (!isSpawnAllowed(task.agent)) {
252
- const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
253
- return {
254
- content: [{ type: "text", text: `Cannot spawn '${task.agent}'. Allowed: ${allowed}` }],
255
- details: {
256
- projectAgentsDir,
257
- results: [],
258
- totalDurationMs: Date.now() - startTime,
259
- },
260
- };
261
- }
292
+ if (!isSpawnAllowed()) {
293
+ const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
294
+ return {
295
+ content: [{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${allowed}` }],
296
+ details: {
297
+ projectAgentsDir,
298
+ results: [],
299
+ totalDurationMs: Date.now() - startTime,
300
+ },
301
+ };
262
302
  }
263
303
 
264
- // Build full prompts with context prepended and generate task IDs
304
+ // Build full prompts with context prepended
265
305
  const tasksWithContext = tasks.map((t) => ({
266
- agent: t.agent,
267
306
  task: context ? `${context}\n\n${t.task}` : t.task,
268
- model: t.model,
269
307
  description: t.description,
270
- taskId: generateTaskName(),
308
+ taskId: t.id,
271
309
  }));
272
310
 
273
311
  // Initialize progress for all tasks
274
312
  for (let i = 0; i < tasksWithContext.length; i++) {
275
313
  const t = tasksWithContext[i];
276
- const agentCfg = getAgent(agents, t.agent);
277
314
  progressMap.set(i, {
278
315
  index: i,
279
316
  taskId: t.taskId,
280
- agent: t.agent,
281
- agentSource: agentCfg?.source ?? "user",
317
+ agent: agentName,
318
+ agentSource: agent.source,
282
319
  status: "pending",
283
320
  task: t.task,
284
321
  recentTools: [],
@@ -286,36 +323,40 @@ export async function createTaskTool(
286
323
  toolCount: 0,
287
324
  tokens: 0,
288
325
  durationMs: 0,
289
- modelOverride: t.model,
326
+ modelOverride: model,
290
327
  description: t.description,
291
328
  });
292
329
  }
293
330
  emitProgress();
294
331
 
295
332
  // Execute in parallel with concurrency limit
296
- const results = await mapWithConcurrencyLimit(tasksWithContext, MAX_CONCURRENCY, async (task, index) => {
297
- const agent = getAgent(agents, task.agent)!;
298
- return runSubprocess({
299
- cwd: session.cwd,
300
- agent,
301
- task: task.task,
302
- description: task.description,
303
- index,
304
- taskId: task.taskId,
305
- context: undefined, // Already prepended above
306
- modelOverride: task.model,
307
- outputSchema,
308
- sessionFile,
309
- persistArtifacts: !!artifactsDir,
310
- artifactsDir: effectiveArtifactsDir,
311
- signal,
312
- eventBus: undefined,
313
- onProgress: (progress) => {
314
- progressMap.set(index, structuredClone(progress));
315
- emitProgress();
316
- },
317
- });
318
- });
333
+ const results = await mapWithConcurrencyLimit(
334
+ tasksWithContext,
335
+ MAX_CONCURRENCY,
336
+ async (task, index) => {
337
+ return runSubprocess({
338
+ cwd: session.cwd,
339
+ agent,
340
+ task: task.task,
341
+ description: task.description,
342
+ index,
343
+ taskId: task.taskId,
344
+ context: undefined, // Already prepended above
345
+ modelOverride: model,
346
+ outputSchema,
347
+ sessionFile,
348
+ persistArtifacts: !!artifactsDir,
349
+ artifactsDir: effectiveArtifactsDir,
350
+ signal,
351
+ eventBus: undefined,
352
+ onProgress: (progress) => {
353
+ progressMap.set(index, structuredClone(progress));
354
+ emitProgress();
355
+ },
356
+ });
357
+ },
358
+ signal,
359
+ );
319
360
 
320
361
  // Aggregate usage from executor results (already accumulated incrementally)
321
362
  const aggregatedUsage = createUsageTotals();
@@ -349,14 +390,10 @@ export async function createTaskTool(
349
390
  return `[${r.agent}] ${status}${meta} ${r.taskId}\n${preview}`;
350
391
  });
351
392
 
352
- const skippedNote =
353
- skippedSelfRecursion > 0
354
- ? ` (${skippedSelfRecursion} ${blockedAgent} task${skippedSelfRecursion > 1 ? "s" : ""} skipped - self-recursion blocked)`
355
- : "";
356
393
  const outputIds = results.map((r) => r.taskId);
357
394
  const outputHint =
358
395
  outputIds.length > 0 ? `\n\nUse output tool for full logs: output ids ${outputIds.join(", ")}` : "";
359
- const summary = `${successCount}/${results.length} succeeded${skippedNote} [${formatDuration(
396
+ const summary = `${successCount}/${results.length} succeeded [${formatDuration(
360
397
  totalDuration,
361
398
  )}]\n\n${summaries.join("\n\n---\n\n")}${outputHint}`;
362
399
 
@@ -7,24 +7,45 @@ import { MAX_CONCURRENCY } from "./types";
7
7
  /**
8
8
  * Execute items with a concurrency limit using a worker pool pattern.
9
9
  * Results are returned in the same order as input items.
10
+ * Fails fast on first error - does not wait for other workers to complete.
10
11
  *
11
12
  * @param items - Items to process
12
13
  * @param concurrency - Maximum concurrent operations
13
14
  * @param fn - Async function to execute for each item
15
+ * @param signal - Optional abort signal to stop scheduling work
14
16
  */
15
17
  export async function mapWithConcurrencyLimit<T, R>(
16
18
  items: T[],
17
19
  concurrency: number,
18
20
  fn: (item: T, index: number) => Promise<R>,
21
+ signal?: AbortSignal,
19
22
  ): Promise<R[]> {
20
23
  const limit = Math.max(1, Math.min(concurrency, items.length, MAX_CONCURRENCY));
21
24
  const results: R[] = new Array(items.length);
22
25
  let nextIndex = 0;
23
26
 
27
+ // Create internal abort controller to cancel workers on any rejection
28
+ const abortController = new AbortController();
29
+ const workerSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
30
+
31
+ // Promise that rejects on first error - used to fail fast
32
+ let rejectFirst: (error: unknown) => void;
33
+ const firstErrorPromise = new Promise<never>((_, reject) => {
34
+ rejectFirst = reject;
35
+ });
36
+
24
37
  const worker = async (): Promise<void> => {
25
- while (nextIndex < items.length) {
38
+ while (true) {
39
+ workerSignal.throwIfAborted();
26
40
  const index = nextIndex++;
27
- results[index] = await fn(items[index], index);
41
+ if (index >= items.length) return;
42
+ try {
43
+ results[index] = await fn(items[index], index);
44
+ } catch (error) {
45
+ abortController.abort();
46
+ rejectFirst(error);
47
+ throw error;
48
+ }
28
49
  }
29
50
  };
30
51
 
@@ -32,7 +53,13 @@ export async function mapWithConcurrencyLimit<T, R>(
32
53
  const workers = Array(limit)
33
54
  .fill(null)
34
55
  .map(() => worker());
56
+ await Promise.race([Promise.all(workers), firstErrorPromise]);
57
+
58
+ // Check external abort
59
+ if (signal?.aborted) {
60
+ const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
61
+ throw reason;
62
+ }
35
63
 
36
- await Promise.all(workers);
37
64
  return results;
38
65
  }
@@ -58,12 +58,20 @@ function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): s
58
58
  counts.set(finding.priority, (counts.get(finding.priority) ?? 0) + 1);
59
59
  }
60
60
 
61
+ const priorityMeta: Record<number, { icon: string; color: "error" | "warning" | "muted" | "accent" }> = {
62
+ 0: { icon: theme.styledSymbol("status.error", "error"), color: "error" },
63
+ 1: { icon: theme.styledSymbol("status.warning", "warning"), color: "warning" },
64
+ 2: { icon: theme.styledSymbol("status.warning", "muted"), color: "muted" },
65
+ 3: { icon: theme.styledSymbol("status.info", "accent"), color: "accent" },
66
+ };
67
+
61
68
  const parts: string[] = [];
62
69
  for (const priority of [0, 1, 2, 3]) {
63
70
  const label = PRIORITY_LABELS[priority] ?? "P?";
64
- const color = priority === 0 ? "error" : priority === 1 ? "warning" : "muted";
71
+ const meta = priorityMeta[priority] ?? { icon: "", color: "muted" as const };
65
72
  const count = counts.get(priority) ?? 0;
66
- parts.push(theme.fg(color, `${label}:${count}`));
73
+ const text = theme.fg(meta.color, `${label}:${count}`);
74
+ parts.push(meta.icon ? `${meta.icon} ${text}` : text);
67
75
  }
68
76
 
69
77
  return `${theme.fg("dim", "Findings:")} ${parts.join(theme.sep.dot)}`;
@@ -123,13 +131,19 @@ function renderJsonTreeLines(
123
131
  pushLine(`${prefix}${iconArray} ${header}`);
124
132
  if (val.length === 0) {
125
133
  pushLine(
126
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", "[]")}`,
134
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
135
+ "dim",
136
+ "[]",
137
+ )}`,
127
138
  );
128
139
  return;
129
140
  }
130
141
  if (depth >= maxDepth) {
131
142
  pushLine(
132
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", theme.format.ellipsis)}`,
143
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
144
+ "dim",
145
+ theme.format.ellipsis,
146
+ )}`,
133
147
  );
134
148
  return;
135
149
  }
@@ -150,13 +164,19 @@ function renderJsonTreeLines(
150
164
  const entries = Object.entries(val as Record<string, unknown>);
151
165
  if (entries.length === 0) {
152
166
  pushLine(
153
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", "{}")}`,
167
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
168
+ "dim",
169
+ "{}",
170
+ )}`,
154
171
  );
155
172
  return;
156
173
  }
157
174
  if (depth >= maxDepth) {
158
175
  pushLine(
159
- `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg("dim", theme.format.ellipsis)}`,
176
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.hook)} ${theme.fg(
177
+ "dim",
178
+ theme.format.ellipsis,
179
+ )}`,
160
180
  );
161
181
  return;
162
182
  }
@@ -233,19 +253,25 @@ function renderOutputSection(
233
253
  */
234
254
  export function renderCall(args: TaskParams, theme: Theme): Component {
235
255
  const label = theme.fg("toolTitle", theme.bold("Task"));
256
+ const agentTag = theme.italic(
257
+ theme.fg("dim", `${theme.format.bracketLeft}${args.agent}${theme.format.bracketRight}`),
258
+ );
236
259
 
237
260
  if (args.tasks.length === 1) {
238
- // Single task - show agent and task preview
261
+ // Single task - show description preview
239
262
  const task = args.tasks[0];
240
- const summary = task.description?.trim() || task.task;
241
- const taskPreview = truncate(summary, 60, theme.format.ellipsis);
242
- return new Text(`${label} ${theme.fg("accent", task.agent)}: ${theme.fg("muted", taskPreview)}`, 0, 0);
263
+ const summary = task.description.trim() || task.task;
264
+ const taskPreview = truncate(summary, 50, theme.format.ellipsis);
265
+ return new Text(`${label} ${agentTag} ${theme.fg("muted", taskPreview)}`, 0, 0);
243
266
  }
244
267
 
245
- // Multiple tasks - show count and descriptions (or agent names as fallback)
246
- const agents = args.tasks.map((t) => t.description?.trim() || t.agent).join(", ");
268
+ // Multiple tasks - show count and descriptions
269
+ const descriptions = args.tasks.map((t) => t.description.trim()).join(", ");
247
270
  return new Text(
248
- `${label} ${theme.fg("muted", `${args.tasks.length} agents: ${truncate(agents, 50, theme.format.ellipsis)}`)}`,
271
+ `${label} ${agentTag} ${args.tasks.length} agents: ${theme.fg(
272
+ "muted",
273
+ truncate(descriptions, 50, theme.format.ellipsis),
274
+ )}`,
249
275
  0,
250
276
  0,
251
277
  );
@@ -275,23 +301,14 @@ function renderAgentProgress(
275
301
  ? "error"
276
302
  : "accent";
277
303
 
278
- // Main status line - use taskId for Output tool
279
- let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", progress.taskId)}`;
304
+ // Main status line: taskId: description [status] · stats · ⟨agent⟩
280
305
  const description = progress.description?.trim();
281
- if (description) {
282
- statusLine += ` ${theme.fg("muted", truncate(description, 40, theme.format.ellipsis))}`;
283
- }
306
+ const titlePart = description ? `${theme.bold(progress.taskId)}: ${description}` : progress.taskId;
307
+ let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
284
308
 
285
309
  // Only show badge for non-running states (spinner already indicates running)
286
- if (progress.status !== "running") {
287
- const statusLabel =
288
- progress.status === "completed"
289
- ? "done"
290
- : progress.status === "failed"
291
- ? "failed"
292
- : progress.status === "aborted"
293
- ? "aborted"
294
- : "pending";
310
+ if (progress.status === "failed" || progress.status === "aborted") {
311
+ const statusLabel = progress.status === "failed" ? "failed" : "aborted";
295
312
  statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`;
296
313
  }
297
314
 
@@ -338,6 +355,21 @@ function renderAgentProgress(
338
355
 
339
356
  // Render extracted tool data inline (e.g., review findings)
340
357
  if (progress.extractedToolData) {
358
+ // For completed tasks, check for review verdict from complete tool
359
+ if (progress.status === "completed") {
360
+ const completeData = progress.extractedToolData.complete as Array<{ data: unknown }> | undefined;
361
+ const reportFindingData = progress.extractedToolData.report_finding as ReportFindingDetails[] | undefined;
362
+ const reviewData = completeData
363
+ ?.map((c) => c.data as SubmitReviewDetails)
364
+ .filter((d) => d && typeof d === "object" && "overall_correctness" in d);
365
+ if (reviewData && reviewData.length > 0) {
366
+ const summary = reviewData[reviewData.length - 1];
367
+ const findings = reportFindingData ?? [];
368
+ lines.push(...renderReviewResult(summary, findings, continuePrefix, expanded, theme));
369
+ return lines; // Review result handles its own rendering
370
+ }
371
+ }
372
+
341
373
  for (const [toolName, dataArray] of Object.entries(progress.extractedToolData)) {
342
374
  const handler = subprocessToolRegistry.getHandler(toolName);
343
375
  if (handler?.renderInline) {
@@ -381,7 +413,10 @@ function renderReviewResult(
381
413
  const verdictColor = summary.overall_correctness === "correct" ? "success" : "error";
382
414
  const verdictIcon = summary.overall_correctness === "correct" ? theme.status.success : theme.status.error;
383
415
  lines.push(
384
- `${continuePrefix}${theme.fg(verdictColor, verdictIcon)} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${theme.fg("dim", `(${(summary.confidence * 100).toFixed(0)}% confidence)`)}`,
416
+ `${continuePrefix} Patch is ${theme.fg(verdictColor, summary.overall_correctness)} ${theme.fg(
417
+ verdictColor,
418
+ verdictIcon,
419
+ )} ${theme.fg("dim", `(${(summary.confidence * 100).toFixed(0)}% confidence)`)}`,
385
420
  );
386
421
 
387
422
  // Explanation preview (first ~80 chars when collapsed, full when expanded)
@@ -395,7 +430,7 @@ function renderReviewResult(
395
430
  } else {
396
431
  // Preview: first sentence or ~100 chars
397
432
  const preview = truncate(`${summary.explanation.split(/[.!?]/)[0]}.`, 100, theme.format.ellipsis);
398
- lines.push(`${continuePrefix}${theme.fg("dim", `Summary: ${preview}`)}`);
433
+ lines.push(`${continuePrefix}${theme.fg("dim", preview)}`);
399
434
  }
400
435
  }
401
436
 
@@ -411,7 +446,7 @@ function renderReviewResult(
411
446
  }
412
447
 
413
448
  /**
414
- * Render review findings list (used with and without submit_review).
449
+ * Render review findings list.
415
450
  */
416
451
  function renderFindings(
417
452
  findings: ReportFindingDetails[],
@@ -472,12 +507,14 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
472
507
  const iconColor = success ? "success" : "error";
473
508
  const statusText = aborted ? "aborted" : success ? "done" : "failed";
474
509
 
475
- // Main status line - use taskId for Output tool
476
- let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", result.taskId)} ${formatBadge(statusText, iconColor, theme)}`;
510
+ // Main status line: taskId: description [status] · stats · ⟨agent⟩
477
511
  const description = result.description?.trim();
478
- if (description) {
479
- statusLine += ` ${theme.fg("muted", truncate(description, 40, theme.format.ellipsis))}`;
480
- }
512
+ const titlePart = description ? `${theme.bold(result.taskId)}: ${description}` : result.taskId;
513
+ let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
514
+ statusText,
515
+ iconColor,
516
+ theme,
517
+ )}`;
481
518
  if (result.tokens > 0) {
482
519
  statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(result.tokens)} tokens`)}`;
483
520
  }
@@ -489,10 +526,16 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
489
526
 
490
527
  lines.push(statusLine);
491
528
 
492
- // Check for review result (submit_review + report_finding)
493
- const submitReviewData = result.extractedToolData?.submit_review as SubmitReviewDetails[] | undefined;
529
+ // Check for review result (complete with review schema + report_finding)
530
+ const completeData = result.extractedToolData?.complete as Array<{ data: unknown }> | undefined;
494
531
  const reportFindingData = result.extractedToolData?.report_finding as ReportFindingDetails[] | undefined;
495
532
 
533
+ // Extract review verdict from complete tool's data field if it matches SubmitReviewDetails
534
+ const reviewData = completeData
535
+ ?.map((c) => c.data as SubmitReviewDetails)
536
+ .filter((d) => d && typeof d === "object" && "overall_correctness" in d);
537
+ const submitReviewData = reviewData && reviewData.length > 0 ? reviewData : undefined;
538
+
496
539
  if (submitReviewData && submitReviewData.length > 0) {
497
540
  // Use combined review renderer
498
541
  const summary = submitReviewData[submitReviewData.length - 1];
@@ -502,7 +545,10 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
502
545
  }
503
546
  if (reportFindingData && reportFindingData.length > 0) {
504
547
  lines.push(
505
- `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg("dim", "Review summary missing (submit_review not called)")}`,
548
+ `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg(
549
+ "dim",
550
+ "Review summary missing (complete not called)",
551
+ )}`,
506
552
  );
507
553
  lines.push(`${continuePrefix}${formatFindingSummary(reportFindingData, theme)}`);
508
554
  lines.push(`${continuePrefix}`); // Spacing
@@ -515,7 +561,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
515
561
  if (result.extractedToolData) {
516
562
  for (const [toolName, dataArray] of Object.entries(result.extractedToolData)) {
517
563
  // Skip review tools - handled above
518
- if (toolName === "submit_review" || toolName === "report_finding") continue;
564
+ if (toolName === "complete" || toolName === "report_finding") continue;
519
565
 
520
566
  const handler = subprocessToolRegistry.getHandler(toolName);
521
567
  if (handler?.renderFinal && (dataArray as unknown[]).length > 0) {
@@ -41,20 +41,29 @@ export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress";
41
41
 
42
42
  /** Single task item for parallel execution */
43
43
  export const taskItemSchema = Type.Object({
44
- agent: Type.String({ description: "Agent name" }),
44
+ id: Type.String({
45
+ description: "Short task identifier for display (max 32 chars, CamelCase, e.g. 'SessionStore', 'WebFetchFix')",
46
+ maxLength: 32,
47
+ pattern: "^[A-Za-z][A-Za-z0-9]*$",
48
+ }),
45
49
  task: Type.String({ description: "Task description for the agent" }),
46
- description: Type.Optional(Type.String({ description: "Short description for UI display" })),
47
- model: Type.Optional(Type.String({ description: "Model override for this task" })),
50
+ description: Type.String({ description: "Short description for UI display" }),
48
51
  });
49
52
 
50
53
  export type TaskItem = Static<typeof taskItemSchema>;
51
54
 
52
55
  /** Task tool parameters */
53
56
  export const taskSchema = Type.Object({
54
- context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })),
55
- output_schema: Type.Optional(
57
+ agent: Type.String({ description: "Agent type to use for all tasks" }),
58
+ context: Type.String({ description: "Shared context prepended to all task prompts" }),
59
+ model: Type.Optional(
60
+ Type.String({
61
+ description: "Model override for all tasks (fuzzy matching, e.g. 'sonnet', 'opus')",
62
+ }),
63
+ ),
64
+ output: Type.Optional(
56
65
  Type.Any({
57
- description: "JSON schema for structured subagent output (used by the complete tool)",
66
+ description: "JTD schema for structured subagent output (used by the complete tool)",
58
67
  }),
59
68
  ),
60
69
  tasks: Type.Array(taskItemSchema, {