@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.1

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 (158) hide show
  1. package/CHANGELOG.md +90 -4
  2. package/dist/cli.js +869 -825
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/capability/mcp.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/config/keybindings.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +66 -34
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  10. package/dist/types/extensibility/shared-events.d.ts +2 -2
  11. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  12. package/dist/types/internal-urls/index.d.ts +1 -0
  13. package/dist/types/internal-urls/types.d.ts +1 -1
  14. package/dist/types/irc/bus.d.ts +66 -0
  15. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  17. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  18. package/dist/types/mcp/types.d.ts +2 -0
  19. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  20. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  21. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  22. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  23. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  25. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  26. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  27. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  28. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  29. package/dist/types/modes/components/welcome.d.ts +3 -9
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  32. package/dist/types/modes/interactive-mode.d.ts +3 -2
  33. package/dist/types/modes/theme/theme.d.ts +3 -1
  34. package/dist/types/modes/types.d.ts +3 -2
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  36. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  37. package/dist/types/registry/agent-registry.d.ts +16 -5
  38. package/dist/types/session/agent-session.d.ts +35 -30
  39. package/dist/types/session/messages.d.ts +2 -4
  40. package/dist/types/session/session-history-format.d.ts +12 -0
  41. package/dist/types/session/session-manager.d.ts +21 -3
  42. package/dist/types/session/streaming-output.d.ts +23 -0
  43. package/dist/types/task/executor.d.ts +11 -2
  44. package/dist/types/task/index.d.ts +11 -4
  45. package/dist/types/task/output-manager.d.ts +0 -7
  46. package/dist/types/task/repair-args.d.ts +8 -7
  47. package/dist/types/task/types.d.ts +55 -51
  48. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  49. package/dist/types/tools/find.d.ts +0 -11
  50. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  51. package/dist/types/tools/index.d.ts +1 -3
  52. package/dist/types/tools/irc.d.ts +76 -38
  53. package/dist/types/tools/job.d.ts +7 -1
  54. package/dist/types/tools/render-utils.d.ts +22 -0
  55. package/examples/extensions/with-deps/package.json +1 -0
  56. package/package.json +11 -10
  57. package/scripts/bundle-dist.ts +28 -19
  58. package/src/async/index.ts +0 -1
  59. package/src/capability/mcp.ts +1 -0
  60. package/src/cli/gallery-cli.ts +6 -5
  61. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  62. package/src/cli/gallery-fixtures/types.ts +5 -0
  63. package/src/cli.ts +20 -6
  64. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  65. package/src/config/keybindings.ts +6 -1
  66. package/src/config/mcp-schema.json +4 -0
  67. package/src/config/settings-schema.ts +68 -41
  68. package/src/config/settings.ts +7 -0
  69. package/src/edit/renderer.ts +96 -46
  70. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  71. package/src/eval/agent-bridge.ts +3 -16
  72. package/src/eval/js/shared/prelude.txt +1 -1
  73. package/src/eval/py/prelude.py +5 -6
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +44 -14
  76. package/src/extensibility/custom-tools/types.ts +2 -2
  77. package/src/extensibility/shared-events.ts +2 -2
  78. package/src/internal-urls/docs-index.generated.ts +9 -9
  79. package/src/internal-urls/history-protocol.ts +113 -0
  80. package/src/internal-urls/index.ts +1 -0
  81. package/src/internal-urls/router.ts +3 -1
  82. package/src/internal-urls/types.ts +1 -1
  83. package/src/irc/bus.ts +292 -0
  84. package/src/main.ts +8 -60
  85. package/src/mcp/manager.ts +3 -0
  86. package/src/mcp/oauth-discovery.ts +27 -2
  87. package/src/mcp/oauth-flow.ts +47 -1
  88. package/src/mcp/transports/stdio.ts +3 -0
  89. package/src/mcp/types.ts +2 -0
  90. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  91. package/src/modes/components/assistant-message.ts +15 -0
  92. package/src/modes/components/btw-panel.ts +5 -1
  93. package/src/modes/components/compaction-summary-message.ts +68 -32
  94. package/src/modes/components/custom-editor.ts +10 -0
  95. package/src/modes/components/mcp-add-wizard.ts +13 -0
  96. package/src/modes/components/settings-selector.ts +2 -0
  97. package/src/modes/components/status-line/component.ts +22 -12
  98. package/src/modes/components/status-line/types.ts +3 -0
  99. package/src/modes/components/tool-execution.ts +31 -1
  100. package/src/modes/components/transcript-container.ts +99 -18
  101. package/src/modes/components/tree-selector.ts +6 -1
  102. package/src/modes/components/ttsr-notification.ts +72 -30
  103. package/src/modes/components/welcome.ts +9 -33
  104. package/src/modes/controllers/event-controller.ts +93 -4
  105. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  106. package/src/modes/controllers/input-controller.ts +18 -2
  107. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  108. package/src/modes/controllers/selector-controller.ts +25 -17
  109. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  110. package/src/modes/interactive-mode.ts +17 -15
  111. package/src/modes/theme/theme.ts +24 -5
  112. package/src/modes/types.ts +3 -5
  113. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  114. package/src/modes/utils/ui-helpers.ts +51 -49
  115. package/src/prompts/system/irc-incoming.md +3 -4
  116. package/src/prompts/system/orchestrate-notice.md +2 -2
  117. package/src/prompts/system/subagent-system-prompt.md +0 -5
  118. package/src/prompts/system/system-prompt.md +1 -0
  119. package/src/prompts/system/workflow-notice.md +2 -2
  120. package/src/prompts/tools/eval.md +3 -3
  121. package/src/prompts/tools/irc.md +29 -19
  122. package/src/prompts/tools/read.md +2 -2
  123. package/src/prompts/tools/task-summary.md +5 -16
  124. package/src/prompts/tools/task.md +43 -29
  125. package/src/registry/agent-lifecycle.ts +218 -0
  126. package/src/registry/agent-registry.ts +16 -5
  127. package/src/sdk.ts +29 -9
  128. package/src/session/agent-session.ts +268 -241
  129. package/src/session/messages.ts +11 -78
  130. package/src/session/session-history-format.ts +246 -0
  131. package/src/session/session-manager.ts +59 -5
  132. package/src/session/streaming-output.ts +60 -0
  133. package/src/task/executor.ts +855 -466
  134. package/src/task/index.ts +723 -794
  135. package/src/task/output-manager.ts +0 -11
  136. package/src/task/render.ts +142 -66
  137. package/src/task/repair-args.ts +21 -9
  138. package/src/task/types.ts +73 -66
  139. package/src/tools/ask.ts +4 -2
  140. package/src/tools/bash.ts +15 -5
  141. package/src/tools/browser/tab-worker.ts +26 -7
  142. package/src/tools/browser.ts +28 -1
  143. package/src/tools/find.ts +2 -27
  144. package/src/tools/grouped-file-output.ts +1 -118
  145. package/src/tools/index.ts +4 -12
  146. package/src/tools/irc.ts +596 -171
  147. package/src/tools/job.ts +41 -7
  148. package/src/tools/read.ts +57 -1
  149. package/src/tools/render-utils.ts +56 -0
  150. package/src/tools/renderers.ts +2 -0
  151. package/src/tools/resolve.ts +4 -1
  152. package/src/tools/write.ts +65 -47
  153. package/src/web/search/providers/anthropic.ts +29 -4
  154. package/dist/types/async/support.d.ts +0 -2
  155. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  156. package/dist/types/task/simple-mode.d.ts +0 -8
  157. package/src/async/support.ts +0 -5
  158. package/src/task/simple-mode.ts +0 -27
package/src/task/index.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  * - .omp/agents/*.md (project-level)
8
8
  *
9
9
  * Supports:
10
- * - Single agent execution
11
- * - Parallel execution with concurrency limits
10
+ * - Single agent spawn per call (parallelism = parallel task calls)
11
+ * - Batch spawning + shared context per call when `task.batch` is enabled
12
+ * - Background execution through AsyncJobManager when `async.enabled` is enabled
12
13
  * - Progress tracking via JSON events
13
14
  * - Session artifacts for debugging
14
15
  */
@@ -27,29 +28,33 @@ import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.m
27
28
  import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
28
29
  import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
29
30
  import { truncateForPrompt } from "../tools/approval";
31
+ import { isIrcEnabled } from "../tools/irc";
30
32
  import { formatBytes, formatDuration } from "../tools/render-utils";
31
33
  import {
32
34
  type AgentDefinition,
33
35
  type AgentProgress,
34
36
  getTaskSchema,
35
37
  type SingleResult,
38
+ type TaskItem,
36
39
  type TaskParams,
37
40
  type TaskToolDetails,
38
41
  type TaskToolSchemaInstance,
39
42
  } from "./types";
40
43
  // Import review tools for side effects (registers subagent tool handlers)
41
44
  import "../tools/review";
45
+ import type { AsyncJobManager } from "../async";
42
46
  import type { LocalProtocolOptions } from "../internal-urls";
43
47
  import { loadOverallPlanReference } from "../plan-mode/plan-handoff";
48
+ import { AgentRegistry } from "../registry/agent-registry";
44
49
  import { generateCommitMessage } from "../utils/commit-message-generator";
45
50
  import * as git from "../utils/git";
46
51
  import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
47
52
  import { runSubprocess } from "./executor";
53
+ import { generateTaskName } from "./name-generator";
48
54
  import { AgentOutputManager } from "./output-manager";
49
55
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
50
56
  import { renderResult, renderCall as renderTaskCall } from "./render";
51
57
  import { repairTaskParams } from "./repair-args";
52
- import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
53
58
  import {
54
59
  applyNestedPatches,
55
60
  captureBaseline,
@@ -65,12 +70,12 @@ import {
65
70
  type WorktreeBaseline,
66
71
  } from "./worktree";
67
72
 
68
- function renderSubagentUserPrompt(assignment: string, simpleMode: TaskSimpleMode): string {
73
+ function renderSubagentUserPrompt(assignment: string): string {
69
74
  return prompt.render(subagentUserPromptTemplate, {
70
75
  assignment: assignment.trim(),
71
- independentMode: simpleMode === "independent",
72
76
  });
73
77
  }
78
+
74
79
  function createUsageTotals(): Usage {
75
80
  return {
76
81
  input: 0,
@@ -165,6 +170,17 @@ export function isReadOnlyAgent(agent: AgentDefinition): boolean {
165
170
  return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool));
166
171
  }
167
172
 
173
+ /**
174
+ * Preview text for a child result. Falls back to "(no output)" — annotated
175
+ * with the request count when the child actually did work, so the parent can
176
+ * tell a no-op child from one that burned requests before being cancelled.
177
+ */
178
+ export function formatResultOutputFallback(result: Pick<SingleResult, "output" | "stderr" | "requests">): string {
179
+ const base = result.output.trim() || result.stderr.trim();
180
+ if (base) return base;
181
+ return result.requests > 0 ? `(no output) after ${result.requests} req` : "(no output)";
182
+ }
183
+
168
184
  /**
169
185
  * Render the tool description from a cached agent list and current settings.
170
186
  */
@@ -172,9 +188,9 @@ function renderDescription(
172
188
  agents: AgentDefinition[],
173
189
  maxConcurrency: number,
174
190
  isolationEnabled: boolean,
175
- asyncEnabled: boolean,
176
191
  disabledAgents: string[],
177
- simpleMode: TaskSimpleMode,
192
+ batchEnabled: boolean,
193
+ asyncEnabled: boolean,
178
194
  ircEnabled: boolean,
179
195
  parentSpawns: string,
180
196
  ): string {
@@ -196,19 +212,14 @@ function renderDescription(
196
212
  description: agent.description,
197
213
  readOnly: isReadOnlyAgent(agent),
198
214
  }));
199
- const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
200
215
  return prompt.render(taskDescriptionTemplate, {
201
216
  agents: renderedAgents,
202
217
  spawningDisabled,
203
218
  MAX_CONCURRENCY: maxConcurrency,
204
219
  isolationEnabled,
220
+ batchEnabled,
205
221
  asyncEnabled,
206
- contextEnabled,
207
- customSchemaEnabled,
208
222
  ircEnabled,
209
- defaultMode: simpleMode === "default",
210
- schemaFreeMode: simpleMode === "schema-free",
211
- independentMode: simpleMode === "independent",
212
223
  });
213
224
  }
214
225
 
@@ -219,88 +230,120 @@ function createTaskModeError(text: string): AgentToolResult<TaskToolDetails> {
219
230
  };
220
231
  }
221
232
 
222
- function validateTaskModeParams(simpleMode: TaskSimpleMode, params: TaskParams): string | undefined {
223
- const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
224
- const disallowedFields: string[] = [];
225
- if (!contextEnabled && params.context !== undefined) {
226
- disallowedFields.push("context");
227
- }
228
- if (!customSchemaEnabled && params.schema !== undefined) {
229
- disallowedFields.push("schema");
230
- }
231
- if (disallowedFields.length === 0) {
232
- return undefined;
233
- }
234
-
235
- if (simpleMode === "schema-free") {
236
- return "task.simple is set to schema-free, so the task tool does not accept `schema`. Remove it and rely on the selected agent definition or inherited session schema.";
233
+ /**
234
+ * Reject fields the current configuration does not accept. `schema` is never
235
+ * accepted (structured output comes from the agent definition's `output`
236
+ * frontmatter, the inherited session schema, or an eval-workflow
237
+ * `agent(..., schema)` call); `tasks`/`context` require `task.batch`.
238
+ */
239
+ function validateShapeParams(batchEnabled: boolean, params: TaskParams): string | undefined {
240
+ if ((params as Record<string, unknown>).schema !== undefined) {
241
+ return "The task tool does not accept `schema`. Rely on the selected agent definition's `output` schema or the inherited session schema; workflows needing ad-hoc structured output use eval `agent(prompt, schema)`.";
237
242
  }
238
-
239
- if (disallowedFields.length === 1) {
240
- return `task.simple is set to independent, so the task tool does not accept \`${disallowedFields[0]}\`. Put everything the subagent needs inside each task assignment.`;
243
+ if (!batchEnabled) {
244
+ const disallowed = (["tasks", "context"] as const).filter(field => params[field] !== undefined);
245
+ if (disallowed.length > 0) {
246
+ return `task.batch is disabled, so the task tool does not accept ${disallowed.map(f => `\`${f}\``).join(" or ")}. Spawn one agent per call with \`assignment\`, or enable the task.batch setting.`;
247
+ }
241
248
  }
242
-
243
- return "task.simple is set to independent, so the task tool does not accept `context` or `schema`. Put all required background and output expectations inside each task assignment or the selected agent definition.";
249
+ return undefined;
244
250
  }
245
251
 
246
- /** Sentinel for async jobs whose subagent finished with a failing result; batch counters are already updated. */
247
- class TaskJobError extends Error {}
248
-
249
252
  /**
250
- * Validate task ids: every task needs a non-empty id and ids must be unique
251
- * (case-insensitive). Returns a problem description, or undefined when valid.
253
+ * Validate the spawn parameter contract against the wire shapes. `agent` is
254
+ * always required. With `task.batch` the model-facing shape is
255
+ * `{ agent, context, tasks[] }` — `tasks` non-empty with per-item assignments
256
+ * and unique ids, `context` non-empty, no top-level `assignment` alongside.
257
+ * The flat `{ agent, ...item }` form stays accepted at runtime under either
258
+ * setting (internal callers, stale transcripts). Returns a problem
259
+ * description, or undefined when valid.
252
260
  */
253
- function validateTaskIds(tasks: TaskParams["tasks"]): string | undefined {
254
- const missingTaskIndexes: number[] = [];
255
- const idIndexes = new Map<string, number[]>();
256
-
257
- for (let i = 0; i < tasks.length; i++) {
258
- const id = tasks[i]?.id;
259
- if (typeof id !== "string" || id.trim() === "") {
260
- missingTaskIndexes.push(i);
261
- continue;
261
+ function validateSpawnParams(params: TaskParams, batchEnabled: boolean): string | undefined {
262
+ const agent = typeof params.agent === "string" ? params.agent.trim() : "";
263
+ if (!agent) {
264
+ return "Missing `agent`. Provide an agent type to spawn.";
265
+ }
266
+ const hasAssignment = typeof params.assignment === "string" && params.assignment.trim() !== "";
267
+ const tasks = params.tasks;
268
+ if (batchEnabled && tasks !== undefined) {
269
+ if (!Array.isArray(tasks) || tasks.length === 0) {
270
+ return "Missing `tasks`. Provide at least one task item ({ id?, description?, assignment }).";
262
271
  }
263
- const normalizedId = id.toLowerCase();
264
- const indexes = idIndexes.get(normalizedId);
265
- if (indexes) {
266
- indexes.push(i);
267
- } else {
268
- idIndexes.set(normalizedId, [i]);
272
+ if (hasAssignment) {
273
+ return "Top-level `assignment` is not part of the batch shape. Put the work in `tasks[]` items.";
269
274
  }
270
- }
271
-
272
- const duplicateIds: Array<{ id: string; indexes: number[] }> = [];
273
- for (const [normalizedId, indexes] of idIndexes.entries()) {
274
- if (indexes.length > 1) {
275
- duplicateIds.push({
276
- id: tasks[indexes[0]]?.id ?? normalizedId,
277
- indexes,
278
- });
275
+ for (let i = 0; i < tasks.length; i++) {
276
+ const item = tasks[i];
277
+ if (!item || typeof item.assignment !== "string" || item.assignment.trim() === "") {
278
+ return `Task ${i + 1}${item?.id ? ` (\`${item.id}\`)` : ""} is missing \`assignment\`. Every task needs complete, self-contained instructions.`;
279
+ }
280
+ }
281
+ const seen = new Map<string, string>();
282
+ for (const item of tasks) {
283
+ const id = item.id?.trim();
284
+ if (!id) continue;
285
+ const key = id.toLowerCase();
286
+ const existing = seen.get(key);
287
+ if (existing !== undefined) {
288
+ return `Duplicate task id ${existing === id ? `\`${id}\`` : `\`${existing}\` / \`${id}\``}. Provided ids must be unique within a call (case-insensitive).`;
289
+ }
290
+ seen.set(key, id);
291
+ }
292
+ if (typeof params.context !== "string" || params.context.trim() === "") {
293
+ return "Missing `context`. Provide the shared background for this batch — goal, constraints, and any contract the tasks share.";
279
294
  }
280
- }
281
-
282
- if (missingTaskIndexes.length === 0 && duplicateIds.length === 0) {
283
295
  return undefined;
284
296
  }
297
+ if (!hasAssignment) {
298
+ return batchEnabled
299
+ ? "Missing `tasks`. Provide a `tasks` array (one subagent per item) with a shared `context`."
300
+ : "Missing `assignment`. Provide complete, self-contained instructions for the agent.";
301
+ }
302
+ return undefined;
303
+ }
285
304
 
286
- const problems: string[] = [];
287
- if (missingTaskIndexes.length > 0) {
288
- problems.push(`Missing task ids at indexes: ${missingTaskIndexes.join(", ")}`);
305
+ /**
306
+ * Normalize a validated call into its spawn list: the `tasks[]` batch when
307
+ * provided, otherwise the single top-level spawn.
308
+ */
309
+ function resolveSpawnItems(params: TaskParams): TaskItem[] {
310
+ if (Array.isArray(params.tasks) && params.tasks.length > 0) {
311
+ return params.tasks;
289
312
  }
290
- if (duplicateIds.length > 0) {
291
- const details = duplicateIds.map(entry => `${entry.id} (indexes ${entry.indexes.join(", ")})`).join("; ");
292
- problems.push(`Duplicate task ids detected (case-insensitive): ${details}`);
313
+ return [{ id: params.id, description: params.description, assignment: params.assignment }];
314
+ }
315
+
316
+ /**
317
+ * Per-spawn params handed to the executor path: top-level call fields with the
318
+ * item's identity substituted in. `tasks` never leaks into a spawn; the shared
319
+ * `context` rides along unchanged. Keys are only materialized when present —
320
+ * `#runSpawn` distinguishes an absent `isolated` from an explicit one. The
321
+ * item's `isolated` (batch form) wins over the top-level flag (flat form).
322
+ */
323
+ function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams {
324
+ const spawn: TaskParams = { agent: params.agent };
325
+ if (item.id !== undefined) spawn.id = item.id;
326
+ if (item.description !== undefined) spawn.description = item.description;
327
+ if (item.assignment !== undefined) spawn.assignment = item.assignment;
328
+ if (params.context !== undefined) spawn.context = params.context;
329
+ if (item.isolated !== undefined) {
330
+ spawn.isolated = item.isolated;
331
+ } else if ("isolated" in params) {
332
+ spawn.isolated = params.isolated;
293
333
  }
294
- return `Invalid tasks: ${problems.join(". ")}`;
334
+ return spawn;
295
335
  }
296
336
 
337
+ /** Sentinel for async jobs whose subagent finished with a failing result; progress is already updated. */
338
+ class TaskJobError extends Error {}
339
+
297
340
  /**
298
341
  * Process-level memo for create-time agent discovery, keyed by resolved cwd.
299
342
  *
300
343
  * `TaskTool.create` runs for every (sub)agent session in this process and the
301
344
  * walk-up + plugin-registry scan in `discoverAgents` is identical for a given
302
345
  * cwd, so repeat creations reuse the first scan. Execution-time discovery
303
- * (`#executeSync`) intentionally stays fresh. The memo also tracks the live
346
+ * (`#runSpawn`) intentionally stays fresh. The memo also tracks the live
304
347
  * `discoverAgents` binding: test spies swap that binding, which invalidates
305
348
  * the memo automatically.
306
349
  */
@@ -332,8 +375,9 @@ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
332
375
  /**
333
376
  * Task tool - Delegate tasks to specialized agents.
334
377
  *
335
- * Requires async initialization to discover available agents.
336
- * Use `TaskTool.create(session)` to instantiate.
378
+ * Each call spawns one subagent or, with `task.batch`, one per `tasks[]`
379
+ * item. When `async.enabled` is on, spawns run as AsyncJobManager jobs; when
380
+ * disabled, the tool blocks until every spawn finishes.
337
381
  */
338
382
  export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetails, Theme> {
339
383
  readonly name = "task";
@@ -344,11 +388,24 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
344
388
  if (typeof params.agent === "string") {
345
389
  lines.push(`Agent: ${truncateForPrompt(params.agent)}`);
346
390
  }
391
+ if (typeof params.id === "string" && params.id.trim()) {
392
+ lines.push(`Task: ${truncateForPrompt(params.id)}`);
393
+ }
394
+ if (typeof params.assignment === "string") {
395
+ lines.push(`Assignment:\n${truncateForPrompt(params.assignment)}`);
396
+ }
397
+ if (typeof params.context === "string" && params.context.trim()) {
398
+ lines.push(`Context:\n${truncateForPrompt(params.context)}`);
399
+ }
347
400
  const tasks = Array.isArray(params.tasks) ? params.tasks : [];
348
401
  const firstTask = tasks[0];
349
402
  if (firstTask) {
350
- lines.push(`Task: ${truncateForPrompt(firstTask.id)}`);
351
- lines.push(`Assignment:\n${truncateForPrompt(firstTask.assignment)}`);
403
+ if (typeof firstTask.id === "string" && firstTask.id.trim()) {
404
+ lines.push(`Task: ${truncateForPrompt(firstTask.id)}`);
405
+ }
406
+ if (typeof firstTask.assignment === "string") {
407
+ lines.push(`Assignment:\n${truncateForPrompt(firstTask.assignment)}`);
408
+ }
352
409
  if (tasks.length > 1) {
353
410
  lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`);
354
411
  }
@@ -356,7 +413,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
356
413
  return lines;
357
414
  };
358
415
  readonly label = "Task";
359
- readonly summary = "Spawn a subagent to complete a parallel task";
416
+ readonly summary = "Spawn subagents to complete delegated tasks";
360
417
  readonly strict = true;
361
418
  readonly loadMode = "discoverable";
362
419
  readonly renderResult = renderResult;
@@ -366,10 +423,16 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
366
423
  readonly mergeCallAndResult = true;
367
424
  readonly #discoveredAgents: AgentDefinition[];
368
425
  readonly #blockedAgent: string | undefined;
426
+ /**
427
+ * One semaphore per TaskTool instance (i.e. per session): bounds concurrent
428
+ * subagents across parallel `task` calls within the session. Sized from
429
+ * `task.maxConcurrency` at first use; later setting changes do not resize it.
430
+ */
431
+ #spawnSemaphore: Semaphore | undefined;
369
432
 
370
433
  get parameters(): TaskToolSchemaInstance {
371
434
  const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
372
- return getTaskSchema({ isolationEnabled, simpleMode: this.#getTaskSimpleMode() });
435
+ return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled() });
373
436
  }
374
437
 
375
438
  renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
@@ -385,10 +448,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
385
448
  this.#discoveredAgents,
386
449
  maxConcurrency,
387
450
  isolationMode !== "none",
388
- this.session.settings.get("async.enabled"),
389
451
  disabledAgents,
390
- this.#getTaskSimpleMode(),
391
- this.session.settings.get("irc.enabled") === true,
452
+ this.#isBatchEnabled(),
453
+ this.session.settings.get("async.enabled"),
454
+ isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
392
455
  this.session.getSessionSpawns() ?? "*",
393
456
  );
394
457
  }
@@ -400,8 +463,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
400
463
  this.#discoveredAgents = discoveredAgents;
401
464
  }
402
465
 
403
- #getTaskSimpleMode(): TaskSimpleMode {
404
- return this.session.settings.get("task.simple");
466
+ #isBatchEnabled(): boolean {
467
+ return this.session.settings.get("task.batch");
468
+ }
469
+
470
+ #getSpawnSemaphore(): Semaphore {
471
+ this.#spawnSemaphore ??= new Semaphore(this.session.settings.get("task.maxConcurrency"));
472
+ return this.#spawnSemaphore;
405
473
  }
406
474
 
407
475
  /**
@@ -419,327 +487,423 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
419
487
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
420
488
  ): Promise<AgentToolResult<TaskToolDetails>> {
421
489
  const params = repairTaskParams(rawParams as TaskParams);
422
- const simpleMode = this.#getTaskSimpleMode();
423
- const validationError = validateTaskModeParams(simpleMode, params);
490
+ const batchEnabled = this.#isBatchEnabled();
491
+ const validationError = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled);
424
492
  if (validationError) {
425
493
  return createTaskModeError(validationError);
426
494
  }
427
495
 
428
- const asyncEnabled = this.session.settings.get("async.enabled");
496
+ const spawnItems = resolveSpawnItems(params);
429
497
  const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
430
- if (!asyncEnabled || selectedAgent?.blocking === true) {
431
- return this.#executeSync(toolCallId, params, signal, onUpdate);
432
- }
433
-
434
- const manager = this.session.asyncJobManager;
435
- if (!manager) {
436
- // Async was requested but no manager is registered (e.g. an
437
- // orphaned session whose host never wired one up). Falling back
438
- // to the sync path keeps the tool usable; only background/job-poll
439
- // semantics are lost.
440
- logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
441
- return this.#executeSync(toolCallId, params, signal, onUpdate);
442
- }
443
-
444
- const taskItems = params.tasks ?? [];
445
- if (taskItems.length === 0) {
446
- return this.#executeSync(toolCallId, params, signal, onUpdate);
447
- }
448
-
449
- const taskIdProblem = validateTaskIds(taskItems);
450
- if (taskIdProblem) {
451
- return createTaskModeError(taskIdProblem);
498
+ const asyncEnabled = this.session.settings.get("async.enabled");
499
+ const manager = asyncEnabled ? this.session.asyncJobManager : undefined;
500
+ if (!asyncEnabled || !manager || selectedAgent?.blocking === true) {
501
+ // Sync fallback: async execution disabled, orphaned host that never
502
+ // wired a job manager, or an agent definition that declares
503
+ // `blocking: true`. The session-scoped semaphore still bounds fan-out
504
+ // across parallel task calls.
505
+ if (asyncEnabled && !manager) {
506
+ logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
507
+ }
508
+ return this.#executeSyncFanout(toolCallId, params, spawnItems, signal, onUpdate);
452
509
  }
453
510
 
511
+ // Resolve agent ids up front so the immediate result can name them.
454
512
  const outputManager =
455
513
  this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
456
- const uniqueIds = await outputManager.allocateBatch(taskItems.map(t => t.id));
457
- const fallbackAgentSource =
458
- this.#discoveredAgents.find(agent => agent.name === params.agent)?.source ?? "bundled";
459
- const progressByTaskId = new Map<string, AgentProgress>();
460
- for (let index = 0; index < taskItems.length; index++) {
461
- const taskItem = taskItems[index];
462
- const assignment = taskItem.assignment.trim();
463
- progressByTaskId.set(taskItem.id, {
464
- index,
465
- id: taskItem.id,
466
- agent: params.agent,
467
- agentSource: fallbackAgentSource,
468
- status: "pending",
469
- task: renderSubagentUserPrompt(assignment, simpleMode),
470
- assignment,
471
- description: taskItem.description,
472
- recentTools: [],
473
- recentOutput: [],
474
- toolCount: 0,
475
- tokens: 0,
476
- cost: 0,
477
- durationMs: 0,
514
+ const agentLabel = params.agent ?? "task";
515
+ const agentSource = selectedAgent?.source ?? "bundled";
516
+ const spawns: Array<{ agentId: string; item: TaskItem; progress: AgentProgress }> = [];
517
+ for (let index = 0; index < spawnItems.length; index++) {
518
+ const item = spawnItems[index];
519
+ const agentId = await outputManager.allocate(item.id?.trim() || generateTaskName());
520
+ const assignment = (item.assignment ?? "").trim();
521
+ spawns.push({
522
+ agentId,
523
+ item,
524
+ progress: {
525
+ index,
526
+ id: agentId,
527
+ agent: agentLabel,
528
+ agentSource,
529
+ status: "pending",
530
+ task: renderSubagentUserPrompt(assignment),
531
+ assignment,
532
+ description: item.description,
533
+ recentTools: [],
534
+ recentOutput: [],
535
+ toolCount: 0,
536
+ requests: 0,
537
+ tokens: 0,
538
+ cost: 0,
539
+ durationMs: 0,
540
+ },
478
541
  });
479
542
  }
480
543
 
481
- const startedJobs: Array<{ jobId: string; taskId: string }> = [];
482
- const failedSchedules: string[] = [];
483
- let completedJobs = 0;
484
- let failedJobs = 0;
485
-
486
- const getProgressSnapshot = (): AgentProgress[] => {
487
- // Shallow copies: top-level fields are reassigned (never mutated in
488
- // place) and the large nested payloads (extractedToolData) are
489
- // immutable once attached — structuredClone here cost O(batch × payload)
490
- // per progress event.
491
- return Array.from(progressByTaskId.values())
492
- .sort((a, b) => a.index - b.index)
493
- .map(progress => ({ ...progress }));
494
- };
495
-
544
+ // Aggregate async state for the one tool call: every spawn's job reports
545
+ // into the shared progress snapshot; the call stays "running" until all
546
+ // jobs settle, then turns "failed" if any spawn failed. The single-spawn
547
+ // case passes the job's own suggestion through (pre-batch behavior).
548
+ const single = spawns.length === 1;
549
+ let settledCount = 0;
550
+ let failedCount = 0;
551
+ let primaryJobId = spawns[0].agentId;
496
552
  const buildAsyncDetails = (state: "running" | "completed" | "failed", jobId: string): TaskToolDetails => ({
497
553
  projectAgentsDir: null,
498
554
  results: [],
499
555
  totalDurationMs: 0,
500
- progress: getProgressSnapshot(),
501
- async: { state, jobId, type: "task" },
556
+ progress: spawns.map(spawn => ({ ...spawn.progress })),
557
+ async: {
558
+ state: single ? state : settledCount < spawns.length ? "running" : failedCount > 0 ? "failed" : "completed",
559
+ jobId: single ? jobId : primaryJobId,
560
+ type: "task",
561
+ },
502
562
  });
503
563
 
504
- const emitAsyncUpdate = (state: "running" | "completed" | "failed", text: string): void => {
505
- const primaryJobId = startedJobs[0]?.jobId ?? "task";
506
- onUpdate?.({
507
- content: [{ type: "text", text }],
508
- details: buildAsyncDetails(state, primaryJobId),
509
- });
510
- };
511
-
512
- const maxConcurrency = this.session.settings.get("task.maxConcurrency");
513
- const semaphore = new Semaphore(maxConcurrency);
514
-
515
- for (let i = 0; i < taskItems.length; i++) {
516
- const taskItem = taskItems[i];
517
- if (signal?.aborted) {
518
- failedSchedules.push(`${taskItem.id}: cancelled before scheduling`);
519
- completedJobs += 1;
520
- const progress = progressByTaskId.get(taskItem.id);
521
- if (progress) {
522
- progress.status = "aborted";
523
- }
524
- continue;
525
- }
526
-
527
- const uniqueId = uniqueIds[i];
528
- const singleParams: TaskParams = { ...params, tasks: [taskItem] };
529
- const label = uniqueId;
564
+ const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
565
+ const started: Array<{ agentId: string; jobId: string; description?: string }> = [];
566
+ const failedSchedules: string[] = [];
567
+ for (const spawn of spawns) {
530
568
  try {
531
- const jobId = manager.register(
532
- "task",
533
- label,
534
- async ({ signal: runSignal, reportProgress, markRunning }) => {
535
- const startedAt = Date.now();
536
- const progress = progressByTaskId.get(taskItem.id);
537
- await semaphore.acquire();
538
- if (runSignal.aborted) {
539
- semaphore.release();
540
- if (progress) {
541
- progress.status = "aborted";
542
- }
543
- completedJobs += 1;
544
- failedJobs += 1;
545
- throw new Error("Aborted before execution");
546
- }
547
- markRunning();
548
- if (progress) {
549
- progress.status = "running";
550
- }
551
- await reportProgress(
552
- `Running background task ${taskItem.id}...`,
553
- buildAsyncDetails("running", startedJobs[0]?.jobId ?? label) as unknown as Record<string, unknown>,
554
- );
555
- try {
556
- const result = await this.#executeSync(toolCallId, singleParams, runSignal, undefined, [uniqueId]);
557
- const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
558
- const singleResult = result.details?.results[0];
559
- // A missing per-task result means #executeSync failed at the
560
- // tool level (results: []) — treat it as a failure, not success.
561
- const resultFailed =
562
- !singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0;
563
- if (progress) {
564
- progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed";
565
- progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
566
- progress.tokens = singleResult?.tokens ?? 0;
567
- progress.contextTokens = singleResult?.contextTokens;
568
- progress.contextWindow = singleResult?.contextWindow;
569
- progress.cost = singleResult?.usage?.cost.total ?? 0;
570
- progress.extractedToolData = singleResult?.extractedToolData;
571
- progress.retryFailure = singleResult?.retryFailure;
572
- progress.retryState = undefined;
573
- }
574
- completedJobs += 1;
575
- if (resultFailed) {
576
- failedJobs += 1;
577
- }
578
- const remaining = taskItems.length - completedJobs;
579
- const isDone = remaining === 0;
580
- await reportProgress(
581
- isDone
582
- ? `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`
583
- : `Background task batch progress: ${completedJobs}/${taskItems.length} finished (${remaining} running).`,
584
- buildAsyncDetails(
585
- isDone ? (failedJobs > 0 || failedSchedules.length > 0 ? "failed" : "completed") : "running",
586
- startedJobs[0]?.jobId ?? label,
587
- ) as unknown as Record<string, unknown>,
588
- );
589
- if (isDone) {
590
- emitAsyncUpdate(
591
- failedJobs > 0 || failedSchedules.length > 0 ? "failed" : "completed",
592
- `Background task batch complete: ${completedJobs}/${taskItems.length} finished.`,
593
- );
594
- }
595
- if (resultFailed) {
596
- // Mark the job itself failed; counters above are already updated.
597
- throw new TaskJobError(finalText);
598
- }
599
- return finalText;
600
- } catch (error) {
601
- if (error instanceof TaskJobError) {
602
- throw error;
603
- }
604
- if (progress) {
605
- progress.status = "failed";
606
- progress.durationMs = Math.max(0, Date.now() - startedAt);
607
- }
608
- completedJobs += 1;
609
- failedJobs += 1;
610
- const remaining = taskItems.length - completedJobs;
611
- const isDone = remaining === 0;
612
- await reportProgress(
613
- isDone
614
- ? `Background task batch complete with failures: ${failedJobs} failed.`
615
- : `Background task batch progress: ${completedJobs}/${taskItems.length} finished (${remaining} running).`,
616
- buildAsyncDetails(
617
- isDone ? "failed" : "running",
618
- startedJobs[0]?.jobId ?? label,
619
- ) as unknown as Record<string, unknown>,
620
- );
621
- if (isDone) {
622
- emitAsyncUpdate(
623
- "failed",
624
- `Background task batch complete with failures: ${failedJobs} failed.`,
625
- );
626
- }
627
- throw error;
628
- } finally {
629
- semaphore.release();
630
- }
631
- },
632
- {
633
- id: label,
634
- queued: true,
635
- ownerId: this.session.getAgentId?.() ?? undefined,
636
- onProgress: (text, details) => {
637
- const progressDetails =
638
- (details as TaskToolDetails | undefined) ??
639
- buildAsyncDetails("running", startedJobs[0]?.jobId ?? label);
640
- onUpdate?.({ content: [{ type: "text", text }], details: progressDetails });
641
- },
569
+ const jobId = this.#registerSpawnJob({
570
+ manager,
571
+ toolCallId,
572
+ spawnParams: spawnParamsFor(params, spawn.item),
573
+ agentId: spawn.agentId,
574
+ progress: spawn.progress,
575
+ ircEnabled,
576
+ buildDetails: buildAsyncDetails,
577
+ onUpdate,
578
+ onSettled: failed => {
579
+ settledCount += 1;
580
+ if (failed) failedCount += 1;
642
581
  },
643
- );
644
- startedJobs.push({ jobId, taskId: taskItem.id });
582
+ });
583
+ if (started.length === 0) primaryJobId = jobId;
584
+ started.push({ agentId: spawn.agentId, jobId, description: spawn.item.description });
645
585
  } catch (error) {
646
586
  const message = error instanceof Error ? error.message : String(error);
647
- failedSchedules.push(`${taskItem.id}: ${message}`);
648
- completedJobs += 1;
649
- const progress = progressByTaskId.get(taskItem.id);
650
- if (progress) {
651
- progress.status = "failed";
652
- }
587
+ failedSchedules.push(`${spawn.agentId}: ${message}`);
588
+ spawn.progress.status = "failed";
589
+ settledCount += 1;
590
+ failedCount += 1;
653
591
  }
654
592
  }
655
593
 
656
- if (startedJobs.length === 0) {
657
- const failureText = `Failed to start background task jobs: ${failedSchedules.join("; ")}`;
594
+ if (started.length === 0) {
658
595
  return {
659
- content: [{ type: "text", text: failureText }],
596
+ content: [
597
+ {
598
+ type: "text",
599
+ text: `Failed to start background task job${single ? "" : "s"}: ${failedSchedules.join("; ")}`,
600
+ },
601
+ ],
660
602
  details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
661
603
  };
662
604
  }
663
605
 
664
- emitAsyncUpdate(
665
- "running",
666
- `Launching ${startedJobs.length} background ${startedJobs.length === 1 ? "task" : "tasks"}...`,
667
- );
606
+ if (single) {
607
+ const { agentId, jobId, description } = started[0];
608
+ const coordinationHint = ircEnabled
609
+ ? `DM \`${agentId}\` via \`irc\` to coordinate while it runs; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
610
+ : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`;
611
+ const descriptionSuffix = description ? ` — ${description}` : "";
612
+ onUpdate?.({
613
+ content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }],
614
+ details: buildAsyncDetails("running", jobId),
615
+ });
616
+ return {
617
+ content: [
618
+ {
619
+ type: "text",
620
+ text: `Spawned agent \`${agentId}\` (job \`${jobId}\`)${descriptionSuffix}. The result will be delivered when it yields. ${coordinationHint}`,
621
+ },
622
+ ],
623
+ details: buildAsyncDetails("running", jobId),
624
+ };
625
+ }
668
626
 
627
+ const coordinationHint = ircEnabled
628
+ ? `DM these ids via \`irc\` to coordinate while they run; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
629
+ : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`;
669
630
  const scheduleFailureSummary =
670
631
  failedSchedules.length > 0
671
- ? ` Failed to schedule ${failedSchedules.length} task${failedSchedules.length === 1 ? "" : "s"}.`
632
+ ? ` Failed to schedule ${failedSchedules.length} spawn${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}.`
672
633
  : "";
673
-
674
- const ircEnabled = this.session.settings.get("irc.enabled") === true;
675
- const taskIdByItemId = new Map<string, string>();
676
- for (let i = 0; i < taskItems.length; i++) {
677
- taskIdByItemId.set(taskItems[i].id, uniqueIds[i]);
678
- }
679
- const startedListing = startedJobs
680
- .map(({ taskId, jobId }) => {
681
- const id = taskIdByItemId.get(taskId) ?? taskId;
682
- const desc = progressByTaskId.get(taskId)?.description;
683
- const prefix = `- \`${id}\` (job \`${jobId}\`)`;
684
- return desc ? `${prefix} — ${desc}` : prefix;
634
+ const startedListing = started
635
+ .map(({ agentId, jobId, description }) => {
636
+ const prefix = `- \`${agentId}\` (job \`${jobId}\`)`;
637
+ return description ? `${prefix} ${description}` : prefix;
685
638
  })
686
639
  .join("\n");
687
- const coordinationHint = ircEnabled
688
- ? ` DM these ids via \`irc\` to coordinate while they run; reach for \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
689
- : ` Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`;
690
-
640
+ onUpdate?.({
641
+ content: [{ type: "text", text: `Spawned ${started.length} agents...` }],
642
+ details: buildAsyncDetails("running", primaryJobId),
643
+ });
691
644
  return {
692
645
  content: [
693
646
  {
694
647
  type: "text",
695
- text: `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.\n${startedListing}\n${coordinationHint}`,
648
+ text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${startedListing}\n${coordinationHint}`,
696
649
  },
697
650
  ],
651
+ details: buildAsyncDetails("running", primaryJobId),
652
+ };
653
+ }
654
+
655
+ /**
656
+ * Register one background job that runs a single spawn to completion and
657
+ * delivers its yield text. The job body mirrors the sync path; `buildDetails`
658
+ * supplies the (possibly batch-shared) progress snapshot and `onSettled`
659
+ * feeds the caller's aggregate counters.
660
+ */
661
+ #registerSpawnJob(options: {
662
+ manager: AsyncJobManager;
663
+ toolCallId: string;
664
+ spawnParams: TaskParams;
665
+ agentId: string;
666
+ progress: AgentProgress;
667
+ ircEnabled: boolean;
668
+ buildDetails: (state: "running" | "completed" | "failed", jobId: string) => TaskToolDetails;
669
+ onUpdate?: AgentToolUpdateCallback<TaskToolDetails>;
670
+ onSettled?: (failed: boolean) => void;
671
+ }): string {
672
+ const { manager, toolCallId, spawnParams, agentId, progress, ircEnabled, buildDetails, onUpdate, onSettled } =
673
+ options;
674
+ const buildFollowUpHint = (aborted: boolean): string => {
675
+ if (aborted) {
676
+ return `\n\n${agentId} was aborted — transcript at history://${agentId}`;
677
+ }
678
+ const followUp = ircEnabled ? "message it via `irc` to follow up; " : "";
679
+ return `\n\n${agentId} is now idle — ${followUp}transcript at history://${agentId}`;
680
+ };
681
+ return manager.register(
682
+ "task",
683
+ agentId,
684
+ async ({ jobId: ownJobId, signal: runSignal, reportProgress, markRunning }) => {
685
+ const startedAt = Date.now();
686
+ const semaphore = this.#getSpawnSemaphore();
687
+ await semaphore.acquire();
688
+ if (runSignal.aborted) {
689
+ semaphore.release();
690
+ progress.status = "aborted";
691
+ onSettled?.(true);
692
+ throw new Error("Aborted before execution");
693
+ }
694
+ markRunning();
695
+ progress.status = "running";
696
+ await reportProgress(
697
+ `Running background task ${agentId}...`,
698
+ buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
699
+ );
700
+ try {
701
+ const result = await this.#executeSync(toolCallId, spawnParams, runSignal, undefined, agentId);
702
+ const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
703
+ const singleResult = result.details?.results[0];
704
+ // A missing result means the sync path failed at the tool level
705
+ // (results: []) — treat it as a failure, not success.
706
+ const resultFailed = !singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0;
707
+ progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed";
708
+ progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
709
+ progress.tokens = singleResult?.tokens ?? 0;
710
+ progress.requests = singleResult?.requests ?? 0;
711
+ progress.contextTokens = singleResult?.contextTokens;
712
+ progress.contextWindow = singleResult?.contextWindow;
713
+ progress.cost = singleResult?.usage?.cost.total ?? 0;
714
+ progress.extractedToolData = singleResult?.extractedToolData;
715
+ progress.retryFailure = singleResult?.retryFailure;
716
+ progress.retryState = undefined;
717
+ onSettled?.(resultFailed);
718
+ const statusText = resultFailed
719
+ ? `Background task ${agentId} failed.`
720
+ : `Background task ${agentId} complete.`;
721
+ await reportProgress(
722
+ statusText,
723
+ buildDetails(resultFailed ? "failed" : "completed", ownJobId) as unknown as Record<string, unknown>,
724
+ );
725
+ onUpdate?.({
726
+ content: [{ type: "text", text: statusText }],
727
+ details: buildDetails(resultFailed ? "failed" : "completed", ownJobId),
728
+ });
729
+ const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`;
730
+ if (resultFailed) {
731
+ // Mark the job itself failed; the failed agent stays interrogable.
732
+ throw new TaskJobError(deliveryText);
733
+ }
734
+ return deliveryText;
735
+ } catch (error) {
736
+ if (error instanceof TaskJobError) {
737
+ throw error;
738
+ }
739
+ progress.status = "failed";
740
+ progress.durationMs = Math.max(0, Date.now() - startedAt);
741
+ onSettled?.(true);
742
+ const statusText = `Background task ${agentId} failed.`;
743
+ await reportProgress(statusText, buildDetails("failed", ownJobId) as unknown as Record<string, unknown>);
744
+ onUpdate?.({
745
+ content: [{ type: "text", text: statusText }],
746
+ details: buildDetails("failed", ownJobId),
747
+ });
748
+ const message = error instanceof Error ? error.message : String(error);
749
+ const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
750
+ throw new TaskJobError(`${message}${hint}`);
751
+ } finally {
752
+ semaphore.release();
753
+ }
754
+ },
755
+ {
756
+ id: agentId,
757
+ queued: true,
758
+ ownerId: this.session.getAgentId?.() ?? undefined,
759
+ onProgress: (text, details) => {
760
+ const progressDetails = (details as TaskToolDetails | undefined) ?? buildDetails("running", agentId);
761
+ onUpdate?.({ content: [{ type: "text", text }], details: progressDetails });
762
+ },
763
+ },
764
+ );
765
+ }
766
+
767
+ /**
768
+ * Sync fallback fan-out (no job manager, or a `blocking: true` agent): run
769
+ * every spawn to completion inline and merge the per-spawn payloads into a
770
+ * single tool result. The session-scoped semaphore still bounds concurrency
771
+ * across parallel task calls.
772
+ */
773
+ async #executeSyncFanout(
774
+ toolCallId: string,
775
+ params: TaskParams,
776
+ spawnItems: TaskItem[],
777
+ signal?: AbortSignal,
778
+ onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
779
+ ): Promise<AgentToolResult<TaskToolDetails>> {
780
+ const semaphore = this.#getSpawnSemaphore();
781
+ if (spawnItems.length === 1) {
782
+ await semaphore.acquire();
783
+ try {
784
+ return await this.#executeSync(toolCallId, spawnParamsFor(params, spawnItems[0]), signal, onUpdate);
785
+ } finally {
786
+ semaphore.release();
787
+ }
788
+ }
789
+
790
+ const startTime = Date.now();
791
+ const latestProgress = new Map<number, AgentProgress>();
792
+ const emitCombined = () => {
793
+ onUpdate?.({
794
+ content: [{ type: "text", text: `Running ${spawnItems.length} agents...` }],
795
+ details: {
796
+ projectAgentsDir: null,
797
+ results: [],
798
+ totalDurationMs: Date.now() - startTime,
799
+ progress: Array.from(latestProgress.entries())
800
+ .sort((a, b) => a[0] - b[0])
801
+ .map(([, progress]) => progress),
802
+ },
803
+ });
804
+ };
805
+
806
+ const { results: payloads } = await mapWithConcurrencyLimit(
807
+ spawnItems,
808
+ spawnItems.length,
809
+ async (item, index, workerSignal) => {
810
+ await semaphore.acquire();
811
+ try {
812
+ const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onUpdate
813
+ ? update => {
814
+ const progress = update.details?.progress?.[0];
815
+ if (progress) {
816
+ latestProgress.set(index, { ...progress, index });
817
+ emitCombined();
818
+ }
819
+ }
820
+ : undefined;
821
+ return await this.#executeSync(toolCallId, spawnParamsFor(params, item), workerSignal, itemOnUpdate);
822
+ } finally {
823
+ semaphore.release();
824
+ }
825
+ },
826
+ signal,
827
+ );
828
+
829
+ const results: SingleResult[] = [];
830
+ const contentParts: string[] = [];
831
+ const outputPaths: string[] = [];
832
+ const usageTotals = createUsageTotals();
833
+ let hasUsage = false;
834
+ let projectAgentsDir: string | null = null;
835
+ for (let index = 0; index < spawnItems.length; index++) {
836
+ const payload = payloads[index];
837
+ if (!payload) {
838
+ contentParts.push(`Task ${spawnItems[index].id?.trim() || `#${index + 1}`}: cancelled before start.`);
839
+ continue;
840
+ }
841
+ projectAgentsDir ??= payload.details?.projectAgentsDir ?? null;
842
+ const text = payload.content.find(part => part.type === "text")?.text;
843
+ if (text) contentParts.push(text);
844
+ for (const result of payload.details?.results ?? []) {
845
+ results.push({ ...result, index });
846
+ if (result.usage) {
847
+ addUsageTotals(usageTotals, result.usage);
848
+ hasUsage = true;
849
+ }
850
+ if (result.outputPath) outputPaths.push(result.outputPath);
851
+ }
852
+ }
853
+
854
+ return {
855
+ content: [{ type: "text", text: contentParts.join("\n\n") }],
698
856
  details: {
699
- projectAgentsDir: null,
700
- results: [],
701
- totalDurationMs: 0,
702
- progress: getProgressSnapshot(),
703
- async: { state: "running", jobId: startedJobs[0].jobId, type: "task" },
857
+ projectAgentsDir,
858
+ results,
859
+ totalDurationMs: Date.now() - startTime,
860
+ usage: hasUsage ? usageTotals : undefined,
861
+ outputPaths: outputPaths.length > 0 ? outputPaths : undefined,
704
862
  },
705
863
  };
706
864
  }
707
865
 
866
+ /**
867
+ * Synchronous execution of one spawn. Used as the body of every
868
+ * async job and directly by the sync fallback (no job manager / blocking
869
+ * agent) and by in-process callers that need the result inline (e.g. the
870
+ * commit flow's analyze_files tool).
871
+ */
708
872
  async #executeSync(
709
873
  toolCallId: string,
710
874
  params: TaskParams,
711
875
  signal?: AbortSignal,
712
876
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
713
- preAllocatedIds?: string[],
877
+ preAllocatedId?: string,
878
+ ): Promise<AgentToolResult<TaskToolDetails>> {
879
+ return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId);
880
+ }
881
+
882
+ /** Spawn a fresh subagent and run it to completion. */
883
+ async #runSpawn(
884
+ toolCallId: string,
885
+ params: TaskParams,
886
+ signal?: AbortSignal,
887
+ onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
888
+ preAllocatedId?: string,
714
889
  ): Promise<AgentToolResult<TaskToolDetails>> {
715
890
  const startTime = Date.now();
716
891
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
717
- const { agent: agentName, context, schema: outputSchema } = params;
718
- const simpleMode = this.#getTaskSimpleMode();
719
- const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
720
- const sharedContext = contextEnabled ? context?.trim() : undefined;
892
+ const agentName = params.agent ?? "";
893
+ const sharedContext = this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined;
894
+ const assignment = (params.assignment ?? "").trim();
721
895
  const isolationMode = this.session.settings.get("task.isolation.mode");
722
896
  const isolationRequested = "isolated" in params ? params.isolated === true : false;
723
897
  const isIsolated = isolationMode !== "none" && isolationRequested;
724
898
  const mergeMode = this.session.settings.get("task.isolation.merge");
725
899
  const commitStyle = this.session.settings.get("task.isolation.commits");
726
- const maxConcurrency = this.session.settings.get("task.maxConcurrency");
727
900
  const taskDepth = this.session.taskDepth ?? 0;
728
901
  const subagentLspEnabled = (this.session.enableLsp ?? true) && this.session.settings.get("task.enableLsp");
729
902
 
730
903
  if (isolationMode === "none" && "isolated" in params) {
731
904
  return {
732
- content: [
733
- {
734
- type: "text",
735
- text: "Task isolation is disabled.",
736
- },
737
- ],
738
- details: {
739
- projectAgentsDir,
740
- results: [],
741
- totalDurationMs: 0,
742
- },
905
+ content: [{ type: "text", text: "Task isolation is disabled." }],
906
+ details: { projectAgentsDir, results: [], totalDurationMs: 0 },
743
907
  };
744
908
  }
745
909
 
@@ -748,17 +912,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
748
912
  if (!agent) {
749
913
  const available = agents.map(a => a.name).join(", ") || "none";
750
914
  return {
751
- content: [
752
- {
753
- type: "text",
754
- text: `Unknown agent "${agentName}". Available: ${available}`,
755
- },
756
- ],
757
- details: {
758
- projectAgentsDir,
759
- results: [],
760
- totalDurationMs: 0,
761
- },
915
+ content: [{ type: "text", text: `Unknown agent "${agentName}". Available: ${available}` }],
916
+ details: { projectAgentsDir, results: [], totalDurationMs: 0 },
762
917
  };
763
918
  }
764
919
 
@@ -773,11 +928,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
773
928
  text: `Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
774
929
  },
775
930
  ],
776
- details: {
777
- projectAgentsDir,
778
- results: [],
779
- totalDurationMs: 0,
780
- },
931
+ details: { projectAgentsDir, results: [], totalDurationMs: 0 },
781
932
  };
782
933
  }
783
934
 
@@ -811,43 +962,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
811
962
  });
812
963
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
813
964
 
814
- // Output schema priority: task call > agent frontmatter > inherited parent session.
815
- // task.simple can disable the task-call override while leaving agent/session schemas intact.
816
- const effectiveOutputSchema = customSchemaEnabled
817
- ? (outputSchema ?? effectiveAgent.output ?? this.session.outputSchema)
818
- : (effectiveAgent.output ?? this.session.outputSchema);
819
-
820
- // Handle empty or missing tasks
821
- if (!params.tasks || params.tasks.length === 0) {
822
- return {
823
- content: [
824
- {
825
- type: "text",
826
- text: contextEnabled
827
- ? "No tasks provided. Use: { agent, context?, tasks: [{ id, description, assignment }, ...] }"
828
- : "No tasks provided. Use: { agent, tasks: [{ id, description, assignment }, ...] }",
829
- },
830
- ],
831
- details: {
832
- projectAgentsDir,
833
- results: [],
834
- totalDurationMs: 0,
835
- },
836
- };
837
- }
838
-
839
- const tasks = params.tasks;
840
- const taskIdProblem = validateTaskIds(tasks);
841
- if (taskIdProblem) {
842
- return {
843
- content: [{ type: "text", text: taskIdProblem }],
844
- details: {
845
- projectAgentsDir,
846
- results: [],
847
- totalDurationMs: 0,
848
- },
849
- };
850
- }
965
+ // Output schema priority: agent frontmatter > inherited parent session.
966
+ // The task call itself never carries a schema; workflows needing ad-hoc
967
+ // structured output go through eval agent(prompt, schema).
968
+ const effectiveOutputSchema = effectiveAgent.output ?? this.session.outputSchema;
851
969
 
852
970
  let repoRoot: string | null = null;
853
971
  let baseline: WorktreeBaseline | null = null;
@@ -858,17 +976,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
858
976
  } catch (err) {
859
977
  const message = err instanceof Error ? err.message : String(err);
860
978
  return {
861
- content: [
862
- {
863
- type: "text",
864
- text: `Isolated task execution requires a git repository. ${message}`,
865
- },
866
- ],
867
- details: {
868
- projectAgentsDir,
869
- results: [],
870
- totalDurationMs: Date.now() - startTime,
871
- },
979
+ content: [{ type: "text", text: `Isolated task execution requires a git repository. ${message}` }],
980
+ details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
872
981
  };
873
982
  }
874
983
  }
@@ -901,23 +1010,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
901
1010
  localProtocolOptions,
902
1011
  );
903
1012
 
904
- // Initialize progress tracking
905
- const progressMap = new Map<number, AgentProgress>();
906
-
907
- // Update callback
908
- const emitProgress = () => {
909
- const progress = Array.from(progressMap.values()).sort((a, b) => a.index - b.index);
910
- onUpdate?.({
911
- content: [{ type: "text", text: `Running ${params.tasks.length} agents...` }],
912
- details: {
913
- projectAgentsDir,
914
- results: [],
915
- totalDurationMs: Date.now() - startTime,
916
- progress,
917
- },
918
- });
919
- };
920
-
921
1013
  try {
922
1014
  // Check self-recursion prevention
923
1015
  if (this.#blockedAgent && agentName === this.#blockedAgent) {
@@ -928,11 +1020,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
928
1020
  text: `Cannot spawn ${this.#blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`,
929
1021
  },
930
1022
  ],
931
- details: {
932
- projectAgentsDir,
933
- results: [],
934
- totalDurationMs: Date.now() - startTime,
935
- },
1023
+ details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
936
1024
  };
937
1025
  }
938
1026
 
@@ -949,36 +1037,21 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
949
1037
  const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
950
1038
  return {
951
1039
  content: [{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${allowed}` }],
952
- details: {
953
- projectAgentsDir,
954
- results: [],
955
- totalDurationMs: Date.now() - startTime,
956
- },
1040
+ details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
957
1041
  };
958
1042
  }
959
1043
 
960
- // Write parent conversation context for subagents. When IRC is available,
961
- // subagents should ask live peers instead of reading a stale markdown dump.
962
1044
  await fs.mkdir(effectiveArtifactsDir, { recursive: true });
963
- const shouldWriteConversationContext = this.session.settings.get("irc.enabled") !== true;
964
- const compactContext = shouldWriteConversationContext ? this.session.getCompactContext?.() : undefined;
965
- let contextFilePath: string | undefined;
966
- if (compactContext) {
967
- contextFilePath = path.join(effectiveArtifactsDir, "context.md");
968
- await Bun.write(contextFilePath, compactContext);
969
- }
970
1045
 
971
- // Build full prompts with context prepended
972
- // Allocate unique IDs across the session to prevent artifact collisions
973
- let uniqueIds: string[];
974
- if (preAllocatedIds && preAllocatedIds.length === tasks.length) {
975
- uniqueIds = preAllocatedIds;
1046
+ // Allocate a unique ID across the session to prevent artifact collisions
1047
+ let agentId: string;
1048
+ if (preAllocatedId) {
1049
+ agentId = preAllocatedId;
976
1050
  } else {
977
1051
  const outputManager =
978
1052
  this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
979
- uniqueIds = await outputManager.allocateBatch(tasks.map(t => t.id));
1053
+ agentId = await outputManager.allocate(params.id?.trim() || generateTaskName());
980
1054
  }
981
- const tasksWithUniqueIds = tasks.map((t, i) => ({ ...t, id: uniqueIds[i] }));
982
1055
 
983
1056
  const availableSkills = [...(this.session.skills ?? [])];
984
1057
  // Resolve autoload skills from agent definition against available skills
@@ -995,85 +1068,102 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
995
1068
  const parentEvalSessionId = this.session.getEvalSessionId?.() ?? undefined;
996
1069
  const mcpManager = this.session.mcpManager ?? MCPManager.instance();
997
1070
 
998
- // Initialize progress for all tasks
999
- for (let i = 0; i < tasksWithUniqueIds.length; i++) {
1000
- const taskItem = tasksWithUniqueIds[i];
1001
- const assignment = taskItem.assignment.trim();
1002
- progressMap.set(i, {
1003
- index: i,
1004
- id: taskItem.id,
1005
- agent: agentName,
1006
- agentSource: agent.source,
1007
- status: "pending",
1008
- task: renderSubagentUserPrompt(assignment, simpleMode),
1009
- assignment,
1010
- recentTools: [],
1011
- recentOutput: [],
1012
- toolCount: 0,
1013
- tokens: 0,
1014
- cost: 0,
1015
- durationMs: 0,
1016
- modelOverride,
1017
- description: taskItem.description,
1071
+ // Progress tracking for the single agent
1072
+ let latestProgress: AgentProgress = {
1073
+ index: 0,
1074
+ id: agentId,
1075
+ agent: agentName,
1076
+ agentSource: agent.source,
1077
+ status: "pending",
1078
+ task: renderSubagentUserPrompt(assignment),
1079
+ assignment,
1080
+ recentTools: [],
1081
+ recentOutput: [],
1082
+ toolCount: 0,
1083
+ requests: 0,
1084
+ tokens: 0,
1085
+ cost: 0,
1086
+ durationMs: 0,
1087
+ modelOverride,
1088
+ description: params.description,
1089
+ };
1090
+ const emitProgress = () => {
1091
+ onUpdate?.({
1092
+ content: [{ type: "text", text: `Running agent ${agentId}...` }],
1093
+ details: {
1094
+ projectAgentsDir,
1095
+ results: [],
1096
+ totalDurationMs: Date.now() - startTime,
1097
+ progress: [latestProgress],
1098
+ },
1018
1099
  });
1019
- }
1100
+ };
1020
1101
  emitProgress();
1021
1102
 
1022
- const runTask = async (
1023
- task: (typeof tasksWithUniqueIds)[number],
1024
- index: number,
1025
- workerSignal?: AbortSignal,
1026
- ) => {
1103
+ const buildCommitMessageFn = () =>
1104
+ commitStyle === "ai" && this.session.modelRegistry
1105
+ ? async (diff: string) => {
1106
+ return generateCommitMessage(
1107
+ diff,
1108
+ this.session.modelRegistry!,
1109
+ this.session.settings,
1110
+ this.session.getSessionId?.() ?? undefined,
1111
+ );
1112
+ }
1113
+ : undefined;
1114
+
1115
+ const sharedRunOptions = {
1116
+ cwd: this.session.cwd,
1117
+ agent: effectiveAgent,
1118
+ task: renderSubagentUserPrompt(assignment),
1119
+ assignment,
1120
+ context: sharedContext,
1121
+ planReference,
1122
+ description: params.description,
1123
+ index: 0,
1124
+ parentToolCallId: toolCallId,
1125
+ id: agentId,
1126
+ taskDepth,
1127
+ modelOverride,
1128
+ parentActiveModelPattern,
1129
+ thinkingLevel: thinkingLevelOverride,
1130
+ outputSchema: effectiveOutputSchema,
1131
+ sessionFile,
1132
+ persistArtifacts: !!artifactsDir,
1133
+ artifactsDir: effectiveArtifactsDir,
1134
+ enableLsp: subagentLspEnabled,
1135
+ signal,
1136
+ eventBus: this.session.eventBus,
1137
+ onProgress: (progress: AgentProgress) => {
1138
+ // Shallow snapshot; recentTools is mutated in place by the
1139
+ // executor, the rest is reassigned or immutable. A deep clone
1140
+ // here cost O(extractedToolData) per progress event.
1141
+ latestProgress = { ...progress, recentTools: progress.recentTools.slice() };
1142
+ emitProgress();
1143
+ },
1144
+ authStorage: this.session.authStorage,
1145
+ modelRegistry: this.session.modelRegistry,
1146
+ settings: this.session.settings,
1147
+ mcpManager,
1148
+ contextFiles,
1149
+ skills: availableSkills,
1150
+ autoloadSkills: resolvedAutoloadSkills,
1151
+ workspaceTree: this.session.workspaceTree,
1152
+ promptTemplates,
1153
+ rules: this.session.rules,
1154
+ preloadedExtensionPaths: this.session.extensionPaths,
1155
+ preloadedCustomToolPaths: this.session.customToolPaths,
1156
+ localProtocolOptions,
1157
+ parentArtifactManager,
1158
+ parentHindsightSessionState: this.session.getHindsightSessionState?.(),
1159
+ parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
1160
+ parentTelemetry: this.session.getTelemetry?.(),
1161
+ parentEvalSessionId,
1162
+ };
1163
+
1164
+ const runTask = async (): Promise<SingleResult> => {
1027
1165
  if (!isIsolated) {
1028
- return runSubprocess({
1029
- cwd: this.session.cwd,
1030
- agent: effectiveAgent,
1031
- task: renderSubagentUserPrompt(task.assignment, simpleMode),
1032
- assignment: task.assignment.trim(),
1033
- context: sharedContext,
1034
- planReference,
1035
- description: task.description,
1036
- index,
1037
- parentToolCallId: toolCallId,
1038
- id: task.id,
1039
- taskDepth,
1040
- modelOverride,
1041
- parentActiveModelPattern,
1042
- thinkingLevel: thinkingLevelOverride,
1043
- outputSchema: effectiveOutputSchema,
1044
- sessionFile,
1045
- persistArtifacts: !!artifactsDir,
1046
- artifactsDir: effectiveArtifactsDir,
1047
- contextFile: contextFilePath,
1048
- enableLsp: subagentLspEnabled,
1049
- signal: workerSignal ?? signal,
1050
- eventBus: this.session.eventBus,
1051
- onProgress: progress => {
1052
- // Shallow snapshot; recentTools is mutated in place by the
1053
- // executor, the rest is reassigned or immutable. A deep clone
1054
- // here cost O(extractedToolData) per progress event.
1055
- progressMap.set(index, { ...progress, recentTools: progress.recentTools.slice() });
1056
- emitProgress();
1057
- },
1058
- authStorage: this.session.authStorage,
1059
- modelRegistry: this.session.modelRegistry,
1060
- settings: this.session.settings,
1061
- mcpManager,
1062
- contextFiles,
1063
- skills: availableSkills,
1064
- autoloadSkills: resolvedAutoloadSkills,
1065
- workspaceTree: this.session.workspaceTree,
1066
- promptTemplates,
1067
- rules: this.session.rules,
1068
- preloadedExtensionPaths: this.session.extensionPaths,
1069
- preloadedCustomToolPaths: this.session.customToolPaths,
1070
- localProtocolOptions,
1071
- parentArtifactManager,
1072
- parentHindsightSessionState: this.session.getHindsightSessionState?.(),
1073
- parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
1074
- parentTelemetry: this.session.getTelemetry?.(),
1075
- parentEvalSessionId,
1076
- });
1166
+ return runSubprocess(sharedRunOptions);
1077
1167
  }
1078
1168
 
1079
1169
  const taskStart = Date.now();
@@ -1084,73 +1174,25 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1084
1174
  }
1085
1175
  const taskBaseline = structuredClone(baseline);
1086
1176
 
1087
- isolationHandle = await ensureIsolation(repoRoot, task.id, preferredIsolationBackend);
1177
+ isolationHandle = await ensureIsolation(repoRoot, agentId, preferredIsolationBackend);
1088
1178
  const isolationDir = isolationHandle.mergedDir;
1089
1179
 
1180
+ // Isolated runs re-discover extensions/custom tools inside the
1181
+ // worktree instead of reusing the parent's source paths.
1090
1182
  const result = await runSubprocess({
1091
- cwd: this.session.cwd,
1183
+ ...sharedRunOptions,
1092
1184
  worktree: isolationDir,
1093
- agent: effectiveAgent,
1094
- task: renderSubagentUserPrompt(task.assignment, simpleMode),
1095
- assignment: task.assignment.trim(),
1096
- context: sharedContext,
1097
- planReference,
1098
- description: task.description,
1099
- index,
1100
- parentToolCallId: toolCallId,
1101
- id: task.id,
1102
- taskDepth,
1103
- modelOverride,
1104
- parentActiveModelPattern,
1105
- thinkingLevel: thinkingLevelOverride,
1106
- outputSchema: effectiveOutputSchema,
1107
- sessionFile,
1108
- persistArtifacts: !!artifactsDir,
1109
- artifactsDir: effectiveArtifactsDir,
1110
- contextFile: contextFilePath,
1111
- enableLsp: subagentLspEnabled,
1112
- signal: workerSignal ?? signal,
1113
- eventBus: this.session.eventBus,
1114
- onProgress: progress => {
1115
- progressMap.set(index, { ...progress, recentTools: progress.recentTools.slice() });
1116
- emitProgress();
1117
- },
1118
- authStorage: this.session.authStorage,
1119
- modelRegistry: this.session.modelRegistry,
1120
- settings: this.session.settings,
1121
- mcpManager,
1122
- contextFiles,
1123
- skills: availableSkills,
1124
- autoloadSkills: resolvedAutoloadSkills,
1125
- workspaceTree: this.session.workspaceTree,
1126
- promptTemplates,
1127
- rules: this.session.rules,
1128
- localProtocolOptions,
1129
- parentArtifactManager,
1130
- parentHindsightSessionState: this.session.getHindsightSessionState?.(),
1131
- parentMnemopiSessionState: this.session.getMnemopiSessionState?.(),
1132
- parentTelemetry: this.session.getTelemetry?.(),
1133
- parentEvalSessionId,
1185
+ preloadedExtensionPaths: undefined,
1186
+ preloadedCustomToolPaths: undefined,
1134
1187
  });
1135
1188
  if (mergeMode === "branch" && result.exitCode === 0) {
1136
1189
  try {
1137
- const commitMsg =
1138
- commitStyle === "ai" && this.session.modelRegistry
1139
- ? async (diff: string) => {
1140
- return generateCommitMessage(
1141
- diff,
1142
- this.session.modelRegistry!,
1143
- this.session.settings,
1144
- this.session.getSessionId?.() ?? undefined,
1145
- );
1146
- }
1147
- : undefined;
1148
1190
  const commitResult = await commitToBranch(
1149
1191
  isolationDir,
1150
1192
  taskBaseline,
1151
- task.id,
1152
- task.description,
1153
- commitMsg,
1193
+ agentId,
1194
+ params.description,
1195
+ buildCommitMessageFn(),
1154
1196
  );
1155
1197
  return {
1156
1198
  ...result,
@@ -1159,7 +1201,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1159
1201
  };
1160
1202
  } catch (mergeErr) {
1161
1203
  // Agent succeeded but branch commit failed — clean up stale branch
1162
- const branchName = `omp/task/${task.id}`;
1204
+ const branchName = `omp/task/${agentId}`;
1163
1205
  await git.branch.tryDelete(repoRoot, branchName);
1164
1206
  const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1165
1207
  return { ...result, error: `Merge failed: ${msg}` };
@@ -1168,7 +1210,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1168
1210
  if (result.exitCode === 0) {
1169
1211
  try {
1170
1212
  const delta = await captureDeltaPatch(isolationDir, taskBaseline);
1171
- const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`);
1213
+ const patchPath = path.join(effectiveArtifactsDir, `${agentId}.patch`);
1172
1214
  await Bun.write(patchPath, delta.rootPatch);
1173
1215
  return {
1174
1216
  ...result,
@@ -1183,21 +1225,21 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1183
1225
  return result;
1184
1226
  } catch (err) {
1185
1227
  const message = err instanceof Error ? err.message : String(err);
1186
- const assignment = task.assignment.trim();
1187
1228
  return {
1188
- index,
1189
- id: task.id,
1229
+ index: 0,
1230
+ id: agentId,
1190
1231
  agent: agent.name,
1191
1232
  agentSource: agent.source,
1192
- task: renderSubagentUserPrompt(assignment, simpleMode),
1233
+ task: renderSubagentUserPrompt(assignment),
1193
1234
  assignment,
1194
- description: task.description,
1235
+ description: params.description,
1195
1236
  exitCode: 1,
1196
1237
  output: "",
1197
1238
  stderr: message,
1198
1239
  truncated: false,
1199
1240
  durationMs: Date.now() - taskStart,
1200
1241
  tokens: 0,
1242
+ requests: 0,
1201
1243
  modelOverride,
1202
1244
  error: message,
1203
1245
  };
@@ -1208,147 +1250,68 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1208
1250
  }
1209
1251
  };
1210
1252
 
1211
- // Execute in parallel with concurrency limit
1212
- const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
1213
- tasksWithUniqueIds,
1214
- maxConcurrency,
1215
- runTask,
1216
- signal,
1217
- );
1218
-
1219
- // Fill in skipped tasks (undefined entries from abort) with placeholder results
1220
- const results: SingleResult[] = partialResults.map((result, index) => {
1221
- if (result !== undefined) {
1222
- return result;
1223
- }
1224
- const task = tasksWithUniqueIds[index];
1225
- const assignment = task.assignment.trim();
1226
- return {
1227
- index,
1228
- id: task.id,
1229
- agent: agentName,
1230
- agentSource: agent.source,
1231
- task: renderSubagentUserPrompt(assignment, simpleMode),
1232
- assignment,
1233
- description: task.description,
1234
- exitCode: 1,
1235
- output: "",
1236
- stderr: "Skipped (cancelled before start)",
1237
- truncated: false,
1238
- durationMs: 0,
1239
- tokens: 0,
1240
- modelOverride,
1241
- error: "Cancelled before start",
1242
- aborted: true,
1243
- abortReason: "Cancelled before start",
1244
- };
1245
- });
1246
-
1247
- // Aggregate usage from executor results (already accumulated incrementally)
1248
- const aggregatedUsage = createUsageTotals();
1249
- let hasAggregatedUsage = false;
1250
- for (const result of results) {
1251
- if (result.usage) {
1252
- addUsageTotals(aggregatedUsage, result.usage);
1253
- hasAggregatedUsage = true;
1254
- }
1255
- }
1256
-
1257
- // Collect output paths (artifacts already written by executor in real-time)
1258
- const outputPaths: string[] = [];
1259
- const patchPaths: string[] = [];
1260
- for (const result of results) {
1261
- if (result.outputPath) {
1262
- outputPaths.push(result.outputPath);
1263
- }
1264
- if (result.patchPath) {
1265
- patchPaths.push(result.patchPath);
1266
- }
1267
- }
1253
+ const result = await runTask();
1268
1254
 
1269
1255
  let mergeSummary = "";
1270
1256
  let changesApplied: boolean | null = null;
1271
1257
  let hadAnyChanges = false;
1272
- let mergedBranchesForNestedPatches: Set<string> | null = null;
1258
+ let mergedBranchForNestedPatches = false;
1273
1259
  if (isIsolated && repoRoot) {
1274
1260
  try {
1275
1261
  if (mergeMode === "branch") {
1276
- // Branch mode: merge task branches sequentially
1277
- const branchEntries = results
1278
- .filter(r => r.branchName && r.exitCode === 0 && !r.aborted)
1279
- .map(r => ({ branchName: r.branchName!, taskId: r.id, description: r.description }));
1280
-
1281
- if (branchEntries.length === 0) {
1262
+ if (!result.branchName || result.exitCode !== 0 || result.aborted) {
1282
1263
  changesApplied = true;
1283
- hadAnyChanges = false;
1284
1264
  mergeSummary = "\n\nNo changes to apply.";
1285
1265
  } else {
1286
- const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
1287
- mergedBranchesForNestedPatches = new Set(mergeResult.merged);
1266
+ const mergeResult = await mergeTaskBranches(repoRoot, [
1267
+ { branchName: result.branchName, taskId: result.id, description: result.description },
1268
+ ]);
1269
+ mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
1288
1270
  changesApplied = mergeResult.failed.length === 0;
1289
1271
  hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
1290
1272
 
1291
1273
  if (changesApplied) {
1292
1274
  mergeSummary = hadAnyChanges
1293
- ? `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`
1275
+ ? `\n\nMerged branch: ${result.branchName}`
1294
1276
  : "\n\nNo changes to apply.";
1295
1277
  } else {
1296
- const mergedPart =
1297
- mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
1298
- const failedPart = `Failed: ${mergeResult.failed.join(", ")}.`;
1299
1278
  const conflictPart = mergeResult.conflict ? `\nConflict: ${mergeResult.conflict}` : "";
1300
- mergeSummary = `\n\n<system-notification>Branch merge failed. ${mergedPart}${failedPart}${conflictPart}\nUnmerged branches remain for manual resolution.</system-notification>`;
1279
+ mergeSummary = `\n\n<system-notification>Branch merge failed: ${result.branchName}.${conflictPart}\nThe unmerged branch remains for manual resolution.</system-notification>`;
1301
1280
  }
1302
1281
  if (mergeResult.stashConflict) {
1303
1282
  mergeSummary += `\n\n<system-notification>${mergeResult.stashConflict}</system-notification>`;
1304
1283
  }
1305
- }
1306
1284
 
1307
- // Clean up merged branches (keep failed ones for manual resolution)
1308
- const allBranches = branchEntries.map(b => b.branchName);
1309
- if (changesApplied) {
1310
- await cleanupTaskBranches(repoRoot, allBranches);
1285
+ // Clean up the merged branch (keep failed ones for manual resolution)
1286
+ if (changesApplied) {
1287
+ await cleanupTaskBranches(repoRoot, [result.branchName]);
1288
+ }
1311
1289
  }
1312
1290
  } else {
1313
- // Patch mode: apply patches from successful tasks. Failed or
1314
- // aborted siblings must not block completed work from landing.
1315
- const successfulResults = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted);
1316
- const patchesInOrder = successfulResults.map(result => result.patchPath).filter(Boolean) as string[];
1317
- const missingPatch = successfulResults.some(result => !result.patchPath);
1318
- if (missingPatch) {
1291
+ // Patch mode: apply the patch from a successful run. A failed or
1292
+ // aborted run has nothing to apply and must not block the result.
1293
+ const succeeded = result.exitCode === 0 && !result.error && !result.aborted;
1294
+ if (!succeeded) {
1295
+ changesApplied = true;
1296
+ hadAnyChanges = false;
1297
+ } else if (!result.patchPath) {
1319
1298
  changesApplied = false;
1320
1299
  hadAnyChanges = false;
1321
1300
  } else {
1322
- const patchStats = await Promise.all(
1323
- patchesInOrder.map(async patchPath => ({
1324
- patchPath,
1325
- size: (await fs.stat(patchPath)).size,
1326
- })),
1327
- );
1328
- const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1329
- if (nonEmptyPatches.length === 0) {
1301
+ const patchText = await Bun.file(result.patchPath).text();
1302
+ if (!patchText.trim()) {
1330
1303
  changesApplied = true;
1331
1304
  hadAnyChanges = false;
1332
1305
  } else {
1333
- const patchTexts = await Promise.all(
1334
- nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
1335
- );
1336
- const combinedPatch = patchTexts
1337
- .map(text => (text.endsWith("\n") ? text : `${text}\n`))
1338
- .join("");
1339
- if (!combinedPatch.trim()) {
1340
- changesApplied = true;
1341
- hadAnyChanges = false;
1342
- } else {
1343
- changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
1344
- if (changesApplied) {
1345
- try {
1346
- await git.patch.applyText(repoRoot, combinedPatch);
1347
- hadAnyChanges = true;
1348
- } catch {
1349
- changesApplied = false;
1350
- hadAnyChanges = false;
1351
- }
1306
+ const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
1307
+ changesApplied = await git.patch.canApplyText(repoRoot, normalized);
1308
+ if (changesApplied) {
1309
+ try {
1310
+ await git.patch.applyText(repoRoot, normalized);
1311
+ hadAnyChanges = true;
1312
+ } catch {
1313
+ changesApplied = false;
1314
+ hadAnyChanges = false;
1352
1315
  }
1353
1316
  }
1354
1317
  }
@@ -1359,10 +1322,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1359
1322
  } else {
1360
1323
  const notification =
1361
1324
  "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
1362
- const patchList =
1363
- patchPaths.length > 0
1364
- ? `\n\nPatch artifacts:\n${patchPaths.map(patch => `- ${patch}`).join("\n")}`
1365
- : "";
1325
+ const patchList = result.patchPath ? `\n\nPatch artifact:\n- ${result.patchPath}` : "";
1366
1326
  mergeSummary = `\n\n${notification}${patchList}`;
1367
1327
  }
1368
1328
  }
@@ -1376,34 +1336,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1376
1336
 
1377
1337
  // Apply nested repo patches (separate from parent git)
1378
1338
  if (isIsolated && repoRoot && (mergeMode === "branch" || changesApplied !== false)) {
1379
- const allNestedPatches = results
1380
- .filter(r => {
1381
- if (!r.nestedPatches || r.nestedPatches.length === 0 || r.exitCode !== 0 || r.aborted) {
1382
- return false;
1383
- }
1384
- if (mergeMode !== "branch") {
1385
- return true;
1386
- }
1387
- if (!r.branchName || !mergedBranchesForNestedPatches) {
1388
- return false;
1389
- }
1390
- return mergedBranchesForNestedPatches.has(r.branchName);
1391
- })
1392
- .flatMap(r => r.nestedPatches!);
1393
- if (allNestedPatches.length > 0) {
1339
+ const nestedPatches = result.nestedPatches ?? [];
1340
+ const eligible =
1341
+ nestedPatches.length > 0 &&
1342
+ result.exitCode === 0 &&
1343
+ !result.aborted &&
1344
+ (mergeMode !== "branch" || mergedBranchForNestedPatches);
1345
+ if (eligible) {
1394
1346
  try {
1395
- const commitMsg =
1396
- commitStyle === "ai" && this.session.modelRegistry
1397
- ? async (diff: string) => {
1398
- return generateCommitMessage(
1399
- diff,
1400
- this.session.modelRegistry!,
1401
- this.session.settings,
1402
- this.session.getSessionId?.() ?? undefined,
1403
- );
1404
- }
1405
- : undefined;
1406
- await applyNestedPatches(repoRoot, allNestedPatches, commitMsg);
1347
+ await applyNestedPatches(repoRoot, nestedPatches, buildCommitMessageFn());
1407
1348
  } catch {
1408
1349
  // Nested patch failures are non-fatal to the parent merge
1409
1350
  mergeSummary +=
@@ -1412,58 +1353,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1412
1353
  }
1413
1354
  }
1414
1355
 
1415
- // Build final output - match plugin format
1416
- const cancelledCount = results.filter(r => r.aborted).length;
1417
- const successCount = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted).length;
1418
- const totalDuration = Date.now() - startTime;
1419
-
1420
- const summaries = results.map(r => {
1421
- const status = r.aborted
1422
- ? "cancelled"
1423
- : r.exitCode === 0 && r.error
1424
- ? "merge failed"
1425
- : r.exitCode === 0
1426
- ? "completed"
1427
- : `failed (exit ${r.exitCode})`;
1428
- const output = r.output.trim() || r.stderr.trim() || "(no output)";
1429
- const outputCharCount = r.outputMeta?.charCount ?? output.length;
1430
- const fullOutputThreshold = 5000;
1431
- let preview = output;
1432
- let truncated = false;
1433
- if (outputCharCount > fullOutputThreshold) {
1434
- const slice = output.slice(0, fullOutputThreshold);
1435
- const lastNewline = slice.lastIndexOf("\n");
1436
- preview = lastNewline >= 0 ? slice.slice(0, lastNewline) : slice;
1437
- truncated = true;
1438
- }
1439
- return {
1440
- agent: r.agent,
1441
- status,
1442
- id: r.id,
1443
- preview,
1444
- truncated,
1445
- meta: r.outputMeta
1446
- ? {
1447
- lineCount: r.outputMeta.lineCount,
1448
- charSize: formatBytes(r.outputMeta.charCount),
1449
- }
1450
- : undefined,
1451
- };
1452
- });
1453
-
1454
- const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`);
1455
- const summary = prompt.render(taskSummaryTemplate, {
1456
- successCount,
1457
- totalCount: results.length,
1458
- cancelledCount,
1459
- hasCancelledNote: aborted && cancelledCount > 0,
1460
- duration: formatDuration(totalDuration),
1461
- summaries,
1462
- outputIds,
1463
- agentName,
1464
- mergeSummary,
1465
- });
1466
-
1467
1356
  // Cleanup temp directory if used
1468
1357
  const shouldCleanupTempArtifacts =
1469
1358
  tempArtifactsDir && (!isIsolated || changesApplied === true || changesApplied === null);
@@ -1471,25 +1360,65 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1471
1360
  await fs.rm(tempArtifactsDir, { recursive: true, force: true });
1472
1361
  }
1473
1362
 
1474
- return {
1475
- content: [{ type: "text", text: summary }],
1476
- details: {
1477
- projectAgentsDir,
1478
- results: results,
1479
- totalDurationMs: totalDuration,
1480
- usage: hasAggregatedUsage ? aggregatedUsage : undefined,
1481
- outputPaths,
1482
- },
1483
- };
1363
+ return this.#buildResultPayload(result, projectAgentsDir, Date.now() - startTime, mergeSummary);
1484
1364
  } catch (err) {
1485
1365
  return {
1486
1366
  content: [{ type: "text", text: `Task execution failed: ${err}` }],
1487
- details: {
1488
- projectAgentsDir,
1489
- results: [],
1490
- totalDurationMs: Date.now() - startTime,
1491
- },
1367
+ details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
1492
1368
  };
1493
1369
  }
1494
1370
  }
1371
+
1372
+ /** Build the tool result (summary text + details) for a settled run. */
1373
+ #buildResultPayload(
1374
+ result: SingleResult,
1375
+ projectAgentsDir: string | null,
1376
+ totalDurationMs: number,
1377
+ mergeSummary: string,
1378
+ ): AgentToolResult<TaskToolDetails> {
1379
+ const status = result.aborted
1380
+ ? "cancelled"
1381
+ : result.exitCode === 0 && result.error
1382
+ ? "merge failed"
1383
+ : result.exitCode === 0
1384
+ ? "completed"
1385
+ : `failed (exit ${result.exitCode})`;
1386
+ const output = formatResultOutputFallback(result);
1387
+ const outputCharCount = result.outputMeta?.charCount ?? output.length;
1388
+ const fullOutputThreshold = 5000;
1389
+ let preview = output;
1390
+ let truncated = false;
1391
+ if (outputCharCount > fullOutputThreshold) {
1392
+ const slice = output.slice(0, fullOutputThreshold);
1393
+ const lastNewline = slice.lastIndexOf("\n");
1394
+ preview = lastNewline >= 0 ? slice.slice(0, lastNewline) : slice;
1395
+ truncated = true;
1396
+ }
1397
+ const summary = prompt.render(taskSummaryTemplate, {
1398
+ agentName: result.agent,
1399
+ id: result.id,
1400
+ status,
1401
+ duration: formatDuration(totalDurationMs),
1402
+ preview,
1403
+ truncated,
1404
+ meta: result.outputMeta
1405
+ ? {
1406
+ lineCount: result.outputMeta.lineCount,
1407
+ charSize: formatBytes(result.outputMeta.charCount),
1408
+ }
1409
+ : undefined,
1410
+ mergeSummary,
1411
+ });
1412
+
1413
+ return {
1414
+ content: [{ type: "text", text: summary }],
1415
+ details: {
1416
+ projectAgentsDir,
1417
+ results: [result],
1418
+ totalDurationMs,
1419
+ usage: result.usage,
1420
+ outputPaths: result.outputPath ? [result.outputPath] : undefined,
1421
+ },
1422
+ };
1423
+ }
1495
1424
  }