@gotgenes/pi-subagents 6.8.1 → 6.8.3

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,24 @@ 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
+ ## [6.8.3](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.8.2...pi-subagents-v6.8.3) (2026-05-22)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * mark Step D1 complete in architecture.md ([#113](https://github.com/gotgenes/pi-packages/issues/113)) ([b42de23](https://github.com/gotgenes/pi-packages/commit/b42de238710931348336d6e7fd81bb000a0ab584))
14
+ * plan disambiguate SpawnOptions (public vs internal) ([#113](https://github.com/gotgenes/pi-packages/issues/113)) ([2f3cebc](https://github.com/gotgenes/pi-packages/commit/2f3cebc6623e0ba20c73145d8e7c6b9ffae6f875))
15
+ * **retro:** add retro notes for issue [#112](https://github.com/gotgenes/pi-packages/issues/112) ([2a59ed4](https://github.com/gotgenes/pi-packages/commit/2a59ed4e4f5462cdbf8df96e4b675a9c5ec6eb9d))
16
+
17
+ ## [6.8.2](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.8.1...pi-subagents-v6.8.2) (2026-05-22)
18
+
19
+
20
+ ### Documentation
21
+
22
+ * mark Step C complete in architecture.md ([#112](https://github.com/gotgenes/pi-packages/issues/112)) ([9f6e783](https://github.com/gotgenes/pi-packages/commit/9f6e783ab4ddcabdc24224e02aa631b93b0fb6d4))
23
+ * plan replace AgentManager callbacks with observer interface ([#112](https://github.com/gotgenes/pi-packages/issues/112)) ([b32dde1](https://github.com/gotgenes/pi-packages/commit/b32dde1e473611b2734a287e88c8d155642405a7))
24
+ * **retro:** add retro notes for issue [#123](https://github.com/gotgenes/pi-packages/issues/123) ([e9333ee](https://github.com/gotgenes/pi-packages/commit/e9333ee2af70e821ba1bace63bdbd7befa72ce84))
25
+
8
26
  ## [6.8.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.8.0...pi-subagents-v6.8.1) (2026-05-21)
9
27
 
10
28
 
@@ -384,8 +384,8 @@ Each step is sequenced so it makes the next step easier.
384
384
  | ~~Mutable state bag~~ | ~~`AgentActivity` (7 fields)~~ | **Fixed #110**: `AgentActivityTracker` class; `ui-observer.ts` calls transition methods; widget, notification, agent-tool use read-only accessors |
385
385
  | ~~Settings relay~~ | ~~`AgentMenuDeps` (13 fields)~~ | **Fixed #109**: `SettingsManager` class; 6 callback fields collapsed to `settings: SettingsManager`; `AgentMenuDeps` now 8 fields |
386
386
  | ~~Post-construction mutation~~ | ~~`AgentRecord` non-transition state~~ | **Fixed #111**: `ExecutionState`, `WorktreeState`, `NotificationState` collaborators; `pendingSteers` moved to `AgentManager`; stats encapsulated behind mutation methods |
387
- | Fire-and-forget callbacks | `AgentManagerOptions` | `onStart`, `onComplete`, `onCompact` wired as closures in `index.ts` |
388
- | Duplicate `SpawnOptions` | `service.ts` + `agent-manager.ts` | Two incompatible shapes (JSON-friendly vs runtime types) with the same name |
387
+ | ~~Fire-and-forget callbacks~~ | ~~`AgentManagerOptions`~~ | **Fixed #112**: `AgentManagerObserver` interface; `observer` replaces 3 callbacks; `index.ts` constructs one observer object instead of 3 closure lambdas |
388
+ | ~~Duplicate `SpawnOptions`~~ | ~~`service.ts` + `agent-manager.ts`~~ | **Fixed #113**: internal type renamed to `AgentSpawnConfig`; public `SpawnOptions` in `service.ts` unchanged |
389
389
  | Type dumping ground | `types.ts` | `NotificationDetails` used only by notification/renderer; ~~`DEFAULT_AGENT_NAMES` moved to `AgentTypeRegistry` (#108)~~; `AgentConfig` (21 fields) consumers use 2–4 each |
390
390
  | Wide dependency bags | `AgentToolDeps` (7), `AgentMenuDeps` (8) | Settings narrowed (#109); registry narrowed (#108); more narrowing planned in D steps |
391
391
 
@@ -445,21 +445,22 @@ Split post-construction mutation into phase-specific collaborators, each born co
445
445
  Each piece is born complete at the moment its information is available.
446
446
  The record doesn't accumulate half-baked state — it receives fully constructed collaborators.
447
447
 
448
- ### Step C: Replace `AgentManager` callbacks with observer (#112)
448
+ ### Step C: Replace `AgentManager` callbacks with observer (#112)
449
449
 
450
- Replace the `onStart`/`onComplete`/`onCompact` callback parameters with an `AgentManagerObserver` interface (or typed event emitter).
451
- The observer methods receive the same data the callbacks receive today.
452
- `index.ts` constructs the observer once instead of building 3 closure callbacks that capture `runtime`, `pi`, `notifications`, etc.
453
- `AgentManagerOptions` drops from 85 fields.
450
+ **Done.**
451
+ `AgentManagerObserver` interface replaces `onStart`/`onComplete`/`onCompact`.
452
+ `index.ts` constructs one observer object instead of 3 closure lambdas.
453
+ `AgentManagerOptions` drops from 97 fields.
454
454
 
455
455
  ### Step D: Disambiguate `SpawnOptions` and narrow dependency bags
456
456
 
457
457
  With the registry class, settings manager, and observer in place, the dependency bags shrink naturally.
458
458
 
459
- #### D1. Disambiguate `SpawnOptions` (#113)
459
+ #### D1. Disambiguate `SpawnOptions` (#113)
460
460
 
461
- Rename the internal `SpawnOptions` in `agent-manager.ts` to `AgentSpawnConfig` (or similar) to distinguish it from the JSON-friendly public `SpawnOptions` in `service.ts`.
462
- The two types serve different consumers and should not share a name.
461
+ **Done.**
462
+ Internal `SpawnOptions` in `agent-manager.ts` renamed to `AgentSpawnConfig`.
463
+ Public `SpawnOptions` in `service.ts` is unchanged.
463
464
 
464
465
  #### D2. Narrow `AgentToolDeps` and `AgentMenuDeps` (#114)
465
466
 
@@ -0,0 +1,241 @@
1
+ ---
2
+ issue: 112
3
+ issue_title: "refactor(pi-subagents): replace AgentManager callbacks with observer interface"
4
+ ---
5
+
6
+ # Replace AgentManager callbacks with observer interface
7
+
8
+ ## Problem Statement
9
+
10
+ `AgentManagerOptions` accepts three fire-and-forget callbacks — `onStart`, `onComplete`, `onCompact` — that `index.ts` wires as closure lambdas capturing `runtime`, `pi`, `notifications`, and other extension state.
11
+ This is the same callback-threading pattern that was replaced with direct session subscriptions in #100, but one level up.
12
+ The callbacks are notification-style (fire-and-forget, no return value) and match the observer pattern exactly.
13
+
14
+ ## Goals
15
+
16
+ - Replace the three callback parameters on `AgentManagerOptions` with a single `observer?: AgentManagerObserver` interface.
17
+ - `index.ts` constructs one observer object instead of three independent closure lambdas.
18
+ - `AgentManagerOptions` shrinks by two net fields (remove 3 callbacks, add 1 observer).
19
+ - Preserve all existing behavior: lifecycle events, record persistence, notification dispatch, compaction events.
20
+ - Non-breaking refactor — no public API changes (the callbacks are internal to the package).
21
+
22
+ ## Non-Goals
23
+
24
+ - Extracting the observer implementation from `index.ts` into its own module — that can follow if the object grows.
25
+ - Changing `RecordObserverOptions` (session-level observer) — it remains a separate concern at a different layer.
26
+ - Narrowing `AgentToolDeps` or `AgentMenuDeps` — tracked in #114.
27
+ - Disambiguating `SpawnOptions` — tracked in #113.
28
+
29
+ ## Background
30
+
31
+ ### Current callback wiring
32
+
33
+ `agent-manager.ts` defines three callback type aliases and stores them as private fields:
34
+
35
+ ```typescript
36
+ export type OnAgentStart = (record: AgentRecord) => void;
37
+ export type OnAgentComplete = (record: AgentRecord) => void;
38
+ export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
39
+ ```
40
+
41
+ `AgentManager` invokes them at three points:
42
+
43
+ 1. `onStart` — called in `startAgent()` after `record.markRunning()`.
44
+ 2. `onComplete` — called in `startAgent()`'s `.then()` and `.catch()` handlers for background agents, and in `drainQueue()` on late failure.
45
+ 3. `onCompact` — relayed through `subscribeRecordObserver()` → `RecordObserverOptions.onCompact`.
46
+
47
+ `index.ts` builds ~30 lines of closure lambdas (lines 73–116) that capture `pi`, `notifications`, and `buildEventData`.
48
+
49
+ ### Observer pattern already established
50
+
51
+ `record-observer.ts` and `ui/ui-observer.ts` are session-level observers that subscribe directly to session events.
52
+ This refactoring applies the same principle at the manager level — grouping related notification callbacks into a single interface.
53
+
54
+ ### Dependency: issue #111 (AgentRecord lifecycle split)
55
+
56
+ Issue #111 is closed.
57
+ The observer interface is designed against the current record shape, which reflects the post-#111 lifecycle split.
58
+
59
+ ### Architecture reference
60
+
61
+ Phase 7, Step C in `docs/architecture/architecture.md`.
62
+
63
+ ## Design Overview
64
+
65
+ ### Observer interface
66
+
67
+ ```typescript
68
+ export interface AgentManagerObserver {
69
+ onAgentStarted(record: AgentRecord): void;
70
+ onAgentCompleted(record: AgentRecord): void;
71
+ onAgentCompacted(record: AgentRecord, info: CompactionInfo): void;
72
+ }
73
+ ```
74
+
75
+ All three methods are fire-and-forget (void return, no async).
76
+ The interface uses past-tense naming (`Started`, `Completed`, `Compacted`) to signal that these are after-the-fact notifications, not hooks that influence the operation.
77
+
78
+ ### `AgentManagerOptions` change
79
+
80
+ ```typescript
81
+ export interface AgentManagerOptions {
82
+ runner: AgentRunner;
83
+ worktrees: WorktreeManager;
84
+ exec: ShellExec;
85
+ registry: AgentTypeRegistry;
86
+ getMaxConcurrent?: () => number;
87
+ getRunConfig?: () => RunConfig;
88
+ observer?: AgentManagerObserver; // replaces onStart, onComplete, onCompact
89
+ }
90
+ ```
91
+
92
+ Fields go from 9 → 7 (remove 3 callbacks, add 1 observer).
93
+
94
+ ### AgentManager internal changes
95
+
96
+ The three private fields (`onStart`, `onComplete`, `onCompact`) become one: `private observer?: AgentManagerObserver`.
97
+ Call sites change from `this.onStart?.(record)` to `this.observer?.onAgentStarted(record)`.
98
+
99
+ The `onCompact` relay into `subscribeRecordObserver` changes from:
100
+
101
+ ```typescript
102
+ onCompact: (r, info) => this.onCompact?.(r, info),
103
+ ```
104
+
105
+ to:
106
+
107
+ ```typescript
108
+ onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
109
+ ```
110
+
111
+ ### `CompactionInfo` stays in `agent-manager.ts`
112
+
113
+ `CompactionInfo` is a data shape consumed by the observer interface and already defined in `agent-manager.ts`.
114
+ It stays co-located since both `AgentManagerObserver` and `CompactionInfo` are exported from the same module.
115
+ `record-observer.ts` continues to import `CompactionInfo` from `agent-manager.ts`.
116
+
117
+ ### Observer construction in `index.ts`
118
+
119
+ The three closure lambdas collapse into one object literal:
120
+
121
+ ```typescript
122
+ const observer: AgentManagerObserver = {
123
+ onAgentStarted(record) {
124
+ pi.events.emit("subagents:started", {
125
+ id: record.id, type: record.type, description: record.description,
126
+ });
127
+ },
128
+ onAgentCompleted(record) {
129
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
130
+ const eventData = buildEventData(record);
131
+ if (isError) pi.events.emit("subagents:failed", eventData);
132
+ else pi.events.emit("subagents:completed", eventData);
133
+
134
+ pi.appendEntry("subagents:record", { /* same fields as today */ });
135
+
136
+ if (record.notification?.resultConsumed) {
137
+ notifications.cleanupCompleted(record.id);
138
+ return;
139
+ }
140
+ notifications.sendCompletion(record);
141
+ },
142
+ onAgentCompacted(record, info) {
143
+ pi.events.emit("subagents:compacted", {
144
+ id: record.id, type: record.type, description: record.description,
145
+ reason: info.reason, tokensBefore: info.tokensBefore,
146
+ compactionCount: record.compactionCount,
147
+ });
148
+ },
149
+ };
150
+ ```
151
+
152
+ Passed as `observer` to `new AgentManager({ ..., observer })`.
153
+
154
+ ### Removed exports
155
+
156
+ The three callback type aliases are removed:
157
+
158
+ - `OnAgentStart`
159
+ - `OnAgentComplete`
160
+ - `OnAgentCompact`
161
+
162
+ These are only imported in `agent-manager.test.ts` — no production consumers outside `agent-manager.ts`.
163
+
164
+ ## Module-Level Changes
165
+
166
+ ### `src/agent-manager.ts`
167
+
168
+ - Add `AgentManagerObserver` interface (3 methods).
169
+ - Remove `OnAgentStart`, `OnAgentComplete`, `OnAgentCompact` type aliases.
170
+ - Replace `onStart`, `onComplete`, `onCompact` fields in `AgentManagerOptions` with `observer?: AgentManagerObserver`.
171
+ - Replace three private fields with `private observer?: AgentManagerObserver`.
172
+ - Update constructor to assign `this.observer = options.observer`.
173
+ - Update all call sites: `this.onStart?.(record)` → `this.observer?.onAgentStarted(record)`, etc.
174
+ - The `onCompact` relay to `subscribeRecordObserver` uses `this.observer?.onAgentCompacted`.
175
+
176
+ ### `src/index.ts`
177
+
178
+ - Replace the three closure-lambda properties (`onComplete:`, `onStart:`, `onCompact:`) with a single `observer` object literal.
179
+ - Import `AgentManagerObserver` from `agent-manager.ts`.
180
+ - Remove any now-unused callback type imports.
181
+
182
+ ### `src/record-observer.ts`
183
+
184
+ - No changes.
185
+ `RecordObserverOptions.onCompact` stays as-is — it is the session-level relay, not the manager-level observer.
186
+ `CompactionInfo` import from `agent-manager.ts` stays unchanged.
187
+
188
+ ### `test/agent-manager.test.ts`
189
+
190
+ - Update imports: remove `OnAgentStart`, `OnAgentComplete`, `OnAgentCompact`; add `AgentManagerObserver`.
191
+ - Update `createManager` helper: replace the three callback overrides with `observer?: Partial<AgentManagerObserver>`.
192
+ The factory spreads a no-op default observer with test-provided overrides.
193
+ - Update each test that wires a callback to construct an observer object instead.
194
+
195
+ ## Test Impact Analysis
196
+
197
+ 1. No new unit tests are enabled by this refactoring — it's a 1:1 shape change.
198
+ 2. No existing tests become redundant — each test exercises a specific lifecycle scenario (race condition, foreground vs background, error handling, queue drain).
199
+ 3. All existing `agent-manager.test.ts` tests stay, with mechanical updates to the observer wiring.
200
+ The assertions remain the same (e.g., "observer.onAgentCompleted fires with `resultConsumed=false` when `markConsumed` called after await").
201
+
202
+ ## TDD Order
203
+
204
+ ### Step 1: Introduce `AgentManagerObserver` interface alongside existing callbacks
205
+
206
+ Add the interface to `agent-manager.ts`.
207
+ No production behavior changes — the interface is unused at this point.
208
+
209
+ - Commit: `refactor: add AgentManagerObserver interface`
210
+
211
+ ### Step 2: Switch `AgentManager` internals to observer
212
+
213
+ 1. Replace the three `onStart`/`onComplete`/`onCompact` fields on `AgentManagerOptions` with `observer?: AgentManagerObserver`.
214
+ 2. Replace the three private fields with one `private observer?: AgentManagerObserver`.
215
+ 3. Update all internal call sites (`this.onStart?.(record)` → `this.observer?.onAgentStarted(record)`, etc.).
216
+ 4. Remove the three callback type aliases (`OnAgentStart`, `OnAgentComplete`, `OnAgentCompact`).
217
+ 5. Update `test/agent-manager.test.ts`: change imports, update `createManager` helper and all test call sites to construct observer objects instead of individual callbacks.
218
+ 6. Run `pnpm run check` to verify types.
219
+
220
+ - Commit: `refactor: replace AgentManager callbacks with observer interface (#112)`
221
+
222
+ ### Step 3: Update `index.ts` to construct observer
223
+
224
+ 1. Replace the three closure-lambda properties in the `new AgentManager({...})` call with a single `observer` object.
225
+ 2. Import `AgentManagerObserver` type.
226
+ 3. Remove unused callback type imports.
227
+ 4. Run full test suite.
228
+
229
+ - Commit: `refactor: construct AgentManagerObserver in index.ts (#112)`
230
+
231
+ ## Risks and Mitigations
232
+
233
+ | Risk | Mitigation |
234
+ | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | Tests break silently because esbuild ignores excess properties — old `onComplete` fields pass through without error | Step 2 removes the type aliases, so TypeScript catches any test still using the old shape via `pnpm run check` |
236
+ | `onComplete` error-swallowing behavior changes | The `try/catch` around `this.onComplete?.(record)` in the `.then()` path moves exactly to `this.observer?.onAgentCompleted(record)` — same guard, same catch, same debugLog |
237
+ | `Partial<AgentManagerObserver>` in test factory silently drops required methods | Tests that need a specific method construct it explicitly; the factory only provides a convenient default for tests that don't care about any observer method |
238
+
239
+ ## Open Questions
240
+
241
+ - None — the issue's proposed design is unambiguous and aligns with the established session-observer pattern.
@@ -0,0 +1,155 @@
1
+ ---
2
+ issue: 113
3
+ issue_title: "refactor(pi-subagents): disambiguate SpawnOptions (public vs internal)"
4
+ ---
5
+
6
+ # Disambiguate SpawnOptions (public vs internal)
7
+
8
+ ## Problem Statement
9
+
10
+ `SpawnOptions` is defined in two places with the same name but incompatible shapes:
11
+
12
+ - `service.ts` (public API): 8 fields, JSON-friendly (`model` is `string`, `thinkingLevel` is `string`, uses `foreground` not `isBackground`).
13
+ - `agent-manager.ts` (internal): 13 fields, runtime types (`model` is `Model<any>`, `thinkingLevel` is `ThinkingLevel`, includes `signal`, `onSessionCreated`, `invocation`).
14
+
15
+ The name collision makes it ambiguous which type a reader is working with when they see `SpawnOptions` in a signature.
16
+ `service-adapter.ts` manually converts between the two shapes.
17
+
18
+ ## Goals
19
+
20
+ - Rename the internal `SpawnOptions` in `agent-manager.ts` to `AgentSpawnConfig`.
21
+ - Keep the public `SpawnOptions` in `service.ts` unchanged — it's the published API.
22
+ - Update all internal consumers (`agent-tool.ts`, `agent-menu.ts`, `agent-manager.ts`) to use the new name.
23
+ - Update the `SpawnArgs` internal interface in `agent-manager.ts` to reference `AgentSpawnConfig`.
24
+ - Non-breaking refactor — the public API surface is unchanged.
25
+
26
+ ## Non-Goals
27
+
28
+ - Splitting `AgentSpawnConfig` into agent-configuration fields vs execution/lifecycle fields — the issue mentions this as a "consider" item; defer to a follow-up if the type grows further.
29
+ - Narrowing `AgentToolDeps` or `AgentMenuDeps` — tracked in #114.
30
+ - Removing `onSessionCreated` — it's a legitimate per-spawn callback used by `agent-tool.ts` for UI streaming, structurally different from the lifecycle observer (#112).
31
+
32
+ ## Background
33
+
34
+ ### Current consumers of internal `SpawnOptions`
35
+
36
+ | File | How it references `SpawnOptions` |
37
+ | --------------------- | ------------------------------------------------------------------------------------------- |
38
+ | `agent-manager.ts` | Defines the type; uses it in `spawn()`, `spawnAndWait()`, and `SpawnArgs` |
39
+ | `tools/agent-tool.ts` | Imports and uses in `AgentToolManager.spawn` and `AgentToolManager.spawnAndWait` signatures |
40
+ | `ui/agent-menu.ts` | Imports and uses in `AgentMenuManager.spawnAndWait` signature |
41
+
42
+ ### Public `SpawnOptions` in `service.ts`
43
+
44
+ Defined alongside `SubagentsService`.
45
+ Used by `service-adapter.ts` at the conversion boundary.
46
+ Published via `package.json` exports — **not touched by this change**.
47
+
48
+ ### Dependency: issue #112 (observer refactor)
49
+
50
+ Issue #112 is closed.
51
+ The observer eliminated `onStart`/`onComplete`/`onCompact` from `AgentManagerOptions`.
52
+ `onSessionCreated` remains on the internal `SpawnOptions` (now `AgentSpawnConfig`) — it's per-spawn, not per-manager.
53
+
54
+ ### Architecture reference
55
+
56
+ Phase 7, Step D1 in `docs/architecture/architecture.md`.
57
+
58
+ ## Design Overview
59
+
60
+ This is a pure rename — no structural or behavioral changes.
61
+ The internal `SpawnOptions` becomes `AgentSpawnConfig`.
62
+ Every `import type { SpawnOptions }` from `"../agent-manager.js"` or `"./agent-manager.js"` becomes `import type { AgentSpawnConfig }`.
63
+
64
+ The name `AgentSpawnConfig` was chosen because:
65
+
66
+ 1. It disambiguates from the public `SpawnOptions`.
67
+ 2. It follows the established naming convention in this package (`AgentRecord`, `AgentInvocation`, `AgentTypeRegistry`).
68
+ 3. "Config" conveys that this is a configuration bag assembled by the caller and consumed by the manager — not a service-level options type.
69
+
70
+ ### Type shape (unchanged)
71
+
72
+ ```typescript
73
+ export interface AgentSpawnConfig {
74
+ description: string;
75
+ model?: Model<any>;
76
+ maxTurns?: number;
77
+ isolated?: boolean;
78
+ inheritContext?: boolean;
79
+ thinkingLevel?: ThinkingLevel;
80
+ isBackground?: boolean;
81
+ bypassQueue?: boolean;
82
+ isolation?: IsolationMode;
83
+ invocation?: AgentInvocation;
84
+ signal?: AbortSignal;
85
+ onSessionCreated?: (session: AgentSession) => void;
86
+ parentSessionFile?: string;
87
+ parentSessionId?: string;
88
+ }
89
+ ```
90
+
91
+ ## Module-Level Changes
92
+
93
+ ### `src/agent-manager.ts`
94
+
95
+ - Rename `export interface SpawnOptions` → `export interface AgentSpawnConfig`.
96
+ - Update `SpawnArgs.options` type from `SpawnOptions` to `AgentSpawnConfig`.
97
+ - Update `spawn()` parameter type from `SpawnOptions` to `AgentSpawnConfig`.
98
+ - Update `spawnAndWait()` parameter type from `Omit<SpawnOptions, "isBackground">` to `Omit<AgentSpawnConfig, "isBackground">`.
99
+
100
+ ### `src/tools/agent-tool.ts`
101
+
102
+ - Change import from `SpawnOptions` to `AgentSpawnConfig`.
103
+ - Update `AgentToolManager.spawn` and `AgentToolManager.spawnAndWait` signatures.
104
+
105
+ ### `src/ui/agent-menu.ts`
106
+
107
+ - Change import from `SpawnOptions` to `AgentSpawnConfig`.
108
+ - Update `AgentMenuManager.spawnAndWait` signature.
109
+
110
+ ### `src/service.ts`
111
+
112
+ - No changes — the public `SpawnOptions` stays as-is.
113
+
114
+ ### `src/service-adapter.ts`
115
+
116
+ - No changes — it already uses `unknown` for the spawn options parameter in `AgentManagerLike`.
117
+
118
+ ### `test/agent-manager.test.ts`
119
+
120
+ - No import changes needed — the test file does not import `SpawnOptions`.
121
+ - One test description string mentions "SpawnOptions" → update to "AgentSpawnConfig" for accuracy.
122
+
123
+ ## Test Impact Analysis
124
+
125
+ 1. No new tests are enabled by this rename — it's a 1:1 name substitution.
126
+ 2. No existing tests become redundant.
127
+ 3. All existing tests stay as-is — they construct raw object literals that structurally satisfy the type regardless of its name.
128
+
129
+ ## TDD Order
130
+
131
+ ### Step 1: Rename `SpawnOptions` to `AgentSpawnConfig` and update all consumers
132
+
133
+ 1. Rename the interface in `agent-manager.ts`.
134
+ 2. Update `SpawnArgs`, `spawn()`, and `spawnAndWait()` in `agent-manager.ts`.
135
+ 3. Update the import and signatures in `tools/agent-tool.ts`.
136
+ 4. Update the import and signature in `ui/agent-menu.ts`.
137
+ 5. Update the test description string in `test/agent-manager.test.ts`.
138
+ 6. Run `pnpm run check` to verify types.
139
+ 7. Run `pnpm vitest run` to verify all tests pass.
140
+
141
+ - Commit: `refactor: rename internal SpawnOptions to AgentSpawnConfig (#113)`
142
+
143
+ This is a single-step refactor because the rename is mechanical and all consumers must be updated atomically for the type checker to stay green.
144
+
145
+ ## Risks and Mitigations
146
+
147
+ | Risk | Mitigation |
148
+ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
149
+ | Missed consumer still references old name | `pnpm run check` will catch any unresolved `SpawnOptions` import from `agent-manager.ts` since the export no longer exists |
150
+ | Test descriptions become misleading | Grep for "SpawnOptions" in test files and update any description strings that reference the old name |
151
+ | Confusion with `service.ts` `SpawnOptions` during review | The plan is scoped to internal-only changes; `service.ts` is explicitly listed as "no changes" |
152
+
153
+ ## Open Questions
154
+
155
+ - None — the rename is unambiguous and aligns with both the issue proposal and the architecture doc.
@@ -0,0 +1,35 @@
1
+ ---
2
+ issue: 112
3
+ issue_title: "refactor(pi-subagents): replace AgentManager callbacks with observer interface"
4
+ ---
5
+
6
+ # Retro: #112 — replace AgentManager callbacks with observer interface
7
+
8
+ ## Final Retrospective (2026-05-21T21:00:00-04:00)
9
+
10
+ ### Session summary
11
+
12
+ Replaced three fire-and-forget callback fields (`onStart`, `onComplete`, `onCompact`) on `AgentManagerOptions` with a single `AgentManagerObserver` interface.
13
+ The refactoring touched `agent-manager.ts`, `index.ts`, and `agent-manager.test.ts` with zero test-count delta (652/652).
14
+ Released as `pi-subagents-v6.8.2`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The issue description was thorough and unambiguous — no `ask_user` needed during planning, and the design mapped directly to implementation.
21
+ - Self-identified the testing skill's single-call-site rule during execution: the plan split Steps 2 and 3 into separate commits, but `AgentManagerOptions` has one call site in `index.ts`, so both had to land together.
22
+ Merged them without rework.
23
+ - Pre-existing lint issue (unused `ExecutionState` import in `agent-manager.ts`) caught and fixed proactively during the lint step.
24
+
25
+ #### What caused friction (agent side)
26
+
27
+ - `instruction-violation` (self-identified) — The plan wrote TDD Steps 2 and 3 as separate commits, but the testing skill says "when a TDD step changes an interface that has a single call site, the step must include updating that call site."
28
+ The planning phase loaded the testing skill but didn't apply this specific rule when structuring the TDD order.
29
+ Impact: added friction but no rework — caught during execution when `pnpm run check` surfaced the expected type error in `index.ts` after Step 2.
30
+ - `other` (tool interaction) — One `Edit` tool `oldText` match failure in `agent-manager.ts` because the selected block included a blank line that didn't exist between `subscribeRecordObserver` and `options.onSessionCreated`.
31
+ Impact: one extra read + edit cycle (~30 seconds).
32
+
33
+ #### What caused friction (user side)
34
+
35
+ - Nothing — the session ran without user corrections or redirections.
@@ -0,0 +1,49 @@
1
+ ---
2
+ issue: 123
3
+ issue_title: "refactor(pi-subagents): remove vi.fn() cast smell from test helpers"
4
+ ---
5
+
6
+ # Retro: #123 — remove vi.fn() cast smell from test helpers
7
+
8
+ ## Final Retrospective (2026-05-22T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented removal of all 9 `as ReturnType<typeof vi.fn>` casts and 5 `vi.mocked()` calls across 3 test files, replacing them with named typed mock variables.
13
+ Released as `pi-subagents-v6.8.1`.
14
+ Pure test hygiene — no production code changes, no behavioral changes, 652 tests unchanged.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - **Three-file scope executed cleanly once the plan was right.**
21
+ Each file was an independent commit with no cross-file dependencies.
22
+ The named-variable pattern (`const mockGetRecord = vi.fn<AgentManagerLike["getRecord"]>()`) worked identically across all three test files despite different mock construction styles (factory function vs `beforeEach` assignment).
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ - `scope-drift` — The initial plan scoped the fix to `service-adapter.test.ts` only, listing `lifecycle.test.ts` and `tool-start.test.ts` as explicit Non-Goals — despite the planning-phase grep showing all 9 cast sites across 3 files.
27
+ The user redirected with "let's eliminate this pattern of behavior."
28
+ Updated the GitHub issue body, rewrote the plan, and amended the commit.
29
+ Impact: plan rewrite (~2 minutes), no implementation rework.
30
+ User-caught.
31
+ - `missing-context` — Imported `MockInstance` from `vitest` during step 1 (`service-adapter.test.ts`) but actually used `ReturnType<typeof vi.fn<...>>` — matching the pattern used in the other two files.
32
+ The unused import was caught by `pnpm run lint` at the post-implementation check, not proactively.
33
+ Impact: one extra edit + amend cycle.
34
+ Self-identified (via lint).
35
+ - `other` — Ran `git commit --amend` to fix the unused import but HEAD was step 3's commit (`tool-start`), not step 1's (`service-adapter`).
36
+ The amend landed the `service-adapter.test.ts` change into the wrong commit with the wrong message.
37
+ Required `git reset --soft` back to the plan commit and recommitting all 3 files.
38
+ Impact: ~1 minute of git surgery, clean result.
39
+ Self-identified.
40
+
41
+ #### What caused friction (user side)
42
+
43
+ - The issue body scoped the fix to `service-adapter.test.ts` only, which the agent followed literally.
44
+ The broader intent ("eliminate this pattern") was implicit.
45
+ Flagging the desired scope as "all files with this pattern" in the issue body would have avoided the plan rewrite.
46
+
47
+ ### Changes made
48
+
49
+ 1. Wrote retro file at `packages/pi-subagents/docs/retro/0123-remove-vi-fn-cast-smell.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.8.1",
3
+ "version": "6.8.3",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -13,7 +13,6 @@ import { AgentRecord } from "./agent-record.js";
13
13
  import type { AgentRunner } from "./agent-runner.js";
14
14
  import { AgentTypeRegistry } from "./agent-types.js";
15
15
  import { debugLog } from "./debug.js";
16
- import type { ExecutionState } from "./execution-state.js";
17
16
  import { buildParentSnapshot } from "./parent-snapshot.js";
18
17
  import { subscribeRecordObserver } from "./record-observer.js";
19
18
  import type { RunConfig } from "./runtime.js";
@@ -21,11 +20,15 @@ import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, Subagen
21
20
  import type { WorktreeManager } from "./worktree.js";
22
21
  import { WorktreeState } from "./worktree-state.js";
23
22
 
24
- export type OnAgentComplete = (record: AgentRecord) => void;
25
- export type OnAgentStart = (record: AgentRecord) => void;
26
- export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
27
23
  export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
28
24
 
25
+ /** Observer interface for agent lifecycle notifications. */
26
+ export interface AgentManagerObserver {
27
+ onAgentStarted(record: AgentRecord): void;
28
+ onAgentCompleted(record: AgentRecord): void;
29
+ onAgentCompacted(record: AgentRecord, info: CompactionInfo): void;
30
+ }
31
+
29
32
  /** Default max concurrent background agents. */
30
33
  const DEFAULT_MAX_CONCURRENT = 4;
31
34
 
@@ -37,19 +40,17 @@ export interface AgentManagerOptions {
37
40
  /** Injected getter for the concurrency limit — owned by SettingsManager. */
38
41
  getMaxConcurrent?: () => number;
39
42
  getRunConfig?: () => RunConfig;
40
- onStart?: OnAgentStart;
41
- onComplete?: OnAgentComplete;
42
- onCompact?: OnAgentCompact;
43
+ observer?: AgentManagerObserver;
43
44
  }
44
45
 
45
46
  interface SpawnArgs {
46
47
  snapshot: ParentSnapshot;
47
48
  type: SubagentType;
48
49
  prompt: string;
49
- options: SpawnOptions;
50
+ options: AgentSpawnConfig;
50
51
  }
51
52
 
52
- export interface SpawnOptions {
53
+ export interface AgentSpawnConfig {
53
54
  description: string;
54
55
  model?: Model<any>;
55
56
  maxTurns?: number;
@@ -80,9 +81,7 @@ export interface SpawnOptions {
80
81
  export class AgentManager {
81
82
  private agents = new Map<string, AgentRecord>();
82
83
  private cleanupInterval: ReturnType<typeof setInterval>;
83
- private onComplete?: OnAgentComplete;
84
- private onStart?: OnAgentStart;
85
- private onCompact?: OnAgentCompact;
84
+ private readonly observer?: AgentManagerObserver;
86
85
  private readonly runner: AgentRunner;
87
86
  private readonly worktrees: WorktreeManager;
88
87
  private readonly exec: ShellExec;
@@ -102,9 +101,7 @@ export class AgentManager {
102
101
  this.worktrees = options.worktrees;
103
102
  this.exec = options.exec;
104
103
  this.registry = options.registry;
105
- this.onComplete = options.onComplete;
106
- this.onStart = options.onStart;
107
- this.onCompact = options.onCompact;
104
+ this.observer = options.observer;
108
105
  this.getRunConfig = options.getRunConfig;
109
106
  this._getMaxConcurrent = options.getMaxConcurrent ?? (() => DEFAULT_MAX_CONCURRENT);
110
107
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
@@ -141,7 +138,7 @@ export class AgentManager {
141
138
  ctx: ExtensionContext,
142
139
  type: SubagentType,
143
140
  prompt: string,
144
- options: SpawnOptions,
141
+ options: AgentSpawnConfig,
145
142
  ): string {
146
143
  const id = randomUUID().slice(0, 17);
147
144
  const abortController = new AbortController();
@@ -196,7 +193,7 @@ export class AgentManager {
196
193
 
197
194
  record.markRunning(Date.now());
198
195
  if (options.isBackground) this.runningBackground++;
199
- this.onStart?.(record);
196
+ this.observer?.onAgentStarted(record);
200
197
 
201
198
  // Wire parent abort signal to stop the subagent when the parent is interrupted
202
199
  let detachParentSignal: (() => void) | undefined;
@@ -239,7 +236,7 @@ export class AgentManager {
239
236
  }
240
237
  // Subscribe record observer for stats accumulation
241
238
  unsubRecordObserver = subscribeRecordObserver(session, record, {
242
- onCompact: (r, info) => this.onCompact?.(r, info),
239
+ onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
243
240
  });
244
241
  options.onSessionCreated?.(session);
245
242
  },
@@ -268,7 +265,7 @@ export class AgentManager {
268
265
 
269
266
  if (options.isBackground) {
270
267
  this.runningBackground--;
271
- try { this.onComplete?.(record); } catch (err) { debugLog("onComplete callback", err); }
268
+ try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
272
269
  this.drainQueue();
273
270
  }
274
271
  return responseText;
@@ -290,7 +287,7 @@ export class AgentManager {
290
287
 
291
288
  if (options.isBackground) {
292
289
  this.runningBackground--;
293
- this.onComplete?.(record);
290
+ this.observer?.onAgentCompleted(record);
294
291
  this.drainQueue();
295
292
  }
296
293
  return "";
@@ -311,7 +308,7 @@ export class AgentManager {
311
308
  // Late failure (e.g. strict worktree-isolation) — surface on the record
312
309
  // so the user/agent can see it via /agents, then keep draining.
313
310
  record.markError(err);
314
- this.onComplete?.(record);
311
+ this.observer?.onAgentCompleted(record);
315
312
  }
316
313
  }
317
314
  }
@@ -324,7 +321,7 @@ export class AgentManager {
324
321
  ctx: ExtensionContext,
325
322
  type: SubagentType,
326
323
  prompt: string,
327
- options: Omit<SpawnOptions, "isBackground">,
324
+ options: Omit<AgentSpawnConfig, "isBackground">,
328
325
  ): Promise<AgentRecord> {
329
326
  const id = this.spawn(ctx, type, prompt, { ...options, isBackground: false });
330
327
  const record = this.agents.get(id)!;
@@ -347,7 +344,7 @@ export class AgentManager {
347
344
  record.resetForResume(Date.now());
348
345
 
349
346
  const unsubResume = subscribeRecordObserver(session, record, {
350
- onCompact: (r, info) => this.onCompact?.(r, info),
347
+ onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
351
348
  });
352
349
 
353
350
  try {
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { join } from "node:path";
14
14
  import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
15
- import { AgentManager } from "./agent-manager.js";
15
+ import { AgentManager, type AgentManagerObserver } from "./agent-manager.js";
16
16
  import { getAgentConversation, resumeAgent, runAgent, steerAgent } from "./agent-runner.js";
17
17
  import { AgentTypeRegistry } from "./agent-types.js";
18
18
  import { loadCustomAgents } from "./custom-agents.js";
@@ -64,14 +64,18 @@ export default function (pi: ExtensionAPI) {
64
64
  });
65
65
  settings.load();
66
66
 
67
- // Background completion: emit lifecycle event and delegate to notification system
68
- const manager = new AgentManager({
69
- runner: { run: runAgent, resume: resumeAgent },
70
- worktrees: new GitWorktreeManager(process.cwd()),
71
- exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
72
- registry,
73
- onComplete: (record) => {
74
- // Emit lifecycle event based on terminal status
67
+ // Observer: receives agent lifecycle notifications and dispatches events/notifications.
68
+ const observer: AgentManagerObserver = {
69
+ onAgentStarted(record) {
70
+ // Emit started event when agent transitions to running (including from queue).
71
+ pi.events.emit("subagents:started", {
72
+ id: record.id,
73
+ type: record.type,
74
+ description: record.description,
75
+ });
76
+ },
77
+ onAgentCompleted(record) {
78
+ // Emit lifecycle event based on terminal status.
75
79
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
76
80
  const eventData = buildEventData(record);
77
81
  if (isError) {
@@ -80,14 +84,14 @@ export default function (pi: ExtensionAPI) {
80
84
  pi.events.emit("subagents:completed", eventData);
81
85
  }
82
86
 
83
- // Persist final record for cross-extension history reconstruction
87
+ // Persist final record for cross-extension history reconstruction.
84
88
  pi.appendEntry("subagents:record", {
85
89
  id: record.id, type: record.type, description: record.description,
86
90
  status: record.status, result: record.result, error: record.error,
87
91
  startedAt: record.startedAt, completedAt: record.completedAt,
88
92
  });
89
93
 
90
- // Skip notification if result was already consumed via get_subagent_result
94
+ // Skip notification if result was already consumed via get_subagent_result.
91
95
  if (record.notification?.resultConsumed) {
92
96
  notifications.cleanupCompleted(record.id);
93
97
  return;
@@ -95,15 +99,7 @@ export default function (pi: ExtensionAPI) {
95
99
 
96
100
  notifications.sendCompletion(record);
97
101
  },
98
- onStart: (record) => {
99
- // Emit started event when agent transitions to running (including from queue)
100
- pi.events.emit("subagents:started", {
101
- id: record.id,
102
- type: record.type,
103
- description: record.description,
104
- });
105
- },
106
- onCompact: (record, info) => {
102
+ onAgentCompacted(record, info) {
107
103
  // Emit compacted event when agent's session compacts (preserves count on record).
108
104
  pi.events.emit("subagents:compacted", {
109
105
  id: record.id,
@@ -114,6 +110,14 @@ export default function (pi: ExtensionAPI) {
114
110
  compactionCount: record.compactionCount,
115
111
  });
116
112
  },
113
+ };
114
+
115
+ const manager = new AgentManager({
116
+ runner: { run: runAgent, resume: resumeAgent },
117
+ worktrees: new GitWorktreeManager(process.cwd()),
118
+ exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
119
+ registry,
120
+ observer,
117
121
  getMaxConcurrent: () => settings.maxConcurrent,
118
122
  getRunConfig: () => settings,
119
123
  });
@@ -1,7 +1,7 @@
1
1
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "@sinclair/typebox";
4
- import type { SpawnOptions } from "../agent-manager.js";
4
+ import type { AgentSpawnConfig } from "../agent-manager.js";
5
5
  import { normalizeMaxTurns } from "../agent-runner.js";
6
6
  import { AgentTypeRegistry } from "../agent-types.js";
7
7
  import { resolveAgentInvocationConfig } from "../invocation-config.js";
@@ -75,8 +75,8 @@ export function buildDetails(
75
75
 
76
76
  /** Narrow manager interface — only the methods the Agent tool calls. */
77
77
  export interface AgentToolManager {
78
- spawn: (ctx: ExtensionContext, type: string, prompt: string, opts: SpawnOptions) => string;
79
- spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<SpawnOptions, "isBackground">) => Promise<AgentRecord>;
78
+ spawn: (ctx: ExtensionContext, type: string, prompt: string, opts: AgentSpawnConfig) => string;
79
+ spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<AgentRecord>;
80
80
  resume: (id: string, prompt: string, signal: AbortSignal) => Promise<AgentRecord | undefined>;
81
81
  getRecord: (id: string) => AgentRecord | undefined;
82
82
  getMaxConcurrent: () => number;
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import type { SpawnOptions } from "../agent-manager.js";
5
+ import type { AgentSpawnConfig } from "../agent-manager.js";
6
6
  import {
7
7
  AgentTypeRegistry,
8
8
  BUILTIN_TOOL_NAMES,
@@ -19,7 +19,7 @@ export interface AgentMenuManager {
19
19
  listAgents: () => AgentRecord[];
20
20
  getRecord: (id: string) => AgentRecord | undefined;
21
21
  /** Used by generate wizard to spawn an agent that writes the .md file. */
22
- spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<SpawnOptions, "isBackground">) => Promise<AgentRecord>;
22
+ spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<AgentRecord>;
23
23
  }
24
24
 
25
25
  /** Narrow settings interface required by the agent menu. */