@gotgenes/pi-subagents 2.0.0 → 4.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,53 @@ 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
+ ## [4.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v3.0.0...pi-subagents-v4.0.0) (2026-05-17)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * The untyped globalThis[Symbol.for("pi-subagents:manager")] accessor is removed. Use getSubagentsService() from the package's public exports instead.
14
+ * The public API surface is now exported from src/service.ts. The old untyped Symbol.for("pi-subagents:manager") global will be removed in a subsequent commit.
15
+
16
+ ### Features
17
+
18
+ * add SubagentRecord serializer ([d7afb45](https://github.com/gotgenes/pi-packages/commit/d7afb4569c9e28ce5d4bf7fb1ac560b0bcbb7c90))
19
+ * add SubagentsService types and accessor functions ([468623c](https://github.com/gotgenes/pi-packages/commit/468623c936f45cc30d3c5dde134cc2d21da4a0c4))
20
+ * expose public service entry point via package exports ([0dbeaaf](https://github.com/gotgenes/pi-packages/commit/0dbeaaf39c79717df8cabf59e8ba53652f9bc7af))
21
+ * implement getRecord and listAgents on SubagentsService adapter ([a6da473](https://github.com/gotgenes/pi-packages/commit/a6da47393f6faa3fef93bd065c1ad1a0613d1636))
22
+ * implement spawn with model resolution on SubagentsService adapter ([fd70d82](https://github.com/gotgenes/pi-packages/commit/fd70d828905bc3415fa8b8aebfe4c2a5355209cb))
23
+ * implement steer, abort, waitForAll, hasRunning on adapter ([00f0b99](https://github.com/gotgenes/pi-packages/commit/00f0b99ea978625798ba67a40b375e42006d33e4))
24
+ * publish SubagentsService at extension init, remove old untyped global ([6047e2b](https://github.com/gotgenes/pi-packages/commit/6047e2bbbaf87b5e28325b084b09daf2b0c9b6b9))
25
+
26
+
27
+ ### Documentation
28
+
29
+ * plan SubagentsService implementation ([#48](https://github.com/gotgenes/pi-packages/issues/48)) ([6bd2af8](https://github.com/gotgenes/pi-packages/commit/6bd2af862fb7e7f429617c154391c800b50c5d86))
30
+ * **retro:** add retro notes for issue [#49](https://github.com/gotgenes/pi-packages/issues/49) ([69a5bfc](https://github.com/gotgenes/pi-packages/commit/69a5bfc94edfc445d46fb495449649998614f86d))
31
+
32
+ ## [3.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v2.0.0...pi-subagents-v3.0.0) (2026-05-17)
33
+
34
+
35
+ ### ⚠ BREAKING CHANGES
36
+
37
+ * The JoinMode type and defaultJoinMode setting are removed from the public settings interface.
38
+ * The join-mode setting (smart/async/group) is removed. Background agents always notify individually on completion.
39
+ * 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.
40
+ * 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.
41
+
42
+ ### Features
43
+
44
+ * remove group-join and cross-extension-rpc source ([b7d7f21](https://github.com/gotgenes/pi-packages/commit/b7d7f21af265e2ff95f0534f5c0b51f71b8f1e7f))
45
+ * remove group-join wiring from index.ts ([4e2dc7f](https://github.com/gotgenes/pi-packages/commit/4e2dc7f8a98e441308f229745dfc42d09784d786))
46
+ * remove join-mode types and settings ([1d98793](https://github.com/gotgenes/pi-packages/commit/1d98793eb85aa3d9815274aa443c34bb4434b6f9))
47
+ * remove RPC wiring from index.ts ([3a960af](https://github.com/gotgenes/pi-packages/commit/3a960af8f6bb83219497d6229d12c6859cf3eb71))
48
+
49
+
50
+ ### Documentation
51
+
52
+ * 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))
53
+ * remove group-join and RPC from README and AGENTS ([9f65f7a](https://github.com/gotgenes/pi-packages/commit/9f65f7a21611af8dead00a5fb34d7d10f3d6ab43))
54
+
8
55
  ## [2.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v1.0.2...pi-subagents-v2.0.0) (2026-05-17)
9
56
 
10
57
 
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,303 @@
1
+ ---
2
+ issue: 48
3
+ issue_title: "feat: implement and publish SubagentsAPI at extension init"
4
+ ---
5
+
6
+ # Implement and publish SubagentsService
7
+
8
+ ## Problem Statement
9
+
10
+ The package currently exposes an untyped, undocumented manager via `Symbol.for("pi-subagents:manager")` on `globalThis`.
11
+ This forces consumers to guess the API shape, lacks model resolution at the boundary (causing "No API key found for undefined" crashes when consumers pass string model names), and leaks non-serializable internals (`AgentSession`, `AbortController`) in returned records.
12
+
13
+ The architecture doc specifies a typed interface with `Symbol.for()` accessor functions that other extensions import as an optional peer dependency.
14
+ This issue implements that boundary, following the naming and structural conventions established by `pi-permission-system`.
15
+
16
+ ## Goals
17
+
18
+ - Export `SubagentsService` interface, `SubagentRecord`, `SubagentStatus`, `SpawnOptions`, `LifetimeUsage`, accessor functions (`publishSubagentsService`, `getSubagentsService`), and event constants from the package's public entry point.
19
+ - Create `src/service-adapter.ts` — an adapter wrapping `AgentManager` to satisfy `SubagentsService`, handling string model resolution and record serialization.
20
+ - Call `publishSubagentsService()` at extension init; clean up on `session_shutdown`.
21
+ - Remove the old `Symbol.for("pi-subagents:manager")` global key.
22
+ - This is a **breaking change** (`feat!:`) — the old untyped global key is removed and replaced with the typed service under a new key.
23
+ - Follow the naming and structural conventions established by `pi-permission-system` (`service.ts`, `@gotgenes/<pkg>:service` key, `Record<symbol, unknown>` cast).
24
+
25
+ ## Non-Goals
26
+
27
+ - Consumer extensions (scheduling, transcript) — these are separate packages.
28
+ - Native Pi service registry integration (`pi.registerService()`) — deferred to a future Pi SDK release.
29
+ - `SubagentsService.resume()` — not part of the initial interface per the architecture doc.
30
+ - Output-file JSONL format migration (#61).
31
+
32
+ ## Background
33
+
34
+ ### Prerequisite issues
35
+
36
+ - #49 (remove group-join and RPC) — **closed/merged**. The untyped RPC channels are already gone.
37
+ - #52 (remove scheduled subagents) — **closed/merged**.
38
+ - #51 (update ADR for hard fork) — **closed/merged**.
39
+
40
+ ### Relevant modules
41
+
42
+ | Module | Role in this change |
43
+ | ----------------------- | ---------------------------------------------------------------------------------------------------- |
44
+ | `src/index.ts` | Wiring layer. Currently publishes the untyped global; will call `publishSubagentsService()` instead. |
45
+ | `src/agent-manager.ts` | Core lifecycle manager. The adapter wraps its public methods. |
46
+ | `src/model-resolver.ts` | `resolveModel()` converts string → `Model`. The adapter calls this at the API boundary. |
47
+ | `src/types.ts` | Defines `AgentRecord` (internal, non-serializable). |
48
+ | `src/usage.ts` | Exports `LifetimeUsage` (already serializable). |
49
+
50
+ ### Constraints from AGENTS.md
51
+
52
+ - One concern per file — types/accessors in `src/service.ts`, adapter logic in `src/service-adapter.ts`.
53
+ - Avoid `any` unless absolutely necessary — the accessor functions use `Record<symbol, unknown>` on `globalThis`.
54
+ - Pi SDK imports stay out of business-logic modules — `service-adapter.ts` accepts `pi` and `ctx` as narrow interface parameters.
55
+ - Narrow interface types for collaborators — the adapter takes a minimal `AgentManagerLike` interface, not the concrete `AgentManager` class.
56
+
57
+ ### Alignment with pi-permission-system
58
+
59
+ This plan deliberately follows the pattern established by `@gotgenes/pi-permission-system`:
60
+
61
+ | Aspect | pi-permission-system | pi-subagents (this plan) |
62
+ | --------------- | ------------------------------------------ | --------------------------------------- |
63
+ | Public file | `src/service.ts` | `src/service.ts` |
64
+ | Interface name | `PermissionsService` | `SubagentsService` |
65
+ | Symbol.for key | `"@gotgenes/pi-permission-system:service"` | `"@gotgenes/pi-subagents:service"` |
66
+ | globalThis cast | `Record<symbol, unknown>` | `Record<symbol, unknown>` |
67
+ | Accessors | `publish/get/unpublishPermissionsService` | `publish/get/unpublishSubagentsService` |
68
+ | exports → | `./src/service.ts` | `./src/service.ts` |
69
+
70
+ The architecture doc uses `SubagentsAPI` naming and `pi:service:subagents` key; it should be updated during implementation to reflect the final naming.
71
+
72
+ ## Design Overview
73
+
74
+ ### Module decomposition
75
+
76
+ ```text
77
+ src/service.ts ← SubagentsService interface, SubagentRecord, SpawnOptions,
78
+ SubagentStatus, accessor functions, event constants
79
+ src/service-adapter.ts ← createSubagentsService() factory, record serialization,
80
+ model resolution at the boundary
81
+ src/index.ts ← wire: publishSubagentsService(createSubagentsService(...))
82
+ ```
83
+
84
+ ### Types (in `src/service.ts`)
85
+
86
+ ```typescript
87
+ export type SubagentStatus =
88
+ | "queued" | "running" | "completed" | "steered"
89
+ | "aborted" | "stopped" | "error";
90
+
91
+ export interface SubagentRecord {
92
+ id: string;
93
+ type: string;
94
+ description: string;
95
+ status: SubagentStatus;
96
+ result?: string;
97
+ error?: string;
98
+ toolUses: number;
99
+ startedAt: number;
100
+ completedAt?: number;
101
+ lifetimeUsage: LifetimeUsage;
102
+ compactionCount: number;
103
+ worktreeResult?: { hasChanges: boolean; branch?: string };
104
+ }
105
+
106
+ export interface SpawnOptions {
107
+ description?: string;
108
+ model?: string;
109
+ maxTurns?: number;
110
+ thinkingLevel?: string;
111
+ isolated?: boolean;
112
+ inheritContext?: boolean;
113
+ foreground?: boolean;
114
+ bypassQueue?: boolean;
115
+ isolation?: "worktree";
116
+ }
117
+
118
+ export interface SubagentsService {
119
+ spawn(type: string, prompt: string, options?: SpawnOptions): string;
120
+ getRecord(id: string): SubagentRecord | undefined;
121
+ listAgents(): SubagentRecord[];
122
+ abort(id: string): boolean;
123
+ steer(id: string, message: string): Promise<boolean>;
124
+ waitForAll(): Promise<void>;
125
+ hasRunning(): boolean;
126
+ }
127
+
128
+ export const SUBAGENT_EVENTS = {
129
+ STARTED: "subagents:started",
130
+ COMPLETED: "subagents:completed",
131
+ ACTIVITY: "subagents:activity",
132
+ } as const;
133
+ ```
134
+
135
+ ### Accessor pattern
136
+
137
+ ```typescript
138
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service");
139
+
140
+ export function publishSubagentsService(service: SubagentsService): void {
141
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
142
+ }
143
+
144
+ export function getSubagentsService(): SubagentsService | undefined {
145
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
146
+ | SubagentsService
147
+ | undefined;
148
+ }
149
+
150
+ export function unpublishSubagentsService(): void {
151
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
152
+ }
153
+ ```
154
+
155
+ ### Adapter (`src/service-adapter.ts`)
156
+
157
+ The adapter accepts narrow interfaces rather than concrete classes:
158
+
159
+ ```typescript
160
+ interface AgentManagerLike {
161
+ spawn(pi: any, ctx: any, type: string, prompt: string, options: any): string;
162
+ getRecord(id: string): AgentRecord | undefined;
163
+ listAgents(): AgentRecord[];
164
+ abort(id: string): boolean;
165
+ waitForAll(): Promise<void>;
166
+ hasRunning(): boolean;
167
+ }
168
+
169
+ interface AdapterDeps {
170
+ manager: AgentManagerLike;
171
+ resolveModel: (input: string, registry: ModelRegistry) => any;
172
+ getCtx: () => { pi: any; ctx: any } | undefined;
173
+ getModelRegistry: () => ModelRegistry | undefined;
174
+ }
175
+ ```
176
+
177
+ Key behaviors:
178
+
179
+ 1. **String model resolution** — `spawn()` calls `resolveModel(options.model, registry)` before delegating to the manager.
180
+ If resolution fails, throws with the error string (list of available models).
181
+ 2. **Session gating** — throws if `getCtx()` returns `undefined` (no active session).
182
+ 3. **Record serialization** — `toSubagentRecord()` strips `session`, `abortController`, `promise`, `pendingSteers`, `outputCleanup` from `AgentRecord`.
183
+ 4. **Steer delegation** — uses the same pattern as the `steer_subagent` tool: checks status, queues if session not ready, delegates to `session.steer()`.
184
+
185
+ This mirrors the `pi-permission-system` pattern: a slim `service.ts` defines the contract and accessors; a separate adapter file contains the implementation wiring.
186
+
187
+ ### Public entry point
188
+
189
+ The package currently has no explicit `exports` field in `package.json`.
190
+ Since Pi loads the extension via `pi.extensions` (pointing at `./src/index.ts`), the service types and accessors need a separate public entry point.
191
+ Add an `exports` map:
192
+
193
+ ```json
194
+ {
195
+ "exports": {
196
+ ".": "./src/service.ts"
197
+ }
198
+ }
199
+ ```
200
+
201
+ This exposes the types and accessor functions to consumers who `import("@gotgenes/pi-subagents")`.
202
+ The extension entry point (`./src/index.ts`) remains declared in `pi.extensions`.
203
+ This matches the pattern established by `pi-permission-system` (`exports` → `service.ts`, `pi.extensions` → `index.ts`).
204
+
205
+ ### Edge cases
206
+
207
+ - **No active session**: `spawn()` throws `"No active session — cannot spawn agents outside a session."`.
208
+ - **Model resolution failure**: `spawn()` throws with the error string from `resolveModel()`.
209
+ - **Missing description**: default to a truncated prompt (`prompt.slice(0, 80)`).
210
+ - **Steer on non-running agent**: returns `false`.
211
+ - **Steer before session ready**: queues the message (returns `true`).
212
+
213
+ ### Naming conventions
214
+
215
+ Following `pi-permission-system`'s established pattern:
216
+
217
+ - Public file: `service.ts` (not `api.ts`)
218
+ - Interface: `SubagentsService` (not `SubagentsAPI`)
219
+ - Symbol key: `"@gotgenes/pi-subagents:service"` (scoped package name, not generic `pi:service:*`)
220
+ - globalThis cast: `Record<symbol, unknown>` (not `any`)
221
+ - Accessor names: `publish/get/unpublishSubagentsService`
222
+
223
+ ## Module-Level Changes
224
+
225
+ ### New files
226
+
227
+ | File | Contents |
228
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
229
+ | `src/service.ts` | `SubagentsService` interface, `SubagentRecord`, `SubagentStatus`, `SpawnOptions`, `LifetimeUsage` re-export, accessor functions, event constants, `unpublishSubagentsService`. |
230
+ | `src/service-adapter.ts` | `createSubagentsService()` factory. `toSubagentRecord()` serializer. Narrow `AgentManagerLike` and `AdapterDeps` interfaces. |
231
+ | `test/service-adapter.test.ts` | Unit tests for the adapter (model resolution, serialization, session gating, steer delegation). |
232
+ | `test/service.test.ts` | Unit tests for accessor functions (publish/get/unpublish round-trip, isolation between keys). |
233
+
234
+ ### Modified files
235
+
236
+ | File | Change |
237
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
238
+ | `src/index.ts` | Import `publishSubagentsService`, `unpublishSubagentsService` from `./service.js` and `createSubagentsService` from `./service-adapter.js`. Replace `Symbol.for("pi-subagents:manager")` block with `publishSubagentsService(createSubagentsService(...))`. In `session_shutdown`, call `unpublishSubagentsService()` instead of `delete (globalThis as any)[MANAGER_KEY]`. |
239
+ | `package.json` | Add `"exports": { ".": "./src/service.ts" }`. |
240
+ | `src/usage.ts` | No change needed — `LifetimeUsage` is already exported. Re-exported from `src/service.ts`. |
241
+
242
+ ## Test Impact Analysis
243
+
244
+ 1. **New unit tests enabled**: `test/service-adapter.test.ts` tests the adapter in isolation against a mock `AgentManagerLike` — model resolution, record stripping, session gating, steer semantics.
245
+ `test/service.test.ts` tests the accessor functions (publish/get/unpublish lifecycle, `undefined` before publish).
246
+ 2. **Existing tests that become redundant**: None — the old `Symbol.for("pi-subagents:manager")` global was not unit-tested.
247
+ 3. **Existing tests that must stay**: All `agent-manager.test.ts` and `agent-runner.test.ts` tests remain — they test the internal engine, not the public service boundary.
248
+ Any test referencing `MANAGER_KEY` or `"pi-subagents:manager"` in string assertions must be updated.
249
+
250
+ ## TDD Order
251
+
252
+ 1. **`src/service.ts` — types, accessors, and event constants.**
253
+ Test: `test/service.test.ts` — `publishSubagentsService` stores on globalThis, `getSubagentsService` retrieves it, `unpublishSubagentsService` removes it, `getSubagentsService` returns `undefined` when not published.
254
+ Commit: `feat!: add SubagentsService types and accessor functions`
255
+
256
+ 2. **`src/service-adapter.ts` — `toSubagentRecord()` serializer.**
257
+ Test: `test/service-adapter.test.ts` — given an `AgentRecord` with `session`, `abortController`, `promise`, `pendingSteers`, `outputCleanup`, verify the returned `SubagentRecord` contains only serializable fields.
258
+ Commit: `feat: add SubagentRecord serializer`
259
+
260
+ 3. **`src/service-adapter.ts` — `createSubagentsService().getRecord()` and `listAgents()`.**
261
+ Test: verify `getRecord` delegates to manager and serializes; `listAgents` returns serialized records sorted by `startedAt` descending.
262
+ Commit: `feat: implement getRecord and listAgents on SubagentsService adapter`
263
+
264
+ 4. **`src/service-adapter.ts` — `spawn()` with model resolution and session gating.**
265
+ Test: (a) throws when `getCtx()` returns `undefined`; (b) resolves string model names via `resolveModel`; (c) throws on model resolution failure; (d) delegates to manager with resolved model; (e) uses truncated prompt as default description.
266
+ Commit: `feat: implement spawn with model resolution on SubagentsService adapter`
267
+
268
+ 5. **`src/service-adapter.ts` — `steer()`, `abort()`, `waitForAll()`, `hasRunning()`.**
269
+ Test: `steer` returns `false` for non-running agent, `true` when session queues or delivers; `abort`/`waitForAll`/`hasRunning` delegate to manager.
270
+ Commit: `feat: implement steer, abort, waitForAll, hasRunning on adapter`
271
+
272
+ 6. **Wire into `src/index.ts` — replace old global with typed service.**
273
+ Replace `Symbol.for("pi-subagents:manager")` block with `publishSubagentsService(createSubagentsService(...))`.
274
+ Update `session_shutdown` to call `unpublishSubagentsService()`.
275
+ Commit: `feat!: publish SubagentsService at extension init, remove old untyped global`
276
+
277
+ 7. **Add `exports` to `package.json`.**
278
+ Add `"exports": { ".": "./src/service.ts" }` so consumers can `import("@gotgenes/pi-subagents")`.
279
+ Commit: `feat: expose public service entry point via package exports`
280
+
281
+ 8. **Run full suite and type check.**
282
+ `pnpm vitest run && pnpm run check`.
283
+ Fix any straggling references to `MANAGER_KEY` or `"pi-subagents:manager"` in tests.
284
+ Commit (if fixes needed): `test: update references to old Symbol.for key`
285
+
286
+ ## Risks and Mitigations
287
+
288
+ | Risk | Mitigation |
289
+ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
290
+ | Consumers relying on the old `pi-subagents:manager` key break silently | This is a `feat!:` (major bump). No other package in this monorepo references the old key. Document migration in CHANGELOG via release-please. |
291
+ | `exports` field breaks Pi's extension loader | Pi loads via `pi.extensions` (`./src/index.ts`), which is separate from `exports`. The `exports` field only affects `import("@gotgenes/pi-subagents")` from consumer code. Same pattern as `pi-permission-system`. |
292
+ | Adapter leaks internal state if `AgentRecord` gains new non-serializable fields | `toSubagentRecord()` uses an explicit allowlist (pick pattern), not a denylist. New fields must be opted in. |
293
+ | `steer()` race condition — session created between status check and queue push | The existing tool handler has the same race window and handles it acceptably. The adapter uses the same pattern (check session → queue if absent → delegate if present). |
294
+ | `resolveModel` returns `any` — type unsafety at boundary | The adapter's `AgentManagerLike.spawn` already accepts `Model<any>` for the `options.model` field. The `any` is confined to the model-resolution seam, matching existing code. |
295
+ | Architecture doc uses different naming (`SubagentsAPI`, `pi:service:subagents`) | Open question documented below. Update the architecture doc during implementation to reflect final naming. |
296
+
297
+ ## Open Questions
298
+
299
+ - Should `SubagentsService` be augmented with an `onEvent(channel, callback)` subscription method, or is `pi.events.on(SUBAGENT_EVENTS.COMPLETED, ...)` sufficient for consumers?
300
+ Deferred — consumers already have access to `pi.events` and the event constants are exported.
301
+ - The architecture doc uses `SubagentsAPI` naming and `pi:service:subagents` key.
302
+ This plan intentionally diverges to align with the established `pi-permission-system` pattern (`*Service` naming, `@gotgenes/<pkg>:service` key, `Record<symbol, unknown>` cast).
303
+ The architecture doc should be updated during implementation to reflect the final naming.