@gotgenes/pi-subagents 16.2.1 → 16.3.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.
@@ -0,0 +1,232 @@
1
+ ---
2
+ issue: 376
3
+ issue_title: "Extract the manager observer from index.ts into a class"
4
+ ---
5
+
6
+ # Extract the manager observer from index.ts into a class
7
+
8
+ ## Problem Statement
9
+
10
+ `index.ts` constructs an inline `SubagentManagerObserver` object literal (~50 lines, four methods) at the composition root and hands it to `SubagentManager`.
11
+ The literal mixes three concerns: emitting `pi.events` lifecycle events, persisting the final record via `pi.appendEntry`, and dispatching completion notifications through the `NotificationManager`.
12
+ Per code-design principle 9 (state and behavior belong in a class, not a closure-captured literal), these three concerns cannot be unit-tested today without booting the entire extension.
13
+ `index.ts` is the package's dominant churn hotspot (31.3, 91 commits); shrinking it is the goal of Phase 17 Track B.
14
+
15
+ ## Goals
16
+
17
+ - Extract the inline observer literal into a `SubagentEventsObserver` class under `src/observation/`, constructed with narrow deps: an `emit` function, an `appendEntry` function, and the `NotificationSystem`.
18
+ - `index.ts` instantiates the class and passes it to `SubagentManager` in place of the literal.
19
+ - Unit-test the observer's three concerns directly (event shapes, record persistence payload, notification dispatch branching) without booting the extension.
20
+ - Preserve every emitted event payload and `appendEntry` shape byte-for-byte — this is a pure extraction with no observable behavior change (not breaking).
21
+ - Bring `index.ts` below 170 lines.
22
+
23
+ ## Non-Goals
24
+
25
+ - Splitting widget delegation out of `SubagentRuntime` (Phase 17 Step 6, Issue [#377]) — Step 5 is its prerequisite but lands separately.
26
+ - Moving `buildEventData` out of `notification.ts` — it stays where it is tested and is imported by the new observer.
27
+ - Pushing event-payload construction onto `Subagent` (a Tell-Don't-Ask improvement) — tracked as an Open Question, out of scope here.
28
+ - Narrowing `NotificationSystem` to a two-method interface — see Open Questions.
29
+ - Any other Phase 17 step.
30
+
31
+ ## Background
32
+
33
+ Relevant modules:
34
+
35
+ - `src/index.ts` (226 lines) — composition root.
36
+ Defines `const observer: SubagentManagerObserver = { … }` at lines 79–134 and passes it to `new SubagentManager({ …, observer, … })`.
37
+ - `src/lifecycle/subagent-manager.ts` — owns the `SubagentManagerObserver` interface (four methods: `onSubagentStarted`, `onSubagentCompleted`, `onSubagentCompacted`, `onSubagentCreated`).
38
+ This interface is the manager's contract and stays here.
39
+ - `src/observation/notification.ts` — exports `buildEventData(record)` (pure, tested in `notification.test.ts`), the `NotificationSystem` interface (`cancelNudge`, `sendCompletion`, `cleanupCompleted`, `dispose`), and `NotificationManager`.
40
+ - `src/observation/record-observer.ts` — the established pattern for an observation-domain module that subscribes/dispatches with narrow deps; the new class is a sibling.
41
+
42
+ The current literal's four methods:
43
+
44
+ - `onSubagentStarted(record)` → `emit("subagents:started", { id, type, description })`.
45
+ - `onSubagentCompleted(record)` → branch on terminal status to `emit("subagents:failed" | "subagents:completed", buildEventData(record))`; `appendEntry("subagents:record", { …8 fields… })`; then either `notifications.cleanupCompleted(record.id)` (when `record.notification?.resultConsumed`) or `notifications.sendCompletion(record)`.
46
+ - `onSubagentCompacted(record, info)` → `emit("subagents:compacted", { id, type, description, reason, tokensBefore, compactionCount })`.
47
+ - `onSubagentCreated(record)` → `emit("subagents:created", { id, type, description, isBackground: true })`.
48
+
49
+ SDK signatures the narrow deps mirror:
50
+
51
+ - `EventBus.emit(channel: string, data: unknown): void`.
52
+ - `ExtensionAPI.appendEntry<T = unknown>(customType: string, data?: T): void`.
53
+
54
+ The applicable AGENTS.md constraints: Pi SDK imports stay out of the observation module — the class accepts `emit`/`appendEntry` as injected callbacks (the same pattern `SettingsManager` uses with `SettingsEmit`), and `index.ts` wires them to `pi.events.emit`/`pi.appendEntry` via arrows (avoids `@typescript-eslint/unbound-method`).
55
+
56
+ ## Design Overview
57
+
58
+ ### Class shape
59
+
60
+ ```typescript
61
+ /** Emit callback — a subset of `pi.events.emit`. */
62
+ export type EventEmit = (channel: string, data: unknown) => void;
63
+
64
+ /** Append callback — a subset of `pi.appendEntry`. */
65
+ export type AppendEntry = (customType: string, data: unknown) => void;
66
+
67
+ export interface SubagentEventsObserverDeps {
68
+ emit: EventEmit;
69
+ appendEntry: AppendEntry;
70
+ notifications: NotificationSystem;
71
+ }
72
+
73
+ export class SubagentEventsObserver implements SubagentManagerObserver {
74
+ private readonly emit: EventEmit;
75
+ private readonly appendEntry: AppendEntry;
76
+ private readonly notifications: NotificationSystem;
77
+
78
+ constructor(deps: SubagentEventsObserverDeps) {
79
+ this.emit = deps.emit;
80
+ this.appendEntry = deps.appendEntry;
81
+ this.notifications = deps.notifications;
82
+ }
83
+
84
+ onSubagentStarted(record: Subagent): void { /* emit started */ }
85
+ onSubagentCompleted(record: Subagent): void { /* emit failed|completed, appendEntry, dispatch */ }
86
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void { /* emit compacted */ }
87
+ onSubagentCreated(record: Subagent): void { /* emit created */ }
88
+ }
89
+ ```
90
+
91
+ The four method bodies are moved verbatim from the literal — same event channels, same payload fields, same branching.
92
+ `buildEventData` is imported from `#src/observation/notification`.
93
+
94
+ ### Call site in index.ts
95
+
96
+ ```typescript
97
+ const observer = new SubagentEventsObserver({
98
+ emit: (channel, data) => pi.events.emit(channel, data),
99
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
100
+ notifications,
101
+ });
102
+
103
+ const manager = new SubagentManager({
104
+ createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
105
+ baseCwd: process.cwd(),
106
+ observer,
107
+ limiter,
108
+ getRunConfig: () => settings,
109
+ });
110
+ ```
111
+
112
+ `notifications` (the `NotificationManager`) is already constructed earlier in `index.ts`, so the observer construction slots in where the literal was.
113
+
114
+ ### Extracted-module interaction with upstream deps
115
+
116
+ The observer only *reads* from its inputs and *tells* its collaborators — no output-argument mutation, no reverse-search, no reach-back into the manager:
117
+
118
+ ```text
119
+ onSubagentCompleted(record):
120
+ reads record.status / record.id / … (Subagent getters)
121
+ calls buildEventData(record) (pure helper)
122
+ tells this.emit("subagents:completed", data) (injected callback)
123
+ tells this.appendEntry("subagents:record", {…}) (injected callback)
124
+ reads record.notification?.resultConsumed (LoD chain — pre-existing)
125
+ tells this.notifications.cleanupCompleted | sendCompletion
126
+ ```
127
+
128
+ The one Law-of-Demeter chain (`record.notification?.resultConsumed`) is carried over unchanged from the literal; it is pre-existing and out of scope (see Open Questions).
129
+
130
+ ### Design-review checklist result
131
+
132
+ | Smell | Location | Evidence | Disposition |
133
+ | ---------------- | ------------------------------------- | -------------------------------------------------------- | -------------------------------------------- |
134
+ | Dependency width | `SubagentEventsObserverDeps` (3 deps) | all methods use `emit`; `onSubagentCompleted` uses all 3 | OK — narrow |
135
+ | LoD violation | `onSubagentCompleted` | `record.notification?.resultConsumed` | Track and watch — pre-existing, out of scope |
136
+ | Output argument | — | none | Clean |
137
+ | ISP | `NotificationSystem` (4 methods) | observer uses `sendCompletion`, `cleanupCompleted` | Accept per issue spec — see Open Questions |
138
+
139
+ The extraction introduces a real collaborator (a class that owns three behaviors and is independently testable), not a procedure split — each method returns no value but performs a distinct side-effecting concern that was previously untestable in isolation.
140
+
141
+ ## Module-Level Changes
142
+
143
+ - `src/observation/subagent-events-observer.ts` (new)
144
+ - Export `SubagentEventsObserver implements SubagentManagerObserver`, plus `EventEmit`, `AppendEntry`, and `SubagentEventsObserverDeps` types.
145
+ - Imports: `SubagentManagerObserver` from `#src/lifecycle/subagent-manager`, `buildEventData` and `NotificationSystem` from `#src/observation/notification`, `Subagent` and `CompactionInfo` from `#src/types`.
146
+ - `src/index.ts` (changed)
147
+ - Remove the inline `const observer: SubagentManagerObserver = { … }` literal (lines 79–134).
148
+ - Add `import { SubagentEventsObserver } from "#src/observation/subagent-events-observer"`.
149
+ - Construct `const observer = new SubagentEventsObserver({ emit, appendEntry, notifications })`.
150
+ - Remove now-unused imports: `buildEventData` (moves to the new module) and the `type SubagentManagerObserver` import (the literal annotation is gone).
151
+ - Keep `NotificationDetails` and `NotificationManager` imports (still used by `registerMessageRenderer` and the `notifications` construction).
152
+ - `test/observation/subagent-events-observer.test.ts` (new)
153
+ - Unit tests for all four methods (see TDD Order).
154
+ - `docs/architecture/architecture.md` (changed)
155
+ - Mark Phase 17 Step 5 `✅ Complete` and add a "Landed" note (index.ts line count, file/test counts).
156
+ - Update the "Total LOC … (60 files …)" listings to 61 files and refresh the test count.
157
+ - The churn-hotspot note for `index.ts` stays — Step 5 continues the shrink trend.
158
+
159
+ Grep confirms no `package-*/SKILL.md` documents the inline observer literal or `SubagentEventsObserver` by name; no skill-doc update needed.
160
+
161
+ ## Test Impact Analysis
162
+
163
+ 1. **New unit tests enabled.**
164
+ All four observer methods become directly testable with `vi.fn()` stubs for `emit`/`appendEntry` and a stubbed `NotificationSystem`.
165
+ Previously the only way to exercise these paths was through `SubagentManager` integration tests that drove the manager's `buildObserver` forwarding — the index-level observer body itself had zero coverage.
166
+ 2. **No existing tests become redundant.**
167
+ The `subagent-manager.test.ts` observer tests (lines 76–148) exercise the manager's *forwarding* (`buildObserver` → `this.observer?.onSubagent…`), a different layer that stays.
168
+ `notification.test.ts` keeps the `buildEventData` tests.
169
+ `agent-tool.test.ts:153` keeps its note that `subagents:created` is delegated to the observer.
170
+ 3. **Tests that must stay as-is.**
171
+ The manager forwarding tests and the `buildEventData` purity tests genuinely exercise layers the extraction does not touch.
172
+
173
+ ## Invariants at risk
174
+
175
+ This step touches `index.ts`, which prior Phase 17 steps shrank, and the event/notification dispatch surface.
176
+ Step 6 ([#377]) depends on this step; it must not regress the index.ts shrink.
177
+
178
+ | Invariant | Pinned by |
179
+ | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
180
+ | Completion of a successful background agent emits `subagents:completed` (not `subagents:failed`) with `buildEventData` shape | new `onSubagentCompleted` success test |
181
+ | Terminal `error`/`stopped`/`aborted` status emits `subagents:failed` | new `onSubagentCompleted` error test |
182
+ | `appendEntry("subagents:record", …)` persists the eight-field record on every completion | new `onSubagentCompleted` appendEntry test |
183
+ | Already-consumed result skips `sendCompletion` and calls `cleanupCompleted` | new `onSubagentCompleted` resultConsumed test |
184
+ | `subagents:created` carries `isBackground: true` | new `onSubagentCreated` test |
185
+
186
+ These invariants lived only in the untested inline literal before; the new tests pin them for the first time.
187
+
188
+ ## TDD Order
189
+
190
+ 1. **Red → Green → Commit: extract `SubagentEventsObserver` and wire `index.ts`.**
191
+
192
+ Write `test/observation/subagent-events-observer.test.ts` first (red), covering:
193
+ - `onSubagentStarted` emits `subagents:started` with `{ id, type, description }`.
194
+ - `onSubagentCompleted` (success status) emits `subagents:completed` with `buildEventData(record)`, calls `appendEntry("subagents:record", …)` with the eight fields, and calls `notifications.sendCompletion(record)`.
195
+ - `onSubagentCompleted` (error/stopped/aborted) emits `subagents:failed`.
196
+ - `onSubagentCompleted` with `record.notification?.resultConsumed` calls `notifications.cleanupCompleted(record.id)` and does *not* call `sendCompletion`.
197
+ - `onSubagentCompacted` emits `subagents:compacted` with `{ id, type, description, reason, tokensBefore, compactionCount }`.
198
+ - `onSubagentCreated` emits `subagents:created` with `{ id, type, description, isBackground: true }`.
199
+
200
+ Use `createTestSubagent({ … })` from `#test/helpers/make-subagent` for records (it provides status, result, error, `toolCallId` for `NotificationState`, `compactionCount`); `vi.fn()` for `emit`/`appendEntry`; a stub object for `NotificationSystem`.
201
+
202
+ Then create `src/observation/subagent-events-observer.ts` (green) and update `index.ts` in the **same commit**: replace the literal with `new SubagentEventsObserver({ … })` and drop the now-unused `buildEventData` / `type SubagentManagerObserver` imports.
203
+ The class and the index.ts swap are coupled — index.ts is the sole call site of the literal being replaced, and the new class needs a consumer to satisfy `pnpm fallow dead-code`.
204
+
205
+ Run `pnpm --filter @gotgenes/pi-subagents run check` and `pnpm --filter @gotgenes/pi-subagents exec vitest run`.
206
+ Commit: `refactor: extract SubagentEventsObserver from index.ts (#376)`
207
+
208
+ 2. **Commit: mark Phase 17 Step 5 complete in the architecture doc.**
209
+
210
+ In `docs/architecture/architecture.md`, mark Step 5 `✅ Complete`, add a "Landed" note (new index.ts line count, file count 60 → 61, refreshed test count), and update the two "(60 files …)" LOC listings.
211
+ Commit: `docs: mark Phase 17 Step 5 complete in architecture.md (#376)`
212
+
213
+ ## Risks and Mitigations
214
+
215
+ - **Risk:** the extracted methods drift from the literal's exact event payloads or `appendEntry` shape.
216
+ **Mitigation:** move the bodies verbatim; the new tests assert each payload field explicitly, pinning the shapes.
217
+ - **Risk:** passing `pi.events.emit` / `pi.appendEntry` as bare values trips `@typescript-eslint/unbound-method`.
218
+ **Mitigation:** wire them as arrow callbacks in `index.ts` (`(channel, data) => pi.events.emit(channel, data)`), matching the existing `SettingsManager` emit wiring.
219
+ - **Risk:** the new class is flagged as dead code by `pnpm fallow dead-code` if it lands before its consumer.
220
+ **Mitigation:** the class and the `index.ts` wiring land in one commit (TDD step 1).
221
+ - **Risk:** `index.ts` does not drop below 170 lines.
222
+ **Mitigation:** the literal is ~50 lines and the replacement is ~6; 226 − ~50 + ~6 ≈ 182 minus the removed import lines lands it near/below 170 — verify with `wc -l` during implementation and, if just over, the net is still a clear shrink (the Outcome target is directional, not a hard gate).
223
+
224
+ ## Open Questions
225
+
226
+ - Should `NotificationSystem` be narrowed to a two-method `CompletionNotifier` (`sendCompletion`, `cleanupCompleted`) for the observer per ISP?
227
+ Deferred: the issue prescribes passing `NotificationSystem`, and the interface is already cohesive.
228
+ Revisit only if a third consumer wants the narrow slice.
229
+ - Should event-payload construction (and the `record.notification?.resultConsumed` read) move onto `Subagent` as Tell-Don't-Ask methods (`record.toEventData()`, `record.isResultConsumed()`)?
230
+ Deferred: out of scope for a faithful extraction; `buildEventData` already exists as a pure helper.
231
+
232
+ [#377]: https://github.com/gotgenes/pi-packages/issues/377
@@ -0,0 +1,254 @@
1
+ ---
2
+ issue: 377
3
+ issue_title: "Split widget delegation out of SubagentRuntime"
4
+ ---
5
+
6
+ # Split widget delegation out of SubagentRuntime
7
+
8
+ ## Problem Statement
9
+
10
+ `SubagentRuntime.widget` is assigned after construction (`runtime.widget = new AgentWidget(...)` in `index.ts`), violating construct-complete (principle 8).
11
+ The runtime then carries five relay-only delegation methods — `setUICtx`, `onTurnStart`, `markFinished`, `update`, `ensureTimer` — that do nothing but forward to `this.widget?`.
12
+ That is a relay-only dependency (design-review check 5): the runtime holds the widget purely to hand it to others, and the coupling leaks into tests, where the `AgentToolRuntime` fixture stubs all five widget methods on the runtime mock.
13
+
14
+ The issue's "Proposed change" — construct the widget before its consumers and pass the `WidgetLike` handle directly to the tool deps and `NotificationManager` — is the right intent but is **not directly feasible** as stated.
15
+ `NotificationManager` is not just a consumer of the widget; it is a transitive *dependency* of the widget.
16
+ The construction graph contains a genuine cycle:
17
+
18
+ ```text
19
+ NotificationManager → widget → manager → observer (SubagentEventsObserver) → NotificationManager
20
+ ```
21
+
22
+ The widget needs the manager (`listAgents()`); the manager needs the observer; the observer needs the `NotificationSystem`; and `NotificationManager` needs the widget (`markFinished`/`update`).
23
+ The current `runtime.widget` lazy field exists precisely to break this cycle.
24
+ Removing it forces the single unavoidable late seam to move — and the operator's design principles (no setters, instantiate ready-to-work, constructor DI) rule out relocating it to a setter or a forward-referenced `let`.
25
+
26
+ The cycle has exactly one weak edge: `NotificationManager → widget`.
27
+ Dissolving that edge collapses the cycle entirely, after which the remaining relay removal is mechanical.
28
+
29
+ ## Goals
30
+
31
+ - Remove the `widget` field and the five relay methods (`setUICtx`, `onTurnStart`, `markFinished`, `update`, `ensureTimer`) from `SubagentRuntime`.
32
+ - Eliminate the post-construction `runtime.widget =` write from `index.ts`.
33
+ - Inject the `AgentWidget` handle directly into its real consumers (`AgentTool`, `ToolStartHandler`) via constructor DI.
34
+ - Dissolve `NotificationManager`'s widget dependency so the construction graph is a clean linear DAG (`notifications → observer → manager → widget → tool/handler`) with no cycle, no setter, and no forward-referenced `let`.
35
+ - Narrow `AgentToolRuntime` to drop the four widget methods it currently declares.
36
+ - Behavior-preserving: no observable change to widget rendering, the finished-agent linger countdown, or completion notifications.
37
+
38
+ This change is **not breaking** — it is an internal refactor.
39
+ `SubagentRuntime` is not part of the published service surface (`src/service/service.ts`); no exported API, event channel, or config default changes.
40
+
41
+ ## Non-Goals
42
+
43
+ - Reworking how the widget is rendered, the `/agents` menu, the conversation viewer, or any first-principles UI reconsideration — that is Phase 18.
44
+ - Replacing the widget's poll-based refresh with an event-subscription model — heavier machinery, deferred.
45
+ - Changing `NotificationManager`'s nudge scheduling, `sendMessage` wiring, or `agentActivity` ownership.
46
+ - Touching the foreground-runner / background-spawner direct widget calls (they are already clean consumers that receive the widget from `AgentTool`) beyond the type of the handle they accept.
47
+ - Phase 17 Steps 7–9 (test-fixture consolidation, settings-loader duplication).
48
+
49
+ ## Background
50
+
51
+ Relevant modules and their current widget coupling:
52
+
53
+ | Module | Current widget coupling | Disposition |
54
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
55
+ | `src/runtime.ts` | `WidgetLike` interface, `widget` field, 5 relay methods | Remove all widget knowledge |
56
+ | `src/index.ts` | `runtime.widget = new AgentWidget(...)` post-construction; wires `runtime.markFinished`/`runtime.update` into `NotificationManager` | Construct widget as a `const`; inject directly; reorder |
57
+ | `src/observation/notification.ts` | `NotificationManager` ctor takes `markFinished` + `updateWidget` callbacks; `sendCompletion`/`cleanupCompleted` call them | Drop both callbacks; keep `agentActivity.delete` + nudge |
58
+ | `src/ui/agent-widget.ts` | `AgentWidget(manager, agentActivity, registry)`; `markFinished(id)` seeds `finishedTurnAge`; `update()` renders | Self-seed `finishedTurnAge` from `listAgents()` during `update()` |
59
+ | `src/tools/agent-tool.ts` | `AgentToolRuntime` declares `setUICtx`/`ensureTimer`/`update`/`markFinished`; `execute` calls `this.runtime.setUICtx`; passes `this.runtime` as the widget to runner/spawner | Add a `widget` ctor param; narrow `AgentToolRuntime` |
60
+ | `src/handlers/tool-start.ts` | `ToolStartRuntime` declares `setUICtx`/`onTurnStart`; calls them on `this.runtime` | Accept a widget instead of the runtime |
61
+ | `src/tools/foreground-runner.ts` | `ForegroundWidgetDeps { ensureTimer, markFinished }` | Unchanged interface; receives the real widget |
62
+ | `src/tools/background-spawner.ts` | `BackgroundWidgetDeps { ensureTimer, update }` | Unchanged interface; receives the real widget |
63
+
64
+ Key facts establishing behavior preservation for the dissolve:
65
+
66
+ - The widget timer (`ensureTimer()` → `setInterval(update, 80)`) runs while any agent is active or lingering, and is cleared only on `update()`'s idle path (`!hasActive && !hasFinished`).
67
+ A background agent is **active at the moment it completes**, so the timer is always running then, and `hasFinished` keeps it running through the linger window.
68
+ - The linger countdown is **turn-based**: `markFinished` seeds `finishedTurnAge[id] = 0`, `onTurnStart` ages every entry by 1, and `shouldShowFinished` hides an agent once its age reaches `maxAge` (1 for completed, 2 for error/aborted).
69
+ Seeding ≤80ms later (on the next timer tick) lands in the same turn, so the expiry behavior is identical.
70
+ - `NotificationManager.sendCompletion`/`cleanupCompleted` only deletes the live `AgentActivityTracker` (running-display state) — finished agents render from `listAgents()` + `finishedTurnAge`, so the deletion is orthogonal to linger.
71
+
72
+ AGENTS.md / architecture constraints that apply:
73
+
74
+ - Principle 8 (construct complete; no post-construction field writes from external code) is the motivating rule — the fix must not relocate the smell to a setter or forward-ref `let` (operator steer).
75
+ - Phase 17 Step 1 deleted a `prefer-const` eslint-disable dance; reintroducing a forward-referenced `let widget` (which trips `prefer-const`, per the `code-design` skill) would regress that and is explicitly avoided.
76
+ - `src/runtime.ts` is **not** in the rolled public type bundle (`exports` points at `src/service/service.ts`), so this change does not require `pnpm run verify:public-types`.
77
+
78
+ `AgentWidget`, `NotificationManager`, and the runtime are all internal; the published `SubagentsService` (via `service-adapter.ts`, which consumes the runtime's `currentCtx`/`buildSnapshot` only) is untouched.
79
+
80
+ ## Design Overview
81
+
82
+ ### Decision: dissolve the cycle, don't relocate the seam
83
+
84
+ Three candidate seam placements were considered (recorded for the retro):
85
+
86
+ 1. **Late-bind notifications into the observer via a setter** — rejected: violates the no-setters principle; leaves a post-construction write.
87
+ 2. **Forward-referenced `let widget` + closures into `NotificationManager`** — rejected: trips `prefer-const` → eslint-disable, reintroducing the exact smell Phase 17 Step 1 removed; not "ready-to-work."
88
+ 3. **Dissolve `NotificationManager`'s widget dependency (chosen)** — the widget owns its own finished-agent detection; `NotificationManager` no longer references the widget; the cycle disappears and every object is constructible ready-to-work in a single linear pass.
89
+
90
+ ### Tidy-first sequencing
91
+
92
+ Per "make the change that makes the change easy, then make the easy change," the work splits into a hard preparatory refactor and a mechanical follow-up:
93
+
94
+ - **Prep (Step 1, the hard part):** make the widget self-sufficient and remove the widget callbacks from `NotificationManager`.
95
+ This breaks the cycle while `SubagentRuntime` still carries its relay methods (so `AgentTool`/`ToolStartHandler` are untouched and the repo stays green).
96
+ - **Easy (Step 2):** with the cycle gone, the widget becomes a `const` injected directly into `AgentTool` and `ToolStartHandler`; the five relay methods, the `widget` field, and the post-construction write delete cleanly.
97
+
98
+ ### Widget self-seeding
99
+
100
+ Add a private helper invoked at the top of `update()`, before state assembly:
101
+
102
+ ```ts
103
+ /** Seed linger tracking for any newly-observed finished agent (replaces external markFinished). */
104
+ private seedFinishedAgents(agents: readonly AgentSummary[]): void {
105
+ for (const a of agents) {
106
+ if (a.completedAt && !this.finishedTurnAge.has(a.id)) {
107
+ this.finishedTurnAge.set(a.id, 0);
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ `update()` calls `this.seedFinishedAgents(allAgents)` immediately after `const allAgents = this.manager.listAgents()`.
114
+ The existing public `markFinished(id)` stays (foreground-runner still calls it directly for immediacy, and it remains idempotent — `seedFinishedAgents` only seeds when absent).
115
+
116
+ ### NotificationManager after the dissolve
117
+
118
+ Constructor drops the last two params:
119
+
120
+ ```ts
121
+ constructor(
122
+ private sendMessage: (msg: {...}, opts?: {...}) => void,
123
+ private agentActivity: Map<string, AgentActivityTracker>,
124
+ ) {}
125
+ ```
126
+
127
+ `sendCompletion(record)` → `agentActivity.delete(record.id)`; `scheduleNudge(...)` (drop `markFinished` + `updateWidget`).
128
+ `cleanupCompleted(id)` → `agentActivity.delete(id)` (drop `markFinished` + `updateWidget`).
129
+ The `NotificationSystem` interface is unchanged (method signatures identical).
130
+
131
+ ### Consumer interfaces after the easy change (ISP — narrow per consumer)
132
+
133
+ Drop the shared 5-method `WidgetLike` from `runtime.ts`.
134
+ Each consumer declares only what it uses; `AgentWidget` satisfies all structurally:
135
+
136
+ ```ts
137
+ // agent-tool.ts — the slice AgentTool + its runner/spawner need
138
+ export interface AgentToolWidget {
139
+ setUICtx(ctx: UICtx): void;
140
+ ensureTimer(): void;
141
+ markFinished(id: string): void;
142
+ update(): void;
143
+ }
144
+
145
+ // tool-start.ts — the slice the turn handler needs
146
+ export interface ToolStartWidget {
147
+ setUICtx(ctx: unknown): void;
148
+ onTurnStart(): void;
149
+ }
150
+ ```
151
+
152
+ `AgentTool` gains a `widget: AgentToolWidget` constructor param; `execute` calls `this.widget.setUICtx(...)` and passes `this.widget` to `runForeground`/`spawnBackground` (which already accept the narrow `ForegroundWidgetDeps`/`BackgroundWidgetDeps`).
153
+ `ToolStartHandler` accepts a `ToolStartWidget` instead of `ToolStartRuntime` (rename).
154
+ `AgentToolRuntime` loses `setUICtx`, `ensureTimer`, `update`, `markFinished` — keeping `agentActivity`, `buildSnapshot`, `getModelInfo`, `getSessionInfo`.
155
+
156
+ ### Final construction order in `index.ts`
157
+
158
+ ```text
159
+ registry → runtime → notifications(sendMessage, agentActivity)
160
+ → settings → observer(notifications) → limiter → manager(observer)
161
+ → service → lifecycle(runtime) → session handlers
162
+ → const widget = new AgentWidget(manager, agentActivity, registry)
163
+ → toolStart = new ToolStartHandler(widget)
164
+ → AgentTool(manager, runtime, widget, settings, registry, agentDir)
165
+ → GetResultTool / SteerTool / agentsMenu
166
+ ```
167
+
168
+ No object is mutated after construction; the widget is a `const`.
169
+ `service-adapter.ts` and `lifecycle.ts` keep consuming `runtime` for `currentCtx`/`buildSnapshot`/`setSessionContext` — none of which involve the widget.
170
+
171
+ ## Module-Level Changes
172
+
173
+ - `src/observation/notification.ts` — remove `markFinished` and `updateWidget` constructor params; drop their calls in `sendCompletion`/`cleanupCompleted`.
174
+ `NotificationSystem` interface unchanged.
175
+ - `src/ui/agent-widget.ts` — add private `seedFinishedAgents(agents)`; call it at the top of `update()` after `listAgents()`.
176
+ `markFinished` retained (idempotent).
177
+ - `src/runtime.ts` — delete the `WidgetLike` interface, the `widget` field, and the five relay methods (`setUICtx`, `onTurnStart`, `markFinished`, `update`, `ensureTimer`); remove the now-unused `UICtx` import.
178
+ Update the class/file doc comment to drop "Persistent widget reference" / "Widget delegation methods."
179
+ - `src/tools/agent-tool.ts` — add `AgentToolWidget` interface; add `widget` constructor param; `execute` uses `this.widget` for `setUICtx` and passes it to runner/spawner; narrow `AgentToolRuntime` (drop 4 widget methods); remove now-unused `WidgetLike`-related imports if any.
180
+ - `src/handlers/tool-start.ts` — rename `ToolStartRuntime` → `ToolStartWidget`; constructor takes the widget; `handleToolExecutionStart` calls `this.widget.setUICtx`/`this.widget.onTurnStart`.
181
+ - `src/index.ts` — construct `NotificationManager` with two args; construct `const widget = new AgentWidget(...)` after the manager; pass `widget` to `new ToolStartHandler(...)` and `new AgentTool(...)`; remove the `runtime.widget =` line and the stale comment block above the `NotificationManager` construction.
182
+ - `test/observation/notification.test.ts` — drop the `markFinished`/`updateWidget` stub fields and the two `toHaveBeenCalled` assertions; assert `agentActivity.delete` + nudge behavior instead.
183
+ - `test/ui/agent-widget.test.ts` — add coverage: `update()` self-seeds `finishedTurnAge` for a completed agent in `listAgents()`; the agent then expires after `maxAge` turns via `onTurnStart`; an already-seeded agent is not re-seeded.
184
+ - `test/runtime.test.ts` — remove the "widget delegation methods" describe block, the "widget field accepts a `WidgetLike` stub" test, the `runtime.widget` default assertion, the `WidgetLike` import, and the `createWidgetStub` helper.
185
+ - `test/helpers/make-deps.ts` — add a `widget` field (stub satisfying `AgentToolWidget`) to `AgentToolFixture`; narrow the `runtime` stub (drop `setUICtx`/`ensureTimer`/`update`/`markFinished`); update the doc comment that claims the runtime "also satisfies `BackgroundWidgetDeps`/`ForegroundWidgetDeps`."
186
+ - `test/tools/agent-tool.test.ts` — `makeTool` passes `deps.widget`; the "sets UI context on runtime" test asserts on `deps.widget.setUICtx`.
187
+ - `test/handlers/tool-start.test.ts` — construct `ToolStartHandler` with a widget stub; assert on `widget.setUICtx`/`widget.onTurnStart`.
188
+ - `docs/architecture/architecture.md` — rewrite the Step 6 `- Change:` / `- Outcome:` bullets to the dissolve approach; add a `- Landed:` bullet and mark the heading `✅ Complete`.
189
+ The findings-summary line (~915) describing the smell stays accurate and is left unchanged.
190
+
191
+ No `.pi/skills/package-pi-subagents/SKILL.md` references to `runtime.widget`, `WidgetLike`, or the relay methods exist (grep-verified), so no skill update is required.
192
+
193
+ ## Test Impact Analysis
194
+
195
+ 1. **New tests enabled by the extraction.**
196
+ Widget self-seeding (`seedFinishedAgents`) becomes directly unit-testable on `AgentWidget` via a stub manager whose `listAgents()` returns a completed record — previously the seeding path was only reachable through `NotificationManager.markFinished`.
197
+ `NotificationManager` becomes testable without any widget stub at all.
198
+ 2. **Tests simplified / made redundant.**
199
+ The two `NotificationManager` assertions on `markFinished`/`updateWidget` are deleted (the behavior moved to the widget).
200
+ The entire `SubagentRuntime` "widget delegation methods" describe block (5 trivial pass-through tests) and the `WidgetLike` stub plumbing are removed — pure relay tests with no remaining subject.
201
+ 3. **Tests that must stay as-is.**
202
+ The widget's render/linger/timer tests in `agent-widget.test.ts` (turn-aging via `onTurnStart`, `shouldShowFinished` thresholds, idle-path `clearWidget`) genuinely exercise the layer and are unchanged — the new self-seed test sits alongside them.
203
+ `NotificationManager`'s nudge-scheduling and `sendMessage` tests are unchanged.
204
+
205
+ ## Invariants at risk
206
+
207
+ This step touches surfaces refactored by earlier Phase 17 steps; their documented outcomes must not regress:
208
+
209
+ - **Step 1 (`#381`) — "every spawned agent has a `promise` at spawn; no forward-ref `prefer-const` eslint-disable."**
210
+ Pinned by the limiter/manager tests in `test/lifecycle/`.
211
+ Reintroducing a forward-ref `let widget` would regress this spirit — the chosen dissolve approach specifically avoids it.
212
+ - **Step 5 (`#376`) — "`index.ts` < 170 lines; observer's three concerns unit-tested directly."**
213
+ This step removes lines from `index.ts` (relay wiring + `runtime.widget =`), so the line budget is preserved or improved; `SubagentEventsObserver` is untouched.
214
+ No new behavior is added to `index.ts`.
215
+ - **Construct-complete (principle 8), the roadmap-wide Category-B/C invariant.**
216
+ Pinned after this change by the absence of any `runtime.widget =` (grep-verifiable) and by `test/runtime.test.ts` no longer constructing a widget — add the grep check to the acceptance criteria.
217
+
218
+ ## TDD Order
219
+
220
+ 1. **Widget self-detects finished agents (prep — make it easy).**
221
+ - Red: in `test/ui/agent-widget.test.ts`, assert `update()` seeds `finishedTurnAge` for a completed agent returned by `listAgents()` (rendered as finished), that it expires after `maxAge` turns via `onTurnStart`, and that an already-tracked agent is not re-seeded.
222
+ - Green: add `private seedFinishedAgents(agents)` and call it at the top of `update()`.
223
+ - Commit: `test:` then `feat:` — or a single `feat: self-seed finished agents in AgentWidget.update (#377)`.
224
+ 2. **Drop widget callbacks from NotificationManager (prep — breaks the cycle).**
225
+ - Red/Green: update `test/observation/notification.test.ts` to construct `NotificationManager` with two args and assert `agentActivity.delete` + nudge (drop the `markFinished`/`updateWidget` assertions); remove the two callbacks from the constructor and from `sendCompletion`/`cleanupCompleted`; update the `index.ts` construction site in the same commit (sole call site — the type checker requires it atomic).
226
+ - Commit: `refactor: dissolve NotificationManager widget dependency (#377)`.
227
+ - Outcome: the construction cycle is gone; `SubagentRuntime` still carries its relay methods (repo green).
228
+ 3. **Inject the widget directly; remove relay methods and the widget field from SubagentRuntime (the easy change).**
229
+ - This is one atomic commit: removing the `widget` field, the five relay methods, and narrowing `AgentToolRuntime` breaks `AgentTool`, `ToolStartHandler`, `index.ts`, and their tests at the type level simultaneously (export/field removal — per the workflow rule, fold all consumer and consumer-test updates into one step).
230
+ - Changes: add `AgentToolWidget` + `widget` param to `AgentTool`; rename `ToolStartRuntime` → `ToolStartWidget` and accept the widget in `ToolStartHandler`; delete `WidgetLike` + `widget` + 5 relay methods from `runtime.ts`; construct `const widget` and reorder `index.ts` (remove the `runtime.widget =` write); update `make-deps.ts` (add `widget` stub, narrow `runtime`), `agent-tool.test.ts`, `tool-start.test.ts`, and `runtime.test.ts` (delete the delegation describe block + `WidgetLike` plumbing).
231
+ - Commit: `refactor: inject widget directly, remove relay methods from SubagentRuntime (#377)`.
232
+ - Acceptance: `rg 'runtime\.widget|\.widget =' src/` returns nothing; `rg 'WidgetLike' src/ test/` returns nothing; `pnpm run check && pnpm run lint && pnpm -r run test && pnpm fallow dead-code` clean.
233
+ 4. **Update the architecture roadmap.**
234
+ - Rewrite Step 6 `- Change:`/`- Outcome:` to the dissolve approach, add `- Landed:`, mark `✅ Complete`.
235
+ - Commit: `docs: record Phase 17 Step 6 widget-delegation split (#377)`.
236
+
237
+ ## Risks and Mitigations
238
+
239
+ - **Risk: self-seed timing differs from the old immediate `markFinished`/`update`.**
240
+ Mitigation: the timer is always running at a background completion (the agent was active); the seed lands ≤80ms later within the same turn; linger expiry is turn-based, so the rendered outcome is identical.
241
+ The new widget test pins the seed-then-expire behavior.
242
+ - **Risk: a completed agent never expires if it is never seeded.**
243
+ Mitigation: `seedFinishedAgents` runs on every `update()` tick while the widget is live; the idle-path `clearWidget` only fires once nothing is active or finished, so a finished agent is always seeded before the widget can go idle.
244
+ - **Risk: removing exports/fields breaks consumers mid-refactor.**
245
+ Mitigation: Step 2 breaks the cycle while leaving the relay methods in place (repo green); Step 3 folds the export/field removal and all consumers + tests into one atomic commit.
246
+ - **Risk: regressing an earlier Phase 17 invariant (forward-ref dance, `index.ts` budget).**
247
+ Mitigation: the dissolve approach introduces no `let widget` and only removes lines from `index.ts`; the Invariants-at-risk grep checks are in the Step 3 acceptance criteria.
248
+
249
+ ## Open Questions
250
+
251
+ - Should `AgentTool` receive one `AgentToolWidget` and forward it, or should it receive the narrower `ForegroundWidgetDeps`/`BackgroundWidgetDeps` separately?
252
+ Plan assumes a single `AgentToolWidget` superset (it also calls `setUICtx` itself) forwarded to the runner/spawner — revisit only if ISP pressure appears during implementation.
253
+ - Whether foreground-runner's explicit `markFinished(fgId)` is now redundant given widget self-seeding.
254
+ Plan keeps it (immediacy; idempotent) to hold scope tight; a follow-up could remove it if the self-seed proves sufficient.
@@ -49,3 +49,56 @@ Pre-completion reviewer returned WARN (2 non-blocking findings; the doc metric r
49
49
  - **Pre-completion reviewer WARN findings** (both addressed inline):
50
50
  1. `architecture.md` health-metric rows still carried "→ 59 after Step 4" annotations after landing — updated to actual counts (60 files, 8,356 LOC) and the docs commit amended.
51
51
  2. `WorkspaceBracket.hasProvider()` TDA trade-off — documented in the `run()` call-site comment; noted here for Phase 18 awareness.
52
+
53
+ ## Stage: Final Retrospective (2026-06-15T02:04:42Z)
54
+
55
+ ### Session summary
56
+
57
+ Shipped issue #375 (Phase 17 Step 4) cleanly across four stages in one continuous session: planning produced a 4-step plan, TDD implemented it in 4 commits (suite 982 → 994), shipping pushed/verified-CI/closed-the-issue and merged release-please PR #406 (`pi-subagents-v16.2.1`, which actually carried #374's `fix:` — #375's `refactor:`/`docs:` commits trigger no bump).
58
+ The only substantive friction was a single self-identified plan deviation in TDD (a microtask-boundary timing trap), resolved inside the same commit with no rework, reorder, or user correction.
59
+
60
+ ### Observations
61
+
62
+ #### What went well
63
+
64
+ - **The cross-step-invariant discipline from the #374 retro paid off a second time.**
65
+ The #374 process retro added the "Invariants at risk" plan section; the #375 plan used it to list the three prior Phase 17 invariants (at-spawn `promise`, construct-complete, zero external field writes), each already pinned by a named test.
66
+ All three held through the extraction with a green suite — the regression class that bit #374 did not recur.
67
+ This is the first time the new section was load-bearing on a *fresh* issue rather than as a post-hoc correction.
68
+ - **Planning corrected the issue's own design sketch instead of implementing it literally.**
69
+ The issue proposed `RunListeners.attach(unsub, detach)` and "three dispose paths collapse into one"; the plan recognized the two handles attach at different lifecycle moments (so `attach` had to split into `wireSignal`/`attachObserver`/`release`) and that the two dispose sites have genuinely different error-handling semantics (so they stay separate per the structural-duplication heuristic).
70
+ Treating the issue body as a hypothesis, not a spec, avoided a wrong abstraction.
71
+
72
+ #### What caused friction (agent side)
73
+
74
+ - `missing-context` — the plan's Design Overview sketched `const cwd = await this.workspaceBracket.prepare(...)` **unconditionally**, but the original `run()` only awaited inside `if (provider) { ... }`, keeping the no-provider path synchronous up to the factory call.
75
+ An always-`async` helper adds a microtask boundary even when it returns immediately, so the queued-abort test in `subagent-manager.test.ts` ("abort removes a queued agent without ever running it") failed: it asserts `factory.toHaveBeenCalledOnce()` synchronously, and the factory call had been deferred a tick.
76
+ The first fix attempt (`if (this.execution.getWorkspaceProvider)`) also failed because `SubagentManager.spawn()` always injects `getWorkspaceProvider: () => this._workspaceProvider` as a function regardless of whether a provider is registered, so the guard was always true.
77
+ Resolved by adding `WorkspaceBracket.hasProvider()` (a synchronous predicate) and guarding the `await` with it.
78
+ Impact: ~2 test-run cycles inside TDD step 3, one extra method (`hasProvider`) plus 2 unit tests not in the plan, and a mild Tell-Don't-Ask trade-off the pre-completion reviewer flagged as WARN.
79
+ Self-identified via the failing test; no commit reorder, no rework beyond the in-commit fix.
80
+ - `other` (minor) — `subagent.ts` first landed at 469 LOC, above the plan's ≤ 450 gate; two trim passes (stale module-level doc comment, redundant field comments) brought it to 448.
81
+ The trimmed comments were genuinely stale post-extraction, so the trim was legitimate, but the LOC estimate ("≈ 40 lines removed") was optimistic about the structural change alone.
82
+ Impact: 2 extra edits, no rework.
83
+
84
+ #### What caused friction (user side)
85
+
86
+ - None.
87
+ The four-stage flow ran end-to-end on prompt templates with zero mid-stream user corrections or strategic interventions — the work was clean enough that none were needed.
88
+
89
+ ### Diagnostic details
90
+
91
+ - **Model-performance correlation** — Planning ran on `anthropic/claude-opus-4-8` (pinned via `plan-issue.md`, appropriate for judgment-heavy design); TDD on `anthropic/claude-sonnet-4-6` (appropriate); the pre-completion reviewer subagent on its frontmatter default; Retro on `anthropic/claude-opus-4-8` (pinned).
92
+ The **ship stage ran on `opencode-go/deepseek-v4-flash`** — the same weak-model-on-release-management pattern flagged in the #374 retro.
93
+ It again executed cleanly, including the non-trivial reasoning that PR #406 mapped to #374 not #375 and the `UNSTABLE`/empty-rollup `GITHUB_TOKEN` diagnosis.
94
+ Second consecutive clean run, so the risk remains theoretical (irreversible ops on a weak model) rather than demonstrated harm.
95
+ - **Escalation-delay tracking** — the microtask friction was not a rabbit hole: the diagnosis moved methodically (failing test → `concurrency-limiter.ts` → `subagent-manager.ts:156` injection point → root cause) across ~2 test-run cycles, under the 5-call flag threshold.
96
+ - **Unused-tool detection** — the friction was exact-symbol tracing (`grep` for `getWorkspaceProvider`), which `grep` handled correctly; no Explore/colgrep dispatch was warranted.
97
+ - **Feedback-loop gap analysis** — verification was incremental throughout: per-file `vitest` after each red/green, `pnpm run check` after the interface-change step, full suite + lint + `fallow dead-code` before the pre-completion review.
98
+ No end-only-verification gap.
99
+
100
+ ### Changes made
101
+
102
+ 1. `.pi/skills/testing/SKILL.md` — added a bullet under `### Interface and type changes` on the conditional-`await` → always-`async` microtask-boundary trap (sibling to the existing runtime-vs-typecheck timing rule).
103
+ 2. `packages/pi-subagents/docs/retro/0375-extract-run-listener-workspace-bracket.md` — added this Final Retrospective stage entry.
104
+ 3. Considered but not landed (operator-declined or out of scope): pinning `/ship-issue` to a stronger model (recurrence of the #374 finding, no demonstrated harm this session), a `plan-issue` rule on preserving conditional awaits in extracted-method sketches, and a rule against trimming comments to hit a LOC gate.