@gotgenes/pi-subagents 17.4.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.
- package/CHANGELOG.md +9 -0
- package/docs/architecture/architecture.md +10 -3
- package/docs/decisions/0004-reconsider-ui-direction.md +8 -0
- package/docs/plans/0463-file-snapshot-source-evicted-agents.md +306 -0
- package/docs/retro/0462-navigation-renderer-tui-components.md +56 -0
- package/docs/retro/0463-file-snapshot-source-evicted-agents.md +49 -0
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/src/lifecycle/subagent-manager.ts +47 -1
- package/src/ui/session-navigation.ts +66 -12
- package/src/ui/session-navigator.ts +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [17.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v17.4.0...pi-subagents-v17.5.0) (2026-06-23)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-subagents:** add file-snapshot transcript source ([06a9ee3](https://github.com/gotgenes/pi-packages/commit/06a9ee39b5f529f770c39cf8d8bdcefd1511bb7d))
|
|
14
|
+
* **pi-subagents:** retain evicted-agent descriptors in the manager ([3128e2a](https://github.com/gotgenes/pi-packages/commit/3128e2a44b94be9b976b74d24cf5127ca0944074))
|
|
15
|
+
* **pi-subagents:** source evicted-agent transcripts from disk in /subagent-sessions ([b4da762](https://github.com/gotgenes/pi-packages/commit/b4da762b0574b49e028518c62e7b2f11acaf1ad4))
|
|
16
|
+
|
|
8
17
|
## [17.4.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v17.3.0...pi-subagents-v17.4.0) (2026-06-23)
|
|
9
18
|
|
|
10
19
|
|
|
@@ -1036,16 +1036,23 @@ Selection and sourcing are untouched; native navigation now renders at parity, u
|
|
|
1036
1036
|
|
|
1037
1037
|
`Release: independent`
|
|
1038
1038
|
|
|
1039
|
-
### Step 4b — File-snapshot source for evicted agents ([#463])
|
|
1039
|
+
### ✅ Step 4b — File-snapshot source for evicted agents ([#463])
|
|
1040
1040
|
|
|
1041
1041
|
Smell: Category C (coupling) — the #445 slice sources transcripts live from `manager.listAgents()` only; an agent evicted by the 10-minute cleanup sweep has a persisted session JSONL but no live record, so it is unreachable.
|
|
1042
|
-
This step adds the file-snapshot `TranscriptSource` branch (`parseSessionEntries(readFile(outputFile))` → drop the `SessionHeader` → `buildSessionContext(...).messages`) and broadens the candidate set to
|
|
1042
|
+
This step adds the file-snapshot `TranscriptSource` branch (`parseSessionEntries(readFile(outputFile))` → drop the `SessionHeader` → `buildSessionContext(...).messages`) and broadens the candidate set to evicted agents, behind the same seam; the renderer is untouched.
|
|
1043
1043
|
|
|
1044
1044
|
Independent: this is a new capability the bespoke viewer never had, so it gates nothing and is not a Step 5 prerequisite.
|
|
1045
1045
|
Best sequenced after Step 4a (shared renderer), but carries no hard dependency.
|
|
1046
1046
|
|
|
1047
1047
|
Outcome: the operator can view a fully-evicted agent's transcript from its persisted session file; the dual-source design recorded in [ADR-0004] Addendum 2 is fully realized.
|
|
1048
1048
|
|
|
1049
|
+
Landed ([#463]): `fileSnapshotSource(outputFile, readFile)` lands in the pure `session-navigation.ts` (`parseSessionEntries` → drop the `SessionHeader` → `buildSessionContext(...).messages`; a static no-subscribe, no-streaming source).
|
|
1050
|
+
The candidate set is broadened via **manager-retained descriptors**, not a directory scan: the persisted child session carries no subagent `type`/`description` (those live only on the in-memory record), so a scan would yield degraded labels and parse every file per open.
|
|
1051
|
+
Instead `SubagentManager.cleanup()` stashes a lightweight `EvictedSubagent` descriptor (label fields + `outputFile`, no messages) before disposing a record with a persisted file, exposed via `listEvicted()` and cleared by `clearCompleted()`/`dispose()`.
|
|
1052
|
+
`NavigationEntry` became a `live | evicted` discriminated union; the handler selects `liveSource` vs `fileSnapshotSource` by kind inside a `try/catch` (an unreadable file notifies and skips), and `index.ts` injects `readFileSync`.
|
|
1053
|
+
Evicted entries carry an `· evicted (snapshot)` label marker.
|
|
1054
|
+
Coverage is in-session evictions (the sweep's only targets); old-session orphan files — the ones a scan would surface with degraded labels — are out of scope.
|
|
1055
|
+
|
|
1049
1056
|
`Release: independent`
|
|
1050
1057
|
|
|
1051
1058
|
### Step 5 — Dissolve `/agents` and remove the conversation-viewer subtree ([#442])
|
|
@@ -1111,7 +1118,7 @@ flowchart LR
|
|
|
1111
1118
|
S3["✅ Step 3 - Background widget (#444)"]
|
|
1112
1119
|
S4["✅ Step 4 - Native session nav slice (#445)"]
|
|
1113
1120
|
S4a["✅ Step 4a - Renderer to TUI components (#462)"]
|
|
1114
|
-
S4b["Step 4b - File-snapshot source (#463)"]
|
|
1121
|
+
S4b["✅ Step 4b - File-snapshot source (#463)"]
|
|
1115
1122
|
S5["Step 5 - Dissolve /agents + viewer (#442)"]
|
|
1116
1123
|
S6["Step 6 - Remove definition mgmt (#441)"]
|
|
1117
1124
|
S7["Step 7 - Test clones (#443)"]
|
|
@@ -248,7 +248,15 @@ This matches the bespoke viewer's current reach (gated on `record.isSessionReady
|
|
|
248
248
|
A foreground agent is navigable only _after_ it completes — while it runs, the root turn is blocked on it — whereas a background agent is navigable live.
|
|
249
249
|
The background widget ([#444]) remains the optional secondary selection gesture for background agents; the command is the primary, unit-testable surface.
|
|
250
250
|
|
|
251
|
+
**Evicted-agent candidate set ([#463]).**
|
|
252
|
+
Step 4b realizes the file-snapshot branch for fully-evicted agents.
|
|
253
|
+
The candidate set is broadened via **manager-retained descriptors**, not a directory scan of the tasks directory: the persisted child session carries no subagent `type`/`description` (those live only on the in-memory record), so a scan yields degraded labels and parses every file per picker open.
|
|
254
|
+
The cleanup sweep instead stashes a lightweight `EvictedSubagent` descriptor (label fields + `outputFile`, no messages) before disposing a record, preserving rich labels and bounded memory.
|
|
255
|
+
This covers in-session evictions — the sweep's only targets, since a fresh manager per session never reloads prior-process subagents.
|
|
256
|
+
The "render from the file snapshot" mechanism for an evicted agent (above) is unchanged; only the candidate-set _enumeration_ is pinned to descriptors.
|
|
257
|
+
|
|
251
258
|
[#444]: https://github.com/gotgenes/pi-packages/issues/444
|
|
252
259
|
[#445]: https://github.com/gotgenes/pi-packages/issues/445
|
|
253
260
|
[#446]: https://github.com/gotgenes/pi-packages/issues/446
|
|
254
261
|
[#447]: https://github.com/gotgenes/pi-packages/issues/447
|
|
262
|
+
[#463]: https://github.com/gotgenes/pi-packages/issues/463
|
|
@@ -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
|
|
@@ -47,3 +47,59 @@ Test count went from 1084 to 1088 (+4 net: +4 accessor/seam tests, +3 new naviga
|
|
|
47
47
|
Pi's own JS passes `{ truncated: true }` but that path is untyped.
|
|
48
48
|
- Pre-completion reviewer verdict: **WARN** (no failures; 3 non-blocking findings).
|
|
49
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
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({
|
|
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
|
}
|
|
@@ -14,12 +14,31 @@ import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent
|
|
|
14
14
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
15
15
|
import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
|
|
16
16
|
import type { SubagentSession } from "#src/lifecycle/subagent-session";
|
|
17
|
-
import { SubagentState } from "#src/lifecycle/subagent-state";
|
|
17
|
+
import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
18
18
|
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
19
19
|
|
|
20
20
|
import type { RunConfig } from "#src/runtime";
|
|
21
21
|
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A lightweight snapshot of a subagent evicted by the 10-minute cleanup sweep.
|
|
25
|
+
*
|
|
26
|
+
* The sweep frees the heavy in-memory session (its message history included);
|
|
27
|
+
* this descriptor retains only the fields the session navigator needs to label
|
|
28
|
+
* the agent in the picker, plus the persisted `outputFile` to source its
|
|
29
|
+
* transcript from disk. Carries no messages, so memory stays bounded.
|
|
30
|
+
*/
|
|
31
|
+
export interface EvictedSubagent {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly type: SubagentType;
|
|
34
|
+
readonly description: string;
|
|
35
|
+
readonly status: SubagentStatus;
|
|
36
|
+
readonly startedAt: number;
|
|
37
|
+
readonly completedAt: number | undefined;
|
|
38
|
+
readonly toolUses: number;
|
|
39
|
+
readonly outputFile: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
/** Observer interface for agent lifecycle notifications. */
|
|
24
43
|
export interface SubagentManagerObserver {
|
|
25
44
|
onSubagentStarted(record: Subagent): void;
|
|
@@ -65,6 +84,8 @@ export interface AgentSpawnConfig {
|
|
|
65
84
|
|
|
66
85
|
export class SubagentManager {
|
|
67
86
|
private agents = new Map<string, Subagent>();
|
|
87
|
+
/** Descriptors of agents removed by the cleanup sweep, keyed by id — navigable from disk. */
|
|
88
|
+
private readonly evicted = new Map<string, EvictedSubagent>();
|
|
68
89
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
69
90
|
private readonly observer?: SubagentManagerObserver;
|
|
70
91
|
private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
@@ -220,6 +241,11 @@ export class SubagentManager {
|
|
|
220
241
|
);
|
|
221
242
|
}
|
|
222
243
|
|
|
244
|
+
/** Descriptors of agents evicted by the cleanup sweep, most recent first. */
|
|
245
|
+
listEvicted(): EvictedSubagent[] {
|
|
246
|
+
return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
247
|
+
}
|
|
248
|
+
|
|
223
249
|
abort(id: string): boolean {
|
|
224
250
|
const record = this.agents.get(id);
|
|
225
251
|
if (!record) return false;
|
|
@@ -245,6 +271,9 @@ export class SubagentManager {
|
|
|
245
271
|
for (const [id, record] of this.agents) {
|
|
246
272
|
if (record.status === "running" || record.status === "queued") continue;
|
|
247
273
|
if ((record.completedAt ?? 0) >= cutoff) continue;
|
|
274
|
+
// Retain a navigable descriptor before freeing the heavy session. Only an
|
|
275
|
+
// agent with a persisted file can be sourced from disk after eviction.
|
|
276
|
+
if (record.outputFile) this.evicted.set(id, toEvictedSubagent(record, record.outputFile));
|
|
248
277
|
this.removeRecord(id, record);
|
|
249
278
|
}
|
|
250
279
|
}
|
|
@@ -258,6 +287,8 @@ export class SubagentManager {
|
|
|
258
287
|
if (record.status === "running" || record.status === "queued") continue;
|
|
259
288
|
this.removeRecord(id, record);
|
|
260
289
|
}
|
|
290
|
+
// Evicted descriptors belong to the session that swept them — a new session starts empty.
|
|
291
|
+
this.evicted.clear();
|
|
261
292
|
}
|
|
262
293
|
|
|
263
294
|
/** Whether any agents are still running or queued. */
|
|
@@ -314,5 +345,20 @@ export class SubagentManager {
|
|
|
314
345
|
record.disposeSession();
|
|
315
346
|
}
|
|
316
347
|
this.agents.clear();
|
|
348
|
+
this.evicted.clear();
|
|
317
349
|
}
|
|
318
350
|
}
|
|
351
|
+
|
|
352
|
+
/** Capture an evicted agent's navigable fields from its record. */
|
|
353
|
+
function toEvictedSubagent(record: Subagent, outputFile: string): EvictedSubagent {
|
|
354
|
+
return {
|
|
355
|
+
id: record.id,
|
|
356
|
+
type: record.type,
|
|
357
|
+
description: record.description,
|
|
358
|
+
status: record.status,
|
|
359
|
+
startedAt: record.startedAt,
|
|
360
|
+
completedAt: record.completedAt,
|
|
361
|
+
toolUses: record.toolUses,
|
|
362
|
+
outputFile,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* components require a `TUI`, `cwd`, and markdown theme.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type
|
|
15
|
+
import { buildSessionContext, parseSessionEntries, type SessionEntry, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
17
|
+
import type { EvictedSubagent } from "#src/lifecycle/subagent-manager";
|
|
17
18
|
import type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
18
19
|
import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
|
|
19
20
|
import { formatDuration, getDisplayName } from "#src/ui/display";
|
|
@@ -37,10 +38,24 @@ export interface NavigableSubagent {
|
|
|
37
38
|
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A navigable entry plus the label shown in the picker.
|
|
43
|
+
*
|
|
44
|
+
* A `live` entry sources its transcript from the in-memory record; an `evicted`
|
|
45
|
+
* entry sources it from the persisted session file (the record is gone).
|
|
46
|
+
*/
|
|
47
|
+
export type NavigationEntry =
|
|
48
|
+
| { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
|
|
49
|
+
| { readonly kind: "evicted"; readonly label: string; readonly outputFile: string };
|
|
50
|
+
|
|
51
|
+
/** The fields `buildLabel` reads — shared by a live record and an evicted descriptor. */
|
|
52
|
+
interface LabelFields {
|
|
53
|
+
readonly type: SubagentType;
|
|
54
|
+
readonly description: string;
|
|
55
|
+
readonly status: SubagentStatus;
|
|
56
|
+
readonly startedAt: number;
|
|
57
|
+
readonly completedAt: number | undefined;
|
|
58
|
+
readonly toolUses: number;
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
/** Running-agent streaming state, surfaced by a live source. */
|
|
@@ -61,14 +76,52 @@ export interface TranscriptSource {
|
|
|
61
76
|
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Label every navigable subagent for the picker: live records with a viewable
|
|
81
|
+
* session, then agents evicted by the cleanup sweep (deduped against live ids).
|
|
82
|
+
*/
|
|
65
83
|
export function listNavigableAgents(
|
|
66
84
|
agents: readonly NavigableSubagent[],
|
|
85
|
+
evicted: readonly EvictedSubagent[],
|
|
67
86
|
registry: AgentConfigLookup,
|
|
68
87
|
): NavigationEntry[] {
|
|
69
|
-
|
|
88
|
+
const live = agents
|
|
70
89
|
.filter((record) => record.isSessionReady())
|
|
71
|
-
.map((record) => ({ record, label: buildLabel(record, registry) }));
|
|
90
|
+
.map((record): NavigationEntry => ({ kind: "live", record, label: buildLabel(record, registry) }));
|
|
91
|
+
const liveIds = new Set(agents.map((record) => record.id));
|
|
92
|
+
const evictedEntries = evicted
|
|
93
|
+
.filter((descriptor) => !liveIds.has(descriptor.id))
|
|
94
|
+
.map((descriptor): NavigationEntry => ({
|
|
95
|
+
kind: "evicted",
|
|
96
|
+
outputFile: descriptor.outputFile,
|
|
97
|
+
label: buildLabel(descriptor, registry, true),
|
|
98
|
+
}));
|
|
99
|
+
return [...live, ...evictedEntries];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Source a transcript from a persisted child-session JSONL snapshot.
|
|
104
|
+
*
|
|
105
|
+
* For an agent evicted from the manager's map by the 10-minute cleanup sweep:
|
|
106
|
+
* the in-memory record (and its message history) is gone, but the session file
|
|
107
|
+
* survives on disk. Reads the file, drops the `SessionHeader`, and resolves the
|
|
108
|
+
* message list via Pi's own parser. A static snapshot — no subscription, no
|
|
109
|
+
* streaming, no live tool registry. `readFile` is injected so this module makes
|
|
110
|
+
* no `fs` calls.
|
|
111
|
+
*/
|
|
112
|
+
export function fileSnapshotSource(
|
|
113
|
+
outputFile: string,
|
|
114
|
+
readFile: (path: string) => string,
|
|
115
|
+
): TranscriptSource {
|
|
116
|
+
const entries = parseSessionEntries(readFile(outputFile));
|
|
117
|
+
const sessionEntries = entries.filter((entry): entry is SessionEntry => entry.type !== "session");
|
|
118
|
+
const { messages } = buildSessionContext(sessionEntries);
|
|
119
|
+
return {
|
|
120
|
+
getMessages: () => messages,
|
|
121
|
+
subscribe: () => undefined,
|
|
122
|
+
streaming: () => undefined,
|
|
123
|
+
getToolDefinition: () => undefined,
|
|
124
|
+
};
|
|
72
125
|
}
|
|
73
126
|
|
|
74
127
|
/** Source a transcript live from an in-memory record (this slice's only source). */
|
|
@@ -84,8 +137,9 @@ export function liveSource(record: NavigableSubagent): TranscriptSource {
|
|
|
84
137
|
};
|
|
85
138
|
}
|
|
86
139
|
|
|
87
|
-
function buildLabel(
|
|
88
|
-
const name = getDisplayName(
|
|
89
|
-
const duration = formatDuration(
|
|
90
|
-
|
|
140
|
+
function buildLabel(fields: LabelFields, registry: AgentConfigLookup, evicted = false): string {
|
|
141
|
+
const name = getDisplayName(fields.type, registry);
|
|
142
|
+
const duration = formatDuration(fields.startedAt, fields.completedAt);
|
|
143
|
+
const marker = evicted ? " · evicted (snapshot)" : "";
|
|
144
|
+
return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
|
|
91
145
|
}
|
|
@@ -38,9 +38,10 @@ import {
|
|
|
38
38
|
visibleWidth,
|
|
39
39
|
} from "@earendil-works/pi-tui";
|
|
40
40
|
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
41
|
+
import type { EvictedSubagent } from "#src/lifecycle/subagent-manager";
|
|
41
42
|
import type { SessionMessage } from "#src/types";
|
|
42
43
|
import { describeActivity, type Theme } from "#src/ui/display";
|
|
43
|
-
import { listNavigableAgents, liveSource, type NavigableSubagent, type TranscriptSource } from "#src/ui/session-navigation";
|
|
44
|
+
import { fileSnapshotSource, listNavigableAgents, liveSource, type NavigableSubagent, type TranscriptSource } from "#src/ui/session-navigation";
|
|
44
45
|
|
|
45
46
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
47
|
|
|
@@ -68,9 +69,13 @@ export interface SessionNavigatorUI {
|
|
|
68
69
|
export interface SessionNavigatorParams {
|
|
69
70
|
ui: SessionNavigatorUI;
|
|
70
71
|
agents: readonly NavigableSubagent[];
|
|
72
|
+
/** Descriptors of agents evicted by the cleanup sweep, sourced from disk when picked. */
|
|
73
|
+
evicted: readonly EvictedSubagent[];
|
|
71
74
|
registry: AgentConfigLookup;
|
|
72
75
|
/** Working directory for tool-call rendering (relative path display). */
|
|
73
76
|
cwd: string;
|
|
77
|
+
/** Reads a persisted session file for the file-snapshot source. */
|
|
78
|
+
readFile: (path: string) => string;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/** Options for the read-only transcript overlay. */
|
|
@@ -91,8 +96,8 @@ export interface TranscriptOverlayOptions {
|
|
|
91
96
|
* manager, so it stays a reactive consumer with no inbound call into the core.
|
|
92
97
|
*/
|
|
93
98
|
export class SessionNavigatorHandler {
|
|
94
|
-
async handle({ ui, agents, registry, cwd }: SessionNavigatorParams): Promise<void> {
|
|
95
|
-
const entries = listNavigableAgents(agents, registry);
|
|
99
|
+
async handle({ ui, agents, evicted, registry, cwd, readFile }: SessionNavigatorParams): Promise<void> {
|
|
100
|
+
const entries = listNavigableAgents(agents, evicted, registry);
|
|
96
101
|
if (entries.length === 0) {
|
|
97
102
|
ui.notify("No subagent sessions to view.", "info");
|
|
98
103
|
return;
|
|
@@ -105,7 +110,13 @@ export class SessionNavigatorHandler {
|
|
|
105
110
|
const entry = entries.find((candidate) => candidate.label === choice);
|
|
106
111
|
if (!entry) return;
|
|
107
112
|
|
|
108
|
-
|
|
113
|
+
let source: TranscriptSource;
|
|
114
|
+
try {
|
|
115
|
+
source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
|
|
116
|
+
} catch {
|
|
117
|
+
ui.notify("Could not read the session transcript file.", "error");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
109
120
|
const markdownTheme = getMarkdownTheme();
|
|
110
121
|
await ui.custom<undefined>(
|
|
111
122
|
(tui, theme, _keybindings, done) =>
|