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