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