@gotgenes/pi-subagents 13.1.0 → 13.2.1

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,304 @@
1
+ ---
2
+ issue: 277
3
+ issue_title: "Encapsulate AgentSession behind SubagentSession; retire the remaining agent.session reach-throughs"
4
+ ---
5
+
6
+ # Encapsulate AgentSession behind SubagentSession
7
+
8
+ ## Problem Statement
9
+
10
+ Callers outside the `lifecycle/` domain reach through `Agent.session` (which returns the raw SDK `AgentSession` via `this.subagentSession?.session`) and operate on the session object directly.
11
+ This violates Law of Demeter and Tell-Don't-Ask — the missing abstractions are intent-revealing methods on `Agent` and `SubagentSession` that delegate internally.
12
+
13
+ Issue #265 introduced `SubagentSession` and routed the run/resume/dispose path through it.
14
+ The remaining consumer-facing reach-throughs were deferred to this issue.
15
+
16
+ ## Goals
17
+
18
+ - Add intent-revealing methods to `Agent` and `SubagentSession` that replace every raw `AgentSession` access outside `lifecycle/`.
19
+ - Collapse the duplicated steer buffer-or-deliver logic into a single `Agent.steer()` method.
20
+ - Narrow the `onSessionCreated` observer callback to stop delivering the raw `AgentSession` to spawners.
21
+ - Remove the `Agent.session` getter.
22
+ - After this change, no production module outside `lifecycle/` references the raw `AgentSession`.
23
+
24
+ ## Non-Goals
25
+
26
+ - The `Agent` → `Subagent` class rename — independent of this issue, can land in either order.
27
+ - Changing the public `SubagentsService` API surface in `service/service.ts` — it already uses `SubagentRecord` (no live session objects).
28
+ - Refactoring the conversation viewer's rich-rendering internals — only its session reference changes.
29
+ - Architecture doc updates for the file-layout listing — `SubagentSession` and `Agent` files are not being added, moved, or removed.
30
+
31
+ ## Background
32
+
33
+ ### Affected reach-through sites
34
+
35
+ Every site reaches the raw `AgentSession` exposed by the `Agent.session` getter.
36
+
37
+ | Reach-through | Production files | Current pattern |
38
+ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
39
+ | Steer buffer-or-deliver (duplicated) | `service/service-adapter.ts:93`, `tools/steer-tool.ts:43` | `record.session` → `session.steer()` or `record.queueSteer()` |
40
+ | Context-percent stats | `tools/get-result-tool.ts:56`, `tools/steer-tool.ts:57`, `observation/notification.ts:49`, `ui/conversation-viewer.ts:145` | `getSessionContextPercent(record.session)` |
41
+ | Conversation viewing | `tools/get-result-tool.ts:83-84`, `ui/agent-menu.ts:255`, `ui/conversation-viewer.ts:63,223` | `getAgentConversation(record.session)` / `session.messages` |
42
+ | Session-readiness guard | `tools/agent-tool.ts:111`, `lifecycle/agent-manager.ts:205` | `!record.session` / `!agent?.session` |
43
+ | Observer callback delivers raw session | `tools/background-spawner.ts:56`, `tools/foreground-runner.ts:111` | `onSessionCreated(_agent, session)` → `tracker.setSession(session)` + `subscribeUIObserver(session, tracker)` |
44
+ | Activity-tracker session stats | `ui/widget-renderer.ts:106` | `activity?.session` → `getSessionContextPercent(activity.session)` (via `AgentActivityTracker.session`) |
45
+
46
+ ### Existing delegate methods
47
+
48
+ `SubagentSession` already has `steer(message)`, `runTurnLoop()`, `resumeTurnLoop()`, and `dispose()`.
49
+ The `session` getter is marked "retired by #277" in its JSDoc.
50
+
51
+ `subscribeAgentObserver` in `observation/record-observer.ts` already accepts `SubscribableSession` (not `AgentSession`), so it can accept `SubagentSession` directly once `SubagentSession` implements the `subscribe()` delegate.
52
+
53
+ ### Public API surface
54
+
55
+ The public API (`SubagentsService` + `SubagentRecord` in `service/service.ts`) does not expose `Agent` or `AgentSession`.
56
+ Removing `Agent.session` is not a breaking change for cross-extension consumers.
57
+
58
+ ## Design Overview
59
+
60
+ ### New delegate methods on `SubagentSession`
61
+
62
+ ```typescript
63
+ // Delegates to getAgentConversation(this._session)
64
+ getConversation(): string
65
+
66
+ // Delegates to getSessionContextPercent(this._session)
67
+ getContextPercent(): number | null
68
+
69
+ // Delegates to this._session.subscribe(fn) — satisfies SubscribableSession
70
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void
71
+
72
+ // Delegates to this._session.getSessionStats() — satisfies SessionLike
73
+ getSessionStats(): SessionStatsLike
74
+
75
+ // Delegates to this._session.messages
76
+ get messages(): readonly unknown[]
77
+ ```
78
+
79
+ With `subscribe()` and `getSessionStats()`, `SubagentSession` structurally satisfies both `SubscribableSession` and `SessionLike`.
80
+ This lets spawners pass a `SubagentSession` directly to `subscribeUIObserver()` and `tracker.setSession()` without exposing the raw `AgentSession`.
81
+
82
+ ### New intent-revealing methods on `Agent`
83
+
84
+ ```typescript
85
+ // Buffer-or-deliver: returns true if delivered, false if buffered
86
+ async steer(message: string): Promise<boolean>
87
+
88
+ // Returns true when a SubagentSession is available
89
+ isSessionReady(): boolean
90
+
91
+ // Delegates to SubagentSession.getConversation(), returns undefined if no session
92
+ getConversation(): string | undefined
93
+
94
+ // Delegates to SubagentSession.getContextPercent(), returns null if no session
95
+ getContextPercent(): number | null
96
+
97
+ // Delegates to SubagentSession.subscribe() for conversation-viewer live updates
98
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined
99
+
100
+ // Delegates to SubagentSession.messages for conversation-viewer rendering
101
+ get messages(): readonly unknown[]
102
+ ```
103
+
104
+ The `steer()` method replaces the duplicated buffer-or-deliver logic:
105
+
106
+ ```typescript
107
+ async steer(message: string): Promise<boolean> {
108
+ if (!this.subagentSession) {
109
+ this.queueSteer(message);
110
+ return false;
111
+ }
112
+ await this.subagentSession.steer(message);
113
+ return true;
114
+ }
115
+ ```
116
+
117
+ ### Observer callback narrowing
118
+
119
+ `AgentLifecycleObserver.onSessionCreated` changes from `(agent: Agent, session: AgentSession)` to `(agent: Agent)`.
120
+ Spawners access `agent.subagentSession!` (already public) which satisfies `SubscribableSession & SessionLike`:
121
+
122
+ ```typescript
123
+ // tools/background-spawner.ts — after
124
+ onSessionCreated: (agent) => {
125
+ const sub = agent.subagentSession!;
126
+ bgState.setSession(sub); // SubagentSession satisfies SessionLike
127
+ subscribeUIObserver(sub, bgState); // SubagentSession satisfies SubscribableSession
128
+ },
129
+ ```
130
+
131
+ ### Internal lifecycle wiring
132
+
133
+ `Agent.run()` already passes the session to `subscribeAgentObserver()`, which accepts `SubscribableSession`.
134
+ After the change, it passes `this.subagentSession` directly instead of `this.subagentSession.session`:
135
+
136
+ ```typescript
137
+ // lifecycle/agent.ts — Agent.run(), after
138
+ this.attachObserver(subscribeAgentObserver(this.subagentSession, this, { ... }));
139
+ this.observer?.onSessionCreated?.(this);
140
+ ```
141
+
142
+ ### Removals
143
+
144
+ - `Agent.session` getter — removed entirely.
145
+ - `SubagentSession.session` getter — marked `@internal`, kept for lifecycle-internal uses (the `getLastAssistantText()` private helper reads `this._session.messages`).
146
+ - `Agent.queueSteer()` — becomes private (called only from `Agent.steer()` and `Agent.flushPendingSteers()`).
147
+ - `Agent.flushPendingSteers()` — becomes private (called only from `Agent.run()`).
148
+
149
+ ## Module-Level Changes
150
+
151
+ ### `src/lifecycle/subagent-session.ts`
152
+
153
+ - Add `getConversation()`, `getContextPercent()`, `subscribe()`, `getSessionStats()`, `messages` getter.
154
+ - Import `getAgentConversation` from `#src/session/conversation` and `getSessionContextPercent`, `SessionStatsLike` from `#src/lifecycle/usage`.
155
+ - Mark the `session` getter with `@internal` JSDoc.
156
+
157
+ ### `src/lifecycle/agent.ts`
158
+
159
+ - Add `steer()`, `isSessionReady()`, `getConversation()`, `getContextPercent()`, `subscribeToUpdates()`, `messages` getter.
160
+ - Remove the `session` getter.
161
+ - Make `queueSteer()` and `flushPendingSteers()` private.
162
+ - In `run()`: pass `this.subagentSession` (not `this.subagentSession.session`) to `subscribeAgentObserver()` and fire `onSessionCreated(this)` without the session param.
163
+ - In `resume()`: pass `subagentSession` (not `subagentSession.session`) to `subscribeAgentObserver()`.
164
+ - Remove the `AgentSession` import.
165
+ - Update `AgentLifecycleObserver.onSessionCreated` to `(agent: Agent)` — no session param.
166
+
167
+ ### `src/service/service-adapter.ts`
168
+
169
+ - Replace the 6-line steer reach-through with `await record.steer(message)`.
170
+ - Remove the `!session` guard — `Agent.steer()` owns it.
171
+
172
+ ### `src/tools/steer-tool.ts`
173
+
174
+ - Replace the buffer-or-deliver dance with `const delivered = await record.steer(message)`.
175
+ - Use `record.getContextPercent()` instead of `getSessionContextPercent(session)`.
176
+ - Remove the `getSessionContextPercent` import.
177
+
178
+ ### `src/tools/get-result-tool.ts`
179
+
180
+ - Use `record.getContextPercent()` instead of `getSessionContextPercent(record.session)`.
181
+ - Use `record.getConversation()` instead of `getAgentConversation(record.session)`.
182
+ - Remove the `getSessionContextPercent` and `getAgentConversation` imports.
183
+
184
+ ### `src/tools/agent-tool.ts`
185
+
186
+ - Use `existing.isSessionReady()` instead of `!existing.session`.
187
+
188
+ ### `src/lifecycle/agent-manager.ts`
189
+
190
+ - Use `agent.isSessionReady()` instead of `agent?.session`.
191
+ - Update the `onSessionCreated` relay to `(agent) => options.observer!.onSessionCreated!(agent)` — no session param.
192
+
193
+ ### `src/observation/notification.ts`
194
+
195
+ - Use `record.getContextPercent()` instead of `getSessionContextPercent(record.session)`.
196
+ - Remove the `getSessionContextPercent` import.
197
+
198
+ ### `src/ui/conversation-viewer.ts`
199
+
200
+ - Remove `session: AgentSession` from `ConversationViewerOptions`.
201
+ - Remove the `private session: AgentSession` field.
202
+ - Use `this.record.subscribeToUpdates(() => ...)` instead of `session.subscribe(...)`.
203
+ - Use `this.record.messages` instead of `this.session.messages`.
204
+ - Use `this.record.getContextPercent()` instead of `getSessionContextPercent(this.record.session)`.
205
+ - Remove the `AgentSession` and `getSessionContextPercent` imports.
206
+
207
+ ### `src/ui/agent-menu.ts`
208
+
209
+ - Use `record.isSessionReady()` instead of `record.session` check.
210
+ - Remove the `session` variable and stop passing it to `ConversationViewer`.
211
+
212
+ ### `src/tools/background-spawner.ts`
213
+
214
+ - Update `onSessionCreated` callback: `(agent) => { const sub = agent.subagentSession!; ... }`.
215
+ - Pass `sub` instead of `session` to `bgState.setSession()` and `subscribeUIObserver()`.
216
+
217
+ ### `src/tools/foreground-runner.ts`
218
+
219
+ - Same pattern as `background-spawner.ts`.
220
+
221
+ ### `test/helpers/mock-session.ts`
222
+
223
+ - Update `createSubagentSessionStub` to include the new delegate methods (`getConversation`, `getContextPercent`, `subscribe`, `getSessionStats`, `messages`).
224
+ - The stub's `subscribe` can delegate to the underlying mock session's `subscribe`.
225
+
226
+ ### Test files requiring updates
227
+
228
+ - `test/lifecycle/subagent-session.test.ts` — add tests for new delegate methods.
229
+ - `test/lifecycle/agent.test.ts` — add tests for `steer()`, `isSessionReady()`, `getConversation()`, `getContextPercent()`; update `queueSteer` and `flushPendingSteers` tests (now private, tested through `steer()`); update `session` getter tests to `isSessionReady()`.
230
+ - `test/service/service-adapter.test.ts` — update steer tests (no more session reach-through).
231
+ - `test/tools/steer-tool.test.ts` — update to use `Agent.steer()` semantics.
232
+ - `test/tools/get-result-tool.test.ts` — update context-percent and conversation assertions.
233
+ - `test/tools/agent-tool.test.ts` — update resume guard to use `isSessionReady()`.
234
+ - `test/tools/background-spawner.test.ts` — update `onSessionCreated` callback assertions.
235
+ - `test/tools/foreground-runner.test.ts` — update `onSessionCreated` callback assertions.
236
+ - `test/observation/notification.test.ts` — update context-percent assertions.
237
+ - `test/conversation-viewer.test.ts` — remove `session` from viewer options, update to use `record` methods.
238
+ - `test/ui/agent-menu.test.ts` — update session guard assertions.
239
+ - `test/lifecycle/agent-manager.test.ts` — update resume guard + observer relay tests.
240
+
241
+ ## Test Impact Analysis
242
+
243
+ 1. **New unit tests enabled:** Direct testing of `SubagentSession.getConversation()`, `getContextPercent()`, `subscribe()`, `getSessionStats()`, `messages` — these were previously only testable by reaching through the raw session.
244
+ Direct testing of `Agent.steer()` buffer-or-deliver logic — previously scattered across consumer tests.
245
+ 2. **Existing tests that become simpler:** `steer-tool.test.ts` and `service-adapter.test.ts` no longer need to set up the session-present/session-absent dance — they test through `Agent.steer()`.
246
+ `get-result-tool.test.ts` no longer needs mock sessions for context-percent assertions.
247
+ 3. **Existing tests that stay as-is:** `SubagentSession.runTurnLoop()` and `resumeTurnLoop()` tests — they exercise turn driving, which is unrelated.
248
+ `Agent.run()` integration tests — they verify the full lifecycle including observer wiring.
249
+
250
+ ## TDD Order
251
+
252
+ 1. Add delegate methods to `SubagentSession` (`getConversation`, `getContextPercent`, `subscribe`, `getSessionStats`, `messages`).
253
+ Update `createSubagentSessionStub` test helper to include the new methods.
254
+ Test: unit tests for each delegate method in `subagent-session.test.ts`.
255
+ Commit: `feat: add delegate methods to SubagentSession for session encapsulation (#277)`.
256
+
257
+ 2. Add intent-revealing methods to `Agent` (`steer`, `isSessionReady`, `getConversation`, `getContextPercent`, `subscribeToUpdates`, `messages`).
258
+ Test: unit tests for each method in `agent.test.ts`.
259
+ Commit: `feat: add session-encapsulation methods to Agent (#277)`.
260
+
261
+ 3. Migrate steer callers: replace the buffer-or-deliver reach-through in `service-adapter.ts` and `steer-tool.ts` with `record.steer()`.
262
+ Replace `getSessionContextPercent(session)` in `steer-tool.ts` with `record.getContextPercent()`.
263
+ Make `queueSteer()` and `flushPendingSteers()` private on `Agent`.
264
+ Update tests in `service-adapter.test.ts`, `steer-tool.test.ts`, `agent.test.ts` (the `queueSteer`/`flushPendingSteers` tests become tests for `Agent.steer()`; existing tests from step 2 may cover this).
265
+ Commit: `refactor: use Agent.steer for buffer-or-deliver (#277)`.
266
+
267
+ 4. Migrate context-percent, conversation, and readiness callers: `get-result-tool.ts`, `agent-tool.ts`, `agent-manager.ts`, `notification.ts`.
268
+ Update corresponding test files.
269
+ Commit: `refactor: replace session reach-throughs in tools and observation (#277)`.
270
+
271
+ 5. Refactor conversation viewer and agent-menu: remove `session: AgentSession` from `ConversationViewerOptions`, use `record` methods for subscribe/messages/contextPercent.
272
+ Update `conversation-viewer.test.ts` and `agent-menu.test.ts`.
273
+ Commit: `refactor: remove raw session from conversation viewer (#277)`.
274
+
275
+ 6. Narrow `onSessionCreated` callback: change `AgentLifecycleObserver.onSessionCreated` to `(agent: Agent)` — no session param.
276
+ Update `Agent.run()` and `Agent.resume()` to pass `SubagentSession` (not raw session) to `subscribeAgentObserver`.
277
+ Update spawners (`background-spawner.ts`, `foreground-runner.ts`) and `agent-manager.ts` relay.
278
+ Update corresponding test files.
279
+ Commit: `refactor: narrow onSessionCreated to hide raw AgentSession (#277)`.
280
+
281
+ 7. Remove `Agent.session` getter.
282
+ Mark `SubagentSession.session` getter as `@internal`.
283
+ Remove `AgentSession` import from `agent.ts`.
284
+ Update any remaining tests that reference `Agent.session` (use `agent.isSessionReady()` or `agent.subagentSession`).
285
+ Verify with `pnpm run check` and `pnpm -r run test`.
286
+ Commit: `refactor: remove Agent.session getter (#277)`.
287
+
288
+ 8. Update the architecture doc's "Session encapsulation debt" section to reflect completion.
289
+ Commit: `docs: mark session encapsulation debt resolved (#277)`.
290
+
291
+ ## Risks and Mitigations
292
+
293
+ | Risk | Mitigation |
294
+ | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
295
+ | `SubagentSession` might not structurally satisfy `SubscribableSession` or `SessionLike` | Verify with `pnpm run check` after step 1 — the delegate methods must match the expected signatures exactly. |
296
+ | Making `queueSteer`/`flushPendingSteers` private breaks tests | Step 3 explicitly migrates those tests to exercise `Agent.steer()` instead. |
297
+ | The conversation viewer's `messages` accessor returns `readonly unknown[]` which loses typing | The viewer already casts messages to `{ role: string; [key: string]: unknown }` — the cast is unchanged, and the viewer's rendering is unaffected. |
298
+ | Narrowing `onSessionCreated` could break the `agent-manager.ts` relay's non-null assertion | The relay checks `options.observer?.onSessionCreated` before wiring — the narrowed signature is structurally compatible; the only change is dropping the second parameter. |
299
+ | Large number of test files to update | Steps 3–6 group migrations by concern (steer, stats/conversation/readiness, viewer, observer) so each commit is self-contained and reviewable. |
300
+
301
+ ## Open Questions
302
+
303
+ - None — the issue's proposed methods are unambiguous and the acceptance criteria are clear.
304
+ The only design addition beyond the issue is narrowing `onSessionCreated` and adding `messages`/`subscribeToUpdates` for the conversation viewer, both of which are required to satisfy the "no production module outside lifecycle/ references AgentSession" acceptance criterion.
@@ -56,3 +56,42 @@ Pre-completion reviewer: initial FAIL (MD060 table alignment in SKILL.md, auto-f
56
56
  - `print-mode.test.ts` now mocks `#src/lifecycle/create-subagent-session` (was `#src/lifecycle/agent-runner`); `index.ts` wraps the factory as `(params) => createSubagentSession(params, deps)`, so the module mock still intercepts it.
57
57
  - fallow stayed clean throughout — the transient duplication of IO interfaces + turn-loop helpers between `agent-runner.ts` and the new modules (steps 3–5) was removed in step 6 before the pre-completion gate ran.
58
58
  - Reviewer's two minor non-blocking notes: SKILL.md Session-domain count now lists `conversation.ts` but still omits the pre-existing `content-items.ts` (drift predates this issue); `create-subagent-session.ts` keeps an accurate "old runner's runAgent()" provenance comment.
59
+
60
+ ## Stage: Final Retrospective (2026-05-30T13:37:00Z)
61
+
62
+ ### Session summary
63
+
64
+ Shipped issue #265 (`pi-subagents-v13.1.0`) and ran the retrospective across all three stages (planning, TDD implementation, ship).
65
+ The implementation landed cleanly in 7 TDD commits + 1 docs commit; test count 951 → 960.
66
+
67
+ ### Observations
68
+
69
+ #### What went well
70
+
71
+ - The lift-and-shift strategy (new modules alongside old, swap consumers, delete old) kept every intermediate commit compiling and the suite green — zero broken-baseline moments across 7 steps.
72
+ - The `createSubagentSessionStub` pattern (steer/dispose delegate to the wrapped `MockSession`) let 6 tool/service test files migrate with a one-line change each, preserving all existing session-spy assertions.
73
+ - Verification ran incrementally: `pnpm vitest run <file>` after every Red/Green, `pnpm run check` after every interface change, and `pnpm -r run test` (full cross-package) after step 6's deletion to confirm the permission system (1504 tests) was unaffected.
74
+
75
+ #### What caused friction (agent side)
76
+
77
+ 1. `missing-context` — The plan's step-5 file list omitted 6 tool/service test files (`steer-tool`, `agent-tool`, `background-spawner`, `foreground-runner`, `get-result-tool`, `service-adapter`) that directly set `record.execution = { session, outputFile }`.
78
+ The existing planning rule ("grep all test files for every removed symbol") was present but was not applied to the renamed `.execution` property — only to removed type imports.
79
+ Impact: step 5 took ~2× expected time; each file was discovered reactively via `tsc --noEmit` errors.
80
+ 2. `rabbit-hole` — Step 6's `sed` invocation for import-path renames failed silently on macOS BSD `sed` because the `#` delimiter clashed with `#test/helpers/...` paths. 3 consecutive tool calls were spent diagnosing and retrying before switching to per-file `sed` with `@` delimiters.
81
+ Impact: added ~3 minutes of friction; the `edit` tool would have been safer for targeted, known-file replacements.
82
+ 3. `other` (autoformat race) — The `pi-autoformat` extension ran concurrently with `git commit` twice during the docs phase, causing `.git/index.lock` conflicts.
83
+ Recovery was mechanical (remove lock, retry) but required user intervention once.
84
+ Impact: one user prompt to retry; no rework.
85
+ 4. `other` (markdown table alignment) — Replacing short table cells with long module lists in SKILL.md broke MD060's compact-table rule.
86
+ The pre-completion reviewer caught it (initial FAIL); `rumdl fmt` auto-fixed it.
87
+ Impact: one amend + re-lint cycle; self-identified after reviewer report.
88
+
89
+ #### What caused friction (user side)
90
+
91
+ - No user-side friction observed.
92
+ The user's only intervention was a retry prompt after the autoformat/git-lock race — a timing issue, not a judgment or context gap.
93
+
94
+ ### Changes made
95
+
96
+ 1. `.pi/skills/testing/SKILL.md` — added rule about `??` swallowing explicit `undefined` in factory overrides (under "Vitest mock patterns").
97
+ 2. `packages/pi-subagents/docs/retro/0265-born-complete-subagent-session.md` — appended Final Retrospective stage entry.
@@ -0,0 +1,80 @@
1
+ ---
2
+ issue: 277
3
+ issue_title: "Encapsulate AgentSession behind SubagentSession; retire the remaining agent.session reach-throughs"
4
+ ---
5
+
6
+ # Retro: #277 — Encapsulate AgentSession behind SubagentSession
7
+
8
+ ## Stage: Planning (2026-05-30T12:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced an 8-step TDD plan covering delegate methods on `SubagentSession`, intent-revealing methods on `Agent`, caller migration across tools/service/UI/observation, observer callback narrowing, and `Agent.session` getter removal.
13
+ The plan extends the issue's three proposed methods with `getContextPercent()`, `subscribeToUpdates()`, and `messages` to fully satisfy the acceptance criterion that no production module outside `lifecycle/` references the raw `AgentSession`.
14
+
15
+ ### Observations
16
+
17
+ - `subscribeAgentObserver` already accepts `SubscribableSession` (not `AgentSession`), so adding `subscribe()` to `SubagentSession` enables passing it directly — no `session` getter needed for observer wiring.
18
+ - The `onSessionCreated` observer callback delivers raw `AgentSession` to spawners in `tools/`.
19
+ The plan narrows it to `(agent: Agent)` and has spawners use `agent.subagentSession!` which structurally satisfies both `SubscribableSession` and `SessionLike` via the new delegate methods.
20
+ - The conversation viewer uses `session.messages` for rendering and `session.subscribe()` for live updates — both require delegate methods beyond the issue's three proposed methods.
21
+ - `queueSteer()` and `flushPendingSteers()` become private after migration, which requires migrating existing tests that call them directly.
22
+ - The public API surface (`SubagentsService` + `SubagentRecord`) is unaffected — `Agent` is internal.
23
+
24
+ ## Stage: Implementation — TDD (2026-05-30T15:10:00Z)
25
+
26
+ ### Session summary
27
+
28
+ All 8 TDD steps completed in one session. 13 commits landed: 2 `feat:`, 5 `refactor:`, 2 `docs:`, 1 `style:`, plus the earlier plan and retro commits.
29
+ Test count went from 960 → 973 (+13 net: +18 new tests, −5 removed tests for the retired `session` getter and `queueSteer`/`flushPendingSteers` private methods).
30
+
31
+ ### Observations
32
+
33
+ - `subscribeAgentObserver` already accepted `SubscribableSession`, so `SubagentSession` (once it grew a `subscribe()` delegate) could be passed directly in `Agent.run()` and `Agent.resume()` — no cast needed.
34
+ - Adding `messages` to `MockSession` interface was a minor unplanned step: the field existed at runtime (via `createMockSession`'s spread) but the interface didn't declare it, causing a type error when `createSubagentSessionStub` tried to forward it.
35
+ - `onSessionCreated` tests in `foreground-runner.test.ts` called the callback with 2 args `(record, mockSess)`.
36
+ After narrowing, the first test case was missing a `subagentSession` setup; fixed by setting `record.subagentSession` before invoking the callback.
37
+ - The `get-result-tool.test.ts` verbose conversation test needed updating: previously built a mock session with messages and passed it directly; now the stub's `getConversation` must return the expected text via `mockReturnValue`.
38
+ - Pre-completion reviewer returned **WARN** for two stale `architecture.md` passages: (1) conversation-viewer description still said "subscribes directly to `AgentSession`"; (2) `Agent` classDiagram still listed `queueSteer`/`flushPendingSteers` as public and omitted the 6 new public members.
39
+ Both fixed in a `docs:` commit before closing.
40
+
41
+ ## Stage: Final Retrospective (2026-05-30T16:00:00Z)
42
+
43
+ ### Session summary
44
+
45
+ Issue #277 shipped across three sessions (planning, TDD, ship) in a single day.
46
+ All 8 TDD steps completed cleanly, 13 implementation commits landed, and `pi-subagents-v13.2.0` released.
47
+
48
+ ### Observations
49
+
50
+ #### What went well
51
+
52
+ - Planning correctly identified scope beyond the issue's three proposed methods: `getContextPercent()`, `subscribeToUpdates()`, and `messages` were needed to satisfy the "no production module outside `lifecycle/` references `AgentSession`" acceptance criterion.
53
+ This prevented mid-TDD design revisions.
54
+ - The discovery that `subscribeAgentObserver` already accepted `SubscribableSession` meant adding `subscribe()` to `SubagentSession` was sufficient for observer wiring — no type cast or adapter needed.
55
+ - Lift-and-shift execution was precise: new methods added first (steps 1-2), callers migrated by concern (steps 3-6), old getter removed last (step 7).
56
+ No step broke any other step's tests.
57
+ - Pre-completion reviewer caught two stale `architecture.md` passages (classDiagram with removed public methods, conversation-viewer description) that the implementation steps missed.
58
+ Both fixed in a `docs:` commit before closing.
59
+
60
+ #### What caused friction (agent side)
61
+
62
+ 1. `missing-context` — `MockSession` interface lacked `messages`.
63
+ The field existed at runtime (via `createMockSession`'s spread + `Record<string, unknown>` return type) but the `MockSession` interface didn't declare it.
64
+ Adding `messages` to `createSubagentSessionStub` triggered a type error.
65
+ Impact: 2 extra edits to `mock-session.ts` (add field to interface, add to `base` object), no rework.
66
+ 2. `missing-context` — `get-result-tool.test.ts` conversation test relied on raw mock session messages.
67
+ After migrating to `record.getConversation()`, the stub's `getConversation` returned `""` by default.
68
+ The test needed `stub.getConversation.mockReturnValue("...")` instead.
69
+ Impact: 1 test update, caught immediately by `pnpm vitest run`.
70
+ 3. `missing-context` — `foreground-runner.test.ts` called `onSessionCreated(record, mockSess)` with 2 args.
71
+ After narrowing the callback to `(agent: Agent)`, the first test lacked `record.subagentSession` setup.
72
+ Impact: 2 test blocks updated, caught by `pnpm vitest run`.
73
+ 4. `missing-context` — First `get-result-tool.ts` edit left a double-nested `if (conversation)` block.
74
+ The multi-edit replaced the outer `if (params.verbose && record.session)` but preserved the inner guard, creating `if (conversation) { if (conversation) { ... } }`.
75
+ Impact: Self-caught on immediate read-back; fixed in the same commit, no rework.
76
+
77
+ #### What caused friction (user side)
78
+
79
+ - None observed.
80
+ The user's involvement was limited to the `/plan-issue`, `/tdd-plan`, `/ship-issue`, and `/retro` prompts — no mid-session corrections or redirections were needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "13.1.0",
3
+ "version": "13.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -113,7 +113,7 @@ export class AgentManager {
113
113
  this.observer?.onAgentStarted(agent);
114
114
  },
115
115
  onSessionCreated: options.observer?.onSessionCreated
116
- ? (agent, session) => options.observer!.onSessionCreated!(agent, session)
116
+ ? (agent) => options.observer!.onSessionCreated!(agent)
117
117
  : undefined,
118
118
  onRunFinished: (agent) => {
119
119
  if (options.isBackground) {
@@ -202,7 +202,7 @@ export class AgentManager {
202
202
  signal?: AbortSignal,
203
203
  ): Promise<Agent | undefined> {
204
204
  const agent = this.agents.get(id);
205
- if (!agent?.session) return undefined;
205
+ if (!agent?.isSessionReady()) return undefined;
206
206
  await agent.resume(prompt, signal);
207
207
  return agent;
208
208
  }
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { Model } from "@earendil-works/pi-ai";
22
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
22
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
23
23
  import { debugLog } from "#src/debug";
24
24
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
25
25
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
@@ -36,8 +36,8 @@ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType,
36
36
  export interface AgentLifecycleObserver {
37
37
  /** Fires when the agent transitions to running (inside run(), after markRunning). */
38
38
  onStarted?(agent: Agent): void;
39
- /** Fires once the session is created — delivers the session to external consumers. */
40
- onSessionCreated?(agent: Agent, session: AgentSession): void;
39
+ /** Fires once the session is created — the agent's subagentSession is now available. */
40
+ onSessionCreated?(agent: Agent): void;
41
41
  /** Fires once when the run completes or fails (for concurrency drain). */
42
42
  onRunFinished?(agent: Agent): void;
43
43
  /** Fires on compaction events during the run. */
@@ -154,16 +154,52 @@ export class Agent {
154
154
  /** Number of steer messages waiting to be delivered. */
155
155
  get pendingSteerCount(): number { return this._pendingSteers.length; }
156
156
 
157
- /** The active agent session, or undefined before the session is created. */
158
- get session(): AgentSession | undefined {
159
- return this.subagentSession?.session;
160
- }
161
-
162
157
  /** Path to the agent's session JSONL file, or undefined if not yet available. */
163
158
  get outputFile(): string | undefined {
164
159
  return this.subagentSession?.outputFile;
165
160
  }
166
161
 
162
+ /** Returns true when a SubagentSession is available (session is ready). */
163
+ isSessionReady(): boolean {
164
+ return this.subagentSession != null;
165
+ }
166
+
167
+ /**
168
+ * Deliver or buffer a steer message.
169
+ * Returns true when delivered immediately; false when buffered for later delivery.
170
+ */
171
+ async steer(message: string): Promise<boolean> {
172
+ if (!this.subagentSession) {
173
+ this.queueSteer(message);
174
+ return false;
175
+ }
176
+ await this.subagentSession.steer(message);
177
+ return true;
178
+ }
179
+
180
+ /** Return the session conversation as formatted text, or undefined if no session. */
181
+ getConversation(): string | undefined {
182
+ return this.subagentSession?.getConversation();
183
+ }
184
+
185
+ /** Return the session context window utilization (0-100), or null if unavailable. */
186
+ getContextPercent(): number | null {
187
+ return this.subagentSession?.getContextPercent() ?? null;
188
+ }
189
+
190
+ /**
191
+ * Subscribe to session events for live updates (e.g., conversation viewer).
192
+ * Returns an unsubscribe function, or undefined if no session is available.
193
+ */
194
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
195
+ return this.subagentSession?.subscribe(fn);
196
+ }
197
+
198
+ /** The session's message history, or an empty array if no session. */
199
+ get messages(): readonly unknown[] {
200
+ return this.subagentSession?.messages ?? [];
201
+ }
202
+
167
203
  constructor(init: AgentInit) {
168
204
  // Identity
169
205
  this.id = init.id;
@@ -264,12 +300,11 @@ export class Agent {
264
300
  return;
265
301
  }
266
302
 
267
- const session = this.subagentSession.session;
268
303
  this.flushPendingSteers();
269
- this.attachObserver(subscribeAgentObserver(session, this, {
304
+ this.attachObserver(subscribeAgentObserver(this.subagentSession, this, {
270
305
  onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
271
306
  }));
272
- this.observer?.onSessionCreated?.(this, session);
307
+ this.observer?.onSessionCreated?.(this);
273
308
 
274
309
  const runConfig = this._getRunConfig?.();
275
310
  try {
@@ -301,7 +336,7 @@ export class Agent {
301
336
  }
302
337
 
303
338
  this.resetForResume(Date.now());
304
- this.attachObserver(subscribeAgentObserver(subagentSession.session, this, {
339
+ this.attachObserver(subscribeAgentObserver(subagentSession, this, {
305
340
  onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
306
341
  }));
307
342
 
@@ -404,17 +439,17 @@ export class Agent {
404
439
 
405
440
  /**
406
441
  * Buffer a steer message for delivery once the session is ready.
407
- * Called when steer is requested before onSessionCreated fires.
442
+ * Called internally from steer() before the session is ready.
408
443
  */
409
- queueSteer(message: string): void {
444
+ private queueSteer(message: string): void {
410
445
  this._pendingSteers.push(message);
411
446
  }
412
447
 
413
448
  /**
414
449
  * Flush all buffered steer messages to the session and clear the buffer.
415
- * Called once the session is available, delegating to SubagentSession.steer.
450
+ * Called once the session is available (inside run()).
416
451
  */
417
- flushPendingSteers(): void {
452
+ private flushPendingSteers(): void {
418
453
  for (const msg of this._pendingSteers) {
419
454
  this.subagentSession?.steer(msg).catch(() => {});
420
455
  }
@@ -16,7 +16,9 @@ import {
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
18
18
  import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
19
+ import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
19
20
  import { extractText } from "#src/session/context";
21
+ import { getAgentConversation } from "#src/session/conversation";
20
22
 
21
23
  /** Outcome of one turn loop. */
22
24
  export interface TurnLoopResult {
@@ -61,7 +63,10 @@ export class SubagentSession {
61
63
  private readonly meta: SubagentSessionMeta,
62
64
  ) {}
63
65
 
64
- /** Wrapped session — exposed for observer wiring + consumers; retired by #277. */
66
+ /**
67
+ * Wrapped session — for lifecycle-internal use only.
68
+ * @internal consumers outside lifecycle/ use the delegate methods below.
69
+ */
65
70
  get session(): AgentSession {
66
71
  return this._session;
67
72
  }
@@ -146,6 +151,31 @@ export class SubagentSession {
146
151
  await this._session.steer(message);
147
152
  }
148
153
 
154
+ /** Return the session's conversation as formatted text. */
155
+ getConversation(): string {
156
+ return getAgentConversation(this._session);
157
+ }
158
+
159
+ /** Return the session context window utilization (0-100), or null when unavailable. */
160
+ getContextPercent(): number | null {
161
+ return getSessionContextPercent(this._session);
162
+ }
163
+
164
+ /** Subscribe to session events. Satisfies `SubscribableSession`. */
165
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void {
166
+ return this._session.subscribe(fn);
167
+ }
168
+
169
+ /** Return session token statistics. Satisfies `SessionLike`. */
170
+ getSessionStats(): SessionStatsLike {
171
+ return this._session.getSessionStats();
172
+ }
173
+
174
+ /** The session's message history. */
175
+ get messages(): readonly unknown[] {
176
+ return this._session.messages as readonly unknown[];
177
+ }
178
+
149
179
  /** Tear down: session.dispose() + emit `disposed` (registry unregister). */
150
180
  dispose(): void {
151
181
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
@@ -1,5 +1,5 @@
1
1
  import { debugLog } from "#src/debug";
2
- import { getLifetimeTotal, getSessionContextPercent } from "#src/lifecycle/usage";
2
+ import { getLifetimeTotal } from "#src/lifecycle/usage";
3
3
  import type { Agent } from "#src/types";
4
4
  import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
5
5
 
@@ -46,7 +46,7 @@ export function formatTaskNotification(record: Agent, resultMaxLen: number): str
46
46
  const status = getStatusLabel(record.status, record.error);
47
47
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
48
48
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
49
- const contextPercent = getSessionContextPercent(record.session);
49
+ const contextPercent = record.getContextPercent();
50
50
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
51
51
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
52
52