@gotgenes/pi-subagents 6.6.0 → 6.7.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 CHANGED
@@ -5,6 +5,20 @@ 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
+ ## [6.7.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.6.0...pi-subagents-v6.7.0) (2026-05-21)
9
+
10
+
11
+ ### Features
12
+
13
+ * add AgentActivityTracker class ([#110](https://github.com/gotgenes/pi-packages/issues/110)) ([151308a](https://github.com/gotgenes/pi-packages/commit/151308ad3da569d72e40495bece502281dc302a8))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan AgentActivityTracker class ([#110](https://github.com/gotgenes/pi-packages/issues/110)) ([8f5e56b](https://github.com/gotgenes/pi-packages/commit/8f5e56ba0a29730e060bac4658bb4953ce36d4a9))
19
+ * **retro:** add retro notes for issue [#118](https://github.com/gotgenes/pi-packages/issues/118) ([1959e52](https://github.com/gotgenes/pi-packages/commit/1959e52d8b150bbc90da72edd93dc6f2ec315e22))
20
+ * update architecture doc — mark A3 done, fix Map type ([#110](https://github.com/gotgenes/pi-packages/issues/110)) ([463e997](https://github.com/gotgenes/pi-packages/commit/463e9974db21df17f4b2578825f3c67bc1e90b29))
21
+
8
22
  ## [6.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.5.0...pi-subagents-v6.6.0) (2026-05-21)
9
23
 
10
24
 
@@ -82,7 +82,7 @@ Record statistics (tool uses, token usage, compaction counts) are updated by `re
82
82
  UI streaming (active tools, response text, turn counts) is handled by `ui/ui-observer.ts`, which subscribes to the same session events independently.
83
83
  Neither observer wraps or forwards the other — both subscribe directly to the session.
84
84
 
85
- The widget reads agent state by polling a shared `Map<string, AgentActivity>` on `SubagentRuntime` every 80 ms. The conversation viewer subscribes directly to `AgentSession` objects.
85
+ The widget reads agent state by polling a shared `Map<string, AgentActivityTracker>` on `SubagentRuntime` every 80 ms. The conversation viewer subscribes directly to `AgentSession` objects.
86
86
 
87
87
  Cross-extension consumers use the typed `SubagentsService` API published via `Symbol.for("@gotgenes/pi-subagents:service")` on `globalThis`.
88
88
 
@@ -381,7 +381,7 @@ Each step is sequenced so it makes the next step easier.
381
381
  | -------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
382
382
  | ~~Global mutable state~~ | ~~`agent-types.ts`~~ | **Fixed #108**: `AgentTypeRegistry` class; `reloadCustomAgents` callback removed from `AgentToolDeps` and `AgentMenuDeps` |
383
383
  | Closure bag as class | `createNotificationSystem()` | Returns 4 functions sharing closure state (`pendingNudges`, timers) |
384
- | Mutable state bag | `AgentActivity` (7 fields) | Written by `ui-observer.ts`, read by widget, notification, agent-tool |
384
+ | ~~Mutable state bag~~ | ~~`AgentActivity` (7 fields)~~ | **Fixed #110**: `AgentActivityTracker` class; `ui-observer.ts` calls transition methods; widget, notification, agent-tool use read-only accessors |
385
385
  | ~~Settings relay~~ | ~~`AgentMenuDeps` (13 fields)~~ | **Fixed #109**: `SettingsManager` class; 6 callback fields collapsed to `settings: SettingsManager`; `AgentMenuDeps` now 8 fields |
386
386
  | Post-construction mutation | `AgentRecord` non-transition state | `session`, `outputFile`, `worktree`, `promise` written by external code after construction |
387
387
  | Fire-and-forget callbacks | `AgentManagerOptions` | `onStart`, `onComplete`, `onCompact` wired as closures in `index.ts` |
@@ -422,11 +422,12 @@ Each owns the full consequence chain: normalize → set in memory → notify cal
422
422
 
423
423
  Impact: eliminates LoD / Tell-Don't-Ask violation in `showSettings`; menu no longer coordinates between settings and manager.
424
424
 
425
- #### A3. `AgentActivityTracker` class (#110)
425
+ #### ~~A3. `AgentActivityTracker` class (#110)~~ — **Done**
426
426
 
427
- Wrap the 7-field mutable `AgentActivity` interface with transition methods (`onToolStart()`, `onToolEnd()`, `onMessageUpdate()`, `onTurnEnd()`).
428
- `ui-observer.ts` calls tracker methods instead of writing raw fields.
429
- The notification system, widget, and agent-tool receive a proper collaborator instead of reaching into a shared `Map<string, AgentActivity>`.
427
+ Wrapped the 7-field mutable `AgentActivity` interface in an `AgentActivityTracker` class (`src/ui/agent-activity-tracker.ts`).
428
+ `ui-observer.ts` calls transition methods (`onToolStart`, `onToolEnd`, `onMessageStart`, `onMessageUpdate`, `onTurnEnd`, `onUsageUpdate`, `setSession`).
429
+ The notification system, widget, conversation viewer, and agent-tool use read-only accessors.
430
+ The shared map on `SubagentRuntime` is now `Map<string, AgentActivityTracker>`.
430
431
 
431
432
  Impact: eliminates output-argument writes in `ui-observer.ts`, makes the mutation contract explicit.
432
433
 
@@ -489,7 +490,7 @@ The 654-line file splits along a natural seam.
489
490
  | ------------------------------------------ | ---------------------------------------------------------------------------- | ------- |
490
491
  | Module-scoped mutable state | ~~1 (`agent-types.ts` Map)~~ | **0** ✓ |
491
492
  | Closure-bag "classes" | ~~2~~ 1 (`createNotificationSystem`; settings free functions **fixed #109**) | 0 |
492
- | Externally-mutated state bags | 2 (`AgentActivity`, `AgentRecord` non-transition fields) | 0 |
493
+ | Externally-mutated state bags | ~~2~~ 1 (`AgentRecord` non-transition fields; `AgentActivity` **fixed #110**)| 0 |
493
494
  | `AgentManagerOptions` fields | 8 | 5 |
494
495
  | `AgentToolDeps` fields | ~~9~~ **7** (−6 registry #108, −1 settings #109 → +1 settings obj) | ~5 |
495
496
  | `AgentMenuDeps` fields | ~~13~~ **8** (−6 settings #109 collapsed to 1; −1 registry #108) | ~6 ✓ |
@@ -0,0 +1,297 @@
1
+ ---
2
+ issue: 110
3
+ issue_title: "refactor(pi-subagents): wrap AgentActivity in AgentActivityTracker class"
4
+ ---
5
+
6
+ # Wrap AgentActivity in AgentActivityTracker class
7
+
8
+ ## Problem Statement
9
+
10
+ `AgentActivity` is a 7-field mutable interface (`activeTools`, `toolUses`, `responseText`, `session`, `turnCount`, `maxTurns`, `lifetimeUsage`) shared across 4 modules.
11
+ `ui-observer.ts` writes raw fields on it (output arguments), the widget and conversation viewer read them, and the agent-tool creates empty instances and stuffs them into a shared `Map`.
12
+ The mutation contract is implicit — callers know which fields to set by convention, not by API.
13
+
14
+ This is Phase 7, Step A3 in the architecture doc.
15
+
16
+ ## Goals
17
+
18
+ - Wrap `AgentActivity` in an `AgentActivityTracker` class with explicit transition methods.
19
+ - Replace the output-argument writes in `ui-observer.ts` with tracker method calls.
20
+ - Expose read-only accessors for the state the widget, notification system, conversation viewer, and agent-tool need.
21
+ - Change the shared `Map<string, AgentActivity>` on `SubagentRuntime` to `Map<string, AgentActivityTracker>`.
22
+ - Preserve all existing behavior — this is a pure encapsulation refactor.
23
+
24
+ ## Non-Goals
25
+
26
+ - Splitting `AgentRecord` lifecycle state (#111) — deferred to Step B.
27
+ - Replacing `AgentManager` callbacks with an observer (#112) — deferred to Step C.
28
+ - Narrowing `AgentToolDeps` or `AgentMenuDeps` further (#114) — deferred to Step D2.
29
+ - Changing `createNotificationSystem` from closure to class (#116) — deferred to Step E2.
30
+
31
+ ## Background
32
+
33
+ ### Who writes AgentActivity today
34
+
35
+ `ui-observer.ts` (`subscribeUIObserver`) is the sole writer.
36
+ It subscribes to session events and mutates the state object directly:
37
+
38
+ - `state.activeTools.set(...)` / `state.activeTools.delete(...)` on tool start/end
39
+ - `state.toolUses++` on tool end
40
+ - `state.responseText = ""` on message start, `state.responseText += delta` on message update
41
+ - `state.turnCount++` on turn end
42
+ - `addUsage(state.lifetimeUsage, ...)` on message end with assistant usage
43
+
44
+ The agent-tool also writes `session` after session creation (`fgState.session = session`, `bgState.session = session`).
45
+
46
+ ### Who reads AgentActivity today
47
+
48
+ | Consumer | Fields read |
49
+ | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- |
50
+ | `agent-widget.ts` (widget render) | `activeTools`, `responseText`, `toolUses`, `turnCount`, `maxTurns`, `lifetimeUsage`, `session` |
51
+ | `conversation-viewer.ts` | `toolUses`, `lifetimeUsage`, `session`, `activeTools`, `responseText` |
52
+ | `notification.ts` (`buildNotificationDetails`) | `turnCount`, `maxTurns` |
53
+ | `agent-tool.ts` (foreground streaming) | `toolUses`, `turnCount`, `maxTurns`, `activeTools`, `responseText`, `lifetimeUsage` |
54
+ | `agent-menu.ts` (conversation viewer launch) | passes to `ConversationViewer` |
55
+
56
+ ### Who creates AgentActivity today
57
+
58
+ `createAgentActivity()` in `agent-tool.ts` — a factory function that returns a plain object with defaults.
59
+
60
+ ### AGENTS.md constraints
61
+
62
+ - One concern per file: the tracker gets its own module.
63
+ - Avoid `any`: use typed accessors.
64
+ - Output arguments: this refactor eliminates them from `ui-observer.ts`.
65
+ - Keep modules focused: the tracker owns its mutable state; consumers use read-only accessors.
66
+
67
+ ## Design Overview
68
+
69
+ ### AgentActivityTracker class
70
+
71
+ New file: `src/ui/agent-activity-tracker.ts`.
72
+
73
+ The class owns the 7 mutable fields and exposes:
74
+
75
+ 1. Transition methods (the write surface — called by `ui-observer.ts` and agent-tool):
76
+
77
+ ```typescript
78
+ onToolStart(toolName: string): void // adds to activeTools map
79
+ onToolEnd(toolName: string): void // removes from activeTools, increments toolUses
80
+ onMessageStart(): void // resets responseText
81
+ onMessageUpdate(delta: string): void // appends to responseText
82
+ onTurnEnd(): void // increments turnCount
83
+ onUsageUpdate(usage: UsageDelta): void // accumulates into lifetimeUsage
84
+ setSession(session: SessionLike): void // one-time session binding
85
+ ```
86
+
87
+ 2. Read-only accessors (the read surface — used by widget, notification, conversation viewer, agent-tool streaming):
88
+
89
+ ```typescript
90
+ get activeTools(): ReadonlyMap<string, string>
91
+ get toolUses(): number
92
+ get responseText(): string
93
+ get session(): SessionLike | undefined
94
+ get turnCount(): number
95
+ get maxTurns(): number | undefined
96
+ get lifetimeUsage(): Readonly<LifetimeUsage>
97
+ ```
98
+
99
+ The constructor accepts `maxTurns?: number` (set at creation time, immutable).
100
+
101
+ ### UsageDelta type
102
+
103
+ A narrow type for the usage values passed to `onUsageUpdate`:
104
+
105
+ ```typescript
106
+ interface UsageDelta { input: number; output: number; cacheWrite: number }
107
+ ```
108
+
109
+ This matches the shape `addUsage` already expects and avoids coupling to the full `LifetimeUsage` type name for what is logically a delta.
110
+
111
+ ### activeTools key strategy
112
+
113
+ The current `activeTools` Map uses `toolName + "_" + Date.now()` as a key to allow multiple concurrent tools with the same name.
114
+ `onToolStart` returns `void` and generates the key internally (same `Date.now()` strategy).
115
+ `onToolEnd(toolName)` finds and removes the first matching entry (same logic as today).
116
+
117
+ ### subscribeUIObserver changes
118
+
119
+ `subscribeUIObserver` changes its second parameter from `state: AgentActivity` to `tracker: AgentActivityTracker`.
120
+ Instead of writing fields, it calls tracker methods:
121
+
122
+ ```typescript
123
+ // Before:
124
+ state.activeTools.set(event.toolName + "_" + Date.now(), event.toolName);
125
+ // After:
126
+ tracker.onToolStart(event.toolName);
127
+ ```
128
+
129
+ ### Consumer interface
130
+
131
+ Readers continue to access the same properties but through getters.
132
+ Since the tracker exposes matching property names, consumer code like `bg.turnCount` and `bg.toolUses` remains syntactically identical — only the type annotation changes from `AgentActivity` to `AgentActivityTracker`.
133
+
134
+ The `AgentActivity` interface is removed entirely.
135
+ All references migrate to `AgentActivityTracker`.
136
+
137
+ ### Map type change
138
+
139
+ `SubagentRuntime.agentActivity` changes from `Map<string, AgentActivity>` to `Map<string, AgentActivityTracker>`.
140
+ All dependency bags that pass this map (`AgentToolDeps`, `AgentMenuDeps`, `NotificationDeps`, `AgentWidget` constructor) update their type annotations.
141
+
142
+ ### createAgentActivity replacement
143
+
144
+ The factory function in `agent-tool.ts` is replaced by `new AgentActivityTracker(maxTurns)`.
145
+
146
+ ## Module-Level Changes
147
+
148
+ ### New file
149
+
150
+ | File | What |
151
+ | ---------------------------------- | ---------------------------------------------------------------------------- |
152
+ | `src/ui/agent-activity-tracker.ts` | `AgentActivityTracker` class with transition methods and read-only accessors |
153
+
154
+ ### New test file
155
+
156
+ | File | What |
157
+ | ---------------------------------------- | ------------------------------------------------------------- |
158
+ | `test/ui/agent-activity-tracker.test.ts` | Unit tests for all transition methods and read-only accessors |
159
+
160
+ ### Modified files
161
+
162
+ | File | What changes |
163
+ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164
+ | `src/ui/ui-observer.ts` | Accept `AgentActivityTracker` instead of `AgentActivity`; call transition methods instead of writing fields |
165
+ | `src/ui/agent-widget.ts` | Remove `AgentActivity` interface; import `AgentActivityTracker`; update `Map` type and read sites (property names stay the same) |
166
+ | `src/ui/conversation-viewer.ts` | Import `AgentActivityTracker` instead of `AgentActivity`; update parameter type |
167
+ | `src/ui/agent-menu.ts` | Import `AgentActivityTracker` instead of `AgentActivity`; update `Map` type |
168
+ | `src/tools/agent-tool.ts` | Import `AgentActivityTracker`; replace `createAgentActivity()` with `new AgentActivityTracker()`; replace `fgState.session = session` with `fgState.setSession(session)`; update `Map` type in `AgentToolDeps` |
169
+ | `src/notification.ts` | Import `AgentActivityTracker` instead of `AgentActivity`; update `buildNotificationDetails` parameter and `NotificationDeps.agentActivity` Map type |
170
+ | `src/runtime.ts` | Import `AgentActivityTracker`; change `agentActivity` Map type; update `AgentWidget` import (type only change since `AgentActivity` moves) |
171
+ | `src/index.ts` | No changes (already references `runtime.agentActivity` by reference, the type flows through) |
172
+
173
+ ### Modified test files
174
+
175
+ | File | What changes |
176
+ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | `test/ui/ui-observer.test.ts` | Replace `makeActivity()` factory with `new AgentActivityTracker()`; access state through getters; assertions on accessor values instead of raw field reads |
178
+ | `test/notification.test.ts` | Replace `as AgentActivity` casts with `new AgentActivityTracker()`; set up tracker state via transition methods |
179
+ | `test/tools/agent-tool.test.ts` | Update `Map<string, AgentActivity>` type to `Map<string, AgentActivityTracker>` |
180
+
181
+ ### Removed exports
182
+
183
+ | Symbol | Was in | Replaced by |
184
+ | ---------------------------------- | ------------------------- | ------------------------------------------------------------------ |
185
+ | `AgentActivity` (interface) | `src/ui/agent-widget.ts` | `AgentActivityTracker` class in `src/ui/agent-activity-tracker.ts` |
186
+ | `createAgentActivity()` (function) | `src/tools/agent-tool.ts` | `new AgentActivityTracker(maxTurns)` |
187
+
188
+ Grep verification: `AgentActivity` is referenced in 7 source files and 3 test files (listed above) — all accounted for in the modified files list.
189
+
190
+ ## Test Impact Analysis
191
+
192
+ ### New unit tests enabled
193
+
194
+ The `AgentActivityTracker` class enables focused unit tests for each transition method in isolation:
195
+
196
+ - `onToolStart` / `onToolEnd` — concurrent tool tracking, correct toolUses increment
197
+ - `onMessageStart` / `onMessageUpdate` — response text lifecycle
198
+ - `onTurnEnd` — turn counting from initial value
199
+ - `onUsageUpdate` — accumulation semantics
200
+ - `setSession` — one-time binding
201
+ - Read-only accessors — verify consumers cannot mutate internal state
202
+
203
+ These were previously impossible to test without going through `subscribeUIObserver` and a mock session.
204
+
205
+ ### Existing tests that simplify
206
+
207
+ `test/ui/ui-observer.test.ts` — currently constructs a plain `AgentActivity` object and asserts on raw field mutations.
208
+ After the change, it constructs an `AgentActivityTracker` and the assertions read the same properties through accessors.
209
+ The `makeActivity()` helper is replaced by `new AgentActivityTracker()`.
210
+ The test logic stays the same (event → tracker state check) but the assertions use getter-backed properties.
211
+
212
+ ### Existing tests that stay as-is
213
+
214
+ `test/notification.test.ts` — the pure helper tests (`escapeXml`, `getStatusLabel`, `formatTaskNotification`, `buildNotificationDetails`) only need their `AgentActivity` type references updated to `AgentActivityTracker`.
215
+ The notification system integration tests that cast `{} as AgentActivity` need minimal updates to construct a real tracker instead.
216
+
217
+ `test/tools/agent-tool.test.ts` — only the `Map` type annotation changes.
218
+
219
+ ## TDD Order
220
+
221
+ ### 1. Red/green: AgentActivityTracker class — transition methods and read-only accessors
222
+
223
+ Test file: `test/ui/agent-activity-tracker.test.ts`
224
+
225
+ Tests:
226
+
227
+ - Constructor sets initial state (`turnCount: 1`, empty `activeTools`, `toolUses: 0`, empty `responseText`, zero `lifetimeUsage`, `maxTurns` from constructor arg, `session` undefined)
228
+ - `onToolStart` adds entry to `activeTools`
229
+ - `onToolEnd` removes entry and increments `toolUses`
230
+ - `onToolEnd` with no matching tool is a no-op (defensive)
231
+ - Multiple concurrent tools with same name tracked independently
232
+ - `onMessageStart` resets `responseText` to empty
233
+ - `onMessageUpdate` appends delta to `responseText`
234
+ - `onTurnEnd` increments `turnCount`
235
+ - `onUsageUpdate` accumulates into `lifetimeUsage`
236
+ - `setSession` stores the session reference
237
+ - Read-only: `activeTools` returns `ReadonlyMap`
238
+ - Read-only: `lifetimeUsage` returns `Readonly<LifetimeUsage>`
239
+
240
+ Commit: `feat: add AgentActivityTracker class (#110)`
241
+
242
+ ### 2. Red/green: migrate ui-observer to use AgentActivityTracker
243
+
244
+ Update `src/ui/ui-observer.ts` to accept `AgentActivityTracker` and call transition methods.
245
+ Update `test/ui/ui-observer.test.ts`: replace `makeActivity()` with `new AgentActivityTracker()`, read state through accessors.
246
+
247
+ All existing test scenarios must pass unchanged (same events → same observable state).
248
+
249
+ Commit: `refactor: migrate ui-observer to AgentActivityTracker (#110)`
250
+
251
+ ### 3. Migrate agent-widget and conversation-viewer
252
+
253
+ Update `src/ui/agent-widget.ts`: remove `AgentActivity` interface, import `AgentActivityTracker`, update `Map` type and constructor parameter.
254
+ Update `src/ui/conversation-viewer.ts`: import `AgentActivityTracker`, update parameter type.
255
+
256
+ No test changes needed — widget and conversation viewer are not unit-tested (they render UI).
257
+
258
+ Commit: `refactor: migrate widget and conversation-viewer to AgentActivityTracker (#110)`
259
+
260
+ ### 4. Migrate agent-tool
261
+
262
+ Update `src/tools/agent-tool.ts`: import `AgentActivityTracker`, replace `createAgentActivity()` with `new AgentActivityTracker()`, replace `fgState.session = session` with `fgState.setSession(session)`, update `AgentToolDeps` Map type.
263
+ Update `test/tools/agent-tool.test.ts`: update Map type.
264
+
265
+ Commit: `refactor: migrate agent-tool to AgentActivityTracker (#110)`
266
+
267
+ ### 5. Migrate notification and agent-menu
268
+
269
+ Update `src/notification.ts`: import `AgentActivityTracker`, update `buildNotificationDetails` parameter and `NotificationDeps` Map type.
270
+ Update `src/ui/agent-menu.ts`: import `AgentActivityTracker`, update Map type in `AgentMenuDeps`.
271
+ Update `test/notification.test.ts`: replace `as AgentActivity` casts with real `AgentActivityTracker` instances.
272
+
273
+ Commit: `refactor: migrate notification and agent-menu to AgentActivityTracker (#110)`
274
+
275
+ ### 6. Migrate runtime and clean up
276
+
277
+ Update `src/runtime.ts`: import `AgentActivityTracker`, change `agentActivity` Map type.
278
+ Remove any remaining `AgentActivity` references.
279
+ Run `pnpm run check` to verify no type errors remain.
280
+
281
+ Commit: `refactor: complete AgentActivityTracker migration, remove AgentActivity interface (#110)`
282
+
283
+ ## Risks and Mitigations
284
+
285
+ | Risk | Mitigation |
286
+ | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
287
+ | Read-only getters change identity semantics for `activeTools` and `lifetimeUsage` | The `ReadonlyMap` and `Readonly<LifetimeUsage>` types restrict writes at compile time. Consumers already only read these values, so no runtime change. Return the internal mutable reference cast to readonly (no copy overhead). |
288
+ | Spreading a class instance loses methods | Grep for `{ ...activity` or `{ ...bg` patterns — none found in current code. The `buildDetails` function spreads `detailBase` (a plain object), not the activity. |
289
+ | `onToolEnd` matching logic fragility | Port the exact same `for...of` + `break` pattern from `ui-observer.ts`. New unit tests cover this independently. |
290
+ | `turnCount` initial value of 1 (not 0) | The current `createAgentActivity` sets `turnCount: 1`. The tracker constructor preserves this. A dedicated test asserts the initial value. |
291
+ | Test files casting `{} as AgentActivity` | Replace with real `AgentActivityTracker` instances. Where tests only need `turnCount` and `maxTurns` (e.g., `buildNotificationDetails`), construct a tracker and call `onTurnEnd` to set up the desired state. |
292
+
293
+ ## Open Questions
294
+
295
+ - None — the issue and architecture doc are prescriptive about the approach.
296
+ The only design latitude is whether `onToolStart` returns a key (for symmetric `onToolEnd(key)`) or whether `onToolEnd(toolName)` does a lookup.
297
+ The plan uses `onToolEnd(toolName)` with lookup to match the existing pattern and avoid threading a key through the session event handler.
@@ -0,0 +1,40 @@
1
+ ---
2
+ issue: 118
3
+ issue_title: "refactor(pi-subagents): SettingsManager apply methods — eliminate cross-collaborator orchestration"
4
+ ---
5
+
6
+ # Retro: #118 — SettingsManager apply methods
7
+
8
+ ## Final Retrospective (2026-05-21T21:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented 3 `apply*` methods on `SettingsManager` (`applyMaxConcurrent`, `applyDefaultMaxTurns`, `applyGraceTurns`) across 5 TDD cycles plus doc updates, released as `pi-subagents-v6.6.0`.
13
+ Each method owns the full consequence chain (normalize → set → callback → persist → emit → return toast), eliminating the LoD/Tell-Don't-Ask violation in `showSettings` that was identified during the #109 retro.
14
+ `notifyConcurrencyChanged` was removed from `AgentMenuManager`; the menu no longer coordinates between settings and the agent manager.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - **Retro-driven improvement validated.**
21
+ Issue #118 was filed during the #109 retro as a LoD/Tell-Don't-Ask follow-up, and the plan-issue prompt's consumer call-site sketch heuristic (added in #109's retro) was already in the plan template.
22
+ The plan for #118 included concrete before/after call-site sketches that made the design unambiguous — no `ask-user` decision needed.
23
+ - **Interface-then-wiring TDD order worked cleanly.**
24
+ The #109 retro noted that interface changes propagate to `index.ts` immediately, forcing unplanned bridge edits.
25
+ This time the plan accounted for it: Cycle 4 committed only menu files (leaving a known `index.ts` type error), and Cycle 5 fixed the wiring in a separate commit.
26
+ The intermediate type error was contained and expected.
27
+ - **`defaultMaxTurns` branch consolidation.**
28
+ During Cycle 4, the separate `n === 0` and `n >= 1` branches in `showSettings` were consolidated to a single `n >= 0` check, since `applyDefaultMaxTurns` handles the 0→unlimited mapping internally.
29
+ This was a minor but correct simplification that emerged naturally from the Tell-Don't-Ask refactor.
30
+
31
+ #### What caused friction (agent side)
32
+
33
+ - No material friction.
34
+ All 5 TDD cycles completed without rework, failed edits, or unexpected test failures.
35
+ The plan was tight and the issue's "Proposed change" section was unambiguous.
36
+
37
+ #### What caused friction (user side)
38
+
39
+ - No material friction observed.
40
+ The session ran end-to-end (plan → implement → ship → release) without user intervention.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.6.0",
3
+ "version": "6.7.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  import { debugLog } from "./debug.js";
2
2
  import type { AgentRecord, NotificationDetails } from "./types.js";
3
- import type { AgentActivity } from "./ui/agent-widget.js";
3
+ import type { AgentActivityTracker } from "./ui/agent-activity-tracker.js";
4
4
  import { getLifetimeTotal, getSessionContextPercent } from "./usage.js";
5
5
 
6
6
  // ---- Pure helpers (exported for unit testing) ----
@@ -60,7 +60,7 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
60
60
  export function buildNotificationDetails(
61
61
  record: AgentRecord,
62
62
  resultMaxLen: number,
63
- activity?: AgentActivity,
63
+ activity?: AgentActivityTracker,
64
64
  ): NotificationDetails {
65
65
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
66
66
 
@@ -113,7 +113,7 @@ export interface NotificationDeps {
113
113
  msg: { customType: string; content: string; display: boolean; details?: unknown },
114
114
  opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
115
115
  ) => void;
116
- agentActivity: Map<string, AgentActivity>;
116
+ agentActivity: Map<string, AgentActivityTracker>;
117
117
  markFinished: (id: string) => void;
118
118
  updateWidget: () => void;
119
119
  }
package/src/runtime.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * Follows the same pattern as pi-permission-system's ExtensionRuntime.
7
7
  */
8
8
 
9
- import type { AgentActivity, AgentWidget, UICtx } from "./ui/agent-widget.js";
9
+ import type { AgentActivityTracker } from "./ui/agent-activity-tracker.js";
10
+ import type { AgentWidget, UICtx } from "./ui/agent-widget.js";
10
11
 
11
12
  /**
12
13
  * Narrow config subset read by AgentManager when constructing RunOptions.
@@ -31,7 +32,7 @@ export class SubagentRuntime {
31
32
  * Per-agent live activity state shared across the notification system,
32
33
  * widget, and tool handlers. The Map itself is never replaced.
33
34
  */
34
- readonly agentActivity: Map<string, AgentActivity> = new Map();
35
+ readonly agentActivity: Map<string, AgentActivityTracker> = new Map();
35
36
  /**
36
37
  * Persistent widget reference. Null until constructed after AgentManager.
37
38
  * Delegation methods use optional chaining so callers never need `widget!`.
@@ -8,8 +8,8 @@ import { resolveAgentInvocationConfig } from "../invocation-config.js";
8
8
  import { resolveInvocationModel } from "../model-resolver.js";
9
9
 
10
10
  import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
11
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
11
12
  import {
12
- type AgentActivity,
13
13
  type AgentDetails,
14
14
  buildInvocationTags,
15
15
  describeActivity,
@@ -26,19 +26,6 @@ import { formatLifetimeTokens, textResult } from "./helpers.js";
26
26
 
27
27
  // ---- Agent-tool-specific helpers ----
28
28
 
29
- /** Create a fresh AgentActivity state for tracking UI progress. */
30
- function createAgentActivity(maxTurns?: number): AgentActivity {
31
- return {
32
- activeTools: new Map(),
33
- toolUses: 0,
34
- turnCount: 1,
35
- maxTurns,
36
- responseText: "",
37
- session: undefined,
38
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
39
- };
40
- }
41
-
42
29
  /** Parenthetical status note for completed agent result text. */
43
30
  export function getStatusNote(status: string): string {
44
31
  switch (status) {
@@ -66,7 +53,7 @@ export function buildDetails(
66
53
  session?: any;
67
54
  lifetimeUsage: LifetimeUsage;
68
55
  },
69
- activity?: AgentActivity,
56
+ activity?: AgentActivityTracker,
70
57
  overrides?: Partial<AgentDetails>,
71
58
  ): AgentDetails {
72
59
  return {
@@ -106,7 +93,7 @@ export interface AgentToolWidget {
106
93
  export interface AgentToolDeps {
107
94
  manager: AgentToolManager;
108
95
  widget: AgentToolWidget;
109
- agentActivity: Map<string, AgentActivity>;
96
+ agentActivity: Map<string, AgentActivityTracker>;
110
97
  emitEvent: (name: string, data: unknown) => void;
111
98
  registry: AgentTypeRegistry;
112
99
  typeListText: string;
@@ -415,7 +402,7 @@ Guidelines:
415
402
 
416
403
  // Background execution
417
404
  if (runInBackground) {
418
- const bgState = createAgentActivity(effectiveMaxTurns);
405
+ const bgState = new AgentActivityTracker(effectiveMaxTurns);
419
406
 
420
407
  let id: string;
421
408
 
@@ -433,7 +420,7 @@ Guidelines:
433
420
  isolation,
434
421
  invocation: agentInvocation,
435
422
  onSessionCreated: (session: any) => {
436
- bgState.session = session;
423
+ bgState.setSession(session);
437
424
  subscribeUIObserver(session, bgState);
438
425
  },
439
426
  });
@@ -487,7 +474,7 @@ Guidelines:
487
474
  const startedAt = Date.now();
488
475
  let fgId: string | undefined;
489
476
 
490
- const fgState = createAgentActivity(effectiveMaxTurns);
477
+ const fgState = new AgentActivityTracker(effectiveMaxTurns);
491
478
  let unsubUI: (() => void) | undefined;
492
479
 
493
480
  const streamUpdate = () => {
@@ -535,7 +522,7 @@ Guidelines:
535
522
  parentSessionFile: ctx.sessionManager.getSessionFile(),
536
523
  parentSessionId: ctx.sessionManager.getSessionId(),
537
524
  onSessionCreated: (session: any) => {
538
- fgState.session = session;
525
+ fgState.setSession(session);
539
526
  unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
540
527
  for (const a of deps.manager.listAgents()) {
541
528
  if (a.session === session) {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * agent-activity-tracker.ts — Per-agent live activity state with explicit transition methods.
3
+ *
4
+ * Replaces the mutable `AgentActivity` interface that was written via output arguments
5
+ * in `ui-observer.ts`. Callers use named transition methods; readers use read-only accessors.
6
+ */
7
+
8
+ import { addUsage, type LifetimeUsage, type SessionLike } from "../usage.js";
9
+
10
+ /** Usage delta accepted by onUsageUpdate — matches the LifetimeUsage accumulator shape. */
11
+ export interface UsageDelta {
12
+ input: number;
13
+ output: number;
14
+ cacheWrite: number;
15
+ }
16
+
17
+ /** Per-agent live activity state with explicit transition methods and read-only accessors. */
18
+ export class AgentActivityTracker {
19
+ private _activeTools = new Map<string, string>();
20
+ private _toolKeySeq = 0;
21
+ private _toolUses = 0;
22
+ private _responseText = "";
23
+ private _session: SessionLike | undefined = undefined;
24
+ private _turnCount = 1;
25
+ private _lifetimeUsage: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
26
+
27
+ constructor(private readonly _maxTurns?: number) {}
28
+
29
+ // ── Transition methods (write surface) ──────────────────────────────────
30
+
31
+ /** Record that a tool has started executing. */
32
+ onToolStart(toolName: string): void {
33
+ this._activeTools.set(toolName + "_" + (++this._toolKeySeq), toolName);
34
+ }
35
+
36
+ /** Record that a tool has finished executing; increments toolUses. No-op when no matching tool is active. */
37
+ onToolEnd(toolName: string): void {
38
+ for (const [key, name] of this._activeTools) {
39
+ if (name === toolName) {
40
+ this._activeTools.delete(key);
41
+ this._toolUses++;
42
+ break;
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Reset the current response text (called at the start of each assistant message). */
48
+ onMessageStart(): void {
49
+ this._responseText = "";
50
+ }
51
+
52
+ /** Append a text delta to the current response text. */
53
+ onMessageUpdate(delta: string): void {
54
+ this._responseText += delta;
55
+ }
56
+
57
+ /** Record that a turn has ended; increments turnCount. */
58
+ onTurnEnd(): void {
59
+ this._turnCount++;
60
+ }
61
+
62
+ /** Accumulate a usage delta into the lifetime usage totals. */
63
+ onUsageUpdate(delta: UsageDelta): void {
64
+ addUsage(this._lifetimeUsage, delta);
65
+ }
66
+
67
+ /** Bind the session reference (called once when the agent session is created). */
68
+ setSession(session: SessionLike): void {
69
+ this._session = session;
70
+ }
71
+
72
+ // ── Read-only accessors ──────────────────────────────────────────────────
73
+
74
+ /** Currently-active tools: key → tool name. Multiple entries for concurrent same-name tools. */
75
+ get activeTools(): ReadonlyMap<string, string> {
76
+ return this._activeTools;
77
+ }
78
+
79
+ /** Total completed tool invocations. */
80
+ get toolUses(): number {
81
+ return this._toolUses;
82
+ }
83
+
84
+ /** The agent's latest partial response text (reset at each message start). */
85
+ get responseText(): string {
86
+ return this._responseText;
87
+ }
88
+
89
+ /** The active SDK session, or undefined before the first session is created. */
90
+ get session(): SessionLike | undefined {
91
+ return this._session;
92
+ }
93
+
94
+ /** Current turn count (starts at 1). */
95
+ get turnCount(): number {
96
+ return this._turnCount;
97
+ }
98
+
99
+ /** Effective max turns for this agent, or undefined for unlimited. */
100
+ get maxTurns(): number | undefined {
101
+ return this._maxTurns;
102
+ }
103
+
104
+ /** Accumulated lifetime token usage (survives compaction). */
105
+ get lifetimeUsage(): Readonly<LifetimeUsage> {
106
+ return this._lifetimeUsage;
107
+ }
108
+ }
@@ -9,7 +9,7 @@ import {
9
9
  } from "../agent-types.js";
10
10
  import type { ModelRegistry } from "../model-resolver.js";
11
11
  import type { AgentConfig, AgentRecord } from "../types.js";
12
- import type { AgentActivity } from "./agent-widget.js";
12
+ import type { AgentActivityTracker } from "./agent-activity-tracker.js";
13
13
  import { formatDuration, getDisplayName } from "./agent-widget.js";
14
14
 
15
15
  // ---- Deps interface ----
@@ -35,7 +35,7 @@ export interface AgentMenuSettings {
35
35
  export interface AgentMenuDeps {
36
36
  manager: AgentMenuManager;
37
37
  registry: AgentTypeRegistry;
38
- agentActivity: Map<string, AgentActivity>;
38
+ agentActivity: Map<string, AgentActivityTracker>;
39
39
  /** Resolve model label for a given agent type + registry. */
40
40
  getModelLabel: (type: string, registry?: ModelRegistry) => string;
41
41
  /** Settings manager — owns in-memory values and persistence. */
@@ -9,7 +9,8 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { type AgentConfigLookup, AgentTypeRegistry } from "../agent-types.js";
11
11
  import type { AgentInvocation, SubagentType } from "../types.js";
12
- import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
12
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
+ import type { AgentActivityTracker } from "./agent-activity-tracker.js";
13
14
 
14
15
  // ---- Constants ----
15
16
 
@@ -49,20 +50,6 @@ export type UICtx = {
49
50
  ): void;
50
51
  };
51
52
 
52
- /** Per-agent live activity state. */
53
- export interface AgentActivity {
54
- activeTools: Map<string, string>;
55
- toolUses: number;
56
- responseText: string;
57
- session?: SessionLike;
58
- /** Current turn count. */
59
- turnCount: number;
60
- /** Effective max turns for this agent (undefined = unlimited). */
61
- maxTurns?: number;
62
- /** Lifetime usage breakdown — see LifetimeUsage docs. */
63
- lifetimeUsage: LifetimeUsage;
64
- }
65
-
66
53
  /** Metadata attached to Agent tool results for custom rendering. */
67
54
  export interface AgentDetails {
68
55
  displayName: string;
@@ -177,7 +164,7 @@ function truncateLine(text: string, len = 60): string {
177
164
  }
178
165
 
179
166
  /** Build a human-readable activity string from currently-running tools or response text. */
180
- export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
167
+ export function describeActivity(activeTools: ReadonlyMap<string, string>, responseText?: string): string {
181
168
  if (activeTools.size > 0) {
182
169
  const groups = new Map<string, number>();
183
170
  for (const toolName of activeTools.values()) {
@@ -224,7 +211,7 @@ export class AgentWidget {
224
211
 
225
212
  constructor(
226
213
  private manager: AgentManager,
227
- private agentActivity: Map<string, AgentActivity>,
214
+ private agentActivity: Map<string, AgentActivityTracker>,
228
215
  private registry: AgentTypeRegistry,
229
216
  ) {}
230
217
 
@@ -11,8 +11,8 @@ import type { AgentConfigLookup } from "../agent-types.js";
11
11
  import { extractText } from "../context.js";
12
12
  import type { AgentRecord } from "../types.js";
13
13
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
14
- import type { Theme } from "./agent-widget.js";
15
- import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
14
+ import type { AgentActivityTracker } from "./agent-activity-tracker.js";
15
+ import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel, type Theme } from "./agent-widget.js";
16
16
 
17
17
  /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
18
18
  const CHROME_LINES_BASE = 6;
@@ -31,7 +31,7 @@ export class ConversationViewer implements Component {
31
31
  private tui: TUI,
32
32
  private session: AgentSession,
33
33
  private record: AgentRecord,
34
- private activity: AgentActivity | undefined,
34
+ private activity: AgentActivityTracker | undefined,
35
35
  private theme: Theme,
36
36
  private done: (result: undefined) => void,
37
37
  private registry: AgentConfigLookup,
@@ -1,13 +1,12 @@
1
1
  /**
2
- * ui-observer.ts — Subscribes to session events and updates AgentActivity state.
2
+ * ui-observer.ts — Subscribes to session events and updates AgentActivityTracker state.
3
3
  *
4
4
  * Replaces the callback-based createActivityTracker pattern with a direct
5
5
  * session subscription for streaming UI state (active tools, response text,
6
6
  * turn count, lifetime usage).
7
7
  */
8
8
 
9
- import { addUsage } from "../usage.js";
10
- import type { AgentActivity } from "./agent-widget.js";
9
+ import type { AgentActivityTracker } from "./agent-activity-tracker.js";
11
10
 
12
11
  /** Narrow session interface — only the subscribe method needed by the observer. */
13
12
  interface SubscribableSession {
@@ -15,15 +14,15 @@ interface SubscribableSession {
15
14
  }
16
15
 
17
16
  /**
18
- * Subscribe to session events and stream UI state into an AgentActivity object.
17
+ * Subscribe to session events and stream UI state into an AgentActivityTracker.
19
18
  *
20
19
  * Handles:
21
- * - `tool_execution_start` → add to `state.activeTools`
22
- * - `tool_execution_end` → remove from `state.activeTools`, `state.toolUses++`
23
- * - `message_start` → reset `state.responseText`
24
- * - `message_update` (text_delta) → append to `state.responseText`
25
- * - `turn_end` → `state.turnCount++`
26
- * - `message_end` (assistant, with usage) → `addUsage(state.lifetimeUsage, …)`
20
+ * - `tool_execution_start` → `tracker.onToolStart(name)`
21
+ * - `tool_execution_end` → `tracker.onToolEnd(name)`
22
+ * - `message_start` → `tracker.onMessageStart()`
23
+ * - `message_update` (text_delta) → `tracker.onMessageUpdate(delta)`
24
+ * - `turn_end` → `tracker.onTurnEnd()`
25
+ * - `message_end` (assistant, with usage) → `tracker.onUsageUpdate(usage)`
27
26
  *
28
27
  * Calls `onUpdate?.()` after each state mutation to trigger re-renders.
29
28
  *
@@ -31,47 +30,41 @@ interface SubscribableSession {
31
30
  */
32
31
  export function subscribeUIObserver(
33
32
  session: SubscribableSession,
34
- state: AgentActivity,
33
+ tracker: AgentActivityTracker,
35
34
  onUpdate?: () => void,
36
35
  ): () => void {
37
36
  return session.subscribe((event: any) => {
38
37
  if (event.type === "tool_execution_start") {
39
- state.activeTools.set(event.toolName + "_" + Date.now(), event.toolName);
38
+ tracker.onToolStart(event.toolName);
40
39
  onUpdate?.();
41
40
  }
42
41
 
43
42
  if (event.type === "tool_execution_end") {
44
- for (const [key, name] of state.activeTools) {
45
- if (name === event.toolName) {
46
- state.activeTools.delete(key);
47
- break;
48
- }
49
- }
50
- state.toolUses++;
43
+ tracker.onToolEnd(event.toolName);
51
44
  onUpdate?.();
52
45
  }
53
46
 
54
47
  if (event.type === "message_start") {
55
- state.responseText = "";
48
+ tracker.onMessageStart();
56
49
  }
57
50
 
58
51
  if (
59
52
  event.type === "message_update" &&
60
53
  event.assistantMessageEvent?.type === "text_delta"
61
54
  ) {
62
- state.responseText += event.assistantMessageEvent.delta;
55
+ tracker.onMessageUpdate(event.assistantMessageEvent.delta);
63
56
  onUpdate?.();
64
57
  }
65
58
 
66
59
  if (event.type === "turn_end") {
67
- state.turnCount++;
60
+ tracker.onTurnEnd();
68
61
  onUpdate?.();
69
62
  }
70
63
 
71
64
  if (event.type === "message_end" && event.message?.role === "assistant") {
72
65
  const u = event.message.usage;
73
66
  if (u) {
74
- addUsage(state.lifetimeUsage, {
67
+ tracker.onUsageUpdate({
75
68
  input: u.input ?? 0,
76
69
  output: u.output ?? 0,
77
70
  cacheWrite: u.cacheWrite ?? 0,