@esso0428/pi-subagents 0.17.18 → 0.17.20

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.20] - 2026-10-01
11
+
12
+ ### Changed
13
+ - **Removed blocking waits from `get_subagent_result`**: the legacy `wait` parameter is now a non-blocking compatibility flag. Running or queued agents return their current status immediately, while background and wait-group completion notifications remain the expandable output surface for finished results.
14
+
15
+ ## [0.17.19] - 2026-09-30
16
+
17
+ ### Added
18
+ - **Added nonblocking subagent wait groups**: `Agent` now accepts `wait: true` for background notification grouping, with `wait_group` and `wait_group_done` for explicit multi-agent groups, plus the `subagent_wait_group` tool to create, rename, and seal groups. A sealed wait group emits one completion notification after all members reach a terminal state, avoiding blocking waits and notification spam.
19
+
10
20
  ## [0.17.18] - 2026-09-30
11
21
 
12
22
  ### Changed
package/README.md CHANGED
@@ -61,6 +61,38 @@ Agent({
61
61
 
62
62
  Foreground agents block until complete and return results inline. Background agents return an ID immediately and notify you on completion.
63
63
 
64
+ ### Wait groups
65
+
66
+ Use `wait: true` on background agents to suppress individual completion notifications and receive one grouped notification instead:
67
+
68
+ ```
69
+ const group = subagent_wait_group({
70
+ action: "create",
71
+ summary: "Compare notification group API designs",
72
+ })
73
+
74
+ Agent({
75
+ subagent_type: "Explore",
76
+ prompt: "Design a group-handle API",
77
+ description: "Design group handle",
78
+ run_in_background: true,
79
+ wait: true,
80
+ wait_group: group.group_id,
81
+ })
82
+
83
+ Agent({
84
+ subagent_type: "Plan",
85
+ prompt: "Design a batch API",
86
+ description: "Design batch API",
87
+ run_in_background: true,
88
+ wait: true,
89
+ wait_group: group.group_id,
90
+ wait_group_done: true,
91
+ })
92
+ ```
93
+
94
+ `subagent_wait_group` supports `create`, `update`, and `seal`. A sealed group sends one notification after every member reaches a terminal state (`completed`, `steered`, `error`, `stopped`, or `aborted`). If `wait: true` is used without `wait_group`, the extension creates and seals a one-agent implicit group.
95
+
64
96
  ### Scheduling
65
97
 
66
98
  Add a `schedule` field to register the agent to fire later instead of running now:
@@ -400,10 +432,10 @@ Check status and retrieve results from a background agent.
400
432
  | Parameter | Type | Required | Description |
401
433
  |-----------|------|----------|-------------|
402
434
  | `agent_id` | string | yes | Agent ID to check |
403
- | `wait` | boolean | no | Wait for completion |
435
+ | `wait` | boolean | no | Deprecated compatibility flag; never blocks |
404
436
  | `verbose` | boolean | no | Include full conversation log |
405
437
 
406
- Cancelling a `wait: true` call (for example, with `Esc`) stops only the wait. The background agent keeps running, and its completion notification still arrives normally.
438
+ `get_subagent_result` never blocks. If an agent is still running or queued, the tool returns the current status immediately; wait for the background or wait-group completion notification for the expandable final output.
407
439
 
408
440
  ### `steer_subagent`
409
441
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.17.18",
3
+ "version": "0.17.20",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
@@ -184,6 +184,8 @@ interface SpawnOptions {
184
184
  cwd?: string;
185
185
  /** Resolved invocation snapshot captured for UI display. */
186
186
  invocation?: AgentInvocation;
187
+ /** Explicit nonblocking completion wait group for this background agent. */
188
+ waitGroupId?: string;
187
189
  /** Parent abort signal — when aborted, the subagent is also stopped. */
188
190
  signal?: AbortSignal;
189
191
  /** Called on tool start/end with activity info (for streaming progress to UI). */
@@ -376,6 +378,7 @@ export class AgentManager {
376
378
  // have no inline surface — stay visible instead of vanishing.
377
379
  isBackground: options.isBackground,
378
380
  invocation: options.invocation,
381
+ waitGroupId: options.waitGroupId,
379
382
  };
380
383
  this.agents.set(id, record);
381
384
  this.recoveryCwds.set(id, ctx.cwd);
@@ -807,6 +810,9 @@ export class AgentManager {
807
810
  record.status = "stopped";
808
811
  record.completedAt = Date.now();
809
812
  this.checkpoint(record);
813
+ if (record.waitGroupId) {
814
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
815
+ }
810
816
  return true;
811
817
  }
812
818
 
@@ -885,6 +891,9 @@ export class AgentManager {
885
891
  record.status = "stopped";
886
892
  record.completedAt = Date.now();
887
893
  this.checkpoint(record);
894
+ if (record.waitGroupId) {
895
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
896
+ }
888
897
  count++;
889
898
  }
890
899
  }
@@ -37,6 +37,7 @@ export const SUBAGENT_TOOL_NAMES = {
37
37
  AGENT: "Agent",
38
38
  GET_RESULT: "get_subagent_result",
39
39
  STEER: "steer_subagent",
40
+ WAIT_GROUP: "subagent_wait_group",
40
41
  } as const;
41
42
 
42
43
  /** Names of tools registered by this extension that subagents must NOT inherit. */
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Agent — LLM-callable: spawn a sub-agent
6
6
  * get_subagent_result — LLM-callable: check background agent status/result
7
7
  * steer_subagent — LLM-callable: send a steering message to a running agent
8
+ * subagent_wait_group — LLM-callable: create, update, or seal a wait group
8
9
  *
9
10
  * Commands:
10
11
  * /agents — Interactive agent management menu
@@ -51,6 +52,7 @@ import {
51
52
  } from "./ui/agent-widget.js";
52
53
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
53
54
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
55
+ import { WaitGroupManager } from "./wait-group.js";
54
56
 
55
57
  // ---- Shared helpers ----
56
58
 
@@ -59,39 +61,6 @@ function textResult(msg: string, details?: AgentDetails) {
59
61
  return { content: [{ type: "text" as const, text: msg }], details: details as any };
60
62
  }
61
63
 
62
- /** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
63
- function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
64
- if (!signal) return promise;
65
- if (signal.aborted) return Promise.reject(signal.reason);
66
-
67
- return new Promise<T>((resolve, reject) => {
68
- let settled = false;
69
- const cleanup = () => signal.removeEventListener("abort", onAbort);
70
- const onAbort = () => {
71
- if (settled) return;
72
- settled = true;
73
- cleanup();
74
- reject(signal.reason);
75
- };
76
-
77
- signal.addEventListener("abort", onAbort, { once: true });
78
- promise.then(
79
- (value) => {
80
- if (settled) return;
81
- settled = true;
82
- cleanup();
83
- resolve(value);
84
- },
85
- (error: unknown) => {
86
- if (settled) return;
87
- settled = true;
88
- cleanup();
89
- reject(error);
90
- },
91
- );
92
- });
93
- }
94
-
95
64
  export function renderRunningAgentStatus(
96
65
  frame: string,
97
66
  statsText: string,
@@ -310,7 +279,10 @@ export default function (pi: ExtensionAPI) {
310
279
  }
311
280
 
312
281
  const all = [d, ...(d.others ?? [])];
313
- return new Text(all.map(renderOne).join("\n"), 0, 0);
282
+ const groupHeader = d.groupSummary
283
+ ? theme.fg("dim", `Wait group: ${d.groupSummary} (${d.groupId ?? ""})`)
284
+ : undefined;
285
+ return new Text([groupHeader, all.map(renderOne).join("\n")].filter(Boolean).join("\n"), 0, 0);
314
286
  }
315
287
  );
316
288
 
@@ -332,9 +304,6 @@ export default function (pi: ExtensionAPI) {
332
304
  // before they reach pi.sendMessage (fire-and-forget).
333
305
  const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
334
306
  const NUDGE_HOLD_MS = 200;
335
- // A queued result wait must observe completion before its held notification
336
- // can fire, so successful waits can still suppress that redundant nudge.
337
- const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
338
307
 
339
308
  function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
340
309
  cancelNudge(key);
@@ -411,6 +380,42 @@ export default function (pi: ExtensionAPI) {
411
380
  30_000,
412
381
  );
413
382
 
383
+ // Explicit wait groups are separate from smart/group join mode: they never
384
+ // time out or partially deliver, and are released only when sealed.
385
+ const waitGroups = new WaitGroupManager((groupId, summary, records) => {
386
+ for (const record of records) {
387
+ agentActivity.delete(record.id);
388
+ widget.markFinished(record.id);
389
+ }
390
+
391
+ const groupKey = `wait-group:${groupId}`;
392
+ scheduleNudge(groupKey, () => {
393
+ const notifications = records.map(record => {
394
+ const consumed = record.resultConsumed ? "\n(Result already retrieved via get_subagent_result.)" : "";
395
+ return formatTaskNotification(record, 300) + consumed;
396
+ }).join("\n\n");
397
+ const consumedCount = records.filter(record => record.resultConsumed).length;
398
+ const consumedNote = consumedCount > 0
399
+ ? ` ${consumedCount} result${consumedCount === 1 ? " was" : "s were"} already retrieved.`
400
+ : "";
401
+ const [first, ...rest] = records;
402
+ const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
403
+ details.groupId = groupId;
404
+ details.groupSummary = summary;
405
+ if (rest.length > 0) {
406
+ details.others = rest.map(record => buildNotificationDetails(record, 300, agentActivity.get(record.id)));
407
+ }
408
+
409
+ pi.sendMessage<NotificationDetails>({
410
+ customType: "subagent-notification",
411
+ content: `Background agent wait group completed: ${summary} (group ${groupId}).${consumedNote}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
412
+ display: true,
413
+ details,
414
+ }, { deliverAs: "followUp", triggerTurn: true });
415
+ });
416
+ widget.update();
417
+ });
418
+
414
419
  /** Helper: build event data for lifecycle events from an AgentRecord. */
415
420
  function buildEventData(record: AgentRecord) {
416
421
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
@@ -463,6 +468,17 @@ export default function (pi: ExtensionAPI) {
463
468
  transcriptPath: record.transcriptPath,
464
469
  });
465
470
 
471
+ // Explicit wait-group members never emit individual notifications. Result
472
+ // consumption does not remove membership; the sealed group still delivers
473
+ // exactly one notification for all terminal members.
474
+ if (record.waitGroupId) {
475
+ waitGroups.onAgentComplete(record);
476
+ agentActivity.delete(record.id);
477
+ widget.markFinished(record.id);
478
+ widget.update();
479
+ return;
480
+ }
481
+
466
482
  // Skip notification if result was already consumed via get_subagent_result
467
483
  if (record.resultConsumed) {
468
484
  agentActivity.delete(record.id);
@@ -633,6 +649,8 @@ export default function (pi: ExtensionAPI) {
633
649
  manager.abortAll();
634
650
  for (const timer of pendingNudges.values()) clearTimeout(timer);
635
651
  pendingNudges.clear();
652
+ groupJoin.dispose();
653
+ waitGroups.dispose();
636
654
  widget.dispose();
637
655
  manager.dispose();
638
656
  });
@@ -856,6 +874,7 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
856
874
  Notes:
857
875
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
858
876
  - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
877
+ - For nonblocking grouped notification, add wait: true. Use subagent_wait_group to create/update/seal explicit groups; wait_group_done seals after the final spawn.
859
878
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
860
879
  - resume continues a previous agent by ID; steer_subagent messages a running one.
861
880
  - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
@@ -880,6 +899,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
880
899
  - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
881
900
  - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
882
901
  - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
902
+ - For nonblocking grouped notification, set wait: true with run_in_background: true. Omit wait_group for a one-agent implicit group, or create an explicit group with subagent_wait_group and seal it (or set wait_group_done: true on the final Agent call).
883
903
  - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
884
904
  - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
885
905
  - Use steer_subagent to send mid-run messages to a running background agent.
@@ -959,6 +979,7 @@ Terse command-style prompts produce shallow, generic work.
959
979
  "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
960
980
  "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
961
981
  "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
982
+ "For a nonblocking grouped notification, use wait: true with run_in_background: true; create/update/seal explicit groups with subagent_wait_group.",
962
983
  "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
963
984
  ],
964
985
  parameters: Type.Object({
@@ -993,6 +1014,22 @@ Terse command-style prompts produce shallow, generic work.
993
1014
  description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
994
1015
  }),
995
1016
  ),
1017
+ wait: Type.Optional(
1018
+ Type.Boolean({
1019
+ description: "With run_in_background: true, suppress individual completion notification and wait for a sealed group notification. This never blocks execution.",
1020
+ }),
1021
+ ),
1022
+ wait_group: Type.Optional(
1023
+ Type.String({
1024
+ minLength: 1,
1025
+ description: "Existing explicit wait-group ID created by subagent_wait_group. Requires wait: true.",
1026
+ }),
1027
+ ),
1028
+ wait_group_done: Type.Optional(
1029
+ Type.Boolean({
1030
+ description: "With wait: true, seal wait_group after this agent is spawned. Use on the final member.",
1031
+ }),
1032
+ ),
996
1033
  resume: Type.Optional(
997
1034
  Type.String({
998
1035
  description: "Optional agent ID to resume from. Continues from previous context.",
@@ -1169,6 +1206,9 @@ Terse command-style prompts produce shallow, generic work.
1169
1206
  const thinking = resolvedConfig.thinking;
1170
1207
  const inheritContext = resolvedConfig.inheritContext;
1171
1208
  const runInBackground = resolvedConfig.runInBackground;
1209
+ const wait = params.wait === true;
1210
+ const waitGroup = typeof params.wait_group === "string" ? params.wait_group.trim() : undefined;
1211
+ const waitGroupDone = params.wait_group_done === true;
1172
1212
  const isolated = resolvedConfig.isolated;
1173
1213
  const isolation = resolvedConfig.isolation;
1174
1214
  // Whether this spawn writes its .output transcript. Per-agent
@@ -1228,6 +1268,19 @@ Terse command-style prompts produce shallow, generic work.
1228
1268
  tags: agentTags.length > 0 ? agentTags : undefined,
1229
1269
  };
1230
1270
 
1271
+ if ((waitGroup || waitGroupDone) && !wait) {
1272
+ return textResult("wait_group and wait_group_done require wait: true.");
1273
+ }
1274
+ if (wait && !runInBackground) {
1275
+ return textResult("wait: true requires run_in_background: true; it controls nonblocking background notifications.");
1276
+ }
1277
+ if (wait && params.schedule) {
1278
+ return textResult("Cannot combine wait: true with schedule — scheduled jobs are separate future runs.");
1279
+ }
1280
+ if (wait && params.resume) {
1281
+ return textResult("Cannot combine wait: true with resume — wait groups apply to fresh background spawns.");
1282
+ }
1283
+
1231
1284
  // ---- Schedule: register a job, don't spawn now ----
1232
1285
  if (params.schedule) {
1233
1286
  if (!isSchedulingEnabled()) {
@@ -1300,8 +1353,21 @@ Terse command-style prompts produce shallow, generic work.
1300
1353
  // Wrap onSessionCreated to wire output file streaming.
1301
1354
  // The callback reads the transcript paths installed synchronously by
1302
1355
  // onSpawned before the agent can queue or start.
1303
- let id: string;
1304
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1356
+ let id = "";
1357
+ let effectiveWaitGroupId: string | undefined;
1358
+ let implicitWaitGroupId: string | undefined;
1359
+ if (wait) {
1360
+ if (waitGroup) {
1361
+ if (!waitGroups.hasGroup(waitGroup)) {
1362
+ return textResult(`Wait group not found: "${waitGroup}". Create it with subagent_wait_group first.`);
1363
+ }
1364
+ effectiveWaitGroupId = waitGroup;
1365
+ } else {
1366
+ implicitWaitGroupId = waitGroups.create(params.description);
1367
+ effectiveWaitGroupId = implicitWaitGroupId;
1368
+ }
1369
+ }
1370
+ const joinMode = wait ? undefined : resolveJoinMode(defaultJoinMode, true);
1305
1371
  const origBgOnSession = bgCallbacks.onSessionCreated;
1306
1372
  bgCallbacks.onSessionCreated = (session: any) => {
1307
1373
  origBgOnSession(session);
@@ -1322,12 +1388,17 @@ Terse command-style prompts produce shallow, generic work.
1322
1388
  isBackground: true,
1323
1389
  isolation,
1324
1390
  invocation: agentInvocation,
1391
+ waitGroupId: effectiveWaitGroupId,
1325
1392
  onSpawned: (spawnedId) => {
1393
+ id = spawnedId;
1326
1394
  attachTranscript(manager.getRecord(spawnedId), spawnedId);
1395
+ if (effectiveWaitGroupId) waitGroups.addAgent(effectiveWaitGroupId, spawnedId);
1327
1396
  },
1328
1397
  ...bgCallbacks,
1329
1398
  });
1330
1399
  } catch (err) {
1400
+ if (effectiveWaitGroupId && id) waitGroups.removeAgent(effectiveWaitGroupId, id);
1401
+ if (implicitWaitGroupId) waitGroups.discard(implicitWaitGroupId);
1331
1402
  return textResult(err instanceof Error ? err.message : String(err));
1332
1403
  }
1333
1404
 
@@ -1339,7 +1410,9 @@ Terse command-style prompts produce shallow, generic work.
1339
1410
  record.toolCallId = toolCallId;
1340
1411
  }
1341
1412
 
1342
- if (joinMode == null || joinMode === 'async') {
1413
+ if (effectiveWaitGroupId) {
1414
+ if (implicitWaitGroupId || waitGroupDone) waitGroups.seal(effectiveWaitGroupId);
1415
+ } else if (joinMode == null || joinMode === 'async') {
1343
1416
  // Foreground/no join mode or explicit async — not part of any batch
1344
1417
  } else {
1345
1418
  // smart or group — add to current batch
@@ -1370,7 +1443,10 @@ Terse command-style prompts produce shallow, generic work.
1370
1443
  `Description: ${params.description}\n` +
1371
1444
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1372
1445
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1373
- `\nYou will be notified when this agent completes.\n` +
1446
+ (effectiveWaitGroupId
1447
+ ? `\nWait group: ${effectiveWaitGroupId}${implicitWaitGroupId || waitGroupDone ? " (sealed)" : " (open — seal it with subagent_wait_group)"}.\n` +
1448
+ `You will receive one grouped notification when the sealed wait group completes.\n`
1449
+ : `\nYou will be notified when this agent completes.\n`) +
1374
1450
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1375
1451
  `Do not duplicate this agent's work.`,
1376
1452
  { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
@@ -1494,6 +1570,75 @@ Terse command-style prompts produce shallow, generic work.
1494
1570
  },
1495
1571
  }));
1496
1572
 
1573
+ // ---- subagent_wait_group tool ----
1574
+
1575
+ pi.registerTool(defineTool({
1576
+ name: SUBAGENT_TOOL_NAMES.WAIT_GROUP,
1577
+ label: "Subagent Wait Group",
1578
+ description:
1579
+ "Create, update, or seal a nonblocking wait group for background Agent calls. " +
1580
+ "A sealed group sends one completion notification after all member agents finish.",
1581
+ promptSnippet: "Create, update, or seal a grouped subagent completion notification",
1582
+ parameters: Type.Object({
1583
+ action: Type.String({
1584
+ description: "Operation: create, update, or seal.",
1585
+ }),
1586
+ group_id: Type.Optional(Type.String({
1587
+ minLength: 1,
1588
+ description: "Wait group ID. Required for update and seal; optional custom ID for create.",
1589
+ })),
1590
+ summary: Type.Optional(Type.String({
1591
+ minLength: 1,
1592
+ description: "Human-readable group summary. Required for create and update; shown in the eventual notification.",
1593
+ })),
1594
+ }),
1595
+ execute: async (_toolCallId, params) => {
1596
+ const action = String(params.action).trim();
1597
+ const groupId = typeof params.group_id === "string" ? params.group_id.trim() : undefined;
1598
+ const summary = typeof params.summary === "string" ? params.summary.trim() : undefined;
1599
+
1600
+ try {
1601
+ if (action === "create") {
1602
+ if (!summary) return textResult("summary is required when creating a wait group.");
1603
+ const createdId = waitGroups.create(summary, groupId);
1604
+ return textResult(
1605
+ `Created subagent wait group.\n` +
1606
+ `Group ID: ${createdId}\n` +
1607
+ `Summary: ${summary}\n\n` +
1608
+ `Use Agent with run_in_background: true, wait: true, wait_group: "${createdId}". ` +
1609
+ `Seal the group after adding members.`,
1610
+ );
1611
+ }
1612
+ if (action === "update") {
1613
+ if (!groupId) return textResult("group_id is required when updating a wait group.");
1614
+ if (!summary) return textResult("summary is required when updating a wait group.");
1615
+ waitGroups.update(groupId, summary);
1616
+ return textResult(`Updated subagent wait group ${groupId}.\nSummary: ${summary}`);
1617
+ }
1618
+ if (action === "seal") {
1619
+ if (!groupId) return textResult("group_id is required when sealing a wait group.");
1620
+ const snapshot = waitGroups.getGroup(groupId);
1621
+ const delivered = waitGroups.seal(groupId);
1622
+ if (!snapshot) return textResult(`Wait group not found: "${groupId}".`);
1623
+ if (snapshot.agentIds.length === 0) {
1624
+ return textResult(`Sealed empty subagent wait group ${groupId}. No completion notification will be sent.`);
1625
+ }
1626
+ return textResult(
1627
+ `Sealed subagent wait group ${groupId}.\n` +
1628
+ `Summary: ${snapshot.summary}\n` +
1629
+ `Members: ${snapshot.agentIds.length}\n` +
1630
+ (delivered
1631
+ ? "All members were already complete; notification has been queued."
1632
+ : "You will receive one notification after all members finish."),
1633
+ );
1634
+ }
1635
+ return textResult(`Unknown action "${action}". Use create, update, or seal.`);
1636
+ } catch (err) {
1637
+ return textResult(err instanceof Error ? err.message : String(err));
1638
+ }
1639
+ },
1640
+ }));
1641
+
1497
1642
  // ---- get_subagent_result tool ----
1498
1643
 
1499
1644
  pi.registerTool(defineTool({
@@ -1508,7 +1653,7 @@ Terse command-style prompts produce shallow, generic work.
1508
1653
  }),
1509
1654
  wait: Type.Optional(
1510
1655
  Type.Boolean({
1511
- description: "If true, wait for the agent to complete before returning. Default: false.",
1656
+ description: "Deprecated compatibility flag. Results never block; running agents return current status immediately.",
1512
1657
  }),
1513
1658
  ),
1514
1659
  verbose: Type.Optional(
@@ -1517,27 +1662,12 @@ Terse command-style prompts produce shallow, generic work.
1517
1662
  }),
1518
1663
  ),
1519
1664
  }),
1520
- execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
1665
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1521
1666
  const record = manager.getRecord(params.agent_id);
1522
1667
  if (!record) {
1523
1668
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1524
1669
  }
1525
1670
 
1526
- // Wait for completion if requested. Cancellation stops only this tool
1527
- // call; the background agent keeps running and remains unconsumed so its
1528
- // completion notification can still be delivered.
1529
- // Queued agents have no promise yet (it's created when the queue starts
1530
- // them), so poll until they leave the queue, then await like a running one.
1531
- if (params.wait && (record.status === "running" || record.status === "queued")) {
1532
- while (record.status === "queued") {
1533
- await abortable(
1534
- new Promise<void>((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)),
1535
- signal,
1536
- );
1537
- }
1538
- if (record.promise) await abortable(record.promise, signal);
1539
- }
1540
-
1541
1671
  const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1542
1672
  ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1543
1673
  : undefined;
@@ -1556,8 +1686,10 @@ Terse command-style prompts produce shallow, generic work.
1556
1686
  `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1557
1687
  `Description: ${record.description}\n\n`;
1558
1688
 
1559
- if (record.status === "running") {
1560
- output += "Agent is still running. Use wait: true or check back later.";
1689
+ if (record.status === "running" || record.status === "queued") {
1690
+ output += params.wait
1691
+ ? `Agent is still ${record.status}. wait:true is deprecated and no longer blocks; wait for the background completion notification or check again later.`
1692
+ : `Agent is still ${record.status}. Wait for the background completion notification or check again later.`;
1561
1693
  } else if (record.status === "error") {
1562
1694
  output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
1563
1695
  } else {
package/src/types.ts CHANGED
@@ -103,6 +103,8 @@ export interface AgentRecord {
103
103
  worktreeResult?: { hasChanges: boolean; branch?: string };
104
104
  /** The tool_use_id from the original Agent tool call. */
105
105
  toolCallId?: string;
106
+ /** Explicit nonblocking completion wait group for this background agent. */
107
+ waitGroupId?: string;
106
108
  /** Path to the streaming output transcript file. */
107
109
  outputFile?: string;
108
110
  /** Absolute path to the durable project-local transcript while live. */
@@ -164,6 +166,10 @@ export interface NotificationDetails {
164
166
  resultPreview: string;
165
167
  /** Additional agents in a group notification. */
166
168
  others?: NotificationDetails[];
169
+ /** Stable ID for an explicit wait-group notification. */
170
+ groupId?: string;
171
+ /** Human-readable summary for an explicit wait-group notification. */
172
+ groupSummary?: string;
167
173
  }
168
174
 
169
175
  export interface EnvInfo {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * wait-group.ts — Explicit, nonblocking completion wait groups.
3
+ *
4
+ * Unlike GroupJoinManager, wait groups never time out or partially deliver.
5
+ * A group delivers exactly once after it has been sealed and every member has
6
+ * reached a terminal state.
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import type { AgentRecord } from "./types.js";
11
+
12
+ export const TERMINAL_AGENT_STATUSES = [
13
+ "completed",
14
+ "steered",
15
+ "error",
16
+ "stopped",
17
+ "aborted",
18
+ ] as const;
19
+
20
+ export type TerminalAgentStatus = (typeof TERMINAL_AGENT_STATUSES)[number];
21
+
22
+ export interface WaitGroupSnapshot {
23
+ groupId: string;
24
+ summary: string;
25
+ agentIds: readonly string[];
26
+ sealed: boolean;
27
+ delivered: boolean;
28
+ }
29
+
30
+ type WaitGroup = {
31
+ groupId: string;
32
+ summary: string;
33
+ agentIds: Set<string>;
34
+ completedRecords: Map<string, AgentRecord>;
35
+ sealed: boolean;
36
+ delivered: boolean;
37
+ };
38
+
39
+ export type WaitGroupDeliveryCallback = (
40
+ groupId: string,
41
+ summary: string,
42
+ records: AgentRecord[],
43
+ ) => void;
44
+
45
+ function normalizeSummary(summary: string): string {
46
+ const normalized = summary.trim();
47
+ if (!normalized) throw new Error("Wait group summary must not be empty.");
48
+ return normalized;
49
+ }
50
+
51
+ function createGroupId(): string {
52
+ return `wait-${randomUUID().slice(0, 17)}`;
53
+ }
54
+
55
+ export function isTerminalAgentStatus(status: AgentRecord["status"]): status is TerminalAgentStatus {
56
+ return (TERMINAL_AGENT_STATUSES as readonly string[]).includes(status);
57
+ }
58
+
59
+ export class WaitGroupManager {
60
+ private groups = new Map<string, WaitGroup>();
61
+ private agentToGroup = new Map<string, string>();
62
+
63
+ constructor(private deliverCb: WaitGroupDeliveryCallback) {}
64
+
65
+ create(summary: string, requestedGroupId?: string): string {
66
+ const groupId = requestedGroupId?.trim() || createGroupId();
67
+ if (!groupId) throw new Error("Wait group ID must not be empty.");
68
+ if (this.groups.has(groupId)) throw new Error(`Wait group already exists: "${groupId}".`);
69
+
70
+ this.groups.set(groupId, {
71
+ groupId,
72
+ summary: normalizeSummary(summary),
73
+ agentIds: new Set(),
74
+ completedRecords: new Map(),
75
+ sealed: false,
76
+ delivered: false,
77
+ });
78
+ return groupId;
79
+ }
80
+
81
+ update(groupId: string, summary: string): void {
82
+ const group = this.requireGroup(groupId);
83
+ if (group.delivered) throw new Error(`Wait group "${groupId}" has already delivered.`);
84
+ group.summary = normalizeSummary(summary);
85
+ }
86
+
87
+ addAgent(groupId: string, agentId: string): void {
88
+ const group = this.requireGroup(groupId);
89
+ if (group.delivered) throw new Error(`Wait group "${groupId}" has already delivered.`);
90
+ if (group.sealed) throw new Error(`Wait group "${groupId}" is already sealed.`);
91
+
92
+ const existingGroupId = this.agentToGroup.get(agentId);
93
+ if (existingGroupId && existingGroupId !== groupId) {
94
+ throw new Error(`Agent "${agentId}" already belongs to wait group "${existingGroupId}".`);
95
+ }
96
+
97
+ group.agentIds.add(agentId);
98
+ this.agentToGroup.set(agentId, groupId);
99
+ }
100
+
101
+ removeAgent(groupId: string, agentId: string): void {
102
+ const group = this.groups.get(groupId);
103
+ if (!group || group.delivered) return;
104
+ group.agentIds.delete(agentId);
105
+ group.completedRecords.delete(agentId);
106
+ this.agentToGroup.delete(agentId);
107
+ if (group.agentIds.size === 0 && !group.sealed) this.groups.delete(groupId);
108
+ }
109
+
110
+ /**
111
+ * Seal a group. Returns true when this call delivered the group, otherwise
112
+ * false when members are still running or it was already sealed.
113
+ */
114
+ seal(groupId: string): boolean {
115
+ const group = this.requireGroup(groupId);
116
+ if (group.delivered) return false;
117
+ if (group.sealed) return false;
118
+ group.sealed = true;
119
+ return this.tryDeliver(group);
120
+ }
121
+
122
+ /** Register a terminal completion and deliver if the sealed group is ready. */
123
+ onAgentComplete(record: AgentRecord): "pass" | "held" | "delivered" {
124
+ const groupId = this.agentToGroup.get(record.id);
125
+ if (!groupId) return "pass";
126
+
127
+ const group = this.groups.get(groupId);
128
+ if (!group) return "pass";
129
+ if (!isTerminalAgentStatus(record.status)) return "held";
130
+ if (group.delivered) return "delivered";
131
+
132
+ group.completedRecords.set(record.id, record);
133
+ return this.tryDeliver(group) ? "delivered" : "held";
134
+ }
135
+
136
+ hasGroup(groupId: string): boolean {
137
+ return this.groups.has(groupId);
138
+ }
139
+
140
+ getGroup(groupId: string): WaitGroupSnapshot | undefined {
141
+ const group = this.groups.get(groupId);
142
+ if (!group) return undefined;
143
+ return {
144
+ groupId: group.groupId,
145
+ summary: group.summary,
146
+ agentIds: [...group.agentIds],
147
+ sealed: group.sealed,
148
+ delivered: group.delivered,
149
+ };
150
+ }
151
+
152
+ /** Remove an empty implicit group when its spawn failed before registration. */
153
+ discard(groupId: string): void {
154
+ const group = this.groups.get(groupId);
155
+ if (!group || group.agentIds.size > 0 || group.delivered) return;
156
+ this.groups.delete(groupId);
157
+ }
158
+
159
+ isGrouped(agentId: string): boolean {
160
+ return this.agentToGroup.has(agentId);
161
+ }
162
+
163
+ private requireGroup(groupId: string): WaitGroup {
164
+ const group = this.groups.get(groupId);
165
+ if (!group) throw new Error(`Wait group not found: "${groupId}".`);
166
+ return group;
167
+ }
168
+
169
+ private tryDeliver(group: WaitGroup): boolean {
170
+ if (group.delivered || !group.sealed || group.agentIds.size === 0) return false;
171
+ if ([...group.agentIds].some(id => !group.completedRecords.has(id))) return false;
172
+
173
+ group.delivered = true;
174
+ // Preserve join order in the notification rather than completion order.
175
+ const records = [...group.agentIds]
176
+ .map(id => group.completedRecords.get(id))
177
+ .filter((record): record is AgentRecord => record !== undefined);
178
+ this.deliverCb(group.groupId, group.summary, records);
179
+ for (const id of group.agentIds) {
180
+ this.agentToGroup.delete(id);
181
+ }
182
+ this.groups.delete(group.groupId);
183
+ return true;
184
+ }
185
+
186
+ dispose(): void {
187
+ this.groups.clear();
188
+ this.agentToGroup.clear();
189
+ }
190
+ }