@gotgenes/pi-subagents 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,104 +14,97 @@ This document describes the architecture of the pi-subagents fork: a focused, co
14
14
  Scheduling is a separate concern that any extension can implement by calling `spawn()` on the published API.
15
15
  5. **UI extraction is deferred** — the widget, conversation viewer, and `/agents` command menu stay in the core for now.
16
16
  They are the first candidate for extraction once the API boundary is proven stable.
17
- 6. **Snapshot, don't capture** — mutable parent state (ctx, session, model) is read once at spawn time and frozen into a plain data object.
17
+ 6. **Snapshot, don't capture** — mutable parent state (ctx, session, model) is read once at spawn time and frozen into a `ParentSnapshot` data object.
18
18
  No live references survive past the spawn call.
19
- 7. **Subscribe, don't thread** — observation of agent progress uses event subscription on the session, not callback parameters threaded through multiple layers.
19
+ 7. **Subscribe, don't thread** — observation of agent progress uses direct session-event subscription, not callback parameters threaded through multiple layers.
20
+ 8. **Construct complete** — objects are born with all their dependencies.
21
+ If state isn't available yet, the object that needs it doesn't exist yet.
22
+ No post-construction field writes from external code — if an object can't be instantiated ready-to-go, the prep work hasn't been done and the right dependencies haven't been identified.
23
+ 9. **State owns its mutations** — mutable state lives in a class whose methods enforce valid transitions and invariants.
24
+ Free functions that mutate module-scoped variables, closure-captured bags-of-functions, and external writes to shared interfaces are replaced by classes that encapsulate the state they manage.
20
25
 
21
26
  ## Current state
22
27
 
23
- The extension is ~6,100 LOC across 35 focused modules with a typed `SubagentsService` API boundary.
24
- The `index.ts` entry point is ~270 lines; the rest is decomposed into domain modules.
28
+ The extension is organized into 39 focused modules with a typed `SubagentsService` API boundary.
25
29
 
26
30
  ```text
27
- index.ts (274 LOC) — entry point, tool registration, event wiring
28
- agent-manager.ts (499) — lifecycle, concurrency, queue
29
- agent-runner.ts (512) — session creation, turn loop, tool filtering
30
- session-config.ts (243) — pure session-config assembler
31
- agent-types.ts (138) — type registry (defaults + custom .md files)
32
- types.ts (126)shared type definitions
33
- runtime.ts (94) SubagentRuntime factory (session-scoped state)
34
-
35
- prompts.ts system prompt assembly
36
- context.ts — parent conversation extraction
37
- memory.ts — persistent MEMORY.md per agent
38
- skill-loader.ts preload .pi/skills into prompts
39
- env.ts git/platform detection
40
-
41
- worktree.ts — git worktree isolation
42
- usage.ts — token usage tracking
43
- model-resolver.ts fuzzy model name resolution
44
- invocation-config.ts merge tool params with agent config
45
- session-dir.ts subagent session directory derivation
46
- settings.ts persistent operational settings
47
-
48
- service.ts — SubagentsService interface + Symbol.for() accessors
49
- service-adapter.ts — SubagentsService implementation wrapping AgentManager
50
-
51
- tools/agent-tool.ts Agent tool definition + execute
52
- tools/get-result-tool.ts — get_subagent_result tool
53
- tools/steer-tool.ts steer_subagent tool
54
- tools/helpers.ts shared tool utilities
55
-
56
- handlers/lifecycle.ts session_start, session_before_switch, session_shutdown
57
- handlers/tool-start.ts — tool_execution_start handler
58
-
59
- notification.ts completion nudges, custom message renderer
60
- renderer.ts — notification TUI component
61
-
62
- ui/agent-widget.ts above-editor live status widget
63
- ui/agent-menu.ts /agents slash command menu
31
+ index.ts — entry point, tool registration, event wiring
32
+ agent-manager.ts — lifecycle, concurrency, queue
33
+ agent-runner.ts — session creation, turn loop, tool filtering
34
+ session-config.ts — pure session-config assembler
35
+ agent-types.ts — type registry (defaults + custom .md files)
36
+ agent-record.ts — agent record with encapsulated status transitions
37
+ types.ts shared type definitions
38
+ runtime.ts — SubagentRuntime factory (session-scoped state)
39
+ parent-snapshot.ts immutable snapshot of parent session state
40
+
41
+ prompts.ts — system prompt assembly
42
+ context.ts parent conversation extraction
43
+ memory.ts persistent MEMORY.md per agent
44
+ skill-loader.ts — preload .pi/skills into prompts
45
+ env.ts — git/platform detection
46
+
47
+ worktree.ts git worktree isolation
48
+ usage.ts token usage tracking
49
+ model-resolver.ts fuzzy model name resolution
50
+ invocation-config.ts merge tool params with agent config
51
+ session-dir.ts — subagent session directory derivation
52
+ settings.ts — persistent operational settings; `SettingsManager` class owns all three in-memory values
53
+
54
+ service.ts — SubagentsService interface + Symbol.for() accessors
55
+ service-adapter.ts SubagentsService implementation wrapping AgentManager
56
+
57
+ tools/agent-tool.ts Agent tool definition + execute
58
+ tools/get-result-tool.ts get_subagent_result tool
59
+ tools/steer-tool.ts — steer_subagent tool
60
+ tools/helpers.ts shared tool utilities
61
+
62
+ handlers/lifecycle.ts — session_start, session_before_switch, session_shutdown
63
+ handlers/tool-start.ts tool_execution_start handler
64
+
65
+ notification.ts — completion nudges, custom message renderer
66
+ renderer.ts notification TUI component
67
+ record-observer.ts session-event observer for record statistics
68
+
69
+ ui/agent-widget.ts — above-editor live status widget
70
+ ui/agent-menu.ts — /agents slash command menu
64
71
  ui/conversation-viewer.ts — scrollable session overlay
72
+ ui/ui-observer.ts — session-event observer for UI streaming
65
73
 
66
- default-agents.ts — embedded default agent configs (general-purpose, Explore, Plan)
67
- custom-agents.ts — user-defined agent .md file loader
68
- debug.ts — debug logging utility
74
+ default-agents.ts — embedded default agent configs (general-purpose, Explore, Plan)
75
+ custom-agents.ts — user-defined agent .md file loader
76
+ debug.ts — debug logging utility
69
77
  ```
70
78
 
71
- ### Coupling today
79
+ ### Observation model
72
80
 
73
- The widget reads agent state by holding a direct reference to `SubagentRuntime` and polling a shared mutable `Map<string, AgentActivity>` every 80 ms. The conversation viewer subscribes directly to `AgentSession` objects.
81
+ Record statistics (tool uses, token usage, compaction counts) are updated by `record-observer.ts`, which subscribes directly to session events.
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
+ Neither observer wraps or forwards the other — both subscribe directly to the session.
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.
74
86
 
75
87
  Cross-extension consumers use the typed `SubagentsService` API published via `Symbol.for("@gotgenes/pi-subagents:service")` on `globalThis`.
76
- The ad-hoc RPC layer and untyped `Symbol.for("pi-subagents:manager")` have been removed.
77
88
 
78
- ## Target state
89
+ ## Cross-extension architecture
79
90
 
80
- ```text
81
- ┌────────────────────────────────────────────────────────┐
82
- @gotgenes/pi-subagents (this package)
83
- │ │
84
- │ Exports: │
85
- │ SubagentsService interface │
86
- │ publishSubagentsService() / getSubagentsService()
87
- SubagentRecord, SubagentStatus, LifetimeUsage types │
88
- │ SUBAGENT_EVENTS constants │
89
- │ │
90
- │ Core: │
91
- Agent + get_subagent_result + steer_subagent tools │
92
- │ AgentManager, agent-runner, agent-types │
93
- │ publishSubagentsService(impl) ← called at init │
94
- │ │
95
- │ Internal UI (widget, viewer, /agents menu) │
96
- │ ← moves to pi-subagents-ui later │
97
- └──────────────────────┬─────────────────────────────────┘
98
- │ Symbol.for("@gotgenes/pi-subagents:service")
99
-
100
- ┌─────────────────┼──────────────────┐
101
- │ │ │
102
- ▼ ▼ ▼
103
- ┌─────────┐ ┌──────────────┐ ┌──────────────┐
104
- │ pi- │ │ pi-subagents │ │ any future │
105
- │ schedule│ │ -ui │ │ extension │
106
- │ (other │ │ (deferred) │ │ │
107
- │ ext) │ └──────────────┘ └──────────────┘
108
- └─────────┘
109
-
110
- │ getSubagentsService()?.spawn(...)
111
- │ (optional peer dep + dynamic import for types)
112
-
91
+ ```mermaid
92
+ flowchart TD
93
+ subgraph core["@gotgenes/pi-subagents (this package)"]
94
+ direction TB
95
+ exports["SubagentsService interface\npublish / getSubagentsService()\nSubagentRecord, SubagentStatus, LifetimeUsage\nSUBAGENT_EVENTS constants"]
96
+ engine["Agent + get_subagent_result + steer_subagent tools\nAgentManager, agent-runner, agent-types\npublishSubagentsService() called at init"]
97
+ ui["Internal UI: widget, viewer, /agents menu\n(candidate for extraction to pi-subagents-ui)"]
98
+ end
99
+
100
+ core -- "Symbol.for() on globalThis" --> sched["scheduling extension\n(hypothetical)"]
101
+ core -- "Symbol.for() on globalThis" --> subui["pi-subagents-ui\n(deferred)"]
102
+ core -- "Symbol.for() on globalThis" --> future["any future extension"]
113
103
  ```
114
104
 
105
+ Consumers call `getSubagentsService()?.spawn(...)` at runtime.
106
+ They declare this package as an optional peer dependency and use dynamic import for compile-time types.
107
+
115
108
  ### What the core owns
116
109
 
117
110
  - The three tools: `Agent`, `get_subagent_result`, `steer_subagent`.
@@ -119,6 +112,8 @@ The ad-hoc RPC layer and untyped `Symbol.for("pi-subagents:manager")` have been
119
112
  - `agent-runner` — session creation, turn loop, tool filtering, extension binding (Patches 2 and 3).
120
113
  - `session-config` — pure configuration assembler (extracted from `agent-runner`).
121
114
  - `SubagentRuntime` — session-scoped state bag with methods.
115
+ - `ParentSnapshot` — immutable snapshot of parent session state, captured once at spawn time.
116
+ - `record-observer` — session-event observer that updates record statistics without callback threading.
122
117
  - Agent type registry — default agents, custom `.md` file loading.
123
118
  - Prompt assembly, context extraction, memory, skills, environment.
124
119
  - Worktree isolation.
@@ -127,33 +122,20 @@ The ad-hoc RPC layer and untyped `Symbol.for("pi-subagents:manager")` have been
127
122
  - Settings persistence.
128
123
  - Internal UI (widget, conversation viewer, `/agents` menu) — these stay until the API boundary is proven, then move to a separate extension.
129
124
 
130
- ### What the core drops
125
+ ### What the core dropped
131
126
 
132
- - **Scheduling** (`schedule.ts`, `schedule-store.ts`, `ui/schedule-menu.ts`) — 612 LOC removed.
133
- The `schedule` parameter is removed from the `Agent` tool schema.
127
+ - **Scheduling** (`schedule.ts`, `schedule-store.ts`, `ui/schedule-menu.ts`) — removed (#52).
134
128
  Any extension that wants scheduling can implement it by calling `getSubagentsService()?.spawn(...)` on a timer.
135
- - **Ad-hoc RPC** (`cross-extension-rpc.ts`) — replaced by the typed `SubagentsService` published via `Symbol.for()`.
136
- The untyped event-bus RPC channels are removed.
137
- - **Group join** (`group-join.ts`) — 141 LOC removed.
138
- The grouped notification batching adds complexity for a marginal UX improvement.
129
+ - **Ad-hoc RPC** (`cross-extension-rpc.ts`) — replaced by the typed `SubagentsService` published via `Symbol.for()` (#49).
130
+ - **Group join** (`group-join.ts`) removed (#49).
139
131
  Individual completion notifications are sufficient.
140
132
  - **Output file** (`output-file.ts`) — replaced by `session-dir.ts` + `SessionManager.create()` (#61).
141
- Subagent transcripts are now written in Pi's official JSONL session format via the SDK's `SessionManager`, nested under the parent session directory.
142
-
143
- ### Estimated impact (realized)
144
-
145
- | Subsystem | Status | LOC impact |
146
- | ---------------------- | -------------- | ------------------------------------------ |
147
- | Scheduling | Removed (#52) | −612 |
148
- | Ad-hoc RPC | Removed (#49) | −080 |
149
- | Group join | Removed (#49) | −141 |
150
- | Output file | Replaced (#61) | −83 (replaced by 38-line `session-dir.ts`) |
151
- | index.ts decomposition | Done (#54) | 1,894 → 274 |
152
-
153
- The codebase is now ~6,100 LOC across 35 modules.
154
- The `index.ts` entry point is 274 lines.
133
+ Subagent transcripts are now written in Pi's official JSONL session format.
134
+ - **Callback threading** — the three-layer `on*` callback chain through `SpawnOptions` → `AgentManager` → `RunOptions` was replaced by direct session-event subscriptions (#100).
135
+ - **Live `ctx` capture** — `SpawnArgs` previously held a mutable `ctx: ExtensionContext` reference that could go stale in the concurrency queue.
136
+ Replaced by `ParentSnapshot`, an immutable data object captured once at spawn time (#99).
155
137
 
156
- ## SubagentsService (done — #48)
138
+ ## SubagentsService
157
139
 
158
140
  The `SubagentsService` interface, accessor functions, and serializable types are exported from `@gotgenes/pi-subagents` via the `./service` export map entry.
159
141
  No separate API package is needed.
@@ -223,8 +205,7 @@ The core emits events on `pi.events` that any extension can observe:
223
205
  | `subagents:completed` | `{ id, type, status, result?, error? }` | Agent finishes |
224
206
  | `subagents:activity` | `{ id, toolName?, textDelta?, turnCount? }` | Streaming progress |
225
207
 
226
- These replace the ad-hoc RPC channels.
227
- They are fire-and-forget broadcast events — no request IDs, no reply channels.
208
+ These are fire-and-forget broadcast events — no request IDs, no reply channels.
228
209
 
229
210
  ### Consumer example: scheduling extension
230
211
 
@@ -269,71 +250,78 @@ export default function (pi) {
269
250
  }
270
251
  ```
271
252
 
272
- ## index.ts decomposition (done — #54, #69, #70)
253
+ ## index.ts decomposition
273
254
 
274
- The original 1,894-line `index.ts` has been decomposed into focused modules:
255
+ The original monolithic `index.ts` has been decomposed into focused modules:
275
256
 
276
257
  ```text
277
258
  src/
278
- ├── index.ts (274) ← slimmed entry point: init, tool registration
279
- ├── runtime.ts (94) ← SubagentRuntime: session-scoped state + methods
259
+ ├── index.ts slimmed entry point: init, tool registration
260
+ ├── runtime.ts SubagentRuntime: session-scoped state + methods
280
261
  ├── tools/
281
- │ ├── agent-tool.ts (626) ← Agent tool definition + execute
282
- │ ├── get-result-tool.ts get_subagent_result tool
283
- │ ├── steer-tool.ts steer_subagent tool
284
- │ └── helpers.ts shared tool utilities
262
+ │ ├── agent-tool.ts Agent tool definition + execute
263
+ │ ├── get-result-tool.ts get_subagent_result tool
264
+ │ ├── steer-tool.ts steer_subagent tool
265
+ │ └── helpers.ts shared tool utilities
285
266
  ├── handlers/
286
- │ ├── lifecycle.ts session_start, session_before_switch, session_shutdown
287
- │ └── tool-start.ts tool_execution_start handler
288
- ├── notification.ts completion nudges, custom renderer
289
- ├── renderer.ts notification TUI component
290
- ├── ui/agent-menu.ts (677) ← /agents slash command menu
291
- ├── service-adapter.ts SubagentsService implementation wrapping AgentManager
267
+ │ ├── lifecycle.ts session_start, session_before_switch, session_shutdown
268
+ │ └── tool-start.ts tool_execution_start handler
269
+ ├── notification.ts completion nudges, custom renderer
270
+ ├── renderer.ts notification TUI component
271
+ ├── ui/agent-menu.ts /agents slash command menu
272
+ ├── service-adapter.ts SubagentsService implementation wrapping AgentManager
292
273
  └── (existing domain modules unchanged)
293
274
  ```
294
275
 
295
276
  Each extracted module receives narrow constructor-injected dependencies rather than closing over module-level state.
296
277
  Handlers call methods on narrow runtime interfaces — no raw field writes, no `widget!` reach-throughs.
297
278
 
298
- ## Phase plan (Phases 1–5 complete)
279
+ ## Phase plan
299
280
 
300
- ### Phase 1: Export `SubagentsService` from this package (done — #48)
281
+ ### Phase 1: Export `SubagentsService` from this package (#48)
301
282
 
302
283
  Added the `SubagentsService` interface, serializable types, `Symbol.for()` accessor functions, and `SUBAGENT_EVENTS` constants as public exports.
303
284
  Wired `service-adapter.ts` to wrap `AgentManager` and call `publishSubagentsService()` at extension init.
304
285
 
305
- ### Phase 2: Remove scheduling (done — issue #52)
286
+ ### Phase 2: Remove scheduling (#52)
306
287
 
307
288
  Deleted `schedule.ts`, `schedule-store.ts`, `ui/schedule-menu.ts`.
308
289
  Removed the `schedule` parameter from the `Agent` tool schema.
309
290
  Removed scheduler setup and lifecycle hooks from `index.ts`.
310
291
 
311
- ### Phase 3: Remove group-join, ad-hoc RPC; replace output-file (done — #49, #61)
292
+ ### Phase 3: Remove group-join, ad-hoc RPC; replace output-file (#49, #61)
312
293
 
313
294
  Deleted `group-join.ts`, `cross-extension-rpc.ts` (#49).
314
295
  Replaced `output-file.ts` with `SessionManager.create()` + `session-dir.ts` (#61).
315
296
  Simplified `index.ts` to use direct individual notifications.
316
297
  Lifecycle events emitted on `pi.events` for external consumers.
317
298
 
318
- ### Phase 4: Implement and publish `SubagentsService` (done — #48)
299
+ ### Phase 4: Implement and publish `SubagentsService` (#48)
319
300
 
320
301
  Wired `service-adapter.ts` to wrap `AgentManager` and call `publishSubagentsService()` at extension init.
321
302
  Model strings are resolved inside the adapter.
322
303
 
323
- ### Phase 5: Decompose `index.ts` (done — #54, #69, #70, #87)
304
+ ### Phase 5: Decompose `index.ts` (#54, #69, #70, #87)
324
305
 
325
306
  Extracted tools, notifications, activity tracking, event handlers, and the `/agents` command into separate modules.
326
307
  Created `SubagentRuntime` factory to hold session-scoped state.
327
- `src/index.ts` shrank from ~1,894 lines to ~274 lines.
328
308
 
329
309
  ### Phase 6 (future): Extract UI to `@gotgenes/pi-subagents-ui`
330
310
 
331
311
  Move `ui/agent-widget.ts`, `ui/conversation-viewer.ts`, the `/agents` command, notifications, and activity tracking to a separate extension that consumes `SubagentsService` + lifecycle events.
332
312
  This phase is deferred until the API boundary is proven stable in production.
333
313
 
334
- ## Structural refactoring roadmap (post-#54) complete
314
+ ### Phase 7: Encapsulation and dependency narrowing
335
315
 
336
- All structural refactoring phases are complete.
316
+ Target: every mutable state bag becomes a class, every dependency bag narrows to what its consumer uses, every callback becomes either a method on a collaborator or an event on an observable.
317
+
318
+ The work is sequenced so each change makes the next change easy.
319
+ See the [Encapsulation roadmap](#encapsulation-roadmap) section for the full breakdown.
320
+
321
+ ## Structural refactoring roadmap
322
+
323
+ Phases 1–5 are complete.
324
+ Phase 7 (encapsulation and dependency narrowing) is the active structural track.
337
325
  See `git log` for the full history; issue references are preserved below for traceability.
338
326
 
339
327
  | Phase | Issue | Summary |
@@ -345,188 +333,184 @@ See `git log` for the full history; issue references are preserved below for tra
345
333
 
346
334
  The remaining open issue is #22 (parent-session resolution), a cross-extension track that does not gate the structural work.
347
335
 
348
- ---
336
+ ## AgentManager decomposition
349
337
 
350
- ## Next target: AgentManager internal decomposition
338
+ AgentManager was decomposed in three steps to untangle record management, concurrency control, and execution orchestration.
351
339
 
352
- The structural refactoring roadmap decomposed the extension entry point and established clean module boundaries.
353
- AgentManager itself — the central class — was not touched structurally.
354
- A design review reveals three tangled responsibilities and two systemic patterns that inflate complexity.
340
+ ### Step 1: Record state machine (#98, #102)
355
341
 
356
- ### Problem statement
342
+ Extracted status-transition methods (`markRunning`, `markCompleted`, `markAborted`, `markSteered`, `markError`, `markStopped`, `resetForResume`) onto `AgentRecord`.
343
+ Replaced scattered field writes across 6 sites with encapsulated transition methods.
344
+ Issue #102 consolidated test `AgentRecord` construction into a shared factory.
357
345
 
358
- AgentManager is a 500-line class that serves as the single mediator between tool callers and the agent runner.
359
- Every concern passes through it because it owns the `AgentRecord`.
346
+ ### Step 2: Parent snapshot (#99)
360
347
 
361
- Three responsibilities are tangled:
348
+ Replaced live `ctx: ExtensionContext` capture in `SpawnArgs` with an immutable `ParentSnapshot` data object.
349
+ The snapshot is taken once at spawn time; queued agents execute against frozen state rather than a potentially stale session reference.
350
+ `runAgent()` accepts `ParentSnapshot` instead of `ctx`.
351
+ `pi: ExtensionAPI` was removed from `SpawnArgs` — `runAgent()` accepts a `ShellExec` function instead.
362
352
 
363
- 1. **Record registry** create, track, query, clean up `AgentRecord` instances.
364
- 2. **Concurrency control** — queue, running count, drain, `bypassQueue`.
365
- 3. **Execution orchestration** — thread options to the runner, intercept callbacks to update records, wire abort signals, manage worktree lifecycle.
353
+ ### Step 3: Session-event observation (#100)
366
354
 
367
- `startAgent()` alone is ~130 lines because it handles all three.
368
- The `.then()` / `.catch()` blocks mix status updates (job 1), worktree cleanup (job 3), notification callbacks (job 1), and queue draining (job 2).
355
+ Replaced three-layer callback threading with direct session subscriptions.
356
+ `record-observer.ts` subscribes to the session to update record statistics (tool uses, lifetime usage, compaction count).
357
+ `ui/ui-observer.ts` subscribes to the session to stream UI state (active tools, response text, turn count).
358
+ `SpawnOptions` and `RunOptions` dropped all `on*` callback fields except `onSessionCreated` (which delivers the session object to enable external subscriptions).
369
359
 
370
- Two systemic patterns compound the problem:
360
+ ### Realized impact
371
361
 
372
- ### Problem 1: Callback threading
362
+ | Metric | Before | After |
363
+ | --------------------------------- | ------ | ----------------------- |
364
+ | `SpawnOptions` callback fields | 6 | 1 (`onSessionCreated`) |
365
+ | `RunOptions` callback fields | 6 | 1 (`onSessionCreated`) |
366
+ | Callback layers | 3 | 0 (direct subscription) |
367
+ | Live `ctx` references in queue | 1 | 0 (snapshot) |
368
+ | Scattered status-transition sites | 6 | 1 (state machine) |
373
369
 
374
- `SpawnOptions` carries 6 `on*` callback fields.
375
- They thread through three layers:
376
-
377
- ```text
378
- agent-tool.ts (UI tracking state)
379
- → AgentManager.startAgent() wraps each to update the record, then forwards
380
- → runner.run() subscribes to session events, calls callbacks
381
- ```
370
+ ---
382
371
 
383
- The callbacks serve two purposes that are tangled together:
372
+ ## Encapsulation roadmap
384
373
 
385
- 1. **Record statistics** `onToolActivity` increments `toolUses`, `onAssistantUsage` accumulates `lifetimeUsage`, `onCompaction` increments `compactionCount`, `onSessionCreated` captures the session and output file.
386
- This is internal bookkeeping that belongs to the record.
387
- 2. **UI streaming** — the same callbacks update the widget's active-tool display, response text preview, and turn counter.
388
- This is presentation that belongs to the UI layer.
374
+ This section describes the Phase 7 targets: encapsulating mutable state into classes, replacing callbacks with semantic components, and narrowing dependency bags.
389
375
 
390
- The session already emits all of these events via `session.subscribe()`.
391
- The runner subscribes to session events, translates them into callback invocations, AgentManager wraps each callback to update the record, then forwards to the caller's callback.
392
- Three layers reimplementing what a single event subscription could provide.
376
+ Each step is sequenced so it makes the next step easier.
393
377
 
394
- ### Problem 2: Live `ctx` capture
378
+ ### Current smells
395
379
 
396
- `ctx: ExtensionContext` is a mutable reference to the parent session.
397
- It is captured into `SpawnArgs` and held in the concurrency queue:
380
+ | Smell | Location | Evidence |
381
+ | -------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
382
+ | ~~Global mutable state~~ | ~~`agent-types.ts`~~ | **Fixed #108**: `AgentTypeRegistry` class; `reloadCustomAgents` callback removed from `AgentToolDeps` and `AgentMenuDeps` |
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 |
385
+ | ~~Settings relay~~ | ~~`AgentMenuDeps` (13 fields)~~ | **Fixed #109**: `SettingsManager` class; 6 callback fields collapsed to `settings: SettingsManager`; `AgentMenuDeps` now 8 fields |
386
+ | Post-construction mutation | `AgentRecord` non-transition state | `session`, `outputFile`, `worktree`, `promise` written by external code after construction |
387
+ | Fire-and-forget callbacks | `AgentManagerOptions` | `onStart`, `onComplete`, `onCompact` wired as closures in `index.ts` |
388
+ | Duplicate `SpawnOptions` | `service.ts` + `agent-manager.ts` | Two incompatible shapes (JSON-friendly vs runtime types) with the same name |
389
+ | Type dumping ground | `types.ts` | `NotificationDetails` used only by notification/renderer; ~~`DEFAULT_AGENT_NAMES` moved to `AgentTypeRegistry` (#108)~~; `AgentConfig` (21 fields) consumers use 2–4 each |
390
+ | Wide dependency bags | `AgentToolDeps` (7), `AgentMenuDeps` (8) | Settings narrowed (#109); registry narrowed (#108); more narrowing planned in D steps |
398
391
 
399
- ```typescript
400
- const args: SpawnArgs = { pi, ctx, type, prompt, options };
401
- this.queue.push({ id, args }); // ctx held until dequeue
402
- ```
392
+ ### Step A: Extract state into classes (foundation, parallel)
403
393
 
404
- When the queued agent dequeues, `runAgent()` reads from the live `ctx`:
394
+ These three extractions are independent and can proceed in any order.
395
+ Each eliminates a category of global/closure state and gives orphaned callbacks a natural home.
405
396
 
406
- - `ctx.cwd` directory that may have changed.
407
- - `ctx.getSystemPrompt()` — live method call on a potentially stale session.
408
- - `ctx.model` — model that may have been switched.
409
- - `ctx.modelRegistry` — registry reference.
397
+ #### A1. `AgentTypeRegistry` class (#108)
410
398
 
411
- If the parent session changes between queue and dequeue (model switch, cwd change, session restart), the agent reads invalid state.
412
- The same live reference persists in `runtime.currentCtx` for the service-adapter.
399
+ Wrap the module-scoped `agents` Map and free functions in `agent-types.ts` into an injectable class.
400
+ `reloadCustomAgents` (currently a callback threaded through `AgentToolDeps` and `AgentMenuDeps`) becomes `registry.reload()`.
401
+ `DEFAULT_AGENT_NAMES` moves from `types.ts` to the registry.
413
402
 
414
- Additionally, `inheritContext` calls `ctx.sessionManager.getBranch()` at run time.
415
- The user's intent is to fork the conversation as it existed when they asked for the agent — not the conversation at some arbitrary later point when a queue slot opens.
403
+ Impact: eliminates global mutable state, enables test isolation without module resets, removes `reloadCustomAgents` callback from 2 dependency bags.
416
404
 
417
- ### Design: snapshot at spawn time
405
+ #### ~~A2. `SettingsManager` class (#109)~~ — **Done**
418
406
 
419
- Replace the live `ctx` capture with a plain data snapshot taken once at spawn time:
407
+ Encapsulated settings load/save/apply cycle into `SettingsManager` (in `settings.ts`).
408
+ Owns `defaultMaxTurns`, `graceTurns`, `maxConcurrent` with normalizing property accessors.
409
+ Absorbed `SettingsAppliers`, `applyAndEmitLoaded`, `saveAndEmitChanged`.
410
+ The 6 settings-related fields in `AgentMenuDeps` collapsed to `settings: AgentMenuSettings`.
411
+ `AgentManager` reads `maxConcurrent` via injected `getMaxConcurrent` function.
412
+ `SubagentRuntime.defaultMaxTurns` and `.graceTurns` removed.
420
413
 
421
- ```typescript
422
- interface ParentSnapshot {
423
- cwd: string;
424
- systemPrompt: string;
425
- model: unknown;
426
- modelRegistry: { find(...): unknown; getAvailable?(): ... };
427
- parentContext?: string; // pre-built text if inheritContext
428
- }
429
- ```
414
+ Impact: reduced `AgentMenuDeps` from 13 → 8 fields; `AgentToolDeps` from 8 → 7 fields.
430
415
 
431
- This snapshot is:
416
+ #### A2b. `SettingsManager` apply methods (#118)
432
417
 
433
- - Captured once in `spawn()` (or by the tool before calling `spawn()`).
434
- - Stored in `SpawnArgs` instead of `ctx`.
435
- - Passed to `runner.run()` instead of `ctx: ExtensionContext`.
436
- - Immutable no staleness risk, no session-lifetime coupling.
418
+ Eliminate the cross-collaborator orchestration in `showSettings`.
419
+ The menu currently mutates `settings`, pokes `manager.notifyConcurrencyChanged()`, then calls `settings.saveAndNotify()` — it knows too much about the consequence chain.
420
+ Add `applyMaxConcurrent(n)`, `applyDefaultMaxTurns(n)`, `applyGraceTurns(n)` methods that own the full lifecycle: normalize → apply → notify interested parties (via callback) → persist → emit event → return toast.
421
+ `SettingsManager` accepts an `onMaxConcurrentChanged` callback wired to `manager.notifyConcurrencyChanged()` at init.
422
+ `notifyConcurrencyChanged` disappears from `AgentMenuManager`.
437
423
 
438
- `runAgent()` already reads exactly these 4 values from `ctx` and never touches it again.
439
- `buildParentContext()` also reads once and produces a string.
440
- The snapshot formalizes what is already happening, and makes the "read once" guarantee structural.
424
+ Impact: eliminates LoD / Tell-Don't-Ask violation; menu no longer coordinates between settings and manager.
441
425
 
442
- ### Design: session-event observation replaces callback threading
426
+ #### A3. `AgentActivityTracker` class (#110)
443
427
 
444
- The session emits events via `session.subscribe()`.
445
- Today, `runner.run()` subscribes and translates events into `RunOptions.on*()` callbacks, AgentManager wraps those to update the record, then forwards to the caller.
428
+ Wrap the 7-field mutable `AgentActivity` interface with transition methods (`onToolStart()`, `onToolEnd()`, `onMessageUpdate()`, `onTurnEnd()`).
429
+ `ui-observer.ts` calls tracker methods instead of writing raw fields.
430
+ The notification system, widget, and agent-tool receive a proper collaborator instead of reaching into a shared `Map<string, AgentActivity>`.
446
431
 
447
- The target replaces this three-layer chain with direct subscription:
432
+ Impact: eliminates output-argument writes in `ui-observer.ts`, makes the mutation contract explicit.
448
433
 
449
- ```text
450
- session.subscribe()
451
-
452
- ┌─────────────┼─────────────┐
453
- │ │
454
- Record observer UI observer
455
- (accumulates stats on record) (updates widget state)
456
- managed by AgentManager managed by agent-tool
457
- subscribes in startAgent() subscribes after spawn
458
- ```
459
-
460
- AgentManager subscribes to the session to update the record (toolUses, lifetimeUsage, compactionCount, outputFile).
461
- The agent-tool subscribes to the session to stream UI state (active tools, response text, turn count).
462
- Neither layer wraps or forwards the other's callbacks.
434
+ ### Step B: Split `AgentRecord` lifecycle state (#111)
463
435
 
464
- `RunOptions` drops all 6 `on*` fields and becomes pure configuration.
465
- `SpawnOptions` drops all 6 `on*` fields and becomes identity + dispatch mode.
466
- The session reference reaches callers via `record.session` (already stored) or via an `onSessionCreated` callback that is the one callback that remains (it delivers the session object, enabling the external subscription).
436
+ `AgentRecord` is currently constructed in `spawn()` before most of its state exists, then mutated across 4 files as information trickles in.
437
+ The fix is not setter methods it's splitting along lifecycle boundaries so each object is born complete.
467
438
 
468
- ### Design: record state machine
439
+ - **`AgentRecord`** stays as identity + status state machine (what we know at spawn time).
440
+ - **Execution state** (`session`, `promise`, `outputFile`) → a new object constructed when the runner creates the session, injected as a complete collaborator.
441
+ - **Worktree state** (`worktree` info, cleanup result) → a new object constructed when isolation is set up, only exists for worktree agents.
442
+ - **`pendingSteers`** moves to a queue on the manager (where they're buffered), not a field on the record.
469
443
 
470
- Status transitions are scattered across 6 locations (`startAgent` `.then()`, `.catch()`, `resume()`, `abort()`, `abortAll()`, `drainQueue()`).
471
- Each location sets `record.status` plus associated fields (`completedAt`, `result`, `error`) in ad-hoc combinations.
444
+ Each piece is born complete at the moment its information is available.
445
+ The record doesn't accumulate half-baked state it receives fully constructed collaborators.
472
446
 
473
- Extract a state machine on `AgentRecord` (or a thin wrapper) that owns all transitions:
447
+ ### Step C: Replace `AgentManager` callbacks with observer (#112)
474
448
 
475
- ```typescript
476
- record.markRunning(startedAt)
477
- record.markCompleted(result, completedAt)
478
- record.markError(error)
479
- record.markStopped()
480
- record.resetForResume()
481
- ```
449
+ Replace the `onStart`/`onComplete`/`onCompact` callback parameters with an `AgentManagerObserver` interface (or typed event emitter).
450
+ The observer methods receive the same data the callbacks receive today.
451
+ `index.ts` constructs the observer once instead of building 3 closure callbacks that capture `runtime`, `pi`, `notifications`, etc.
452
+ `AgentManagerOptions` drops from 8 → 5 fields.
482
453
 
483
- Each method sets exactly the fields that belong to that transition.
484
- Invalid transitions (e.g., `markCompleted` on an already-stopped record) are no-ops.
485
- The `if (record.status !== "stopped")` guards in `.then()` and `.catch()` become part of the transition logic rather than scattered conditionals.
454
+ ### Step D: Disambiguate `SpawnOptions` and narrow dependency bags
486
455
 
487
- ### Phased implementation
456
+ With the registry class, settings manager, and observer in place, the dependency bags shrink naturally.
488
457
 
489
- The three designs are independent and can land in any order.
490
- The recommended sequence minimizes intermediate churn.
458
+ #### D1. Disambiguate `SpawnOptions` (#113)
491
459
 
492
- #### Step 1: Record state machine (done #98, #102)
460
+ Rename the internal `SpawnOptions` in `agent-manager.ts` to `AgentSpawnConfig` (or similar) to distinguish it from the JSON-friendly public `SpawnOptions` in `service.ts`.
461
+ The two types serve different consumers and should not share a name.
493
462
 
494
- Extract status-transition methods onto `AgentRecord` (or a `RecordManager` wrapper).
495
- Purely mechanical — replace scattered field writes with method calls.
496
- No interface changes for callers.
463
+ #### D2. Narrow `AgentToolDeps` and `AgentMenuDeps` (#114)
497
464
 
498
- This is the lowest-risk change and immediately reduces `startAgent()` line count.
465
+ | Bag | Before | After | How |
466
+ | --------------- | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------- |
467
+ | `AgentToolDeps` | 9 fields | ~5 | Registry owns reload; activity tracker is a collaborator; `emitEvent` moves to observer |
468
+ | `AgentMenuDeps` | 13 fields | ~6 | Settings manager absorbs 6 fields (#109); apply methods remove `notifyConcurrencyChanged` (#118); registry owns reload |
499
469
 
500
- Issue #102 consolidated test `AgentRecord` construction into a shared factory as follow-up.
470
+ ### Step E: Decompose large files and relocate types (parallel)
501
471
 
502
- #### Step 2: Parent snapshot (#99)
472
+ #### E1. Split `agent-tool.ts` foreground/background (#115)
503
473
 
504
- Replace `ctx: ExtensionContext` in `SpawnArgs` with a `ParentSnapshot` data object.
505
- Capture the snapshot in `spawn()` or at the tool call site.
506
- Update `runner.run()` signature to accept `ParentSnapshot` instead of `ctx`.
507
- Remove `pi: ExtensionAPI` from `SpawnArgs` (it is only used to pass to `runner.run()`, which only uses it for `detectEnv()` — that can accept a shell-exec function instead).
474
+ Extract the foreground execution loop (spinner, streaming, result rendering) and background spawn path into separate modules.
475
+ The 654-line file splits along a natural seam.
508
476
 
509
- This change narrows the `AgentRunner` interface and eliminates live-reference capture.
477
+ #### E2. Type housekeeping (#116)
510
478
 
511
- #### Step 3: Session-event observation (#100)
479
+ - Move `NotificationDetails` from `types.ts` to `notification.ts`.
480
+ - Move `DEFAULT_AGENT_NAMES` from `types.ts` to the registry.
481
+ - Move `ParentSnapshot` from `types.ts` to `parent-snapshot.ts`.
482
+ - Move `EnvInfo` from `types.ts` to `env.ts`.
483
+ - Convert `createNotificationSystem` closure to `NotificationManager` class.
484
+ - Convert `ConversationViewer` constructor from 6 positional parameters to an options bag.
485
+ - Define narrow `AgentConfig` subset interfaces for consumers that use 2–4 fields of the 21-field type.
512
486
 
513
- Replace the callback-threading pattern with direct session subscriptions.
514
- AgentManager subscribes to the session after creation to update the record.
515
- The agent-tool subscribes to the session after spawn to stream UI state.
516
- `RunOptions` and `SpawnOptions` drop all `on*` callback fields.
487
+ ### Expected impact
517
488
 
518
- This is the largest change but depends on Step 2 (the runner signature is already narrower) and benefits from Step 1 (the record's transition methods encapsulate the stats updates that the subscription drives).
489
+ | Metric | Before | After |
490
+ | ------------------------------------------ | ---------------------------------------------------------------------------- | ------- |
491
+ | Module-scoped mutable state | ~~1 (`agent-types.ts` Map)~~ | **0** ✓ |
492
+ | Closure-bag "classes" | ~~2~~ 1 (`createNotificationSystem`; settings free functions **fixed #109**) | 0 |
493
+ | Externally-mutated state bags | 2 (`AgentActivity`, `AgentRecord` non-transition fields) | 0 |
494
+ | `AgentManagerOptions` fields | 8 | 5 |
495
+ | `AgentToolDeps` fields | ~~9~~ **7** (−6 registry #108, −1 settings #109 → +1 settings obj) | ~5 |
496
+ | `AgentMenuDeps` fields | ~~13~~ **8** (−6 settings #109 collapsed to 1; −1 registry #108) | ~6 ✓ |
497
+ | `SpawnOptions` callback fields | 1 (`onSessionCreated`) | 0 |
498
+ | Callbacks threaded through deps | ~~8~~ 0 remaining settings callbacks (**fixed #109**); `emitEvent` ×3 remain | 0 |
499
+ | Types in `types.ts` without a natural home | 4 | 0 |
519
500
 
520
- ### Expected outcome
501
+ ### Dependency graph
521
502
 
522
- | Metric | Before | After |
523
- | --------------------------------- | ------ | ------------------------ |
524
- | `SpawnOptions` fields | 19 | ~8 (identity + dispatch) |
525
- | `RunOptions` fields | 15 | ~9 (config only) |
526
- | `startAgent()` lines | ~130 | ~50 |
527
- | Callback layers | 3 | 0 (direct subscription) |
528
- | Live `ctx` references in queue | 1 | 0 (snapshot) |
529
- | Scattered status-transition sites | 6 | 1 (state machine) |
503
+ ```text
504
+ A1 (Registry) ──────────────────┐
505
+ A2 (Settings) ── A2b (Apply) ──┤
506
+ A3 (Activity Tracker) ───────────┤
507
+ ├── D2 (Narrow deps) ── E1 (agent-tool split)
508
+ B (Record lifecycle) ───────────┤
509
+ └── C (Observer) ────────────┤
510
+ └── D1 (SpawnOptions) ──┘
511
+
512
+ E2 (Type housekeeping) ── can start after A1, runs parallel to later steps
513
+ ```
530
514
 
531
515
  ---
532
516