@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.6

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 (109) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +3799 -3729
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/cli/bench-cli.d.ts +1 -7
  5. package/dist/types/cli/usage-cli.d.ts +1 -0
  6. package/dist/types/commands/usage.d.ts +7 -0
  7. package/dist/types/config/settings-schema.d.ts +19 -9
  8. package/dist/types/config/settings.d.ts +3 -2
  9. package/dist/types/discovery/helpers.d.ts +2 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  11. package/dist/types/irc/bus.d.ts +4 -0
  12. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  13. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  15. package/dist/types/modes/components/index.d.ts +2 -1
  16. package/dist/types/modes/components/model-browser.d.ts +100 -0
  17. package/dist/types/modes/components/model-hub.d.ts +52 -0
  18. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  19. package/dist/types/modes/components/session-selector.d.ts +13 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  22. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/queue-input.d.ts +8 -0
  25. package/dist/types/modes/shared.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +1 -1
  28. package/dist/types/session/agent-storage.d.ts +57 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/task/executor.d.ts +26 -13
  31. package/dist/types/task/index.d.ts +12 -11
  32. package/dist/types/task/label.d.ts +4 -0
  33. package/dist/types/task/repair-args.d.ts +8 -8
  34. package/dist/types/task/types.d.ts +31 -56
  35. package/dist/types/tools/ask.d.ts +12 -0
  36. package/dist/types/tools/conflict-detect.d.ts +17 -1
  37. package/dist/types/tools/job.d.ts +16 -0
  38. package/package.json +12 -12
  39. package/scripts/build-binary.ts +0 -1
  40. package/scripts/compile-binary.ts +4 -3
  41. package/src/async/job-manager.ts +9 -0
  42. package/src/cli/bench-cli.ts +7 -26
  43. package/src/cli/usage-cli.ts +11 -0
  44. package/src/commands/usage.ts +13 -2
  45. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  46. package/src/config/settings-schema.ts +18 -7
  47. package/src/config/settings.ts +13 -4
  48. package/src/discovery/helpers.ts +3 -4
  49. package/src/extensibility/custom-tools/loader.ts +70 -37
  50. package/src/extensibility/extensions/types.ts +46 -0
  51. package/src/irc/bus.ts +61 -20
  52. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  53. package/src/modes/components/advisor-config.ts +32 -22
  54. package/src/modes/components/ask-dialog.ts +888 -0
  55. package/src/modes/components/custom-editor.test.ts +58 -1
  56. package/src/modes/components/custom-editor.ts +42 -11
  57. package/src/modes/components/index.ts +2 -1
  58. package/src/modes/components/model-browser.ts +769 -0
  59. package/src/modes/components/model-hub.ts +2002 -0
  60. package/src/modes/components/pause-screen.ts +208 -0
  61. package/src/modes/components/session-selector.ts +299 -42
  62. package/src/modes/components/tool-execution.ts +2 -0
  63. package/src/modes/components/usage-row.ts +5 -6
  64. package/src/modes/controllers/event-controller.ts +8 -2
  65. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  66. package/src/modes/controllers/input-controller.ts +140 -6
  67. package/src/modes/controllers/selector-controller.ts +160 -97
  68. package/src/modes/controllers/tan-command-controller.ts +1 -1
  69. package/src/modes/controllers/todo-command-controller.ts +1 -2
  70. package/src/modes/interactive-mode.ts +8 -0
  71. package/src/modes/queue-input.ts +132 -0
  72. package/src/modes/shared.ts +1 -1
  73. package/src/modes/theme/theme.ts +3 -3
  74. package/src/modes/types.ts +4 -0
  75. package/src/modes/utils/ui-helpers.ts +50 -24
  76. package/src/prompts/agents/scout.md +0 -1
  77. package/src/prompts/agents/task.md +1 -1
  78. package/src/prompts/system/subagent-system-prompt.md +1 -5
  79. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  80. package/src/prompts/system/task-label.md +23 -0
  81. package/src/prompts/tools/job.md +1 -1
  82. package/src/prompts/tools/task-summary.md +3 -0
  83. package/src/prompts/tools/task.md +17 -18
  84. package/src/session/agent-session.ts +186 -49
  85. package/src/session/agent-storage.ts +330 -3
  86. package/src/session/history-storage.ts +1 -34
  87. package/src/session/session-context.test.ts +73 -0
  88. package/src/session/session-context.ts +43 -26
  89. package/src/slash-commands/builtin-registry.ts +18 -0
  90. package/src/task/agents.ts +2 -0
  91. package/src/task/executor.ts +159 -46
  92. package/src/task/index.ts +377 -239
  93. package/src/task/label.ts +38 -0
  94. package/src/task/render.ts +74 -22
  95. package/src/task/repair-args.ts +20 -31
  96. package/src/task/spawn-policy.test.ts +4 -4
  97. package/src/task/types.ts +46 -66
  98. package/src/tools/ask.ts +233 -40
  99. package/src/tools/conflict-detect.ts +102 -5
  100. package/src/tools/index.ts +1 -0
  101. package/src/tools/irc.ts +20 -11
  102. package/src/tools/job.ts +158 -18
  103. package/src/tools/write.ts +70 -6
  104. package/src/vibe/runtime.ts +1 -1
  105. package/src/web/search/providers/browser-headers.ts +30 -13
  106. package/dist/types/modes/components/model-selector.d.ts +0 -37
  107. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  108. package/src/modes/components/model-selector.ts +0 -1291
  109. package/src/tools/bash-command-fixup.ts +0 -4
package/src/task/index.ts CHANGED
@@ -30,7 +30,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
30
30
  import { truncateForPrompt } from "../tools/approval";
31
31
  import { isIrcEnabled } from "../tools/irc";
32
32
  import { formatBytes, formatDuration } from "../tools/render-utils";
33
- import { DEFAULT_SPAWN_AGENT, resolveSpawnPolicy } from "./spawn-policy";
33
+ import { resolveSpawnPolicy } from "./spawn-policy";
34
34
  import {
35
35
  type AgentDefinition,
36
36
  type AgentProgress,
@@ -200,16 +200,17 @@ function renderDescription(
200
200
  name: agent.name,
201
201
  description: agent.description,
202
202
  readOnly: isReadOnlyAgent(agent),
203
+ blocking: agent.blocking === true,
203
204
  }));
204
205
  return prompt.render(taskDescriptionTemplate, {
205
206
  agents: renderedAgents,
206
207
  spawningDisabled,
207
208
  defaultAgent: spawnPolicy.defaultAgent,
208
- defaultAgentIsGeneric: spawnPolicy.defaultAgent === DEFAULT_SPAWN_AGENT,
209
209
  allowedAgentsText: spawnPolicy.allowedPromptText,
210
210
  isolationEnabled,
211
211
  batchEnabled,
212
212
  asyncEnabled,
213
+ hasBlockingAgents: renderedAgents.some(agent => agent.blocking),
213
214
  ircEnabled,
214
215
  });
215
216
  }
@@ -234,92 +235,90 @@ function validateShapeParams(batchEnabled: boolean, params: TaskParams): string
234
235
  if (!batchEnabled) {
235
236
  const disallowed = (["tasks", "context"] as const).filter(field => params[field] !== undefined);
236
237
  if (disallowed.length > 0) {
237
- 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.`;
238
+ return `task.batch is disabled, so the task tool does not accept ${disallowed.map(f => `\`${f}\``).join(" or ")}. Spawn one agent per call with \`task\`, or enable the task.batch setting.`;
238
239
  }
239
240
  }
240
241
  return undefined;
241
242
  }
242
243
 
243
244
  /**
244
- * Validate the spawn parameter contract against the wire shapes. `agent`
245
- * defaults to `task` (the schema default; `execute` normalizes the same way for
246
- * direct callers), so the missing-`agent` guard only fires for callers that
247
- * invoke this validator with an unnormalized blank agent. With `task.batch` the
248
- * model-facing shape is
249
- * `{ agent, context, tasks[] }` `tasks` non-empty with per-item assignments
250
- * and unique ids, `context` non-empty, no top-level `assignment` alongside.
251
- * The flat `{ agent, ...item }` form stays accepted at runtime under either
252
- * setting (internal callers, stale transcripts). Returns a problem
253
- * description, or undefined when valid.
245
+ * Validate the spawn parameter contract against the wire shapes. With
246
+ * `task.batch` the model-facing shape is `{ context, tasks[] }` `tasks`
247
+ * non-empty with per-item `task` instructions and unique names, `context`
248
+ * non-empty, no top-level `task` alongside. The flat `{ agent?, ...item }`
249
+ * form stays accepted at runtime under either setting (internal callers, stale
250
+ * transcripts). Missing `agent` values resolve against the session spawn
251
+ * policy later, in `spawnParamsFor`. Returns a problem description, or
252
+ * undefined when valid.
254
253
  */
255
254
  function validateSpawnParams(params: TaskParams, batchEnabled: boolean): string | undefined {
256
- const agent = typeof params.agent === "string" ? params.agent.trim() : "";
257
- if (!agent) {
258
- return "Missing `agent`. Provide an agent type to spawn.";
259
- }
260
- const hasAssignment = typeof params.assignment === "string" && params.assignment.trim() !== "";
255
+ const hasTask = typeof params.task === "string" && params.task.trim() !== "";
261
256
  const tasks = params.tasks;
262
257
  if (batchEnabled && tasks !== undefined) {
263
258
  if (!Array.isArray(tasks) || tasks.length === 0) {
264
- return "Missing `tasks`. Provide at least one task item ({ id?, description?, assignment }).";
259
+ return "Missing `tasks`. Provide at least one task item ({ name?, agent?, task }).";
265
260
  }
266
- if (hasAssignment) {
267
- return "Top-level `assignment` is not part of the batch shape. Put the work in `tasks[]` items.";
261
+ if (hasTask) {
262
+ return "Top-level `task` is not part of the batch shape. Put the work in `tasks[]` items.";
268
263
  }
269
264
  for (let i = 0; i < tasks.length; i++) {
270
265
  const item = tasks[i];
271
- if (!item || typeof item.assignment !== "string" || item.assignment.trim() === "") {
272
- return `Task ${i + 1}${item?.id ? ` (\`${item.id}\`)` : ""} is missing \`assignment\`. Every task needs complete, self-contained instructions.`;
266
+ if (!item || typeof item.task !== "string" || item.task.trim() === "") {
267
+ return `Task ${i + 1}${item?.name ? ` (\`${item.name}\`)` : ""} is missing \`task\`. Every task needs complete, self-contained instructions.`;
273
268
  }
274
269
  }
275
270
  const seen = new Map<string, string>();
276
271
  for (const item of tasks) {
277
- const id = item.id?.trim();
278
- if (!id) continue;
279
- const key = id.toLowerCase();
272
+ const name = item.name?.trim();
273
+ if (!name) continue;
274
+ const key = name.toLowerCase();
280
275
  const existing = seen.get(key);
281
276
  if (existing !== undefined) {
282
- return `Duplicate task id ${existing === id ? `\`${id}\`` : `\`${existing}\` / \`${id}\``}. Provided ids must be unique within a call (case-insensitive).`;
277
+ return `Duplicate task name ${existing === name ? `\`${name}\`` : `\`${existing}\` / \`${name}\``}. Provided names must be unique within a call (case-insensitive).`;
283
278
  }
284
- seen.set(key, id);
279
+ seen.set(key, name);
285
280
  }
286
281
  if (typeof params.context !== "string" || params.context.trim() === "") {
287
282
  return "Missing `context`. Provide the shared background for this batch — goal, constraints, and any contract the tasks share.";
288
283
  }
289
284
  return undefined;
290
285
  }
291
- if (!hasAssignment) {
286
+ if (!hasTask) {
292
287
  return batchEnabled
293
288
  ? "Missing `tasks`. Provide a `tasks` array (one subagent per item) with a shared `context`."
294
- : "Missing `assignment`. Provide complete, self-contained instructions for the agent.";
289
+ : "Missing `task`. Provide complete, self-contained instructions for the agent.";
295
290
  }
296
291
  return undefined;
297
292
  }
298
293
 
299
294
  /**
300
295
  * Normalize a validated call into its spawn list: the `tasks[]` batch when
301
- * provided, otherwise the single top-level spawn.
296
+ * provided, otherwise the single top-level spawn. The flat form's `isolated`
297
+ * flag is only materialized when the caller sent one — `#runSpawn`
298
+ * distinguishes an absent key from an explicit value.
302
299
  */
303
300
  function resolveSpawnItems(params: TaskParams): TaskItem[] {
304
301
  if (Array.isArray(params.tasks) && params.tasks.length > 0) {
305
302
  return params.tasks;
306
303
  }
307
- return [{ id: params.id, description: params.description, role: params.role, assignment: params.assignment }];
304
+ const item: TaskItem = { name: params.name, agent: params.agent, task: params.task };
305
+ if ("isolated" in params) item.isolated = params.isolated;
306
+ return [item];
308
307
  }
309
308
 
310
309
  /**
311
310
  * Per-spawn params handed to the executor path: top-level call fields with the
312
- * item's identity substituted in. `tasks` never leaks into a spawn; the shared
313
- * `context` rides along unchanged. Keys are only materialized when present
314
- * `#runSpawn` distinguishes an absent `isolated` from an explicit one. The
315
- * item's `isolated` (batch form) wins over the top-level flag (flat form).
311
+ * item's identity substituted in. Each spawn's `agent` resolves here
312
+ * the item's own value, else `defaultAgent` from the session spawn policy.
313
+ * `tasks` never leaks into a spawn; the shared `context` rides along
314
+ * unchanged. Keys are only materialized when present `#runSpawn`
315
+ * distinguishes an absent `isolated` from an explicit one. The item's
316
+ * `isolated` (batch form) wins over the top-level flag (flat form).
316
317
  */
317
- function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams {
318
- const spawn: TaskParams = { agent: params.agent };
319
- if (item.id !== undefined) spawn.id = item.id;
320
- if (item.description !== undefined) spawn.description = item.description;
321
- if (item.role !== undefined) spawn.role = item.role;
322
- if (item.assignment !== undefined) spawn.assignment = item.assignment;
318
+ function spawnParamsFor(params: TaskParams, item: TaskItem, defaultAgent: string): TaskParams {
319
+ const spawn: TaskParams = { agent: item.agent?.trim() || defaultAgent };
320
+ if (item.name !== undefined) spawn.name = item.name;
321
+ if (item.task !== undefined) spawn.task = item.task;
323
322
  if (params.context !== undefined) spawn.context = params.context;
324
323
  if (item.isolated !== undefined) {
325
324
  spawn.isolated = item.isolated;
@@ -329,33 +328,83 @@ function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams {
329
328
  return spawn;
330
329
  }
331
330
 
332
- /** Generic worker agents whose output sharpens with a tailored `role` rather than the bare type. */
331
+ /** One sync-executed spawn: its item, position in the original call, and (for mixed calls) a pre-claimed agent id. */
332
+ interface SyncSpawnRef {
333
+ item: TaskItem;
334
+ index: number;
335
+ preAllocatedId?: string;
336
+ }
337
+
338
+ /** Merged view of a sync spawn set's payloads: joined text plus flattened results/usage/paths. */
339
+ interface MergedSyncPayloads {
340
+ contentParts: string[];
341
+ results: SingleResult[];
342
+ usage?: Usage;
343
+ outputPaths?: string[];
344
+ projectAgentsDir: string | null;
345
+ }
346
+
347
+ /**
348
+ * Merge per-spawn sync payloads into one result view. `index` is each spawn's
349
+ * position in the original call so batch rows keep stable ordering; a missing
350
+ * payload (cancelled before start) becomes an explanatory content line.
351
+ */
352
+ function mergeSyncPayloads(
353
+ spawns: SyncSpawnRef[],
354
+ payloads: (AgentToolResult<TaskToolDetails> | undefined)[],
355
+ ): MergedSyncPayloads {
356
+ const results: SingleResult[] = [];
357
+ const contentParts: string[] = [];
358
+ const outputPaths: string[] = [];
359
+ const usageTotals = createUsageTotals();
360
+ let hasUsage = false;
361
+ let projectAgentsDir: string | null = null;
362
+ for (let position = 0; position < spawns.length; position++) {
363
+ const payload = payloads[position];
364
+ const { item, index } = spawns[position];
365
+ if (!payload) {
366
+ contentParts.push(`Task ${item.name?.trim() || `#${index + 1}`}: cancelled before start.`);
367
+ continue;
368
+ }
369
+ projectAgentsDir ??= payload.details?.projectAgentsDir ?? null;
370
+ const text = payload.content.find(part => part.type === "text")?.text;
371
+ if (text) contentParts.push(text);
372
+ for (const result of payload.details?.results ?? []) {
373
+ results.push({ ...result, index });
374
+ if (result.usage) {
375
+ addUsageTotals(usageTotals, result.usage);
376
+ hasUsage = true;
377
+ }
378
+ if (result.outputPath) outputPaths.push(result.outputPath);
379
+ }
380
+ }
381
+ return {
382
+ contentParts,
383
+ results,
384
+ usage: hasUsage ? usageTotals : undefined,
385
+ outputPaths: outputPaths.length > 0 ? outputPaths : undefined,
386
+ projectAgentsDir,
387
+ };
388
+ }
389
+
390
+ /** Generic worker agent types; several in one call usually means a more specific type exists. */
333
391
  const GENERIC_SPAWN_AGENTS: ReadonlySet<string> = new Set(["task", "sonic"]);
334
392
 
335
393
  /**
336
394
  * Advisory — never a rejection — nudging the spawner toward tailored
337
- * specialists when it spawns generic role-less workers and still holds spawn
338
- * capacity (DepthCapacity: it currently has the `task` tool). Fires when a
339
- * generic `task`/`sonic` spawn carries no `role`, or when one call clones
340
- * the same agent ≥2× all without roles. Returns undefined when no nudge applies.
395
+ * specific agent types when one call resolves ≥2 items to a generic
396
+ * `task`/`sonic` worker and the spawner still holds spawn capacity
397
+ * (DepthCapacity: it currently has the `task` tool). `agentNames` are the
398
+ * per-item resolved agent types. Returns undefined when no nudge applies.
341
399
  */
342
- export function buildSpecializationAdvisory(
343
- agentName: string | undefined,
344
- items: TaskItem[],
345
- depthCapacity: boolean,
346
- ): string | undefined {
400
+ export function buildSpecializationAdvisory(agentNames: string[], depthCapacity: boolean): string | undefined {
347
401
  if (!depthCapacity) return undefined;
348
- const rolelessCount = items.filter(item => !item.role?.trim()).length;
349
- if (rolelessCount === 0) return undefined;
350
- const generic = agentName !== undefined && GENERIC_SPAWN_AGENTS.has(agentName);
351
- const cloned = items.length >= 2 && rolelessCount === items.length;
352
- if (!generic && !cloned) return undefined;
353
- const label = agentName ?? "task";
402
+ const generics = agentNames.filter(name => GENERIC_SPAWN_AGENTS.has(name));
403
+ if (generics.length < 2) return undefined;
354
404
  return (
355
- `Tip: spawned ${rolelessCount} \`${label}\` worker${rolelessCount === 1 ? "" : "s"} without a \`role\`. ` +
356
- `Tailored specialists outperform generic workers give each spawn a \`role\` naming its expertise ` +
357
- `(e.g. "Auth-flow security reviewer"). Depth budget remains, so decompose into named specialists ` +
358
- `rather than cloning one generic worker.`
405
+ `Tip: this call spawned ${generics.length} generic \`${generics[0]}\` workers. ` +
406
+ `Check the agent list for a closer specialist type e.g. read-only research belongs on ` +
407
+ `\`agent: "scout"\`, which runs on a faster model.`
359
408
  );
360
409
  }
361
410
 
@@ -379,14 +428,15 @@ export function buildCoordinationAdvisory(
379
428
 
380
429
  /**
381
430
  * Compose the non-blocking advisory appended to a `task` result: the
382
- * specialization nudge, plus only when the siblings keep running after this
383
- * call (`willRunAsync`) — the coordination suggestion. Coordination is gated on
384
- * async because a sync fanout's siblings have already finished, so a
385
- * "coordinate while they run" hint would misfire. Returns undefined when
386
- * neither applies.
431
+ * specialization nudge (from the per-item resolved agent types), plus only
432
+ * when some spawns keep running after this call (`willRunAsync`) — the
433
+ * coordination suggestion over those still-live spawns (`items`). Coordination
434
+ * is gated on async because a sync spawn has already finished by the time the
435
+ * call returns, so a "coordinate while they run" hint would misfire. Returns
436
+ * undefined when neither applies.
387
437
  */
388
438
  export function composeSpawnAdvisory(args: {
389
- agentName: string | undefined;
439
+ agents: string[];
390
440
  items: TaskItem[];
391
441
  depthCapacity: boolean;
392
442
  ircEnabled: boolean;
@@ -394,7 +444,7 @@ export function composeSpawnAdvisory(args: {
394
444
  }): string | undefined {
395
445
  return (
396
446
  [
397
- buildSpecializationAdvisory(args.agentName, args.items, args.depthCapacity),
447
+ buildSpecializationAdvisory(args.agents, args.depthCapacity),
398
448
  args.willRunAsync ? buildCoordinationAdvisory(args.items, args.depthCapacity, args.ircEnabled) : undefined,
399
449
  ]
400
450
  .filter(Boolean)
@@ -456,14 +506,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
456
506
  if (typeof params.agent === "string") {
457
507
  lines.push(`Agent: ${truncateForPrompt(params.agent)}`);
458
508
  }
459
- if (typeof params.role === "string" && params.role.trim()) {
460
- lines.push(`Role: ${truncateForPrompt(params.role)}`);
461
- }
462
- if (typeof params.id === "string" && params.id.trim()) {
463
- lines.push(`Task: ${truncateForPrompt(params.id)}`);
509
+ if (typeof params.name === "string" && params.name.trim()) {
510
+ lines.push(`Name: ${truncateForPrompt(params.name)}`);
464
511
  }
465
- if (typeof params.assignment === "string") {
466
- lines.push(`Assignment:\n${truncateForPrompt(params.assignment)}`);
512
+ if (typeof params.task === "string") {
513
+ lines.push(`Task:\n${truncateForPrompt(params.task)}`);
467
514
  }
468
515
  if (typeof params.context === "string" && params.context.trim()) {
469
516
  lines.push(`Context:\n${truncateForPrompt(params.context)}`);
@@ -471,14 +518,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
471
518
  const tasks = Array.isArray(params.tasks) ? params.tasks : [];
472
519
  const firstTask = tasks[0];
473
520
  if (firstTask) {
474
- if (typeof firstTask.id === "string" && firstTask.id.trim()) {
475
- lines.push(`Task: ${truncateForPrompt(firstTask.id)}`);
521
+ if (typeof firstTask.name === "string" && firstTask.name.trim()) {
522
+ lines.push(`Name: ${truncateForPrompt(firstTask.name)}`);
476
523
  }
477
- if (typeof firstTask.role === "string" && firstTask.role.trim()) {
478
- lines.push(`Role: ${truncateForPrompt(firstTask.role)}`);
524
+ if (typeof firstTask.agent === "string" && firstTask.agent.trim()) {
525
+ lines.push(`Agent: ${truncateForPrompt(firstTask.agent)}`);
479
526
  }
480
- if (typeof firstTask.assignment === "string") {
481
- lines.push(`Assignment:\n${truncateForPrompt(firstTask.assignment)}`);
527
+ if (typeof firstTask.task === "string") {
528
+ lines.push(`Task:\n${truncateForPrompt(firstTask.task)}`);
482
529
  }
483
530
  if (tasks.length > 1) {
484
531
  lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`);
@@ -570,15 +617,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
570
617
  signal?: AbortSignal,
571
618
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
572
619
  ): Promise<AgentToolResult<TaskToolDetails>> {
573
- const repaired = repairTaskParams(rawParams as TaskParams);
574
- // Schema defaults run for model calls, but internal callers and stale
575
- // transcripts can bypass arktype. Normalize once so every downstream path
576
- // sees the session's actual default agent.
620
+ const params = repairTaskParams(rawParams as TaskParams);
621
+ // Schema defaults fill `agent` for model calls, but internal callers
622
+ // and stale transcripts can bypass arktype. `spawnParamsFor` resolves each
623
+ // item's agent type against the session's actual default agent.
577
624
  const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
578
- const params =
579
- typeof repaired.agent === "string" && repaired.agent.trim() !== ""
580
- ? repaired
581
- : { ...repaired, agent: defaultAgent };
582
625
  const batchEnabled = this.#isBatchEnabled();
583
626
  const validationError = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled);
584
627
  if (validationError) {
@@ -586,23 +629,31 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
586
629
  }
587
630
 
588
631
  const spawnItems = resolveSpawnItems(params);
589
- const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
632
+ const resolvedAgents = spawnItems.map(item => item.agent?.trim() || defaultAgent);
633
+ // Execution mode is per item: an item whose agent type declares
634
+ // `blocking: true` runs inline on this turn (the parent waits on its
635
+ // result); every other item becomes a background job when async
636
+ // execution is available.
637
+ const itemBlocking = resolvedAgents.map(
638
+ name => this.#discoveredAgents.find(agent => agent.name === name)?.blocking === true,
639
+ );
590
640
  const asyncEnabled = this.session.settings.get("async.enabled");
591
641
  const manager = asyncEnabled ? this.session.asyncJobManager : undefined;
642
+ const asyncItems = manager ? spawnItems.filter((_, index) => !itemBlocking[index]) : [];
592
643
  const depthCapacity = canSpawnAtDepth(
593
644
  this.session.settings.get("task.maxRecursionDepth") ?? 2,
594
645
  this.session.taskDepth ?? 0,
595
646
  );
596
647
  const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0);
597
- // Coordination only makes sense when the siblings keep running after this
598
- // call returns (async). In the sync fallback they have already completed,
599
- // so a "coordinate while they run" hint would misfire.
600
- const willRunAsync = !!manager && selectedAgent?.blocking !== true;
648
+ // Coordination only makes sense for spawns that keep running after this
649
+ // call returns (the async subset). Blocking items have already completed
650
+ // by then, so a "coordinate while they run" hint would misfire.
651
+ const willRunAsync = asyncItems.length > 0;
601
652
  const advisory = this.session.suppressSpawnAdvisory
602
653
  ? undefined
603
654
  : composeSpawnAdvisory({
604
- agentName: params.agent,
605
- items: spawnItems,
655
+ agents: resolvedAgents,
656
+ items: asyncItems,
606
657
  depthCapacity,
607
658
  ircEnabled,
608
659
  willRunAsync,
@@ -623,39 +674,49 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
623
674
  if (!appended) content.push({ type: "text", text: advisory });
624
675
  return { ...result, content };
625
676
  };
626
- if (!asyncEnabled || !manager || selectedAgent?.blocking === true) {
677
+ if (!manager || asyncItems.length === 0) {
627
678
  // Sync fallback: async execution disabled, orphaned host that never
628
- // wired a job manager, or an agent definition that declares
679
+ // wired a job manager, or every item's agent type declares
629
680
  // `blocking: true`. The session-scoped semaphore still bounds fan-out
630
681
  // across parallel task calls.
631
- if (asyncEnabled && !manager) {
682
+ if (asyncEnabled && !this.session.asyncJobManager) {
632
683
  logger.warn("task: no AsyncJobManager registered; falling back to sync execution");
633
684
  }
634
- return withAdvisory(await this.#executeSyncFanout(toolCallId, params, spawnItems, signal, onUpdate));
685
+ return withAdvisory(
686
+ await this.#executeSyncFanout(toolCallId, params, spawnItems, defaultAgent, signal, onUpdate),
687
+ );
635
688
  }
636
689
 
637
690
  // Resolve agent ids up front so the immediate result can name them.
638
691
  const outputManager =
639
692
  this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
640
- const agentLabel = params.agent ?? "task";
641
- const agentSource = selectedAgent?.source ?? "bundled";
642
- const spawns: Array<{ agentId: string; item: TaskItem; progress: AgentProgress }> = [];
693
+ const callStartedAt = Date.now();
694
+ const spawns: Array<{
695
+ agentId: string;
696
+ item: TaskItem;
697
+ index: number;
698
+ blocking: boolean;
699
+ progress: AgentProgress;
700
+ }> = [];
643
701
  for (let index = 0; index < spawnItems.length; index++) {
644
702
  const item = spawnItems[index];
645
- const agentId = await outputManager.allocate(item.id?.trim() || generateTaskName());
646
- const assignment = (item.assignment ?? "").trim();
703
+ const agentType = resolvedAgents[index];
704
+ const agentSource = this.#discoveredAgents.find(agent => agent.name === agentType)?.source ?? "bundled";
705
+ const agentId = await outputManager.allocate(item.name?.trim() || generateTaskName());
706
+ const assignment = (item.task ?? "").trim();
647
707
  spawns.push({
648
708
  agentId,
649
709
  item,
710
+ index,
711
+ blocking: itemBlocking[index],
650
712
  progress: {
651
713
  index,
652
714
  id: agentId,
653
- agent: agentLabel,
715
+ agent: agentType,
654
716
  agentSource,
655
717
  status: "pending",
656
718
  task: renderSubagentUserPrompt(assignment),
657
719
  assignment,
658
- description: item.description,
659
720
  recentTools: [],
660
721
  recentOutput: [],
661
722
  toolCount: 0,
@@ -666,35 +727,44 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
666
727
  },
667
728
  });
668
729
  }
669
-
670
- // Aggregate async state for the one tool call: every spawn's job reports
671
- // into the shared progress snapshot; the call stays "running" until all
672
- // jobs settle, then turns "failed" if any spawn failed. The single-spawn
673
- // case passes the job's own suggestion through (pre-batch behavior).
674
- const single = spawns.length === 1;
730
+ const asyncSpawns = spawns.filter(spawn => !spawn.blocking);
731
+ const syncSpawns = spawns.filter(spawn => spawn.blocking);
732
+ const agentLabel = [...new Set(asyncSpawns.map(spawn => spawn.progress.agent))].join(", ");
733
+
734
+ // Aggregate state for the one tool call. Async spawns report into the
735
+ // shared progress snapshot through their jobs: the async half stays
736
+ // "running" until every job settles, then turns "failed" if any spawn
737
+ // failed. Blocking spawns run inline below and land in `results` before
738
+ // the call returns, so post-return job updates never drop them.
675
739
  let settledCount = 0;
676
740
  let failedCount = 0;
677
- let primaryJobId = spawns[0].agentId;
678
- const buildAsyncDetails = (state: "running" | "completed" | "failed", jobId: string): TaskToolDetails => ({
679
- projectAgentsDir: null,
680
- results: [],
681
- totalDurationMs: 0,
741
+ let primaryJobId = asyncSpawns[0].agentId;
742
+ const syncResults: SingleResult[] = [];
743
+ let syncUsage: Usage | undefined;
744
+ let syncOutputPaths: string[] | undefined;
745
+ let syncProjectAgentsDir: string | null = null;
746
+ const buildAsyncDetails = (): TaskToolDetails => ({
747
+ projectAgentsDir: syncProjectAgentsDir,
748
+ results: [...syncResults],
749
+ totalDurationMs: Date.now() - callStartedAt,
750
+ usage: syncUsage,
751
+ outputPaths: syncOutputPaths,
682
752
  progress: spawns.map(spawn => ({ ...spawn.progress })),
683
753
  async: {
684
- state: single ? state : settledCount < spawns.length ? "running" : failedCount > 0 ? "failed" : "completed",
685
- jobId: single ? jobId : primaryJobId,
754
+ state: settledCount < asyncSpawns.length ? "running" : failedCount > 0 ? "failed" : "completed",
755
+ jobId: primaryJobId,
686
756
  type: "task",
687
757
  },
688
758
  });
689
759
 
690
- const started: Array<{ agentId: string; jobId: string; description?: string }> = [];
760
+ const started: Array<{ agentId: string; jobId: string }> = [];
691
761
  const failedSchedules: string[] = [];
692
- for (const spawn of spawns) {
762
+ for (const spawn of asyncSpawns) {
693
763
  try {
694
764
  const jobId = this.#registerSpawnJob({
695
765
  manager,
696
766
  toolCallId,
697
- spawnParams: spawnParamsFor(params, spawn.item),
767
+ spawnParams: spawnParamsFor(params, spawn.item, defaultAgent),
698
768
  agentId: spawn.agentId,
699
769
  progress: spawn.progress,
700
770
  ircEnabled,
@@ -706,7 +776,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
706
776
  },
707
777
  });
708
778
  if (started.length === 0) primaryJobId = jobId;
709
- started.push({ agentId: spawn.agentId, jobId, description: spawn.item.description });
779
+ started.push({ agentId: spawn.agentId, jobId });
710
780
  } catch (error) {
711
781
  const message = error instanceof Error ? error.message : String(error);
712
782
  failedSchedules.push(`${spawn.agentId}: ${message}`);
@@ -716,64 +786,129 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
716
786
  }
717
787
  }
718
788
 
719
- if (started.length === 0) {
789
+ if (started.length === 0 && syncSpawns.length === 0) {
720
790
  return {
721
791
  content: [
722
792
  {
723
793
  type: "text",
724
- text: `Failed to start background task job${single ? "" : "s"}: ${failedSchedules.join("; ")}`,
794
+ text: `Failed to start background task job${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}`,
725
795
  },
726
796
  ],
727
797
  details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
728
798
  };
729
799
  }
730
800
 
731
- if (single) {
732
- const { agentId, jobId, description } = started[0];
733
- const coordinationHint = ircEnabled
734
- ? `DM \`${agentId}\` via \`irc\` to coordinate while it runs; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
735
- : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`;
736
- const descriptionSuffix = description ? ` — ${description}` : "";
801
+ const scheduleFailureSummary =
802
+ failedSchedules.length > 0
803
+ ? ` Failed to schedule ${failedSchedules.length} spawn${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}.`
804
+ : "";
805
+ const coordinationHint =
806
+ started.length === 1
807
+ ? ircEnabled
808
+ ? `DM \`${started[0].agentId}\` via \`irc\` to coordinate while it runs; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
809
+ : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
810
+ : ircEnabled
811
+ ? `DM these ids via \`irc\` to coordinate while they run; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
812
+ : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`;
813
+
814
+ if (syncSpawns.length === 0) {
815
+ if (spawns.length === 1) {
816
+ const { agentId, jobId } = started[0];
817
+ onUpdate?.({
818
+ content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }],
819
+ details: buildAsyncDetails(),
820
+ });
821
+ return withAdvisory({
822
+ content: [
823
+ {
824
+ type: "text",
825
+ text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). The result will be delivered when it yields. ${coordinationHint}`,
826
+ },
827
+ ],
828
+ details: buildAsyncDetails(),
829
+ });
830
+ }
831
+ const startedListing = started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n");
737
832
  onUpdate?.({
738
- content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }],
739
- details: buildAsyncDetails("running", jobId),
833
+ content: [{ type: "text", text: `Spawned ${started.length} agents...` }],
834
+ details: buildAsyncDetails(),
740
835
  });
741
836
  return withAdvisory({
742
837
  content: [
743
838
  {
744
839
  type: "text",
745
- text: `Spawned agent \`${agentId}\` (job \`${jobId}\`)${descriptionSuffix}. The result will be delivered when it yields. ${coordinationHint}`,
840
+ text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${startedListing}\n${coordinationHint}`,
746
841
  },
747
842
  ],
748
- details: buildAsyncDetails("running", jobId),
843
+ details: buildAsyncDetails(),
749
844
  });
750
845
  }
751
846
 
752
- const coordinationHint = ircEnabled
753
- ? `DM these ids via \`irc\` to coordinate while they run; use \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
754
- : `Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`;
755
- const scheduleFailureSummary =
756
- failedSchedules.length > 0
757
- ? ` Failed to schedule ${failedSchedules.length} spawn${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}.`
758
- : "";
759
- const startedListing = started
760
- .map(({ agentId, jobId, description }) => {
761
- const prefix = `- \`${agentId}\` (job \`${jobId}\`)`;
762
- return description ? `${prefix} — ${description}` : prefix;
763
- })
764
- .join("\n");
847
+ // Mixed call: the async jobs above already run detached; the blocking
848
+ // subset runs inline and gates the call's return exactly what each
849
+ // agent type declares (`blocking: true` = the parent waits on it).
850
+ const syncLabel = syncSpawns.map(spawn => `\`${spawn.agentId}\``).join(", ");
765
851
  onUpdate?.({
766
- content: [{ type: "text", text: `Spawned ${started.length} agents...` }],
767
- details: buildAsyncDetails("running", primaryJobId),
768
- });
769
- return withAdvisory({
770
852
  content: [
771
853
  {
772
854
  type: "text",
773
- text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${startedListing}\n${coordinationHint}`,
855
+ text: `Running ${syncLabel} inline; ${started.length} background agent${started.length === 1 ? "" : "s"} spawned...`,
774
856
  },
775
857
  ],
776
- details: buildAsyncDetails("running", primaryJobId),
858
+ details: buildAsyncDetails(),
859
+ });
860
+ const payloads = await this.#runSyncSpawns({
861
+ toolCallId,
862
+ params,
863
+ defaultAgent,
864
+ signal,
865
+ spawns: syncSpawns.map(spawn => ({ item: spawn.item, index: spawn.index, preAllocatedId: spawn.agentId })),
866
+ onItemProgress: onUpdate
867
+ ? (index, progress) => {
868
+ const spawn = spawns[index];
869
+ if (spawn) spawn.progress = { ...progress, index };
870
+ onUpdate({
871
+ content: [{ type: "text", text: `Running ${syncLabel} inline...` }],
872
+ details: buildAsyncDetails(),
873
+ });
874
+ }
875
+ : undefined,
876
+ });
877
+ const merged = mergeSyncPayloads(
878
+ syncSpawns.map(spawn => ({ item: spawn.item, index: spawn.index })),
879
+ payloads,
880
+ );
881
+ syncResults.push(...merged.results);
882
+ syncUsage = merged.usage;
883
+ syncOutputPaths = merged.outputPaths;
884
+ syncProjectAgentsDir = merged.projectAgentsDir;
885
+ // Settle the inline spawns' progress rows from their merged results so
886
+ // post-return job updates carry final statuses, not the last snapshot.
887
+ for (let position = 0; position < syncSpawns.length; position++) {
888
+ const spawn = syncSpawns[position];
889
+ const result = merged.results.find(r => r.id === spawn.agentId);
890
+ if (result) {
891
+ spawn.progress.status = result.aborted
892
+ ? "aborted"
893
+ : result.exitCode === 0 && !result.error
894
+ ? "completed"
895
+ : "failed";
896
+ spawn.progress.durationMs = result.durationMs;
897
+ } else {
898
+ spawn.progress.status = payloads[position] ? "failed" : "aborted";
899
+ }
900
+ }
901
+
902
+ const spawnedSummary =
903
+ started.length > 0
904
+ ? `Spawned ${started.length} background agent${started.length === 1 ? "" : "s"}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n")}\n${coordinationHint}`
905
+ : scheduleFailureSummary.trim();
906
+ const text = [merged.contentParts.join("\n\n"), spawnedSummary]
907
+ .filter(section => section.trim().length > 0)
908
+ .join("\n\n");
909
+ return withAdvisory({
910
+ content: [{ type: "text", text: text.length > 0 ? text : "No results." }],
911
+ details: buildAsyncDetails(),
777
912
  });
778
913
  }
779
914
 
@@ -790,7 +925,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
790
925
  agentId: string;
791
926
  progress: AgentProgress;
792
927
  ircEnabled: boolean;
793
- buildDetails: (state: "running" | "completed" | "failed", jobId: string) => TaskToolDetails;
928
+ buildDetails: () => TaskToolDetails;
794
929
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>;
795
930
  onSettled?: (failed: boolean) => void;
796
931
  }): string {
@@ -798,6 +933,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
798
933
  options;
799
934
  const buildFollowUpHint = (aborted: boolean): string => {
800
935
  if (aborted) {
936
+ const status = AgentRegistry.global().get(agentId)?.status;
937
+ if (status === "idle" || status === "parked") {
938
+ const followUp = ircEnabled ? "message it via `irc` to resume; " : "";
939
+ return `\n\n${agentId} was stopped but is still resumable — ${followUp}transcript at history://${agentId}`;
940
+ }
801
941
  return `\n\n${agentId} was aborted — transcript at history://${agentId}`;
802
942
  }
803
943
  const followUp = ircEnabled ? "message it via `irc` to follow up; " : "";
@@ -806,7 +946,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
806
946
  return manager.register(
807
947
  "task",
808
948
  agentId,
809
- async ({ jobId: ownJobId, signal: runSignal, reportProgress, markRunning }) => {
949
+ async ({ signal: runSignal, reportProgress, markRunning }) => {
810
950
  const startedAt = Date.now();
811
951
  const semaphore = this.#getSpawnSemaphore();
812
952
  let semaphoreHeld = false;
@@ -840,10 +980,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
840
980
  try {
841
981
  markRunning();
842
982
  progress.status = "running";
843
- await reportProgress(
844
- `Running background task ${agentId}...`,
845
- buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
846
- );
983
+ await reportProgress(`Running background task ${agentId}...`);
847
984
  const result = await this.#executeSync(
848
985
  toolCallId,
849
986
  spawnParams,
@@ -873,14 +1010,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
873
1010
  const statusText = resultFailed
874
1011
  ? `Background task ${agentId} failed.`
875
1012
  : `Background task ${agentId} complete.`;
876
- await reportProgress(
877
- statusText,
878
- buildDetails(resultFailed ? "failed" : "completed", ownJobId) as unknown as Record<string, unknown>,
879
- );
880
- onUpdate?.({
881
- content: [{ type: "text", text: statusText }],
882
- details: buildDetails(resultFailed ? "failed" : "completed", ownJobId),
883
- });
1013
+ await reportProgress(statusText);
884
1014
  const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`;
885
1015
  if (resultFailed) {
886
1016
  // Mark the job itself failed; the failed agent stays interrogable.
@@ -895,11 +1025,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
895
1025
  progress.durationMs = Math.max(0, Date.now() - startedAt);
896
1026
  onSettled?.(true);
897
1027
  const statusText = `Background task ${agentId} failed.`;
898
- await reportProgress(statusText, buildDetails("failed", ownJobId) as unknown as Record<string, unknown>);
899
- onUpdate?.({
900
- content: [{ type: "text", text: statusText }],
901
- details: buildDetails("failed", ownJobId),
902
- });
1028
+ await reportProgress(statusText);
903
1029
  const message = error instanceof Error ? error.message : String(error);
904
1030
  const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
905
1031
  throw new TaskJobError(`${message}${hint}`);
@@ -909,38 +1035,39 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
909
1035
  },
910
1036
  {
911
1037
  id: agentId,
1038
+ agentId,
912
1039
  queued: true,
913
1040
  ownerId: this.session.getAgentId?.() ?? undefined,
914
- onProgress: (text, details) => {
915
- const progressDetails = (details as TaskToolDetails | undefined) ?? buildDetails("running", agentId);
916
- onUpdate?.({ content: [{ type: "text", text }], details: progressDetails });
1041
+ onProgress: text => {
1042
+ onUpdate?.({ content: [{ type: "text", text }], details: buildDetails() });
917
1043
  },
918
1044
  },
919
1045
  );
920
1046
  }
921
1047
 
922
1048
  /**
923
- * Sync fallback fan-out (no job manager, or a `blocking: true` agent): run
924
- * every spawn to completion inline and merge the per-spawn payloads into a
925
- * single tool result. The session-scoped semaphore still bounds concurrency
926
- * across parallel task calls.
1049
+ * Sync fan-out (async unavailable, or every item's agent type is
1050
+ * `blocking: true`): run every spawn to completion inline and merge the
1051
+ * per-spawn payloads into a single tool result. The session-scoped
1052
+ * semaphore still bounds concurrency across parallel task calls.
927
1053
  */
928
1054
  async #executeSyncFanout(
929
1055
  toolCallId: string,
930
1056
  params: TaskParams,
931
1057
  spawnItems: TaskItem[],
1058
+ defaultAgent: string,
932
1059
  signal?: AbortSignal,
933
1060
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
934
1061
  ): Promise<AgentToolResult<TaskToolDetails>> {
935
- const semaphore = this.#getSpawnSemaphore();
936
1062
  if (spawnItems.length === 1) {
1063
+ const semaphore = this.#getSpawnSemaphore();
937
1064
  const invokedAt = Date.now();
938
1065
  await semaphore.acquire(signal);
939
1066
  const acquiredAt = Date.now();
940
1067
  try {
941
1068
  return await this.#executeSync(
942
1069
  toolCallId,
943
- spawnParamsFor(params, spawnItems[0]),
1070
+ spawnParamsFor(params, spawnItems[0], defaultAgent),
944
1071
  signal,
945
1072
  onUpdate,
946
1073
  undefined,
@@ -969,30 +1096,75 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
969
1096
  });
970
1097
  };
971
1098
 
972
- const { results: payloads } = await mapWithConcurrencyLimit(
973
- spawnItems,
974
- spawnItems.length,
975
- async (item, index, workerSignal) => {
1099
+ const payloads = await this.#runSyncSpawns({
1100
+ toolCallId,
1101
+ params,
1102
+ defaultAgent,
1103
+ signal,
1104
+ spawns: spawnItems.map((item, index) => ({ item, index })),
1105
+ onItemProgress: onUpdate
1106
+ ? (index, progress) => {
1107
+ latestProgress.set(index, { ...progress, index });
1108
+ emitCombined();
1109
+ }
1110
+ : undefined,
1111
+ });
1112
+
1113
+ const merged = mergeSyncPayloads(
1114
+ spawnItems.map((item, index) => ({ item, index })),
1115
+ payloads,
1116
+ );
1117
+ return {
1118
+ content: [{ type: "text", text: merged.contentParts.join("\n\n") }],
1119
+ details: {
1120
+ projectAgentsDir: merged.projectAgentsDir,
1121
+ results: merged.results,
1122
+ totalDurationMs: Date.now() - startTime,
1123
+ usage: merged.usage,
1124
+ outputPaths: merged.outputPaths,
1125
+ },
1126
+ };
1127
+ }
1128
+
1129
+ /**
1130
+ * Run a set of spawns to completion inline, bounded by the session spawn
1131
+ * semaphore. `preAllocatedId` reuses an id claimed up front (mixed calls);
1132
+ * `index` is each item's position in the original call so progress rows and
1133
+ * merged results keep stable ordering. Per-item progress snapshots flow
1134
+ * through `onItemProgress`. Returns per-spawn payloads in input order;
1135
+ * `undefined` marks a spawn cancelled before it started.
1136
+ */
1137
+ async #runSyncSpawns(args: {
1138
+ toolCallId: string;
1139
+ params: TaskParams;
1140
+ defaultAgent: string;
1141
+ spawns: SyncSpawnRef[];
1142
+ signal?: AbortSignal;
1143
+ onItemProgress?: (index: number, progress: AgentProgress) => void;
1144
+ }): Promise<(AgentToolResult<TaskToolDetails> | undefined)[]> {
1145
+ const { toolCallId, params, defaultAgent, spawns, signal, onItemProgress } = args;
1146
+ const semaphore = this.#getSpawnSemaphore();
1147
+ const { results } = await mapWithConcurrencyLimit(
1148
+ spawns,
1149
+ spawns.length,
1150
+ async (spawn, _position, workerSignal) => {
976
1151
  const invokedAt = Date.now();
977
1152
  await semaphore.acquire(workerSignal);
978
1153
  const acquiredAt = Date.now();
979
1154
  try {
980
- const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onUpdate
1155
+ const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onItemProgress
981
1156
  ? update => {
982
1157
  const progress = update.details?.progress?.[0];
983
- if (progress) {
984
- latestProgress.set(index, { ...progress, index });
985
- emitCombined();
986
- }
1158
+ if (progress) onItemProgress(spawn.index, progress);
987
1159
  }
988
1160
  : undefined;
989
1161
  return await this.#executeSync(
990
1162
  toolCallId,
991
- spawnParamsFor(params, item),
1163
+ spawnParamsFor(params, spawn.item, defaultAgent),
992
1164
  workerSignal,
993
1165
  itemOnUpdate,
994
- undefined,
995
- index,
1166
+ spawn.preAllocatedId,
1167
+ spawn.index,
996
1168
  false,
997
1169
  { invokedAt, acquiredAt },
998
1170
  );
@@ -1002,42 +1174,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1002
1174
  },
1003
1175
  signal,
1004
1176
  );
1005
-
1006
- const results: SingleResult[] = [];
1007
- const contentParts: string[] = [];
1008
- const outputPaths: string[] = [];
1009
- const usageTotals = createUsageTotals();
1010
- let hasUsage = false;
1011
- let projectAgentsDir: string | null = null;
1012
- for (let index = 0; index < spawnItems.length; index++) {
1013
- const payload = payloads[index];
1014
- if (!payload) {
1015
- contentParts.push(`Task ${spawnItems[index].id?.trim() || `#${index + 1}`}: cancelled before start.`);
1016
- continue;
1017
- }
1018
- projectAgentsDir ??= payload.details?.projectAgentsDir ?? null;
1019
- const text = payload.content.find(part => part.type === "text")?.text;
1020
- if (text) contentParts.push(text);
1021
- for (const result of payload.details?.results ?? []) {
1022
- results.push({ ...result, index });
1023
- if (result.usage) {
1024
- addUsageTotals(usageTotals, result.usage);
1025
- hasUsage = true;
1026
- }
1027
- if (result.outputPath) outputPaths.push(result.outputPath);
1028
- }
1029
- }
1030
-
1031
- return {
1032
- content: [{ type: "text", text: contentParts.join("\n\n") }],
1033
- details: {
1034
- projectAgentsDir,
1035
- results,
1036
- totalDurationMs: Date.now() - startTime,
1037
- usage: hasUsage ? usageTotals : undefined,
1038
- outputPaths: outputPaths.length > 0 ? outputPaths : undefined,
1039
- },
1040
- };
1177
+ return results;
1041
1178
  }
1042
1179
 
1043
1180
  /**
@@ -1074,7 +1211,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1074
1211
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
1075
1212
  const agentName = params.agent ?? "";
1076
1213
  const sharedContext = this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined;
1077
- const assignment = (params.assignment ?? "").trim();
1214
+ const assignment = (params.task ?? "").trim();
1078
1215
  const isolationMode = this.session.settings.get("task.isolation.mode");
1079
1216
  const isolationRequested = "isolated" in params ? params.isolated === true : false;
1080
1217
  const isIsolated = isolationMode !== "none" && isolationRequested;
@@ -1228,7 +1365,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1228
1365
  } else {
1229
1366
  const outputManager =
1230
1367
  this.session.agentOutputManager ?? new AgentOutputManager(this.session.getArtifactsDir ?? (() => null));
1231
- agentId = await outputManager.allocate(params.id?.trim() || generateTaskName());
1368
+ agentId = await outputManager.allocate(params.name?.trim() || generateTaskName());
1232
1369
  }
1233
1370
 
1234
1371
  const availableSkills = [...(this.session.skills ?? [])];
@@ -1263,7 +1400,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1263
1400
  cost: 0,
1264
1401
  durationMs: 0,
1265
1402
  modelOverride,
1266
- description: params.description,
1267
1403
  };
1268
1404
  const emitProgress = () => {
1269
1405
  onUpdate?.({
@@ -1287,8 +1423,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1287
1423
  assignment,
1288
1424
  context: sharedContext,
1289
1425
  planReference,
1290
- description: params.description,
1291
- role: params.role,
1292
1426
  index: spawnIndex,
1293
1427
  parentToolCallId: toolCallId,
1294
1428
  detached,
@@ -1356,7 +1490,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1356
1490
  agentId,
1357
1491
  mergeMode,
1358
1492
  artifactsDir: effectiveArtifactsDir,
1359
- description: params.description,
1360
1493
  buildCommitMessage: buildCommitMessageFn,
1361
1494
  buildFailureResult: err => {
1362
1495
  const message = err instanceof Error ? err.message : String(err);
@@ -1367,7 +1500,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1367
1500
  agentSource: agent.source,
1368
1501
  task: renderSubagentUserPrompt(assignment),
1369
1502
  assignment,
1370
- description: params.description,
1371
1503
  exitCode: 1,
1372
1504
  output: "",
1373
1505
  stderr: message,
@@ -1447,11 +1579,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1447
1579
  preview = lastNewline >= 0 ? slice.slice(0, lastNewline) : slice;
1448
1580
  truncated = true;
1449
1581
  }
1582
+ // A stopped-but-adopted agent (soft-budget stop) stays messageable; tell
1583
+ // the parent so it can resume via irc instead of redoing the work.
1584
+ const refStatus = AgentRegistry.global().get(result.id)?.status;
1585
+ const resumable = result.aborted && (refStatus === "idle" || refStatus === "parked");
1450
1586
  const summary = prompt.render(taskSummaryTemplate, {
1451
1587
  agentName: result.agent,
1452
1588
  id: result.id,
1453
1589
  status,
1454
1590
  duration: formatDuration(totalDurationMs),
1591
+ abortReason: result.aborted ? result.abortReason : undefined,
1592
+ resumable,
1455
1593
  preview,
1456
1594
  truncated,
1457
1595
  meta: result.outputMeta