@oh-my-pi/pi-coding-agent 3.25.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 (157) hide show
  1. package/CHANGELOG.md +90 -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/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +55 -32
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +152 -76
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -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) {
@@ -4,20 +4,34 @@ import { type Static, Type } from "@sinclair/typebox";
4
4
  /** Source of an agent definition */
5
5
  export type AgentSource = "bundled" | "user" | "project";
6
6
 
7
+ function getEnv(name: string, defaultValue: number): number {
8
+ const value = process.env[name];
9
+ if (value === undefined) {
10
+ return defaultValue;
11
+ }
12
+ try {
13
+ const number = Number.parseInt(value, 10);
14
+ if (!Number.isNaN(number) && number > 0) {
15
+ return number;
16
+ }
17
+ } catch {}
18
+ return defaultValue;
19
+ }
20
+
7
21
  /** Maximum tasks per call */
8
- export const MAX_PARALLEL_TASKS = 32;
22
+ export const MAX_PARALLEL_TASKS = getEnv("OMP_TASK_MAX_PARALLEL", 32);
9
23
 
10
24
  /** Maximum concurrent workers */
11
- export const MAX_CONCURRENCY = 16;
25
+ export const MAX_CONCURRENCY = getEnv("OMP_TASK_MAX_CONCURRENCY", 16);
12
26
 
13
27
  /** Maximum output bytes per agent */
14
- export const MAX_OUTPUT_BYTES = 500_000;
28
+ export const MAX_OUTPUT_BYTES = getEnv("OMP_TASK_MAX_OUTPUT_BYTES", 500_000);
15
29
 
16
30
  /** Maximum output lines per agent */
17
- export const MAX_OUTPUT_LINES = 5000;
31
+ export const MAX_OUTPUT_LINES = getEnv("OMP_TASK_MAX_OUTPUT_LINES", 5000);
18
32
 
19
33
  /** Maximum agents to show in description */
20
- export const MAX_AGENTS_IN_DESCRIPTION = 10;
34
+ export const MAX_AGENTS_IN_DESCRIPTION = getEnv("OMP_TASK_MAX_AGENTS_IN_DESCRIPTION", 10);
21
35
 
22
36
  /** EventBus channel for raw subagent events */
23
37
  export const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
@@ -27,20 +41,29 @@ export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress";
27
41
 
28
42
  /** Single task item for parallel execution */
29
43
  export const taskItemSchema = Type.Object({
30
- 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
+ }),
31
49
  task: Type.String({ description: "Task description for the agent" }),
32
- description: Type.Optional(Type.String({ description: "Short description for UI display" })),
33
- model: Type.Optional(Type.String({ description: "Model override for this task" })),
50
+ description: Type.String({ description: "Short description for UI display" }),
34
51
  });
35
52
 
36
53
  export type TaskItem = Static<typeof taskItemSchema>;
37
54
 
38
55
  /** Task tool parameters */
39
56
  export const taskSchema = Type.Object({
40
- context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })),
41
- 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(
42
65
  Type.Any({
43
- 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)",
44
67
  }),
45
68
  ),
46
69
  tasks: Type.Array(taskItemSchema, {
@@ -19,12 +19,17 @@ import type { AgentSessionEvent } from "../../agent-session";
19
19
  import { parseModelPattern, parseModelString } from "../../model-resolver";
20
20
  import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
21
21
  import { SessionManager } from "../../session-manager";
22
+ import { untilAborted } from "../../utils";
22
23
  import type { SubagentWorkerRequest, SubagentWorkerResponse, SubagentWorkerStartPayload } from "./worker-protocol";
23
24
 
24
25
  type PostMessageFn = (message: SubagentWorkerResponse) => void;
25
26
 
26
27
  const postMessageSafe: PostMessageFn = (message) => {
27
- (globalThis as typeof globalThis & { postMessage: PostMessageFn }).postMessage(message);
28
+ try {
29
+ (globalThis as typeof globalThis & { postMessage: PostMessageFn }).postMessage(message);
30
+ } catch {
31
+ // Parent may have terminated worker, nothing we can do
32
+ }
28
33
  };
29
34
 
30
35
  interface WorkerMessageEvent<T> {
@@ -49,9 +54,33 @@ const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent => {
49
54
  return agentEventTypes.has(event.type as AgentEvent["type"]);
50
55
  };
51
56
 
52
- let running = false;
53
- let abortRequested = false;
54
- let activeSession: { abort: () => Promise<void>; dispose: () => Promise<void> } | null = null;
57
+ interface RunState {
58
+ abortController: AbortController;
59
+ startTime: number;
60
+ session: { abort: () => Promise<void>; dispose: () => Promise<void> } | null;
61
+ unsubscribe: (() => void) | null;
62
+ sendDoneOnce: (message: Extract<SubagentWorkerResponse, { type: "done" }>) => void;
63
+ }
64
+
65
+ const createSendDoneOnce = (): RunState["sendDoneOnce"] => {
66
+ let sent = false;
67
+ return (message) => {
68
+ if (sent) return;
69
+ sent = true;
70
+ postMessageSafe(message);
71
+ };
72
+ };
73
+
74
+ const createRunState = (): RunState => ({
75
+ abortController: new AbortController(),
76
+ startTime: Date.now(),
77
+ session: null,
78
+ unsubscribe: null,
79
+ sendDoneOnce: createSendDoneOnce(),
80
+ });
81
+
82
+ let activeRun: RunState | null = null;
83
+ let pendingAbort = false;
55
84
 
56
85
  /**
57
86
  * Resolve model string to Model object with optional thinking level.
@@ -92,26 +121,35 @@ function resolveModelOverride(
92
121
  * - OMP_BLOCKED_AGENT: payload.blockedAgent (prevents same-agent recursion)
93
122
  * - OMP_SPAWNS: payload.spawnsEnv (controls nested spawn permissions)
94
123
  */
95
- async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
96
- const startTime = Date.now();
124
+ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): Promise<void> {
125
+ const { signal } = runState.abortController;
126
+ const startTime = runState.startTime;
97
127
  let exitCode = 0;
98
128
  let error: string | undefined;
99
129
  let aborted = false;
130
+ const sessionAbortController = new AbortController();
100
131
 
101
- try {
102
- // Check for pre-start abort
103
- if (abortRequested) {
132
+ // Helper to check abort status - throws if aborted to exit early
133
+ const checkAbort = (): void => {
134
+ if (signal.aborted) {
104
135
  aborted = true;
105
136
  exitCode = 1;
106
- return;
137
+ throw new Error("Aborted");
107
138
  }
139
+ };
140
+
141
+ try {
142
+ // Check for pre-start abort
143
+ checkAbort();
108
144
 
109
145
  // Set working directory (CLI does this implicitly)
110
146
  process.chdir(payload.cwd);
111
147
 
112
148
  // Discover auth and models (equivalent to CLI's discoverAuthStorage/discoverModels)
113
149
  const authStorage = await discoverAuthStorage();
150
+ checkAbort();
114
151
  const modelRegistry = await discoverModels(authStorage);
152
+ checkAbort();
115
153
 
116
154
  // Resolve model override (equivalent to CLI's parseModelPattern with --model)
117
155
  const { model, thinkingLevel } = resolveModelOverride(payload.model, modelRegistry);
@@ -120,6 +158,7 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
120
158
  const sessionManager = payload.sessionFile
121
159
  ? await SessionManager.open(payload.sessionFile)
122
160
  : SessionManager.inMemory(payload.cwd);
161
+ checkAbort();
123
162
 
124
163
  // Create agent session (equivalent to CLI's createAgentSession)
125
164
  // Note: hasUI: false disables interactive features
@@ -143,7 +182,16 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
143
182
  spawns: payload.spawnsEnv,
144
183
  });
145
184
 
146
- activeSession = session;
185
+ runState.session = session;
186
+ checkAbort();
187
+
188
+ signal.addEventListener(
189
+ "abort",
190
+ () => {
191
+ void session.abort();
192
+ },
193
+ { once: true, signal: sessionAbortController.signal },
194
+ );
147
195
 
148
196
  // Initialize extensions (equivalent to CLI's extension initialization)
149
197
  // Note: Does not support --extension CLI flag or extension CLI flags
@@ -174,7 +222,7 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
174
222
  let completeCalled = false;
175
223
 
176
224
  // Subscribe to events and forward to parent (equivalent to --mode json output)
177
- session.subscribe((event: AgentSessionEvent) => {
225
+ runState.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
178
226
  if (isAgentEvent(event)) {
179
227
  postMessageSafe({ type: "event", event });
180
228
  // Track when complete tool is called
@@ -189,7 +237,7 @@ async function runTask(payload: SubagentWorkerStartPayload): Promise<void> {
189
237
 
190
238
  // Retry loop if complete was not called
191
239
  let retryCount = 0;
192
- while (!completeCalled && retryCount < MAX_COMPLETE_RETRIES && !abortRequested) {
240
+ while (!completeCalled && retryCount < MAX_COMPLETE_RETRIES && !signal.aborted) {
193
241
  retryCount++;
194
242
  const reminder = `<system-reminder>
195
243
  CRITICAL: You stopped without calling the complete tool. This is reminder ${retryCount} of ${MAX_COMPLETE_RETRIES}.
@@ -214,26 +262,45 @@ Call complete now.`;
214
262
  }
215
263
  } catch (err) {
216
264
  exitCode = 1;
217
- error = err instanceof Error ? err.stack || err.message : String(err);
265
+ // Don't record abort as error - it's handled via the aborted flag
266
+ if (!signal.aborted) {
267
+ error = err instanceof Error ? err.stack || err.message : String(err);
268
+ }
218
269
  } finally {
219
270
  // Handle abort requested during execution
220
- if (abortRequested) {
271
+ if (signal.aborted) {
221
272
  aborted = true;
222
273
  if (exitCode === 0) exitCode = 1;
223
274
  }
224
275
 
225
- // Cleanup session
226
- if (activeSession) {
276
+ sessionAbortController.abort();
277
+
278
+ if (runState.unsubscribe) {
279
+ try {
280
+ runState.unsubscribe();
281
+ } catch {
282
+ // Ignore unsubscribe errors
283
+ }
284
+ runState.unsubscribe = null;
285
+ }
286
+
287
+ // Cleanup session with timeout to prevent hanging
288
+ if (runState.session) {
289
+ const session = runState.session;
290
+ runState.session = null;
227
291
  try {
228
- await activeSession.dispose();
292
+ await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
229
293
  } catch {
230
294
  // Ignore cleanup errors
231
295
  }
232
- activeSession = null;
233
296
  }
234
297
 
235
- // Send completion message to parent
236
- postMessageSafe({
298
+ if (activeRun === runState) {
299
+ activeRun = null;
300
+ }
301
+
302
+ // Send completion message to parent (only once)
303
+ runState.sendDoneOnce({
237
304
  type: "done",
238
305
  exitCode,
239
306
  durationMs: Date.now() - startTime,
@@ -245,12 +312,65 @@ Call complete now.`;
245
312
 
246
313
  /** Handle abort request from parent */
247
314
  function handleAbort(): void {
248
- abortRequested = true;
249
- if (activeSession) {
250
- void activeSession.abort();
315
+ const runState = activeRun;
316
+ if (!runState) {
317
+ pendingAbort = true;
318
+ return;
319
+ }
320
+ runState.abortController.abort();
321
+ if (runState.session) {
322
+ void runState.session.abort();
251
323
  }
252
324
  }
253
325
 
326
+ const reportFatal = (message: string): void => {
327
+ const runState = activeRun;
328
+ if (runState) {
329
+ runState.abortController.abort();
330
+ if (runState.session) {
331
+ void runState.session.abort();
332
+ }
333
+ runState.sendDoneOnce({
334
+ type: "done",
335
+ exitCode: 1,
336
+ durationMs: Date.now() - runState.startTime,
337
+ error: message,
338
+ aborted: false,
339
+ });
340
+ return;
341
+ }
342
+
343
+ postMessageSafe({
344
+ type: "done",
345
+ exitCode: 1,
346
+ durationMs: 0,
347
+ error: message,
348
+ aborted: false,
349
+ });
350
+ };
351
+
352
+ // Global error handlers to ensure we always send a done message
353
+ // Using self instead of globalThis for proper worker scope typing
354
+ declare const self: {
355
+ addEventListener(type: "error", listener: (event: ErrorEvent) => void): void;
356
+ addEventListener(type: "unhandledrejection", listener: (event: { reason: unknown }) => void): void;
357
+ addEventListener(type: "messageerror", listener: (event: MessageEvent) => void): void;
358
+ };
359
+
360
+ self.addEventListener("error", (event) => {
361
+ reportFatal(`Uncaught error: ${event.message || "Unknown error"}`);
362
+ });
363
+
364
+ self.addEventListener("unhandledrejection", (event) => {
365
+ const reason = event.reason;
366
+ const message = reason instanceof Error ? reason.stack || reason.message : String(reason);
367
+ reportFatal(`Unhandled rejection: ${message}`);
368
+ });
369
+
370
+ self.addEventListener("messageerror", () => {
371
+ reportFatal("Failed to deserialize parent message");
372
+ });
373
+
254
374
  // Message handler - receives start/abort commands from parent
255
375
  globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorkerRequest>) => {
256
376
  const message = event.data;
@@ -263,8 +383,13 @@ globalThis.addEventListener("message", (event: WorkerMessageEvent<SubagentWorker
263
383
 
264
384
  if (message.type === "start") {
265
385
  // Only allow one task per worker
266
- if (running) return;
267
- running = true;
268
- void runTask(message.payload);
386
+ if (activeRun) return;
387
+ const runState = createRunState();
388
+ if (pendingAbort) {
389
+ pendingAbort = false;
390
+ runState.abortController.abort();
391
+ }
392
+ activeRun = runState;
393
+ void runTask(runState, message.payload);
269
394
  }
270
395
  });