@gotgenes/pi-subagents 17.2.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/docs/architecture/architecture.md +20 -5
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +34 -0
- package/docs/plans/0445-native-session-navigation.md +365 -0
- package/docs/retro/0444-shrink-widget-to-background-agents.md +38 -0
- package/docs/retro/0445-native-session-navigation.md +57 -0
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/lifecycle/subagent-session.ts +6 -0
- package/src/lifecycle/subagent.ts +6 -1
- package/src/types.ts +10 -1
- package/src/ui/session-navigation.ts +114 -0
- package/src/ui/session-navigator.ts +237 -0
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.3.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v17.2.0...pi-subagents-v17.3.0) (2026-06-22)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add /subagent-sessions read-only navigation command ([#445](https://github.com/gotgenes/pi-packages/issues/445)) ([341385c](https://github.com/gotgenes/pi-packages/commit/341385cf1f0c9f5ae1d3035c5b3b34bc3e636c92))
|
|
14
|
+
* add subagent session selection and live transcript source ([#445](https://github.com/gotgenes/pi-packages/issues/445)) ([7173647](https://github.com/gotgenes/pi-packages/commit/71736478c37af2c6ceaebc3ce0ee5a85e75ab1cb))
|
|
15
|
+
* add typed agentMessages accessor on subagent record ([#445](https://github.com/gotgenes/pi-packages/issues/445)) ([3bd49e3](https://github.com/gotgenes/pi-packages/commit/3bd49e37feea2cca52c706f7e765b8ae934ab9fe))
|
|
16
|
+
|
|
8
17
|
## [17.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v17.1.0...pi-subagents-v17.2.0) (2026-06-20)
|
|
9
18
|
|
|
10
19
|
|
|
@@ -500,6 +500,9 @@ Everything else — permissions, worktree/workspace isolation, UI, telemetry —
|
|
|
500
500
|
|
|
501
501
|
The rationale and the full reasoning chain that led here are recorded in [`docs/decisions/0002-extensions-on-a-minimal-core.md`](../decisions/0002-extensions-on-a-minimal-core.md).
|
|
502
502
|
|
|
503
|
+
A separate, longer-horizon note — [`client-server-opportunities.md`](./client-server-opportunities.md) — records what Pi's eventual client-server split (Mario Zechner's session-sync unification) would unlock for pi-subagents: viewing live subagent sessions, viewing suspended ones, and operators interacting with a subagent through an editor.
|
|
504
|
+
That architecture is not on the near-term roadmap; the note captures the opportunity so it is on record.
|
|
505
|
+
|
|
503
506
|
### Two extension surfaces
|
|
504
507
|
|
|
505
508
|
Extensions attach through exactly two surfaces, distinguished by the direction of information flow.
|
|
@@ -990,16 +993,28 @@ Smell: Category C (coupling) — the bespoke `ConversationViewer` re-implements
|
|
|
990
993
|
This step adds the new surface alongside the existing viewer; it does not touch `agent-menu.ts`.
|
|
991
994
|
Target files:
|
|
992
995
|
|
|
993
|
-
- New `src/ui/session-navigator.ts`
|
|
994
|
-
- `
|
|
996
|
+
- New `src/ui/session-navigator.ts` — a flat command that lists any subagent with a live record or a persisted session file (foreground included, never background-filtered), lets the operator pick one, and renders that child's transcript read-only.
|
|
997
|
+
- New typed accessor on `Subagent`/`SubagentSession` returning `record.messages` as `AgentMessage[]` (the boundary currently widens it to `readonly unknown[]`).
|
|
998
|
+
- `src/index.ts` — register the new command; the background widget ([#444]) is an optional secondary selection gesture, not a dependency.
|
|
995
999
|
|
|
996
1000
|
ADR-0004 Decision B: "Tell-Don't-Ask — hand Pi the session path; Pi owns the viewer."
|
|
997
|
-
Mechanism (confirmed by the Step 1 spike
|
|
1001
|
+
Mechanism (confirmed by the Step 1 spike and revised by [ADR-0004] Addendum 2): a **read-only** (non-interactive) transcript **dual-sourced by liveness**, rendered through Pi's own public entry components (no bespoke renderer).
|
|
1002
|
+
|
|
1003
|
+
- **Tracked agent** (still in `manager.listAgents()`) — render live from the in-memory record: `record.messages` for history, `record.subscribeToUpdates()` to re-render on streaming updates, and `record.activeTools` / `record.responseText` for the running-agent streaming indicator.
|
|
1004
|
+
- **Evicted / untracked agent** — render from the file snapshot: `parseSessionEntries(readFileSync(record.outputFile, "utf8"))` → drop the `SessionHeader` → `buildSessionContext(...).messages`.
|
|
1005
|
+
|
|
1006
|
+
Both sources yield `AgentMessage[]`, so one Pi-component renderer serves both: Pi's public entry components (`AssistantMessageComponent` / `ToolExecutionComponent` / …) or `serializeConversation` (see the [ADR-0004] addendum, Findings 0 and 1).
|
|
1007
|
+
Neither `switchSession` (a full takeover that invalidates the root's in-flight turn) nor `loadEntriesFromFile` (a test-only export the package's public barrel does not re-export, in both `0.79.1` and `0.79.8`) is used.
|
|
998
1008
|
`Subagent.outputFile` already exposes the persisted child session JSONL path via `subagentSession?.outputFile` — no new SDK dependency.
|
|
999
|
-
Rendering uses Pi's own public APIs (no bespoke renderer): `parseSessionEntries` → drop the `SessionHeader` → `buildSessionContext(...).messages` → Pi's public entry components (`AssistantMessageComponent` / `ToolExecutionComponent` / …) or `serializeConversation` (see the [ADR-0004] addendum, Findings 0 and 1).
|
|
1000
1009
|
The new surface stands up while the old `viewAgentConversation`/`ConversationViewer` path still works; the bespoke viewer is removed only by the terminal cut (Step 5).
|
|
1001
1010
|
|
|
1002
|
-
Outcome: operator views
|
|
1011
|
+
Outcome: operator views any subagent's session through Pi's native machinery — live for a running agent, a file snapshot for an evicted one; the new surface coexists with the old viewer until Step 5.
|
|
1012
|
+
|
|
1013
|
+
Landed ([#445], sliced): #445 shipped the first releasable vertical slice — the `/subagent-sessions` command (`src/ui/session-navigator.ts`), the pure selection/sourcing/text-render core (`src/ui/session-navigation.ts`), and the typed `agentMessages` accessor (`SessionMessage` on `SubagentSession`/`Subagent`).
|
|
1014
|
+
It is **live-source only** behind a renderer-agnostic `TranscriptSource` seam, rendered via Pi's `serializeConversation` text.
|
|
1015
|
+
With the `manager.listAgents()`-only candidate set, no listed record is ever session-disposed (dispose-and-delete are atomic), so the file-snapshot branch has no reachable caller and was deferred to keep `fallow dead-code` clean.
|
|
1016
|
+
Two follow-ups complete the step behind the same seam: (a) upgrade the renderer from `serializeConversation` text to Pi's per-entry TUI components; (b) broaden the candidate set to evicted agents and add the file-snapshot source (`parseSessionEntries` → `buildSessionContext`).
|
|
1017
|
+
The step heading stays unchecked until both land.
|
|
1003
1018
|
|
|
1004
1019
|
`Release: independent` (spike-gated)
|
|
1005
1020
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Client-server architecture: opportunities for pi-subagents
|
|
2
|
+
|
|
3
|
+
This note is forward-looking.
|
|
4
|
+
Pi's client-server split is **not on the near-term roadmap** — it is a long-term vision recorded in Mario Zechner's [pi session sync unification][session-sync] plan.
|
|
5
|
+
This document captures what that architecture would let pi-subagents do, so the opportunity is on record alongside the existing [Target architecture](./architecture.md#target-architecture) section.
|
|
6
|
+
|
|
7
|
+
It assumes the session-sync plan's shape:
|
|
8
|
+
|
|
9
|
+
- `AgentSession` owns all authoritative intra-session state and emits all sync events.
|
|
10
|
+
- `AgentSessionServer` owns multiple `AgentSession` runtimes, session lifecycle, client subscriptions, snapshots, and deltas.
|
|
11
|
+
- `AgentSessionClient` is the only thing a renderer (interactive mode, web mode) talks to.
|
|
12
|
+
- The sync model is **snapshot plus delta**: a client joins a session, receives one canonical snapshot, then a stream of deltas.
|
|
13
|
+
- Commands go up; state deltas come down.
|
|
14
|
+
- A client can `watch` (read-only) or `join` (interactive) a session, and can hold synchronized caches for many sessions at once.
|
|
15
|
+
- Session lifecycle (`new`, `resume`, `fork`, `import`, `join`, `leave`, `watch`, `unwatch`) is normal server API, not hidden local runtime replacement.
|
|
16
|
+
|
|
17
|
+
## The key realization
|
|
18
|
+
|
|
19
|
+
A subagent **is** a child `AgentSession`. pi-subagents already creates one via `createSubagentSession`, drives its turn loop through `SubagentSession`, and disposes it.
|
|
20
|
+
|
|
21
|
+
Today that child session is an in-process object visible only through machinery pi-subagents had to invent because Pi has no session-sync client:
|
|
22
|
+
|
|
23
|
+
- `record-observer` re-derives live activity from raw session events.
|
|
24
|
+
- the 80 ms `SubagentManager.listAgents()` widget poll.
|
|
25
|
+
- the [#277] Law-of-Demeter accessors (`Subagent.getConversation()`, `.messages`, `.subscribeToUpdates()`, `.getContextPercent()`) that re-expose session internals.
|
|
26
|
+
- the bespoke `ConversationViewer`, and [ADR-0004]'s replacement, native session navigation.
|
|
27
|
+
- [ADR-0004]'s dual-source-by-liveness split (tracked agent → in-memory record; evicted → file snapshot).
|
|
28
|
+
|
|
29
|
+
Every one of those is pi-subagents reinventing an `AgentSessionClient`.
|
|
30
|
+
The session-sync plan supplies the real one.
|
|
31
|
+
If subagent child sessions are registered as first-class sessions in the `AgentSessionServer`, all three target capabilities reduce to the same primitive the operator's own session already uses: `watch`/`join` a session id, receive a snapshot, then a delta stream.
|
|
32
|
+
|
|
33
|
+
## Capability 1 — viewing live subagent sessions
|
|
34
|
+
|
|
35
|
+
`watchSession(subagentSessionId)` gives the operator's client a canonical `SessionSnapshot` (current streaming message, pending tool executions with partial results, queue contents, context usage, active tools) followed by the live delta stream (`message_update`, `tool_execution_start/update/end`, `turn_start/end`).
|
|
36
|
+
|
|
37
|
+
What changes versus today:
|
|
38
|
+
|
|
39
|
+
1. **Late-join correctness.**
|
|
40
|
+
The snapshot carries in-flight state, so an operator attaching to an already-running subagent immediately sees its current streaming message and pending tools — not just future events.
|
|
41
|
+
Today the conversation viewer only catches future deltas plus whatever happens to be in the in-memory record.
|
|
42
|
+
2. **The widget and viewer become thin renderers** of synchronized session state.
|
|
43
|
+
The 80 ms poll, `record-observer`, and the [#277] accessors disappear — they were all substitutes for `subscribeSession`.
|
|
44
|
+
3. **A unified, reconnect-safe live session list.**
|
|
45
|
+
The global event scope (`session_created`, `session_status_changed` idle/busy) means the operator sees every subagent the instant it spawns, with a live status badge per agent — replacing both `listAgents()` polling and the hand-rolled `subagents:*` broadcast tier.
|
|
46
|
+
4. **Multi-session client state** lets one operator client hold synchronized caches for N subagents at once.
|
|
47
|
+
This is the clean foundation for the parallel-agent navigation gesture that [ADR-0004]'s spike (entry-criterion #3) struggled to design — tabbed or split views of multiple live subagents fall out of the model with no redesign.
|
|
48
|
+
|
|
49
|
+
## Capability 2 — viewing suspended subagent sessions
|
|
50
|
+
|
|
51
|
+
Today "suspended" is a dead end: once a background subagent completes and `disposeSession()` fires, the live `AgentSession` is gone, and the only artifact is the persisted JSONL read one-shot via `parseSessionEntries`.
|
|
52
|
+
That is a static file dump with no liveness and no path back to interaction.
|
|
53
|
+
|
|
54
|
+
Under the server model, the live/suspended distinction collapses at the client.
|
|
55
|
+
Session lifecycle is normal server API (`resume_session`, `join_session`, `import_session`), so a suspended subagent is just a session the server can rehydrate from `SessionManager` on demand:
|
|
56
|
+
|
|
57
|
+
1. The operator calls `joinSession(id)` (or the server lazily reloads the `AgentSession` from JSONL when a client joins a dormant session), gets a snapshot, and renders it through the same components as a live session.
|
|
58
|
+
[ADR-0004]'s two-code-path split (in-memory record vs file snapshot) stops being the client's problem — whether the session is resident or rehydrated is the server's private concern.
|
|
59
|
+
2. A genuinely paused subagent (one parked by the `ConcurrencyLimiter` awaiting capacity, or deliberately held) becomes representable: the server holds the runtime idle, the operator can `watch` its frozen state, and then sees it transition to `busy` via `session_status_changed` when capacity frees up.
|
|
60
|
+
3. Subagents can outlive the operator's TUI.
|
|
61
|
+
Because the server is a separate process, if the operator's interactive client restarts it reconnects and re-lists or re-joins subagent sessions that survived — impossible in today's single-process model.
|
|
62
|
+
|
|
63
|
+
## Capability 3 — operators interacting via an editor and submitting messages
|
|
64
|
+
|
|
65
|
+
Today the only inbound channel to a running subagent is `steer_subagent` — a single buffer-or-deliver message through `Subagent.steer`.
|
|
66
|
+
No editor, no takeover, no real conversation.
|
|
67
|
+
|
|
68
|
+
The plan's `join` versus `watch` split is the right primitive:
|
|
69
|
+
|
|
70
|
+
1. **`watch` is read-only** (Capability 1).
|
|
71
|
+
**`join` is interactive participation**: the operator's client sends session commands scoped to the subagent's session id — `prompt`, `abort`, `set model`, `set thinking level`, `set active tools`, `navigate tree`, `compact`, `run bash`.
|
|
72
|
+
2. **Editor flow.**
|
|
73
|
+
The operator opens the subagent session in their client, types in the editor (editor text, focus, and overlays are explicitly client-local UI state, never synchronized), and submits a `prompt` command targeting the subagent's session id.
|
|
74
|
+
The command goes up; the server applies it to the subagent's `AgentSession` (which already owns prompt submission, queued steering, and follow-up messages); canonical deltas flow back down to all subscribers.
|
|
75
|
+
This is precisely "commands go up, state deltas come down," applied to a subagent session instead of the operator's own.
|
|
76
|
+
3. **`steer` generalizes into the full command surface.**
|
|
77
|
+
pi-subagents' bespoke buffer-or-deliver logic (`Subagent.steer` rejecting when not running) is subsumed by `AgentSession`'s queued-steering machinery, and the custom buffering becomes redundant.
|
|
78
|
+
4. **Multiple clients on one session.**
|
|
79
|
+
The plan supports this explicitly, with deltas broadcast to all subscribers including the initiator, so an operator can jump into a subagent the parent agent spawned, inject guidance, and the parent's view stays consistent.
|
|
80
|
+
5. **Fork and rewind, not just append.**
|
|
81
|
+
`navigate tree` plus `fork_session` let an operator rewind a subagent or fork it at an entry — full session editing, far beyond appending a steer message.
|
|
82
|
+
|
|
83
|
+
## What pi-subagents itself becomes
|
|
84
|
+
|
|
85
|
+
This validates and sharpens the direction the architecture doc is already heading. pi-subagents' stated core job — spawn a child session derived from the parent, run the turn loop, track and stream and collect the result, gate concurrency, support resume, and publish its lifecycle — is almost exactly the `AgentSessionServer`'s job for child sessions.
|
|
86
|
+
|
|
87
|
+
- **The observation tier dissolves.**
|
|
88
|
+
This confirms [Phase 18]'s own finding that "the activity/metrics push tier is provisional" — under the server it genuinely does not need to exist in pi-subagents.
|
|
89
|
+
- **The recursion principle gets its real substrate.**
|
|
90
|
+
The architecture doc's "a subagent is a recursive Pi" framing maps directly onto the plan's session model: a subagent session is just another session in the server, with the same snapshot, delta, and command surface.
|
|
91
|
+
- **pi-subagents keeps only its semantic layer** — agent type, description, result text, token totals, concurrency admission, and the recursion guard — the things a generic session server does not know.
|
|
92
|
+
The liveness and activity channel rides on session sync instead of a hand-rolled broadcast.
|
|
93
|
+
|
|
94
|
+
### Bespoke machinery the server would retire
|
|
95
|
+
|
|
96
|
+
| Today (pi-subagents reinvents it) | Under the server architecture |
|
|
97
|
+
| ------------------------------------------------------------------------ | ---------------------------------------------------------- |
|
|
98
|
+
| `record-observer` re-deriving live activity | `subscribeSession` delta stream |
|
|
99
|
+
| 80 ms `listAgents()` widget poll | `session_created` / `session_status_changed` global events |
|
|
100
|
+
| [#277] accessors (`messages`, `subscribeToUpdates`, `getContextPercent`) | `SessionSnapshot` + deltas |
|
|
101
|
+
| `ConversationViewer` / native session navigation | client renders the snapshot through Pi's own components |
|
|
102
|
+
| dual-source-by-liveness split | server rehydrates; client sees one session shape |
|
|
103
|
+
| `steer_subagent` buffer-or-deliver | `join` + `prompt` / queued-steering commands |
|
|
104
|
+
| `subagents:*` activity broadcasts | session sync (semantic events stay; liveness moves) |
|
|
105
|
+
|
|
106
|
+
## Prerequisites and open questions
|
|
107
|
+
|
|
108
|
+
Being honest about the constraints the plan itself raises:
|
|
109
|
+
|
|
110
|
+
1. **Registry-local provider state is a hard prerequisite** (the plan states this explicitly).
|
|
111
|
+
Subagents run in-process under a multi-session server — exactly the scenario where process-global provider registration would corrupt one session's model view from another's extension load. pi-subagents is well-positioned (it already snapshots model and registry into `ParentSnapshot`), but the underlying Pi change must land first.
|
|
112
|
+
2. **The recursion guard stays pi-subagents' invariant.**
|
|
113
|
+
A subagent session created through the server must still have pi-subagents' three tools stripped, and an operator-joined subagent must not be able to spawn observers of itself in a loop.
|
|
114
|
+
3. **Workspace context must travel in the snapshot.**
|
|
115
|
+
Subagents can run in a different cwd or git worktree (`pi-subagents-worktrees`).
|
|
116
|
+
The plan's `SessionSnapshot.cwd` field already accounts for this, so an operator's client renders and interacts in the correct workspace.
|
|
117
|
+
4. **Operator-submitted versus parent-submitted command permissions.**
|
|
118
|
+
Commands an operator joins and submits flow through the subagent's `AgentSession`, so pi-permission-system's in-child gating still applies — but whether an operator's `run bash` or `prompt` should carry a different permission posture than the parent agent's is a genuine open design question, not something to assume.
|
|
119
|
+
|
|
120
|
+
## Net assessment
|
|
121
|
+
|
|
122
|
+
This architecture turns all three capabilities from bespoke, fragile features pi-subagents must build and maintain into thin clients of one uniform session-sync surface — and in doing so lets pi-subagents shed most of the observation infrastructure it only built because that surface did not exist yet.
|
|
123
|
+
|
|
124
|
+
[session-sync]: https://jot.mariozechner.at/s/zgzbq9n4f4mfck
|
|
125
|
+
[ADR-0004]: ../decisions/0004-reconsider-ui-direction.md
|
|
126
|
+
[Phase 18]: ./architecture.md#phase-18-complete
|
|
127
|
+
[#277]: https://github.com/gotgenes/pi-packages/issues/277
|
|
@@ -214,6 +214,40 @@ Reject the tentative `/subagents:settings` and the `/agents-settings` alternativ
|
|
|
214
214
|
|
|
215
215
|
This keeps bespoke transcript rendering out of the package (rendering is Pi's own public components), adds no inbound call from the UI to the core, and preserves the Phase 18 spine invariants (#422–#425).
|
|
216
216
|
|
|
217
|
+
## Addendum 2 (2026-06-20): Step 4 sourcing — live record plus file snapshot ([#445])
|
|
218
|
+
|
|
219
|
+
This addendum revises the first addendum's Criterion 2 answer for Step 4 ([#445]).
|
|
220
|
+
The "strictly read-only, file-only" sourcing and the "not a live overlay" framing are superseded by the dual-source decision below; everything else from the spike stands.
|
|
221
|
+
|
|
222
|
+
**Why revise.**
|
|
223
|
+
The spike's mechanism — `parseSessionEntries(readFileSync(record.outputFile))` read once — serves a _completed_ subagent well but is a frozen snapshot for a _running_ one: it shows only what was flushed to disk at open time and does not stream.
|
|
224
|
+
The clarified desired behavior is to see the **live** activity of any subagent _and_ the activity of a completed subagent.
|
|
225
|
+
The bespoke `ConversationViewer` being removed in Step 5 already delivers both — it subscribes to the live in-memory record (`record.subscribeToUpdates()`) and renders `record.messages` plus a streaming indicator built from `record.activeTools` / `record.responseText`.
|
|
226
|
+
That liveness comes from the in-memory record, not the persisted file, so a file-only viewer drops it.
|
|
227
|
+
|
|
228
|
+
**Decision — dual-source, one renderer.**
|
|
229
|
+
Step 4 sources the transcript by liveness and renders both sources through the same Pi public entry components (Finding 1 holds — no bespoke renderer):
|
|
230
|
+
|
|
231
|
+
- **Tracked agent (still in `manager.listAgents()`)** — render from the live in-memory record: `record.messages` for history, `record.subscribeToUpdates()` to re-render on streaming updates, and `record.activeTools` / `record.responseText` for the running-agent streaming indicator.
|
|
232
|
+
This is live.
|
|
233
|
+
- **Evicted / untracked agent** — render from the file snapshot: `parseSessionEntries(readFileSync(record.outputFile, "utf8"))` → drop the `SessionHeader` → `buildSessionContext(...).messages` (Findings 0 and 1).
|
|
234
|
+
|
|
235
|
+
Both sources yield `AgentMessage[]`, so a single Pi-component renderer serves both.
|
|
236
|
+
|
|
237
|
+
**Type-boundary note.**
|
|
238
|
+
`SubagentSession.messages` is deliberately widened to `readonly unknown[]` at the core boundary (`src/lifecycle/subagent-session.ts`), even though the underlying `_session.messages` is the SDK's `AgentMessage[]`.
|
|
239
|
+
Step 4 should add a typed accessor that returns `AgentMessage[]` (or narrow at the boundary) rather than feeding `unknown[]` into Pi's components.
|
|
240
|
+
This is a read accessor on the existing record — it adds no inbound call from the UI to the core and does not regress the Phase 18 spine invariants.
|
|
241
|
+
|
|
242
|
+
**Still read-only (non-interactive).**
|
|
243
|
+
The viewer remains strictly non-interactive: Criterion 2's anti-redundant-steering rationale stands (steering lives in the `steer_subagent` tool and the widget), and Criterion 1's rejection of `switchSession` is unchanged — "live" here means the in-memory subscription, not an active-session takeover.
|
|
244
|
+
|
|
245
|
+
**Candidate set (revises Criterion 3).**
|
|
246
|
+
The selection command lists **any subagent with a live record or a persisted session file** — foreground agents included, not only running background agents.
|
|
247
|
+
This matches the bespoke viewer's current reach (gated on `record.isSessionReady()`, never background-filtered) and avoids an interim regression.
|
|
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
|
+
The background widget ([#444]) remains the optional secondary selection gesture for background agents; the command is the primary, unit-testable surface.
|
|
250
|
+
|
|
217
251
|
[#444]: https://github.com/gotgenes/pi-packages/issues/444
|
|
218
252
|
[#445]: https://github.com/gotgenes/pi-packages/issues/445
|
|
219
253
|
[#446]: https://github.com/gotgenes/pi-packages/issues/446
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 445
|
|
3
|
+
issue_title: "pi-subagents: implement native session navigation for any subagent (live or completed)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Native session navigation for subagents (live transcript, text renderer)
|
|
7
|
+
|
|
8
|
+
## Release Recommendation
|
|
9
|
+
|
|
10
|
+
**Release:** ship independently
|
|
11
|
+
|
|
12
|
+
Phase 19 Step 4 ([#445]) carries `Release: independent` (spike-gated) in the architecture roadmap.
|
|
13
|
+
The only batch defined in Phase 19 is "dissolve-agents" (Steps 5–6, [#442]/[#441]); Step 4 is an additive replacement surface that stands up alongside the bespoke viewer and is not part of that batch.
|
|
14
|
+
It ships on its own.
|
|
15
|
+
|
|
16
|
+
## Problem Statement
|
|
17
|
+
|
|
18
|
+
The operator can view a subagent's conversation only through the bespoke `ConversationViewer` overlay, reachable inside the monolithic `/agents` menu.
|
|
19
|
+
That overlay re-implements session-transcript rendering — message formatting, a streaming indicator, scroll chrome — even though Pi already persists each child as a standalone session JSONL and ships its own session-rendering machinery.
|
|
20
|
+
ADR-0004 Decision B replaces the bespoke overlay with **native session navigation**: a flat command that lists the session's subagents, lets the operator pick one, and renders that child's transcript read-only through Pi's own components.
|
|
21
|
+
|
|
22
|
+
ADR-0004 Addendum 2 (the Step 1 spike, [#446]) refines the sourcing: a file read is a *snapshot* (good for a completed agent, frozen for a running one), so the transcript is **dual-sourced by liveness** — live from the in-memory record for a tracked agent, a file snapshot for an evicted one — rendered through one renderer.
|
|
23
|
+
|
|
24
|
+
This issue is deliberately **sliced** (see Decomposition below).
|
|
25
|
+
It stands up the complete navigation UX — list → pick → read-only, live transcript — behind a renderer-agnostic source seam, using Pi's `serializeConversation` as the first renderer.
|
|
26
|
+
Upgrading that renderer to Pi's per-entry TUI components, and broadening the candidate set to evicted agents, are named follow-ups.
|
|
27
|
+
|
|
28
|
+
## Goals
|
|
29
|
+
|
|
30
|
+
- Add a flat `/subagent-sessions` command that lists navigable subagents (`manager.listAgents()`, gated on `isSessionReady()`), lets the operator pick one, and renders its transcript **read-only** (non-interactive) in a scrollable overlay.
|
|
31
|
+
- Source the transcript **live** from the in-memory record: history from a new typed `agentMessages` accessor, live re-render via `record.subscribeToUpdates()`, and a running-agent streaming indicator from `record.activeTools` / `record.responseText`.
|
|
32
|
+
- Render through Pi's own `serializeConversation` (no bespoke message-formatting code) behind a `TranscriptSource` seam, so a follow-up swaps in Pi's per-entry TUI components without touching selection or sourcing.
|
|
33
|
+
- Add a typed `agentMessages` accessor on `SubagentSession` and `Subagent` returning `readonly SessionMessage[]` (the boundary currently widens `messages` to `readonly unknown[]`).
|
|
34
|
+
- Stand the new surface up **alongside** the existing `viewAgentConversation` / `ConversationViewer` path — it is removed only by Phase 19 Step 5 ([#442]).
|
|
35
|
+
|
|
36
|
+
Non-breaking: this is pure addition.
|
|
37
|
+
No existing export, command, default, or behavior changes.
|
|
38
|
+
|
|
39
|
+
## Non-Goals
|
|
40
|
+
|
|
41
|
+
- Do **not** implement the **file-snapshot** source branch (`parseSessionEntries` → `buildSessionContext`) in this issue.
|
|
42
|
+
With the `listAgents()`-only candidate set, no listed record is ever session-disposed (see Background), so the file branch has **no reachable caller** here — implementing it now would be dead code that fails the `fallow dead-code` gate.
|
|
43
|
+
It ships with the follow-up that broadens the candidate set to evicted agents.
|
|
44
|
+
- Do **not** use Pi's per-entry TUI components (`AssistantMessageComponent` / `ToolExecutionComponent` / …) as the renderer in this issue — that is the named renderer follow-up.
|
|
45
|
+
- Do **not** add in-session steering or any interactive gesture — steering stays in `steer_subagent` and the widget; the viewer is strictly read-only.
|
|
46
|
+
- Do **not** use `switchSession` or `loadEntriesFromFile` (per the ADR-0004 addendum).
|
|
47
|
+
- Do **not** touch `agent-menu.ts`, `conversation-viewer.ts`, or `message-formatters.ts` — they stay live until Step 5 ([#442]).
|
|
48
|
+
- Do **not** wire a widget selection gesture — the command is the primary surface; a widget gesture ([#444]) is an optional later enhancement.
|
|
49
|
+
|
|
50
|
+
## Background
|
|
51
|
+
|
|
52
|
+
Relevant existing modules:
|
|
53
|
+
|
|
54
|
+
- `src/lifecycle/subagent-session.ts` — `SubagentSession` wraps one SDK `AgentSession`.
|
|
55
|
+
Its `get messages(): readonly unknown[]` returns `this._session.messages as readonly unknown[]` — deliberately widened at the core boundary.
|
|
56
|
+
It already exposes `subscribe(fn)`, `outputFile`, `getContextPercent()`.
|
|
57
|
+
- `src/lifecycle/subagent.ts` — `Subagent` delegates `get messages(): readonly unknown[]`, `subscribeToUpdates(fn)`, `get outputFile()`, `isSessionReady()`, `get activeTools()`, `get responseText()`, `get status()` to its `subagentSession` / `state`.
|
|
58
|
+
These are the exact accessors the bespoke `ConversationViewer` reads — the new navigator reads the same surface.
|
|
59
|
+
- `src/lifecycle/subagent-manager.ts` — `listAgents()` returns tracked `Subagent[]` (newest first).
|
|
60
|
+
`cleanup()` (every 60 s) and `clearCompleted()` call `removeRecord(id, record)`, which calls `record.disposeSession()` **then** `agents.delete(id)` atomically.
|
|
61
|
+
`disposeSession()` disposes the wrapped session but does **not** null `subagentSession`; however, because dispose-and-delete are atomic, **no record that remains in `listAgents()` is ever session-disposed**.
|
|
62
|
+
Consequence: with the `listAgents()`-only candidate set, every listed, session-ready record has a live (non-disposed) session — so the live source is always valid and the file-snapshot branch is unreachable (drives the Non-Goal above).
|
|
63
|
+
- `src/ui/conversation-viewer.ts` — the bespoke overlay being replaced (deleted in Step 5).
|
|
64
|
+
Reads `record.messages`, subscribes via `record.subscribeToUpdates()`, renders a streaming indicator from `record.activeTools` / `record.responseText`, and owns its own scroll chrome (up/down/pageUp/pageDown/home/end/esc).
|
|
65
|
+
The new navigator must **not** import from it or from `message-formatters.ts` (both doomed in Step 5).
|
|
66
|
+
- `src/ui/agent-menu.ts` — `viewAgentConversation(ui, record)` gates on `record.isSessionReady()` and opens the overlay via `ui.custom<undefined>((tui, theme, _kb, done) => new ConversationViewer({...}), { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "70%" } })`.
|
|
67
|
+
This is the wiring shape the navigator mirrors.
|
|
68
|
+
- `src/ui/subagents-settings.ts` ([#447]) — the precedent for an additive, flat, narrow-interface command extracted from the menu.
|
|
69
|
+
- `src/ui/display.ts` — `getDisplayName(type, registry)`, `formatDuration(startedAt, completedAt)`, `getPromptModeLabel(type, registry)` for option labels.
|
|
70
|
+
- `src/types.ts` — shared type barrel; already imports SDK types (`AgentSessionEvent`, `ThinkingLevel`).
|
|
71
|
+
- `src/index.ts` — constructs `manager`, `registry`, and `settings`, and registers commands (`agents`, `subagents-settings`).
|
|
72
|
+
Sibling commands use flat hyphenated names with no `:` namespace.
|
|
73
|
+
|
|
74
|
+
Pi SDK facts verified for this plan:
|
|
75
|
+
|
|
76
|
+
- `parseSessionEntries`, `buildSessionContext`, `serializeConversation`, `SessionContext`, `FileEntry`, `SessionEntry`, and the per-entry components are all re-exported from the public `@earendil-works/pi-coding-agent` barrel; `loadEntriesFromFile` is **not** (confirmed in `dist/index.d.ts`).
|
|
77
|
+
- `SessionContext.messages` is typed `AgentMessage[]` (`AgentMessage` from `@earendil-works/pi-agent-core`, which is **not** a direct dependency).
|
|
78
|
+
Derive the type from the barrel-exported `SessionContext` rather than adding a dependency: `type SessionMessage = SessionContext["messages"][number]`.
|
|
79
|
+
- `serializeConversation(messages: Message[]): string` takes a **mutable** `Message[]` (from `@earendil-works/pi-ai`).
|
|
80
|
+
The accessor returns `readonly SessionMessage[]`, so the renderer must spread (`serializeConversation([...messages])`); the spread also resolves the readonly→mutable mismatch.
|
|
81
|
+
`AgentMessage` assignability to `Message` is verified in TDD step 2 (Pi itself feeds `buildSessionContext` output to serialization, so it is expected to hold); if it does not, narrow via a typed adapter at the renderer boundary.
|
|
82
|
+
|
|
83
|
+
AGENTS.md / package constraints:
|
|
84
|
+
|
|
85
|
+
- pi-subagents is a minimal core; surviving UI is an **in-core reactive consumer** with no inbound calls into the core.
|
|
86
|
+
The new `agentMessages` accessor is a read-only getter — it adds no inbound call and preserves the Phase 18 spine invariants ([#422]–[#425]).
|
|
87
|
+
- Keep Pi SDK *coupling* out of pure helpers: the pure module uses only Pi's pure utility functions (`serializeConversation`) and an **injected** file/registry, so it stays unit-testable; the overlay component and command handler are SDK/TUI consumers (allowed to import SDK/TUI directly).
|
|
88
|
+
- Use narrow interface types (not the concrete `Subagent` class) at the seam — concrete class types leak private fields into test mocks.
|
|
89
|
+
|
|
90
|
+
## Design Overview
|
|
91
|
+
|
|
92
|
+
Two new modules split pure logic from SDK/TUI wiring, plus a typed accessor on the record.
|
|
93
|
+
|
|
94
|
+
### Typed `agentMessages` accessor (boundary narrowing)
|
|
95
|
+
|
|
96
|
+
`src/types.ts` gains a derived alias (no new dependency):
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import type { SessionContext } from "@earendil-works/pi-coding-agent";
|
|
100
|
+
|
|
101
|
+
/** One message in a child session's history, typed from Pi's SessionContext. */
|
|
102
|
+
export type SessionMessage = SessionContext["messages"][number];
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`SubagentSession` and `Subagent` each gain a typed read accessor alongside the existing widened `messages`:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// SubagentSession
|
|
109
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
110
|
+
return this._session.messages;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Subagent
|
|
114
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
115
|
+
return this.subagentSession?.agentMessages ?? [];
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`_session.messages` is already `AgentMessage[]` (= `SessionMessage[]`), so this is a typed view of the same data — no cast through `unknown`.
|
|
120
|
+
The widened `messages` getter stays (other readers depend on it); `agentMessages` is the typed accessor the navigator uses.
|
|
121
|
+
|
|
122
|
+
### `session-navigation.ts` — pure selection, source, and text rendering
|
|
123
|
+
|
|
124
|
+
A pure module (SDK pure-utility imports only; no TUI, no `ExtensionAPI`).
|
|
125
|
+
It declares narrow interfaces for the record fields it reads (ISP — not the concrete `Subagent`).
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
/** The record fields the navigator reads to label and source a transcript. */
|
|
129
|
+
export interface NavigableSubagent {
|
|
130
|
+
readonly id: string;
|
|
131
|
+
readonly type: SubagentType;
|
|
132
|
+
readonly description: string;
|
|
133
|
+
readonly status: SubagentStatus;
|
|
134
|
+
readonly startedAt: number;
|
|
135
|
+
readonly completedAt: number | undefined;
|
|
136
|
+
readonly toolUses: number;
|
|
137
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
138
|
+
readonly responseText: string;
|
|
139
|
+
readonly agentMessages: readonly SessionMessage[];
|
|
140
|
+
isSessionReady(): boolean;
|
|
141
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A navigable entry: the record plus its display label. */
|
|
145
|
+
export interface NavigationEntry {
|
|
146
|
+
readonly record: NavigableSubagent;
|
|
147
|
+
readonly label: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Liveness-agnostic transcript source consumed by the renderer. */
|
|
151
|
+
export interface TranscriptSource {
|
|
152
|
+
/** Current message history. */
|
|
153
|
+
getMessages(): readonly SessionMessage[];
|
|
154
|
+
/** Subscribe to live updates; returns an unsubscribe, or undefined for a static snapshot. */
|
|
155
|
+
subscribe(onChange: () => void): (() => void) | undefined;
|
|
156
|
+
/** Running-agent streaming state, or undefined when not streaming. */
|
|
157
|
+
streaming(): { activeTools: ReadonlyMap<string, string>; responseText: string } | undefined;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Functions:
|
|
162
|
+
|
|
163
|
+
- `listNavigableAgents(agents, registry): NavigationEntry[]` — keep records with `isSessionReady()`, map each to a label (`getDisplayName` · description · status · `formatDuration`).
|
|
164
|
+
- `liveSource(record: NavigableSubagent): TranscriptSource` — `getMessages` → `record.agentMessages`; `subscribe` → `record.subscribeToUpdates`; `streaming` → `{ activeTools, responseText }` when `record.status === "running"`, else `undefined`.
|
|
165
|
+
- `renderTranscriptLines(source, opts): string[]` — `serializeConversation([...source.getMessages()])` split on `\n`, then append a minimal streaming indicator (active tool names + a truncated `responseText` preview) when `source.streaming()` is defined; return `[]`-safe content for an empty history (`"(no messages yet)"`).
|
|
166
|
+
|
|
167
|
+
`renderTranscriptLines` carries **no bespoke message formatting** — the transcript text is Pi's `serializeConversation` output; only the small streaming indicator is local (a few lines), and it does not import the doomed `message-formatters.ts`.
|
|
168
|
+
|
|
169
|
+
### `session-navigator.ts` — overlay component + command handler
|
|
170
|
+
|
|
171
|
+
SDK/TUI consumer.
|
|
172
|
+
|
|
173
|
+
- `TranscriptOverlay` — a read-only scrollable `Component`: on construct, subscribe via `source.subscribe(() => tui.requestRender())`; each `render(width)` calls `renderTranscriptLines(source)` and paints a scrolling viewport with header/footer chrome and key handling (up/down, pageUp/pageDown, home/end, esc/q to close); `dispose()` unsubscribes.
|
|
174
|
+
Scroll math mirrors the bespoke viewer's behavior but the content comes from `renderTranscriptLines` (the bespoke viewer is deleted in Step 5; this is fresh code with its own tests, not a clone of doomed code).
|
|
175
|
+
- `SessionNavigatorHandler` — `handle({ ui, agents, registry })`:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
async handle({ ui, agents, registry }: SessionNavigatorParams): Promise<void> {
|
|
179
|
+
const entries = listNavigableAgents(agents, registry);
|
|
180
|
+
if (entries.length === 0) { ui.notify("No subagent sessions to view.", "info"); return; }
|
|
181
|
+
const choice = await ui.select("Subagent sessions", entries.map(e => e.label));
|
|
182
|
+
const entry = entries.find(e => e.label === choice);
|
|
183
|
+
if (!entry) return;
|
|
184
|
+
const source = liveSource(entry.record);
|
|
185
|
+
await ui.custom<undefined>(
|
|
186
|
+
(tui, theme, _kb, done) => new TranscriptOverlay({ tui, theme, source, done, wrapText: wrapTextWithAnsi }),
|
|
187
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "70%" } },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`SessionNavigatorParams` / `SessionNavigatorUI` are narrow interfaces declaring only `select`, `notify`, and `custom` — the methods the handler uses.
|
|
193
|
+
|
|
194
|
+
### Registration call site (`index.ts`)
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const sessionNavigator = new SessionNavigatorHandler();
|
|
198
|
+
pi.registerCommand("subagent-sessions", {
|
|
199
|
+
description: "View a subagent's session transcript (read-only)",
|
|
200
|
+
handler: async (_args, ctx) => {
|
|
201
|
+
await sessionNavigator.handle({ ui: ctx.ui, agents: manager.listAgents(), registry });
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`manager.listAgents()` is called in the handler registration (the navigator never holds a manager reference — it receives the snapshot), keeping the navigator a pure reactive consumer with no inbound core call.
|
|
207
|
+
|
|
208
|
+
### Consumer call-site sketch (Tell-Don't-Ask / LoD check)
|
|
209
|
+
|
|
210
|
+
`liveSource` does not reach through the record to a stranger: it reads the record's own getters (`agentMessages`, `status`, `activeTools`, `responseText`) and delegates subscription to `record.subscribeToUpdates`.
|
|
211
|
+
The renderer talks only to `TranscriptSource`, never to the record — so the component renderer follow-up swaps the source's internals (file vs live) and the renderer (text vs components) independently.
|
|
212
|
+
No output arguments: nothing writes back into the record or the source.
|
|
213
|
+
|
|
214
|
+
### Decomposition and follow-ups (Kent Beck: make the change easy, then make the easy change)
|
|
215
|
+
|
|
216
|
+
This issue is the first releasable vertical slice.
|
|
217
|
+
The `TranscriptSource` seam is the "change made easy" — it decouples *how messages are sourced* from *how they are rendered*, so each follow-up is a localized swap:
|
|
218
|
+
|
|
219
|
+
1. **Renderer upgrade (named follow-up):** replace `renderTranscriptLines`/`TranscriptOverlay`'s `serializeConversation` text path with Pi's per-entry TUI components (`AssistantMessageComponent` / `ToolExecutionComponent` / …) behind the same `TranscriptSource`.
|
|
220
|
+
Selection and sourcing are untouched.
|
|
221
|
+
2. **Evicted-agent source (named follow-up):** broaden the candidate set beyond `listAgents()` and add the **file-snapshot** `TranscriptSource` (`parseSessionEntries(readFileSync(outputFile))` → drop `SessionHeader` → `buildSessionContext(...).messages`) for agents no longer tracked.
|
|
222
|
+
This is where the dual-source design lands its second branch; the renderer is untouched.
|
|
223
|
+
|
|
224
|
+
Recommendation: file these two as Phase 19 follow-up issues at ship time, and update the architecture roadmap's Step 4 description (which currently scopes dual-source + components as one step) to reflect the slice.
|
|
225
|
+
|
|
226
|
+
### Design-review findings
|
|
227
|
+
|
|
228
|
+
| Smell | Location | Evidence | Result |
|
|
229
|
+
| ------------------- | -------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
230
|
+
| Wide interface | `NavigableSubagent` (11 members) | `listNavigableAgents` reads 7 label fields; `liveSource` reads `agentMessages`/streaming/sub | Acceptable — two consumers split the surface; both are real reads, no unused field |
|
|
231
|
+
| LoD violation | `session-navigation.ts` | `liveSource` reads record getters + delegates `subscribeToUpdates`; no `record.x.y.z` chains | None |
|
|
232
|
+
| Output argument | `session-navigation.ts` | source/renderer return values; never write back into the record | None |
|
|
233
|
+
| Concrete-class leak | seam interfaces | navigator depends on `NavigableSubagent`/`TranscriptSource`, not the concrete `Subagent` | None — narrow interfaces |
|
|
234
|
+
|
|
235
|
+
The new collaborators each own behavior (`liveSource` returns a stateful subscription view; `TranscriptOverlay` owns scroll state and the subscription lifecycle; `renderTranscriptLines` returns rendered lines) — not procedure-splitting.
|
|
236
|
+
|
|
237
|
+
### Edge cases
|
|
238
|
+
|
|
239
|
+
- Empty candidate set → `ui.notify("No subagent sessions to view.", "info")`, return.
|
|
240
|
+
- Operator cancels `select` (`undefined`) or the label is stale → return, no overlay.
|
|
241
|
+
- Empty message history (`agentMessages.length === 0`) → render `"(no messages yet)"`.
|
|
242
|
+
- Running agent → streaming indicator appended; a `subscribeToUpdates` event triggers re-render with the latest `agentMessages` + streaming state.
|
|
243
|
+
- Completed tracked agent → `streaming()` returns `undefined` (status not `running`); transcript is the final `agentMessages`, no indicator; `subscribe` still attaches harmlessly.
|
|
244
|
+
- `subscribeToUpdates` returns `undefined` (no live session) → cannot happen for a `listAgents()`-gated, session-ready record, but the overlay tolerates `undefined` (no subscription, static render).
|
|
245
|
+
- Very narrow terminal width → overlay returns `[]` below a minimum width, matching the bespoke viewer.
|
|
246
|
+
|
|
247
|
+
## Module-Level Changes
|
|
248
|
+
|
|
249
|
+
- **New** `src/ui/session-navigation.ts` — `NavigableSubagent`, `NavigationEntry`, `TranscriptSource` interfaces; `listNavigableAgents`, `liveSource`, `renderTranscriptLines` functions.
|
|
250
|
+
- **New** `src/ui/session-navigator.ts` — `TranscriptOverlay` component, `SessionNavigatorHandler` class, `SessionNavigatorParams` / `SessionNavigatorUI` narrow interfaces.
|
|
251
|
+
- **Changed** `src/types.ts` — add `export type SessionMessage = SessionContext["messages"][number]` and the `SessionContext` type import.
|
|
252
|
+
- **Changed** `src/lifecycle/subagent-session.ts` — add `get agentMessages(): readonly SessionMessage[]`.
|
|
253
|
+
- **Changed** `src/lifecycle/subagent.ts` — add `get agentMessages(): readonly SessionMessage[]`.
|
|
254
|
+
- **Changed** `src/index.ts` — import `SessionNavigatorHandler`, construct it, register the `subagent-sessions` command (sole call site of the new export).
|
|
255
|
+
- **New** `test/ui/session-navigation.test.ts` — unit tests for `listNavigableAgents`, `liveSource`, `renderTranscriptLines`.
|
|
256
|
+
- **New** `test/ui/session-navigator.test.ts` — unit tests for `TranscriptOverlay` render/scroll/key handling and `SessionNavigatorHandler` select→custom wiring.
|
|
257
|
+
- **Changed** `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/subagent.test.ts` — add `agentMessages` accessor tests.
|
|
258
|
+
|
|
259
|
+
No removed or renamed exports — grep for removed symbols is not applicable (pure addition).
|
|
260
|
+
|
|
261
|
+
Doc / skill grep results:
|
|
262
|
+
|
|
263
|
+
- `docs/architecture/architecture.md` Step 4 currently describes the **full** dual-source + components scope and names `src/ui/session-navigator.ts` and the typed accessor.
|
|
264
|
+
This issue narrows that scope (live-source + text renderer).
|
|
265
|
+
Update the Step 4 description to reflect the slice and the two named follow-ups — defer the status-line flip and the rescoping edit to ship time (matches the [#447] precedent), or fold a short rescoping note in this issue if preferred.
|
|
266
|
+
- `.pi/skills/package-pi-subagents/SKILL.md` lists the UI domain by count (`ui/` = 10 modules); this adds two files (→ 12).
|
|
267
|
+
The table is a coarse summary not maintained per-file — leave for a later Phase 19 doc-sync unless the operator asks otherwise.
|
|
268
|
+
- No `src/`/`test/` references to a removed symbol (nothing is removed); the bespoke `ConversationViewer` and `message-formatters.ts` are untouched and keep their tests.
|
|
269
|
+
|
|
270
|
+
## Test Impact Analysis
|
|
271
|
+
|
|
272
|
+
1. **New tests enabled.**
|
|
273
|
+
`listNavigableAgents`, `liveSource`, and `renderTranscriptLines` are pure and unit-testable against narrow `NavigableSubagent` / `TranscriptSource` stubs — no full `Subagent`, `TUI`, or `AgentSession` construction.
|
|
274
|
+
This is strictly more focused than `conversation-viewer.test.ts`, which builds a full record + TUI to exercise transcript rendering.
|
|
275
|
+
The `agentMessages` accessor gets direct unit tests on both `SubagentSession` and `Subagent`.
|
|
276
|
+
2. **Existing tests that become redundant.**
|
|
277
|
+
None in this issue.
|
|
278
|
+
`conversation-viewer.test.ts` and `message-formatters.test.ts` still exercise the live, still-shipping bespoke overlay; they are removed only when Step 5 ([#442]) deletes those modules.
|
|
279
|
+
3. **Existing tests that must stay as-is.**
|
|
280
|
+
All bespoke-viewer and menu tests stay unchanged — this issue does not touch `agent-menu.ts`, `conversation-viewer.ts`, or `message-formatters.ts`.
|
|
281
|
+
|
|
282
|
+
## Invariants at risk
|
|
283
|
+
|
|
284
|
+
This issue adds two modules, one accessor, and one command registration; it does not touch the Phase 18 spine that prior steps refactored.
|
|
285
|
+
The relevant `Outcome:` invariants and their pins:
|
|
286
|
+
|
|
287
|
+
- **[#422] — runtime holds zero UI state.**
|
|
288
|
+
The navigator holds its own overlay/scroll state; nothing is pushed onto the runtime.
|
|
289
|
+
Pinned by `runtime.test.ts` / the service spine tests; unchanged.
|
|
290
|
+
- **[#423] — widget/UI is a reactive consumer with no inbound calls from core spawn tools.**
|
|
291
|
+
The navigator reads via the injected `manager.listAgents()` snapshot and record getters only; the new `agentMessages` accessor is a read-only getter.
|
|
292
|
+
No inbound call into the core.
|
|
293
|
+
A focused navigator test asserts the handler reads only through its injected `agents` + record getters.
|
|
294
|
+
- **[#424] — the `subagent` tool depends only on manager/runtime/settings/registry.**
|
|
295
|
+
No tool change.
|
|
296
|
+
- **[#425] — declared event channels equal emitted channels, no vacant hook.**
|
|
297
|
+
The navigator subscribes to existing record updates; it declares/emits no new channel.
|
|
298
|
+
Pinned by the events-contract suite (`test/service`, `test/observation`); unchanged.
|
|
299
|
+
|
|
300
|
+
A later phase step (the renderer / evicted-source follow-ups) must not regress these with a green suite — the navigator-reads-only test pins #423 for the seam.
|
|
301
|
+
|
|
302
|
+
## TDD Order
|
|
303
|
+
|
|
304
|
+
1. **Red→Green: typed `agentMessages` accessor.**
|
|
305
|
+
Test surface: `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/subagent.test.ts`.
|
|
306
|
+
Tests: `SubagentSession.agentMessages` returns the wrapped session's typed messages; `Subagent.agentMessages` delegates to the session and returns `[]` when no session.
|
|
307
|
+
Implementation: add `SessionMessage` to `src/types.ts` (with the `SessionContext` import); add the two getters.
|
|
308
|
+
Run `pnpm run check` (verifies `SessionContext["messages"][number]` resolves and `_session.messages` is assignable).
|
|
309
|
+
Commit: `feat: add typed agentMessages accessor on subagent record (#445)`.
|
|
310
|
+
2. **Red→Green: pure selection, live source, and text rendering.**
|
|
311
|
+
Test surface: `test/ui/session-navigation.test.ts`.
|
|
312
|
+
Tests:
|
|
313
|
+
- `listNavigableAgents` keeps only `isSessionReady()` records and builds the expected labels (name · description · status · duration); empty input → `[]`.
|
|
314
|
+
- `liveSource.getMessages` returns the record's `agentMessages`; `subscribe` delegates to `subscribeToUpdates`; `streaming()` returns state only when `status === "running"`, else `undefined`.
|
|
315
|
+
- `renderTranscriptLines` returns `serializeConversation` output split into lines; appends the streaming indicator when streaming; returns the empty-history placeholder for `[]`.
|
|
316
|
+
Implementation: create `src/ui/session-navigation.ts` with the three interfaces and three functions.
|
|
317
|
+
Verify `AgentMessage`→`Message` assignability for `serializeConversation([...messages])` here; if it fails, add a typed adapter at the boundary.
|
|
318
|
+
Commit: `feat: add subagent session selection and live transcript source (#445)`.
|
|
319
|
+
3. **Red→Green: read-only overlay component + `/subagent-sessions` command.**
|
|
320
|
+
Test surface: `test/ui/session-navigator.test.ts`.
|
|
321
|
+
Tests:
|
|
322
|
+
- `TranscriptOverlay.render(width)` paints the current `renderTranscriptLines` content with chrome; scroll keys move the viewport; `esc`/`q` calls `done`; `dispose()` unsubscribes; a `subscribe` callback triggers `tui.requestRender`.
|
|
323
|
+
- `SessionNavigatorHandler.handle`: empty entries → `notify` + no `custom`; cancel at `select` → no `custom`; a valid pick → `custom` opened with a `liveSource` for the chosen record (assert via a `makeMenuUI`-style stub).
|
|
324
|
+
Implementation: create `src/ui/session-navigator.ts`; register the command in `src/index.ts` (sole call site).
|
|
325
|
+
Run `pnpm run check` immediately (the registration is the only consumer of the new handler export — both must compile together).
|
|
326
|
+
Run `pnpm fallow dead-code` before pushing (confirms no unreachable file-source branch slipped in).
|
|
327
|
+
Commit: `feat: add /subagent-sessions read-only navigation command (#445)`.
|
|
328
|
+
|
|
329
|
+
Steps 2 and 3 may be folded if preferred, but keeping the pure logic (with its stub-only tests) separate from the TUI/SDK wiring keeps each commit self-contained.
|
|
330
|
+
|
|
331
|
+
## Risks and Mitigations
|
|
332
|
+
|
|
333
|
+
- **Risk: `AgentMessage` is not assignable to `serializeConversation`'s `Message[]`.**
|
|
334
|
+
Mitigation: verify in TDD step 2 (`pnpm run check`); Pi feeds `buildSessionContext` output to serialization, so it is expected to hold.
|
|
335
|
+
If not, add a typed adapter (`toMessages(messages): Message[]`) at the renderer boundary — the seam isolates the fix to `renderTranscriptLines`.
|
|
336
|
+
- **Risk: implementing the file-snapshot branch creates dead code (no caller under `listAgents()`-only) and fails `fallow dead-code`.**
|
|
337
|
+
Mitigation: this issue ships the **live source only** behind the seam; the file source is a named follow-up.
|
|
338
|
+
`pnpm fallow dead-code` runs before push.
|
|
339
|
+
- **Risk: importing the doomed `message-formatters.ts` / `conversation-viewer.ts` couples the navigator to code Step 5 deletes.**
|
|
340
|
+
Mitigation: the navigator imports neither; the streaming indicator is a small local helper and the transcript text is Pi's `serializeConversation`.
|
|
341
|
+
- **Risk: the new overlay duplicates the bespoke viewer's scroll chrome (apparent duplication).**
|
|
342
|
+
Mitigation: the bespoke viewer is deleted in Step 5, so there is no lasting duplication; the new overlay is fresh code with its own tests and a different content source (`renderTranscriptLines` vs bespoke `formatMessage`).
|
|
343
|
+
- **Risk: command-name choice.**
|
|
344
|
+
Mitigation: `subagent-sessions` follows the flat-hyphenated sibling convention (`agents`, `subagents-settings`); confirm at ship time (see Open Questions).
|
|
345
|
+
- **Risk: the architecture roadmap's Step 4 description diverges from the sliced scope.**
|
|
346
|
+
Mitigation: update the Step 4 description and file the two follow-ups at ship time.
|
|
347
|
+
|
|
348
|
+
## Open Questions
|
|
349
|
+
|
|
350
|
+
- **Command name** — `subagent-sessions` is proposed (flat, hyphenated, sibling-consistent).
|
|
351
|
+
Alternatives: `subagent-transcript`, `view-subagent`, `subagents-view`.
|
|
352
|
+
Confirm at ship time; the command is not yet shipped, so it is cheaply renamable.
|
|
353
|
+
- **Whether to rescope the architecture roadmap's Step 4 entry in this issue or defer** to a Phase 19 doc-sync — decide at ship time (matches the [#447] precedent).
|
|
354
|
+
- **Whether to file the two named follow-ups (renderer upgrade, evicted-agent source) now or after this slice lands** — recommended at ship time so the roadmap stays accurate.
|
|
355
|
+
|
|
356
|
+
[#441]: https://github.com/gotgenes/pi-packages/issues/441
|
|
357
|
+
[#442]: https://github.com/gotgenes/pi-packages/issues/442
|
|
358
|
+
[#444]: https://github.com/gotgenes/pi-packages/issues/444
|
|
359
|
+
[#445]: https://github.com/gotgenes/pi-packages/issues/445
|
|
360
|
+
[#446]: https://github.com/gotgenes/pi-packages/issues/446
|
|
361
|
+
[#447]: https://github.com/gotgenes/pi-packages/issues/447
|
|
362
|
+
[#422]: https://github.com/gotgenes/pi-packages/issues/422
|
|
363
|
+
[#423]: https://github.com/gotgenes/pi-packages/issues/423
|
|
364
|
+
[#424]: https://github.com/gotgenes/pi-packages/issues/424
|
|
365
|
+
[#425]: https://github.com/gotgenes/pi-packages/issues/425
|
|
@@ -41,3 +41,41 @@ Test count went from 24 to 26 in `agent-widget.test.ts` (two new background-only
|
|
|
41
41
|
One non-blocking naming WARN — `clearWidget`'s `allAgents` parameter and JSDoc were stale after the refactor (it now receives only background agents); fixed in a follow-up `refactor:` commit (`backgroundAgents`).
|
|
42
42
|
Landed as a separate commit rather than amending the feat commit because HEAD was already the `docs:` commit and the fix must not land in a `docs:` commit.
|
|
43
43
|
- All gates green: `pnpm run check`, root `pnpm run lint`, full vitest (1064), `pnpm fallow dead-code`.
|
|
44
|
+
|
|
45
|
+
## Stage: Final Retrospective (2026-06-20T22:41:58Z)
|
|
46
|
+
|
|
47
|
+
### Session summary
|
|
48
|
+
|
|
49
|
+
Shipped Phase 19 Step 3 end-to-end across three stages (plan → TDD → ship) in one continuous session, releasing `@gotgenes/pi-subagents` v17.2.0.
|
|
50
|
+
The change funnels both `manager.listAgents()` call sites through a single `listBackgroundAgents()` accessor so the above-editor widget shows only background agents.
|
|
51
|
+
Execution was unusually clean: no rabbit holes, no user corrections, one reviewer-caught naming miss resolved in a single follow-up commit.
|
|
52
|
+
|
|
53
|
+
### Observations
|
|
54
|
+
|
|
55
|
+
#### What went well
|
|
56
|
+
|
|
57
|
+
- Tidy-first preparatory step paid off as designed.
|
|
58
|
+
TDD step 1 (`test:` migrate fixtures to background invocation) was inert until the filter landed, turning the behavior change in step 2 into a pure addition with zero fixture churn mid-cycle.
|
|
59
|
+
This is the `code-design` skill's "make the change easy, then make the easy change" applied to a test suite — worth reusing whenever a new predicate will retroactively exclude existing fixtures.
|
|
60
|
+
- The pre-completion reviewer caught semantic drift that all four deterministic gates missed.
|
|
61
|
+
`tsc`, `pnpm run lint`, vitest (1064), and `pnpm fallow dead-code` all passed the stale `clearWidget(allAgents)` parameter — a name whose meaning narrowed to background-only but which no tool flags, because a misleading-but-valid identifier is neither a type nor a lint error.
|
|
62
|
+
This is the reviewer's distinct value: naming/comment staleness after a rename.
|
|
63
|
+
|
|
64
|
+
#### What caused friction (agent side)
|
|
65
|
+
|
|
66
|
+
- `other` (incomplete rename) — during the `feat:` step I renamed the local `allAgents` → `backgroundAgents` in `update()` but left the `clearWidget` parameter (a separate scope receiving the same value) named `allAgents`, along with its JSDoc.
|
|
67
|
+
Impact: one extra `refactor:` commit (`319e7df3`); ~2 minutes.
|
|
68
|
+
No rework to logic.
|
|
69
|
+
Reviewer-caught, not self-caught.
|
|
70
|
+
|
|
71
|
+
#### What caused friction (user side)
|
|
72
|
+
|
|
73
|
+
- None.
|
|
74
|
+
The operator delegated the full plan/TDD/ship/retro workflow and it ran without correction or mid-course steering.
|
|
75
|
+
No earlier-context opportunity identified.
|
|
76
|
+
|
|
77
|
+
### Changes made
|
|
78
|
+
|
|
79
|
+
1. Added this Final Retrospective stage entry to `packages/pi-subagents/docs/retro/0444-shrink-widget-to-background-agents.md`.
|
|
80
|
+
|
|
81
|
+
No `AGENTS.md` or prompt changes: the one friction point (an incomplete rename) was reviewer-caught with trivial cost, and the wins (tidy-first fixture migration, pre-completion reviewer value) are already covered by the `code-design` and `testing` skills — nothing actionable to encode.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 445
|
|
3
|
+
issue_title: "pi-subagents: implement native session navigation for any subagent (live or completed)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #445 — pi-subagents: implement native session navigation for any subagent (live or completed)
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-22T00:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced `docs/plans/0445-native-session-navigation.md`, a sliced plan for Phase 19 Step 4.
|
|
13
|
+
The operator (issue author) chose Pi's per-entry TUI-component renderer as the eventual target but explicitly invited a Kent Beck "make the change easy, then make the easy change" breakdown into incremental, releasable additions, and chose `manager.listAgents()`-only as the candidate set.
|
|
14
|
+
The plan scopes #445 to the first releasable vertical slice — full list → pick → read-only live transcript using `serializeConversation` text rendering behind a renderer-agnostic `TranscriptSource` seam — and names two follow-ups (TUI-component renderer; evicted-agent file source).
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- **Two `ask_user` rounds drove scope.**
|
|
19
|
+
Round 1: renderer choice (text vs TUI components) and candidate-set scope.
|
|
20
|
+
Round 2: decomposition strategy.
|
|
21
|
+
The operator's note on round 1 ("If this sounds large, it's probably because it is... what would Kent Beck do") reframed the whole plan from one big issue into a sliced first release.
|
|
22
|
+
- **Key architectural finding — the file-snapshot branch is unreachable in #445.**
|
|
23
|
+
`SubagentManager.removeRecord` calls `record.disposeSession()` then `agents.delete(id)` atomically, and `disposeSession()` does not null `subagentSession`.
|
|
24
|
+
So no record in `listAgents()` is ever session-disposed; with the `listAgents()`-only candidate set, every listed session-ready record has a live session, and the dual-source "evicted/untracked → file" branch has no caller.
|
|
25
|
+
Implementing it now would be dead code that fails the `fallow dead-code` gate.
|
|
26
|
+
This sharpened the slice: #445 ships the **live source only** behind the seam.
|
|
27
|
+
- **Type-boundary plan.** `AgentMessage` is not in the `@earendil-works/pi-coding-agent` barrel and `@earendil-works/pi-agent-core` is not a dependency, so the plan derives `SessionMessage = SessionContext["messages"][number]` from the barrel-exported `SessionContext` rather than adding a dep. `serializeConversation` takes a mutable `Message[]`, so the renderer spreads (`serializeConversation([...messages])`); `AgentMessage`→`Message` assignability is flagged as a TDD step-2 `pnpm run check` verification with a typed-adapter fallback.
|
|
28
|
+
- **Seam justifies its weight via testability, not just the follow-up.** `TranscriptSource` + narrow `NavigableSubagent` interfaces let the pure module be unit-tested with light stubs (no full `Subagent`/`TUI`/`AgentSession`), and decouple the renderer (text→components) from sourcing (live→file) for the two named follow-ups.
|
|
29
|
+
- **Doomed-code avoidance.**
|
|
30
|
+
The navigator must not import `message-formatters.ts` or `conversation-viewer.ts` (both deleted in Step 5, [#442]); the streaming indicator is a small local helper and the transcript text is Pi's `serializeConversation`.
|
|
31
|
+
- **Open item for ship time:** the architecture roadmap's Step 4 description currently scopes full dual-source + components as one step and will need rescoping to match the slice, plus filing the two follow-up issues.
|
|
32
|
+
Command name `subagent-sessions` is proposed but flagged confirmable.
|
|
33
|
+
- Release: ship independently (roadmap Step 4 is `Release: independent`, spike-gated; not part of the "dissolve-agents" batch).
|
|
34
|
+
|
|
35
|
+
## Stage: Implementation — TDD (2026-06-22T18:03:26Z)
|
|
36
|
+
|
|
37
|
+
### Session summary
|
|
38
|
+
|
|
39
|
+
Implemented the sliced #445 in three TDD cycles: (1) the typed `agentMessages` accessor (`SessionMessage` on `SubagentSession`/`Subagent`); (2) the pure `session-navigation.ts` (selection, `liveSource`, `renderTranscriptLines`); (3) the `session-navigator.ts` overlay + `/subagent-sessions` command + `index.ts` wiring.
|
|
40
|
+
Test count went 1064 → 1084 (+20).
|
|
41
|
+
Pre-completion reviewer returned PASS.
|
|
42
|
+
|
|
43
|
+
### Observations
|
|
44
|
+
|
|
45
|
+
- **`AgentMessage` is NOT assignable to `serializeConversation`'s `Message[]`** (the plan's flagged risk materialized): `AgentMessage` is a superset adding session-display variants like `BashExecutionMessage`.
|
|
46
|
+
Applied the planned mitigation — a private `toMessages` adapter casting via `as unknown as Parameters<typeof serializeConversation>[0]` (`Message` is not re-exported from the `@earendil-works/pi-ai` barrel, so the type is referenced through the function signature rather than imported by name).
|
|
47
|
+
- **`SessionContext` name collision:** `types.ts` already declares a local `SessionContext` interface, so the SDK import is aliased — `import type { SessionContext as SdkSessionContext }` — and `SessionMessage = SdkSessionContext["messages"][number]`.
|
|
48
|
+
- **`describeActivity` survives in `display.ts`** (not the doomed `message-formatters.ts`), so the streaming indicator reuses it — the navigator imports neither doomed module, as planned.
|
|
49
|
+
- **File-snapshot branch confirmed unreachable and omitted:** with the `listAgents()`-only candidate set, no listed record is ever session-disposed (dispose-and-delete are atomic), so the live source is always valid; implementing the file branch would have been dead code.
|
|
50
|
+
`fallow dead-code` is clean.
|
|
51
|
+
- **Minor deviation:** `test/helpers/mock-session.ts` gained an `agentMessages` getter on the session stub (needed for the `Subagent.agentMessages` delegation test) — a small fixture addition not itemized in the plan's Module-Level Changes.
|
|
52
|
+
- **Two lint nits caught by the pre-commit hook** (both per the testing skill): an unnecessary optional chain on a destructured array element, and an unnecessary `!` on `mock.calls[0][0]` — both fixed inline.
|
|
53
|
+
- **Architecture doc:** added a "Landed ([#445], sliced)" note to Step 4; did **not** mark the step `✅` because the component renderer and evicted-agent file source are deferred follow-ups.
|
|
54
|
+
`SKILL.md` `ui/` module count (10 → 12) left for a later Phase 19 doc-sync, per the plan.
|
|
55
|
+
- **Reviewer verdict: PASS.**
|
|
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
|
+
- **Open at ship time:** confirm command name `subagent-sessions`; file the two follow-up issues (component renderer; evicted-agent file source).
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { SteerTool } from "#src/tools/steer-tool";
|
|
|
47
47
|
import { FsAgentFileOps } from "#src/ui/agent-file-ops";
|
|
48
48
|
import { AgentsMenuHandler } from "#src/ui/agent-menu";
|
|
49
49
|
import { AgentWidget } from "#src/ui/agent-widget";
|
|
50
|
+
import { SessionNavigatorHandler } from "#src/ui/session-navigator";
|
|
50
51
|
import { SubagentsSettingsHandler } from "#src/ui/subagents-settings";
|
|
51
52
|
|
|
52
53
|
export default function (pi: ExtensionAPI) {
|
|
@@ -192,4 +193,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
193
|
await subagentsSettings.handle({ ui: ctx.ui });
|
|
193
194
|
},
|
|
194
195
|
});
|
|
196
|
+
|
|
197
|
+
// ---- /subagent-sessions command ----
|
|
198
|
+
|
|
199
|
+
const sessionNavigator = new SessionNavigatorHandler();
|
|
200
|
+
|
|
201
|
+
pi.registerCommand("subagent-sessions", {
|
|
202
|
+
description: "View a subagent's session transcript (read-only)",
|
|
203
|
+
handler: async (_args, ctx) => {
|
|
204
|
+
await sessionNavigator.handle({ ui: ctx.ui, agents: manager.listAgents(), registry });
|
|
205
|
+
},
|
|
206
|
+
});
|
|
195
207
|
}
|
|
@@ -19,6 +19,7 @@ import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
|
|
|
19
19
|
import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
|
|
20
20
|
import { extractText } from "#src/session/context";
|
|
21
21
|
import { getAgentConversation } from "#src/session/conversation";
|
|
22
|
+
import type { SessionMessage } from "#src/types";
|
|
22
23
|
|
|
23
24
|
/** Outcome of one turn loop. */
|
|
24
25
|
export interface TurnLoopResult {
|
|
@@ -178,6 +179,11 @@ export class SubagentSession {
|
|
|
178
179
|
return this._session.messages as readonly unknown[];
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
/** The session's message history, typed for Pi's session-rendering machinery. */
|
|
183
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
184
|
+
return this._session.messages;
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
/** Tear down: session.dispose() + emit `disposed` (registry unregister). */
|
|
182
188
|
dispose(): void {
|
|
183
189
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
|
|
@@ -20,7 +20,7 @@ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
|
|
|
20
20
|
import { NotificationState } from "#src/observation/notification-state";
|
|
21
21
|
import { subscribeSubagentObserver } from "#src/observation/record-observer";
|
|
22
22
|
import type { RunConfig } from "#src/runtime";
|
|
23
|
-
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
23
|
+
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
|
|
24
24
|
|
|
25
25
|
/** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
|
|
26
26
|
export interface SubagentLifecycleObserver {
|
|
@@ -162,6 +162,11 @@ export class Subagent {
|
|
|
162
162
|
return this.subagentSession?.messages ?? [];
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
|
|
166
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
167
|
+
return this.subagentSession?.agentMessages ?? [];
|
|
168
|
+
}
|
|
169
|
+
|
|
165
170
|
constructor(init: SubagentInit) {
|
|
166
171
|
// Identity
|
|
167
172
|
this.id = init.id;
|
package/src/types.ts
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { ModelRegistry } from "#src/session/model-resolver";
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
export { Subagent } from "#src/lifecycle/subagent";
|
|
11
11
|
export type { AgentSessionEvent, ThinkingLevel };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* One message in a child session's history, typed from Pi's `SessionContext`.
|
|
15
|
+
*
|
|
16
|
+
* Derived from the barrel-exported `SessionContext` (whose `messages` field is
|
|
17
|
+
* `AgentMessage[]`) so the package needs no direct dependency on
|
|
18
|
+
* `@earendil-works/pi-agent-core`, which is not re-exported from the public barrel.
|
|
19
|
+
*/
|
|
20
|
+
export type SessionMessage = SdkSessionContext["messages"][number];
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Narrow session interface for event subscription.
|
|
15
24
|
* Used by record-observer — only the subscribe method is needed.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
|
|
3
|
+
*
|
|
4
|
+
* Splits the unit-testable core of the `/subagent-sessions` command from its TUI
|
|
5
|
+
* wiring (`session-navigator.ts`): which subagents are navigable, how a picked
|
|
6
|
+
* agent's transcript is sourced (live, in this slice), and how the transcript
|
|
7
|
+
* renders to plain text via Pi's own `serializeConversation`.
|
|
8
|
+
*
|
|
9
|
+
* The `TranscriptSource` seam decouples *how messages are sourced* (live record
|
|
10
|
+
* here; a file snapshot in a follow-up) from *how they render* (text here; Pi's
|
|
11
|
+
* per-entry components in a follow-up). The renderer talks only to this seam.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
16
|
+
import type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
17
|
+
import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
|
|
18
|
+
import { describeActivity, formatDuration, getDisplayName } from "#src/ui/display";
|
|
19
|
+
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** The record fields the navigator reads to label and live-source a transcript. */
|
|
23
|
+
export interface NavigableSubagent {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly type: SubagentType;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
readonly status: SubagentStatus;
|
|
28
|
+
readonly startedAt: number;
|
|
29
|
+
readonly completedAt: number | undefined;
|
|
30
|
+
readonly toolUses: number;
|
|
31
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
32
|
+
readonly responseText: string;
|
|
33
|
+
readonly agentMessages: readonly SessionMessage[];
|
|
34
|
+
isSessionReady(): boolean;
|
|
35
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A navigable entry: a record plus the label shown in the picker. */
|
|
39
|
+
export interface NavigationEntry {
|
|
40
|
+
readonly record: NavigableSubagent;
|
|
41
|
+
readonly label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Running-agent streaming state, surfaced by a live source. */
|
|
45
|
+
export interface StreamingState {
|
|
46
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
47
|
+
readonly responseText: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Liveness-agnostic transcript source consumed by the renderer. */
|
|
51
|
+
export interface TranscriptSource {
|
|
52
|
+
/** Current message history. */
|
|
53
|
+
getMessages(): readonly SessionMessage[];
|
|
54
|
+
/** Subscribe to changes; returns an unsubscribe, or undefined for a static snapshot. */
|
|
55
|
+
subscribe(onChange: () => void): (() => void) | undefined;
|
|
56
|
+
/** Running-agent streaming state, or undefined when not streaming. */
|
|
57
|
+
streaming(): StreamingState | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Filter the agents to those with a viewable session and label each for the picker. */
|
|
61
|
+
export function listNavigableAgents(
|
|
62
|
+
agents: readonly NavigableSubagent[],
|
|
63
|
+
registry: AgentConfigLookup,
|
|
64
|
+
): NavigationEntry[] {
|
|
65
|
+
return agents
|
|
66
|
+
.filter((record) => record.isSessionReady())
|
|
67
|
+
.map((record) => ({ record, label: buildLabel(record, registry) }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Source a transcript live from an in-memory record (this slice's only source). */
|
|
71
|
+
export function liveSource(record: NavigableSubagent): TranscriptSource {
|
|
72
|
+
return {
|
|
73
|
+
getMessages: () => record.agentMessages,
|
|
74
|
+
subscribe: (onChange) => record.subscribeToUpdates(() => onChange()),
|
|
75
|
+
streaming: () =>
|
|
76
|
+
record.status === "running"
|
|
77
|
+
? { activeTools: record.activeTools, responseText: record.responseText }
|
|
78
|
+
: undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Render a source's transcript to plain text lines via Pi's `serializeConversation`. */
|
|
83
|
+
export function renderTranscriptLines(source: TranscriptSource): string[] {
|
|
84
|
+
const messages = source.getMessages();
|
|
85
|
+
const lines =
|
|
86
|
+
messages.length === 0 ? ["(no messages yet)"] : serializeConversation(toMessages(messages)).split("\n");
|
|
87
|
+
|
|
88
|
+
const streaming = source.streaming();
|
|
89
|
+
if (streaming) {
|
|
90
|
+
lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bridge the session's `AgentMessage[]` to `serializeConversation`'s `Message[]`.
|
|
97
|
+
*
|
|
98
|
+
* `AgentMessage` is a superset of `Message` (it adds session-display variants such
|
|
99
|
+
* as `BashExecutionMessage`); `serializeConversation` renders the shared shape and
|
|
100
|
+
* best-effort text for the rest. `Message` is not re-exported from the public
|
|
101
|
+
* `@earendil-works/pi-ai` barrel, so the parameter type is referenced via the
|
|
102
|
+
* function signature rather than imported by name.
|
|
103
|
+
*/
|
|
104
|
+
function toMessages(
|
|
105
|
+
messages: readonly SessionMessage[],
|
|
106
|
+
): Parameters<typeof serializeConversation>[0] {
|
|
107
|
+
return messages as unknown as Parameters<typeof serializeConversation>[0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildLabel(record: NavigableSubagent, registry: AgentConfigLookup): string {
|
|
111
|
+
const name = getDisplayName(record.type, registry);
|
|
112
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
113
|
+
return `${name} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${duration}`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-navigator.ts — The `/subagent-sessions` command: pick a subagent and
|
|
3
|
+
* read its transcript through Pi's own session-rendering text.
|
|
4
|
+
*
|
|
5
|
+
* SDK/TUI consumer half of native session navigation. The unit-testable core
|
|
6
|
+
* (selection, sourcing, text rendering) lives in `session-navigation.ts`; this
|
|
7
|
+
* module wires that core to the command picker and a read-only scrollable overlay.
|
|
8
|
+
*
|
|
9
|
+
* The overlay is strictly read-only — steering stays in the `steer_subagent` tool
|
|
10
|
+
* and the widget. It consumes a `TranscriptSource`, so the renderer-upgrade and
|
|
11
|
+
* evicted-agent-source follow-ups swap the source/renderer without touching it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
16
|
+
import type { Theme } from "#src/ui/display";
|
|
17
|
+
import {
|
|
18
|
+
listNavigableAgents,
|
|
19
|
+
liveSource,
|
|
20
|
+
type NavigableSubagent,
|
|
21
|
+
renderTranscriptLines,
|
|
22
|
+
type TranscriptSource,
|
|
23
|
+
} from "#src/ui/session-navigation";
|
|
24
|
+
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Chrome lines: top border + header + header sep + footer sep + footer + bottom border. */
|
|
28
|
+
const CHROME_LINES = 6;
|
|
29
|
+
const MIN_VIEWPORT = 3;
|
|
30
|
+
const VIEWPORT_HEIGHT_PCT = 70;
|
|
31
|
+
|
|
32
|
+
/** Component factory shape Pi's `ui.custom` invokes to mount an overlay. */
|
|
33
|
+
export type OverlayComponentFactory<R> = (
|
|
34
|
+
tui: TUI,
|
|
35
|
+
theme: Theme,
|
|
36
|
+
keybindings: unknown,
|
|
37
|
+
done: (result: R) => void,
|
|
38
|
+
) => Component;
|
|
39
|
+
|
|
40
|
+
/** Narrow UI interface — only the `ctx.ui` methods the navigator calls. */
|
|
41
|
+
export interface SessionNavigatorUI {
|
|
42
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
43
|
+
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
44
|
+
custom<R>(component: OverlayComponentFactory<R>, options?: unknown): Promise<R>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parameters for one `/subagent-sessions` invocation. */
|
|
48
|
+
export interface SessionNavigatorParams {
|
|
49
|
+
ui: SessionNavigatorUI;
|
|
50
|
+
agents: readonly NavigableSubagent[];
|
|
51
|
+
registry: AgentConfigLookup;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Options for the read-only transcript overlay. */
|
|
55
|
+
export interface TranscriptOverlayOptions {
|
|
56
|
+
tui: TUI;
|
|
57
|
+
theme: Theme;
|
|
58
|
+
source: TranscriptSource;
|
|
59
|
+
done: (result: undefined) => void;
|
|
60
|
+
wrapText: (text: string, width: number) => string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handler for the `/subagent-sessions` slash command.
|
|
65
|
+
*
|
|
66
|
+
* Lists navigable subagents, lets the operator pick one, and opens its transcript
|
|
67
|
+
* read-only. Receives the agent snapshot (`manager.listAgents()`) rather than the
|
|
68
|
+
* manager, so it stays a reactive consumer with no inbound call into the core.
|
|
69
|
+
*/
|
|
70
|
+
export class SessionNavigatorHandler {
|
|
71
|
+
async handle({ ui, agents, registry }: SessionNavigatorParams): Promise<void> {
|
|
72
|
+
const entries = listNavigableAgents(agents, registry);
|
|
73
|
+
if (entries.length === 0) {
|
|
74
|
+
ui.notify("No subagent sessions to view.", "info");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const choice = await ui.select(
|
|
79
|
+
"Subagent sessions",
|
|
80
|
+
entries.map((entry) => entry.label),
|
|
81
|
+
);
|
|
82
|
+
const entry = entries.find((candidate) => candidate.label === choice);
|
|
83
|
+
if (!entry) return;
|
|
84
|
+
|
|
85
|
+
const source = liveSource(entry.record);
|
|
86
|
+
await ui.custom<undefined>(
|
|
87
|
+
(tui, theme, _keybindings, done) =>
|
|
88
|
+
new TranscriptOverlay({ tui, theme, source, done, wrapText: wrapTextWithAnsi }),
|
|
89
|
+
{
|
|
90
|
+
overlay: true,
|
|
91
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read-only scrollable transcript overlay.
|
|
99
|
+
*
|
|
100
|
+
* Re-renders on every source update (live agents); the transcript text is Pi's
|
|
101
|
+
* `serializeConversation` output from `renderTranscriptLines` — this class owns
|
|
102
|
+
* only scroll state and chrome, no message formatting.
|
|
103
|
+
*/
|
|
104
|
+
export class TranscriptOverlay implements Component {
|
|
105
|
+
private scrollOffset = 0;
|
|
106
|
+
private autoScroll = true;
|
|
107
|
+
private unsubscribe: (() => void) | undefined;
|
|
108
|
+
private closed = false;
|
|
109
|
+
|
|
110
|
+
private readonly tui: TUI;
|
|
111
|
+
private readonly theme: Theme;
|
|
112
|
+
private readonly source: TranscriptSource;
|
|
113
|
+
private readonly done: (result: undefined) => void;
|
|
114
|
+
private readonly wrapText: (text: string, width: number) => string[];
|
|
115
|
+
|
|
116
|
+
constructor({ tui, theme, source, done, wrapText }: TranscriptOverlayOptions) {
|
|
117
|
+
this.tui = tui;
|
|
118
|
+
this.theme = theme;
|
|
119
|
+
this.source = source;
|
|
120
|
+
this.done = done;
|
|
121
|
+
this.wrapText = wrapText;
|
|
122
|
+
this.unsubscribe = source.subscribe(() => {
|
|
123
|
+
if (this.closed) return;
|
|
124
|
+
this.tui.requestRender();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// fallow-ignore-next-line unused-class-member
|
|
129
|
+
handleInput(data: string): void {
|
|
130
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
131
|
+
this.closed = true;
|
|
132
|
+
this.done(undefined);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const totalLines = this.buildContentLines(this.innerWidth()).length;
|
|
137
|
+
const viewportHeight = this.viewportHeight();
|
|
138
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
139
|
+
|
|
140
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
141
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
142
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
143
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
144
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
145
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
146
|
+
} else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
147
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
148
|
+
this.autoScroll = false;
|
|
149
|
+
} else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
150
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
151
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
152
|
+
} else if (matchesKey(data, "home")) {
|
|
153
|
+
this.scrollOffset = 0;
|
|
154
|
+
this.autoScroll = false;
|
|
155
|
+
} else if (matchesKey(data, "end")) {
|
|
156
|
+
this.scrollOffset = maxScroll;
|
|
157
|
+
this.autoScroll = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
render(width: number): string[] {
|
|
162
|
+
if (width < 6) return [];
|
|
163
|
+
const th = this.theme;
|
|
164
|
+
const innerW = width - 4;
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
|
|
167
|
+
const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
168
|
+
const row = (content: string): string =>
|
|
169
|
+
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
|
|
170
|
+
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
171
|
+
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
172
|
+
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
173
|
+
|
|
174
|
+
lines.push(hrTop);
|
|
175
|
+
lines.push(row(th.bold("Subagent session")));
|
|
176
|
+
lines.push(hrMid);
|
|
177
|
+
|
|
178
|
+
const contentLines = this.buildContentLines(innerW);
|
|
179
|
+
const viewportHeight = this.viewportHeight();
|
|
180
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
181
|
+
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
182
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
183
|
+
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
|
|
184
|
+
for (let i = 0; i < viewportHeight; i++) lines.push(row(visible[i] ?? ""));
|
|
185
|
+
|
|
186
|
+
lines.push(hrMid);
|
|
187
|
+
const scrollPct =
|
|
188
|
+
contentLines.length <= viewportHeight
|
|
189
|
+
? "100%"
|
|
190
|
+
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
191
|
+
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
192
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
|
|
193
|
+
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
194
|
+
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
195
|
+
lines.push(hrBot);
|
|
196
|
+
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// fallow-ignore-next-line unused-class-member
|
|
201
|
+
invalidate(): void {
|
|
202
|
+
/* no cached state to clear */
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// fallow-ignore-next-line unused-class-member
|
|
206
|
+
dispose(): void {
|
|
207
|
+
this.closed = true;
|
|
208
|
+
if (this.unsubscribe) {
|
|
209
|
+
this.unsubscribe();
|
|
210
|
+
this.unsubscribe = undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- Private ----
|
|
215
|
+
|
|
216
|
+
private innerWidth(): number {
|
|
217
|
+
return Math.max(0, this.tui.terminal.columns - 4);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private viewportHeight(): number {
|
|
221
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
222
|
+
return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private buildContentLines(innerW: number): string[] {
|
|
226
|
+
if (innerW <= 0) return [];
|
|
227
|
+
const wrapped: string[] = [];
|
|
228
|
+
for (const line of renderTranscriptLines(this.source)) {
|
|
229
|
+
if (line === "") {
|
|
230
|
+
wrapped.push("");
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
wrapped.push(...this.wrapText(line, innerW));
|
|
234
|
+
}
|
|
235
|
+
return wrapped.map((l) => truncateToWidth(l, innerW));
|
|
236
|
+
}
|
|
237
|
+
}
|