@gotgenes/pi-subagents 3.0.0 → 4.1.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,48 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v4.0.0...pi-subagents-v4.1.0) (2026-05-18)
9
+
10
+
11
+ ### Features
12
+
13
+ * add resolveInvocationModel to model-resolver ([462b519](https://github.com/gotgenes/pi-packages/commit/462b5194fdfdf86d8d2d166a99472c651e00b76b))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * remove quotes from rumdl glob patterns in lint:md script ([a8a0c62](https://github.com/gotgenes/pi-packages/commit/a8a0c62feb2fc45cf68cd7d777259dc159de671b))
19
+
20
+
21
+ ### Documentation
22
+
23
+ * plan extract model resolution from Agent.execute ([#53](https://github.com/gotgenes/pi-packages/issues/53)) ([4c07a47](https://github.com/gotgenes/pi-packages/commit/4c07a474f9f25043a2fa3a4f2829e97eb9bb7666))
24
+ * **retro:** add retro notes for issue [#48](https://github.com/gotgenes/pi-packages/issues/48) ([f244c04](https://github.com/gotgenes/pi-packages/commit/f244c04c64f768e724e89d77962f2fb63715b998))
25
+
26
+ ## [4.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v3.0.0...pi-subagents-v4.0.0) (2026-05-17)
27
+
28
+
29
+ ### ⚠ BREAKING CHANGES
30
+
31
+ * The untyped globalThis[Symbol.for("pi-subagents:manager")] accessor is removed. Use getSubagentsService() from the package's public exports instead.
32
+ * 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.
33
+
34
+ ### Features
35
+
36
+ * add SubagentRecord serializer ([d7afb45](https://github.com/gotgenes/pi-packages/commit/d7afb4569c9e28ce5d4bf7fb1ac560b0bcbb7c90))
37
+ * add SubagentsService types and accessor functions ([468623c](https://github.com/gotgenes/pi-packages/commit/468623c936f45cc30d3c5dde134cc2d21da4a0c4))
38
+ * expose public service entry point via package exports ([0dbeaaf](https://github.com/gotgenes/pi-packages/commit/0dbeaaf39c79717df8cabf59e8ba53652f9bc7af))
39
+ * implement getRecord and listAgents on SubagentsService adapter ([a6da473](https://github.com/gotgenes/pi-packages/commit/a6da47393f6faa3fef93bd065c1ad1a0613d1636))
40
+ * implement spawn with model resolution on SubagentsService adapter ([fd70d82](https://github.com/gotgenes/pi-packages/commit/fd70d828905bc3415fa8b8aebfe4c2a5355209cb))
41
+ * implement steer, abort, waitForAll, hasRunning on adapter ([00f0b99](https://github.com/gotgenes/pi-packages/commit/00f0b99ea978625798ba67a40b375e42006d33e4))
42
+ * publish SubagentsService at extension init, remove old untyped global ([6047e2b](https://github.com/gotgenes/pi-packages/commit/6047e2bbbaf87b5e28325b084b09daf2b0c9b6b9))
43
+
44
+
45
+ ### Documentation
46
+
47
+ * plan SubagentsService implementation ([#48](https://github.com/gotgenes/pi-packages/issues/48)) ([6bd2af8](https://github.com/gotgenes/pi-packages/commit/6bd2af862fb7e7f429617c154391c800b50c5d86))
48
+ * **retro:** add retro notes for issue [#49](https://github.com/gotgenes/pi-packages/issues/49) ([69a5bfc](https://github.com/gotgenes/pi-packages/commit/69a5bfc94edfc445d46fb495449649998614f86d))
49
+
8
50
  ## [3.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v2.0.0...pi-subagents-v3.0.0) (2026-05-17)
9
51
 
10
52
 
@@ -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.
@@ -0,0 +1,181 @@
1
+ ---
2
+ issue: 53
3
+ issue_title: "refactor: extract model resolution from Agent.execute"
4
+ ---
5
+
6
+ # Extract model resolution from Agent.execute
7
+
8
+ ## Problem Statement
9
+
10
+ The `Agent` tool's `execute` callback in `index.ts` contains inline model-resolution logic (~lines 660–670) that determines which model an agent runs with.
11
+ This block checks `resolvedConfig.modelInput`, calls `resolveModel()`, distinguishes error strings from resolved model instances, and silently falls back to the parent model for config-specified models that fail resolution.
12
+ The logic is not independently testable — it is only exercised through integration-level agent spawning.
13
+
14
+ A second, simpler call site in `getModelLabel()` (~line 1043) also calls `resolveModel()` inline but only checks whether the model resolves; it does not need the same fallback semantics.
15
+
16
+ ## Goals
17
+
18
+ - Extract the inline model-resolution block from `Agent.execute` into a named, unit-testable function in `model-resolver.ts`.
19
+ - Keep the existing `resolveModel()` function unchanged — the new function composes it.
20
+ - No behavior change: model-resolution priority and fallback semantics remain identical.
21
+
22
+ ## Non-Goals
23
+
24
+ - Changing the `resolveModel()` fuzzy-matching algorithm.
25
+ - Refactoring the `getModelLabel()` call site (~line 1043) — it has different semantics (display-only, no fallback) and does not benefit from the same extraction.
26
+ - Refactoring `service-adapter.ts` model resolution — it already uses a clean injected-dependency pattern.
27
+ - Changing any public API surface.
28
+
29
+ ## Background
30
+
31
+ ### Existing modules
32
+
33
+ | Module | Role |
34
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `model-resolver.ts` | Exports `resolveModel(input, registry)` — returns a `Model` on success or an error string on failure. |
36
+ | `invocation-config.ts` | Exports `resolveAgentInvocationConfig()` — merges tool params with agent config. Returns `modelInput` (the raw string) and `modelFromParams` (whether the string came from tool params vs. agent config). |
37
+ | `service-adapter.ts` | Already receives `resolveModel` as a dependency via `AdapterDeps`. Its model resolution is simpler (always throw on failure). |
38
+ | `index.ts` | `Agent.execute` contains the inline block. Uses both `modelInput` and `modelFromParams` to decide: (a) return error to user if params-specified model fails, or (b) silently fall back to parent model if config-specified model fails. |
39
+
40
+ ### Relevant constraint from AGENTS.md
41
+
42
+ > Keep modules focused and composable (one concern per file).
43
+
44
+ The new function belongs in `model-resolver.ts` alongside `resolveModel()` since it composes the latter with invocation-level fallback policy.
45
+
46
+ ## Design Overview
47
+
48
+ ### New function signature
49
+
50
+ ```typescript
51
+ interface ModelResolutionResult {
52
+ model: unknown;
53
+ error?: undefined;
54
+ }
55
+
56
+ interface ModelResolutionError {
57
+ model?: undefined;
58
+ error: string;
59
+ }
60
+
61
+ type ModelResolution = ModelResolutionResult | ModelResolutionError;
62
+
63
+ function resolveInvocationModel(
64
+ parentModel: unknown,
65
+ modelInput: string | undefined,
66
+ modelFromParams: boolean,
67
+ registry: ModelRegistry,
68
+ ): ModelResolution;
69
+ ```
70
+
71
+ ### Decision model
72
+
73
+ The function encapsulates the existing three-branch logic:
74
+
75
+ 1. **No `modelInput`** → return `{ model: parentModel }` (inherit parent).
76
+ 2. **`modelInput` resolves** → return `{ model: resolved }`.
77
+ 3. **`modelInput` fails to resolve**:
78
+ - If `modelFromParams` (user typed it) → return `{ error: errorMessage }` so the caller can surface it.
79
+ - If `!modelFromParams` (agent config specified it) → return `{ model: parentModel }` (silent fallback).
80
+
81
+ ### Result shape rationale
82
+
83
+ A discriminated union (`ModelResolution`) with `model` and `error` fields avoids the existing `typeof resolved === "string"` type-narrowing smell.
84
+ The caller in `index.ts` becomes:
85
+
86
+ ```typescript
87
+ const resolution = resolveInvocationModel(
88
+ ctx.model,
89
+ resolvedConfig.modelInput,
90
+ resolvedConfig.modelFromParams,
91
+ ctx.modelRegistry,
92
+ );
93
+ if (resolution.error) return textResult(resolution.error);
94
+ const model = resolution.model;
95
+ ```
96
+
97
+ ### Edge cases
98
+
99
+ - `modelInput` is `undefined` → short-circuit, return parent model.
100
+ - `modelInput` is an empty string → delegates to `resolveModel()`, which currently matches vacuously (documented in existing tests); no change in behavior.
101
+
102
+ ## Module-Level Changes
103
+
104
+ ### `src/model-resolver.ts`
105
+
106
+ - Add `ModelResolutionResult`, `ModelResolutionError`, and `ModelResolution` type exports.
107
+ - Add `resolveInvocationModel()` export.
108
+ - No changes to existing `resolveModel()`, `ModelEntry`, or `ModelRegistry`.
109
+
110
+ ### `src/index.ts`
111
+
112
+ - Update import to include `resolveInvocationModel`.
113
+ - Replace the inline model-resolution block in `Agent.execute` (~lines 660–670) with a call to `resolveInvocationModel()` and a check on the result.
114
+ - Remove the now-unused destructuring of `modelFromParams` from `resolvedConfig` at the call site (it is consumed internally by `resolveInvocationModel` via the parameter).
115
+
116
+ ### `test/model-resolver.test.ts`
117
+
118
+ - Add a new `describe("resolveInvocationModel")` block with tests covering all three branches plus edge cases.
119
+
120
+ ## Test Impact Analysis
121
+
122
+ ### New unit tests enabled
123
+
124
+ The extraction enables direct testing of the three-branch fallback logic (inherit, resolve, fallback-on-config-failure) that was previously only exercisable through full agent spawning.
125
+ Specifically:
126
+
127
+ - Parent model inheritance when no `modelInput` is provided.
128
+ - Successful resolution returns the resolved model.
129
+ - User-specified model failure returns an error.
130
+ - Config-specified model failure silently falls back to parent.
131
+
132
+ ### Existing tests that stay as-is
133
+
134
+ - All existing `resolveModel` tests in `test/model-resolver.test.ts` — they test the lower-level function which is unchanged.
135
+ - Integration-level tests in `test/agent-runner.test.ts` and `test/agent-manager.test.ts` — they exercise model usage through the full agent lifecycle.
136
+ - `test/invocation-config.test.ts` — unchanged module.
137
+ - `test/service-adapter.test.ts` — uses its own injected `resolveModel` dependency, unaffected.
138
+
139
+ ### Tests that become redundant
140
+
141
+ None.
142
+ The inline block was not directly tested anywhere — it was only reached through integration paths that test much more than model resolution.
143
+
144
+ ## TDD Order
145
+
146
+ 1. **Red → Green: parent model inheritance.**
147
+ Test: `resolveInvocationModel` returns `{ model: parentModel }` when `modelInput` is `undefined`.
148
+ Commit: `test: add resolveInvocationModel tests for parent model inheritance`
149
+
150
+ 2. **Red → Green: successful model resolution.**
151
+ Test: returns `{ model: resolvedModel }` when `resolveModel` succeeds (both params-specified and config-specified).
152
+ Commit: `test: add resolveInvocationModel tests for successful resolution`
153
+
154
+ 3. **Red → Green: user-specified model failure.**
155
+ Test: returns `{ error: message }` when `modelFromParams` is `true` and `resolveModel` returns an error string.
156
+ Commit: `test: add resolveInvocationModel tests for param model failure`
157
+
158
+ 4. **Red → Green: config-specified model silent fallback.**
159
+ Test: returns `{ model: parentModel }` when `modelFromParams` is `false` and `resolveModel` returns an error string.
160
+ Commit: `test: add resolveInvocationModel tests for config model fallback`
161
+
162
+ 5. **Green: implement `resolveInvocationModel` in `model-resolver.ts`.**
163
+ All four test cases go green.
164
+ Commit: `feat: add resolveInvocationModel to model-resolver`
165
+
166
+ 6. **Refactor: replace inline block in `index.ts`.**
167
+ Replace the inline model-resolution block in `Agent.execute` with a call to `resolveInvocationModel`.
168
+ Run full test suite to confirm no regressions.
169
+ Commit: `refactor: use resolveInvocationModel in Agent.execute (#53)`
170
+
171
+ ## Risks and Mitigations
172
+
173
+ | Risk | Mitigation |
174
+ | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
+ | Subtle behavior difference in the extracted function vs. the inline block | TDD steps 1–4 encode the exact current semantics; step 6 is a pure mechanical substitution. |
176
+ | `resolveModel` return type is `any \| string` — fragile narrowing | The new function encapsulates the `typeof` check behind a discriminated union, reducing but not eliminating the `any`. Fixing the `any` is out of scope (would require Pi SDK model type changes). |
177
+ | Second call site (`getModelLabel`) might seem like it should also use the new function | Explicitly listed as a non-goal — it has display-only semantics with no fallback behavior. |
178
+
179
+ ## Open Questions
180
+
181
+ None — the extraction is mechanical and the issue's acceptance criteria are unambiguous.
@@ -0,0 +1,44 @@
1
+ ---
2
+ issue: 48
3
+ issue_title: "feat: implement and publish SubagentsService at extension init"
4
+ ---
5
+
6
+ # Retro: #48 — implement and publish SubagentsService at extension init
7
+
8
+ ## Final Retrospective (2026-05-17T15:30:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented a typed `SubagentsService` interface with `Symbol.for()` accessor functions, an adapter wrapping `AgentManager` with model resolution and record serialization, and wired it into the extension init.
13
+ Released as `@gotgenes/pi-subagents@4.0.0` (breaking: old untyped global removed).
14
+ The plan was revised mid-session to align naming with `pi-permission-system`'s established conventions after the user flagged the discrepancy.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - TDD execution was clean: 8 steps, zero rework, all 33 new tests green on first pass.
21
+ - The adapter design was well-scoped — `index.ts` wiring was +16/−12 lines, and the narrow `AgentManagerLike` interface made test mocks trivial.
22
+ - The allowlist serialization pattern (`toSubagentRecord`) prevents future leaks of non-serializable fields by default.
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ - `missing-context` — The initial plan adopted the issue body's naming verbatim (`SubagentsAPI`, `api.ts`, `pi:service:subagents`, `(globalThis as any)`) without checking `pi-permission-system` for the established convention (`SubagentsService`, `service.ts`, `@gotgenes/<pkg>:service`, `Record<symbol, unknown>`).
27
+ The user had to explicitly ask "Does this structure follow the pattern set forth by pi-permission-system?"
28
+ Impact: full plan rewrite (replaced entire file), issue title update, issue body update — ~15 minutes of rework across 3 user turns.
29
+ This was **user-caught**.
30
+
31
+ - `missing-context` — Same pattern as the #49 retro: following the issue spec literally without checking the codebase.
32
+ The architecture doc also used the stale naming, reinforcing the wrong choice.
33
+ Root cause: the "Gather context" step in `/plan-issue` didn't include a cross-package convention check.
34
+
35
+ #### What caused friction (user side)
36
+
37
+ - The user had to perform mechanical oversight ("Does this follow the pi-permission-system pattern?") that the planner should have caught independently.
38
+ If the `/plan-issue` prompt included a step to grep sibling packages for established API patterns, this would have been a design decision surfaced during planning rather than a correction after the fact.
39
+
40
+ ### Changes made
41
+
42
+ 1. Created `packages/pi-subagents/docs/retro/0048-implement-subagents-api.md` (this file).
43
+ 2. Updated `.pi/skills/package-pi-subagents/SKILL.md` — changed `SubagentsAPI` → `SubagentsService` in Implementation Priorities; added `service.ts` and `service-adapter.ts` to module dependency graph and descriptions.
44
+ 3. Updated `.pi/prompts/plan-issue.md` — added step 7 to Gather context: check sibling packages for established API patterns before adopting issue body naming.
@@ -0,0 +1,38 @@
1
+ ---
2
+ issue: 49
3
+ issue_title: "feat: remove group-join, output-file, and ad-hoc RPC"
4
+ ---
5
+
6
+ # Retro: #49 — remove group-join, output-file, and ad-hoc RPC
7
+
8
+ ## Final Retrospective (2026-05-17T15:15:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the removal of group-join and ad-hoc RPC from `pi-subagents`, releasing v3.0.0.
13
+ The original scope included `output-file.ts` removal, but the user intervened to retain it for post-hoc debugging value.
14
+ A new issue (#61) was filed to port the output-file format to Pi's official JSONL session schema.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - User intervention produced a materially better outcome — retaining debugging transcripts and identifying a format conformance gap that became #61.
21
+ - TDD execution was clean: 6 steps, zero rework, all tests green on first pass after each step.
22
+ - The `feat!:` → release-please → v3.0.0 pipeline worked smoothly end-to-end.
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ - `missing-context` — Included `output-file.ts` removal in the initial plan without questioning its debugging value, despite AGENTS.md's rule "Ask before removing functionality or changing defaults." The issue body explicitly listed it for removal so I followed the spec literally. Impact: required plan revision (amend commit), scope-narrowing comment on issue, and filing #61 — roughly 10 minutes of rework, but produced a better design.
27
+
28
+ - `missing-context` — When asked whether output-file adheres to Pi's session format, searched the web (`web_search` for "Claude Code session JSONL format") instead of checking the local `~/development/pi/pi` monorepo. The user had to explicitly say "~/development/pi/pi has the code for Pi's JSONL format." Impact: one extra round-trip and less authoritative initial answer (Claude Code's format vs Pi's `SessionManager`). Self-identified after user redirect.
29
+
30
+ - `instruction-violation` (self-identified) — Shell-escaped the `gh issue comment` body incorrectly; backtick-wrapped `src/output-file.ts` was interpreted by bash. Caught immediately via `gh issue view` and fixed with `--edit-last`. Impact: trivial — one extra command.
31
+
32
+ #### What caused friction (user side)
33
+
34
+ - The issue body listed output-file for removal without noting its debugging value. The user's "How confident are we in getting rid of the logging system?" intervention was the correction. If the issue had marked output-file removal as "tentative pending debugging value assessment," the plan would have surfaced it as a design decision from the start. Minor — the discussion was quick and productive.
35
+
36
+ ### Changes made
37
+
38
+ 1. Created `packages/pi-subagents/docs/retro/0049-remove-group-join-output-file-rpc.md` (this file).
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "3.0.0",
3
+ "version": "4.1.0",
4
+ "exports": {
5
+ ".": "./src/service.ts"
6
+ },
4
7
  "description": "A pi extension that brings Claude Code-style autonomous sub-agents to pi. Friendly fork of @tintinweb/pi-subagents.",
5
8
  "author": {
6
9
  "name": "Chris Lasher"
@@ -56,7 +59,7 @@
56
59
  "check": "tsc --noEmit",
57
60
  "test": "vitest run",
58
61
  "test:watch": "vitest",
59
- "lint:md": "rumdl check '*.md' 'docs/**/*.md'",
62
+ "lint:md": "rumdl check *.md docs/**/*.md",
60
63
  "lint": "biome check . && pnpm run lint:md"
61
64
  }
62
65
  }
package/src/index.ts CHANGED
@@ -20,8 +20,10 @@ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTu
20
20
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
21
21
  import { loadCustomAgents } from "./custom-agents.js";
22
22
  import { resolveAgentInvocationConfig } from "./invocation-config.js";
23
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
23
+ import { type ModelRegistry, resolveInvocationModel, resolveModel } from "./model-resolver.js";
24
24
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
25
+ import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
26
+ import { createSubagentsService } from "./service-adapter.js";
25
27
  import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
26
28
  import { type AgentConfig, type AgentInvocation, type AgentRecord, type NotificationDetails, type SubagentType } from "./types.js";
27
29
  import {
@@ -376,18 +378,19 @@ export default function (pi: ExtensionAPI) {
376
378
  });
377
379
  });
378
380
 
379
- // Expose manager via Symbol.for() global registry for cross-package access.
380
- // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
381
- const MANAGER_KEY = Symbol.for("pi-subagents:manager");
382
- (globalThis as any)[MANAGER_KEY] = {
383
- waitForAll: () => manager.waitForAll(),
384
- hasRunning: () => manager.hasRunning(),
385
- spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
386
- manager.spawn(piRef, ctx, type, prompt, options),
387
- getRecord: (id: string) => manager.getRecord(id),
388
- };
381
+ // Typed service published via Symbol.for() for cross-extension access.
382
+ // Consumers: const { getSubagentsService } = await import("@gotgenes/pi-subagents");
383
+ let currentCtx: { pi: unknown; ctx: unknown } | undefined;
384
+ const service = createSubagentsService({
385
+ manager,
386
+ resolveModel,
387
+ getCtx: () => currentCtx,
388
+ getModelRegistry: () => (currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
389
+ });
390
+ publishSubagentsService(service);
389
391
 
390
- pi.on("session_start", async (_event, _ctx) => {
392
+ pi.on("session_start", async (_event, ctx) => {
393
+ currentCtx = { pi, ctx };
391
394
  manager.clearCompleted();
392
395
  });
393
396
 
@@ -398,7 +401,8 @@ export default function (pi: ExtensionAPI) {
398
401
  // On shutdown, abort all agents immediately and clean up.
399
402
  // If the session is going down, there's nothing left to consume agent results.
400
403
  pi.on("session_shutdown", async () => {
401
- delete (globalThis as any)[MANAGER_KEY];
404
+ unpublishSubagentsService();
405
+ currentCtx = undefined;
402
406
  manager.abortAll();
403
407
  for (const timer of pendingNudges.values()) clearTimeout(timer);
404
408
  pendingNudges.clear();
@@ -653,16 +657,14 @@ Guidelines:
653
657
  const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
654
658
 
655
659
  // Resolve model from agent config first; tool-call params only fill gaps.
656
- let model = ctx.model;
657
- if (resolvedConfig.modelInput) {
658
- const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
659
- if (typeof resolved === "string") {
660
- if (resolvedConfig.modelFromParams) return textResult(resolved);
661
- // config-specified: silent fallback to parent
662
- } else {
663
- model = resolved;
664
- }
665
- }
660
+ const resolution = resolveInvocationModel(
661
+ ctx.model,
662
+ resolvedConfig.modelInput,
663
+ resolvedConfig.modelFromParams,
664
+ ctx.modelRegistry,
665
+ );
666
+ if (resolution.error) return textResult(resolution.error);
667
+ const model = resolution.model;
666
668
 
667
669
  const thinking = resolvedConfig.thinking;
668
670
  const inheritContext = resolvedConfig.inheritContext;
@@ -14,6 +14,45 @@ export interface ModelRegistry {
14
14
  getAvailable?(): any[];
15
15
  }
16
16
 
17
+ /** Successful model resolution — `model` is the resolved or inherited model instance. */
18
+ export interface ModelResolutionResult {
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ model: any;
21
+ error?: undefined;
22
+ }
23
+
24
+ /** Failed model resolution when the model was user-specified (params) — surface the error. */
25
+ export interface ModelResolutionError {
26
+ model?: undefined;
27
+ error: string;
28
+ }
29
+
30
+ /** Discriminated union returned by `resolveInvocationModel`. */
31
+ export type ModelResolution = ModelResolutionResult | ModelResolutionError;
32
+
33
+ /**
34
+ * Resolve the effective model for an agent invocation.
35
+ *
36
+ * Encapsulates the three-branch fallback policy used in `Agent.execute`:
37
+ * 1. No `modelInput` → inherit `parentModel`.
38
+ * 2. `modelInput` resolves → return the resolved model.
39
+ * 3. `modelInput` fails:
40
+ * - `modelFromParams` true → return `{ error }` so the caller can surface it.
41
+ * - `modelFromParams` false → silent fallback to `parentModel`.
42
+ */
43
+ export function resolveInvocationModel(
44
+ parentModel: unknown,
45
+ modelInput: string | undefined,
46
+ modelFromParams: boolean,
47
+ registry: ModelRegistry,
48
+ ): ModelResolution {
49
+ if (!modelInput) return { model: parentModel };
50
+ const resolved = resolveModel(modelInput, registry);
51
+ if (typeof resolved !== "string") return { model: resolved };
52
+ if (modelFromParams) return { error: resolved };
53
+ return { model: parentModel };
54
+ }
55
+
17
56
  /**
18
57
  * Resolve a model string to a Model instance.
19
58
  * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * service-adapter.ts — Adapter that wraps AgentManager to satisfy SubagentsService.
3
+ *
4
+ * Handles model resolution at the API boundary, record serialization
5
+ * (stripping non-serializable fields), and session gating.
6
+ */
7
+
8
+ import type { ModelRegistry } from "./model-resolver.js";
9
+ import type { SubagentRecord, SubagentsService } from "./service.js";
10
+ import type { AgentRecord } from "./types.js";
11
+
12
+ /** Narrow interface for the AgentManager — avoids coupling to the concrete class. */
13
+ export interface AgentManagerLike {
14
+ spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: unknown): string;
15
+ getRecord(id: string): AgentRecord | undefined;
16
+ listAgents(): AgentRecord[];
17
+ abort(id: string): boolean;
18
+ waitForAll(): Promise<void>;
19
+ hasRunning(): boolean;
20
+ }
21
+
22
+ /** Dependencies injected into the adapter factory. */
23
+ export interface AdapterDeps {
24
+ manager: AgentManagerLike;
25
+ resolveModel: (input: string, registry: ModelRegistry) => unknown | string;
26
+ getCtx: () => { pi: unknown; ctx: unknown } | undefined;
27
+ getModelRegistry: () => ModelRegistry | undefined;
28
+ }
29
+
30
+ /** Create a SubagentsService backed by the given dependencies. */
31
+ export function createSubagentsService(deps: AdapterDeps): SubagentsService {
32
+ const { manager } = deps;
33
+
34
+ return {
35
+ spawn(type: string, prompt: string, options?) {
36
+ const session = deps.getCtx();
37
+ if (!session) {
38
+ throw new Error("No active session — cannot spawn agents outside a session.");
39
+ }
40
+
41
+ let model: unknown;
42
+ if (options?.model) {
43
+ const registry = deps.getModelRegistry();
44
+ if (!registry) {
45
+ throw new Error("No model registry available.");
46
+ }
47
+ const resolved = deps.resolveModel(options.model, registry);
48
+ if (typeof resolved === "string") {
49
+ throw new Error(resolved);
50
+ }
51
+ model = resolved;
52
+ }
53
+
54
+ const description = options?.description ?? prompt.slice(0, 80);
55
+ const isBackground = !(options?.foreground ?? false);
56
+
57
+ return manager.spawn(session.pi, session.ctx, type, prompt, {
58
+ description,
59
+ model,
60
+ maxTurns: options?.maxTurns,
61
+ thinkingLevel: options?.thinkingLevel,
62
+ isolated: options?.isolated,
63
+ inheritContext: options?.inheritContext,
64
+ bypassQueue: options?.bypassQueue,
65
+ isolation: options?.isolation,
66
+ isBackground,
67
+ });
68
+ },
69
+
70
+ getRecord(id: string): SubagentRecord | undefined {
71
+ const record = manager.getRecord(id);
72
+ return record ? toSubagentRecord(record) : undefined;
73
+ },
74
+
75
+ listAgents(): SubagentRecord[] {
76
+ return manager.listAgents().map(toSubagentRecord);
77
+ },
78
+
79
+ abort(id: string): boolean {
80
+ return manager.abort(id);
81
+ },
82
+
83
+ async steer(id: string, message: string): Promise<boolean> {
84
+ const record = manager.getRecord(id);
85
+ if (!record || record.status !== "running") {
86
+ return false;
87
+ }
88
+ if (!record.session) {
89
+ // Session not ready yet — queue for delivery once initialized
90
+ if (!record.pendingSteers) record.pendingSteers = [];
91
+ record.pendingSteers.push(message);
92
+ return true;
93
+ }
94
+ await record.session.steer(message);
95
+ return true;
96
+ },
97
+
98
+ async waitForAll(): Promise<void> {
99
+ return manager.waitForAll();
100
+ },
101
+
102
+ hasRunning(): boolean {
103
+ return manager.hasRunning();
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Convert an internal AgentRecord to a serializable SubagentRecord.
110
+ * Uses an explicit allowlist — new fields must be opted in.
111
+ */
112
+ export function toSubagentRecord(record: AgentRecord): SubagentRecord {
113
+ const out: SubagentRecord = {
114
+ id: record.id,
115
+ type: record.type,
116
+ description: record.description,
117
+ status: record.status,
118
+ toolUses: record.toolUses,
119
+ startedAt: record.startedAt,
120
+ lifetimeUsage: record.lifetimeUsage,
121
+ compactionCount: record.compactionCount,
122
+ };
123
+
124
+ if (record.result !== undefined) out.result = record.result;
125
+ if (record.error !== undefined) out.error = record.error;
126
+ if (record.completedAt !== undefined) out.completedAt = record.completedAt;
127
+ if (record.worktreeResult !== undefined) out.worktreeResult = record.worktreeResult;
128
+
129
+ return out;
130
+ }
package/src/service.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * service.ts — Public API surface for cross-extension access to subagents.
3
+ *
4
+ * Consumers declare this package as an optional peer dependency and use
5
+ * dynamic import to access the accessor functions:
6
+ *
7
+ * const { getSubagentsService } = await import("@gotgenes/pi-subagents");
8
+ * const svc = getSubagentsService();
9
+ * svc?.spawn("Explore", "Check for stale TODOs");
10
+ */
11
+
12
+ import type { LifetimeUsage } from "./usage.js";
13
+
14
+ export type { LifetimeUsage };
15
+
16
+ export type SubagentStatus =
17
+ | "queued"
18
+ | "running"
19
+ | "completed"
20
+ | "steered"
21
+ | "aborted"
22
+ | "stopped"
23
+ | "error";
24
+
25
+ /** Serializable snapshot of an agent's state — no live session objects. */
26
+ export interface SubagentRecord {
27
+ id: string;
28
+ type: string;
29
+ description: string;
30
+ status: SubagentStatus;
31
+ result?: string;
32
+ error?: string;
33
+ toolUses: number;
34
+ startedAt: number;
35
+ completedAt?: number;
36
+ lifetimeUsage: LifetimeUsage;
37
+ compactionCount: number;
38
+ worktreeResult?: { hasChanges: boolean; branch?: string };
39
+ }
40
+
41
+ /** Options for spawning an agent via the service. */
42
+ export interface SpawnOptions {
43
+ description?: string;
44
+ model?: string;
45
+ maxTurns?: number;
46
+ thinkingLevel?: string;
47
+ isolated?: boolean;
48
+ inheritContext?: boolean;
49
+ foreground?: boolean;
50
+ bypassQueue?: boolean;
51
+ isolation?: "worktree";
52
+ }
53
+
54
+ /** The public service contract for cross-extension subagent access. */
55
+ export interface SubagentsService {
56
+ /** Spawn an agent. Returns the agent ID immediately. */
57
+ spawn(type: string, prompt: string, options?: SpawnOptions): string;
58
+
59
+ /** Get a snapshot of an agent's current state. */
60
+ getRecord(id: string): SubagentRecord | undefined;
61
+
62
+ /** List all tracked agents, most recent first. */
63
+ listAgents(): SubagentRecord[];
64
+
65
+ /** Abort a running or queued agent. Returns false if not found. */
66
+ abort(id: string): boolean;
67
+
68
+ /** Send a steering message to a running agent. */
69
+ steer(id: string, message: string): Promise<boolean>;
70
+
71
+ /** Wait for all running and queued agents to complete. */
72
+ waitForAll(): Promise<void>;
73
+
74
+ /** Whether any agents are running or queued. */
75
+ hasRunning(): boolean;
76
+ }
77
+
78
+ /** Event channel constants for pi.events subscriptions. */
79
+ export const SUBAGENT_EVENTS = {
80
+ STARTED: "subagents:started",
81
+ COMPLETED: "subagents:completed",
82
+ ACTIVITY: "subagents:activity",
83
+ } as const;
84
+
85
+ // ---- Accessor functions ----
86
+
87
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service");
88
+
89
+ /** Publish the SubagentsService on globalThis for cross-extension access. */
90
+ export function publishSubagentsService(service: SubagentsService): void {
91
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
92
+ }
93
+
94
+ /** Retrieve the published SubagentsService, or undefined if not yet published. */
95
+ export function getSubagentsService(): SubagentsService | undefined {
96
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
97
+ | SubagentsService
98
+ | undefined;
99
+ }
100
+
101
+ /** Remove the SubagentsService from globalThis (call on shutdown/reload). */
102
+ export function unpublishSubagentsService(): void {
103
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
104
+ }