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