@gotgenes/pi-subagents 17.3.0 → 17.5.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,306 @@
1
+ ---
2
+ issue: 463
3
+ issue_title: "pi-subagents: add file-snapshot source to /subagent-sessions for evicted agents"
4
+ ---
5
+
6
+ # File-snapshot source for evicted agents in `/subagent-sessions`
7
+
8
+ ## Release Recommendation
9
+
10
+ **Release:** ship independently
11
+
12
+ Architecture roadmap Phase 19 Step 4b ([#463]) carries `Release: independent`.
13
+ This is a new capability the bespoke viewer never had — it gates nothing and is not a Step 5 prerequisite — so it ships on its own once landed.
14
+
15
+ ## Problem Statement
16
+
17
+ The [#445] native-session-navigation slice sources transcripts live, from `manager.listAgents()` only.
18
+ The manager's cleanup sweep (`SubagentManager.cleanup()`, every 60 s) disposes a completed/stopped/errored record 10 minutes after it finishes — `disposeSession()` frees the heavy in-memory session (its message history included) and the record is deleted from the map.
19
+ The lightweight session JSONL persists on disk at `Subagent.outputFile`, but with no live record the agent is unreachable from `/subagent-sessions`.
20
+
21
+ This step makes a fully-evicted agent navigable: it adds the file-snapshot `TranscriptSource` branch the #445 seam was designed for, and broadens the picker's candidate set to include evicted agents.
22
+
23
+ ## Goals
24
+
25
+ - Implement `fileSnapshotSource(outputFile, readFile)` in `src/ui/session-navigation.ts`: `parseSessionEntries(readFile(outputFile))` → drop the `SessionHeader` → `buildSessionContext(...).messages` → a static (no-subscribe, no-streaming) `TranscriptSource`.
26
+ - Broaden the `/subagent-sessions` candidate set to include evicted agents, deduped against live records.
27
+ - Render an evicted agent's transcript read-only from its persisted file when picked; the renderer (`buildTranscriptComponents`) and overlay (`TranscriptOverlay`) are untouched.
28
+ - Inject `readFile` as a parameter so the pure module performs no `fs` calls.
29
+
30
+ This change is **not** breaking: it is additive (a new source branch and a broadened candidate set); no existing behavior, output shape, or default changes for a tracked agent.
31
+
32
+ ## Non-Goals
33
+
34
+ - No directory scan of the tasks directory and no surfacing of prior-process orphan session files (see Design Overview → Candidate-set decision).
35
+ - No change to the cleanup sweep's 10-minute eviction policy or to `disposeSession()`.
36
+ - No renderer or overlay changes — `session-navigator.ts`'s `buildTranscriptComponents` / `TranscriptOverlay` / `addMessageComponents` are unchanged (Step 4a, [#462], already brought them to parity).
37
+ - No `switchSession` / `loadEntriesFromFile` (rejected by the Step 1 spike, [#446]).
38
+ - No live re-read or file-watching of the snapshot — a file source is a static snapshot by design.
39
+
40
+ ## Background
41
+
42
+ Relevant modules:
43
+
44
+ - `src/ui/session-navigation.ts` — the pure, unit-testable core: `NavigableSubagent` (narrow record view), `NavigationEntry`, `TranscriptSource`, `listNavigableAgents(agents, registry)`, `liveSource(record)`, `buildLabel(record, registry)`.
45
+ It imports SDK *types* only (`ToolDefinition`) and the lifecycle type `SubagentStatus`.
46
+ - `src/ui/session-navigator.ts` — the SDK/TUI consumer half: `SessionNavigatorHandler` (picker + source selection) and `TranscriptOverlay` (read-only scrollable renderer).
47
+ The handler already calls `liveSource(entry.record)`; the source is the only seam that varies.
48
+ - `src/lifecycle/subagent-manager.ts` — `listAgents()` (sorted live records), `cleanup()` (the 10-minute sweep), `clearCompleted()` (new-session wipe, called from `handlers/lifecycle.ts` on `session_start` / `session_before_switch`), `removeRecord()` (`disposeSession()` + `agents.delete`).
49
+ - `src/lifecycle/subagent.ts` — `Subagent` exposes `id`, `type`, `description` (readonly), and getters `status`, `startedAt`, `completedAt`, `toolUses`, `outputFile`.
50
+ - `src/index.ts` — registers `subagent-sessions`, calling `sessionNavigator.handle({ ui, agents: manager.listAgents(), registry, cwd })`.
51
+
52
+ SDK functions (`@earendil-works/pi-coding-agent`): `parseSessionEntries(content): FileEntry[]`, `buildSessionContext(entries: SessionEntry[]): { messages: AgentMessage[]; … }`, and the types `SessionEntry` / `SessionHeader`.
53
+ `FileEntry = SessionHeader | SessionEntry`; only the header has `type: "session"`, so `entries.filter((e): e is SessionEntry => e.type !== "session")` is a sound type guard.
54
+ `SessionMessage` (the navigator's message type) is `SessionContext["messages"][number]` = `AgentMessage`, so `buildSessionContext(...).messages` matches `TranscriptSource.getMessages()` with no cast.
55
+
56
+ Constraints from AGENTS.md / skills that apply:
57
+
58
+ - `code-design` SDK-boundary guideline: pure helpers should avoid SDK *runtime* imports.
59
+ `fileSnapshotSource` calls the SDK runtime functions `parseSessionEntries` / `buildSessionContext` directly (see Design Overview → SDK-runtime decision); there is no `no-restricted-imports` lint rule, and the module already depends on SDK types.
60
+ - `testing`: components used by `session-navigator.ts` need `initTheme(undefined, false)` in `beforeAll` (already present in `session-navigator.test.ts`).
61
+ - `fallow dead-code`: a new export with no caller fails the gate.
62
+ Producers (`fileSnapshotSource`, `listEvicted`) gain their caller in the integration step within the same push; the pushed tip is dead-code-clean (see Risks).
63
+
64
+ ## Design Overview
65
+
66
+ ### Candidate-set decision — manager-retained descriptors (not a directory scan)
67
+
68
+ The issue proposes enumerating persisted JSONL files in the tasks directory.
69
+ The persisted child session carries **no** subagent `type` or `description` — those live only on the in-memory `Subagent` record; the file holds only the conversation plus a header (`id`, `timestamp`, `cwd`, `parentSession`).
70
+ A directory scan therefore yields degraded labels (agent type → generic placeholder; curated `description` → a raw first-prompt snippet or filename) and must parse every file on every picker open.
71
+
72
+ Decision (confirmed with the operator): the manager retains a lightweight **descriptor** at eviction time instead.
73
+ When `cleanup()` disposes a record that has a persisted file, it first copies the record's label fields plus `outputFile` into a separate `evicted` map (no messages — the heavy state is still freed, so memory stays bounded by the number of subagents spawned, not their transcripts).
74
+ The picker's candidate set is `live ∪ evicted`, deduped by id, with identical rich labels for both.
75
+
76
+ Coverage: this surfaces agents evicted **in the current root session** — which are the cleanup sweep's only targets.
77
+ A fresh `SubagentManager` is created per session and `clearCompleted()` runs on `session_start`, so prior-process subagents are never reloaded into the manager; the only files a directory scan would *additionally* surface are old-session orphans, which are exactly the ones with degraded labels.
78
+ Picking an evicted agent still reads its file to render (the messages are gone from memory regardless) — so `fileSnapshotSource` is required either way; the descriptor only feeds the *label*.
79
+
80
+ ### SDK-runtime decision — call `parseSessionEntries` / `buildSessionContext` directly
81
+
82
+ `fileSnapshotSource` imports and calls these SDK runtime functions directly rather than injecting them.
83
+ Rationale: they are deterministic parsers of Pi's own session format; the injected `readFile` already provides the unit-test seam (a fake `readFile` returning fixture JSONL fully exercises parse → drop-header → build → messages), so injecting the parsers adds wiring without testability gain.
84
+ There is no `no-restricted-imports` rule, and `session-navigation.ts` already imports SDK types.
85
+
86
+ ### Data shapes
87
+
88
+ `EvictedSubagent` — the descriptor, owned by lifecycle (the manager constructs it), imported as a type by the UI (the UI already imports `SubagentStatus` from lifecycle):
89
+
90
+ ```typescript
91
+ // src/lifecycle/subagent-manager.ts
92
+ export interface EvictedSubagent {
93
+ readonly id: string;
94
+ readonly type: SubagentType;
95
+ readonly description: string;
96
+ readonly status: SubagentStatus;
97
+ readonly startedAt: number;
98
+ readonly completedAt: number | undefined;
99
+ readonly toolUses: number;
100
+ readonly outputFile: string;
101
+ }
102
+ ```
103
+
104
+ `NavigationEntry` changes from `{ record, label }` to a discriminated union so the handler can pick the source by kind:
105
+
106
+ ```typescript
107
+ // src/ui/session-navigation.ts
108
+ export type NavigationEntry =
109
+ | { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
110
+ | { readonly kind: "evicted"; readonly label: string; readonly outputFile: string };
111
+ ```
112
+
113
+ `buildLabel` is narrowed to a per-call interface (ISP) that both a `NavigableSubagent` and an `EvictedSubagent` satisfy, and gains an `evicted` marker:
114
+
115
+ ```typescript
116
+ interface LabelFields {
117
+ readonly type: SubagentType;
118
+ readonly description: string;
119
+ readonly status: SubagentStatus;
120
+ readonly startedAt: number;
121
+ readonly completedAt: number | undefined;
122
+ readonly toolUses: number;
123
+ }
124
+
125
+ function buildLabel(fields: LabelFields, registry: AgentConfigLookup, evicted = false): string {
126
+ const name = getDisplayName(fields.type, registry);
127
+ const duration = formatDuration(fields.startedAt, fields.completedAt);
128
+ const marker = evicted ? " · evicted (snapshot)" : "";
129
+ return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
130
+ }
131
+ ```
132
+
133
+ ### `fileSnapshotSource`
134
+
135
+ ```typescript
136
+ export function fileSnapshotSource(
137
+ outputFile: string,
138
+ readFile: (path: string) => string,
139
+ ): TranscriptSource {
140
+ const entries = parseSessionEntries(readFile(outputFile));
141
+ const sessionEntries = entries.filter(
142
+ (entry): entry is SessionEntry => entry.type !== "session",
143
+ );
144
+ const { messages } = buildSessionContext(sessionEntries);
145
+ return {
146
+ getMessages: () => messages,
147
+ subscribe: () => undefined, // static snapshot — no live updates
148
+ streaming: () => undefined, // never streaming
149
+ getToolDefinition: () => undefined, // no live tool registry off disk
150
+ };
151
+ }
152
+ ```
153
+
154
+ ### `listNavigableAgents` (broadened) and the handler call site
155
+
156
+ ```typescript
157
+ export function listNavigableAgents(
158
+ agents: readonly NavigableSubagent[],
159
+ evicted: readonly EvictedSubagent[],
160
+ registry: AgentConfigLookup,
161
+ ): NavigationEntry[] {
162
+ const live = agents
163
+ .filter((record) => record.isSessionReady())
164
+ .map((record): NavigationEntry => ({ kind: "live", record, label: buildLabel(record, registry) }));
165
+ const liveIds = new Set(agents.map((record) => record.id));
166
+ const evictedEntries = evicted
167
+ .filter((d) => !liveIds.has(d.id))
168
+ .map((d): NavigationEntry => ({ kind: "evicted", outputFile: d.outputFile, label: buildLabel(d, registry, true) }));
169
+ return [...live, ...evictedEntries];
170
+ }
171
+ ```
172
+
173
+ Dedup is keyed by `id` (both shapes carry it) rather than `outputFile`: a record leaves `listAgents()` at the same instant its descriptor is captured, so the sets cannot overlap; the filter is defensive.
174
+ Order is live-then-evicted, which is already recency-ordered (evicted agents completed > 10 min ago).
175
+
176
+ Handler consumer sketch (Tell-Don't-Ask: the entry tells the handler its kind; the handler asks the right source constructor — no reach-through into the record's internals):
177
+
178
+ ```typescript
179
+ const entry = entries.find((candidate) => candidate.label === choice);
180
+ if (!entry) return;
181
+ let source: TranscriptSource;
182
+ try {
183
+ source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
184
+ } catch {
185
+ ui.notify("Could not read the session transcript file.", "error");
186
+ return;
187
+ }
188
+ // …unchanged: getMarkdownTheme(), ui.custom(new TranscriptOverlay({ source, … }))
189
+ ```
190
+
191
+ ### Manager retention sketch
192
+
193
+ ```typescript
194
+ private readonly evicted = new Map<string, EvictedSubagent>();
195
+
196
+ private cleanup() {
197
+ const cutoff = Date.now() - 10 * 60_000;
198
+ for (const [id, record] of this.agents) {
199
+ if (record.status === "running" || record.status === "queued") continue;
200
+ if ((record.completedAt ?? 0) >= cutoff) continue;
201
+ if (record.outputFile) this.evicted.set(id, toEvictedSubagent(record));
202
+ this.removeRecord(id, record);
203
+ }
204
+ }
205
+
206
+ listEvicted(): EvictedSubagent[] {
207
+ return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
208
+ }
209
+ ```
210
+
211
+ `clearCompleted()` and `dispose()` also `this.evicted.clear()` — descriptors belong to the session that evicted them, so a new session starts empty.
212
+ `toEvictedSubagent(record)` copies the eight fields; it is a private helper (or a free function in the module) and reads `record.outputFile` only after the `if (record.outputFile)` guard, so the `outputFile` field is always a defined `string`.
213
+
214
+ ### Edge cases
215
+
216
+ - **`outputFile` undefined at eviction** (headless / never-persisted session) → no descriptor captured; nothing to navigate.
217
+ - **File deleted/unreadable after eviction** → `fileSnapshotSource`'s eager read throws; the handler's `try/catch` notifies and returns without opening the overlay.
218
+ - **Empty or header-only JSONL** → `buildSessionContext` returns `messages: []`; the overlay renders an empty transcript (no crash).
219
+ - **No navigable sessions at all** (no live, no evicted) → existing `ui.notify("No subagent sessions to view.", "info")` path, unchanged.
220
+
221
+ ## Module-Level Changes
222
+
223
+ - `src/lifecycle/subagent-manager.ts` — add the `EvictedSubagent` interface (exported), the `evicted` map field, descriptor capture in `cleanup()`, `evicted.clear()` in `clearCompleted()` and `dispose()`, the `listEvicted()` method, and the `toEvictedSubagent` helper.
224
+ Import `SubagentType` (already in `#src/types`) and `SubagentStatus` (`#src/lifecycle/subagent-state`) for the interface.
225
+ - `src/ui/session-navigation.ts` — add `fileSnapshotSource`; import `EvictedSubagent` (type), and the SDK runtime `parseSessionEntries` / `buildSessionContext` plus the `SessionEntry` type; change `NavigationEntry` to the discriminated union; broaden `listNavigableAgents` to `(agents, evicted, registry)`; narrow `buildLabel` to `LabelFields` + `evicted` flag.
226
+ - `src/ui/session-navigator.ts` — `SessionNavigatorParams` gains `evicted: readonly EvictedSubagent[]` and `readFile: (path: string) => string`; the handler selects the source by `entry.kind` inside a `try/catch`.
227
+ No change to `TranscriptOverlay`, `buildTranscriptComponents`, `addMessageComponents`, or `addUserComponents`.
228
+ - `src/index.ts` — add `import { readFileSync } from "node:fs"`; the `subagent-sessions` handler passes `evicted: manager.listEvicted()` and `readFile: (path) => readFileSync(path, "utf8")`.
229
+ - `docs/architecture/architecture.md` — mark Step 4b ([#463]) **Landed** with a note recording the descriptor-vs-scan decision and that ADR-0004 Addendum 2's dual-source design is now realized; flip the `S4b` node label in the step-dependency Mermaid diagram to ✅.
230
+ - `docs/decisions/0004-reconsider-ui-direction.md` — optional one-line note under Addendum 2 that Step 4b chose manager-retained descriptors over a directory scan (the addendum's "evicted/untracked" wording said file snapshot for *rendering*, which still holds; only the *candidate-set* mechanism is being pinned down).
231
+
232
+ No SKILL update: the package SKILL's UI section references no session-navigation internals and the file/domain counts are unchanged (no new module — `fileSnapshotSource` joins the existing `session-navigation.ts`).
233
+ The architecture LOC/complexity tables are explicit end-of-phase snapshots, not per-file inventories, so a ~30-LOC addition does not update them.
234
+
235
+ ## Test Impact Analysis
236
+
237
+ 1. **New unit tests enabled by this change:**
238
+ - `fileSnapshotSource` (pure, in `session-navigation.test.ts`): fixture JSONL via a fake `readFile` → assert `getMessages()` equals the parsed messages, the `SessionHeader` is dropped, and `subscribe()` / `streaming()` / `getToolDefinition()` return `undefined`.
239
+ - `listNavigableAgents` with evicted descriptors: evicted entries appear with the `· evicted (snapshot)` marker; an evicted descriptor whose id matches a live record is deduped out.
240
+ - `SubagentManager.listEvicted()` (in `subagent-manager.test.ts`): after advancing fake timers past the cutoff, a completed agent with an `outputFile` produces a descriptor; one without an `outputFile` does not; `clearCompleted()` empties the set.
241
+ - `SessionNavigatorHandler` evicted path (in `session-navigator.test.ts`): picking an evicted label opens an overlay sourced from `readFile`; a throwing `readFile` notifies and skips the overlay.
242
+ 2. **Tests that become redundant:** none — the live-source tests still exercise the tracked-agent branch.
243
+ 3. **Tests that must stay as-is:** the existing `liveSource`, `listNavigableAgents` (live-only), `TranscriptOverlay`, and live-source handler tests — they pin the tracked-agent behavior this change must not regress.
244
+
245
+ ## Invariants at risk
246
+
247
+ Step 4 (#445) and Step 4a (#462) established invariants on the navigation surface; this step must preserve them:
248
+
249
+ - **Handler is a reactive consumer with no inbound call into the core** (Invariant #423/#445).
250
+ Pinned by the existing `session-navigator.test.ts` assertion `expect(record.getToolDefinition).not.toHaveBeenCalled()` in the live-source handler test.
251
+ The evicted path reads only `entry.outputFile` (a string) + the injected `readFile`; it makes no inbound call into a record.
252
+ - **Read-only, non-interactive overlay** (#445/#446).
253
+ The file source returns `subscribe: () => undefined` and `streaming: () => undefined`; the overlay's existing read-only behavior is unchanged.
254
+ Pinned by the existing overlay tests.
255
+ - **Renderer parity via Pi per-entry components** (#462).
256
+ The renderer is untouched; both sources still yield `SessionMessage[]` into the same `buildTranscriptComponents`.
257
+ Pinned by the existing `TranscriptOverlay` render tests.
258
+
259
+ No new test is needed for these — existing tests already pin them; the integration step keeps them green.
260
+
261
+ ## TDD Order
262
+
263
+ 1. **`fileSnapshotSource` (pure, additive).**
264
+ Surface: `test/ui/session-navigation.test.ts`.
265
+ Red: a `describe("fileSnapshotSource")` with a fake `readFile` returning fixture JSONL (a `session` header line + a couple of `message` entries) — assert messages parsed, header dropped, and the three static-source methods return `undefined`.
266
+ Green: implement `fileSnapshotSource` and the SDK runtime imports.
267
+ Commit: `test(pi-subagents): cover file-snapshot transcript source` then `feat(pi-subagents): add file-snapshot transcript source` (or a single `feat` commit with test + impl).
268
+ 2. **Manager evicted-descriptor retention (additive).**
269
+ Surface: `test/lifecycle/subagent-manager.test.ts`.
270
+ Red: with fake timers, advance past the 10-minute cutoff and assert `listEvicted()` returns a descriptor for a completed agent with an `outputFile`, none for one without, and an empty set after `clearCompleted()`.
271
+ Green: add `EvictedSubagent`, the `evicted` map, capture in `cleanup()`, `clear()` in `clearCompleted()`/`dispose()`, `listEvicted()`, and `toEvictedSubagent`.
272
+ Run `pnpm run check` after this commit (new exported interface).
273
+ Commit: `feat(pi-subagents): retain evicted-agent descriptors in the manager`.
274
+ 3. **Integrate: broaden the candidate set and dual-source the handler (breaking the `NavigationEntry` shape).**
275
+ This step changes `NavigationEntry` and `listNavigableAgents`'s signature, so the handler, `index.ts` call site, and both UI test files break and are updated together.
276
+ Surfaces: `src/ui/session-navigation.ts`, `src/ui/session-navigator.ts`, `src/index.ts`, `test/ui/session-navigation.test.ts`, `test/ui/session-navigator.test.ts`.
277
+ Red: update `listNavigableAgents` tests to the union shape and add the evicted-entry (marker + dedup) cases; add the handler evicted-path tests (file source + throwing-`readFile` notify).
278
+ Green: change `NavigationEntry` to the union, broaden `listNavigableAgents`, narrow `buildLabel`, add `evicted` + `readFile` to `SessionNavigatorParams`, switch the handler source by kind inside `try/catch`, and wire `manager.listEvicted()` + `readFileSync` in `index.ts`.
279
+ Run `pnpm run check` (shared interface change with a single non-test call site).
280
+ Commit: `feat(pi-subagents): source evicted-agent transcripts from disk in /subagent-sessions`.
281
+ 4. **Docs.**
282
+ Update `docs/architecture/architecture.md` Step 4b to Landed (descriptor decision + dual-source realized) and the `S4b` diagram node to ✅; add the optional Addendum 2 note in `docs/decisions/0004-reconsider-ui-direction.md`.
283
+ Commit: `docs(pi-subagents): mark Phase 19 Step 4b landed (#463)`.
284
+
285
+ ## Risks and Mitigations
286
+
287
+ - **Transient dead code between steps 1–2 and 3.**
288
+ `fileSnapshotSource` and `listEvicted()` have no caller until step 3.
289
+ CI / pre-completion `fallow dead-code` runs on the pushed tip, which is clean once step 3 lands; all steps ship in one push.
290
+ Mitigation: do not run `/ship-issue` before step 3 is committed.
291
+ - **`buildSessionContext` / `parseSessionEntries` behavior on malformed JSONL.**
292
+ Mitigation: the handler `try/catch` covers a throwing read/parse; a TDD fixture exercises a valid header-plus-messages file, and an empty-messages case confirms a graceful empty render.
293
+ - **Memory: the `evicted` map grows over a long session.**
294
+ Each descriptor is eight scalar fields (no messages), bounded by subagents spawned; the heavy session objects are still disposed.
295
+ `clearCompleted()` resets it per session.
296
+ - **Divergence from the issue's "enumerate persisted JSONL files" wording.**
297
+ Documented and operator-confirmed (descriptors over scan, for rich labels and bounded IO); the architecture/ADR notes record the rationale so the file-scan option is not silently lost.
298
+
299
+ ## Open Questions
300
+
301
+ None — the candidate-set strategy and the evicted-entry marker were resolved with the operator during planning.
302
+
303
+ [#445]: https://github.com/gotgenes/pi-packages/issues/445
304
+ [#446]: https://github.com/gotgenes/pi-packages/issues/446
305
+ [#462]: https://github.com/gotgenes/pi-packages/issues/462
306
+ [#463]: https://github.com/gotgenes/pi-packages/issues/463
@@ -55,3 +55,68 @@ Pre-completion reviewer returned PASS.
55
55
  - **Reviewer verdict: PASS.**
56
56
  Two WARN notes, both intentional and plan-sanctioned: Step 4 heading unchecked (correct for a partial slice) and the deferred `SKILL.md` count.
57
57
  - **Open at ship time:** confirm command name `subagent-sessions`; file the two follow-up issues (component renderer; evicted-agent file source).
58
+
59
+ ## Stage: User Note (2026-06-22T18:26:37Z)
60
+
61
+ When new issues are identified during planning, file them before leaving the planning stage rather than deferring to ship time.
62
+ Two motivations: the planning session uses a very capable model, and it already has all the context needed to write the issues well.
63
+ Applied here: the two follow-up issues (#462 renderer upgrade, #463 file-snapshot source) were identified during planning but filed only at ship time — they should have been created at the end of the planning commit instead.
64
+
65
+ ## Stage: Final Retrospective (2026-06-22T18:28:31Z)
66
+
67
+ ### Session summary
68
+
69
+ One continuous session carried #445 from planning through TDD, ship, and a mid-session retro note.
70
+ It delivered the sliced Phase 19 Step 4 — the `/subagent-sessions` command, the live `TranscriptSource`, and the typed `agentMessages` accessor — across three `feat:` commits plus docs, released as `pi-subagents` v17.3.0, with two follow-ups (#462, #463) filed at ship.
71
+ Execution was notably clean: the plan anticipated both hard risks, and every implementation slip was absorbed by the feedback loops within its active TDD cycle.
72
+
73
+ ### Observations
74
+
75
+ #### What went well
76
+
77
+ 1. **The plan pre-empted the two hardest risks.**
78
+ `AgentMessage`→`Message` non-assignability and the unreachable file-snapshot branch were both called out in planning and both materialized exactly as predicted; the planned mitigations (the `toMessages` adapter; shipping the live source only) applied with zero rework.
79
+ 2. **The `TranscriptSource` seam earned its keep.**
80
+ The pure `session-navigation.ts` was unit-tested with light stubs (no full `Subagent`/`TUI`/`AgentSession`), and the seam cleanly hosts both deferred follow-ups — testability justified it independent of the follow-ups.
81
+ 3. **Tight, incremental feedback loops.**
82
+ Per-cycle affected-file test runs, `pnpm run check` after every type-touching step, and full suite + lint + `fallow dead-code` after the last step caught every slip within seconds.
83
+ 4. **Two `ask_user` rounds during planning converted a large issue into a releasable slice** (the operator's Kent Beck nudge) — the central design win of the issue.
84
+
85
+ #### What caused friction (agent side)
86
+
87
+ 1. `missing-context` — wrote test assertions before checking helper behavior: `formatMs` yields `3.0s` not `3s`, `getDisplayName` falls back to `Agent`, `TOOL_DISPLAY` keys are lowercase, and `AgentTypeRegistry` takes `() => new Map()` not `() => []`.
88
+ Impact: ~4 assertion fixes within one Green iteration; no rework beyond the cycle.
89
+ 2. `missing-context` — `SessionContext` name collision: imported the SDK type into `types.ts` without noticing a local `SessionContext` interface already existed.
90
+ Impact: one aliasing edit (`SdkSessionContext`), caught immediately by biome `noRedeclare`.
91
+ 3. `other` (mechanical) — a stray `}` when wrapping a new `describe` block (AGENTS.md already warns about brace-pair edits), and `SessionMessage` imported from the wrong module in the navigator test.
92
+ Impact: one fix each, caught by autoformat and `tsc`.
93
+ 4. `instruction-violation` (self-identified, hook-caught) — two lint nits the `testing` skill already documents: an unnecessary optional chain and an unnecessary `!` on `mock.calls[0][0]`.
94
+ Impact: two inline fixes; no rework.
95
+
96
+ None of the agent-side slips caused rework beyond their active TDD cycle — the autoformat/biome/`tsc`/vitest/pre-commit loops absorbed all of them.
97
+
98
+ #### What caused friction (user side)
99
+
100
+ 1. The dominant cross-session finding (recorded separately as the User Note stage): follow-up issues identified during planning (#462, #463) were filed only at ship time.
101
+ The workflow itself encodes the deferral — the plan said "file at ship time" and `/ship-issue` step 4c creates them.
102
+ The operator wants them filed during planning, while the capable planning model still holds full context.
103
+ Opportunity: move follow-up creation into `/plan-issue`.
104
+
105
+ ### Diagnostic details
106
+
107
+ - **Model-performance correlation** — the only subagent dispatch was the `pre-completion-reviewer` on `anthropic/claude-sonnet-4-6`, a judgment-heavy review task; appropriate match.
108
+ Planning ran on a capable model, which the operator explicitly values.
109
+ No mismatch.
110
+ - **Escalation-delay tracking** — no rabbit-holes; the longest same-area sequence was 4 assertion fixes inside one Green iteration (under the 5-call flag).
111
+ - **Unused-tool detection** — the `missing-context` slips were caught trivially by running tests; reading `display.ts` helpers first would have pre-empted them, but the cost was negligible and no Explore/`colgrep` dispatch was warranted.
112
+ - **Feedback-loop gap analysis** — verification ran incrementally and completely (per-cycle tests, `check` after type changes, full gate after the last step); no gap.
113
+ This is a win, not a gap.
114
+
115
+ ### Changes made
116
+
117
+ Moved follow-up issue creation from ship time to planning time, with the backstop relocated to pre-completion (operator-directed):
118
+
119
+ 1. `.pi/prompts/plan-issue.md` — added a `## File follow-up issues` section before `## Commit`: file plan-named follow-ups during planning with `gh issue create` and record each number in the plan; nothing speculative.
120
+ 2. `.pi/agents/pre-completion-reviewer.md` — added checklist section `2i. Planned follow-up issues` and its report stanza: WARN when the plan names a follow-up but records no issue number; also added the case to the severity model's WARN list.
121
+ 3. `.pi/prompts/ship-issue.md` — removed step `4c. Create planned follow-up issues` (follow-ups are now filed during planning; the close comment in step 5 references the recorded numbers).
122
+ 4. `AGENTS.md` — extended the pre-completion-reviewer coverage enumeration to include planned follow-up filing.
@@ -0,0 +1,105 @@
1
+ ---
2
+ issue: 462
3
+ issue_title: "pi-subagents: upgrade /subagent-sessions renderer to Pi per-entry TUI components"
4
+ ---
5
+
6
+ # Retro: #462 — pi-subagents: upgrade /subagent-sessions renderer to Pi per-entry TUI components
7
+
8
+ ## Stage: Planning (2026-06-22T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned Phase 19 Step 4a: swap the `/subagent-sessions` renderer from `serializeConversation` plain text to Pi's per-entry TUI components behind the existing `TranscriptSource` seam.
13
+ Verified the SDK surface (per-entry components, `parseSkillBlock`, `getMarkdownTheme`, `ToolDefinition`, `AgentSession.getToolDefinition`) and mirrored Pi's own `renderSessionContext`/`rebuildChatFromMessages` mapping.
14
+ Wrote a four-step TDD plan at `packages/pi-subagents/docs/plans/0462-navigation-renderer-tui-components.md` and committed it.
15
+
16
+ ### Observations
17
+
18
+ - Three design decisions were surfaced via `ask_user` and locked by the operator:
19
+ 1. **Rebuild-on-change** (mirror Pi's `rebuildChatFromMessages`) over incremental `updateContent`/`updateResult` — the seam exposes only a full `getMessages()` snapshot plus a coarse `onChange`, so incremental would still diff snapshots while replicating Pi's `pendingTools` streaming state machine for marginal benefit.
20
+ 2. **Keep the lightweight `◍ describeActivity` text indicator** — this absorbs the high-frequency streaming text, so the persisted component tree only changes at message granularity, which is what keeps rebuild-on-change cheap.
21
+ 3. **Wire real tool definitions** — the operator flagged a dependency-inversion risk ("can't `SubagentManager` track this?").
22
+ Resolved dependency-safely: the SDK `AgentSession` already exposes `getToolDefinition(name)`, so a new read accessor on `SubagentSession`/`Subagent` (mirroring the existing `agentMessages` accessor) surfaces it through the record and onto the `TranscriptSource` seam — arrows stay inward, no `SubagentManager` bookkeeping.
23
+ - Key structural move: the component renderer must leave the pure `session-navigation.ts` (per-entry components need `TUI`/`cwd`/`markdownTheme`) and live in the SDK/TUI `session-navigator.ts`.
24
+ The pure module sheds `renderTranscriptLines`/`serializeConversation` entirely, becoming selection + sourcing only.
25
+ - The `renderTranscriptLines` removal, overlay rewrite, `session-navigation.ts` edits, `index.ts` `cwd` wiring, and test updates must land in **one commit** (TDD step 3) — removing the export breaks the overlay and its tests at the type level.
26
+ - Non-breaking internal renderer swap; `Release: independent` per the roadmap.
27
+ Step 4a gates Step 5 ([#442]) for rendering parity but is not part of any release batch.
28
+ - Follow-up [#463] (Step 4b, file-snapshot source) already exists and is open — referenced as a Non-Goal, nothing new to file.
29
+ - Parity is defined as *using Pi's own components*, not byte-equality with the bespoke viewer; `custom`-role messages are skipped (the bespoke viewer never rendered them either), noted as an Open Question.
30
+
31
+ ## Stage: Implementation — TDD (2026-06-22T21:30:00Z)
32
+
33
+ ### Session summary
34
+
35
+ Executed all four planned TDD steps: the `getToolDefinition` read accessor on `SubagentSession`/`Subagent`, the seam method on `TranscriptSource`/`NavigableSubagent`, the per-entry component renderer + overlay rewrite + `cwd` wiring, and the architecture doc flip (Step 4a `✅`).
36
+ The renderer now mounts Pi's interactive components into a cached `Container` rebuilt on source change, mirroring Pi's `renderSessionContext`.
37
+ Test count went from 1084 to 1088 (+4 net: +4 accessor/seam tests, +3 new navigator tests, −3 removed `renderTranscriptLines` tests), all green; one post-review `test:` commit pins invariant #423.
38
+
39
+ ### Observations
40
+
41
+ - The production code (`session-navigation.ts`, `session-navigator.ts`, `index.ts`) type-checked clean on the first `tsc` run — the SDK message-union narrowing (`switch (message.role)`), component construction, and `updateResult(toolResultMessage)` all resolved without casts.
42
+ `SessionMessage` (= the SDK `AgentMessage` union) narrows by `role` even though `@earendil-works/pi-agent-core` is not a direct dependency.
43
+ - Two deviations from the plan's Module-Level Changes, both intentional: (1) `src/types.ts` was *not* changed — `ToolDefinition` is imported directly from `@earendil-works/pi-coding-agent` in each consumer rather than re-exported through the barrel (avoids a speculative re-export `fallow` would flag); (2) `test/helpers/mock-session.ts` gained a `getToolDefinition` stub (needed for the step-1 accessor tests, not listed in the plan).
44
+ - Pi's per-entry components read a *global* interactive theme initialized by `initTheme()`; Pi does this at startup, but tests had to call `initTheme(undefined, false)` in `beforeAll`.
45
+ An early probe with `AssistantMessageComponent` passed without it, but `UserMessageComponent`/`ToolExecutionComponent` throw `"Theme not initialized"` — so the global-theme dependency is component-specific and only surfaced once the broader role set was exercised.
46
+ - `setComplete`'s `TruncationResult` param requires more fields than `BashExecutionMessage` carries, so the bash component is completed with `undefined` truncation info (command + output still render).
47
+ Pi's own JS passes `{ truncated: true }` but that path is untyped.
48
+ - Pre-completion reviewer verdict: **WARN** (no failures; 3 non-blocking findings).
49
+ Reviewer warnings: (1) `package-pi-subagents/SKILL.md` UI module count is stale (10 listed vs 13 actual) — predates this issue, plan left it deliberately; (2) `addMessageComponents` mutates the received `pendingTools` accumulator (output-argument pattern) — intentional, mirrors Pi's own `renderedPendingTools` local and was plan-reviewed; (3) invariant #423 held and is type-enforced but lacked an explicit spy pin — **addressed** by the follow-up `test:` commit asserting the handler never calls `record.getToolDefinition` directly.
50
+
51
+ ## Stage: Final Retrospective (2026-06-23T00:00:00Z)
52
+
53
+ ### Session summary
54
+
55
+ Shipped Phase 19 Step 4a end-to-end across plan → TDD → ship: `/subagent-sessions` now renders through Pi's own per-entry interactive components behind the unchanged `TranscriptSource` seam, released as `pi-subagents` v17.4.0.
56
+ Execution was notably clean — six commits, no rework of production code, CI green on the first push, and the only two friction points (a missing `initTheme()` in tests, a wrong post-review assertion) were each self-caught and resolved in a single iteration.
57
+
58
+ ### Observations
59
+
60
+ #### What went well
61
+
62
+ 1. **Upfront SDK verification in planning paid off at implementation.**
63
+ Planning inspected the actual component constructors, the `renderSessionContext` mapping, and `AgentSession.getToolDefinition` in `node_modules/**/*.d.ts` before committing to a design.
64
+ Result: the production code (`session-navigation.ts`, `session-navigator.ts`, `index.ts`) type-checked clean on the *first* `tsc` run during TDD — the SDK message-union narrowing and component construction needed no casts.
65
+ 2. **Probe-driven de-risking.**
66
+ Before the renderer rewrite, a throwaway `_probe-theme.test.ts` confirmed `getMarkdownTheme()` + a Pi component render works outside interactive mode — validating the riskiest assumption cheaply, then deleted.
67
+ 3. **The operator's planning interjections were strategic, not mechanical.**
68
+ Rather than picking an `ask_user` option, the operator asked "what update frequency are we anticipating?"
69
+ and flagged a dependency-inversion risk on tool definitions.
70
+ Both redirects improved the design: the frequency analysis justified rebuild-on-change, and the inversion concern led to the dependency-safe `getToolDefinition` read accessor (discovered on the SDK, not invented).
71
+ 4. **Incremental verification throughout.**
72
+ Targeted `vitest` per Red→Green step, `pnpm run check` immediately after each shared-interface change, then full suite + root lint + `fallow dead-code` before each commit — no end-of-session verification pile-up.
73
+ 5. **Pre-completion review caught a real gap and the fix's own bug was caught by the loop.**
74
+ The reviewer flagged the missing #423 spy pin; while adding it, an over-assertion (`getToolDefinition` called on a no-toolCall fixture) failed the test run and was removed before committing.
75
+
76
+ #### What caused friction (agent side)
77
+
78
+ 1. `missing-context` (self-identified) — the `initTheme()` requirement for Pi's interactive components.
79
+ The de-risking probe exercised only `AssistantMessageComponent`, which happens *not* to read the global theme, so the probe passed and gave false confidence.
80
+ The real tests then threw `"Theme not initialized"` from `UserMessageComponent`/`ToolExecutionComponent`.
81
+ Impact: one extra Red cycle (4 failing tests) → added `beforeAll(() => initTheme(undefined, false))`; no production rework.
82
+ 2. `other` (self-identified) — a wrong second assertion in the post-review #423 pin (`expect(record.getToolDefinition).toHaveBeenCalled()` on a fixture whose assistant message had no `toolCall`, so the lookup never fires).
83
+ Impact: one failed test run, immediately diagnosed and the bad assertion removed; ~1 iteration.
84
+
85
+ #### What caused friction (user side)
86
+
87
+ - None.
88
+ The operator's interventions were timely and strategic (see What went well #3); no context was withheld and no correction was reactive.
89
+
90
+ ### Diagnostic details
91
+
92
+ - **Feedback-loop gap analysis** — no gap.
93
+ Verification ran incrementally: per-step targeted `vitest`, `pnpm run check` after each interface change, full suite + lint + `fallow` before commits.
94
+ Both friction points were caught by running tests *before* committing, not after.
95
+ - **Escalation-delay tracking** — no rabbit holes; each friction point resolved in a single iteration (well under the 5-tool-call threshold).
96
+ - **Probe-coverage note** — the `initTheme()` miss is a probe *coverage* gap, not a tool-availability gap: a one-variant probe (`AssistantMessageComponent`) did not validate the environment dependency of the broader component set actually used.
97
+ - **Model-performance / unused-tool** — nothing notable.
98
+ The only subagent dispatch (`pre-completion-reviewer`) is purpose-built for judgment-heavy review; SDK inspection used direct `grep`/`bash` over `.d.ts` files, which was appropriate.
99
+
100
+ ### Changes made
101
+
102
+ 1. `.pi/skills/package-pi-subagents/SKILL.md` — added a `## Testing` note: tests mounting Pi's per-entry interactive components must call `initTheme(undefined, false)` in `beforeAll`, with the component-specific / false-confidence caveat.
103
+ 2. `.pi/skills/package-pi-subagents/SKILL.md` — corrected the stale UI domain module count (10 → 13) and extended its responsibility blurb to include session navigation.
104
+ 3. `.pi/skills/testing/SKILL.md` — extended the existing "Exploration before planning" disposable-probe bullet to require exercising the full input variety (variant-specific environment dependencies defeat a one-representative probe).
105
+ Placement confirmed with the operator: kept in the `testing` skill (loaded by both `/plan-issue` and `/tdd-plan`) next to the sibling rule, rather than duplicated into the plan prompt.
@@ -0,0 +1,49 @@
1
+ ---
2
+ issue: 463
3
+ issue_title: "pi-subagents: add file-snapshot source to /subagent-sessions for evicted agents"
4
+ ---
5
+
6
+ # Retro: #463 — pi-subagents: add file-snapshot source to /subagent-sessions for evicted agents
7
+
8
+ ## Stage: Planning (2026-06-23T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a 4-step plan for the Phase 19 Step 4b file-snapshot source: implement `fileSnapshotSource(outputFile, readFile)` in the pure `session-navigation.ts`, broaden the `/subagent-sessions` candidate set to evicted agents, and dual-source the handler by `NavigationEntry.kind`.
13
+ The central design fork — how evicted agents enter the picker — was resolved with the operator via `ask_user`.
14
+
15
+ ### Observations
16
+
17
+ - **Eviction is memory management.**
18
+ The cleanup sweep's `disposeSession()` frees the in-memory message history; the transcript survives only on disk.
19
+ So rendering an evicted agent *always* reads the file (`fileSnapshotSource`) regardless of candidate-set strategy — the strategy only affects the picker *label*.
20
+ - **Persisted child sessions carry no `type`/`description`.**
21
+ The JSONL has only the conversation plus a header (`id`, `timestamp`, `cwd`, `parentSession`).
22
+ A directory scan (the issue's literal wording) would therefore produce degraded labels and parse every file per open.
23
+ - **Decision: manager-retained descriptors over directory scan.**
24
+ The manager stashes a tiny no-messages `EvictedSubagent` descriptor in `cleanup()` before `removeRecord`, cleared in `clearCompleted()`/`dispose()`.
25
+ Rich labels identical to live entries, bounded memory, no per-open parse.
26
+ Coverage is limited to in-session evictions — which are the sweep's only targets, since a fresh manager per session never reloads prior-process subagents.
27
+ Operator confirmed; an `(evicted)` snapshot marker was also chosen for the label.
28
+ - **`NavigationEntry` becomes a discriminated union** (`live` | `evicted`); this breaks the handler, the `index.ts` call site, and both UI test files, so step 3 folds all of them into one commit.
29
+ - **SDK-runtime call kept direct.** `fileSnapshotSource` calls `parseSessionEntries` / `buildSessionContext` directly rather than injecting them — the injected `readFile` already provides the unit-test seam, and there is no `no-restricted-imports` rule.
30
+ - **Transient dead-code risk noted:** `fileSnapshotSource` and `listEvicted()` have no caller until step 3; flagged not to ship before step 3 lands (CI/`fallow` gate the pushed tip).
31
+ - Release: independent (Phase 19 Step 4b roadmap tag).
32
+
33
+ ## Stage: Implementation — TDD (2026-06-23T13:00:00Z)
34
+
35
+ ### Session summary
36
+
37
+ Executed all 4 plan steps in order: (1) `fileSnapshotSource` in the pure `session-navigation.ts`, (2) manager-retained `EvictedSubagent` descriptors (`cleanup` capture, `listEvicted`, `clearCompleted`/`dispose` clear), (3) the breaking `NavigationEntry` discriminated union + handler dual-source + `index.ts` wiring + all test updates in one commit, (4) architecture/ADR doc updates.
38
+ Test count went from 1088 to 1099 (+11); full suite, `check`, root `lint`, and `fallow dead-code` all green.
39
+
40
+ ### Observations
41
+
42
+ - **No deviations from the plan.**
43
+ All steps landed as written; Module-Level Changes matched the touched files exactly.
44
+ - **Exploratory probe paid off.**
45
+ A disposable script confirmed `buildSessionContext` auto-detects the leaf with no `leafId`, the `type !== "session"` filter drops the header, and empty entries yield `[]` — validating the `fileSnapshotSource` shape before writing the test.
46
+ - **Two ESLint auto-fixes during commit hooks:** a stray `!` non-null assertion in the manager test (step 2) and four `entry?.kind` optional chains on a non-nullish destructured `entry` (`@typescript-eslint/no-unnecessary-condition`, step 3).
47
+ Both fixed and re-committed; `check` + tests confirmed green after.
48
+ - **Transient dead code** between steps 1–2 and 3 (predicted in the plan) cleared at the step-3 tip; final `fallow dead-code` is clean.
49
+ - **Pre-completion reviewer: PASS** — deterministic checks, code design, test artifacts, Mermaid render, and all three cross-step invariants (no inbound core call, read-only overlay, renderer parity) verified; no follow-ups deferred.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "17.3.0",
3
+ "version": "17.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * /agents — Interactive agent management menu
12
12
  */
13
13
 
14
+ import { readFileSync } from "node:fs";
14
15
  import { join } from "node:path";
15
16
  import {
16
17
  createAgentSession,
@@ -201,7 +202,14 @@ export default function (pi: ExtensionAPI) {
201
202
  pi.registerCommand("subagent-sessions", {
202
203
  description: "View a subagent's session transcript (read-only)",
203
204
  handler: async (_args, ctx) => {
204
- await sessionNavigator.handle({ ui: ctx.ui, agents: manager.listAgents(), registry });
205
+ await sessionNavigator.handle({
206
+ ui: ctx.ui,
207
+ agents: manager.listAgents(),
208
+ evicted: manager.listEvicted(),
209
+ registry,
210
+ cwd: ctx.cwd,
211
+ readFile: (path) => readFileSync(path, "utf8"),
212
+ });
205
213
  },
206
214
  });
207
215
  }