@esso0428/pi-subagents 0.17.18 → 0.17.19

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,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.19] - 2026-09-30
11
+
12
+ ### Added
13
+ - **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.
14
+
10
15
  ## [0.17.18] - 2026-09-30
11
16
 
12
17
  ### 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:
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.19",
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
 
@@ -310,7 +312,10 @@ export default function (pi: ExtensionAPI) {
310
312
  }
311
313
 
312
314
  const all = [d, ...(d.others ?? [])];
313
- return new Text(all.map(renderOne).join("\n"), 0, 0);
315
+ const groupHeader = d.groupSummary
316
+ ? theme.fg("dim", `Wait group: ${d.groupSummary} (${d.groupId ?? ""})`)
317
+ : undefined;
318
+ return new Text([groupHeader, all.map(renderOne).join("\n")].filter(Boolean).join("\n"), 0, 0);
314
319
  }
315
320
  );
316
321
 
@@ -411,6 +416,42 @@ export default function (pi: ExtensionAPI) {
411
416
  30_000,
412
417
  );
413
418
 
419
+ // Explicit wait groups are separate from smart/group join mode: they never
420
+ // time out or partially deliver, and are released only when sealed.
421
+ const waitGroups = new WaitGroupManager((groupId, summary, records) => {
422
+ for (const record of records) {
423
+ agentActivity.delete(record.id);
424
+ widget.markFinished(record.id);
425
+ }
426
+
427
+ const groupKey = `wait-group:${groupId}`;
428
+ scheduleNudge(groupKey, () => {
429
+ const notifications = records.map(record => {
430
+ const consumed = record.resultConsumed ? "\n(Result already retrieved via get_subagent_result.)" : "";
431
+ return formatTaskNotification(record, 300) + consumed;
432
+ }).join("\n\n");
433
+ const consumedCount = records.filter(record => record.resultConsumed).length;
434
+ const consumedNote = consumedCount > 0
435
+ ? ` ${consumedCount} result${consumedCount === 1 ? " was" : "s were"} already retrieved.`
436
+ : "";
437
+ const [first, ...rest] = records;
438
+ const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
439
+ details.groupId = groupId;
440
+ details.groupSummary = summary;
441
+ if (rest.length > 0) {
442
+ details.others = rest.map(record => buildNotificationDetails(record, 300, agentActivity.get(record.id)));
443
+ }
444
+
445
+ pi.sendMessage<NotificationDetails>({
446
+ customType: "subagent-notification",
447
+ content: `Background agent wait group completed: ${summary} (group ${groupId}).${consumedNote}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
448
+ display: true,
449
+ details,
450
+ }, { deliverAs: "followUp", triggerTurn: true });
451
+ });
452
+ widget.update();
453
+ });
454
+
414
455
  /** Helper: build event data for lifecycle events from an AgentRecord. */
415
456
  function buildEventData(record: AgentRecord) {
416
457
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
@@ -463,6 +504,17 @@ export default function (pi: ExtensionAPI) {
463
504
  transcriptPath: record.transcriptPath,
464
505
  });
465
506
 
507
+ // Explicit wait-group members never emit individual notifications. Result
508
+ // consumption does not remove membership; the sealed group still delivers
509
+ // exactly one notification for all terminal members.
510
+ if (record.waitGroupId) {
511
+ waitGroups.onAgentComplete(record);
512
+ agentActivity.delete(record.id);
513
+ widget.markFinished(record.id);
514
+ widget.update();
515
+ return;
516
+ }
517
+
466
518
  // Skip notification if result was already consumed via get_subagent_result
467
519
  if (record.resultConsumed) {
468
520
  agentActivity.delete(record.id);
@@ -633,6 +685,8 @@ export default function (pi: ExtensionAPI) {
633
685
  manager.abortAll();
634
686
  for (const timer of pendingNudges.values()) clearTimeout(timer);
635
687
  pendingNudges.clear();
688
+ groupJoin.dispose();
689
+ waitGroups.dispose();
636
690
  widget.dispose();
637
691
  manager.dispose();
638
692
  });
@@ -856,6 +910,7 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
856
910
  Notes:
857
911
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
858
912
  - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
913
+ - 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
914
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
860
915
  - resume continues a previous agent by ID; steer_subagent messages a running one.
861
916
  - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
@@ -880,6 +935,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
880
935
  - 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
936
  - 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
937
  - 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.
938
+ - 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
939
  - 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
940
  - 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
941
  - Use steer_subagent to send mid-run messages to a running background agent.
@@ -959,6 +1015,7 @@ Terse command-style prompts produce shallow, generic work.
959
1015
  "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
1016
  "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
1017
  "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.",
1018
+ "For a nonblocking grouped notification, use wait: true with run_in_background: true; create/update/seal explicit groups with subagent_wait_group.",
962
1019
  "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
1020
  ],
964
1021
  parameters: Type.Object({
@@ -993,6 +1050,22 @@ Terse command-style prompts produce shallow, generic work.
993
1050
  description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
994
1051
  }),
995
1052
  ),
1053
+ wait: Type.Optional(
1054
+ Type.Boolean({
1055
+ description: "With run_in_background: true, suppress individual completion notification and wait for a sealed group notification. This never blocks execution.",
1056
+ }),
1057
+ ),
1058
+ wait_group: Type.Optional(
1059
+ Type.String({
1060
+ minLength: 1,
1061
+ description: "Existing explicit wait-group ID created by subagent_wait_group. Requires wait: true.",
1062
+ }),
1063
+ ),
1064
+ wait_group_done: Type.Optional(
1065
+ Type.Boolean({
1066
+ description: "With wait: true, seal wait_group after this agent is spawned. Use on the final member.",
1067
+ }),
1068
+ ),
996
1069
  resume: Type.Optional(
997
1070
  Type.String({
998
1071
  description: "Optional agent ID to resume from. Continues from previous context.",
@@ -1169,6 +1242,9 @@ Terse command-style prompts produce shallow, generic work.
1169
1242
  const thinking = resolvedConfig.thinking;
1170
1243
  const inheritContext = resolvedConfig.inheritContext;
1171
1244
  const runInBackground = resolvedConfig.runInBackground;
1245
+ const wait = params.wait === true;
1246
+ const waitGroup = typeof params.wait_group === "string" ? params.wait_group.trim() : undefined;
1247
+ const waitGroupDone = params.wait_group_done === true;
1172
1248
  const isolated = resolvedConfig.isolated;
1173
1249
  const isolation = resolvedConfig.isolation;
1174
1250
  // Whether this spawn writes its .output transcript. Per-agent
@@ -1228,6 +1304,19 @@ Terse command-style prompts produce shallow, generic work.
1228
1304
  tags: agentTags.length > 0 ? agentTags : undefined,
1229
1305
  };
1230
1306
 
1307
+ if ((waitGroup || waitGroupDone) && !wait) {
1308
+ return textResult("wait_group and wait_group_done require wait: true.");
1309
+ }
1310
+ if (wait && !runInBackground) {
1311
+ return textResult("wait: true requires run_in_background: true; it controls nonblocking background notifications.");
1312
+ }
1313
+ if (wait && params.schedule) {
1314
+ return textResult("Cannot combine wait: true with schedule — scheduled jobs are separate future runs.");
1315
+ }
1316
+ if (wait && params.resume) {
1317
+ return textResult("Cannot combine wait: true with resume — wait groups apply to fresh background spawns.");
1318
+ }
1319
+
1231
1320
  // ---- Schedule: register a job, don't spawn now ----
1232
1321
  if (params.schedule) {
1233
1322
  if (!isSchedulingEnabled()) {
@@ -1300,8 +1389,21 @@ Terse command-style prompts produce shallow, generic work.
1300
1389
  // Wrap onSessionCreated to wire output file streaming.
1301
1390
  // The callback reads the transcript paths installed synchronously by
1302
1391
  // onSpawned before the agent can queue or start.
1303
- let id: string;
1304
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1392
+ let id = "";
1393
+ let effectiveWaitGroupId: string | undefined;
1394
+ let implicitWaitGroupId: string | undefined;
1395
+ if (wait) {
1396
+ if (waitGroup) {
1397
+ if (!waitGroups.hasGroup(waitGroup)) {
1398
+ return textResult(`Wait group not found: "${waitGroup}". Create it with subagent_wait_group first.`);
1399
+ }
1400
+ effectiveWaitGroupId = waitGroup;
1401
+ } else {
1402
+ implicitWaitGroupId = waitGroups.create(params.description);
1403
+ effectiveWaitGroupId = implicitWaitGroupId;
1404
+ }
1405
+ }
1406
+ const joinMode = wait ? undefined : resolveJoinMode(defaultJoinMode, true);
1305
1407
  const origBgOnSession = bgCallbacks.onSessionCreated;
1306
1408
  bgCallbacks.onSessionCreated = (session: any) => {
1307
1409
  origBgOnSession(session);
@@ -1322,12 +1424,17 @@ Terse command-style prompts produce shallow, generic work.
1322
1424
  isBackground: true,
1323
1425
  isolation,
1324
1426
  invocation: agentInvocation,
1427
+ waitGroupId: effectiveWaitGroupId,
1325
1428
  onSpawned: (spawnedId) => {
1429
+ id = spawnedId;
1326
1430
  attachTranscript(manager.getRecord(spawnedId), spawnedId);
1431
+ if (effectiveWaitGroupId) waitGroups.addAgent(effectiveWaitGroupId, spawnedId);
1327
1432
  },
1328
1433
  ...bgCallbacks,
1329
1434
  });
1330
1435
  } catch (err) {
1436
+ if (effectiveWaitGroupId && id) waitGroups.removeAgent(effectiveWaitGroupId, id);
1437
+ if (implicitWaitGroupId) waitGroups.discard(implicitWaitGroupId);
1331
1438
  return textResult(err instanceof Error ? err.message : String(err));
1332
1439
  }
1333
1440
 
@@ -1339,7 +1446,9 @@ Terse command-style prompts produce shallow, generic work.
1339
1446
  record.toolCallId = toolCallId;
1340
1447
  }
1341
1448
 
1342
- if (joinMode == null || joinMode === 'async') {
1449
+ if (effectiveWaitGroupId) {
1450
+ if (implicitWaitGroupId || waitGroupDone) waitGroups.seal(effectiveWaitGroupId);
1451
+ } else if (joinMode == null || joinMode === 'async') {
1343
1452
  // Foreground/no join mode or explicit async — not part of any batch
1344
1453
  } else {
1345
1454
  // smart or group — add to current batch
@@ -1370,7 +1479,10 @@ Terse command-style prompts produce shallow, generic work.
1370
1479
  `Description: ${params.description}\n` +
1371
1480
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1372
1481
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1373
- `\nYou will be notified when this agent completes.\n` +
1482
+ (effectiveWaitGroupId
1483
+ ? `\nWait group: ${effectiveWaitGroupId}${implicitWaitGroupId || waitGroupDone ? " (sealed)" : " (open — seal it with subagent_wait_group)"}.\n` +
1484
+ `You will receive one grouped notification when the sealed wait group completes.\n`
1485
+ : `\nYou will be notified when this agent completes.\n`) +
1374
1486
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1375
1487
  `Do not duplicate this agent's work.`,
1376
1488
  { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
@@ -1494,6 +1606,75 @@ Terse command-style prompts produce shallow, generic work.
1494
1606
  },
1495
1607
  }));
1496
1608
 
1609
+ // ---- subagent_wait_group tool ----
1610
+
1611
+ pi.registerTool(defineTool({
1612
+ name: SUBAGENT_TOOL_NAMES.WAIT_GROUP,
1613
+ label: "Subagent Wait Group",
1614
+ description:
1615
+ "Create, update, or seal a nonblocking wait group for background Agent calls. " +
1616
+ "A sealed group sends one completion notification after all member agents finish.",
1617
+ promptSnippet: "Create, update, or seal a grouped subagent completion notification",
1618
+ parameters: Type.Object({
1619
+ action: Type.String({
1620
+ description: "Operation: create, update, or seal.",
1621
+ }),
1622
+ group_id: Type.Optional(Type.String({
1623
+ minLength: 1,
1624
+ description: "Wait group ID. Required for update and seal; optional custom ID for create.",
1625
+ })),
1626
+ summary: Type.Optional(Type.String({
1627
+ minLength: 1,
1628
+ description: "Human-readable group summary. Required for create and update; shown in the eventual notification.",
1629
+ })),
1630
+ }),
1631
+ execute: async (_toolCallId, params) => {
1632
+ const action = String(params.action).trim();
1633
+ const groupId = typeof params.group_id === "string" ? params.group_id.trim() : undefined;
1634
+ const summary = typeof params.summary === "string" ? params.summary.trim() : undefined;
1635
+
1636
+ try {
1637
+ if (action === "create") {
1638
+ if (!summary) return textResult("summary is required when creating a wait group.");
1639
+ const createdId = waitGroups.create(summary, groupId);
1640
+ return textResult(
1641
+ `Created subagent wait group.\n` +
1642
+ `Group ID: ${createdId}\n` +
1643
+ `Summary: ${summary}\n\n` +
1644
+ `Use Agent with run_in_background: true, wait: true, wait_group: "${createdId}". ` +
1645
+ `Seal the group after adding members.`,
1646
+ );
1647
+ }
1648
+ if (action === "update") {
1649
+ if (!groupId) return textResult("group_id is required when updating a wait group.");
1650
+ if (!summary) return textResult("summary is required when updating a wait group.");
1651
+ waitGroups.update(groupId, summary);
1652
+ return textResult(`Updated subagent wait group ${groupId}.\nSummary: ${summary}`);
1653
+ }
1654
+ if (action === "seal") {
1655
+ if (!groupId) return textResult("group_id is required when sealing a wait group.");
1656
+ const snapshot = waitGroups.getGroup(groupId);
1657
+ const delivered = waitGroups.seal(groupId);
1658
+ if (!snapshot) return textResult(`Wait group not found: "${groupId}".`);
1659
+ if (snapshot.agentIds.length === 0) {
1660
+ return textResult(`Sealed empty subagent wait group ${groupId}. No completion notification will be sent.`);
1661
+ }
1662
+ return textResult(
1663
+ `Sealed subagent wait group ${groupId}.\n` +
1664
+ `Summary: ${snapshot.summary}\n` +
1665
+ `Members: ${snapshot.agentIds.length}\n` +
1666
+ (delivered
1667
+ ? "All members were already complete; notification has been queued."
1668
+ : "You will receive one notification after all members finish."),
1669
+ );
1670
+ }
1671
+ return textResult(`Unknown action "${action}". Use create, update, or seal.`);
1672
+ } catch (err) {
1673
+ return textResult(err instanceof Error ? err.message : String(err));
1674
+ }
1675
+ },
1676
+ }));
1677
+
1497
1678
  // ---- get_subagent_result tool ----
1498
1679
 
1499
1680
  pi.registerTool(defineTool({
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
+ }