@gotgenes/pi-subagents 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v2.0.0...pi-subagents-v3.0.0) (2026-05-17)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * The JoinMode type and defaultJoinMode setting are removed from the public settings interface.
14
+ * The join-mode setting (smart/async/group) is removed. Background agents always notify individually on completion.
15
+ * The subagents:ready event is no longer emitted. Extensions should use the typed SubagentsAPI ([#48](https://github.com/gotgenes/pi-packages/issues/48)) instead of event-based RPC discovery.
16
+ * The subagents:rpc:ping, subagents:rpc:spawn, and subagents:rpc:stop event channels are no longer registered. Use the typed SubagentsAPI via Symbol.for() instead.
17
+
18
+ ### Features
19
+
20
+ * remove group-join and cross-extension-rpc source ([b7d7f21](https://github.com/gotgenes/pi-packages/commit/b7d7f21af265e2ff95f0534f5c0b51f71b8f1e7f))
21
+ * remove group-join wiring from index.ts ([4e2dc7f](https://github.com/gotgenes/pi-packages/commit/4e2dc7f8a98e441308f229745dfc42d09784d786))
22
+ * remove join-mode types and settings ([1d98793](https://github.com/gotgenes/pi-packages/commit/1d98793eb85aa3d9815274aa443c34bb4434b6f9))
23
+ * remove RPC wiring from index.ts ([3a960af](https://github.com/gotgenes/pi-packages/commit/3a960af8f6bb83219497d6229d12c6859cf3eb71))
24
+
25
+
26
+ ### Documentation
27
+
28
+ * plan removal of group-join, output-file, and ad-hoc RPC ([#49](https://github.com/gotgenes/pi-packages/issues/49)) ([853a97f](https://github.com/gotgenes/pi-packages/commit/853a97f1c47051868b2ffddd7d5509f765a80d07))
29
+ * remove group-join and RPC from README and AGENTS ([9f65f7a](https://github.com/gotgenes/pi-packages/commit/9f65f7a21611af8dead00a5fb34d7d10f3d6ab43))
30
+
8
31
  ## [2.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v1.0.2...pi-subagents-v2.0.0) (2026-05-17)
9
32
 
10
33
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
17
17
  ## Features
18
18
 
19
19
  - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
20
- - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
20
+ - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and individual completion notifications
21
21
  - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons
22
22
  - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause)
23
23
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
@@ -31,9 +31,9 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
31
31
  - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
32
32
  - **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
33
33
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
34
- - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
34
+ - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output
35
35
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
36
- - **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on load
36
+
37
37
 
38
38
  ## Install
39
39
 
@@ -103,7 +103,7 @@ Background agent completion notifications render as styled boxes:
103
103
  transcript: .pi/output/agent-abc123.jsonl
104
104
  ```
105
105
 
106
- Group completions render each agent as a separate block. The LLM receives structured `<task-notification>` XML for parsing, while the user sees the themed visual.
106
+ The LLM receives structured `<task-notification>` XML for parsing, while the user sees the themed visual.
107
107
 
108
108
  ## Default Agent Types
109
109
 
@@ -232,7 +232,7 @@ The `/agents` command opens an interactive menu:
232
232
  Running agents (2) — 1 running, 1 done ← only shown when agents exist
233
233
  Agent types (6) ← unified list: defaults + custom
234
234
  Create new agent ← manual wizard or AI-generated
235
- Settings ← max concurrency, max turns, grace turns, join mode
235
+ Settings ← max concurrency, max turns, grace turns
236
236
  ```
237
237
 
238
238
  - **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Select an agent to manage it:
@@ -243,7 +243,7 @@ Settings ← max concurrency, max turns, grac
243
243
  - **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it
244
244
  - **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list (marked `✕`) and can be re-enabled
245
245
  - **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do and a sub-agent writes the `.md` file). Any name is allowed, including default agent names (overrides them)
246
- - **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime
246
+ - **Settings** — configure max concurrency, default max turns, and grace turns at runtime
247
247
 
248
248
  ## Graceful Max Turns
249
249
 
@@ -266,29 +266,14 @@ Background agents are subject to a configurable concurrency limit (default: 4).
266
266
 
267
267
  Foreground agents bypass the queue — they block the parent anyway.
268
268
 
269
- ## Join Strategies
270
-
271
- When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered. It applies only to background agents.
272
-
273
- | Mode | Behavior |
274
- |------|----------|
275
- | `smart` (default) | 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification. Solo agents notify individually. |
276
- | `async` | Each agent sends its own notification on completion (original behavior). Best when results need incremental processing. |
277
- | `group` | Force grouping even when spawning a single agent. Useful when you know more agents will follow. |
278
-
279
- **Timeout behavior:** When agents are grouped, a 30-second timeout starts after the first agent completes. If not all agents finish in time, a partial notification is sent with completed results and remaining agents continue with a shorter 15-second re-batch window for stragglers.
280
-
281
- **Configuration:**
282
- - Configure join mode in `/agents` → Settings → Join mode
283
-
284
269
  ## Persistent Settings
285
270
 
286
- Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode) persist across pi restarts. Two files, merged on load:
271
+ Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns) persist across pi restarts. Two files, merged on load:
287
272
 
288
273
  - **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
289
274
  - **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
290
275
 
291
- **Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`, join mode `smart`).
276
+ **Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`).
292
277
 
293
278
  **Example — global defaults for a beefy machine:**
294
279
 
@@ -318,78 +303,11 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
318
303
  | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
319
304
  | `subagents:steered` | Steering message sent | `id`, `message` |
320
305
  | `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` |
321
- | `subagents:ready` | Extension loaded and RPC handlers registered | — |
322
306
  | `subagents:settings_loaded` | Persisted settings applied at extension init | `settings` (merged global + project) |
323
307
  | `subagents:settings_changed` | `/agents` → Settings mutation was applied | `settings`, `persisted` (`boolean` — `false` on write failure) |
324
308
 
325
309
  `tokens.total` = `input + output + cacheWrite`. `cacheRead` is excluded — each turn's `cacheRead` is the cumulative cached prefix re-read on that one API call, so summing per-message would over-count it. Use `contextUsage.percent` (surfaced as `(NN%)` in the widget) for current context size.
326
310
 
327
- ## Cross-Extension RPC
328
-
329
- Other pi extensions can spawn and stop subagents programmatically via the `pi.events` event bus, without importing this package directly.
330
-
331
- All RPC replies use a standardized envelope: `{ success: true, data?: T }` on success, `{ success: false, error: string }` on failure.
332
-
333
- ### Discovery
334
-
335
- Listen for `subagents:ready` to know when RPC handlers are available:
336
-
337
- ```typescript
338
- pi.events.on("subagents:ready", () => {
339
- // RPC handlers are registered — safe to call ping/spawn/stop
340
- });
341
- ```
342
-
343
- ### Ping
344
-
345
- Check if the subagents extension is loaded and get the protocol version:
346
-
347
- ```typescript
348
- const requestId = crypto.randomUUID();
349
- const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => {
350
- unsub();
351
- if (reply.success) console.log("Protocol version:", reply.data.version);
352
- });
353
- pi.events.emit("subagents:rpc:ping", { requestId });
354
- ```
355
-
356
- ### Spawn
357
-
358
- Spawn a subagent and receive its ID:
359
-
360
- ```typescript
361
- const requestId = crypto.randomUUID();
362
- const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (reply) => {
363
- unsub();
364
- if (!reply.success) {
365
- console.error("Spawn failed:", reply.error);
366
- } else {
367
- console.log("Agent ID:", reply.data.id);
368
- }
369
- });
370
- pi.events.emit("subagents:rpc:spawn", {
371
- requestId,
372
- type: "general-purpose",
373
- prompt: "Do something useful",
374
- options: { description: "My task", run_in_background: true },
375
- });
376
- ```
377
-
378
- ### Stop
379
-
380
- Stop a running agent by ID:
381
-
382
- ```typescript
383
- const requestId = crypto.randomUUID();
384
- const unsub = pi.events.on(`subagents:rpc:stop:reply:${requestId}`, (reply) => {
385
- unsub();
386
- if (!reply.success) console.error("Stop failed:", reply.error);
387
- });
388
- pi.events.emit("subagents:rpc:stop", { requestId, agentId: "agent-id-here" });
389
- ```
390
-
391
- Reply channels are scoped per `requestId`, so concurrent requests don't interfere.
392
-
393
311
  ## Persistent Agent Memory
394
312
 
395
313
  Agents can have persistent memory across sessions. Set `memory` in frontmatter to enable:
@@ -477,8 +395,6 @@ src/
477
395
  agent-types.ts # Unified agent registry (defaults + user), tool name resolution
478
396
  agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
479
397
  agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
480
- cross-extension-rpc.ts # RPC handlers for cross-extension spawn/ping via pi.events
481
- group-join.ts # Group join manager: batched completion notifications with timeout
482
398
  custom-agents.ts # Load user-defined agents from .pi/agents/*.md
483
399
  memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
484
400
  skill-loader.ts # Preload skills (Pi-standard + Agent Skills spec layouts)
@@ -0,0 +1,163 @@
1
+ ---
2
+ issue: 49
3
+ issue_title: "feat: remove group-join, output-file, and ad-hoc RPC"
4
+ ---
5
+
6
+ # Remove group-join and ad-hoc RPC
7
+
8
+ ## Problem Statement
9
+
10
+ Two optional subsystems remain in the core that are either consumer concerns or replaced by the typed `SubagentsAPI` (#48):
11
+
12
+ 1. **Group join** — grouped completion notifications add batching complexity for marginal UX benefit when individual notifications are sufficient.
13
+ 2. **Ad-hoc RPC** — untyped RPC over `pi.events` with per-request reply channels is replaced by the typed `SubagentsAPI` published via `Symbol.for()`.
14
+
15
+ Removing these two subsystems reduces the core's surface area by ~220 source LOC, eliminates the join-mode settings system, and simplifies the completion callback path in `index.ts`.
16
+
17
+ The **output file** subsystem (`src/output-file.ts`) is retained — it provides valuable post-hoc debugging transcripts for subagent sessions.
18
+ A separate issue (#61) tracks porting it to Pi's official JSONL session format.
19
+
20
+ ## Goals
21
+
22
+ - Delete `src/group-join.ts` (141 LOC) and `src/cross-extension-rpc.ts` (80 LOC).
23
+ - Delete `test/cross-extension-rpc.test.ts`.
24
+ - Remove all group-join wiring from `index.ts`: `GroupJoinManager` instantiation, batch tracking (`currentBatchAgents`, `finalizeBatch`, `batchFinalizeTimer`, `batchCounter`), join-mode configuration state, and settings menu entry.
25
+ - Remove all RPC wiring from `index.ts`: `registerRpcHandlers` import, `currentCtx` capture for RPC, `unsubPing/Spawn/Stop` teardown, and the `subagents:ready` broadcast.
26
+ - Remove `JoinMode` type and `joinMode`/`groupId` fields from `types.ts`.
27
+ - Remove `NotificationDetails.others` field (no longer needed without grouping).
28
+ - Remove `defaultJoinMode` from `SubagentsSettings` and `SettingsAppliers` in `settings.ts`.
29
+ - Remove `resolveJoinMode` from `invocation-config.ts`.
30
+ - Simplify the completion callback in `index.ts` to always send an individual notification.
31
+ - Preserve existing lifecycle events (`subagents:started`, `subagents:completed`, `subagents:failed`) — they are already emitted.
32
+ - This is a **breaking change** (`feat!:`) — the join-mode setting is removed and RPC channels are no longer registered.
33
+
34
+ ## Non-Goals
35
+
36
+ - Removing the `Symbol.for("pi-subagents:manager")` global accessor — that belongs to #48 (implement SubagentsAPI), which replaces it with the typed `publishSubagentsAPI()`.
37
+ - Removing `bypassQueue` from `SpawnOptions` — it remains useful for the typed API.
38
+ - Providing migration shims for RPC consumers — none known.
39
+ - Removing or modifying `src/output-file.ts` — retained for debugging value; porting to Pi's JSONL format is tracked in #61.
40
+
41
+ ## Background
42
+
43
+ The architecture doc marks these modules as "removing."
44
+ Issue #52 (remove scheduled subagents) is already implemented and merged.
45
+ Issue #48 (implement SubagentsAPI) depends on RPC removal here since the typed API replaces the untyped RPC.
46
+
47
+ The `index.ts` file is the primary wiring layer affected.
48
+ The completion callback currently routes through `groupJoin.onAgentComplete()` and only falls through to `sendIndividualNudge()` on `'pass'`.
49
+ After this change, the callback always calls `sendIndividualNudge()` directly, which simplifies the control flow from ~90 lines of group/batch logic down to a single function call.
50
+
51
+ The `currentCtx` variable captured in `session_start` exists solely for the RPC spawn handler.
52
+ After RPC removal, session lifecycle hooks simplify: `session_start` only needs `manager.clearCompleted()`.
53
+
54
+ ## Design Overview
55
+
56
+ This is a pure deletion change with minor simplification of the remaining completion path.
57
+
58
+ After removal, the background-agent completion flow becomes:
59
+
60
+ 1. `AgentManager` calls `onComplete(record)`.
61
+ 2. `onComplete` emits `subagents:completed` or `subagents:failed` on `pi.events`.
62
+ 3. `onComplete` persists the record via `pi.appendEntry`.
63
+ 4. If `record.resultConsumed`, clean up widget and return.
64
+ 5. Otherwise, call `sendIndividualNudge(record)` — which schedules the notification with a 200ms debounce window (retained for `get_subagent_result` cancellation).
65
+
66
+ The `NotificationDetails` interface stays (individual notifications still use it) but loses the `others` field.
67
+ The `outputFile` field on `NotificationDetails` stays since output-file is retained.
68
+
69
+ ### Settings changes
70
+
71
+ `SubagentsSettings` loses `defaultJoinMode`.
72
+ The settings menu loses the "Join mode" entry.
73
+ `snapshotSettings()` and `persistToastFor()` patterns are unchanged — they just carry one fewer field.
74
+
75
+ ### Types changes
76
+
77
+ ```typescript
78
+ // Remove from types.ts:
79
+ export type JoinMode = 'async' | 'group' | 'smart';
80
+
81
+ // Remove from AgentRecord:
82
+ groupId?: string;
83
+ joinMode?: JoinMode;
84
+
85
+ // Remove from NotificationDetails:
86
+ others?: NotificationDetails[];
87
+ ```
88
+
89
+ `AgentRecord.outputFile`, `outputCleanup`, and `toolCallId` are retained — they support the output-file subsystem which remains in scope.
90
+
91
+ ## Module-Level Changes
92
+
93
+ ### Delete
94
+
95
+ | File | Lines |
96
+ | ---------------------------------- | ----- |
97
+ | `src/group-join.ts` | 141 |
98
+ | `src/cross-extension-rpc.ts` | 80 |
99
+ | `test/cross-extension-rpc.test.ts` | ~220 |
100
+
101
+ ### Modify
102
+
103
+ | File | Change |
104
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
105
+ | `src/index.ts` | Remove imports for group-join and RPC modules. Remove `GroupJoinManager` instantiation and the grouped-delivery callback (~30 lines). Remove batch tracking state and `finalizeBatch()` (~40 lines). Remove join-mode state (`defaultJoinMode`, `getDefaultJoinMode`, `setDefaultJoinMode`). Remove RPC registration, `currentCtx` capture, `unsubPing/Spawn/Stop` teardown. Remove `subagents:ready` emit. Remove "Join mode" settings menu entry and `snapshotSettings` join-mode field. Simplify `onComplete` callback to always call `sendIndividualNudge`. Remove the `currentBatchAgents` deferred-notification check. Remove join-mode resolution in background spawn path. |
106
+ | `src/types.ts` | Remove `JoinMode` type. Remove `groupId` and `joinMode` from `AgentRecord`. Remove `others` from `NotificationDetails`. |
107
+ | `src/settings.ts` | Remove `JoinMode` import. Remove `defaultJoinMode` from `SubagentsSettings`. Remove `setDefaultJoinMode` from `SettingsAppliers`. Remove `VALID_JOIN_MODES`. Remove sanitize clause and `applySettings` clause for `defaultJoinMode`. |
108
+ | `src/invocation-config.ts` | Remove `JoinMode` import. Remove `resolveJoinMode` export. |
109
+ | `README.md` | Remove "Cross-extension RPC" section. Remove join-mode documentation. Remove `subagents:ready` from events table. Update settings persistence paragraph to drop join-mode. |
110
+ | `.pi/skills/package-pi-subagents/SKILL.md` | Remove `cross-extension-rpc.ts` and `group-join.ts` from architecture diagram and module tables. Update `index.ts` description. |
111
+
112
+ ## Test Impact Analysis
113
+
114
+ 1. No new unit tests are needed — this is pure deletion.
115
+ 2. `test/cross-extension-rpc.test.ts` becomes entirely redundant and is deleted.
116
+ 3. `test/output-file.test.ts` is retained (output-file stays).
117
+ 4. There is no dedicated `group-join.test.ts` — the group-join logic was only tested indirectly through integration.
118
+ 5. Existing tests for `agent-manager`, `agent-runner`, `settings`, and `invocation-config` must be checked for references to `joinMode`, `groupId`, `defaultJoinMode`, or `resolveJoinMode` — any such references need updating.
119
+ 6. The notification renderer test (if any) may reference `others` on `NotificationDetails` — check and remove.
120
+
121
+ ## TDD Order
122
+
123
+ Since this is a removal (not a feature), the order is deletion-first with validation passes.
124
+
125
+ 1. **Delete source files for both subsystems.**
126
+ Delete `src/group-join.ts`, `src/cross-extension-rpc.ts`.
127
+ Delete `test/cross-extension-rpc.test.ts`.
128
+ Commit: `feat!: remove group-join and cross-extension-rpc source`
129
+
130
+ 2. **Remove RPC wiring from `index.ts`.**
131
+ Remove `registerRpcHandlers` import and call. Remove `currentCtx` state and RPC-related `session_start`/`session_shutdown` logic (keep `manager.clearCompleted()` call). Remove `unsubPing/Spawn/Stop` teardown. Remove `subagents:ready` emit.
132
+ Commit: `feat!: remove RPC wiring from index.ts`
133
+
134
+ 3. **Remove group-join wiring from `index.ts`.**
135
+ Remove `GroupJoinManager` import and instantiation (including the grouped-delivery callback). Remove batch tracking (`currentBatchAgents`, `batchFinalizeTimer`, `batchCounter`, `finalizeBatch`). Remove `defaultJoinMode` state, `getDefaultJoinMode`, `setDefaultJoinMode`. Remove join-mode resolution in background spawn path. Remove "Join mode" settings menu entry. Remove `defaultJoinMode` from `snapshotSettings()`. Simplify the `onComplete` callback: remove `currentBatchAgents` check and `groupJoin.onAgentComplete()` routing — always call `sendIndividualNudge(record)`. Remove `setDefaultJoinMode` from `applyAndEmitLoaded` appliers.
136
+ Commit: `feat!: remove group-join wiring from index.ts`
137
+
138
+ 4. **Clean up types, settings, and invocation-config.**
139
+ Remove `JoinMode` type from `types.ts`. Remove `groupId` and `joinMode` from `AgentRecord`. Remove `others` from `NotificationDetails`. Remove `defaultJoinMode` from `SubagentsSettings` and `SettingsAppliers` in `settings.ts`. Remove `VALID_JOIN_MODES` and sanitize/apply clauses. Remove `resolveJoinMode` and `JoinMode` import from `invocation-config.ts`.
140
+ Commit: `feat!: remove join-mode types and settings`
141
+
142
+ 5. **Verify all tests pass and fix straggling references.**
143
+ Run `pnpm vitest run` and `pnpm run check`. Fix any test fixtures or assertions that reference removed fields (`joinMode`, `groupId`, `defaultJoinMode`, `resolveJoinMode`).
144
+ Commit (if fixes needed): `test: remove references to deleted subsystems from test fixtures`
145
+
146
+ 6. **Update documentation.**
147
+ Update `README.md`: remove "Cross-extension RPC" section, join-mode documentation, `subagents:ready` event row. Update settings persistence paragraph.
148
+ Update `.pi/skills/package-pi-subagents/SKILL.md`: remove `cross-extension-rpc.ts` and `group-join.ts` from architecture diagram and module tables, update `index.ts` description.
149
+ Commit: `docs: remove group-join and RPC from README and AGENTS`
150
+
151
+ ## Risks and Mitigations
152
+
153
+ | Risk | Mitigation |
154
+ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
155
+ | Missed references cause compile errors | `grep -rn 'group.join\|GroupJoin\|registerRpcHandlers\|resolveJoinMode\|JoinMode' src/` after each step. `pnpm run check` catches import errors. |
156
+ | Test fixtures reference removed fields | Step 5 explicitly scans for and fixes these. TypeScript `noEmit` check catches type mismatches. |
157
+ | `Symbol.for("pi-subagents:manager")` accidentally removed | Explicitly out of scope — this belongs to #48. The global accessor stays until the typed API replaces it. |
158
+ | Breaking change not communicated | `feat!:` commit prefix triggers a major version bump via release-please. |
159
+ | Settings file with `defaultJoinMode` on disk | `sanitize()` already drops unknown fields silently — once the field is removed from the interface, existing files just have an inert JSON key that is ignored on load. |
160
+
161
+ ## Open Questions
162
+
163
+ None — the issue scope is fully specified and the architecture doc ratified these removals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "A pi extension that brings Claude Code-style autonomous sub-agents to pi. Friendly fork of @tintinweb/pi-subagents.",
5
5
  "author": {
6
6
  "name": "Chris Lasher"
package/src/index.ts CHANGED
@@ -12,20 +12,18 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, getAgentDir } from "@earendil-works/pi-coding-agent";
16
16
  import { Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
18
  import { AgentManager } from "./agent-manager.js";
19
19
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
20
20
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
21
- import { registerRpcHandlers } from "./cross-extension-rpc.js";
22
21
  import { loadCustomAgents } from "./custom-agents.js";
23
- import { GroupJoinManager } from "./group-join.js";
24
- import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
22
+ import { resolveAgentInvocationConfig } from "./invocation-config.js";
25
23
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
26
24
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
27
25
  import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
28
- import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
26
+ import { type AgentConfig, type AgentInvocation, type AgentRecord, type NotificationDetails, type SubagentType } from "./types.js";
29
27
  import {
30
28
  type AgentActivity,
31
29
  type AgentDetails,
@@ -246,8 +244,7 @@ export default function (pi: ExtensionAPI) {
246
244
  return line;
247
245
  }
248
246
 
249
- const all = [d, ...(d.others ?? [])];
250
- return new Text(all.map(renderOne).join("\n"), 0, 0);
247
+ return new Text(renderOne(d), 0, 0);
251
248
  }
252
249
  );
253
250
 
@@ -307,40 +304,6 @@ export default function (pi: ExtensionAPI) {
307
304
  widget.update();
308
305
  }
309
306
 
310
- // ---- Group join manager ----
311
- const groupJoin = new GroupJoinManager(
312
- (records, partial) => {
313
- for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); }
314
-
315
- const groupKey = `group:${records.map(r => r.id).join(",")}`;
316
- scheduleNudge(groupKey, () => {
317
- // Re-check at send time
318
- const unconsumed = records.filter(r => !r.resultConsumed);
319
- if (unconsumed.length === 0) { widget.update(); return; }
320
-
321
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
322
- const label = partial
323
- ? `${unconsumed.length} agent(s) finished (partial — others still running)`
324
- : `${unconsumed.length} agent(s) finished`;
325
-
326
- const [first, ...rest] = unconsumed;
327
- const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
328
- if (rest.length > 0) {
329
- details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
330
- }
331
-
332
- pi.sendMessage<NotificationDetails>({
333
- customType: "subagent-notification",
334
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
335
- display: true,
336
- details,
337
- }, { deliverAs: "followUp", triggerTurn: true });
338
- });
339
- widget.update();
340
- },
341
- 30_000,
342
- );
343
-
344
307
  /** Helper: build event data for lifecycle events from an AgentRecord. */
345
308
  function buildEventData(record: AgentRecord) {
346
309
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
@@ -366,7 +329,7 @@ export default function (pi: ExtensionAPI) {
366
329
  };
367
330
  }
368
331
 
369
- // Background completion: route through group join or send individual nudge
332
+ // Background completion: emit lifecycle event and send individual nudge
370
333
  const manager = new AgentManager((record) => {
371
334
  // Emit lifecycle event based on terminal status
372
335
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
@@ -392,19 +355,7 @@ export default function (pi: ExtensionAPI) {
392
355
  return;
393
356
  }
394
357
 
395
- // If this agent is pending batch finalization (debounce window still open),
396
- // don't send an individual nudge — finalizeBatch will pick it up retroactively.
397
- if (currentBatchAgents.some(a => a.id === record.id)) {
398
- widget.update();
399
- return;
400
- }
401
-
402
- const result = groupJoin.onAgentComplete(record);
403
- if (result === 'pass') {
404
- sendIndividualNudge(record);
405
- }
406
- // 'held' → do nothing, group will fire later
407
- // 'delivered' → group callback already fired
358
+ sendIndividualNudge(record);
408
359
  widget.update();
409
360
  }, undefined, (record) => {
410
361
  // Emit started event when agent transitions to running (including from queue)
@@ -436,12 +387,7 @@ export default function (pi: ExtensionAPI) {
436
387
  getRecord: (id: string) => manager.getRecord(id),
437
388
  };
438
389
 
439
- // --- Cross-extension RPC via pi.events ---
440
- let currentCtx: ExtensionContext | undefined;
441
-
442
- // Capture ctx from session_start for RPC spawn handler.
443
- pi.on("session_start", async (_event, ctx) => {
444
- currentCtx = ctx;
390
+ pi.on("session_start", async (_event, _ctx) => {
445
391
  manager.clearCompleted();
446
392
  });
447
393
 
@@ -449,23 +395,9 @@ export default function (pi: ExtensionAPI) {
449
395
  manager.clearCompleted();
450
396
  });
451
397
 
452
- const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
453
- events: pi.events,
454
- pi,
455
- getCtx: () => currentCtx,
456
- manager,
457
- });
458
-
459
- // Broadcast readiness so extensions loaded after us can discover us
460
- pi.events.emit("subagents:ready", {});
461
-
462
398
  // On shutdown, abort all agents immediately and clean up.
463
399
  // If the session is going down, there's nothing left to consume agent results.
464
400
  pi.on("session_shutdown", async () => {
465
- unsubSpawnRpc();
466
- unsubStopRpc();
467
- unsubPingRpc();
468
- currentCtx = undefined;
469
401
  delete (globalThis as any)[MANAGER_KEY];
470
402
  manager.abortAll();
471
403
  for (const timer of pendingNudges.values()) clearTimeout(timer);
@@ -476,55 +408,6 @@ export default function (pi: ExtensionAPI) {
476
408
  // Live widget: show running agents above editor
477
409
  const widget = new AgentWidget(manager, agentActivity);
478
410
 
479
- // ---- Join mode configuration ----
480
- let defaultJoinMode: JoinMode = 'smart';
481
- function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
482
- function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
483
-
484
-
485
- // ---- Batch tracking for smart join mode ----
486
- // Collects background agent IDs spawned in the current turn for smart grouping.
487
- // Uses a debounced timer: each new agent resets the 100ms window so that all
488
- // parallel tool calls (which may be dispatched across multiple microtasks by the
489
- // framework) are captured in the same batch.
490
- let currentBatchAgents: { id: string; joinMode: JoinMode }[] = [];
491
- let batchFinalizeTimer: ReturnType<typeof setTimeout> | undefined;
492
- let batchCounter = 0;
493
-
494
- /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
495
- function finalizeBatch() {
496
- batchFinalizeTimer = undefined;
497
- const batchAgents = [...currentBatchAgents];
498
- currentBatchAgents = [];
499
-
500
- const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
501
- if (smartAgents.length >= 2) {
502
- const groupId = `batch-${++batchCounter}`;
503
- const ids = smartAgents.map(a => a.id);
504
- groupJoin.registerGroup(groupId, ids);
505
- // Retroactively process agents that already completed during the debounce window.
506
- // Their onComplete fired but was deferred (agent was in currentBatchAgents),
507
- // so we feed them into the group now.
508
- for (const id of ids) {
509
- const record = manager.getRecord(id);
510
- if (!record) continue;
511
- record.groupId = groupId;
512
- if (record.completedAt != null && !record.resultConsumed) {
513
- groupJoin.onAgentComplete(record);
514
- }
515
- }
516
- } else {
517
- // No group formed — send individual nudges for any agents that completed
518
- // during the debounce window and had their notification deferred.
519
- for (const { id } of batchAgents) {
520
- const record = manager.getRecord(id);
521
- if (record?.completedAt != null && !record.resultConsumed) {
522
- sendIndividualNudge(record);
523
- }
524
- }
525
- }
526
- }
527
-
528
411
  // Grab UI context from first tool execution + clear lingering widget on new turn
529
412
  pi.on("tool_execution_start", async (_event, ctx) => {
530
413
  widget.setUICtx(ctx.ui as UICtx);
@@ -574,7 +457,6 @@ export default function (pi: ExtensionAPI) {
574
457
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
575
458
  setDefaultMaxTurns,
576
459
  setGraceTurns,
577
- setDefaultJoinMode,
578
460
  },
579
461
  (event, payload) => pi.events.emit(event, payload),
580
462
  );
@@ -870,28 +752,15 @@ Guidelines:
870
752
  return textResult(err instanceof Error ? err.message : String(err));
871
753
  }
872
754
 
873
- // Set output file + join mode synchronously after spawn, before the
755
+ // Set output file synchronously after spawn, before the
874
756
  // event loop yields — onSessionCreated is async so this is safe.
875
- const joinMode = resolveJoinMode(defaultJoinMode, true);
876
757
  const record = manager.getRecord(id);
877
- if (record && joinMode) {
878
- record.joinMode = joinMode;
758
+ if (record) {
879
759
  record.toolCallId = toolCallId;
880
760
  record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
881
761
  writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
882
762
  }
883
763
 
884
- if (joinMode == null || joinMode === 'async') {
885
- // Foreground/no join mode or explicit async — not part of any batch
886
- } else {
887
- // smart or group — add to current batch
888
- currentBatchAgents.push({ id, joinMode });
889
- // Debounce: reset timer on each new agent so parallel tool calls
890
- // dispatched across multiple event loop ticks are captured together
891
- if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
892
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
893
- }
894
-
895
764
  agentActivity.set(id, bgState);
896
765
  widget.ensureTimer();
897
766
  widget.update();
@@ -1678,7 +1547,6 @@ ${systemPrompt}
1678
1547
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
1679
1548
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
1680
1549
  graceTurns: getGraceTurns(),
1681
- defaultJoinMode: getDefaultJoinMode(),
1682
1550
  };
1683
1551
  }
1684
1552
 
@@ -1687,7 +1555,6 @@ ${systemPrompt}
1687
1555
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1688
1556
  `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
1689
1557
  `Grace turns (current: ${getGraceTurns()})`,
1690
- `Join mode (current: ${getDefaultJoinMode()})`,
1691
1558
  ]);
1692
1559
  if (!choice) return;
1693
1560
 
@@ -1727,17 +1594,6 @@ ${systemPrompt}
1727
1594
  ctx.ui.notify("Must be a positive integer.", "warning");
1728
1595
  }
1729
1596
  }
1730
- } else if (choice.startsWith("Join mode")) {
1731
- const val = await ctx.ui.select("Default join mode for background agents", [
1732
- "smart — auto-group 2+ agents in same turn (default)",
1733
- "async — always notify individually",
1734
- "group — always group background agents",
1735
- ]);
1736
- if (val) {
1737
- const mode = val.split(" ")[0] as JoinMode;
1738
- setDefaultJoinMode(mode);
1739
- notifyApplied(ctx, `Default join mode set to ${mode}`);
1740
- }
1741
1597
  }
1742
1598
  }
1743
1599
 
@@ -1,4 +1,4 @@
1
- import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
1
+ import type { AgentConfig, IsolationMode, ThinkingLevel } from "./types.js";
2
2
 
3
3
  interface AgentInvocationParams {
4
4
  model?: string;
@@ -34,7 +34,3 @@ export function resolveAgentInvocationConfig(
34
34
  isolation: agentConfig?.isolation ?? params.isolation,
35
35
  };
36
36
  }
37
-
38
- export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
39
- return runInBackground ? defaultJoinMode : undefined;
40
- }
package/src/settings.ts CHANGED
@@ -5,8 +5,6 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
- import type { JoinMode } from "./types.js";
9
-
10
8
  export interface SubagentsSettings {
11
9
  maxConcurrent?: number;
12
10
  /**
@@ -16,7 +14,6 @@ export interface SubagentsSettings {
16
14
  */
17
15
  defaultMaxTurns?: number;
18
16
  graceTurns?: number;
19
- defaultJoinMode?: JoinMode;
20
17
  }
21
18
 
22
19
  /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
@@ -24,14 +21,11 @@ export interface SettingsAppliers {
24
21
  setMaxConcurrent: (n: number) => void;
25
22
  setDefaultMaxTurns: (n: number) => void;
26
23
  setGraceTurns: (n: number) => void;
27
- setDefaultJoinMode: (mode: JoinMode) => void;
28
24
  }
29
25
 
30
26
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
31
27
  export type SettingsEmit = (event: string, payload: unknown) => void;
32
28
 
33
- const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
34
-
35
29
  // Sanity ceilings — prevent hand-edited configs from asking for values that
36
30
  // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
37
31
  // that any realistic power-user setting passes through.
@@ -65,9 +59,6 @@ function sanitize(raw: unknown): SubagentsSettings {
65
59
  ) {
66
60
  out.graceTurns = r.graceTurns as number;
67
61
  }
68
- if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
69
- out.defaultJoinMode = r.defaultJoinMode as JoinMode;
70
- }
71
62
  return out;
72
63
  }
73
64
 
@@ -121,7 +112,6 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
121
112
  if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
122
113
  if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
123
114
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
124
- if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
125
115
  }
126
116
 
127
117
  /**
package/src/types.ts CHANGED
@@ -55,8 +55,6 @@ export interface AgentConfig {
55
55
  source?: "default" | "project" | "global";
56
56
  }
57
57
 
58
- export type JoinMode = 'async' | 'group' | 'smart';
59
-
60
58
  export interface AgentRecord {
61
59
  id: string;
62
60
  type: SubagentType;
@@ -70,8 +68,6 @@ export interface AgentRecord {
70
68
  session?: AgentSession;
71
69
  abortController?: AbortController;
72
70
  promise?: Promise<string>;
73
- groupId?: string;
74
- joinMode?: JoinMode;
75
71
  /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
76
72
  resultConsumed?: boolean;
77
73
  /** Steering messages queued before the session was ready. */
@@ -122,8 +118,7 @@ export interface NotificationDetails {
122
118
  outputFile?: string;
123
119
  error?: string;
124
120
  resultPreview: string;
125
- /** Additional agents in a group notification. */
126
- others?: NotificationDetails[];
121
+
127
122
  }
128
123
 
129
124
  export interface EnvInfo {
@@ -1,95 +0,0 @@
1
- /**
2
- * Cross-extension RPC handlers for the subagents extension.
3
- *
4
- * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
- * using per-request scoped reply channels.
6
- *
7
- * Reply envelope follows pi-mono convention:
8
- * success → { success: true, data?: T }
9
- * error → { success: false, error: string }
10
- */
11
-
12
- /** Minimal event bus interface needed by the RPC handlers. */
13
- export interface EventBus {
14
- on(event: string, handler: (data: unknown) => void): () => void;
15
- emit(event: string, data: unknown): void;
16
- }
17
-
18
- /** RPC reply envelope — matches pi-mono's RpcResponse shape. */
19
- export type RpcReply<T = void> =
20
- | { success: true; data?: T }
21
- | { success: false; error: string };
22
-
23
- /** RPC protocol version — bumped when the envelope or method contracts change. */
24
- export const PROTOCOL_VERSION = 2;
25
-
26
- /** Minimal AgentManager interface needed by the spawn/stop RPCs. */
27
- export interface SpawnCapable {
28
- spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
29
- abort(id: string): boolean;
30
- }
31
-
32
- export interface RpcDeps {
33
- events: EventBus;
34
- pi: unknown; // passed through to manager.spawn
35
- getCtx: () => unknown | undefined; // returns current ExtensionContext
36
- manager: SpawnCapable;
37
- }
38
-
39
- export interface RpcHandle {
40
- unsubPing: () => void;
41
- unsubSpawn: () => void;
42
- unsubStop: () => void;
43
- }
44
-
45
- /**
46
- * Wire a single RPC handler: listen on `channel`, run `fn(params)`,
47
- * emit the reply envelope on `channel:reply:${requestId}`.
48
- */
49
- function handleRpc<P extends { requestId: string }>(
50
- events: EventBus,
51
- channel: string,
52
- fn: (params: P) => unknown | Promise<unknown>,
53
- ): () => void {
54
- return events.on(channel, async (raw: unknown) => {
55
- const params = raw as P;
56
- try {
57
- const data = await fn(params);
58
- const reply: { success: true; data?: unknown } = { success: true };
59
- if (data !== undefined) reply.data = data;
60
- events.emit(`${channel}:reply:${params.requestId}`, reply);
61
- } catch (err: any) {
62
- events.emit(`${channel}:reply:${params.requestId}`, {
63
- success: false, error: err?.message ?? String(err),
64
- });
65
- }
66
- });
67
- }
68
-
69
- /**
70
- * Register ping, spawn, and stop RPC handlers on the event bus.
71
- * Returns unsub functions for cleanup.
72
- */
73
- export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
74
- const { events, pi, getCtx, manager } = deps;
75
-
76
- const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
77
- return { version: PROTOCOL_VERSION };
78
- });
79
-
80
- const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
81
- events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
82
- const ctx = getCtx();
83
- if (!ctx) throw new Error("No active session");
84
- return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
85
- },
86
- );
87
-
88
- const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
89
- events, "subagents:rpc:stop", ({ agentId }) => {
90
- if (!manager.abort(agentId)) throw new Error("Agent not found");
91
- },
92
- );
93
-
94
- return { unsubPing, unsubSpawn, unsubStop };
95
- }
package/src/group-join.ts DELETED
@@ -1,141 +0,0 @@
1
- /**
2
- * group-join.ts — Manages grouped background agent completion notifications.
3
- *
4
- * Instead of each agent individually nudging the main agent on completion,
5
- * agents in a group are held until all complete (or a timeout fires),
6
- * then a single consolidated notification is sent.
7
- */
8
-
9
- import type { AgentRecord } from "./types.js";
10
-
11
- export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
12
-
13
- interface AgentGroup {
14
- groupId: string;
15
- agentIds: Set<string>;
16
- completedRecords: Map<string, AgentRecord>;
17
- timeoutHandle?: ReturnType<typeof setTimeout>;
18
- delivered: boolean;
19
- /** Shorter timeout for stragglers after a partial delivery. */
20
- isStraggler: boolean;
21
- }
22
-
23
- /** Default timeout: 30s after first completion in a group. */
24
- const DEFAULT_TIMEOUT = 30_000;
25
- /** Straggler re-batch timeout: 15s. */
26
- const STRAGGLER_TIMEOUT = 15_000;
27
-
28
- export class GroupJoinManager {
29
- private groups = new Map<string, AgentGroup>();
30
- private agentToGroup = new Map<string, string>();
31
-
32
- constructor(
33
- private deliverCb: DeliveryCallback,
34
- private groupTimeout = DEFAULT_TIMEOUT,
35
- ) {}
36
-
37
- /** Register a group of agent IDs that should be joined. */
38
- registerGroup(groupId: string, agentIds: string[]): void {
39
- const group: AgentGroup = {
40
- groupId,
41
- agentIds: new Set(agentIds),
42
- completedRecords: new Map(),
43
- delivered: false,
44
- isStraggler: false,
45
- };
46
- this.groups.set(groupId, group);
47
- for (const id of agentIds) {
48
- this.agentToGroup.set(id, groupId);
49
- }
50
- }
51
-
52
- /**
53
- * Called when an agent completes.
54
- * Returns:
55
- * - 'pass' — agent is not grouped, caller should send individual nudge
56
- * - 'held' — result held, waiting for group completion
57
- * - 'delivered' — this completion triggered the group notification
58
- */
59
- onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
60
- const groupId = this.agentToGroup.get(record.id);
61
- if (!groupId) return 'pass';
62
-
63
- const group = this.groups.get(groupId);
64
- if (!group || group.delivered) return 'pass';
65
-
66
- group.completedRecords.set(record.id, record);
67
-
68
- // All done — deliver immediately
69
- if (group.completedRecords.size >= group.agentIds.size) {
70
- this.deliver(group, false);
71
- return 'delivered';
72
- }
73
-
74
- // First completion in this batch — start timeout
75
- if (!group.timeoutHandle) {
76
- const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
77
- group.timeoutHandle = setTimeout(() => {
78
- this.onTimeout(group);
79
- }, timeout);
80
- }
81
-
82
- return 'held';
83
- }
84
-
85
- private onTimeout(group: AgentGroup): void {
86
- if (group.delivered) return;
87
- group.timeoutHandle = undefined;
88
-
89
- // Partial delivery — some agents still running
90
- const remaining = new Set<string>();
91
- for (const id of group.agentIds) {
92
- if (!group.completedRecords.has(id)) remaining.add(id);
93
- }
94
-
95
- // Clean up agentToGroup for delivered agents (they won't complete again)
96
- for (const id of group.completedRecords.keys()) {
97
- this.agentToGroup.delete(id);
98
- }
99
-
100
- // Deliver what we have
101
- this.deliverCb([...group.completedRecords.values()], true);
102
-
103
- // Set up straggler group for remaining agents
104
- group.completedRecords.clear();
105
- group.agentIds = remaining;
106
- group.isStraggler = true;
107
- // Timeout will be started when the next straggler completes
108
- }
109
-
110
- private deliver(group: AgentGroup, partial: boolean): void {
111
- if (group.timeoutHandle) {
112
- clearTimeout(group.timeoutHandle);
113
- group.timeoutHandle = undefined;
114
- }
115
- group.delivered = true;
116
- this.deliverCb([...group.completedRecords.values()], partial);
117
- this.cleanupGroup(group.groupId);
118
- }
119
-
120
- private cleanupGroup(groupId: string): void {
121
- const group = this.groups.get(groupId);
122
- if (!group) return;
123
- for (const id of group.agentIds) {
124
- this.agentToGroup.delete(id);
125
- }
126
- this.groups.delete(groupId);
127
- }
128
-
129
- /** Check if an agent is in a group. */
130
- isGrouped(agentId: string): boolean {
131
- return this.agentToGroup.has(agentId);
132
- }
133
-
134
- dispose(): void {
135
- for (const group of this.groups.values()) {
136
- if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
137
- }
138
- this.groups.clear();
139
- this.agentToGroup.clear();
140
- }
141
- }